├── .nvmrc ├── packages ├── www │ ├── .gitignore │ ├── src │ │ ├── styles │ │ │ └── global.css │ │ ├── assets │ │ │ ├── people │ │ │ │ └── tilyupo.jpg │ │ │ ├── syncwave-screenshot-dark.png │ │ │ ├── syncwave-screenshot-light.png │ │ │ ├── syncwave-window-screenshot-dark-v2.png │ │ │ └── syncwave-window-screenshot-light-v2.png │ │ ├── pages │ │ │ ├── changelog │ │ │ │ └── 2025-04-13.md │ │ │ ├── changelog.astro │ │ │ └── 404.astro │ │ └── scripts │ │ │ └── index.js │ ├── tsconfig.json │ ├── public │ │ ├── assets │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── images │ │ │ │ ├── home │ │ │ │ │ ├── dash.png │ │ │ │ │ ├── forest.jpg │ │ │ │ │ ├── insights.png │ │ │ │ │ ├── mountain.jpg │ │ │ │ │ ├── sample.jpg │ │ │ │ │ ├── dashboard.png │ │ │ │ │ └── photography.jpg │ │ │ │ ├── people │ │ │ │ │ ├── man.jpg │ │ │ │ │ ├── man2.jpg │ │ │ │ │ └── women.jpg │ │ │ │ └── brand-logos │ │ │ │ │ ├── microsoft.svg │ │ │ │ │ ├── stripe.svg │ │ │ │ │ └── google.svg │ │ │ ├── android-chrome-192x192.png │ │ │ └── android-chrome-512x512.png │ │ ├── robots.txt │ │ └── site.webmanifest │ ├── astro.config.js │ ├── Dockerfile │ ├── nginx.conf │ ├── package.json │ └── eslint.config.js ├── app │ ├── .npmrc │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ └── android-chrome-512x512.png │ ├── .babelrc │ ├── .prettierignore │ ├── scripts │ │ ├── docker-run.sh │ │ └── docker-build.sh │ ├── src │ │ ├── app.d.ts │ │ ├── pages │ │ │ ├── login.svelte │ │ │ ├── testbed.svelte │ │ │ ├── login-failed.svelte │ │ │ ├── db.svelte │ │ │ └── impersonate.svelte │ │ ├── lib │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ ├── CheckIcon.svelte │ │ │ │ │ ├── ChevronDownIcon.svelte │ │ │ │ │ ├── PlusIcon.svelte │ │ │ │ │ ├── CheckCheckIcon.svelte │ │ │ │ │ ├── MinusIcon.svelte │ │ │ │ │ ├── ArrowLeftFromLineIcon.svelte │ │ │ │ │ ├── PlusSquareIcon.svelte │ │ │ │ │ ├── LogOutIcon.svelte │ │ │ │ │ ├── TimesIcon.svelte │ │ │ │ │ ├── GripHorizontalIcon.svelte │ │ │ │ │ ├── ArrowRightIcon.svelte │ │ │ │ │ ├── EllipsisIcon.svelte │ │ │ │ │ ├── MinusCircleSolidIcon.svelte │ │ │ │ │ ├── LinkIcon.svelte │ │ │ │ │ ├── ClipboardCopyIcon.svelte │ │ │ │ │ ├── ErrorIcon.svelte │ │ │ │ │ ├── WarnIcon.svelte │ │ │ │ │ ├── ArrowRightSquareIcon.svelte │ │ │ │ │ ├── SearchIcon.svelte │ │ │ │ │ ├── RefreshIcon.svelte │ │ │ │ │ ├── Loader.svelte │ │ │ │ │ ├── DoorOpenIcon.svelte │ │ │ │ │ ├── InfoIcon.svelte │ │ │ │ │ ├── UserSolidIcon.svelte │ │ │ │ │ ├── ColumnsIcon.svelte │ │ │ │ │ ├── HashtagIcon.svelte │ │ │ │ │ ├── TrashIcon.svelte │ │ │ │ │ ├── ColumnsSolidIcon.svelte │ │ │ │ │ ├── PlusCircleIcon.svelte │ │ │ │ │ ├── Envelope.svelte │ │ │ │ │ ├── HomeIcon.svelte │ │ │ │ │ ├── LeftPanelIcon.svelte │ │ │ │ │ ├── CircleDashedIcon.svelte │ │ │ │ │ ├── BoardIcon.svelte │ │ │ │ │ ├── UsersSolidIcon.svelte │ │ │ │ │ ├── UsersIcon.svelte │ │ │ │ │ ├── KeySolidIcon.svelte │ │ │ │ │ ├── ChatBubbleSolidIcon.svelte │ │ │ │ │ ├── UserRoundCog.svelte │ │ │ │ │ ├── MessageSquareTextIcon.svelte │ │ │ │ │ ├── InboxSolidIcon.svelte │ │ │ │ │ ├── SignalSolidIcon.svelte │ │ │ │ │ ├── NoSignalSolidIcon.svelte │ │ │ │ │ ├── CogSolidIcon.svelte │ │ │ │ │ └── CogIcon.svelte │ │ │ │ ├── ModalContainer.svelte │ │ │ │ ├── boards │ │ │ │ │ ├── BoardLoader.svelte │ │ │ │ │ └── BoardSettingsColumns.svelte │ │ │ │ ├── PermissionBoundary.svelte │ │ │ │ ├── TimeAgo.svelte │ │ │ │ ├── Loading.svelte │ │ │ │ ├── UploadButton.svelte │ │ │ │ ├── Modal.svelte │ │ │ │ ├── RichtextView.svelte │ │ │ │ ├── Avatar.svelte │ │ │ │ ├── Dropdown.svelte │ │ │ │ └── Select.svelte │ │ │ ├── styles │ │ │ │ ├── settings.css │ │ │ │ ├── text.css │ │ │ │ ├── panel.css │ │ │ │ ├── avatar.css │ │ │ │ ├── app.css │ │ │ │ ├── board.css │ │ │ │ ├── icons.css │ │ │ │ ├── base.css │ │ │ │ ├── modal.css │ │ │ │ ├── main.css │ │ │ │ ├── dropdown.css │ │ │ │ └── input.css │ │ │ ├── utils │ │ │ │ └── time-ago.ts │ │ │ ├── managers │ │ │ │ ├── upload-manager.svelte.ts │ │ │ │ ├── permission-manager.ts │ │ │ │ ├── board-history-manager.ts │ │ │ │ ├── theme-manager.svelte.ts │ │ │ │ ├── modal-manager.svelte.ts │ │ │ │ └── panel-size-manager.svelte.ts │ │ │ └── agent │ │ │ │ ├── awareness.ts │ │ │ │ ├── crdt.svelte.ts │ │ │ │ └── agent.spec.ts │ │ ├── main.ts │ │ ├── document-activity.ts │ │ └── mem-storage.ts │ ├── vite-env.d.ts │ ├── svelte.config.js │ ├── .env.example │ ├── .gitignore │ ├── tsconfig.json │ ├── serve.json │ ├── .prettierrc │ ├── manifest.json │ ├── README.md │ ├── Dockerfile │ ├── vite.config.ts │ └── eslint.config.mjs ├── lib │ ├── src │ │ ├── data │ │ │ ├── instrumented-doc-repo.ts │ │ │ ├── mem-email-provider.ts │ │ │ ├── join-code.ts │ │ │ ├── repos │ │ │ │ ├── base │ │ │ │ │ └── doc.ts │ │ │ │ └── user-repo.ts │ │ │ ├── placement.ts │ │ │ ├── auth.ts │ │ │ └── email-service.ts │ │ ├── self-hosted-client-config.ts │ │ ├── kv │ │ │ ├── mem-mvcc-store.spec.ts │ │ │ ├── rw-mvcc-adapter.spec.ts │ │ │ ├── readonly-cell.ts │ │ │ ├── registry.ts │ │ │ ├── collection-manager.ts │ │ │ ├── cell.ts │ │ │ ├── mem-rw-store.ts │ │ │ ├── counter.ts │ │ │ ├── tx-controller.ts │ │ │ └── collection.ts │ │ ├── transaction-id.ts │ │ ├── uuid.spec.ts │ │ ├── test-instrumentation.ts │ │ ├── codec.ts │ │ ├── base64.ts │ │ ├── hex.ts │ │ ├── crdt │ │ │ └── richtext.ts │ │ ├── web-crypto-provider.ts │ │ ├── mutex.ts │ │ ├── uuid.ts │ │ ├── rand.ts │ │ ├── timestamp.ts │ │ ├── coordinator │ │ │ └── coordinator-client.ts │ │ ├── transport │ │ │ ├── chaos-transport.ts │ │ │ ├── hub.ts │ │ │ ├── instrumented-transport.ts │ │ │ ├── rpc-transport.ts │ │ │ └── transport.ts │ │ ├── constants.ts │ │ ├── node-jwt-provider.ts │ │ ├── hex.spec.ts │ │ ├── deferred.ts │ │ ├── node-crypto-provider.ts │ │ ├── event-emitter.ts │ │ ├── timestamp.spec.ts │ │ ├── type.ts │ │ ├── base64.spec.ts │ │ ├── context.spec.ts │ │ ├── codec.spec.ts │ │ ├── auth │ │ │ └── google.ts │ │ ├── job-manager.ts │ │ ├── cursor.ts │ │ ├── index.ts │ │ └── rw-lock.ts │ ├── eslint.config.js │ └── tsconfig.json ├── server │ ├── fdb │ │ ├── fdb.dev.cluster │ │ ├── fdb.prod.cluster │ │ └── fdb.local.cluster │ ├── eslint.config.js │ ├── scripts │ │ ├── docker-build.sh │ │ └── docker-run.sh │ ├── .gitignore │ ├── tsconfig.json │ ├── src │ │ ├── stage.ts │ │ ├── http │ │ │ ├── metrics.ts │ │ │ └── ui.ts │ │ ├── ses-email-provider.ts │ │ └── event-loop-monitor.ts │ ├── README.md │ ├── .env.example │ ├── vitest.config.ts │ ├── Dockerfile │ ├── docker-compose.yaml │ └── package.json ├── self-hosted │ ├── package.json │ └── Dockerfile ├── scripts │ ├── eslint.config.js │ ├── src │ │ ├── log-prettify.ts │ │ ├── playground.ts │ │ └── toolbox.ts │ └── package.json └── config │ ├── package.json │ └── tsconfig.json ├── .dockerignore ├── .editorconfig ├── LICENSE ├── .gitignore ├── SECURITY.md ├── package.json └── .github └── workflows └── release.yml /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /packages/www/.gitignore: -------------------------------------------------------------------------------- 1 | .astro/ 2 | -------------------------------------------------------------------------------- /packages/app/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/lib/src/data/instrumented-doc-repo.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/server/fdb/fdb.dev.cluster: -------------------------------------------------------------------------------- 1 | docker:docker@fdb:4500 2 | -------------------------------------------------------------------------------- /packages/server/fdb/fdb.prod.cluster: -------------------------------------------------------------------------------- 1 | docker:docker@fdb:4500 2 | -------------------------------------------------------------------------------- /packages/www/src/styles/global.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | -------------------------------------------------------------------------------- /packages/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /packages/server/fdb/fdb.local.cluster: -------------------------------------------------------------------------------- 1 | docker:docker@127.0.0.1:4500 2 | -------------------------------------------------------------------------------- /packages/www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-transform-async-to-generator"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /packages/server/eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from 'syncwave-config'; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /packages/lib/eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from 'syncwave-config/eslint'; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /packages/self-hosted/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncwave-self-hosted", 3 | "private": true 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/server/scripts/docker-build.sh: -------------------------------------------------------------------------------- 1 | docker build --platform linux/amd64 -t tilyupo/test -f ./Dockerfile ../.. 2 | -------------------------------------------------------------------------------- /packages/server/scripts/docker-run.sh: -------------------------------------------------------------------------------- 1 | docker run --platform linux/amd64 --rm -it -p 1234:4567 tilyupo/test 2 | 3 | -------------------------------------------------------------------------------- /packages/scripts/eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from 'syncwave-config/tsconfig.json'; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /packages/app/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/app/public/favicon-16x16.png -------------------------------------------------------------------------------- /packages/app/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/app/public/favicon-32x32.png -------------------------------------------------------------------------------- /packages/app/scripts/docker-run.sh: -------------------------------------------------------------------------------- 1 | docker run --platform linux/amd64 --rm -it -p 3000:3000 -e PUBLIC_STAGE=dev tilyupo/test 2 | 3 | -------------------------------------------------------------------------------- /packages/www/public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/favicon.ico -------------------------------------------------------------------------------- /packages/app/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/app/public/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/app/scripts/docker-build.sh: -------------------------------------------------------------------------------- 1 | docker build --platform linux/amd64 --build-arg STAGE=dev -t tilyupo/test -f ./Dockerfile ../.. 2 | -------------------------------------------------------------------------------- /packages/www/src/assets/people/tilyupo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/src/assets/people/tilyupo.jpg -------------------------------------------------------------------------------- /packages/app/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/app/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/app/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/app/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/www/public/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/favicon-16x16.png -------------------------------------------------------------------------------- /packages/www/public/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/favicon-32x32.png -------------------------------------------------------------------------------- /packages/www/public/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/dash.png -------------------------------------------------------------------------------- /packages/www/public/assets/images/people/man.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/people/man.jpg -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/forest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/forest.jpg -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/insights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/insights.png -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/mountain.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/mountain.jpg -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/sample.jpg -------------------------------------------------------------------------------- /packages/www/public/assets/images/people/man2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/people/man2.jpg -------------------------------------------------------------------------------- /packages/www/public/assets/images/people/women.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/people/women.jpg -------------------------------------------------------------------------------- /packages/www/public/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/www/public/assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/dashboard.png -------------------------------------------------------------------------------- /packages/www/src/assets/syncwave-screenshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/src/assets/syncwave-screenshot-dark.png -------------------------------------------------------------------------------- /packages/www/src/assets/syncwave-screenshot-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/src/assets/syncwave-screenshot-light.png -------------------------------------------------------------------------------- /packages/app/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global {} 4 | 5 | export {}; 6 | -------------------------------------------------------------------------------- /packages/www/public/assets/images/home/photography.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/public/assets/images/home/photography.jpg -------------------------------------------------------------------------------- /packages/app/src/pages/login.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/lib/src/self-hosted-client-config.ts: -------------------------------------------------------------------------------- 1 | export interface SelfHostedClientConfig { 2 | googleClientId: string | undefined; 3 | passwordsEnabled: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/CheckIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ChevronDownIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/www/src/assets/syncwave-window-screenshot-dark-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/src/assets/syncwave-window-screenshot-dark-v2.png -------------------------------------------------------------------------------- /packages/www/src/assets/syncwave-window-screenshot-light-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncwavedev/syncwave/HEAD/packages/www/src/assets/syncwave-window-screenshot-light-v2.png -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/PlusIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/CheckCheckIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | readonly VITE_PUBLIC_STAGE: string; 3 | // more env variables... 4 | } 5 | 6 | interface ImportMeta { 7 | readonly env: ImportMetaEnv; 8 | } 9 | -------------------------------------------------------------------------------- /packages/www/public/robots.txt: -------------------------------------------------------------------------------- 1 | # Example: Allow all bots to scan and index your site. 2 | # Full syntax: https://developers.google.com/search/docs/advanced/robots/create-robots-txt 3 | User-agent: * 4 | Allow: / 5 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/MinusIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ArrowLeftFromLineIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/lib/src/kv/mem-mvcc-store.spec.ts: -------------------------------------------------------------------------------- 1 | import {MemMvccStore} from './mem-mvcc-store.js'; 2 | import {describeMvccStore} from './mvcc-store-testsuite.js'; 3 | 4 | describeMvccStore('mem-mvcc-store', options => new MemMvccStore(options)); 5 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/ModalContainer.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#if modalManager.getIsOpen()} 6 | {@render modalManager.getView()?.()} 7 | {/if} 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/PlusSquareIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /packages/lib/src/transaction-id.ts: -------------------------------------------------------------------------------- 1 | import type {Brand} from './utils.js'; 2 | import {Uuid} from './uuid.js'; 3 | 4 | export type TransactionId = Brand; 5 | 6 | export function TransactionId() { 7 | return Uuid(); 8 | } 9 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/LogOutIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/TimesIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | test.sqlite 2 | test.level 3 | dev.level 4 | dev.leveldb 5 | dev.sqlite 6 | dev.sqlite-wal 7 | dev.sqlite-shm 8 | test.sqlite-journal 9 | dev.sqlite-journal 10 | /.env 11 | !/.env.example 12 | /dev-object-store 13 | /backups 14 | /ui 15 | -------------------------------------------------------------------------------- /packages/www/astro.config.js: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import {defineConfig} from 'astro/config'; 3 | 4 | // https://astro.build/config 5 | export default defineConfig({ 6 | vite: { 7 | plugins: [tailwindcss()], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Exclude all node_modules directories 2 | node_modules 3 | 4 | # Ignore other common temporary files and directories 5 | .DS_Store 6 | npm-debug.log* 7 | yarn-error.log* 8 | .pnpm-debug.log* 9 | dist 10 | .build 11 | .cache 12 | logs 13 | *.log 14 | 15 | .env 16 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/GripHorizontalIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/svelte.config.js: -------------------------------------------------------------------------------- 1 | import {vitePreprocess} from '@sveltejs/vite-plugin-svelte'; 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | }; 8 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "syncwave-config/tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm", 6 | "lib": ["ES2022"] 7 | }, 8 | "include": ["src/**/*.ts", "tests/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ArrowRightIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/EllipsisIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/settings.css: -------------------------------------------------------------------------------- 1 | .settings-element { 2 | padding-inline: 0.75em; 3 | height: 1.8em; 4 | } 5 | 6 | .settings-input { 7 | padding-inline: 0.75em; 8 | height: 2.4em; 9 | 10 | background-color: var(--color-material-elevated-input); 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | max_line_length = 80 10 | 11 | [*.yaml] 12 | indent_size = 2 13 | [*.yml] 14 | indent_size = 2 15 | [*.md] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /packages/www/src/pages/changelog/2025-04-13.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | Improvements: 5 | 6 | - Message typing awareness. 7 | - Design improvements. 8 | - Messages support. 9 | - Card activity. 10 | 11 | Fixes: 12 | 13 | - Resume sync after reconnect. 14 | - Fixes for the new message input. 15 | -------------------------------------------------------------------------------- /packages/app/.env.example: -------------------------------------------------------------------------------- 1 | # valid values are: 2 | # - local (against local server) 3 | # - self (against self-hosted server on the same address /api) 4 | # - dev_self (against self.syncwave.dev/api) 5 | # - dev (against api-dev.syncwave.dev) 6 | # - prod (against api.syncwave.dev) 7 | VITE_PUBLIC_STAGE=local 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/MinusCircleSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/LinkIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ClipboardCopyIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ErrorIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/WarnIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncwave-config", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "dependencies": { 7 | "@eslint/js": "^9.24.0", 8 | "eslint-plugin-prettier": "^5.2.6", 9 | "typescript-eslint": "^8.29.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ArrowRightSquareIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/SearchIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | .netlify 7 | .wrangler 8 | /.svelte-kit 9 | /dist 10 | 11 | # OS 12 | .DS_Store 13 | Thumbs.db 14 | 15 | # Env 16 | .env 17 | .env.* 18 | !.env.example 19 | !.env.test 20 | 21 | # Vite 22 | vite.config.js.timestamp-* 23 | vite.config.ts.timestamp-* 24 | -------------------------------------------------------------------------------- /packages/app/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@webfill/async-context'; 2 | 3 | import './instrumentation.js'; 4 | 5 | import {mount} from 'svelte'; 6 | import App from './App.svelte'; 7 | import './lib/styles/main.css'; 8 | 9 | const app = mount(App, { 10 | target: document.getElementById('app')!, 11 | }); 12 | 13 | export default app; 14 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tsconfig/svelte/tsconfig.json", 4 | "syncwave-config/tsconfig.json" 5 | ], 6 | "compilerOptions": { 7 | "rootDir": ".", 8 | "module": "esnext", 9 | "moduleResolution": "bundler", 10 | "lib": ["DOM", "ES2020"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/lib/src/data/mem-email-provider.ts: -------------------------------------------------------------------------------- 1 | import type {EmailMessage, EmailProvider} from './infrastructure.js'; 2 | 3 | export class MemEmailProvider implements EmailProvider { 4 | outbox: EmailMessage[] = []; 5 | 6 | async send(message: EmailMessage): Promise { 7 | this.outbox.push(message); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/src/stage.ts: -------------------------------------------------------------------------------- 1 | import {assertOneOf} from 'syncwave'; 2 | 3 | export type Stage = 'prod' | 'dev' | 'local' | 'self'; 4 | 5 | export function getStage(): Stage { 6 | return assertOneOf( 7 | process.env.STAGE, 8 | ['prod', 'dev', 'local', 'self'] as const, 9 | 'invalid stage' 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/lib/src/data/join-code.ts: -------------------------------------------------------------------------------- 1 | import {decodeBase64} from '../base64.js'; 2 | import type {CryptoProvider} from './infrastructure.js'; 3 | 4 | export async function createJoinCode(crypto: CryptoProvider) { 5 | const randomBytes = await crypto.randomBytes(9); 6 | return decodeBase64(randomBytes).replaceAll('/', '_').replaceAll('+', '-'); 7 | } 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/RefreshIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/utils/time-ago.ts: -------------------------------------------------------------------------------- 1 | import TimeAgo from 'javascript-time-ago'; 2 | import en from 'javascript-time-ago/locale/en'; 3 | import type {Timestamp} from 'syncwave'; 4 | 5 | TimeAgo.addDefaultLocale(en); 6 | 7 | const timeAgo = new TimeAgo('en-US'); 8 | 9 | export function timeSince(ts: Timestamp) { 10 | return timeAgo.format(new Date(ts)); 11 | } 12 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/Loader.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # Syncwave server 2 | 3 | This is the server component of Syncwave, which handles the backend logic, including user authentication, data synchronization, and API endpoints. 4 | 5 | ## Scripts 6 | 7 | Build docker image: 8 | 9 | ```sh 10 | docker build --build-arg STAGE=dev --platform linux/amd64 -t syncwave/syncwave -f ./Dockerfile ../.. 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/DoorOpenIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/InfoIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/lib/src/kv/rw-mvcc-adapter.spec.ts: -------------------------------------------------------------------------------- 1 | // todo: extract KvStore testsuite (without conflicts) 2 | 3 | import {MemRwStore} from './mem-rw-store.js'; 4 | import {describeMvccStore} from './mvcc-store-testsuite.js'; 5 | import {MvccAdapter} from './rw-mvcc-adapter.js'; 6 | 7 | describeMvccStore( 8 | 'RwMvccAdapter', 9 | options => new MvccAdapter(new MemRwStore(), options) 10 | ); 11 | -------------------------------------------------------------------------------- /packages/scripts/src/log-prettify.ts: -------------------------------------------------------------------------------- 1 | import pretty from 'pino-pretty'; 2 | 3 | const stream = pretty({ 4 | colorize: true, 5 | messageFormat: (log: any) => { 6 | const {traceId, msg} = log as Record; 7 | return `[${traceId.slice(0, 4)}] ${msg}`; 8 | }, 9 | ignore: 'pid,hostname,traceId,sessionId,spanId', 10 | }); 11 | 12 | process.stdin.pipe(stream); 13 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/UserSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /packages/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "syncwave-config/tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "outDir": "./dist/esm", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "noEmit": false, 10 | "target": "ES2016" 11 | }, 12 | "include": ["src/**/*.ts", "tests/**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ColumnsIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/HashtagIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/TrashIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | -------------------------------------------------------------------------------- /packages/app/src/pages/testbed.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | Testbed 16 | -------------------------------------------------------------------------------- /packages/lib/src/kv/readonly-cell.ts: -------------------------------------------------------------------------------- 1 | import {Cell} from './cell.js'; 2 | import type {AppTransaction} from './kv-store.js'; 3 | 4 | export class ReadonlyCell { 5 | private readonly cell: Cell; 6 | 7 | constructor(tx: AppTransaction, initialValue: T) { 8 | this.cell = new Cell(tx, initialValue); 9 | } 10 | 11 | async get(): Promise { 12 | return await this.cell.get(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | STAGE=local 2 | NODE_ENV=development 3 | SECRET_KEY=example_local_secret 4 | GOOGLE_CLIENT_ID=848724615154-hlbsminri03tvelsj62fljh5v0tmcjaa.apps.googleusercontent.com 5 | # you can copy it from https://console.cloud.google.com/apis/credentials 6 | GOOGLE_CLIENT_SECRET=SECRET 7 | 8 | AWS_DEFAULT_REGION=us-east-1 9 | AWS_ACCESS_KEY_ID=aws_access_key_id 10 | AWS_SECRET_ACCESS_KEY=aws_secret_access_key 11 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ColumnsSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /packages/lib/src/uuid.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {createUuid} from './uuid.js'; 3 | 4 | describe('uuid', () => { 5 | it('should create a valid UUID string', () => { 6 | const uuid = createUuid(); 7 | const uuidRegex = 8 | /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 9 | expect(uuidRegex.test(uuid)).toBe(true); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/app/src/pages/login-failed.svelte: -------------------------------------------------------------------------------- 1 |
2 |

Login Failed

3 |

4 | The login attempt was unsuccessful. Please verify your email, or try 5 | signing in with Google. 6 |

7 | Go back to login page 8 |
9 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/PlusCircleIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/Envelope.svelte: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/HomeIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {configDefaults, defineConfig} from 'vitest/config'; 2 | 3 | const isDarwin = process.platform === 'darwin'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | exclude: [ 8 | ...configDefaults.exclude, 9 | ...(isDarwin ? ['./src/fdb-kv-store.spec.ts'] : []), // foundationdb KV store requires additional configuration on host machine to run on MacOS 10 | ], 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/www/public/assets/images/brand-logos/microsoft.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/app/serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": "./dist", 3 | "trailingSlash": false, 4 | "headers": [ 5 | { 6 | "source": "/assets/**", 7 | "headers": [ 8 | { 9 | "key": "Cache-Control", 10 | "value": "max-age=31536000, immutable, public" 11 | } 12 | ] 13 | } 14 | ], 15 | 16 | "rewrites": [{"source": "!/assets/**", "destination": "/index.html"}] 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/text.css: -------------------------------------------------------------------------------- 1 | @theme static { 2 | --text-xs: 0.6875rem; 3 | --text-sm: 0.75rem; 4 | --text-md: 0.8125rem; 5 | --text-lg: 0.875rem; 6 | --text-xl: 0.9375rem; 7 | 8 | --text-xs--line-height: 1.25; 9 | --text-sm--line-height: 1.25; 10 | --text-md--line-height: 1.25; 11 | --text-lg--line-height: 1.25; 12 | --text-xl--line-height: 1.25; 13 | 14 | --leading-relaxed: 1.5; 15 | 16 | --font-sans: 'Inter', sans-serif; 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/LeftPanelIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /packages/app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "arrowParens": "avoid", 4 | "tabWidth": 4, 5 | "useTabs": false, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "printWidth": 80, 9 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-organize-imports"], 10 | "overrides": [ 11 | { 12 | "files": "*.svelte", 13 | "options": { 14 | "parser": "svelte" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/boards/BoardLoader.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
14 |

15 | {board.name} Syncing... 16 |

17 |
18 | -------------------------------------------------------------------------------- /packages/www/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/panel.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --panel-padding-block: 1rem; 3 | --panel-padding-inline: 1rem; 4 | --panel-header-height: 3rem; 5 | } 6 | 7 | @theme static { 8 | --spacing-panel-inline: var(--panel-padding-inline); 9 | --spacing-panel-block: var(--panel-padding-block); 10 | --spacing-panel-inline-half: calc(var(--spacing-panel-inline) / 2); 11 | --spacing-panel-block-half: calc(var(--spacing-panel-block) / 2); 12 | } 13 | 14 | .h-panel-header { 15 | height: var(--panel-header-height); 16 | } 17 | -------------------------------------------------------------------------------- /packages/www/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim AS base 2 | 3 | FROM base AS deps 4 | 5 | WORKDIR /app 6 | 7 | COPY package*.json . 8 | COPY packages/www/package*.json ./packages/www/ 9 | 10 | RUN npm install 11 | 12 | FROM base AS builder 13 | 14 | WORKDIR /app 15 | 16 | COPY . . 17 | COPY --from=deps /app . 18 | 19 | WORKDIR /app/packages/www 20 | RUN npm run build 21 | 22 | FROM nginx:1.26.3-alpine AS runtime 23 | COPY ./packages/www/nginx.conf /etc/nginx/nginx.conf 24 | COPY --from=builder /app/packages/www/dist /usr/share/nginx/html 25 | EXPOSE 8080 26 | -------------------------------------------------------------------------------- /packages/app/src/pages/db.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
9 |

12 | DB Explorer 13 |

14 | 15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /packages/lib/src/kv/registry.ts: -------------------------------------------------------------------------------- 1 | import {AppError} from '../errors.js'; 2 | import {type AppTransaction, isolate} from './kv-store.js'; 3 | 4 | export class Registry { 5 | constructor( 6 | private readonly tx: AppTransaction, 7 | private readonly factory: (tx: AppTransaction) => T 8 | ) {} 9 | 10 | get(name: string): T { 11 | if (name.indexOf('/') !== -1) { 12 | throw new AppError('invalid item name, / is not allowed'); 13 | } 14 | 15 | return this.factory(isolate(['t', name])(this.tx)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/avatar.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --avatar-size: 2.2em; 3 | } 4 | 5 | .avatar { 6 | display: inline-block; 7 | 8 | block-size: var(--avatar-size); 9 | inline-size: var(--avatar-size); 10 | line-height: var(--avatar-size); 11 | 12 | flex-shrink: 0; 13 | 14 | border-radius: 50%; 15 | 16 | vertical-align: text-top; 17 | text-align: center; 18 | } 19 | 20 | .avatar--small { 21 | font-size: 0.8em; 22 | } 23 | 24 | .avatar--x-small { 25 | font-size: 0.6em; 26 | } 27 | 28 | .avatar--large { 29 | font-size: 1.18em; 30 | } 31 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/CircleDashedIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | -------------------------------------------------------------------------------- /packages/lib/src/test-instrumentation.ts: -------------------------------------------------------------------------------- 1 | import {trace} from '@opentelemetry/api'; 2 | import {resourceFromAttributes} from '@opentelemetry/resources'; 3 | import { 4 | AlwaysOnSampler, 5 | BasicTracerProvider, 6 | } from '@opentelemetry/sdk-trace-base'; 7 | import {ATTR_SERVICE_NAME} from '@opentelemetry/semantic-conventions'; 8 | 9 | const tracerProvider = new BasicTracerProvider({ 10 | resource: resourceFromAttributes({ 11 | [ATTR_SERVICE_NAME]: 'test', 12 | }), 13 | sampler: new AlwaysOnSampler(), 14 | }); 15 | trace.setGlobalTracerProvider(tracerProvider); 16 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/BoardIcon.svelte: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/UsersSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /packages/www/src/pages/changelog.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const EXT_LENGTH = '.md'.length; 3 | const DATE_LENGTH = 'YYYY-MM-DD'.length; 4 | 5 | const releaseDates = Object.keys( 6 | import.meta.glob('./changelog/*.md', {eager: true}) 7 | ) 8 | .map(fileName => fileName.slice(-DATE_LENGTH - EXT_LENGTH, -EXT_LENGTH)) 9 | .sort((a, b) => (a > b ? -1 : 1)); 10 | --- 11 | 12 |
    13 | { 14 | releaseDates.map(releaseDate => ( 15 |
  • 16 | {releaseDate} 17 |
  • 18 | )) 19 | } 20 |
21 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/PermissionBoundary.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {@render children()} 22 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/TimeAgo.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {timeAgo} 25 | -------------------------------------------------------------------------------- /packages/lib/src/codec.ts: -------------------------------------------------------------------------------- 1 | import {decode, encode} from 'msgpackr'; 2 | 3 | export interface Codec { 4 | encode(data: TData): Uint8Array; 5 | decode(buf: Uint8Array): TData; 6 | } 7 | 8 | export class MsgpackCodec implements Codec { 9 | encode(data: T): Uint8Array { 10 | return encodeMsgpack(data); 11 | } 12 | 13 | decode(buf: Uint8Array): T { 14 | return decodeMsgpack(buf) as T; 15 | } 16 | } 17 | 18 | export const encodeMsgpack = (data: unknown) => encode(data); 19 | export const decodeMsgpack = (buf: Uint8Array) => decode(buf) as unknown; 20 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/UsersIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --app-border: 0px; 3 | 4 | --app-width: 100vw; 5 | --app-height: calc(100vh - var(--app-border)); 6 | } 7 | 8 | .app { 9 | box-sizing: border-box; 10 | 11 | height: var(--app-height); 12 | width: var(--app-width); 13 | 14 | border-top: var(--app-border) solid var(--color-divider); 15 | 16 | background: var(--color-material-base); 17 | } 18 | 19 | /* Styles for PWA modes (standalone, minimal-ui) */ 20 | @media all and (display-mode: standalone), all and (display-mode: minimal-ui) { 21 | :root { 22 | --app-border: 1px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/board.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --board-height: calc(var(--app-height) - var(--panel-header-height)); 3 | 4 | --column-width: 44ch; 5 | --column-padding-inline: 0.6125rem; 6 | 7 | --board-padding-inline: 2.25rem; 8 | 9 | --board-padding-inline-start: calc( 10 | var(--board-padding-inline) - var(--column-padding-inline) 11 | ); 12 | --board-padding-inline-end: calc( 13 | var(--app-width) - var(--column-width) - 14 | var(--board-padding-inline-start) 15 | ); 16 | } 17 | 18 | @theme static { 19 | --spacing-board-inline: var(--board-padding-inline); 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/KeySolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/scripts/src/playground.ts: -------------------------------------------------------------------------------- 1 | import {decodeTuple, encodeTuple} from 'syncwave'; 2 | import {ClassicLevelStore} from '../../server/src/classic-level-store.js'; 3 | 4 | const store = await ClassicLevelStore.create({ 5 | dbPath: './play.level', 6 | }); 7 | 8 | await store.transact(async tx => { 9 | await tx.put(encodeTuple(['key1']), encodeTuple(['value1'])); 10 | await tx.put(encodeTuple(['key1']), encodeTuple(['value1'])); 11 | }); 12 | 13 | await store.snapshot(async snapshot => { 14 | const value = await snapshot.get(encodeTuple(['key1'])); 15 | console.log('Value for key1:', value ? decodeTuple(value) : 'undefined'); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/lib/src/kv/collection-manager.ts: -------------------------------------------------------------------------------- 1 | import {type Codec} from '../codec.js'; 2 | import {Collection} from './collection.js'; 3 | import {type AppTransaction} from './kv-store.js'; 4 | import {Registry} from './registry.js'; 5 | 6 | export class CollectionManager { 7 | private readonly collections: Registry>; 8 | 9 | constructor(tx: AppTransaction, codec: Codec) { 10 | this.collections = new Registry( 11 | tx, 12 | topicTx => new Collection(topicTx, codec) 13 | ); 14 | } 15 | 16 | get(name: string): Collection { 17 | return this.collections.get(name); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/ChatBubbleSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/UserRoundCog.svelte: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/MessageSquareTextIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/InboxSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/Loading.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | {#if showLoader} 17 |
18 |

19 | Synchronizing your data with this device 20 | 21 |

22 |
23 | {/if} 24 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/icons.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --icon-size: 1em; 3 | } 4 | 5 | .icon { 6 | inline-size: var(--icon-size); 7 | block-size: var(--icon-size); 8 | 9 | fill: var(--icon-fill, none); 10 | 11 | stroke: var(--icon-stroke, currentColor); 12 | stroke-width: var(--icon-stroke-width, 1.5); 13 | stroke-linecap: round; 14 | stroke-linejoin: round; 15 | 16 | display: inline-block; 17 | 18 | vertical-align: middle; 19 | flex-shrink: 0; 20 | 21 | &.icon--solid { 22 | --icon-fill: currentColor; 23 | --icon-stroke: none; 24 | } 25 | } 26 | 27 | .icon--medium { 28 | --icon-stroke-width: 2; 29 | } 30 | 31 | .icon--bold { 32 | --icon-stroke-width: 2.5; 33 | } 34 | -------------------------------------------------------------------------------- /packages/scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncwave-scripts", 3 | "private": true, 4 | "version": "0.0.1", 5 | "engines": { 6 | "node": ">=22.0.0" 7 | }, 8 | "type": "module", 9 | "scripts": { 10 | "clean": "rimraf dist", 11 | "dev": "AWS_DEFAULT_REGION=us-east-1 NODE_OPTIONS='--enable-source-maps' tsx watch --clear-screen=false ./src/entrypoint.ts | tsx ../../packages/scripts/src/log-prettify.ts", 12 | "build": "tsc", 13 | "build:watch": "tsc -w", 14 | "test": "vitest run" 15 | }, 16 | "dependencies": { 17 | "@sinclair/typebox": "^0.34.33", 18 | "dotenv": "^16.5.0", 19 | "pino-pretty": "^13.0.0", 20 | "syncwave": "*", 21 | "syncwave-config": "*" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/lib/src/base64.ts: -------------------------------------------------------------------------------- 1 | import {Type} from '@sinclair/typebox'; 2 | import type {Brand} from './utils.js'; 3 | 4 | export type Base64 = Brand; 5 | 6 | export function decodeBase64(data: Uint8Array): Base64 { 7 | return Buffer.from(data).toString('base64') as Base64; 8 | } 9 | 10 | export function encodeBase64(data: Base64): Uint8Array { 11 | return new Uint8Array(Buffer.from(data, 'base64')); 12 | } 13 | 14 | export function Base64() { 15 | return Type.Unsafe(Type.String({format: 'base64'})); 16 | } 17 | 18 | const base64Regex = 19 | /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; 20 | 21 | export function validateBase64(base64: unknown): boolean { 22 | return typeof base64 === 'string' && base64Regex.test(base64); 23 | } 24 | -------------------------------------------------------------------------------- /packages/app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Syncwave", 3 | "short_name": "Syncwave", 4 | "description": "Fast, collaborative kanban board", 5 | "categories": ["social", "business", "productivity"], 6 | "lang": "en", 7 | "icons": [ 8 | { 9 | "src": "/android-chrome-192x192.png", 10 | "sizes": "192x192", 11 | "type": "image/png" 12 | }, 13 | { 14 | "src": "/android-chrome-512x512.png", 15 | "sizes": "512x512", 16 | "type": "image/png", 17 | "purpose": "any maskable" 18 | } 19 | ], 20 | "start_url": "/", 21 | "scope": "/", 22 | "display_override": ["minimal-ui"], 23 | "display": "standalone", 24 | "theme_color": "#ffffff", 25 | "background_color": "#ffffff" 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/UploadButton.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 29 | 32 | -------------------------------------------------------------------------------- /packages/www/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | server { 9 | listen 8080; 10 | server_name _; 11 | 12 | root /usr/share/nginx/html; 13 | index index.html index.htm; 14 | include /etc/nginx/mime.types; 15 | 16 | gzip on; 17 | gzip_min_length 1000; 18 | gzip_proxied expired no-cache no-store private auth; 19 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 20 | 21 | error_page 404 /404.html; 22 | location = /404.html { 23 | root /usr/share/nginx/html; 24 | internal; 25 | } 26 | 27 | location / { 28 | try_files $uri $uri/index.html =404; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/base.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | @apply text-ink bg-material-1 dark:bg-gray-950 overscroll-none font-sans antialiased select-none; 4 | } 5 | 6 | body { 7 | @apply text-md; 8 | } 9 | 10 | hr { 11 | @apply border-divider; 12 | 13 | &.divider { 14 | @apply border-divider; 15 | } 16 | 17 | &.material-elevated { 18 | @apply border-divider-elevated; 19 | } 20 | } 21 | 22 | textarea { 23 | resize: none; 24 | } 25 | 26 | @layer utilities { 27 | /* Hide scrollbar for Chrome, Safari and Opera */ 28 | .no-scrollbar::-webkit-scrollbar { 29 | display: none; 30 | } 31 | /* Hide scrollbar for IE, Edge and Firefox */ 32 | .no-scrollbar { 33 | -ms-overflow-style: none; /* IE and Edge */ 34 | scrollbar-width: none; /* Firefox */ 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim AS builder 2 | 3 | # We need those for C bindings (foundationdb) 4 | RUN apt-get update 5 | RUN apt-get install -y python3 make g++ curl 6 | RUN rm -rf /var/lib/apt/lists/* 7 | 8 | # Install FoundationDB clients 9 | RUN curl -L -o foundationdb-clients.deb https://github.com/apple/foundationdb/releases/download/7.3.43/foundationdb-clients_7.3.43-1_amd64.deb 10 | RUN dpkg -i foundationdb-clients.deb 11 | RUN rm foundationdb-clients.deb 12 | 13 | WORKDIR /app 14 | 15 | COPY . . 16 | 17 | WORKDIR /app 18 | RUN npm install 19 | 20 | WORKDIR /app/packages/lib 21 | RUN npm run build 22 | 23 | WORKDIR /app/packages/server 24 | RUN npm run build 25 | 26 | EXPOSE 4567 27 | EXPOSE 5678 28 | 29 | ENV NODE_ENV=production 30 | ENV NODE_OPTIONS='--enable-source-maps' 31 | 32 | CMD ["node", "dist/esm/src/entrypoint.js"] 33 | -------------------------------------------------------------------------------- /packages/lib/src/hex.ts: -------------------------------------------------------------------------------- 1 | import {AppError} from './errors.js'; 2 | 3 | const hexRegex = /^([0-9a-fA-F]{2}( [0-9a-fA-F]{2})*)?$/; 4 | 5 | export function validateHexString(hexString: unknown): boolean { 6 | return typeof hexString === 'string' && hexRegex.test(hexString); 7 | } 8 | 9 | export const encodeHex = (hexString: string) => { 10 | if (!validateHexString(hexString)) { 11 | throw new AppError('Invalid hex string format: ' + hexString); 12 | } 13 | 14 | if (hexString === '') { 15 | return new Uint8Array(); 16 | } 17 | 18 | return new Uint8Array(hexString.split(' ').map(hex => parseInt(hex, 16))); 19 | }; 20 | export const decodeHex = (buf: Uint8Array) => { 21 | const hexString = Array.from(buf) 22 | .map(byte => byte.toString(16).padStart(2, '0')) 23 | .join(' '); 24 | return hexString; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/Modal.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#if true} 16 | 17 | 18 | 19 | 24 | 25 |
26 | {@render children()} 27 |
28 |
29 | {/if} 30 | -------------------------------------------------------------------------------- /packages/lib/src/kv/cell.ts: -------------------------------------------------------------------------------- 1 | import {MsgpackCodec} from '../codec.js'; 2 | import type {Tuple} from '../tuple.js'; 3 | import {pipe} from '../utils.js'; 4 | import {type Transaction, withCodec} from './kv-store.js'; 5 | 6 | const key: Tuple = []; 7 | 8 | export class Cell { 9 | private readonly tx: Transaction; 10 | 11 | constructor( 12 | tx: Transaction, 13 | private readonly initialValue: T 14 | ) { 15 | this.tx = pipe(tx, withCodec(new MsgpackCodec())); 16 | } 17 | 18 | async get(): Promise { 19 | const result = await this.tx.get(key); 20 | if (result) { 21 | return result.value; 22 | } 23 | await this.put(this.initialValue); 24 | return this.initialValue; 25 | } 26 | 27 | async put(value: T): Promise { 28 | await this.tx.put(key, {value}); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/scripts/src/toolbox.ts: -------------------------------------------------------------------------------- 1 | // import '../packages/server/src/instrumentation.js'; 2 | 3 | /* eslint-disable */ 4 | import 'dotenv/config'; 5 | 6 | import { 7 | catchConnectionClosed, 8 | CoordinatorClient, 9 | MsgpackCodec, 10 | PersistentConnection, 11 | } from 'syncwave'; 12 | import {WsTransportClient} from '../../app/src/ws-transport-client.js'; 13 | 14 | const client = new CoordinatorClient( 15 | new PersistentConnection( 16 | new WsTransportClient({ 17 | codec: new MsgpackCodec(), 18 | url: 'ws://127.0.0.1:4567', 19 | }) 20 | ), 21 | process.env.JWT_TOKEN 22 | ); 23 | 24 | async function main() { 25 | const result = await client.rpc.getMeViewData({}).first(); 26 | 27 | console.log(result); 28 | } 29 | 30 | catchConnectionClosed(main()).finally(() => { 31 | console.log('end of main'); 32 | client.close('end of main'); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/lib/src/kv/mem-rw-store.ts: -------------------------------------------------------------------------------- 1 | import {RwLock} from '../rw-lock.js'; 2 | import type { 3 | Uint8KvStore, 4 | Uint8Snapshot, 5 | Uint8Transaction, 6 | } from './kv-store.js'; 7 | import {MemMvccStore} from './mem-mvcc-store.js'; 8 | 9 | export class MemRwStore implements Uint8KvStore { 10 | private readonly lock = new RwLock(); 11 | private readonly store: Uint8KvStore = new MemMvccStore(); 12 | 13 | snapshot( 14 | fn: (tx: Uint8Snapshot) => Promise 15 | ): Promise { 16 | return this.lock.runRead(async () => await this.store.snapshot(fn)); 17 | } 18 | 19 | transact( 20 | fn: (tx: Uint8Transaction) => Promise 21 | ): Promise { 22 | return this.lock.runWrite(async () => await this.store.transact(fn)); 23 | } 24 | 25 | close(reason: unknown): void { 26 | this.store.close(reason); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/SignalSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/lib/src/crdt/richtext.ts: -------------------------------------------------------------------------------- 1 | import {Type} from '@sinclair/typebox'; 2 | import type {XmlFragment} from 'yjs'; 3 | import type {Brand} from '../utils.js'; 4 | 5 | export type Richtext = Brand< 6 | {[RICHTEXT_MARKER_KEY]: true; __fragment?: XmlFragment}, 7 | 'richtext' 8 | >; 9 | 10 | const RICHTEXT_MARKER_KEY = '__isRichtextMarker'; 11 | 12 | export function createRichtext(fragment?: XmlFragment): Richtext { 13 | return { 14 | [RICHTEXT_MARKER_KEY]: true, 15 | __fragment: fragment, 16 | } as Richtext; 17 | } 18 | 19 | export function Richtext() { 20 | return Type.Unsafe( 21 | Type.Object({ 22 | [RICHTEXT_MARKER_KEY]: Type.Boolean(), 23 | }) 24 | ); 25 | } 26 | 27 | export function isRichtext(x: unknown): x is Richtext { 28 | return ( 29 | typeof x === 'object' && 30 | x !== null && 31 | RICHTEXT_MARKER_KEY in x && 32 | x[RICHTEXT_MARKER_KEY] === true 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /packages/app/src/lib/managers/upload-manager.svelte.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AttachmentDto, 3 | type BoardId, 4 | type CardId, 5 | type CoordinatorClient, 6 | } from 'syncwave'; 7 | 8 | export interface UploadRequest { 9 | files: File[]; 10 | boardId: BoardId; 11 | cardId: CardId; 12 | } 13 | 14 | export class UploadManager { 15 | constructor(private readonly client: CoordinatorClient) {} 16 | 17 | async upload({files, cardId}: UploadRequest): Promise { 18 | const result: AttachmentDto[] = []; 19 | for (const file of files) { 20 | const attachment = await this.client.rpc.createAttachment({ 21 | cardId, 22 | contentType: file.type || 'application/octet-stream', 23 | data: new Uint8Array(await file.arrayBuffer()), 24 | fileName: file.name, 25 | }); 26 | result.push(attachment); 27 | } 28 | 29 | return result; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/app/src/lib/agent/awareness.ts: -------------------------------------------------------------------------------- 1 | import {SvelteMap} from 'svelte/reactivity'; 2 | import { 3 | context, 4 | type Awareness, 5 | type AwarenessState, 6 | type AwarenessUpdate, 7 | } from 'syncwave'; 8 | 9 | export function observeAwareness(awareness: Awareness) { 10 | const result = new SvelteMap(); 11 | 12 | const handler = ({added, removed, updated}: AwarenessUpdate) => { 13 | const states = awareness.getStates(); 14 | for (const clientId of added.concat(updated)) { 15 | const state = states.get(clientId); 16 | if (state) { 17 | result.set(clientId, state); 18 | } else { 19 | result.delete(clientId); 20 | } 21 | } 22 | 23 | removed.forEach(clientId => result.delete(clientId)); 24 | }; 25 | 26 | awareness.on('change', handler); 27 | 28 | context().onEnd(() => awareness.off('change', handler)); 29 | 30 | return result; 31 | } 32 | -------------------------------------------------------------------------------- /packages/app/README.md: -------------------------------------------------------------------------------- 1 | # sv 2 | 3 | Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npx sv create 12 | 13 | # create a new project in my-app 14 | npx sv create my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /packages/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim AS builder 2 | 3 | # We need those for C bindings (foundationdb) 4 | RUN apt-get update 5 | RUN apt-get install -y python3 make g++ curl 6 | RUN rm -rf /var/lib/apt/lists/* 7 | 8 | # Install FoundationDB clients 9 | RUN curl -L -o foundationdb-clients.deb https://github.com/apple/foundationdb/releases/download/7.3.43/foundationdb-clients_7.3.43-1_amd64.deb 10 | RUN dpkg -i foundationdb-clients.deb 11 | RUN rm foundationdb-clients.deb 12 | 13 | WORKDIR /app 14 | 15 | COPY . . 16 | 17 | WORKDIR /app 18 | RUN npm install 19 | 20 | WORKDIR /app/packages/lib 21 | RUN npm run build 22 | 23 | WORKDIR /app/packages/app 24 | 25 | ARG STAGE 26 | RUN if [ -z "$STAGE" ]; then \ 27 | echo "Error: STAGE argument is required."; \ 28 | exit 1; \ 29 | fi 30 | ENV VITE_PUBLIC_STAGE=$STAGE 31 | 32 | RUN npm run build 33 | 34 | RUN rm -rf src/ static/ emailTemplates/ docker-compose.yml 35 | 36 | USER node:node 37 | 38 | EXPOSE 3000 39 | 40 | ENV NODE_ENV=production 41 | ENV NODE_OPTIONS='--enable-source-maps' 42 | 43 | CMD ["npm", "run", "serve"] 44 | -------------------------------------------------------------------------------- /packages/lib/src/web-crypto-provider.ts: -------------------------------------------------------------------------------- 1 | import {compare, hash} from 'bcryptjs'; 2 | import type {CryptoProvider} from './data/infrastructure.js'; 3 | 4 | // WebCryptoProvider uses bcryptjs (pure JS implementation) instead of bcrypt (C++ bindings) 5 | 6 | export class WebCryptoProvider implements CryptoProvider { 7 | async randomBytes(length: number): Promise { 8 | const array = new Uint8Array(length); 9 | crypto.getRandomValues(array); 10 | return array; 11 | } 12 | async bcryptCompare(params: { 13 | hash: string; 14 | password: string; 15 | }): Promise { 16 | return await compare(params.password, params.hash); 17 | } 18 | async bcryptHash(password: string): Promise { 19 | // 10 is the default, but now considered minimum. 20 | // 12 is recommended for most applications (safe and still fast enough). 21 | // 14+ if your app handles extremely sensitive data and you can afford slower hashing. (Hashing will noticeably slow down.) 22 | return await hash(password, 12); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/NoSignalSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/RichtextView.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | {@html html} 26 |
27 | 28 | 38 | -------------------------------------------------------------------------------- /packages/lib/src/mutex.ts: -------------------------------------------------------------------------------- 1 | import {Deferred} from './deferred.js'; 2 | import {AppError} from './errors.js'; 3 | 4 | export class Mutex { 5 | private locked = false; 6 | private queue: Array<() => void> = []; 7 | 8 | async run(fn: () => Promise): Promise { 9 | try { 10 | await this.lock(); 11 | return await fn(); 12 | } finally { 13 | this.unlock(); 14 | } 15 | } 16 | 17 | // prefer run 18 | async lock(): Promise { 19 | if (this.locked) { 20 | const signal = new Deferred(); 21 | this.queue.push(() => { 22 | this.locked = true; 23 | signal.resolve(); 24 | }); 25 | await signal.promise; 26 | } else { 27 | this.locked = true; 28 | } 29 | } 30 | 31 | // prefer run 32 | unlock(): void { 33 | if (!this.locked) { 34 | throw new AppError('Mutex is not locked'); 35 | } 36 | 37 | this.locked = false; 38 | 39 | this.queue.shift()?.(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/lib/src/uuid.ts: -------------------------------------------------------------------------------- 1 | import {Type} from '@sinclair/typebox'; 2 | import {v4, v7, validate} from 'uuid'; 3 | import {AppError} from './errors.js'; 4 | import type {Brand} from './utils.js'; 5 | 6 | export type Uuid = Brand; 7 | 8 | export function Uuid() { 9 | return Type.Unsafe(Type.String({format: 'uuid'})); 10 | } 11 | 12 | export namespace Uuid { 13 | export const min = '00000000-0000-0000-0000-000000000000' as Uuid; 14 | export const max = 'ffffffff-ffff-ffff-ffff-ffffffffffff' as Uuid; 15 | } 16 | 17 | export function validateUuid(value: unknown): value is Uuid { 18 | return validate(value); 19 | } 20 | 21 | export function createUuid(): Uuid { 22 | return v7() as Uuid; 23 | } 24 | 25 | export function createUuidV4() { 26 | return v4() as Uuid; 27 | } 28 | 29 | export function stringifyUuid(uuid: Uuid): string { 30 | return uuid; 31 | } 32 | 33 | export function parseUuid(uuid: string): Uuid { 34 | if (!validate(uuid)) { 35 | throw new AppError(`parse error: ${uuid} is not a UUID`); 36 | } 37 | return uuid as Uuid; 38 | } 39 | -------------------------------------------------------------------------------- /packages/www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncwave-www", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "engines": { 7 | "node": ">=22.0.0" 8 | }, 9 | "scripts": { 10 | "dev": "astro dev", 11 | "build": "astro build", 12 | "preview": "astro preview" 13 | }, 14 | "devDependencies": { 15 | "@eslint/compat": "^1.2.7", 16 | "@eslint/js": "^9.22.0", 17 | "@tailwindcss/vite": "^4.0.12", 18 | "@types/eslint-config-prettier": "^6.11.3", 19 | "eslint": "^9.22.0", 20 | "eslint-config-prettier": "^10.1.1", 21 | "globals": "^16.0.0", 22 | "prettier": "^3.5.3", 23 | "prettier-plugin-organize-imports": "^4.1.0", 24 | "prettier-plugin-tailwindcss": "^0.6.11", 25 | "typescript": "^5.8.2", 26 | "typescript-eslint": "^8.26.1", 27 | "vite-plugin-node-polyfills": "^0.23.0" 28 | }, 29 | "dependencies": { 30 | "astro": "^5.7.3", 31 | "sharp": "^0.34.1", 32 | "syncwave-config": "*", 33 | "tailwindcss": "^4.0.12" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dmitry Tilyupo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/lib/src/rand.ts: -------------------------------------------------------------------------------- 1 | import seedrandom, {type PRNG} from 'seedrandom'; 2 | import {AppError} from './errors.js'; 3 | 4 | export class Rand { 5 | private readonly rng: PRNG; 6 | constructor(seed: string) { 7 | this.rng = seedrandom(seed); 8 | } 9 | 10 | run(...fn: Array<() => T>): T { 11 | return this.pick(fn)(); 12 | } 13 | 14 | int32(): number; 15 | int32(min: number): number; 16 | int32(min: number, max: number): number; 17 | int32(min?: number, max?: number): number { 18 | if (min !== undefined && max !== undefined) { 19 | return (this.uint32() % (max - min)) + min; 20 | } else if (min !== undefined) { 21 | return this.uint32() % min; 22 | } else { 23 | return this.uint32(); 24 | } 25 | } 26 | 27 | pick(items: T[]): T { 28 | if (items.length === 0) { 29 | throw new AppError('Cannot pick from empty array'); 30 | } 31 | return items[this.int32(0, items.length)]; 32 | } 33 | 34 | private uint32(): number { 35 | return Math.abs(this.rng.int32()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/lib/src/timestamp.ts: -------------------------------------------------------------------------------- 1 | import {Type} from '@sinclair/typebox'; 2 | import type {Brand} from './utils.js'; 3 | 4 | export type Timestamp = Brand; 5 | 6 | export function Timestamp() { 7 | return Type.Unsafe(Type.Number()); 8 | } 9 | 10 | export function getNow(): Timestamp { 11 | return toTimestamp(new Date()); 12 | } 13 | 14 | export function addMinutes(timestamp: Timestamp, minutes: number): Timestamp { 15 | const date = new Date(timestamp); 16 | date.setMinutes(date.getMinutes() + minutes); 17 | return date.getTime() as Timestamp; 18 | } 19 | 20 | export function addHours(timestamp: Timestamp, hours: number): Timestamp { 21 | const date = new Date(timestamp); 22 | date.setHours(date.getHours() + hours); 23 | return date.getTime() as Timestamp; 24 | } 25 | 26 | export function addYears(timestamp: Timestamp, years: number): Timestamp { 27 | const date = new Date(timestamp); 28 | date.setFullYear(date.getFullYear() + years); 29 | return date.getTime() as Timestamp; 30 | } 31 | 32 | export function toTimestamp(date: Date) { 33 | return date.getTime() as Timestamp; 34 | } 35 | -------------------------------------------------------------------------------- /packages/lib/src/coordinator/coordinator-client.ts: -------------------------------------------------------------------------------- 1 | import {context} from '../context.js'; 2 | import {RpcConnection} from '../transport/rpc-transport.js'; 3 | import {createRpcClient} from '../transport/rpc.js'; 4 | import type {Connection} from '../transport/transport.js'; 5 | import {createCoordinatorApi, type CoordinatorRpc} from './coordinator-api.js'; 6 | 7 | export class CoordinatorClient { 8 | private readonly connection: RpcConnection; 9 | public readonly rpc: CoordinatorRpc; 10 | 11 | constructor( 12 | connection: Connection, 13 | private authToken: string | undefined 14 | ) { 15 | this.connection = new RpcConnection(connection); 16 | this.rpc = createRpcClient( 17 | createCoordinatorApi(), 18 | this.connection, 19 | () => ({ 20 | ...context().extract(), 21 | auth: this.authToken, 22 | }) 23 | ); 24 | } 25 | 26 | setAuthToken(token: string | undefined) { 27 | this.authToken = token; 28 | } 29 | 30 | close(reason: unknown): void { 31 | this.connection.close(reason); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # IDE 9 | .vscode 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Optional npm cache directory 40 | .npm 41 | 42 | # Optional eslint cache 43 | .eslintcache 44 | 45 | # Optional REPL history 46 | .node_repl_history 47 | 48 | # Output of 'npm pack' 49 | *.tgz 50 | 51 | # dotenv environment variable files 52 | .env* 53 | 54 | # Mac files 55 | .DS_Store 56 | 57 | # Yarn 58 | yarn-error.log 59 | .pnp/ 60 | .pnp.js 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # build 65 | /packages/*/dist 66 | /play.level 67 | -------------------------------------------------------------------------------- /packages/app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import {svelte} from '@sveltejs/vite-plugin-svelte'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import babel from 'vite-plugin-babel'; 4 | import {nodePolyfills} from 'vite-plugin-node-polyfills'; 5 | import {defineConfig} from 'vitest/config'; 6 | 7 | const esbuildOptions = { 8 | supported: { 9 | // because of zonejs package 10 | 'async-await': false, 11 | }, 12 | }; 13 | 14 | export default defineConfig({ 15 | clearScreen: false, 16 | plugins: [ 17 | tailwindcss(), 18 | svelte(), 19 | nodePolyfills({ 20 | // because of fdb-tuple package 21 | include: ['buffer'], 22 | }), 23 | { 24 | ...babel(), 25 | apply: 'serve', 26 | enforce: 'post', 27 | }, 28 | ], 29 | 30 | optimizeDeps: { 31 | esbuildOptions, 32 | exclude: [], 33 | }, 34 | 35 | build: { 36 | sourcemap: true, 37 | }, 38 | 39 | esbuild: esbuildOptions, 40 | 41 | resolve: process.env.VITEST 42 | ? { 43 | conditions: ['browser'], 44 | } 45 | : undefined, 46 | }); 47 | -------------------------------------------------------------------------------- /packages/app/src/lib/agent/crdt.svelte.ts: -------------------------------------------------------------------------------- 1 | import {assert, Crdt, type Unsubscribe, type ValueChange} from 'syncwave'; 2 | 3 | function applyChange(state: unknown, change: ValueChange) { 4 | assert(change.path.length > 0, 'change path must not be empty'); 5 | assert( 6 | typeof state === 'object' && state !== null, 7 | 'change target must be an object' 8 | ); 9 | const key = change.path[0]; 10 | if (change.path.length === 1) { 11 | (state as Record)[key] = change.value; 12 | } else { 13 | assert(key in state, `key ${key} not found in svelte state`); 14 | applyChange((state as Record)[key], { 15 | path: change.path.slice(1), 16 | value: change.value, 17 | }); 18 | } 19 | } 20 | 21 | export function deriveCrdtSnapshot(crdt: Crdt): [T, Unsubscribe] { 22 | const snapshot = $state(crdt.snapshot({exposeRichtext: true})); 23 | 24 | const unsub = crdt.onChange(changes => { 25 | changes.forEach(change => { 26 | applyChange(snapshot, change); 27 | }); 28 | }); 29 | 30 | return [snapshot, unsub]; 31 | } 32 | -------------------------------------------------------------------------------- /packages/server/src/http/metrics.ts: -------------------------------------------------------------------------------- 1 | import '../instrumentation.js'; 2 | 3 | import 'dotenv/config'; 4 | 5 | import Router from '@koa/router'; 6 | import {AggregatorRegistry, register} from 'prom-client'; 7 | import {getReadableError, log} from 'syncwave'; 8 | 9 | const aggregatorRegistry = new AggregatorRegistry(); 10 | 11 | export function createMetricsRouter(mode: 'cluster' | 'standalone') { 12 | const router = new Router(); 13 | 14 | router.get('/', async ctx => { 15 | try { 16 | if (mode === 'standalone') { 17 | const metrics = await register.metrics(); 18 | ctx.set('Content-Type', register.contentType); 19 | ctx.body = metrics; 20 | } else { 21 | const metrics = await aggregatorRegistry.clusterMetrics(); 22 | ctx.set('Content-Type', aggregatorRegistry.contentType); 23 | ctx.body = metrics; 24 | } 25 | } catch (error) { 26 | log.error({error, msg: 'failed to get metrics'}); 27 | ctx.status = 500; 28 | ctx.body = getReadableError(error); 29 | } 30 | }); 31 | 32 | return router; 33 | } 34 | -------------------------------------------------------------------------------- /packages/lib/src/transport/chaos-transport.ts: -------------------------------------------------------------------------------- 1 | import {Rand} from '../rand.js'; 2 | import type {Observer} from '../subject.js'; 3 | import {type Unsubscribe, wait} from '../utils.js'; 4 | import type {Connection, TransportClient} from './transport.js'; 5 | 6 | export class ChaosConnection implements Connection { 7 | constructor( 8 | private readonly conn: Connection, 9 | private readonly rand: Rand 10 | ) {} 11 | 12 | async send(message: T): Promise { 13 | await wait({ms: this.rand.int32(0, 5), onCancel: 'reject'}); 14 | await this.conn.send(message); 15 | } 16 | 17 | subscribe(observer: Observer): Unsubscribe { 18 | return this.conn.subscribe(observer); 19 | } 20 | 21 | close(reason: unknown): void { 22 | this.conn.close(reason); 23 | } 24 | } 25 | 26 | export class ChaosTransportClient implements TransportClient { 27 | constructor( 28 | private readonly client: TransportClient, 29 | private readonly rand: Rand 30 | ) {} 31 | 32 | async connect(): Promise> { 33 | return new ChaosConnection(await this.client.connect(), this.rand); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/lib/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const RPC_CALL_TIMEOUT_MS = 5_000; 2 | export const MAX_LOOKAHEAD_COUNT = 64; 3 | export const PULL_WAIT_MS = 1000; 4 | export const RECONNECT_WAIT_MS = 1_000; 5 | export const ENVIRONMENT: 'prod' | 'dev' | 'test' = 6 | process.env.NODE_ENV === 'production' 7 | ? 'prod' 8 | : process.env.NODE_ENV === 'test' 9 | ? 'test' 10 | : 'dev'; 11 | export const TXN_RETRIES_COUNT = 128; 12 | export const AUTH_ACTIVITY_WINDOW_ALLOWED_ACTIONS_COUNT = 10; 13 | export const AUTH_ACTIVITY_WINDOW_MINUTES = 15; 14 | export const PULL_INTERVAL_MS = 5_000; 15 | export const EVENT_STORE_MAX_PULL_COUNT = 128; 16 | export const AUTHENTICATOR_PRINCIPAL_CACHE_SIZE = 1024; 17 | export const AWARENESS_OFFLINE_TIMEOUT_MS = 30_000; 18 | export const USER_INACTIVITY_TIMEOUT_MS = 5 * 60_000; // 5 min 19 | export const MESSAGE_TYPING_AWARENESS_TIMEOUT_MS = 3_000; 20 | export const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; 21 | 22 | export const AUTH_CODE_LENGTH = 6; 23 | export const AUTH_CODE_ALPHABET = '23456789BCDFGHJKMNPQRTVWXY'; 24 | 25 | export const RPC_CHUNK_SIZE = 2 * 1024; // 2KB (Typical TCP payload: ~1460 bytes) 26 | export const KV_STORE_QUERY_BATCH_SIZE = 8; 27 | -------------------------------------------------------------------------------- /packages/lib/src/node-jwt-provider.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import type {JwtPayload, JwtProvider} from './data/infrastructure.js'; 3 | import {AppError} from './errors.js'; 4 | 5 | export class JwtVerificationError extends AppError { 6 | constructor(public readonly errors: jwt.VerifyErrors) { 7 | super('JWT verification error'); 8 | } 9 | } 10 | 11 | export class NodeJwtProvider implements JwtProvider { 12 | constructor(private readonly secret: string) {} 13 | 14 | verify(token: string): Promise { 15 | return new Promise((resolve, reject) => { 16 | jwt.verify(token, this.secret, (err, result) => { 17 | if (err) { 18 | return reject(new JwtVerificationError(err)); 19 | } 20 | 21 | resolve(result as JwtPayload); 22 | }); 23 | }); 24 | } 25 | 26 | sign(payload: JwtPayload): Promise { 27 | return new Promise((resolve, reject) => { 28 | jwt.sign(payload, this.secret, (err, result) => { 29 | if (err) return reject(err); 30 | resolve(result!); 31 | }); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/www/eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import {includeIgnoreFile} from '@eslint/compat'; 4 | import {default as eslint} from '@eslint/js'; 5 | import pluginPrettier from 'eslint-config-prettier'; 6 | import prettier from 'eslint-plugin-prettier/recommended'; 7 | import {default as pluginSvelte, default as svelte} from 'eslint-plugin-svelte'; 8 | import globals from 'globals'; 9 | import {fileURLToPath} from 'node:url'; 10 | import tseslint from 'typescript-eslint'; 11 | 12 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 13 | 14 | const config = tseslint.config( 15 | includeIgnoreFile(gitignorePath), 16 | eslint.configs.recommended, 17 | ...tseslint.configs.recommended, 18 | prettier, 19 | ...svelte.configs.recommended, 20 | { 21 | languageOptions: { 22 | globals: globals.browser, 23 | }, 24 | }, 25 | pluginSvelte.configs['flat/recommended'], 26 | pluginPrettier, 27 | ...pluginSvelte.configs['flat/prettier'], 28 | { 29 | rules: { 30 | '@typescript-eslint/no-empty-object-type': 'off', 31 | 'svelte/no-at-html-tags': 'off', 32 | }, 33 | } 34 | ); 35 | 36 | export default config; 37 | -------------------------------------------------------------------------------- /packages/config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "build", 5 | "strictFunctionTypes": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "checkJs": true, 10 | "allowJs": true, 11 | "verbatimModuleSyntax": true, 12 | "isolatedModules": true, 13 | "noErrorTruncation": true, 14 | "noImplicitAny": false, 15 | "noEmitOnError": false, 16 | "skipLibCheck": true, 17 | "allowUnreachableCode": false, 18 | "allowUnusedLabels": false, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowSyntheticDefaultImports": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitOverride": true, 23 | "noImplicitThis": true, 24 | "noImplicitReturns": true, 25 | "pretty": true, 26 | "sourceMap": true, 27 | "strict": true, 28 | "alwaysStrict": true, 29 | "strictNullChecks": true, 30 | "module": "NodeNext", 31 | "moduleResolution": "nodenext", 32 | "lib": ["ES2020"], 33 | "target": "ES2022" 34 | }, 35 | "exclude": ["node_modules"] 36 | } 37 | -------------------------------------------------------------------------------- /packages/lib/src/data/repos/base/doc.ts: -------------------------------------------------------------------------------- 1 | import {type Static, Type} from '@sinclair/typebox'; 2 | import {AppError} from '../../../errors.js'; 3 | import {Timestamp} from '../../../timestamp.js'; 4 | import {type Tuple} from '../../../tuple.js'; 5 | import {type ToSchema} from '../../../type.js'; 6 | 7 | export class ConstraintError extends AppError { 8 | constructor( 9 | public readonly constraintName: string, 10 | message: string 11 | ) { 12 | super('constraint failed: ' + constraintName + ', ' + message); 13 | this.name = 'ConstraintError'; 14 | } 15 | } 16 | 17 | export function Doc(pk: ToSchema) { 18 | return Type.Object({ 19 | pk: pk, 20 | createdAt: Timestamp(), 21 | updatedAt: Timestamp(), 22 | deletedAt: Type.Optional(Timestamp()), 23 | }); 24 | } 25 | 26 | export interface Doc 27 | extends Static>> {} 28 | 29 | export type IndexSpec = 30 | | { 31 | readonly unique?: boolean | undefined; 32 | readonly key: (x: T) => Tuple[]; 33 | readonly filter?: (x: T) => boolean; 34 | } 35 | | ((x: T) => Tuple[]); 36 | 37 | export type IndexMap = Record>; 38 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/modal.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --modal-padding-inline: 2rem; 3 | --modal-padding-block: 1rem; 4 | } 5 | 6 | .modal-padding-inline { 7 | padding-inline: var(--modal-padding-inline); 8 | } 9 | 10 | .modal__overlay { 11 | position: fixed; 12 | top: 0; 13 | left: 0; 14 | z-index: 999; 15 | 16 | width: 100%; 17 | height: 100%; 18 | 19 | background: rgba(0, 0, 0, 0.03); 20 | 21 | @media (prefers-color-scheme: dark) { 22 | background: rgba(0, 0, 0, 0.15); 23 | } 24 | } 25 | 26 | .modal { 27 | background: var(--color-material-elevated); 28 | position: fixed; 29 | top: 3rem; 30 | left: 50%; 31 | width: 100%; 32 | translate: -50%; 33 | border-radius: var(--radius-lg); 34 | box-shadow: var(--shadow-xl); 35 | z-index: 1000; 36 | border: 1px solid var(--color-divider); 37 | 38 | @media (prefers-color-scheme: dark) { 39 | border: 1px solid var(--color-divider-elevated); 40 | } 41 | 42 | &.modal--sm { 43 | max-width: 26rem; 44 | } 45 | 46 | &.modal--md { 47 | max-width: 29rem; 48 | } 49 | 50 | &.modal--lg { 51 | max-width: 32rem; 52 | } 53 | 54 | &.modal--xl { 55 | max-width: 44rem; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/lib/src/kv/counter.ts: -------------------------------------------------------------------------------- 1 | import {context} from '../context.js'; 2 | import type {Tuple} from '../tuple.js'; 3 | import {Cell} from './cell.js'; 4 | import type {Transaction} from './kv-store.js'; 5 | 6 | export class Counter { 7 | private readonly cell: Cell; 8 | 9 | constructor(tx: Transaction, initial: number) { 10 | this.cell = new Cell(tx, initial); 11 | } 12 | 13 | async get(): Promise { 14 | return await context().runChild({span: 'counter.get'}, async () => { 15 | return await this.cell.get(); 16 | }); 17 | } 18 | 19 | async set(value: number): Promise { 20 | return await context().runChild({span: 'counter.set'}, async () => { 21 | await this.cell.put(value); 22 | return value; 23 | }); 24 | } 25 | 26 | async increment(delta?: number): Promise { 27 | return await context().runChild( 28 | {span: 'counter.increment'}, 29 | async () => { 30 | const current = await this.cell.get(); 31 | const next = current + (delta ?? 1); 32 | await this.cell.put(next); 33 | return next; 34 | } 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We actively maintain the latest version of Syncwave. Security updates are only provided for the latest release. 6 | 7 | | Version | Supported | 8 | | ------- | --------- | 9 | | Latest | ✅ | 10 | | Older | ❌ | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | We take security seriously at Syncwave and appreciate your help in disclosing issues responsibly. 15 | 16 | To report a security vulnerability, please email **tilyupo@gmail.com** with the following: 17 | 18 | - Description of the issue 19 | - Steps to reproduce (if applicable) 20 | - Your environment and version of Syncwave 21 | - Any proof-of-concept or exploit code (if available) 22 | 23 | Please do **not** open public GitHub issues for vulnerabilities. 24 | 25 | You will receive a response within **3 working days**. We aim to resolve confirmed issues and release a patch as quickly as possible, ideally within **7 days**. 26 | 27 | ## Disclosure Policy 28 | 29 | 1. Vulnerability is reported privately to the team. 30 | 2. We acknowledge receipt and begin internal review. 31 | 3. If confirmed, we work on a fix and prepare a security release. 32 | 4. You may be credited in the changelog unless anonymity is requested. 33 | 5. The issue and patch will be disclosed after release. 34 | -------------------------------------------------------------------------------- /packages/server/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Init db: 2 | # 3 | # docker compose exec fdb bash -c "fdbcli --exec 'configure new single ssd-redwood-1'" 4 | # docker compose exec fdb bash -c "fdbcli --exec 'status'" 5 | 6 | # env: 7 | # 8 | # FDB_CLUSTER_FILE 9 | 10 | services: 11 | fdb: 12 | image: foundationdb/foundationdb:7.3.59 13 | restart: always 14 | ports: 15 | - "4500:4500" 16 | container_name: fdb 17 | # FoundationDB requires CAP_SYS_NICE for priority-based processes 18 | cap_add: 19 | - SYS_NICE 20 | volumes: 21 | - fdb-data:/var/fdb/data 22 | 23 | fdb_backup_agent: 24 | image: foundationdb/foundationdb:7.3.59 25 | restart: always 26 | depends_on: 27 | - fdb 28 | volumes: 29 | - ./backups:/backups 30 | container_name: fdb_backup_agent 31 | entrypoint: 32 | - /usr/bin/tini 33 | - -g 34 | - -- 35 | - bash 36 | - -c 37 | - | 38 | echo docker:docker@fdb:4500 > /var/fdb/fdb.cluster && 39 | /usr/bin/backup_agent --log -C /var/fdb/fdb.cluster --knob_http_verbose_level=4 --knob_http_request_aws_v4_header=true 40 | 41 | volumes: 42 | fdb-data: 43 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/main.css: -------------------------------------------------------------------------------- 1 | @import '@fontsource/inter/100.css'; 2 | @import '@fontsource/inter/200.css'; 3 | @import '@fontsource/inter/300.css'; 4 | @import '@fontsource/inter/400.css'; 5 | @import '@fontsource/inter/500.css'; 6 | @import '@fontsource/inter/600.css'; 7 | @import '@fontsource/inter/700.css'; 8 | @import '@fontsource/inter/800.css'; 9 | @import '@fontsource/inter/900.css'; 10 | 11 | @import '@fontsource/inter/100-italic.css'; 12 | @import '@fontsource/inter/200-italic.css'; 13 | @import '@fontsource/inter/300-italic.css'; 14 | @import '@fontsource/inter/400-italic.css'; 15 | @import '@fontsource/inter/500-italic.css'; 16 | @import '@fontsource/inter/600-italic.css'; 17 | @import '@fontsource/inter/700-italic.css'; 18 | @import '@fontsource/inter/800-italic.css'; 19 | @import '@fontsource/inter/900-italic.css'; 20 | 21 | @import 'tailwindcss'; 22 | 23 | @import './colors.css'; 24 | @import './text.css'; 25 | @import './base.css'; 26 | @import './app.css'; 27 | @import './panel.css'; 28 | @import './modal.css'; 29 | @import './board.css'; 30 | @import './button.css'; 31 | @import './input.css'; 32 | @import './icons.css'; 33 | @import './avatar.css'; 34 | @import './tiptap.css'; 35 | @import './settings.css'; 36 | @import './dropdown.css'; 37 | 38 | html, 39 | body { 40 | overflow: hidden; 41 | } 42 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/CogSolidIcon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /packages/lib/src/hex.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {validateHexString} from './hex.js'; 3 | 4 | // Test suite for validateHexString 5 | describe('validateHexString', () => { 6 | it('should return true for a valid single-byte hex string', () => { 7 | expect(validateHexString('ff')).toBe(true); 8 | }); 9 | 10 | it('should return true for a valid multi-byte hex string', () => { 11 | expect(validateHexString('0a 1b 2c 3d')).toBe(true); 12 | }); 13 | 14 | it('should return true for an empty string', () => { 15 | expect(validateHexString('')).toBe(true); 16 | }); 17 | 18 | it('should return false for invalid characters in hex string', () => { 19 | expect(validateHexString('zz aa')).toBe(false); 20 | }); 21 | 22 | it('should return false for hex string with uneven nibble count', () => { 23 | expect(validateHexString('1a 2')).toBe(false); 24 | }); 25 | 26 | it('should return false for hex string with invalid spacing', () => { 27 | expect(validateHexString('1a 2b 3c ')).toBe(false); 28 | }); 29 | 30 | it('should return false for null or undefined input', () => { 31 | expect(validateHexString(null)).toBe(false); 32 | expect(validateHexString(undefined)).toBe(false); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/lib/src/deferred.ts: -------------------------------------------------------------------------------- 1 | import type {AppError} from './errors.js'; 2 | 3 | export function createPromiseStatePending(): PromiseState { 4 | return {type: 'pending'}; 5 | } 6 | 7 | export type PromiseState = 8 | | {readonly type: 'fulfilled'; readonly value: T} 9 | | {readonly type: 'pending'} 10 | | {readonly type: 'rejected'; readonly reason: AppError}; 11 | 12 | export class Deferred { 13 | private _state: PromiseState = {type: 'pending'}; 14 | 15 | private _resolve!: (value: T) => void; 16 | private _reject!: (error: AppError) => void; 17 | 18 | public readonly promise: Promise; 19 | 20 | constructor() { 21 | this.promise = new Promise((resolve, reject) => { 22 | this._resolve = resolve; 23 | this._reject = reject; 24 | }); 25 | } 26 | 27 | get state() { 28 | return this._state.type; 29 | } 30 | 31 | resolve(value: T) { 32 | if (this._state.type === 'pending') { 33 | this._state = {type: 'fulfilled', value: value}; 34 | this._resolve(value); 35 | } 36 | } 37 | 38 | reject(error: AppError) { 39 | if (this._state.type === 'pending') { 40 | this._state = {type: 'rejected', reason: error}; 41 | this._reject(error); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/icons/CogIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /packages/lib/src/node-crypto-provider.ts: -------------------------------------------------------------------------------- 1 | import {compare, hash} from 'bcrypt'; 2 | import {randomBytes} from 'crypto'; 3 | import {toError, type CryptoProvider} from 'syncwave'; 4 | 5 | export const NodeCryptoProvider: CryptoProvider = { 6 | randomBytes: (size: number): Promise => { 7 | return new Promise((resolve, reject) => { 8 | try { 9 | randomBytes(size, (error, buffer) => { 10 | if (error) { 11 | reject(error); 12 | return; 13 | } 14 | const randomNumbers = new Uint8Array(buffer); // convert the buffer to an array of numbers 15 | resolve(randomNumbers); 16 | }); 17 | } catch (error) { 18 | reject(toError(error)); 19 | } 20 | }); 21 | }, 22 | bcryptCompare: async ({hash, password}) => { 23 | return await compare(password, hash); 24 | }, 25 | bcryptHash: async password => { 26 | // 10 is the default, but now considered minimum. 27 | // 12 is recommended for most applications (safe and still fast enough). 28 | // 14+ if your app handles extremely sensitive data and you can afford slower hashing. (Hashing will noticeably slow down.) 29 | return await hash(password, 12); 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/app/src/lib/managers/permission-manager.ts: -------------------------------------------------------------------------------- 1 | import type {MemberRole} from 'syncwave'; 2 | import type {MemberView} from '../agent/view.svelte'; 3 | 4 | export type Permission = 'write:card' | 'write:board' | 'delete:board'; 5 | 6 | const PERMISSIONS: Map = new Map([ 7 | ['owner', ['write:card', 'write:board', 'delete:board']], 8 | ['admin', ['write:card', 'write:board']], 9 | ['writer', ['write:card']], 10 | ['reader', []], 11 | ]); 12 | 13 | export class PermissionManager { 14 | private member: MemberView | null = null; 15 | 16 | setMember(member: MemberView) { 17 | this.member = member; 18 | } 19 | 20 | getRole(): MemberRole { 21 | if (!this.member) { 22 | throw Error('cannot get role: membem is not set up'); 23 | } 24 | return this.member.role; 25 | } 26 | 27 | hasRole(role: MemberRole): boolean { 28 | return this.getRole() == role; 29 | } 30 | 31 | getPermissions(): Permission[] { 32 | if (!this.member) { 33 | throw Error('cannot get permission: membem is not set up'); 34 | } 35 | 36 | return PERMISSIONS.get(this.member.role) || []; 37 | } 38 | 39 | hasPermission(permission: Permission): boolean { 40 | return this.getPermissions().includes(permission); 41 | } 42 | } 43 | 44 | const manager = new PermissionManager(); 45 | export default manager; 46 | -------------------------------------------------------------------------------- /packages/www/src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../components/layout.astro'; 3 | --- 4 | 5 | 10 |
13 |
14 |

17 | 404 18 |

19 |

20 | Oops! Page Not Found 21 |

22 |

23 | The page you're looking for doesn't seem to exist. It might have 24 | been moved, deleted, or maybe you just mistyped the URL. 25 |

26 | 34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /packages/lib/src/data/placement.ts: -------------------------------------------------------------------------------- 1 | import {Type, type Static} from '@sinclair/typebox'; 2 | import {assert} from '../utils.js'; 3 | 4 | export function Placement() { 5 | return Type.Object({ 6 | prev: Type.Optional(Type.Number()), 7 | next: Type.Optional(Type.Number()), 8 | }); 9 | } 10 | 11 | export type Placement = Static>; 12 | export function toPosition(placement: {prev?: number; next?: number}): number { 13 | let result: number; 14 | const rand = Math.max(Math.random(), 1e-18); 15 | if (placement.prev && placement.next) { 16 | const middle = (placement.prev + placement.next) / 2; 17 | const diff = Math.abs(placement.next - placement.prev); 18 | const jitter = (diff / 4) * rand; 19 | result = middle + jitter; 20 | } else if (placement.next) { 21 | result = placement.next - rand * 1e200; 22 | } else if (placement.prev) { 23 | result = placement.prev + rand * 1e200; 24 | } else { 25 | result = rand; 26 | } 27 | 28 | assert(Number.isFinite(result), "Placement result is't finite: " + result); 29 | assert(result !== 0, 'Placement result is 0'); 30 | assert( 31 | result !== placement.prev, 32 | 'Placement result is equal to prev: ' + result 33 | ); 34 | assert( 35 | result !== placement.next, 36 | 'Placement result is equal to next: ' + result 37 | ); 38 | 39 | return result; 40 | } 41 | -------------------------------------------------------------------------------- /packages/server/src/ses-email-provider.ts: -------------------------------------------------------------------------------- 1 | import {SendEmailCommand, SESClient} from '@aws-sdk/client-ses'; 2 | import {log, type EmailMessage, type EmailProvider} from 'syncwave'; 3 | 4 | export class SesEmailProvider implements EmailProvider { 5 | private readonly ses: SESClient; 6 | 7 | constructor(region: string) { 8 | this.ses = new SESClient({ 9 | region, 10 | credentials: { 11 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 12 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 13 | }, 14 | }); 15 | } 16 | 17 | async send({html, recipient, subject, text}: EmailMessage): Promise { 18 | log.info({ 19 | msg: `Sending email to ${recipient} with subject: ${subject}`, 20 | }); 21 | 22 | await this.ses.send( 23 | new SendEmailCommand({ 24 | Destination: { 25 | ToAddresses: [recipient], 26 | }, 27 | Source: 'Syncwave ', 28 | Message: { 29 | Subject: { 30 | Data: subject, 31 | }, 32 | Body: { 33 | Text: { 34 | Data: text, 35 | }, 36 | Html: { 37 | Data: html, 38 | }, 39 | }, 40 | }, 41 | }) 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/lib/src/event-emitter.ts: -------------------------------------------------------------------------------- 1 | import {context, type Context} from './context.js'; 2 | import {AppError} from './errors.js'; 3 | import {runAll, type Unsubscribe} from './utils.js'; 4 | 5 | export type EventEmitterCallback = (value: T) => void; 6 | 7 | interface EventEmitterSubscriber { 8 | callback: EventEmitterCallback; 9 | context: Context; 10 | } 11 | 12 | export class EventEmitter { 13 | private subs: Array> = []; 14 | private _open = true; 15 | 16 | get anyObservers(): boolean { 17 | return this.subs.length > 0; 18 | } 19 | 20 | subscribe(callback: EventEmitterCallback): Unsubscribe { 21 | this.ensureOpen(); 22 | 23 | const sub: EventEmitterSubscriber = {callback, context: context()}; 24 | 25 | this.subs.push(sub); 26 | const cleanup = () => { 27 | this.subs = this.subs.filter(x => x !== sub); 28 | }; 29 | const cancelCleanup = context().onEnd(() => cleanup()); 30 | 31 | return reason => { 32 | cancelCleanup(reason); 33 | cleanup(); 34 | }; 35 | } 36 | 37 | emit(value: T): void { 38 | this.ensureOpen(); 39 | 40 | runAll( 41 | this.subs.map(sub => 42 | sub.context.run(() => () => sub.callback(value)) 43 | ) 44 | ); 45 | } 46 | 47 | close(): void { 48 | this._open = false; 49 | } 50 | 51 | private ensureOpen() { 52 | if (!this._open) { 53 | throw new AppError('subject is closed'); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/app/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import {includeIgnoreFile} from '@eslint/compat'; 4 | import {default as eslint} from '@eslint/js'; 5 | import pluginPrettier from 'eslint-config-prettier'; 6 | import prettier from 'eslint-plugin-prettier/recommended'; 7 | import {default as pluginSvelte, default as svelte} from 'eslint-plugin-svelte'; 8 | import globals from 'globals'; 9 | import {fileURLToPath} from 'node:url'; 10 | import tseslint from 'typescript-eslint'; 11 | import svelteConfig from './svelte.config.js'; 12 | 13 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 14 | 15 | const config = tseslint.config( 16 | includeIgnoreFile(gitignorePath), 17 | eslint.configs.recommended, 18 | ...tseslint.configs.recommended, 19 | prettier, 20 | ...svelte.configs.recommended, 21 | { 22 | languageOptions: { 23 | globals: globals.browser, 24 | }, 25 | }, 26 | { 27 | files: ['**/*.svelte', '**/*.svelte.ts'], 28 | languageOptions: { 29 | parserOptions: { 30 | projectService: true, 31 | extraFileExtensions: ['.svelte'], 32 | parser: tseslint.parser, 33 | svelteConfig, 34 | }, 35 | }, 36 | }, 37 | pluginSvelte.configs['flat/recommended'], 38 | pluginPrettier, 39 | ...pluginSvelte.configs['flat/prettier'], 40 | { 41 | rules: { 42 | '@typescript-eslint/no-empty-object-type': 'off', 43 | 'svelte/no-at-html-tags': 'off', 44 | }, 45 | } 46 | ); 47 | 48 | export default config; 49 | -------------------------------------------------------------------------------- /packages/lib/src/transport/hub.ts: -------------------------------------------------------------------------------- 1 | import {Cursor, toCursor} from '../cursor.js'; 2 | import {log} from '../logger.js'; 3 | import {Subject} from '../subject.js'; 4 | import {runAll} from '../utils.js'; 5 | 6 | /** 7 | * Hub doesn't give event delivery guarantees, it is a best effort 8 | * pub/sub mechanism to notify about changes in the data layer. 9 | * 10 | * It can only be used for optimistic realtime notifications. 11 | * System correctness must not depend on it. 12 | */ 13 | export interface Hub { 14 | emit(topic: string): Promise; 15 | subscribe(topic: string): Promise>; 16 | close(reason: unknown): void; 17 | } 18 | 19 | export class MemHub implements Hub { 20 | private readonly subjects = new Map>(); 21 | 22 | constructor() {} 23 | 24 | async emit(topic: string) { 25 | this.subjects 26 | .get(topic) 27 | ?.next() 28 | .catch(error => { 29 | log.error({error, msg: 'MemHub.emit'}); 30 | }); 31 | } 32 | 33 | async subscribe(topic: string): Promise> { 34 | let subject = this.subjects.get(topic); 35 | if (!subject) { 36 | subject = new Subject(); 37 | this.subjects.set(topic, subject); 38 | } 39 | 40 | return toCursor(subject.stream()).finally(() => { 41 | if (!subject.anyObservers) { 42 | this.subjects.delete(topic); 43 | } 44 | }); 45 | } 46 | 47 | close(reason: unknown) { 48 | runAll([...this.subjects.values()].map(x => () => x.close(reason))); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/self-hosted/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim AS base 2 | 3 | FROM base AS deps 4 | 5 | WORKDIR /app 6 | 7 | COPY package*.json . 8 | COPY packages/lib/package*.json ./packages/lib/ 9 | COPY packages/server/package*.json ./packages/server/ 10 | COPY packages/app/package*.json ./packages/app/ 11 | 12 | # Self-hosted doesn't use FoundationDB 13 | RUN cd packages/server && sed -i '/foundationdb/d' package.json 14 | 15 | RUN npm install 16 | 17 | FROM base AS builder 18 | 19 | WORKDIR /app 20 | 21 | COPY . . 22 | COPY --from=deps /app . 23 | 24 | WORKDIR /app/packages/lib 25 | RUN npm run build 26 | 27 | WORKDIR /app/packages/server 28 | RUN rm ./src/fdb-kv-store.ts ./src/fdb-kv-store.spec.ts 29 | RUN npm run build 30 | 31 | WORKDIR /app/packages/app 32 | RUN VITE_PUBLIC_STAGE=self npm run build 33 | 34 | FROM base AS runtime 35 | 36 | WORKDIR /app 37 | 38 | COPY --from=builder /app/package.json /app/package-lock.json ./ 39 | COPY --from=builder /app/packages/lib/package.json ./packages/lib/ 40 | COPY --from=builder /app/packages/server/package.json ./packages/server/ 41 | 42 | RUN npm install --omit=dev 43 | 44 | COPY --from=builder /app/packages/lib/dist ./packages/lib/dist 45 | COPY --from=builder /app/packages/server/dist ./packages/server/dist 46 | COPY --from=builder /app/packages/app/dist ./packages/server/ui/ 47 | 48 | ENV PORT=8080 49 | EXPOSE 8080 50 | 51 | ENV STAGE=self 52 | ENV AWS_DEFAULT_REGION=us-east-1 53 | 54 | ENV NODE_ENV=production 55 | ENV NODE_OPTIONS='--enable-source-maps' 56 | 57 | WORKDIR /app/packages/server 58 | 59 | VOLUME /data 60 | 61 | CMD ["node", "--inspect=0.0.0.0:9229", "dist/esm/src/entrypoint.js"] 62 | -------------------------------------------------------------------------------- /packages/lib/src/timestamp.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, test} from 'vitest'; 2 | import {addHours, addYears, getNow, type Timestamp} from './timestamp.js'; 3 | 4 | // Helper function to check if a value is a valid timestamp 5 | const isValidTimestamp = (value: any): value is Timestamp => { 6 | return typeof value === 'number' && !Number.isNaN(value); 7 | }; 8 | 9 | describe('getNow', () => { 10 | test('returns a valid timestamp', () => { 11 | const now = getNow(); 12 | expect(isValidTimestamp(now)).toBe(true); 13 | }); 14 | }); 15 | 16 | describe('addHours', () => { 17 | test('correctly adds hours to a timestamp', () => { 18 | const now = getNow(); 19 | const result = addHours(now, 5); 20 | expect(result).toBeTypeOf('number'); 21 | expect(isValidTimestamp(result)).toBe(true); 22 | expect(result).toBeGreaterThan(now); 23 | }); 24 | 25 | test('handles negative hours correctly', () => { 26 | const now = getNow(); 27 | const result = addHours(now, -3); 28 | expect(result).toBeLessThan(now); 29 | }); 30 | }); 31 | 32 | describe('addYears', () => { 33 | test('correctly adds years to a timestamp', () => { 34 | const now = getNow(); 35 | const result = addYears(now, 2); 36 | expect(result).toBeTypeOf('number'); 37 | expect(isValidTimestamp(result)).toBe(true); 38 | expect(result).toBeGreaterThan(now); 39 | }); 40 | 41 | test('handles negative years correctly', () => { 42 | const now = getNow(); 43 | const result = addYears(now, -1); 44 | expect(result).toBeLessThan(now); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/lib/src/data/auth.ts: -------------------------------------------------------------------------------- 1 | import {LRUCache} from 'lru-cache'; 2 | import {type JwtProvider} from './infrastructure.js'; 3 | import type {AccountId} from './repos/account-repo.js'; 4 | import {type UserId} from './repos/user-repo.js'; 5 | 6 | export const anonymous: Principal = { 7 | userId: undefined, 8 | accountId: undefined, 9 | }; 10 | 11 | export interface Principal { 12 | readonly userId: UserId | undefined; 13 | readonly accountId: AccountId | undefined; 14 | } 15 | 16 | export const system: Principal = { 17 | userId: undefined, 18 | accountId: undefined, 19 | }; 20 | 21 | export class Authenticator { 22 | private readonly principalCache: LRUCache; 23 | 24 | constructor( 25 | cacheSize: number, 26 | private readonly jwt: JwtProvider 27 | ) { 28 | this.principalCache = new LRUCache({ 29 | max: cacheSize, 30 | }); 31 | } 32 | 33 | async authenticate(jwtToken: string | undefined): Promise { 34 | if (typeof jwtToken === 'string') { 35 | const result = this.principalCache.get(jwtToken); 36 | if (result) { 37 | return result; 38 | } 39 | 40 | const jwtPayload = await this.jwt.verify(jwtToken); 41 | const principal: Principal = { 42 | accountId: jwtPayload.sub as AccountId | undefined, 43 | userId: jwtPayload.uid as UserId | undefined, 44 | }; 45 | this.principalCache.set(jwtToken, principal); 46 | return principal; 47 | } else { 48 | return anonymous; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/lib/src/type.ts: -------------------------------------------------------------------------------- 1 | import {FormatRegistry, type TSchema} from '@sinclair/typebox'; 2 | import {TypeCheck, TypeCompiler} from '@sinclair/typebox/compiler'; 3 | import {validateBase64} from './base64.js'; 4 | import {AppError, getReadableError, toError} from './errors.js'; 5 | import {validateUuid} from './uuid.js'; 6 | 7 | export type ToSchema = TSchema & {static: T}; 8 | 9 | FormatRegistry.Set('uuid', value => validateUuid(value)); 10 | FormatRegistry.Set('base64', value => validateBase64(value)); 11 | 12 | const typeCheckCache = new WeakMap<{}, TypeCheck>(); 13 | 14 | function createTypeCheck(schema: T): TypeCheck { 15 | const cached = typeCheckCache.get(schema); 16 | if (cached) { 17 | return cached as TypeCheck; 18 | } 19 | const result = TypeCompiler.Compile(schema); 20 | typeCheckCache.set(schema, result); 21 | return result; 22 | } 23 | 24 | export function checkValue( 25 | schema: ToSchema, 26 | x: unknown, 27 | message?: string 28 | ): T { 29 | const typeCheck = createTypeCheck(schema); 30 | try { 31 | if (typeCheck.Check(x)) { 32 | return x; 33 | } 34 | 35 | const errors = [...typeCheck.Errors(x)]; 36 | 37 | throw new AppError( 38 | `${message}: validation failed:\n - ` + 39 | errors.map(e => e.message).join('\n - ') + 40 | '\n' + 41 | JSON.stringify(errors), 42 | {cause: errors} 43 | ); 44 | } catch (error) { 45 | throw toError( 46 | new AppError('Check error: ' + getReadableError(error), { 47 | cause: error, 48 | }) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/server/src/http/ui.ts: -------------------------------------------------------------------------------- 1 | import Router from '@koa/router'; 2 | import fs from 'fs/promises'; 3 | import type {Context as KoaContext} from 'koa'; 4 | import serveStatic from 'koa-static'; 5 | import {createUuidV4, type SelfHostedClientConfig} from 'syncwave'; 6 | 7 | export async function createUiRouter(options: { 8 | staticPath: string; 9 | googleClientId: string | undefined; 10 | passwordsEnabled: boolean; 11 | }) { 12 | const router = new Router(); 13 | 14 | const clientConfig: SelfHostedClientConfig = { 15 | googleClientId: options.googleClientId, 16 | passwordsEnabled: options.passwordsEnabled, 17 | }; 18 | 19 | const indexHtml = await fs 20 | .readFile(`${options.staticPath}/index.html`, 'utf-8') 21 | .then(html => { 22 | return html.replace( 23 | '', 24 | ` 25 | ` 28 | ); 29 | }); 30 | 31 | async function serveIndexHtml(ctx: KoaContext) { 32 | ctx.body = indexHtml; 33 | ctx.set('Content-Type', 'text/html; charset=UTF-8'); 34 | ctx.set('Cache-Control', 'no-store'); 35 | ctx.header['X-Content-Type-Options'] = 'nosniff'; 36 | } 37 | 38 | router.get(['/', '/index.html'], serveIndexHtml); 39 | 40 | router.use( 41 | serveStatic(options.staticPath, { 42 | index: createUuidV4(), 43 | immutable: true, 44 | maxage: 365 * 24 * 60 * 60 * 1000, // 1 year 45 | }) 46 | ); 47 | 48 | router.get('/:path(.*)', serveIndexHtml); 49 | 50 | return router; 51 | } 52 | -------------------------------------------------------------------------------- /packages/app/src/lib/managers/board-history-manager.ts: -------------------------------------------------------------------------------- 1 | class BoardHistoryManager { 2 | private static readonly STORAGE_KEY = 'board_key'; 3 | 4 | /** 5 | * Saves a board key to local storage 6 | * @param boardKey The key to save 7 | */ 8 | public static save(boardKey: string): void { 9 | if (!boardKey) { 10 | console.error('Cannot save empty board key'); 11 | return; 12 | } 13 | 14 | try { 15 | localStorage.setItem(this.STORAGE_KEY, boardKey); 16 | console.log(`Board key "${boardKey}" saved successfully`); 17 | } catch (error: unknown) { 18 | console.error('Failed to save board key to local storage:', error); 19 | } 20 | } 21 | 22 | /** 23 | * Retrieves the latest board key from local storage 24 | * @returns The latest board key or null if none exists 25 | */ 26 | public static last(): string | null { 27 | try { 28 | return localStorage.getItem(this.STORAGE_KEY); 29 | } catch (error: unknown) { 30 | console.error( 31 | 'Failed to retrieve board key from local storage:', 32 | error 33 | ); 34 | return null; 35 | } 36 | } 37 | 38 | /** 39 | * Clears the stored board key 40 | */ 41 | public static clear(): void { 42 | try { 43 | localStorage.removeItem(this.STORAGE_KEY); 44 | console.log('Board key cleared successfully'); 45 | } catch (error: unknown) { 46 | console.error( 47 | 'Failed to clear board key from local storage:', 48 | error 49 | ); 50 | } 51 | } 52 | } 53 | 54 | export default BoardHistoryManager; 55 | -------------------------------------------------------------------------------- /packages/app/src/lib/managers/theme-manager.svelte.ts: -------------------------------------------------------------------------------- 1 | export function createThemeManager() { 2 | let userTheme = $state('system'); 3 | let systemTheme = $state('light'); 4 | const theme = $derived(userTheme === 'system' ? systemTheme : userTheme); 5 | 6 | function setUserTheme(newTheme: 'light' | 'dark' | 'system') { 7 | userTheme = newTheme; 8 | } 9 | 10 | $effect(() => { 11 | if (typeof localStorage !== 'undefined') { 12 | const savedTheme = localStorage.getItem('theme'); 13 | if (savedTheme) { 14 | userTheme = savedTheme as 'light' | 'dark' | 'system'; 15 | } 16 | } 17 | }); 18 | 19 | $effect(() => { 20 | if (typeof localStorage !== 'undefined') { 21 | localStorage.setItem('theme', userTheme); 22 | } 23 | }); 24 | 25 | $effect(() => { 26 | if (typeof window !== 'undefined') { 27 | const prefersLight = window.matchMedia( 28 | '(prefers-color-scheme: light)' 29 | ); 30 | systemTheme = prefersLight.matches ? 'light' : 'dark'; 31 | 32 | const listener = (event: MediaQueryListEvent) => { 33 | systemTheme = event.matches ? 'light' : 'dark'; 34 | }; 35 | prefersLight.addEventListener('change', listener); 36 | 37 | return () => { 38 | prefersLight.removeEventListener('change', listener); 39 | }; 40 | } 41 | 42 | return () => {}; 43 | }); 44 | 45 | $effect(() => { 46 | if (typeof document !== 'undefined') { 47 | document.documentElement.setAttribute('data-theme', theme); 48 | } 49 | }); 50 | 51 | return { 52 | getTheme: () => theme, 53 | setUserTheme, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /packages/www/public/assets/images/brand-logos/stripe.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/app/src/pages/impersonate.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 |
22 |

25 | Impersonate User 26 |

27 | 28 |
29 |
30 | 33 | 40 |
41 | 42 | 48 |
49 |
50 |
51 | -------------------------------------------------------------------------------- /packages/server/src/event-loop-monitor.ts: -------------------------------------------------------------------------------- 1 | import {createHook} from 'node:async_hooks'; 2 | import {context, log, type TraceId} from 'syncwave'; 3 | 4 | const THRESHOLD_NS = 1e8; // 100ms 5 | 6 | const cache = new Map< 7 | number, 8 | {type: string; traceId: TraceId; spanId: string; start?: [number, number]} 9 | >(); 10 | 11 | function init(asyncId: number, type: string) { 12 | cache.set(asyncId, { 13 | type, 14 | traceId: context().traceId, 15 | spanId: context().spanId, 16 | }); 17 | } 18 | 19 | function destroy(asyncId: number) { 20 | cache.delete(asyncId); 21 | } 22 | 23 | function before(asyncId: number) { 24 | const cached = cache.get(asyncId); 25 | 26 | if (!cached) { 27 | return; 28 | } 29 | 30 | cache.set(asyncId, { 31 | ...cached, 32 | start: process.hrtime(), 33 | }); 34 | } 35 | 36 | function after(asyncId: number) { 37 | const cached = cache.get(asyncId); 38 | 39 | if (!cached) { 40 | return; 41 | } 42 | 43 | cache.delete(asyncId); 44 | 45 | if (!cached.start) { 46 | return; 47 | } 48 | 49 | const diff = process.hrtime(cached.start); 50 | const diffNs = diff[0] * 1e9 + diff[1]; 51 | if (diffNs > THRESHOLD_NS) { 52 | const time = diffNs / 1e6; // in ms 53 | 54 | log.warn({ 55 | msg: `Event loop blocked for ${time.toFixed(2)}ms`, 56 | info: cached, 57 | }); 58 | } 59 | } 60 | 61 | export const eventLoopMonitor = (() => { 62 | const hook = createHook({init, before, after, destroy}); 63 | 64 | return { 65 | enable: () => { 66 | log.info({msg: 'Initializing event loop monitor'}); 67 | 68 | hook.enable(); 69 | }, 70 | disable: () => { 71 | log.info({msg: 'Disabling event loop monitor'}); 72 | 73 | hook.disable(); 74 | }, 75 | }; 76 | })(); 77 | -------------------------------------------------------------------------------- /packages/lib/src/transport/instrumented-transport.ts: -------------------------------------------------------------------------------- 1 | import {context} from '../context.js'; 2 | import {getReadableError} from '../errors.js'; 3 | import type {Observer} from '../subject.js'; 4 | import type {Unsubscribe} from '../utils.js'; 5 | import type { 6 | Connection, 7 | TransportClient, 8 | TransportServer, 9 | } from './transport.js'; 10 | 11 | export class InstrumentedConnection implements Connection { 12 | constructor(private readonly connection: Connection) {} 13 | async send(message: T): Promise { 14 | context().addEvent('info', 'connection.send'); 15 | return await this.connection.send(message); 16 | } 17 | subscribe(observer: Observer): Unsubscribe { 18 | return this.connection.subscribe(observer); 19 | } 20 | close(reason: unknown): void { 21 | context().addEvent( 22 | 'info', 23 | 'transport.close: ' + getReadableError(reason) 24 | ); 25 | this.connection.close(reason); 26 | } 27 | } 28 | 29 | export class InstrumentedTransportClient implements TransportClient { 30 | constructor(private readonly client: TransportClient) {} 31 | async connect(): Promise> { 32 | return new InstrumentedConnection(await this.client.connect()); 33 | } 34 | } 35 | 36 | export class InstrumentedTransportServer implements TransportServer { 37 | constructor(private readonly server: TransportServer) {} 38 | async launch(cb: (connection: Connection) => void): Promise { 39 | return await this.server.launch(connection => { 40 | return cb(new InstrumentedConnection(connection)); 41 | }); 42 | } 43 | close(reason: unknown): void { 44 | context().addEvent( 45 | 'info', 46 | 'transport.close: ' + getReadableError(reason) 47 | ); 48 | this.server.close(reason); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/Avatar.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | {#if !imageUrl} 47 |
52 | {name.slice(0, 2)?.toUpperCase() ?? 'UN'} 53 |
54 | {:else} 55 |
56 | {name} 61 |
62 | {/if} 63 | -------------------------------------------------------------------------------- /packages/lib/src/transport/rpc-transport.ts: -------------------------------------------------------------------------------- 1 | import type {Observer} from '../subject.js'; 2 | import {checkValue} from '../type.js'; 3 | import {type Unsubscribe} from '../utils.js'; 4 | import {RpcMessage} from './rpc-message.js'; 5 | import type { 6 | Connection, 7 | TransportClient, 8 | TransportServer, 9 | } from './transport.js'; 10 | 11 | const messageSchema = RpcMessage(); 12 | 13 | export class RpcConnection implements Connection { 14 | constructor(private readonly conn: Connection) {} 15 | 16 | async send(message: RpcMessage): Promise { 17 | await this.conn.send(this.parse(message)); 18 | } 19 | 20 | subscribe(observer: Observer): Unsubscribe { 21 | return this.conn.subscribe({ 22 | next: message => observer.next(this.parse(message)), 23 | throw: error => observer.throw(error), 24 | close: reason => observer.close(reason), 25 | }); 26 | } 27 | close(reason: unknown): void { 28 | this.conn.close(reason); 29 | } 30 | 31 | private parse(data: unknown) { 32 | return checkValue(messageSchema, data); 33 | } 34 | } 35 | 36 | export class RpcTransportServer implements TransportServer { 37 | constructor(private readonly server: TransportServer) {} 38 | 39 | async launch(cb: (connection: RpcConnection) => void): Promise { 40 | await this.server.launch(conn => { 41 | cb(new RpcConnection(conn)); 42 | }); 43 | } 44 | close(reason: unknown): void { 45 | this.server.close(reason); 46 | } 47 | } 48 | 49 | export class RpcTransportClient implements TransportClient { 50 | constructor(private readonly client: TransportClient) {} 51 | 52 | async connect(): Promise { 53 | const conn = await this.client.connect(); 54 | return new RpcConnection(conn); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/lib/src/kv/tx-controller.ts: -------------------------------------------------------------------------------- 1 | import {Channel} from 'async-channel'; 2 | import {Deferred} from '../deferred.js'; 3 | import type {Condition, Snapshot, Transaction} from './kv-store.js'; 4 | 5 | export class SnapController { 6 | private snap: Snapshot | undefined = undefined; 7 | private doneSignal = new Deferred(); 8 | private snapQueue = new Channel>(); 9 | 10 | async use(snap: Snapshot) { 11 | await this.snapQueue.push(snap); 12 | } 13 | 14 | async accept() { 15 | this.snap = await this.snapQueue.get(); 16 | } 17 | 18 | async get(key: K) { 19 | return this.snap!.get(key); 20 | } 21 | 22 | query(condition: Condition) { 23 | return this.snap!.query(condition); 24 | } 25 | 26 | done() { 27 | this.snapQueue.close(); 28 | this.doneSignal.resolve(); 29 | } 30 | 31 | result() { 32 | return this.doneSignal.promise; 33 | } 34 | } 35 | 36 | export class TxController { 37 | private tx: Transaction | undefined = undefined; 38 | private txQueue = new Channel>(); 39 | private doneSignal = new Deferred(); 40 | 41 | async get(key: K) { 42 | return this.tx!.get(key); 43 | } 44 | 45 | query(condition: Condition) { 46 | return this.tx!.query(condition); 47 | } 48 | 49 | done() { 50 | this.txQueue.close(); 51 | this.doneSignal.resolve(); 52 | } 53 | 54 | result() { 55 | return this.doneSignal.promise; 56 | } 57 | 58 | async use(tx: Transaction) { 59 | await this.txQueue.push(tx); 60 | } 61 | 62 | async accept() { 63 | this.tx = await this.txQueue.get(); 64 | } 65 | 66 | async put(key: K, value: V) { 67 | return this.tx!.put(key, value); 68 | } 69 | 70 | async delete(key: K) { 71 | return this.tx!.delete(key); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/dropdown.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | position: relative; 3 | display: inline-block; 4 | } 5 | 6 | .dropdown__trigger { 7 | list-style: none; 8 | cursor: pointer; 9 | } 10 | 11 | .dropdown__trigger::-webkit-details-marker { 12 | display: none; 13 | } 14 | 15 | .dropdown__content { 16 | position: absolute; 17 | 18 | background: var(--color-material-floating); 19 | border: 1px solid var(--color-divider-floating); 20 | border-radius: var(--radius-md); 21 | 22 | display: flex; 23 | flex-direction: column; 24 | align-items: start; 25 | 26 | box-shadow: var(--shadow-sm); 27 | 28 | min-width: 26ch; 29 | 30 | z-index: 900; 31 | 32 | padding: 0.3em; 33 | } 34 | 35 | .dropdown__content--bottom-start { 36 | top: calc(100% + 2px); 37 | left: 0; 38 | } 39 | .dropdown__content--bottom-end { 40 | top: calc(100% + 2px); 41 | right: 0; 42 | } 43 | .dropdown__content--top-start { 44 | bottom: calc(100% + 2px); 45 | left: 0; 46 | } 47 | .dropdown__content--top-end { 48 | bottom: calc(100% + 2px); 49 | right: 0; 50 | } 51 | 52 | .dropdown__item { 53 | cursor: pointer; 54 | pointer-events: auto; 55 | 56 | display: inline-flex; 57 | gap: var(--btn-gap, 0.4em); 58 | justify-content: start; 59 | align-items: center; 60 | 61 | border-radius: var(--radius-sm); 62 | 63 | padding: 0.3em 0.6em; 64 | 65 | width: 100%; 66 | 67 | &:where(:has(svg)) { 68 | text-align: start; 69 | 70 | img, 71 | svg { 72 | block-size: var(--btn-icon-size, 1.4em); 73 | inline-size: var(--btn-icon-size, 1.4em); 74 | max-inline-size: unset; 75 | } 76 | } 77 | 78 | &:hover { 79 | background-color: var(--color-material-floating-hover); 80 | } 81 | 82 | &:disabled { 83 | cursor: not-allowed; 84 | pointer-events: none; 85 | 86 | color: var(--color-ink-disabled); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.30", 3 | "name": "syncwave", 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "type": "module", 8 | "engine": { 9 | "node": ">=20.0.0" 10 | }, 11 | "scripts": { 12 | "start": "tsx ./packages/scripts/src/playground.ts | tsx ./packages/scripts/src/log-prettify.ts", 13 | "toolbox": "dotenv -- tsx --import ./packages/server/src/instrumentation.ts ./packages/scripts/src/toolbox.ts | tsx ./packages/scripts/src/log-prettify.ts", 14 | "bootstrap": "npm install && npm install --workspaces && npm run build", 15 | "test": "npm test --workspaces", 16 | "build": "npm run build --workspaces", 17 | "dev": "run-p -l srv app dat", 18 | "srv": "npm run dev --workspace=syncwave-server", 19 | "app": "npm run dev --workspace=syncwave-app", 20 | "dat": "npm run dev --workspace=syncwave", 21 | "release": "bumpp" 22 | }, 23 | "devDependencies": { 24 | "@eslint/js": "^9.23.0", 25 | "@typescript-eslint/parser": "^8.29.0", 26 | "bumpp": "^10.2.3", 27 | "dotenv-cli": "^8.0.0", 28 | "editorconfig": "^2.0.0", 29 | "eslint": "^9.23.0", 30 | "eslint-config-prettier": "^10.1.1", 31 | "eslint-plugin-n": "^17.17.0", 32 | "eslint-plugin-prettier": "^5.2.5", 33 | "npm-run-all": "^4.1.5", 34 | "prettier": "^3.5.3", 35 | "prettier-plugin-organize-imports": "^3.2.3", 36 | "rimraf": "^6.0.1", 37 | "tsx": "^4.15.6", 38 | "typescript": "^5.8.2", 39 | "typescript-eslint": "^8.29.0", 40 | "vite": "^6.2.1", 41 | "vitest": "^2.0.5" 42 | }, 43 | "prettier": { 44 | "bracketSpacing": false, 45 | "arrowParens": "avoid", 46 | "useTabs": false, 47 | "singleQuote": true, 48 | "trailingComma": "es5", 49 | "printWidth": 80, 50 | "plugins": [ 51 | "prettier-plugin-organize-imports" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/app/src/lib/styles/input.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --input--avatar-size: 4rem; 3 | } 4 | 5 | .input { 6 | background-color: var(--input-background-color, var(--color-background)); 7 | color: var(--input-color, var(--color-ink)); 8 | 9 | height: var(--input-height, 2.5em); 10 | 11 | border-radius: var(--input-border-radius, 0.4em); 12 | border: var(--input-border-size, 0px) solid 13 | var(--input-border-color, var(--color-divider-object)); 14 | 15 | pointer-events: auto; 16 | 17 | font-size: 1em; 18 | font-weight: 400; 19 | 20 | padding: var(--input-padding, 0); 21 | 22 | &::placeholder { 23 | color: var(--color-ink-placeholder); 24 | } 25 | 26 | &:focus { 27 | outline: none; 28 | } 29 | 30 | &.input--no-focus { 31 | outline: none; 32 | } 33 | 34 | &.input--bordered { 35 | --input-padding: 0 0.75em; 36 | --input-border-size: 1px; 37 | } 38 | 39 | &.input--text-area { 40 | --input-height: auto; 41 | } 42 | } 43 | 44 | .input--block { 45 | @apply rounded-sm border border-divider-object; 46 | } 47 | 48 | .input--avatar { 49 | position: relative; 50 | 51 | display: grid; 52 | aspect-ratio: 1 / 1; 53 | place-items: center; 54 | 55 | height: var(--input--avatar-size); 56 | width: var(--input--avatar-size); 57 | 58 | border: 1px solid var(--color-divider-object); 59 | border-radius: 100%; 60 | 61 | input[type='file'] { 62 | display: none; 63 | } 64 | 65 | img { 66 | border-radius: 100%; 67 | 68 | object-fit: cover; 69 | 70 | height: calc(var(--input--avatar-size) - 2px); 71 | width: calc(var(--input--avatar-size) - 2px); 72 | } 73 | } 74 | 75 | .input--avatar__icon { 76 | --icon-size: calc(var(--input--avatar-size) / 3); 77 | } 78 | 79 | .input--avatar__remove-btn { 80 | position: absolute; 81 | 82 | right: 0; 83 | 84 | border: 1px solid var(--color-divider-object); 85 | 86 | transform: translateX(50%); 87 | } 88 | -------------------------------------------------------------------------------- /packages/lib/src/base64.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {decodeBase64, encodeBase64, type Base64} from './base64.js'; 3 | 4 | describe('base64 encode / decode', () => { 5 | describe('decode', () => { 6 | it('should decode a Uint8Array into a base64 string', () => { 7 | const buf = new Uint8Array([72, 101, 108, 108, 111]); 8 | const expected = 'SGVsbG8='; 9 | expect(decodeBase64(buf)).toBe(expected); 10 | }); 11 | 12 | it('should decode an empty Uint8Array into an empty string', () => { 13 | expect(decodeBase64(new Uint8Array([]))).toBe(''); 14 | }); 15 | 16 | it('should handle large Uint8Array inputs correctly', () => { 17 | const buf = new Uint8Array([0, 127, 255, 128, 64, 32]); 18 | const expected = 'AH//gEAg'; 19 | expect(decodeBase64(buf)).toBe(expected); 20 | }); 21 | }); 22 | 23 | describe('encode', () => { 24 | it('should encode a base64 string into Uint8Array', () => { 25 | const base64String = 'SGVsbG8=' as Base64; 26 | const expected = new Uint8Array([72, 101, 108, 108, 111]); 27 | expect(encodeBase64(base64String)).toEqual(expected); 28 | }); 29 | 30 | it('should encode an empty base64 string into an empty Uint8Array', () => { 31 | expect(encodeBase64('' as Base64)).toEqual(new Uint8Array([])); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('Base64 utility functions', () => { 37 | it('should decode a Uint8Array into a base64 string', () => { 38 | const buf = new Uint8Array([72, 101, 108, 108, 111]); 39 | const expected = 'SGVsbG8='; 40 | expect(decodeBase64(buf)).toBe(expected); 41 | }); 42 | 43 | it('should encode a base64 string into Uint8Array', () => { 44 | const base64String = 'SGVsbG8=' as Base64; 45 | const expected = new Uint8Array([72, 101, 108, 108, 111]); 46 | expect(encodeBase64(base64String)).toEqual(expected); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/www/public/assets/images/brand-logos/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | 16 | 17 | 21 | 22 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/Dropdown.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 | 67 | -------------------------------------------------------------------------------- /packages/lib/src/transport/transport.ts: -------------------------------------------------------------------------------- 1 | import {AppError} from '../errors.js'; 2 | import {Channel, toStream} from '../stream.js'; 3 | import type {Observer} from '../subject.js'; 4 | import type {Unsubscribe} from '../utils.js'; 5 | 6 | export interface TransportServer { 7 | launch(cb: (connection: Connection) => void): Promise; 8 | close(reason: unknown): void; 9 | } 10 | 11 | export class TransportServerUnreachableError extends AppError {} 12 | 13 | export interface TransportClient { 14 | connect(): Promise>; 15 | } 16 | 17 | export interface BaseConnectionEvent { 18 | readonly type: TType; 19 | } 20 | 21 | export interface CloseConnectionEvent extends BaseConnectionEvent<'close'> {} 22 | 23 | export interface MessageConnectionEvent 24 | extends BaseConnectionEvent<'message'> { 25 | readonly message: T; 26 | } 27 | 28 | export interface Connection { 29 | send(message: T): Promise; 30 | subscribe(observer: Observer): Unsubscribe; 31 | close(reason: unknown): void; 32 | } 33 | 34 | export class ConnectionThrowError extends AppError {} 35 | 36 | export class ConnectionClosedError extends AppError { 37 | ignore = false; 38 | } 39 | 40 | export function catchConnectionClosed( 41 | promise: Promise 42 | ): Promise { 43 | return promise.catch(error => { 44 | if (error instanceof ConnectionClosedError) { 45 | error.ignore = true; 46 | return; 47 | } 48 | throw error; 49 | }); 50 | } 51 | 52 | export function getMessageStream(connection: Connection) { 53 | return toStream(_getMessageAsyncIterable(connection)); 54 | } 55 | 56 | async function* _getMessageAsyncIterable(connection: Connection) { 57 | const channel = new Channel(); 58 | 59 | const unsub = connection.subscribe({ 60 | next: value => channel.next(value), 61 | throw: error => channel.throw(error), 62 | close: () => channel.end(), 63 | }); 64 | 65 | try { 66 | yield* channel; 67 | } finally { 68 | unsub('_getMessageAsyncIterable finally'); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/lib/src/context.spec.ts: -------------------------------------------------------------------------------- 1 | import './test-instrumentation.js'; 2 | 3 | import opentelemetry, {propagation, trace} from '@opentelemetry/api'; 4 | import {describe, expect, it} from 'vitest'; 5 | import {context} from './context.js'; 6 | 7 | describe('Context', () => { 8 | it.skip('should extract context', () => { 9 | const [ctx] = context().createDetached({ 10 | span: 'test', 11 | attributes: {val: 'works'}, 12 | }); 13 | 14 | const extracted = ctx.extract(); 15 | 16 | expect(extracted.traceparent).not.toEqual(''); 17 | expect(extracted.tracestate).toEqual(''); 18 | }); 19 | 20 | it.skip('should create context', () => { 21 | const tracer = opentelemetry.trace.getTracer('syncwave'); 22 | const span = tracer.startSpan( 23 | 'some span', 24 | { 25 | root: true, 26 | attributes: {}, 27 | }, 28 | undefined 29 | ); 30 | const traceId = span.spanContext().traceId; 31 | 32 | expect(traceId).not.toEqual('00000000000000000000000000000000'); 33 | 34 | const carrier = {traceparent: '', tracestate: ''}; 35 | const spanCtx = trace.setSpan(opentelemetry.context.active(), span); 36 | propagation.inject(spanCtx, carrier); 37 | 38 | expect(carrier.traceparent).not.toEqual(''); 39 | expect(carrier.tracestate).toEqual(''); 40 | }); 41 | 42 | it('should end child after promise finishes', async () => { 43 | let childTraceId: string | undefined = undefined; 44 | let currentIndex = 0; 45 | let calledIndex = 0; 46 | const promise = context().runChild({span: 'child'}, async () => { 47 | context().onEnd(() => { 48 | calledIndex = currentIndex; 49 | }); 50 | currentIndex = 1; 51 | await new Promise(resolve => setTimeout(resolve, 2)); 52 | childTraceId = context().traceId; 53 | currentIndex = 2; 54 | }); 55 | currentIndex = 3; 56 | await promise; 57 | currentIndex = 4; 58 | 59 | expect(calledIndex).toEqual(2); 60 | expect(childTraceId).not.toEqual(undefined); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/lib/src/kv/collection.ts: -------------------------------------------------------------------------------- 1 | import {type Codec} from '../codec.js'; 2 | import {context} from '../context.js'; 3 | import {Stream, toStream} from '../stream.js'; 4 | import {NumberPacker} from '../tuple.js'; 5 | import {pipe, whenAll} from '../utils.js'; 6 | import {Counter} from './counter.js'; 7 | import { 8 | type AppTransaction, 9 | type Transaction, 10 | isolate, 11 | withCodec, 12 | withPacker, 13 | } from './kv-store.js'; 14 | 15 | export interface CollectionEntry { 16 | readonly offset: number; 17 | readonly data: T; 18 | } 19 | 20 | export class Collection { 21 | private readonly counter: Counter; 22 | private readonly log: Transaction; 23 | 24 | constructor(tx: AppTransaction, codec: Codec) { 25 | this.counter = new Counter(pipe(tx, isolate(['i'])), 0); 26 | this.log = pipe( 27 | tx, 28 | isolate(['l']), 29 | withPacker(new NumberPacker()), 30 | withCodec(codec) 31 | ); 32 | } 33 | 34 | async length() { 35 | return await context().runChild( 36 | {span: 'collection.length'}, 37 | async () => { 38 | return await this.counter.get(); 39 | } 40 | ); 41 | } 42 | 43 | async append(...data: T[]): Promise { 44 | await context().runChild({span: 'collection.append'}, async () => { 45 | const offset = await this.counter.get(); 46 | await whenAll([ 47 | this.counter.set(offset + data.length), 48 | ...data.map((x, idx) => this.log.put(offset + idx, x)), 49 | ]); 50 | }); 51 | } 52 | 53 | list(start: number, end?: number): Stream> { 54 | return context().runChild({span: 'collection.list'}, () => { 55 | return toStream(this._list(start, end)); 56 | }); 57 | } 58 | 59 | private async *_list( 60 | start: number, 61 | end?: number 62 | ): AsyncIterable> { 63 | for await (const {key, value} of this.log.query({gte: start})) { 64 | if (end !== undefined && key >= end) return; 65 | 66 | yield {offset: key, data: value}; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/lib/src/codec.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, it} from 'vitest'; 2 | import {MsgpackCodec} from './codec.js'; 3 | import {createUuid} from './uuid.js'; 4 | 5 | const testData = [ 6 | {input: null, description: 'null value'}, 7 | {input: undefined, description: 'undefined value'}, 8 | {input: true, description: 'boolean true'}, 9 | {input: false, description: 'boolean false'}, 10 | {input: 42, description: 'integer'}, 11 | {input: 3.14, description: 'floating-point number'}, 12 | {input: 'Hello, world!', description: 'string'}, 13 | {input: [1, 2, 3], description: 'array of numbers'}, 14 | {input: {key: 'value'}, description: 'simple object'}, 15 | {input: {nested: {key: 'value'}}, description: 'nested object'}, 16 | {input: [true, null, {key: 42}], description: 'mixed array'}, 17 | {input: Buffer.from('buffer data'), description: 'Buffer object'}, 18 | {input: Buffer.from('buffer data'), description: 'Buffer object'}, 19 | ]; 20 | 21 | describe('MsgpackrCode', () => { 22 | const codec = new MsgpackCodec(); 23 | 24 | describe('round-trip', () => { 25 | testData.forEach(({input, description}) => { 26 | it(`should correctly encode and decode ${description}`, () => { 27 | const encoded = codec.encode(input); 28 | const decoded = codec.decode(encoded); 29 | expect(decoded).toEqual(input); 30 | expect(encoded).toBeInstanceOf(Uint8Array); 31 | }); 32 | }); 33 | 34 | it('uuid', () => { 35 | const uuid = createUuid(); 36 | const encoded = codec.encode({uuid}); 37 | const {uuid: result} = codec.decode(encoded); 38 | 39 | expect(result).toEqual(uuid); 40 | }); 41 | }); 42 | 43 | describe('edge cases', () => { 44 | it('should throw an error for invalid input to decode', () => { 45 | const invalidData = new Uint8Array([255, 255, 255]); 46 | expect(() => codec.decode(invalidData)).toThrow(); 47 | }); 48 | 49 | it('should handle empty buffer during decode', () => { 50 | const emptyBuffer = new Uint8Array(); 51 | expect(() => codec.decode(emptyBuffer)).toThrow(); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/Select.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | 29 | {@render children?.()} 30 | 31 | 32 | 40 | 41 | {#each options as option (option.value)} 42 | 48 | {#snippet children({selected})} 49 | {option.label} 50 | {#if selected} 51 | 52 | 53 | 54 | {/if} 55 | {/snippet} 56 | 57 | {/each} 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /packages/app/src/lib/managers/modal-manager.svelte.ts: -------------------------------------------------------------------------------- 1 | import type {Snippet} from 'svelte'; 2 | import router from '../router'; 3 | 4 | class ModalManager { 5 | private view = $state(null); 6 | 7 | private history: Snippet[] = []; 8 | 9 | private isOpen = $state(false); 10 | 11 | public open(initialView: Snippet): void { 12 | console.log('Opening command center'); 13 | if (this.isOpen) { 14 | return; 15 | } 16 | 17 | this.isOpen = true; 18 | this.view = initialView; 19 | this.history = [initialView]; 20 | 21 | router.action(() => this.handleBack(), true, 'modal'); 22 | console.log('Registered command center action'); 23 | } 24 | 25 | /** 26 | * Navigates to a new view within the command center. 27 | * @param view The new view to display 28 | * @param replaceView Whether to replace the current view in history 29 | */ 30 | public navigate(view: Snippet, replaceView: boolean = false): void { 31 | if (!this.isOpen) { 32 | this.open(view); 33 | return; 34 | } 35 | 36 | this.view = view; 37 | 38 | if (replaceView && this.history.length > 0) { 39 | this.history[this.history.length - 1] = view; 40 | } else { 41 | this.history.push(view); 42 | 43 | router.action(() => this.handleBack(), true, 'modal'); 44 | } 45 | } 46 | 47 | /** 48 | * Handles back navigation within the command center. 49 | * @returns true if navigation was handled, false if we should close 50 | */ 51 | private handleBack(): boolean { 52 | this.history.pop(); 53 | 54 | if (this.history.length > 0) { 55 | this.view = this.history[this.history.length - 1]; 56 | return true; 57 | } 58 | 59 | this.close(); 60 | return false; 61 | } 62 | 63 | public close(): void { 64 | this.isOpen = false; 65 | this.view = null; 66 | this.history = []; 67 | 68 | router.clearByType('modal'); 69 | } 70 | 71 | public getView(): Snippet | null { 72 | return this.view; 73 | } 74 | 75 | public getIsOpen(): boolean { 76 | return this.isOpen; 77 | } 78 | } 79 | 80 | const manager = new ModalManager(); 81 | export default manager; 82 | -------------------------------------------------------------------------------- /packages/lib/src/auth/google.ts: -------------------------------------------------------------------------------- 1 | import {log} from '../logger.js'; 2 | 3 | export interface GoogleOptions { 4 | readonly appUrl: string; 5 | 6 | readonly clientId: string; 7 | readonly clientSecret: string; 8 | readonly redirectUri: string; 9 | } 10 | 11 | export interface GoogleUser { 12 | readonly id?: string; 13 | readonly email?: string; 14 | readonly verified_email?: boolean; 15 | readonly picture?: unknown; 16 | readonly displayName?: string; 17 | } 18 | 19 | export type GetGoogleUserResult = 20 | | {type: 'success'; user: GoogleUser} 21 | | {type: 'error'}; 22 | 23 | export async function getGoogleUser( 24 | code: string, 25 | options: GoogleOptions 26 | ): Promise { 27 | try { 28 | const tokens = await getTokens(code, options); 29 | const user = await fetch( 30 | `https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=${tokens.access_token}`, 31 | { 32 | headers: { 33 | Authorization: `Bearer ${tokens.id_token}`, 34 | }, 35 | } 36 | ).then(x => x.json() as GoogleUser); 37 | if (typeof user?.id !== 'string' || typeof user?.email !== 'string') { 38 | return {type: 'error'}; 39 | } 40 | 41 | return {type: 'success', user}; 42 | } catch (error: unknown) { 43 | log.error({error, msg: 'Cannot get google user'}); 44 | return {type: 'error'}; 45 | } 46 | } 47 | 48 | interface GoogleToken { 49 | access_token: string; 50 | expires_in: number; 51 | refresh_token: string; 52 | scope: string; 53 | id_token: string; 54 | } 55 | 56 | async function getTokens( 57 | code: string, 58 | {clientId, clientSecret, redirectUri}: GoogleOptions 59 | ): Promise { 60 | const result = await fetch('https://oauth2.googleapis.com/token', { 61 | method: 'post', 62 | body: new URLSearchParams({ 63 | code, 64 | client_id: clientId, 65 | client_secret: clientSecret, 66 | redirect_uri: redirectUri, 67 | grant_type: 'authorization_code', 68 | }).toString(), 69 | headers: { 70 | 'Content-Type': 'application/x-www-form-urlencoded', 71 | }, 72 | }).then(x => x.json() as Promise); 73 | 74 | return result; 75 | } 76 | -------------------------------------------------------------------------------- /packages/www/src/scripts/index.js: -------------------------------------------------------------------------------- 1 | const RESPONSIVE_WIDTH = 1024; 2 | 3 | let isHeaderCollapsed = window.innerWidth < RESPONSIVE_WIDTH; 4 | const collapseBtn = document.getElementById('collapse-btn'); 5 | const collapseHeaderItems = document.getElementById('collapsed-header-items'); 6 | 7 | function onHeaderClickOutside(e) { 8 | if (!collapseHeaderItems.contains(e.target)) { 9 | toggleHeader(); 10 | } 11 | } 12 | 13 | function toggleHeader() { 14 | if (isHeaderCollapsed) { 15 | // collapseHeaderItems.classList.remove("max-md:tw-opacity-0") 16 | collapseHeaderItems.classList.add('opacity-100'); 17 | collapseHeaderItems.style.width = '60vw'; 18 | collapseBtn.classList.remove('bi-list'); 19 | collapseBtn.classList.add('bi-x', 'max-lg:tw-fixed'); 20 | isHeaderCollapsed = false; 21 | 22 | setTimeout( 23 | () => window.addEventListener('click', onHeaderClickOutside), 24 | 1 25 | ); 26 | } else { 27 | collapseHeaderItems.classList.remove('opacity-100'); 28 | collapseHeaderItems.style.width = '0vw'; 29 | collapseBtn.classList.remove('bi-x', 'max-lg:tw-fixed'); 30 | collapseBtn.classList.add('bi-list'); 31 | isHeaderCollapsed = true; 32 | window.removeEventListener('click', onHeaderClickOutside); 33 | } 34 | } 35 | 36 | window.toggleHeader = toggleHeader; 37 | 38 | function responsive() { 39 | if (window.innerWidth > RESPONSIVE_WIDTH) { 40 | collapseHeaderItems.style.width = ''; 41 | } else { 42 | isHeaderCollapsed = true; 43 | } 44 | } 45 | 46 | window.addEventListener('resize', responsive); 47 | 48 | /** 49 | * Animations 50 | */ 51 | 52 | const faqAccordion = document.querySelectorAll('.faq-accordion'); 53 | 54 | faqAccordion.forEach(function (btn) { 55 | btn.addEventListener('click', function () { 56 | this.classList.toggle('active'); 57 | 58 | // Toggle 'rotate' class to rotate the arrow 59 | let content = this.nextElementSibling; 60 | 61 | // content.classList.toggle('!tw-hidden') 62 | if (content.style.maxHeight === '200px') { 63 | content.style.maxHeight = '0px'; 64 | content.style.padding = '0px 18px'; 65 | } else { 66 | content.style.maxHeight = '200px'; 67 | content.style.padding = '20px 18px'; 68 | } 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/lib/src/job-manager.ts: -------------------------------------------------------------------------------- 1 | import {type Cancel, Context} from './context.js'; 2 | import {AppError} from './errors.js'; 3 | import {log} from './logger.js'; 4 | 5 | interface Job { 6 | readonly ctx: Context; 7 | readonly end: Cancel; 8 | } 9 | 10 | export class JobManager { 11 | private readonly runningJobs = new Map(); 12 | private readonly cancelledJobs = new Map(); 13 | 14 | constructor() {} 15 | 16 | async start( 17 | id: T, 18 | ctx: Context, 19 | end: Cancel, 20 | fn: () => Promise 21 | ): Promise { 22 | if (this.runningJobs.has(id)) { 23 | throw new AppError(`job ${id} is already running`); 24 | } else if (this.cancelledJobs.has(id)) { 25 | throw new AppError(`job ${id} is already finished`); 26 | } else { 27 | this.runningJobs.set(id, {ctx, end}); 28 | await ctx.run(fn); 29 | } 30 | } 31 | 32 | isCancelled(id: T) { 33 | return this.cancelledJobs.has(id); 34 | } 35 | 36 | cancel(id: T, reason: unknown) { 37 | if (this.runningJobs.has(id)) { 38 | const job = this.runningJobs.get(id)!; 39 | this.runningJobs.get(id)!.end(reason); 40 | this.runningJobs.delete(id); 41 | this.cancelledJobs.set(id, job); 42 | } else if (this.cancelledJobs.has(id)) { 43 | const job = this.cancelledJobs.get(id)!; 44 | log.trace({ 45 | msg: `job ${id} is already cancelled, job.traceId = ${job.ctx.traceId}`, 46 | }); 47 | } else { 48 | log.trace({msg: `JobManager.cancel: unknown job: ${id}`}); 49 | } 50 | } 51 | 52 | finish(job: T, reason: unknown) { 53 | if (this.runningJobs.has(job) || this.cancelledJobs.has(job)) { 54 | const runningJob = this.runningJobs.get(job); 55 | if (runningJob) { 56 | runningJob.end(reason); 57 | } 58 | this.runningJobs.delete(job); 59 | this.cancelledJobs.delete(job); 60 | } else { 61 | log.trace({msg: `JobManager.finish: unknown job: ${job}`}); 62 | } 63 | } 64 | 65 | finishAll(reason: unknown) { 66 | const runningSnapshot = [...this.runningJobs.keys()]; 67 | runningSnapshot.forEach(job => this.finish(job, reason)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/app/src/lib/managers/panel-size-manager.svelte.ts: -------------------------------------------------------------------------------- 1 | export type PanelType = 'inbox' | 'card_details' | 'home'; 2 | 3 | export class PanelSizeManager { 4 | private _panelWidths: Record = $state({ 5 | inbox: null, 6 | home: null, 7 | card_details: null, 8 | }); 9 | 10 | private readonly _storagePrefix = 'panel_width_'; 11 | 12 | constructor() { 13 | this.loadAllFromStorage(); 14 | } 15 | 16 | public getWidth(panelType: PanelType): number | null { 17 | return this._panelWidths[panelType]; 18 | } 19 | 20 | public setWidth(panelType: PanelType, width: number): void { 21 | if (width <= 0) { 22 | console.error(`Cannot save invalid panel width for ${panelType}`); 23 | return; 24 | } 25 | 26 | this._panelWidths[panelType] = width; 27 | this.saveToStorage(panelType, width); 28 | } 29 | 30 | private saveToStorage(panelType: PanelType, width: number): void { 31 | try { 32 | localStorage.setItem( 33 | this._getStorageKey(panelType), 34 | width.toString() 35 | ); 36 | } catch (error: unknown) { 37 | console.error( 38 | `Failed to save ${panelType} panel width to local storage:`, 39 | error 40 | ); 41 | } 42 | } 43 | 44 | private loadFromStorage(panelType: PanelType): number | null { 45 | try { 46 | const savedWidth = localStorage.getItem( 47 | this._getStorageKey(panelType) 48 | ); 49 | return savedWidth ? parseInt(savedWidth, 10) : null; 50 | } catch (error: unknown) { 51 | console.error( 52 | `Failed to retrieve ${panelType} panel width from local storage:`, 53 | error 54 | ); 55 | return null; 56 | } 57 | } 58 | 59 | private loadAllFromStorage(): void { 60 | for (const panelType in this._panelWidths) { 61 | const width = this.loadFromStorage(panelType as PanelType); 62 | if (width !== null) { 63 | this._panelWidths[panelType as PanelType] = width; 64 | } 65 | } 66 | } 67 | 68 | private _getStorageKey(panelType: PanelType): string { 69 | return this._storagePrefix + panelType; 70 | } 71 | } 72 | 73 | export const panelSizeManager = new PanelSizeManager(); 74 | -------------------------------------------------------------------------------- /packages/app/src/document-activity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventEmitter, 3 | runAll, 4 | USER_INACTIVITY_TIMEOUT_MS, 5 | type ActivityMonitor, 6 | type Unsubscribe, 7 | } from 'syncwave'; 8 | 9 | export class DocumentActivityMonitor implements ActivityMonitor { 10 | private readonly subs: Array<() => void> = []; 11 | private readonly interval: NodeJS.Timeout; 12 | 13 | documentVisibility: EventEmitter; 14 | active: boolean; 15 | 16 | constructor() { 17 | this.active = document.visibilityState === 'visible'; 18 | this.documentVisibility = new EventEmitter(); 19 | 20 | const handleVisibility = (state: 'active' | 'idle') => { 21 | const currentlyActive = state === 'active'; 22 | if (this.active === currentlyActive) return; 23 | 24 | this.active = currentlyActive; 25 | this.documentVisibility.emit(currentlyActive); 26 | }; 27 | 28 | this.interval = setInterval(() => { 29 | if (performance.now() - lastActivity > USER_INACTIVITY_TIMEOUT_MS) { 30 | handleVisibility('idle'); 31 | } 32 | }, 1000); 33 | 34 | let lastActivity = performance.now(); 35 | 36 | const documentEvents: readonly (keyof DocumentEventMap)[] = [ 37 | 'mousemove', 38 | 'mousedown', 39 | 'keydown', 40 | 'scroll', 41 | 'touchstart', 42 | 'pointerdown', 43 | // this event doesn't mean some activity necessarily, so we use document.visibilityState 44 | // in the listener 45 | 'visibilitychange', 46 | ] as const; 47 | 48 | documentEvents.forEach(type => { 49 | const listener = () => { 50 | lastActivity = performance.now(); 51 | handleVisibility( 52 | document.visibilityState === 'visible' ? 'active' : 'idle' 53 | ); 54 | }; 55 | document.addEventListener(type, listener, { 56 | passive: true, 57 | }); 58 | this.subs.push(() => { 59 | document.removeEventListener(type, listener); 60 | }); 61 | }); 62 | } 63 | 64 | subscribe(callback: (active: boolean) => void): Unsubscribe { 65 | return this.documentVisibility.subscribe(callback); 66 | } 67 | 68 | destroy() { 69 | this.documentVisibility.close(); 70 | runAll(this.subs); 71 | clearInterval(this.interval); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/lib/src/data/email-service.ts: -------------------------------------------------------------------------------- 1 | import type {Config, DataEffectScheduler} from './data-layer.js'; 2 | import type {EmailProvider} from './infrastructure.js'; 3 | 4 | export class EmailService { 5 | constructor( 6 | private readonly emailProvider: EmailProvider, 7 | private readonly scheduleEffect: DataEffectScheduler, 8 | private readonly config: Config 9 | ) {} 10 | 11 | scheduleSignInEmail(params: {email: string; code: string}) { 12 | this.scheduleEffect(async () => { 13 | await this.emailProvider.send({ 14 | recipient: params.email, 15 | html: `

16 | Hi there!
17 |
18 | We noticed a request to sign into your Syncwave account.
19 | If this wasn't you, no worries—just ignore this email.
20 |
21 | Your one-time code is (expires in 10 minutes): ${params.code}
22 |
23 | Have a great day!
24 | The Syncwave Team 25 |

`, 26 | subject: 'Your Syncwave Account Sign-In Code', 27 | text: `Hi there! 28 | 29 | We noticed a request to sign into your Syncwave account. If this wasn't you, no worries—just ignore this email. 30 | 31 | Your one-time code is (expires in 10 minutes): ${params.code} 32 | 33 | Have a great day! 34 | The Syncwave Team`, 35 | }); 36 | }); 37 | } 38 | 39 | scheduleInviteEmail(params: { 40 | uiUrl: string; 41 | email: string; 42 | boardName: string; 43 | boardKey: string; 44 | }) { 45 | this.scheduleEffect(async () => { 46 | const boardUrl = `${params.uiUrl}b/${params.boardKey}`; 47 | 48 | const subject = `You got invited to the board ${params.boardName}!`; 49 | await this.emailProvider.send({ 50 | recipient: params.email, 51 | subject, 52 | // todo: generate capability link 53 | text: `You have been invited to join the board ${params.boardName} in Syncwave. Click on the link to accept the invitation: ${boardUrl}`, 54 | html: `

You have been invited to join the board ${params.boardName} in Syncwave. Click on the link to accept the invitation: ${boardUrl}

`, 55 | }); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/lib/src/data/repos/user-repo.ts: -------------------------------------------------------------------------------- 1 | import {type Static, Type} from '@sinclair/typebox'; 2 | import {type CrdtDiff} from '../../crdt/crdt.js'; 3 | import {type AppTransaction, isolate} from '../../kv/kv-store.js'; 4 | import {type Brand} from '../../utils.js'; 5 | import {createUuid, Uuid} from '../../uuid.js'; 6 | import type {DataTriggerScheduler} from '../data-layer.js'; 7 | import {ObjectKey} from '../infrastructure.js'; 8 | import type {TransitionChecker} from '../transition-checker.js'; 9 | import { 10 | CrdtRepo, 11 | type OnCrdtChange, 12 | type QueryOptions, 13 | type Recipe, 14 | } from './base/crdt-repo.js'; 15 | import {Doc} from './base/doc.js'; 16 | 17 | export function UserId() { 18 | return Uuid(); 19 | } 20 | 21 | export type UserId = Brand; 22 | 23 | export function createUserId(): UserId { 24 | return createUuid() as UserId; 25 | } 26 | 27 | export function User() { 28 | return Type.Composite([ 29 | Doc(Type.Tuple([Uuid()])), 30 | Type.Object({ 31 | id: Uuid(), 32 | fullName: Type.String(), 33 | avatarKey: Type.Optional(ObjectKey()), 34 | isDemo: Type.Boolean(), 35 | }), 36 | ]); 37 | } 38 | 39 | export interface User extends Static> {} 40 | 41 | export class UserRepo { 42 | public readonly rawRepo: CrdtRepo; 43 | 44 | constructor(params: { 45 | tx: AppTransaction; 46 | onChange: OnCrdtChange; 47 | scheduleTrigger: DataTriggerScheduler; 48 | }) { 49 | this.rawRepo = new CrdtRepo({ 50 | tx: isolate(['d'])(params.tx), 51 | onChange: params.onChange, 52 | indexes: {}, 53 | schema: User(), 54 | constraints: [], 55 | scheduleTrigger: params.scheduleTrigger, 56 | }); 57 | } 58 | 59 | getById(id: UserId, options?: QueryOptions) { 60 | return this.rawRepo.getById([id], options); 61 | } 62 | 63 | async apply( 64 | id: Uuid, 65 | diff: CrdtDiff, 66 | checker: TransitionChecker 67 | ) { 68 | return await this.rawRepo.apply([id], diff, checker); 69 | } 70 | 71 | async create(user: Omit): Promise { 72 | return this.rawRepo.create({pk: [user.id], ...user}); 73 | } 74 | 75 | update( 76 | id: UserId, 77 | recipe: Recipe, 78 | options?: QueryOptions 79 | ): Promise { 80 | return this.rawRepo.update([id], recipe, options); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/lib/src/cursor.ts: -------------------------------------------------------------------------------- 1 | import {AppError} from './errors.js'; 2 | import {log} from './logger.js'; 3 | 4 | export class Cursor implements AsyncIterable { 5 | private _isConsumed = false; 6 | 7 | constructor(private readonly iter: AsyncIterator) {} 8 | 9 | [Symbol.asyncIterator](): AsyncIterator { 10 | if (this._isConsumed) { 11 | throw new AppError('Cursor already consumed'); 12 | } 13 | this._isConsumed = true; 14 | 15 | return this.iter; 16 | } 17 | 18 | get isConsumed() { 19 | return this._isConsumed; 20 | } 21 | 22 | async first(): Promise { 23 | for await (const item of this) { 24 | return item; 25 | } 26 | 27 | throw new AppError('Cursor.first: stream ended'); 28 | } 29 | 30 | async find( 31 | predicate: (value: T) => Promise | boolean 32 | ): Promise { 33 | for await (const item of this) { 34 | if (await predicate(item)) { 35 | return item; 36 | } 37 | } 38 | 39 | return undefined; 40 | } 41 | 42 | concat(...iterables: AsyncIterable[]): Cursor { 43 | return toCursor(this._concat(...iterables)); 44 | } 45 | 46 | private async *_concat(...iterables: AsyncIterable[]) { 47 | yield* this; 48 | 49 | for (const stream of iterables) { 50 | yield* stream; 51 | } 52 | } 53 | 54 | map(mapper: (value: T) => R | Promise): Cursor { 55 | return toCursor(this._map(mapper)); 56 | } 57 | 58 | private async *_map( 59 | mapper: (value: T) => R | Promise 60 | ): AsyncIterable { 61 | for await (const item of this) { 62 | yield await mapper(item); 63 | } 64 | } 65 | 66 | finally(fn: () => undefined | void): Cursor { 67 | return toCursor(this._finally(fn)); 68 | } 69 | 70 | private async *_finally(fn: () => void) { 71 | try { 72 | yield* this; 73 | } finally { 74 | fn(); 75 | } 76 | } 77 | 78 | close() { 79 | if (this._isConsumed) return; 80 | 81 | this.iter.return?.().catch(error => { 82 | log.error({error, msg: 'failed to close cursor'}); 83 | }); 84 | } 85 | } 86 | 87 | export function toCursor( 88 | source: AsyncIterator | AsyncIterable 89 | ): Cursor { 90 | if (Symbol.asyncIterator in source) { 91 | return new Cursor(source[Symbol.asyncIterator]()); 92 | } 93 | 94 | return new Cursor(source); 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*.*.*' 5 | 6 | name: release 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | deploy: 14 | name: Publish 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: AutoModality/action-clean@v1 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@v3 25 | with: 26 | images: | 27 | syncwave/syncwave 28 | ghcr.io/syncwavedev/syncwave 29 | tags: | 30 | type=semver,pattern={{version}} 31 | type=semver,pattern={{major}}.{{minor}} 32 | type=semver,pattern={{major}} 33 | 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v1 36 | 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v1 39 | 40 | - name: Login to DockerHub 41 | if: github.event_name != 'pull_request' 42 | uses: docker/login-action@v1 43 | with: 44 | username: ${{ secrets.DOCKERHUB_USERNAME }} 45 | password: ${{ secrets.DOCKERHUB_TOKEN }} 46 | 47 | - name: Login to GHCR 48 | if: github.event_name != 'pull_request' 49 | uses: docker/login-action@v1 50 | with: 51 | registry: ghcr.io 52 | username: ${{ github.repository_owner }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Build and push 56 | uses: docker/build-push-action@v2 57 | with: 58 | context: . 59 | file: ./packages/self-hosted/Dockerfile 60 | cache-from: type=registry,ref=ghcr.io/syncwavedev/syncwave-self:self-cache 61 | cache-to: type=registry,mode=max,ref=ghcr.io/syncwavedev/syncwave-self:self-cache 62 | push: ${{ github.event_name != 'pull_request' }} 63 | tags: ${{ steps.meta.outputs.tags }} 64 | labels: ${{ steps.meta.outputs.labels }} 65 | platforms: linux/amd64,linux/arm64/v8 66 | 67 | - name: Create GitHub Release 68 | if: startsWith(github.ref, 'refs/tags/v') 69 | uses: softprops/action-gh-release@v2 70 | with: 71 | tag_name: ${{ github.ref_name }} 72 | name: Syncwave ${{ github.ref_name }} 73 | generate_release_notes: true 74 | body: | 75 | Docker images published with this release: 76 | ${{ steps.meta.outputs.tags }} 77 | 78 | Pull the image: 79 | docker pull syncwave/syncwave:${{ github.ref_name }} 80 | -------------------------------------------------------------------------------- /packages/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth/google.js'; 2 | export * from './awareness.js'; 3 | export * from './batch-processor.js'; 4 | export * from './codec.js'; 5 | export * from './constants.js'; 6 | export * from './context.js'; 7 | export * from './coordinator/auth-api.js'; 8 | export * from './coordinator/coordinator-api.js'; 9 | export * from './coordinator/coordinator-client.js'; 10 | export * from './coordinator/coordinator.js'; 11 | export * from './crdt/crdt.js'; 12 | export * from './crdt/richtext.js'; 13 | export * from './cursor.js'; 14 | export * from './data/auth.js'; 15 | export * from './data/data-layer.js'; 16 | export * from './data/dto.js'; 17 | export * from './data/infrastructure.js'; 18 | export * from './data/join-code.js'; 19 | export * from './data/permission-service.js'; 20 | export * from './data/placement.js'; 21 | export * from './data/repos/account-repo.js'; 22 | export * from './data/repos/attachment-repo.js'; 23 | export * from './data/repos/base/crdt-repo.js'; 24 | export * from './data/repos/board-repo.js'; 25 | export * from './data/repos/card-repo.js'; 26 | export * from './data/repos/column-repo.js'; 27 | export * from './data/repos/member-repo.js'; 28 | export * from './data/repos/message-repo.js'; 29 | export * from './data/repos/user-repo.js'; 30 | export * from './data/write-api.js'; 31 | export * from './deferred.js'; 32 | export * from './e2e-fixture.js'; 33 | export * from './errors.js'; 34 | export * from './event-emitter.js'; 35 | export * from './kv/instrumented-kv-store.js'; 36 | export * from './kv/kv-store-isolator.js'; 37 | export * from './kv/kv-store-mapper.js'; 38 | export * from './kv/kv-store.js'; 39 | export * from './kv/mem-mvcc-store.js'; 40 | export * from './kv/mem-rw-store.js'; 41 | export * from './kv/rw-mvcc-adapter.js'; 42 | export * from './kv/tuple-store.js'; 43 | export * from './logger.js'; 44 | export * from './mutex.js'; 45 | export * from './richtext.js'; 46 | export * from './rw-lock.js'; 47 | export * from './self-hosted-client-config.js'; 48 | export * from './stream.js'; 49 | export * from './subject.js'; 50 | export * from './timestamp.js'; 51 | export * from './transaction-id.js'; 52 | export * from './transport/hub.js'; 53 | export * from './transport/instrumented-transport.js'; 54 | export * from './transport/mem-transport.js'; 55 | export * from './transport/persistent-connection.js'; 56 | export * from './transport/rpc-message.js'; 57 | export * from './transport/rpc-transport.js'; 58 | export * from './transport/rpc.js'; 59 | export * from './transport/transport.js'; 60 | export * from './tuple.js'; 61 | export * from './utils.js'; 62 | export * from './uuid.js'; 63 | -------------------------------------------------------------------------------- /packages/app/src/lib/components/boards/BoardSettingsColumns.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | 76 | -------------------------------------------------------------------------------- /packages/app/src/mem-storage.ts: -------------------------------------------------------------------------------- 1 | export function createMemStorage(): Storage { 2 | const store = new Map(); 3 | 4 | const storage = { 5 | get length() { 6 | return store.size; 7 | }, 8 | 9 | key(index: number): string | null { 10 | const keys = Array.from(store.keys()); 11 | return keys[index] ?? null; 12 | }, 13 | 14 | getItem(key: string): string | null { 15 | return store.get(String(key)) ?? null; 16 | }, 17 | 18 | setItem(key: string, value: string): void { 19 | store.set(String(key), String(value)); 20 | }, 21 | 22 | removeItem(key: string): void { 23 | store.delete(String(key)); 24 | }, 25 | 26 | clear(): void { 27 | store.clear(); 28 | }, 29 | }; 30 | 31 | const protectedKeys = new Set([ 32 | 'getItem', 33 | 'setItem', 34 | 'removeItem', 35 | 'clear', 36 | 'key', 37 | 'length', 38 | ]); 39 | 40 | return new Proxy(storage, { 41 | get(target, prop, receiver) { 42 | if (typeof prop === 'string' && !(prop in target)) { 43 | return target.getItem(prop); 44 | } 45 | return Reflect.get(target, prop, receiver); 46 | }, 47 | 48 | set(target, prop, value) { 49 | const key = String(prop); 50 | if (protectedKeys.has(key)) { 51 | return false; 52 | } 53 | target.setItem(key, String(value)); 54 | return true; 55 | }, 56 | 57 | deleteProperty(target, prop) { 58 | const key = String(prop); 59 | if (protectedKeys.has(key)) { 60 | return false; 61 | } 62 | target.removeItem(key); 63 | return true; 64 | }, 65 | ownKeys() { 66 | return Array.from(store.keys()); 67 | }, 68 | 69 | has(target, prop) { 70 | if (typeof prop === 'string') { 71 | return store.has(prop); 72 | } 73 | return Reflect.has(target, prop); 74 | }, 75 | 76 | getOwnPropertyDescriptor(target, prop) { 77 | if (typeof prop === 'string' && store.has(prop)) { 78 | return { 79 | enumerable: true, 80 | configurable: true, 81 | value: store.get(prop), 82 | writable: true, 83 | }; 84 | } 85 | return Reflect.getOwnPropertyDescriptor(target, prop); 86 | }, 87 | }) as Storage; 88 | } 89 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncwave-server", 3 | "private": true, 4 | "version": "0.0.1", 5 | "main": "dist/cjs/src/index.js", 6 | "module": "dist/esm/src/index.js", 7 | "types": "dist/esm/src/index.d.ts", 8 | "engines": { 9 | "node": ">=22.0.0" 10 | }, 11 | "files": [ 12 | "dist/esm/src", 13 | "dist/esm/package.json", 14 | "dist/cjs/src", 15 | "dist/cjs/package.json", 16 | "src" 17 | ], 18 | "exports": { 19 | ".": { 20 | "import": "./dist/esm/src/index.js", 21 | "require": "./dist/cjs/src/index.js", 22 | "default": "./dist/cjs/src/index.js" 23 | } 24 | }, 25 | "type": "module", 26 | "scripts": { 27 | "clean": "rimraf dist", 28 | "dev": "AWS_DEFAULT_REGION=us-east-1 NODE_OPTIONS='--enable-source-maps' tsx watch --clear-screen=false ./src/entrypoint.ts | tsx ../../packages/scripts/src/log-prettify.ts", 29 | "build": "tsc", 30 | "build:watch": "tsc -w", 31 | "test": "vitest run" 32 | }, 33 | "devDependencies": { 34 | "@types/better-sqlite3": "^7.6.12", 35 | "@types/busboy": "^1.5.4", 36 | "@types/koa": "^2.15.0", 37 | "@types/koa__cors": "^5.0.0", 38 | "@types/koa__router": "^12.0.4", 39 | "@types/koa-static": "^4.0.4", 40 | "@types/node": "^22.10.2", 41 | "@types/ws": "^8.5.13", 42 | "pino-pretty": "^13.0.0", 43 | "rimraf": "^6.0.1", 44 | "tsx": "^4.17.0", 45 | "vitest": "^1.6.0" 46 | }, 47 | "dependencies": { 48 | "@aws-sdk/client-s3": "^3.750.0", 49 | "@aws-sdk/client-ses": "^3.731.1", 50 | "@koa/cors": "^5.0.0", 51 | "@koa/router": "^13.1.0", 52 | "@opentelemetry/api": "^1.9.0", 53 | "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", 54 | "@opentelemetry/resources": "^2.0.0", 55 | "@opentelemetry/sdk-logs": "^0.200.0", 56 | "@opentelemetry/sdk-trace-base": "^2.0.0", 57 | "@opentelemetry/semantic-conventions": "^1.30.0", 58 | "foundationdb": "^2.0.1", 59 | "abstract-level": "^3.1.0", 60 | "better-sqlite3": "^11.9.0", 61 | "busboy": "^1.6.0", 62 | "classic-level": "^3.0.0", 63 | "dotenv": "^16.4.7", 64 | "koa": "^2.15.3", 65 | "koa-helmet": "^8.0.1", 66 | "koa-static": "^5.0.0", 67 | "prom-client": "^15.1.3", 68 | "sharp": "0.33.4", 69 | "sqlite3": "^5.1.7", 70 | "syncwave": "*", 71 | "syncwave-config": "*", 72 | "ts-pattern": "^5.6.2", 73 | "ws": "^8.18.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/app/src/lib/agent/agent.spec.ts: -------------------------------------------------------------------------------- 1 | import {assert, context, Context, E2eFixture, type Unsubscribe} from 'syncwave'; 2 | import {NodeCryptoProvider} from 'syncwave/node-crypto-provider.js'; 3 | import {NodeJwtProvider} from 'syncwave/node-jwt-provider.js'; 4 | import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 5 | import {createMemStorage} from '../../mem-storage'; 6 | import {AuthManager} from '../managers/auth-manager'; 7 | import {Agent} from './agent.svelte'; 8 | 9 | describe('agent', () => { 10 | const email = 'test@test.com'; 11 | let agentA: Agent; 12 | let agentB: Agent; 13 | const now = new Date(); 14 | let ctx: Context; 15 | let endCtx: Unsubscribe; 16 | 17 | async function createAgent(coordinatorFixture: E2eFixture) { 18 | const authManager = new AuthManager(createMemStorage()); 19 | const agent = new Agent( 20 | coordinatorFixture.transportClient, 21 | authManager, 22 | {use: () => ctx}, 23 | { 24 | active: true, 25 | subscribe: () => { 26 | return () => {}; 27 | }, 28 | } 29 | ); 30 | 31 | await agent.sendSignInEmail(email); 32 | 33 | const message = coordinatorFixture.emailProvider.outbox.at(-1); 34 | assert(message !== undefined, 'message expected'); 35 | const code = message.text 36 | .split('\n') 37 | .find(x => x.includes('Your one-time code is')) 38 | ?.split(': ')[1]; 39 | assert(code !== undefined, 'code expected'); 40 | const token = await agent.verifySignInCode({email, code}); 41 | 42 | assert(token.type === 'success', 'token expected'); 43 | 44 | authManager.logIn(token.token, {pageReload: false}); 45 | 46 | return agent; 47 | } 48 | 49 | beforeEach(async () => { 50 | vi.useFakeTimers(); 51 | vi.setSystemTime(now); 52 | [ctx, endCtx] = context().createDetached({span: 'test'}); 53 | const coordinatorFixture = await E2eFixture.start({ 54 | jwtProvider: new NodeJwtProvider('test-secret'), 55 | cryptoService: NodeCryptoProvider, 56 | }); 57 | agentA = await createAgent(coordinatorFixture); 58 | agentB = await createAgent(coordinatorFixture); 59 | }); 60 | 61 | afterEach(() => { 62 | endCtx('test end'); 63 | vi.useRealTimers(); 64 | agentA.close('test end'); 65 | agentB.close('test end'); 66 | }); 67 | 68 | it('should get me', async () => { 69 | const me = await agentA.observeMeAsync(); 70 | 71 | expect(me.account.email).toEqual(email); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/lib/src/rw-lock.ts: -------------------------------------------------------------------------------- 1 | import {Deferred} from './deferred.js'; 2 | import {AppError} from './errors.js'; 3 | 4 | export class RwLock { 5 | private readQueue: Array<() => void> = []; 6 | private writeQueue: Array<() => void> = []; 7 | private readCount = 0; 8 | private writing = false; 9 | 10 | async runWrite(fn: () => Promise): Promise { 11 | try { 12 | await this.writeLock(); 13 | return await fn(); 14 | } finally { 15 | this.unlockWrite(); 16 | } 17 | } 18 | 19 | // prefer runWrite 20 | async writeLock(): Promise { 21 | if (this.readCount > 0 || this.writing) { 22 | const signal = new Deferred(); 23 | this.writeQueue.push(() => { 24 | this.writing = true; 25 | signal.resolve(); 26 | }); 27 | await signal.promise; 28 | } else { 29 | this.writing = true; 30 | } 31 | } 32 | 33 | // prefer runWrite 34 | unlockWrite(): void { 35 | if (!this.writing) { 36 | throw new AppError('Write lock is not held'); 37 | } 38 | 39 | this.writing = false; 40 | 41 | // first start all readers to facilitate read queue progress 42 | if (this.readQueue.length > 0) { 43 | this.readQueue.forEach(fn => fn()); 44 | this.readQueue = []; 45 | } else { 46 | this.writeQueue.shift()?.(); 47 | } 48 | } 49 | 50 | async runRead(fn: () => Promise): Promise { 51 | try { 52 | await this.readLock(); 53 | return await fn(); 54 | } finally { 55 | this.unlockRead(); 56 | } 57 | } 58 | 59 | // prefer runRead 60 | async readLock(): Promise { 61 | if (this.writeQueue.length > 0 || this.writing) { 62 | const signal = new Deferred(); 63 | this.readQueue.push(() => { 64 | this.readCount++; 65 | signal.resolve(); 66 | }); 67 | await signal.promise; 68 | } else { 69 | this.readCount++; 70 | } 71 | } 72 | 73 | // prefer runRead 74 | unlockRead(): void { 75 | if (this.readCount === 0) { 76 | throw new AppError('Read lock is not held'); 77 | } 78 | 79 | this.readCount--; 80 | 81 | // first start a writer to facilitate write queue progress 82 | if (this.writeQueue.length > 0) { 83 | if (this.readCount === 0) { 84 | this.writeQueue.shift()?.(); 85 | } 86 | } else { 87 | this.readQueue.forEach(fn => fn()); 88 | this.readQueue = []; 89 | } 90 | } 91 | } 92 | --------------------------------------------------------------------------------