├── .editorconfig ├── .github └── workflows │ ├── autofix.yml │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package.json ├── playground ├── bun.ts ├── cf.ts ├── deno.ts ├── node.ts ├── public │ ├── index.html │ ├── prosemirror │ │ ├── index.html │ │ ├── index.js │ │ ├── schema.js │ │ └── style.css │ └── tiptap │ │ ├── editor.js │ │ ├── index.html │ │ ├── index.js │ │ ├── style.css │ │ └── theme.css └── wrangler.toml ├── pnpm-lock.yaml ├── renovate.json ├── src ├── index.ts ├── provider.ts └── server.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [{package.json,*.yml,*.cjson}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/autofix.yml: -------------------------------------------------------------------------------- 1 | name: autofix.ci # needed to securely identify the workflow 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main"] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | autofix: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: "pnpm" 21 | - run: pnpm install 22 | - run: pnpm lint:fix 23 | - uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c 24 | with: 25 | commit-message: "chore: apply automated updates" 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: corepack enable 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: "pnpm" 21 | - run: pnpm install 22 | - run: pnpm lint 23 | - run: pnpm test:types 24 | - run: pnpm build 25 | # - run: pnpm vitest --coverage 26 | # - uses: codecov/codecov-action@v4 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy playground 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | # https://github.com/cloudflare/wrangler-action 10 | deploy: 11 | runs-on: ubuntu-latest 12 | name: Deploy 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: corepack enable 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | cache: "pnpm" 20 | - name: Deploy 21 | uses: cloudflare/wrangler-action@v3 22 | with: 23 | workingDirectory: "playground" 24 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 25 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .vscode 5 | .DS_Store 6 | .eslintcache 7 | *.log* 8 | *.env* 9 | .wrangler 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## v0.0.2 5 | 6 | [compare changes](https://github.com/pi0/y-crossws/compare/v0.0.1...v0.0.2) 7 | 8 | ### 🏡 Chore 9 | 10 | - Update deps ([541919f](https://github.com/pi0/y-crossws/commit/541919f)) 11 | 12 | ### ❤️ Contributors 13 | 14 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 15 | 16 | ## v0.0.1 17 | 18 | 19 | ### 🩹 Fixes 20 | 21 | - Make sure message data is of type uint8array ([8197759](https://github.com/pi0/y-crossws/commit/8197759)) 22 | 23 | ### 🏡 Chore 24 | 25 | - Update readme ([a79413c](https://github.com/pi0/y-crossws/commit/a79413c)) 26 | - Update readme ([452f64a](https://github.com/pi0/y-crossws/commit/452f64a)) 27 | - Add cloudflare and deno playgrounds ([41a979b](https://github.com/pi0/y-crossws/commit/41a979b)) 28 | - Keep only cloudflare durable example ([e8cdd77](https://github.com/pi0/y-crossws/commit/e8cdd77)) 29 | - Update examples ([e65cc25](https://github.com/pi0/y-crossws/commit/e65cc25)) 30 | - Disble action for now ([cb8c0bb](https://github.com/pi0/y-crossws/commit/cb8c0bb)) 31 | - Add demo url ([8ddbd3e](https://github.com/pi0/y-crossws/commit/8ddbd3e)) 32 | - Apply automated updates ([37609ab](https://github.com/pi0/y-crossws/commit/37609ab)) 33 | - Update license link ([c5e07f5](https://github.com/pi0/y-crossws/commit/c5e07f5)) 34 | 35 | ### 🤖 CI 36 | 37 | - Enable deploy action ([59ffe1e](https://github.com/pi0/y-crossws/commit/59ffe1e)) 38 | - Enable pnpm ([265f54d](https://github.com/pi0/y-crossws/commit/265f54d)) 39 | - Set account id ([da88a3e](https://github.com/pi0/y-crossws/commit/da88a3e)) 40 | 41 | ### ❤️ Contributors 42 | 43 | - Pooya Parsa ([@pi0](http://github.com/pi0)) 44 | 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Pooya Parsa 4 | Copyright (c) 2019 Kevin Jahns . 5 | 6 | Based on https://github.com/yjs/y-crossws (2.0.4) 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # y-crossws 2 | 3 | 4 | 5 | [![npm version](https://img.shields.io/npm/v/y-crossws?color=yellow)](https://npmjs.com/package/y-crossws) 6 | [![npm downloads](https://img.shields.io/npm/dm/y-crossws?color=yellow)](https://npm.chart.dev/y-crossws) 7 | 8 | 9 | 10 | [yjs](https://docs.yjs.dev/) websocket server powered by [crossws](https://crossws.unjs.io/), works with Node.js, Deno, Bun, Cloudflare Workers and more without any framework dependency and compatible with unmodified [y-websocket](https://github.com/yjs/y-websocket) client provider. 11 | 12 | > [!NOTE] 13 | > 🚧 This is still a work in progress. Feedback and contributions are welcome! 14 | 15 | ## 🍿 Demo 16 | 17 | > [!NOTE] 18 | > See [nuxt-tiptap](https://github.com/pi0/nuxt-tiptap) for a full Demo with Nuxt. 19 | 20 | [tiptap](https://tiptap.dev/) editor with collaborative editing deployed on Cloudflare workers with durable objects. 21 | 22 | - [online deployment](https://y-crossws.pi0.workers.dev/tiptap/) 23 | - [source code](./playground/) 24 | 25 | ## Usage 26 | 27 | We first need to initiate universal cross-hooks: 28 | 29 | ```js 30 | import { createHandler } from "y-crossws"; 31 | 32 | const crosswsHandler = createHandler(); 33 | ``` 34 | 35 | Depending on your server choice, use any of the [crossws supported adapters](https://crossws.unjs.io/adapters). 36 | 37 | ### Node.js 38 | 39 | ```js 40 | import { createServer } from "node:http"; 41 | import { createHandler } from "y-crossws"; 42 | import crossws from "crossws/adapters/node"; 43 | 44 | const server = createServer((req, res) => { 45 | res.statusCode = 426; 46 | res.end(""); 47 | }); 48 | 49 | const ws = crossws(createHandler()); 50 | 51 | server.on("upgrade", ws.handleUpgrade); 52 | 53 | server.listen(3000); 54 | ``` 55 | 56 | > [!NOTE] 57 | > Read more about Node.js adapter in [crossws docs](https://crossws.unjs.io/adapters/node). 58 | 59 | ### Bun 60 | 61 | ```js 62 | import { createHandler } from "y-crossws"; 63 | import crossws from "crossws/adapters/bun"; 64 | 65 | const ws = crossws(createHandler()); 66 | 67 | Bun.serve({ 68 | port: 3000, 69 | websocket: ws.websocket, 70 | fetch(req, server) { 71 | if (request.headers.get("upgrade") === "websocket") { 72 | return ws.handleUpgrade(request, server); 73 | } 74 | return new Response("", { status: 426 }); 75 | }, 76 | }); 77 | ``` 78 | 79 | > [!NOTE] 80 | > Read more about Bun adapter in [crossws docs](https://crossws.unjs.io/adapters/bun). 81 | 82 | ### Deno 83 | 84 | ```js 85 | import { createHandler } from "y-crossws"; 86 | import crossws from "crossws/adapters/deno"; 87 | 88 | const ws = crossws(createHandler()); 89 | 90 | Deno.serve({ port: 3000 }, (request, info) => { 91 | if (request.headers.get("upgrade") === "websocket") { 92 | return ws.handleUpgrade(request, server); 93 | } 94 | return new Response("", { status: 426 }); 95 | }); 96 | ``` 97 | 98 | > [!NOTE] 99 | > Read more about Deno adapter in [crossws docs](https://crossws.unjs.io/adapters/deno). 100 | 101 | ### Cloudflare (with durable objects) 102 | 103 | ```js 104 | import { createHandler } from "y-crossws"; 105 | import { DurableObject } from "cloudflare:workers"; 106 | import crossws from "crossws/adapters/cloudflare-durable"; 107 | 108 | const ws = crossws(createHandler()); 109 | 110 | export default { 111 | async fetch(request, env, context) { 112 | if (request.headers.get("upgrade") === "websocket") { 113 | return ws.handleUpgrade(request, env, context); 114 | } 115 | return new Response("", { status: 426 }); 116 | }, 117 | }; 118 | 119 | export class $DurableObject extends DurableObject { 120 | fetch(request) { 121 | return ws.handleDurableUpgrade(this, request); 122 | } 123 | webSocketMessage(client, message) { 124 | return ws.handleDurableMessage(this, client, message); 125 | } 126 | webSocketClose(client, code, reason, wasClean) { 127 | return ws.handleDurableClose(this, client, code, reason, wasClean); 128 | } 129 | } 130 | ``` 131 | 132 | Update your `wrangler.toml` config to specify Durable object: 133 | 134 | ```toml 135 | durable_objects.bindings = [ 136 | { name = "$DurableObject", class_name = "$DurableObject" } 137 | ] 138 | 139 | migrations = [ 140 | { tag = "v1", new_classes = ["$DurableObject"] } 141 | ] 142 | ``` 143 | 144 | > [!NOTE] 145 | > Read more about Cloudflare adapter in [crossws docs](https://crossws.unjs.io/adapters/cloudflare#durable-objects). 146 | 147 | ## Websocket provider 148 | 149 | You can use `WebsocketProvider` from legacy [y-websocket](https://github.com/yjs/y-websocket) or a native one from `y-crossws`. Both are almost identical in terms of API at the moment, however, the y-crossws version has better typescript refactors and might introduce more enhancements in sync with the server provider in the future. 150 | 151 | ```js 152 | import * as Y from "yjs"; 153 | import { WebsocketProvider } from "y-crossws/provider"; 154 | 155 | const ydoc = new Y.Doc(); 156 | const roomName = "default"; 157 | 158 | const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:"; 159 | const wsUrl = `${wsProto}://${window.location.host}/_ws`; 160 | 161 | const provider = new WebsocketProvider(wsURL, roomName, ydoc, { 162 | /* options */ 163 | }); 164 | ``` 165 | 166 | ### Provider options 167 | 168 | - `params`: URL parameters to append. 169 | - `protocols`: Specify websocket protocols. 170 | - `WebSocketPolyfill`: WebSocket polyfill. 171 | - `maxBackoffTime`: Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential (default `2500`). 172 | - `resyncInterval`: Request server state every `resyncInterval` milliseconds (default `-1`). 173 | - `connect`: Whether to connect to other peers or not (default: `true`). 174 | - `awareness`: Awareness instance. 175 | - `disableBc`: Disable cross-tab BroadcastChannel communication (default: `false`). 176 | 177 | ## Development 178 | 179 |
180 | 181 | local development 182 | 183 | - Clone this repository 184 | - Install the latest LTS version of [Node.js](https://nodejs.org/en/) 185 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 186 | - Install dependencies using `pnpm install` 187 | - Build in stub mode using `pnpm build --stub` 188 | - Run playgrounds with `pnpm dev:*` commands. 189 | 190 |
191 | 192 | ## License 193 | 194 | 💛 Published under the [MIT](https://github.com/pi0/y-crossws/blob/main/LICENSE) license. 195 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unjs from "eslint-config-unjs"; 2 | 3 | export default unjs({ 4 | ignores: [], 5 | rules: { 6 | "unicorn/no-null": 0, 7 | "@typescript-eslint/no-empty-object-type": 0, 8 | "@typescript-eslint/no-non-null-asserted-optional-chain": 0, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-crossws", 3 | "version": "0.0.2", 4 | "description": "yjs websocket server powered by crossws, works on Node.js, Deno, Bun, Cloudflare Workers and more without any framework dependency and compatible with unmodified y-websocket client provider.", 5 | "keywords": [ 6 | "yjs", 7 | "unjs", 8 | "crossws", 9 | "websocket", 10 | "server", 11 | "node", 12 | "deno", 13 | "bun", 14 | "cloudflare", 15 | "workers" 16 | ], 17 | "repository": "pi0/y-crossws", 18 | "license": "MIT", 19 | "sideEffects": false, 20 | "type": "module", 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.mts", 24 | "default": "./dist/index.mjs" 25 | }, 26 | "./provider": { 27 | "types": "./dist/provider.d.mts", 28 | "default": "./dist/provider.mjs" 29 | } 30 | }, 31 | "main": "./dist/index.mjs", 32 | "module": "./dist/index.mjs", 33 | "types": "./dist/index.d.mts", 34 | "files": [ 35 | "dist" 36 | ], 37 | "scripts": { 38 | "build": "unbuild", 39 | "lint": "eslint . && prettier -c src playground", 40 | "lint:fix": "automd && eslint . --fix && prettier -w src playground", 41 | "prepack": "pnpm build", 42 | "play:bun": "PORT=3000 bun run --watch ./playground/bun.ts", 43 | "play:cf": "wrangler dev -c ./playground/wrangler.toml --port 3000", 44 | "play:deno": "PORT=3000 deno run --unstable-byonm -A ./playground/deno.ts", 45 | "play:node": "PORT=3000 jiti ./playground/node.ts", 46 | "release": "pnpm test && changelogen --release && npm publish && git push --follow-tags", 47 | "test": "pnpm lint && pnpm test:types", 48 | "test:types": "tsc --noEmit --skipLibCheck" 49 | }, 50 | "dependencies": { 51 | "lib0": "^0.2.98", 52 | "y-protocols": "^1.0.6" 53 | }, 54 | "devDependencies": { 55 | "@cloudflare/kv-asset-handler": "^0.3.4", 56 | "@cloudflare/workers-types": "^4.20241011.0", 57 | "@types/deno": "^2.0.0", 58 | "@types/node": "^22.7.6", 59 | "@types/ws": "^8.5.12", 60 | "automd": "^0.3.12", 61 | "changelogen": "^0.5.7", 62 | "crossws": ">=0.2.0 <0.4.0", 63 | "eslint": "^9.12.0", 64 | "eslint-config-unjs": "^0.4.1", 65 | "jiti": "^2.3.3", 66 | "prettier": "^3.3.3", 67 | "typescript": "^5.6.3", 68 | "unbuild": "^2.0.0", 69 | "wrangler": "^3.81.0", 70 | "ws": "^8.18.0", 71 | "yjs": "^13.6.20" 72 | }, 73 | "peerDependencies": { 74 | "crossws": ">=0.2.0 <0.4.0", 75 | "yjs": "^13.5.6" 76 | }, 77 | "packageManager": "pnpm@9.12.0" 78 | } 79 | -------------------------------------------------------------------------------- /playground/bun.ts: -------------------------------------------------------------------------------- 1 | import crossws from "crossws/adapters/bun"; 2 | import { createHandler } from "../src/index.ts"; 3 | 4 | // @ts-expect-error TODO 5 | const ws = crossws(createHandler()); 6 | 7 | declare global { 8 | const Bun: { 9 | serve(options: { 10 | port: number | string; 11 | websocket: any; 12 | fetch(request: Request, server: any): Promise; 13 | }): { url: string }; 14 | file(url: URL): BodyInit & { exists(): Promise }; 15 | }; 16 | } 17 | 18 | const server = Bun.serve({ 19 | port: process.env.PORT || 3000, 20 | websocket: ws.websocket, 21 | async fetch(request, server) { 22 | // Websocket 23 | if (request.headers.get("upgrade") === "websocket") { 24 | return ws.handleUpgrade(request, server); 25 | } 26 | 27 | // Static 28 | const url = new URL(request.url); 29 | for (const path of [ 30 | `public${url.pathname}`, 31 | `public${url.pathname}/index.html`, 32 | ]) { 33 | const file = Bun.file(new URL(path, import.meta.url)); 34 | if (await file.exists()) { 35 | return new Response(file); 36 | } 37 | } 38 | return new Response("Not found", { status: 404 }); 39 | }, 40 | }); 41 | 42 | console.log(`Server running at ${server.url}`); 43 | -------------------------------------------------------------------------------- /playground/cf.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../src/index.ts"; 2 | import { getAssetFromKV } from "@cloudflare/kv-asset-handler"; 3 | import { DurableObject } from "cloudflare:workers"; 4 | import crossws from "crossws/adapters/cloudflare-durable"; 5 | 6 | // @ts-ignore 7 | import manifestJSON from "__STATIC_CONTENT_MANIFEST"; 8 | const assetManifest = JSON.parse(manifestJSON); 9 | 10 | // @ts-expect-error TODO 11 | const ws = crossws(createHandler()); 12 | 13 | export default { 14 | async fetch(request: Request, env: any, ctx: any) { 15 | // Handle websocket 16 | if (request.headers.get("upgrade") === "websocket") { 17 | return ws.handleUpgrade(request as any, env, ctx); 18 | } 19 | // Handle static assets 20 | try { 21 | return await getAssetFromKV( 22 | { request, waitUntil: ctx.waitUntil.bind(ctx) }, 23 | { 24 | ASSET_NAMESPACE: env.__STATIC_CONTENT, 25 | ASSET_MANIFEST: assetManifest, 26 | }, 27 | ); 28 | } catch { 29 | const pathname = new URL(request.url).pathname; 30 | return new Response(`"${pathname}" not found`, { status: 404 }); 31 | } 32 | }, 33 | }; 34 | 35 | export class $DurableObject extends DurableObject { 36 | fetch(request: Request) { 37 | return ws.handleDurableUpgrade(this, request); 38 | } 39 | webSocketMessage(client: WebSocket, message: string | ArrayBuffer) { 40 | return ws.handleDurableMessage(this, client, message); 41 | } 42 | webSocketClose( 43 | client: WebSocket, 44 | code: number, 45 | reason: string, 46 | wasClean: boolean, 47 | ) { 48 | return ws.handleDurableClose(this, client, code, reason, wasClean); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /playground/deno.ts: -------------------------------------------------------------------------------- 1 | import crossws from "crossws/adapters/deno"; 2 | import { createHandler } from "../src/index.ts"; 3 | 4 | // @ts-expect-error TODO 5 | const ws = crossws(createHandler()); 6 | 7 | const mimes = { 8 | ".html": "text/html", 9 | ".js": "text/javascript", 10 | ".css": "text/css", 11 | ".svg": "image/svg+xml", 12 | ".json": "application/json", 13 | }; 14 | 15 | Deno.serve( 16 | { 17 | port: Number.parseInt(Deno.env.get("PORT") || "3000"), 18 | onListen: ({ port }) => { 19 | console.log(`Server running at http://localhost:${port}`); 20 | }, 21 | }, 22 | async (request, info) => { 23 | // Websocket 24 | if (request.headers.get("upgrade") === "websocket") { 25 | return ws.handleUpgrade(request, info as any); 26 | } 27 | // Static 28 | const url = new URL(request.url); 29 | for (const path of [ 30 | `public${url.pathname}`, 31 | `public${url.pathname}/index.html`, 32 | ]) { 33 | const contents = await Deno.readTextFile( 34 | new URL(path, import.meta.url), 35 | ).catch(() => undefined); 36 | if (contents) { 37 | const extname = path.match(/\.\w+$/)?.[0] as 38 | | keyof typeof mimes 39 | | undefined; 40 | return new Response(contents, { 41 | headers: { 42 | "content-type": extname ? mimes[extname] : "text/plain", 43 | }, 44 | }); 45 | } 46 | } 47 | return new Response("Not found", { status: 404 }); 48 | }, 49 | ); 50 | -------------------------------------------------------------------------------- /playground/node.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | import { readFile } from "node:fs/promises"; 3 | import { extname } from "node:path"; 4 | import { createHandler } from "y-crossws"; 5 | import crossws from "crossws/adapters/node"; 6 | 7 | const mimes = { 8 | ".html": "text/html", 9 | ".js": "text/javascript", 10 | ".css": "text/css", 11 | ".svg": "image/svg+xml", 12 | ".json": "application/json", 13 | }; 14 | 15 | const server = createServer(async (req, res) => { 16 | const url = new URL(req.url || "/", `http://${req.headers.host}`); 17 | for (const path of [ 18 | `public${url.pathname}`, 19 | `public${url.pathname}/index.html`, 20 | ]) { 21 | const fullPath = new URL(path, import.meta.url); 22 | const contents = await readFile(fullPath).catch(() => undefined); 23 | if (contents) { 24 | const mime = 25 | mimes[extname(fullPath.pathname) as keyof typeof mimes] || "text/plain"; 26 | res.setHeader("content-type", mime); 27 | return res.end(contents); 28 | } 29 | } 30 | res.end(""); 31 | }); 32 | 33 | // @ts-expect-error TODO 34 | const ws = crossws(createHandler()); 35 | 36 | server.on("upgrade", ws.handleUpgrade); 37 | 38 | server.listen(process.env.PORT || 3000, () => { 39 | const addr = server.address() as { port: number }; 40 | console.log(`Server running at http://localhost:${addr.port}`); 41 | }); 42 | -------------------------------------------------------------------------------- /playground/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tiptap + y-crossws 4 | 5 | 6 | 7 | y-cross demos: 8 | 16 |
17 | 18 | Learn more about y-crossws in github 19 | 20 | 21 | -------------------------------------------------------------------------------- /playground/public/prosemirror/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Porsemirror + y-crossws 4 | 5 | 6 | 7 | 8 | 9 | 12 |
13 |
14 | 15 |
16 | 17 | 18 | Loading demo... 19 |
20 |
21 |
22 | 23 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /playground/public/prosemirror/index.js: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import { WebsocketProvider } from "y-websocket"; 3 | import { EditorState } from "prosemirror-state"; 4 | import { EditorView } from "prosemirror-view"; 5 | import { exampleSetup } from "prosemirror-example-setup"; 6 | import { keymap } from "prosemirror-keymap"; 7 | import { 8 | ySyncPlugin, 9 | yCursorPlugin, 10 | yUndoPlugin, 11 | undo, 12 | redo, 13 | } from "y-prosemirror"; 14 | import { schema } from "./schema.js"; 15 | 16 | const ydoc = new Y.Doc(); 17 | 18 | const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:"; 19 | const wsUrl = `${wsProto}//${window.location.host}/_ws`; 20 | 21 | const provider = new WebsocketProvider(wsUrl, "prosemirror", ydoc); 22 | 23 | const type = ydoc.getXmlFragment("prosemirror"); 24 | 25 | const editor = document.querySelector("#editor"); 26 | 27 | editor.innerHTML = ""; 28 | 29 | const prosemirrorView = new EditorView(editor, { 30 | state: EditorState.create({ 31 | schema: schema, 32 | plugins: [ 33 | ySyncPlugin(type), 34 | yCursorPlugin(provider.awareness), 35 | yUndoPlugin(), 36 | keymap({ 37 | "Mod-z": undo, 38 | "Mod-y": redo, 39 | "Mod-Shift-z": redo, 40 | }), 41 | ...exampleSetup({ schema }), 42 | ], 43 | }), 44 | }); 45 | 46 | console.log(exampleSetup({ schema })); 47 | 48 | window.example = { provider, ydoc, type, prosemirrorView }; 49 | -------------------------------------------------------------------------------- /playground/public/prosemirror/schema.js: -------------------------------------------------------------------------------- 1 | import { Schema } from "prosemirror-model"; 2 | 3 | const brDOM = ["br"]; 4 | 5 | const calcYchangeDomAttrs = (attrs, domAttrs = {}) => { 6 | domAttrs = Object.assign({}, domAttrs); 7 | if (attrs.ychange !== null) { 8 | domAttrs.ychange_user = attrs.ychange.user; 9 | domAttrs.ychange_state = attrs.ychange.state; 10 | } 11 | return domAttrs; 12 | }; 13 | 14 | // :: Object 15 | // [Specs](#model.NodeSpec) for the nodes defined in this schema. 16 | export const nodes = { 17 | // :: NodeSpec The top level document node. 18 | doc: { 19 | content: "block+", 20 | }, 21 | 22 | // :: NodeSpec A plain paragraph textblock. Represented in the DOM 23 | // as a `

` element. 24 | paragraph: { 25 | attrs: { ychange: { default: null } }, 26 | content: "inline*", 27 | group: "block", 28 | parseDOM: [{ tag: "p" }], 29 | toDOM(node) { 30 | return ["p", calcYchangeDomAttrs(node.attrs), 0]; 31 | }, 32 | }, 33 | 34 | // :: NodeSpec A blockquote (`

`) wrapping one or more blocks. 35 | blockquote: { 36 | attrs: { ychange: { default: null } }, 37 | content: "block+", 38 | group: "block", 39 | defining: true, 40 | parseDOM: [{ tag: "blockquote" }], 41 | toDOM(node) { 42 | return ["blockquote", calcYchangeDomAttrs(node.attrs), 0]; 43 | }, 44 | }, 45 | 46 | // :: NodeSpec A horizontal rule (`
`). 47 | horizontal_rule: { 48 | attrs: { ychange: { default: null } }, 49 | group: "block", 50 | parseDOM: [{ tag: "hr" }], 51 | toDOM(node) { 52 | return ["hr", calcYchangeDomAttrs(node.attrs)]; 53 | }, 54 | }, 55 | 56 | // :: NodeSpec A heading textblock, with a `level` attribute that 57 | // should hold the number 1 to 6. Parsed and serialized as `

` to 58 | // `

` elements. 59 | heading: { 60 | attrs: { 61 | level: { default: 1 }, 62 | ychange: { default: null }, 63 | }, 64 | content: "inline*", 65 | group: "block", 66 | defining: true, 67 | parseDOM: [ 68 | { tag: "h1", attrs: { level: 1 } }, 69 | { tag: "h2", attrs: { level: 2 } }, 70 | { tag: "h3", attrs: { level: 3 } }, 71 | { tag: "h4", attrs: { level: 4 } }, 72 | { tag: "h5", attrs: { level: 5 } }, 73 | { tag: "h6", attrs: { level: 6 } }, 74 | ], 75 | toDOM(node) { 76 | return ["h" + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0]; 77 | }, 78 | }, 79 | 80 | // :: NodeSpec A code listing. Disallows marks or non-text inline 81 | // nodes by default. Represented as a `
` element with a
 82 |   // `` element inside of it.
 83 |   code_block: {
 84 |     attrs: { ychange: { default: null } },
 85 |     content: "text*",
 86 |     marks: "",
 87 |     group: "block",
 88 |     code: true,
 89 |     defining: true,
 90 |     parseDOM: [{ tag: "pre", preserveWhitespace: "full" }],
 91 |     toDOM(node) {
 92 |       return ["pre", calcYchangeDomAttrs(node.attrs), ["code", 0]];
 93 |     },
 94 |   },
 95 | 
 96 |   // :: NodeSpec The text node.
 97 |   text: {
 98 |     group: "inline",
 99 |   },
100 | 
101 |   // :: NodeSpec An inline image (``) node. Supports `src`,
102 |   // `alt`, and `href` attributes. The latter two default to the empty
103 |   // string.
104 |   image: {
105 |     inline: true,
106 |     attrs: {
107 |       ychange: { default: null },
108 |       src: {},
109 |       alt: { default: null },
110 |       title: { default: null },
111 |     },
112 |     group: "inline",
113 |     draggable: true,
114 |     parseDOM: [
115 |       {
116 |         tag: "img[src]",
117 |         getAttrs(dom) {
118 |           return {
119 |             src: dom.getAttribute("src"),
120 |             title: dom.getAttribute("title"),
121 |             alt: dom.getAttribute("alt"),
122 |           };
123 |         },
124 |       },
125 |     ],
126 |     toDOM(node) {
127 |       const domAttrs = {
128 |         src: node.attrs.src,
129 |         title: node.attrs.title,
130 |         alt: node.attrs.alt,
131 |       };
132 |       return ["img", calcYchangeDomAttrs(node.attrs, domAttrs)];
133 |     },
134 |   },
135 | 
136 |   // :: NodeSpec A hard line break, represented in the DOM as `
`. 137 | hard_break: { 138 | inline: true, 139 | group: "inline", 140 | selectable: false, 141 | parseDOM: [{ tag: "br" }], 142 | toDOM() { 143 | return brDOM; 144 | }, 145 | }, 146 | }; 147 | 148 | const emDOM = ["em", 0]; 149 | const strongDOM = ["strong", 0]; 150 | const codeDOM = ["code", 0]; 151 | 152 | // :: Object [Specs](#model.MarkSpec) for the marks in the schema. 153 | export const marks = { 154 | // :: MarkSpec A link. Has `href` and `title` attributes. `title` 155 | // defaults to the empty string. Rendered and parsed as an `` 156 | // element. 157 | link: { 158 | attrs: { 159 | href: {}, 160 | title: { default: null }, 161 | }, 162 | inclusive: false, 163 | parseDOM: [ 164 | { 165 | tag: "a[href]", 166 | getAttrs(dom) { 167 | return { 168 | href: dom.getAttribute("href"), 169 | title: dom.getAttribute("title"), 170 | }; 171 | }, 172 | }, 173 | ], 174 | toDOM(node) { 175 | return ["a", node.attrs, 0]; 176 | }, 177 | }, 178 | 179 | // :: MarkSpec An emphasis mark. Rendered as an `` element. 180 | // Has parse rules that also match `` and `font-style: italic`. 181 | em: { 182 | parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], 183 | toDOM() { 184 | return emDOM; 185 | }, 186 | }, 187 | 188 | // :: MarkSpec A strong mark. Rendered as ``, parse rules 189 | // also match `` and `font-weight: bold`. 190 | strong: { 191 | parseDOM: [ 192 | { tag: "strong" }, 193 | // This works around a Google Docs misbehavior where 194 | // pasted content will be inexplicably wrapped in `` 195 | // tags with a font-weight normal. 196 | { 197 | tag: "b", 198 | getAttrs: (node) => node.style.fontWeight !== "normal" && null, 199 | }, 200 | { 201 | style: "font-weight", 202 | getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null, 203 | }, 204 | ], 205 | toDOM() { 206 | return strongDOM; 207 | }, 208 | }, 209 | 210 | // :: MarkSpec Code font mark. Represented as a `` element. 211 | code: { 212 | parseDOM: [{ tag: "code" }], 213 | toDOM() { 214 | return codeDOM; 215 | }, 216 | }, 217 | ychange: { 218 | attrs: { 219 | user: { default: null }, 220 | state: { default: null }, 221 | }, 222 | inclusive: false, 223 | parseDOM: [{ tag: "ychange" }], 224 | toDOM(node) { 225 | return [ 226 | "ychange", 227 | { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 228 | 0, 229 | ]; 230 | }, 231 | }, 232 | }; 233 | 234 | // :: Schema 235 | // This schema roughly corresponds to the document schema used by 236 | // [CommonMark](http://commonmark.org/), minus the list elements, 237 | // which are defined in the [`prosemirror-schema-list`](#schema-list) 238 | // module. 239 | // 240 | // To reuse elements from this schema, extend or read from its 241 | // `spec.nodes` and `spec.marks` [properties](#model.Schema.spec). 242 | export const schema = new Schema({ nodes, marks }); 243 | -------------------------------------------------------------------------------- /playground/public/prosemirror/style.css: -------------------------------------------------------------------------------- 1 | /* Custom styles for rendering remote cursor locations */ 2 | 3 | placeholder { 4 | display: inline; 5 | border: 1px solid #ccc; 6 | color: #ccc; 7 | } 8 | placeholder:after { 9 | content: "☁"; 10 | font-size: 200%; 11 | line-height: 0.1; 12 | font-weight: bold; 13 | } 14 | .ProseMirror img { 15 | max-width: 100px; 16 | } 17 | 18 | /* this is a rough fix for the first cursor position when the first paragraph is empty */ 19 | .ProseMirror > .ProseMirror-yjs-cursor:first-child { 20 | margin-top: 16px; 21 | } 22 | .ProseMirror p:first-child, 23 | .ProseMirror h1:first-child, 24 | .ProseMirror h2:first-child, 25 | .ProseMirror h3:first-child, 26 | .ProseMirror h4:first-child, 27 | .ProseMirror h5:first-child, 28 | .ProseMirror h6:first-child { 29 | margin-top: 16px; 30 | } 31 | /* This gives the remote user caret. The colors are automatically overwritten*/ 32 | .ProseMirror-yjs-cursor { 33 | position: relative; 34 | margin-left: -1px; 35 | margin-right: -1px; 36 | border-left: 1px solid black; 37 | border-right: 1px solid black; 38 | border-color: orange; 39 | word-break: normal; 40 | pointer-events: none; 41 | } 42 | /* This renders the username above the caret */ 43 | .ProseMirror-yjs-cursor > div { 44 | position: absolute; 45 | top: -1.05em; 46 | left: -1px; 47 | font-size: 13px; 48 | background-color: rgb(250, 129, 0); 49 | font-family: serif; 50 | font-style: normal; 51 | font-weight: normal; 52 | line-height: normal; 53 | user-select: none; 54 | color: white; 55 | padding-left: 2px; 56 | padding-right: 2px; 57 | white-space: nowrap; 58 | } 59 | #y-functions { 60 | position: absolute; 61 | top: 20px; 62 | right: 20px; 63 | } 64 | #y-functions > * { 65 | display: inline-block; 66 | } 67 | 68 | /* Prosemirror's example style */ 69 | 70 | .ProseMirror { 71 | position: relative; 72 | } 73 | 74 | .ProseMirror { 75 | word-wrap: break-word; 76 | white-space: pre-wrap; 77 | -webkit-font-variant-ligatures: none; 78 | font-variant-ligatures: none; 79 | } 80 | 81 | .ProseMirror pre { 82 | white-space: pre-wrap; 83 | } 84 | 85 | .ProseMirror li { 86 | position: relative; 87 | } 88 | 89 | .ProseMirror-hideselection *::selection { 90 | background: transparent; 91 | } 92 | .ProseMirror-hideselection *::-moz-selection { 93 | background: transparent; 94 | } 95 | .ProseMirror-hideselection { 96 | caret-color: transparent; 97 | } 98 | 99 | .ProseMirror-selectednode { 100 | outline: 2px solid #8cf; 101 | } 102 | 103 | /* Make sure li selections wrap around markers */ 104 | 105 | li.ProseMirror-selectednode { 106 | outline: none; 107 | } 108 | 109 | li.ProseMirror-selectednode:after { 110 | content: ""; 111 | position: absolute; 112 | left: -32px; 113 | right: -2px; 114 | top: -2px; 115 | bottom: -2px; 116 | border: 2px solid #8cf; 117 | pointer-events: none; 118 | } 119 | .ProseMirror-textblock-dropdown { 120 | min-width: 3em; 121 | } 122 | 123 | .ProseMirror-menu { 124 | margin: 0 -4px; 125 | line-height: 1; 126 | } 127 | 128 | .ProseMirror-tooltip .ProseMirror-menu { 129 | width: -webkit-fit-content; 130 | width: fit-content; 131 | white-space: pre; 132 | } 133 | 134 | .ProseMirror-menuitem { 135 | margin-right: 3px; 136 | display: inline-block; 137 | } 138 | 139 | .ProseMirror-menuseparator { 140 | border-right: 1px solid #ddd; 141 | margin-right: 3px; 142 | } 143 | 144 | .ProseMirror-menu-dropdown, 145 | .ProseMirror-menu-dropdown-menu { 146 | font-size: 90%; 147 | white-space: nowrap; 148 | } 149 | 150 | .ProseMirror-menu-dropdown { 151 | vertical-align: 1px; 152 | cursor: pointer; 153 | position: relative; 154 | padding-right: 15px; 155 | } 156 | 157 | .ProseMirror-menu-dropdown-wrap { 158 | padding: 1px 0 1px 4px; 159 | display: inline-block; 160 | position: relative; 161 | } 162 | 163 | .ProseMirror-menu-dropdown:after { 164 | content: ""; 165 | border-left: 4px solid transparent; 166 | border-right: 4px solid transparent; 167 | border-top: 4px solid currentColor; 168 | opacity: 0.6; 169 | position: absolute; 170 | right: 4px; 171 | top: calc(50% - 2px); 172 | } 173 | 174 | .ProseMirror-menu-dropdown-menu, 175 | .ProseMirror-menu-submenu { 176 | position: absolute; 177 | background: white; 178 | color: #666; 179 | border: 1px solid #aaa; 180 | padding: 2px; 181 | } 182 | 183 | .ProseMirror-menu-dropdown-menu { 184 | z-index: 15; 185 | min-width: 6em; 186 | } 187 | 188 | .ProseMirror-menu-dropdown-item { 189 | cursor: pointer; 190 | padding: 2px 8px 2px 4px; 191 | } 192 | 193 | .ProseMirror-menu-dropdown-item:hover { 194 | background: #f2f2f2; 195 | } 196 | 197 | .ProseMirror-menu-submenu-wrap { 198 | position: relative; 199 | margin-right: -4px; 200 | } 201 | 202 | .ProseMirror-menu-submenu-label:after { 203 | content: ""; 204 | border-top: 4px solid transparent; 205 | border-bottom: 4px solid transparent; 206 | border-left: 4px solid currentColor; 207 | opacity: 0.6; 208 | position: absolute; 209 | right: 4px; 210 | top: calc(50% - 4px); 211 | } 212 | 213 | .ProseMirror-menu-submenu { 214 | display: none; 215 | min-width: 4em; 216 | left: 100%; 217 | top: -3px; 218 | } 219 | 220 | .ProseMirror-menu-active { 221 | background: #eee; 222 | border-radius: 4px; 223 | } 224 | 225 | .ProseMirror-menu-active { 226 | background: #eee; 227 | border-radius: 4px; 228 | } 229 | 230 | .ProseMirror-menu-disabled { 231 | opacity: 0.3; 232 | } 233 | 234 | .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, 235 | .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { 236 | display: block; 237 | } 238 | 239 | .ProseMirror-menubar { 240 | border-top-left-radius: inherit; 241 | border-top-right-radius: inherit; 242 | position: relative; 243 | min-height: 1em; 244 | color: #666; 245 | padding: 1px 6px; 246 | top: 0; 247 | left: 0; 248 | right: 0; 249 | border-bottom: 1px solid silver; 250 | background: white; 251 | z-index: 10; 252 | -moz-box-sizing: border-box; 253 | box-sizing: border-box; 254 | overflow: visible; 255 | } 256 | 257 | .ProseMirror-icon { 258 | display: inline-block; 259 | line-height: 0.8; 260 | vertical-align: -2px; /* Compensate for padding */ 261 | padding: 2px 8px; 262 | cursor: pointer; 263 | } 264 | 265 | .ProseMirror-menu-disabled.ProseMirror-icon { 266 | cursor: default; 267 | } 268 | 269 | .ProseMirror-icon svg { 270 | fill: currentColor; 271 | height: 1em; 272 | } 273 | 274 | .ProseMirror-icon span { 275 | vertical-align: text-top; 276 | } 277 | .ProseMirror-gapcursor { 278 | display: none; 279 | pointer-events: none; 280 | position: absolute; 281 | } 282 | 283 | .ProseMirror-gapcursor:after { 284 | content: ""; 285 | display: block; 286 | position: absolute; 287 | top: -2px; 288 | width: 20px; 289 | border-top: 1px solid black; 290 | animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; 291 | } 292 | 293 | @keyframes ProseMirror-cursor-blink { 294 | to { 295 | visibility: hidden; 296 | } 297 | } 298 | 299 | .ProseMirror-focused .ProseMirror-gapcursor { 300 | display: block; 301 | } 302 | /* Add space around the hr to make clicking it easier */ 303 | 304 | .ProseMirror-example-setup-style hr { 305 | padding: 2px 10px; 306 | border: none; 307 | margin: 1em 0; 308 | } 309 | 310 | .ProseMirror-example-setup-style hr:after { 311 | content: ""; 312 | display: block; 313 | height: 1px; 314 | background-color: silver; 315 | line-height: 2px; 316 | } 317 | 318 | .ProseMirror ul, 319 | .ProseMirror ol { 320 | padding-left: 30px; 321 | } 322 | 323 | .ProseMirror blockquote { 324 | padding-left: 1em; 325 | border-left: 3px solid #eee; 326 | margin-left: 0; 327 | margin-right: 0; 328 | } 329 | 330 | .ProseMirror-example-setup-style img { 331 | cursor: default; 332 | } 333 | 334 | .ProseMirror-prompt { 335 | background: white; 336 | padding: 5px 10px 5px 15px; 337 | border: 1px solid silver; 338 | position: fixed; 339 | border-radius: 3px; 340 | z-index: 11; 341 | box-shadow: -0.5px 2px 5px rgba(0, 0, 0, 0.2); 342 | } 343 | 344 | .ProseMirror-prompt h5 { 345 | margin: 0; 346 | font-weight: normal; 347 | font-size: 100%; 348 | color: #444; 349 | } 350 | 351 | .ProseMirror-prompt input[type="text"], 352 | .ProseMirror-prompt textarea { 353 | background: #eee; 354 | border: none; 355 | outline: none; 356 | } 357 | 358 | .ProseMirror-prompt input[type="text"] { 359 | padding: 0 4px; 360 | } 361 | 362 | .ProseMirror-prompt-close { 363 | position: absolute; 364 | left: 2px; 365 | top: 1px; 366 | color: #666; 367 | border: none; 368 | background: transparent; 369 | padding: 0; 370 | } 371 | 372 | .ProseMirror-prompt-close:after { 373 | content: "✕"; 374 | font-size: 12px; 375 | } 376 | 377 | .ProseMirror-invalid { 378 | background: #ffc; 379 | border: 1px solid #cc7; 380 | border-radius: 4px; 381 | padding: 5px 10px; 382 | position: absolute; 383 | min-width: 10em; 384 | } 385 | 386 | .ProseMirror-prompt-buttons { 387 | margin-top: 5px; 388 | display: none; 389 | } 390 | #editor, 391 | .editor { 392 | background: white; 393 | color: black; 394 | background-clip: padding-box; 395 | border-radius: 4px; 396 | border: 2px solid rgba(0, 0, 0, 0.2); 397 | padding: 5px 0; 398 | margin-bottom: 23px; 399 | } 400 | 401 | .ProseMirror p:first-child, 402 | .ProseMirror h1:first-child, 403 | .ProseMirror h2:first-child, 404 | .ProseMirror h3:first-child, 405 | .ProseMirror h4:first-child, 406 | .ProseMirror h5:first-child, 407 | .ProseMirror h6:first-child { 408 | margin-top: 10px; 409 | } 410 | 411 | .ProseMirror { 412 | padding: 4px 8px 4px 14px; 413 | line-height: 1.2; 414 | outline: none; 415 | } 416 | 417 | .ProseMirror p { 418 | margin-bottom: 1em; 419 | } 420 | -------------------------------------------------------------------------------- /playground/public/tiptap/editor.js: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/ueberdosis/tiptap/blob/main/demos/src/Demos/CollaborationSplitPane/React/Editor.jsx 2 | 3 | import CharacterCount from "@tiptap/extension-character-count"; 4 | import Collaboration from "@tiptap/extension-collaboration"; 5 | import CollaborationCursor from "@tiptap/extension-collaboration-cursor"; 6 | import Highlight from "@tiptap/extension-highlight"; 7 | import TaskItem from "@tiptap/extension-task-item"; 8 | import TaskList from "@tiptap/extension-task-list"; 9 | import { 10 | useEditor, 11 | EditorContent, 12 | BubbleMenu, 13 | FloatingMenu, 14 | } from "@tiptap/react"; 15 | import StarterKit from "@tiptap/starter-kit"; 16 | import React, { useCallback, useEffect, useState } from "react"; 17 | import { faker } from "@faker-js/faker"; 18 | 19 | const h = React.createElement; 20 | 21 | // prettier-ignore 22 | const colors = ["#958DF1", "#F98181", "#FBBC88", "#FAF594", "#70CFF8", "#94FADB", "#B9F18D", "#C3E2C2", "#EAECCC", "#AFC8AD", "#EEC759", "#9BB8CD", "#FF90BC", "#FFC0D9", "#DC8686", "#7ED7C1", "#F3EEEA", "#89B9AD", "#D0BFFF", "#FFF8C9", "#CBFFA9", "#9BABB8", "#E3F4F4"]; 23 | 24 | const defaultContent = ` 25 |

Hi 👋, this is a collaborative document.

26 |

Feel free to edit and collaborate in real-time!

27 | `; 28 | 29 | const getInitialUser = () => { 30 | return { 31 | name: faker.person.fullName(), 32 | color: colors[Math.floor(Math.random() * colors.length)], 33 | }; 34 | }; 35 | 36 | const Editor = ({ ydoc, provider, room }) => { 37 | const [status, setStatus] = useState("connecting"); 38 | const [currentUser, setCurrentUser] = useState(getInitialUser); 39 | 40 | const editor = useEditor({ 41 | onCreate: ({ editor: currentEditor }) => { 42 | provider.on("synced", () => { 43 | if (currentEditor.isEmpty) { 44 | currentEditor.commands.setContent(defaultContent); 45 | } 46 | }); 47 | }, 48 | extensions: [ 49 | StarterKit.configure({ history: false }), 50 | Highlight, 51 | TaskList, 52 | TaskItem, 53 | CharacterCount.configure({ limit: 10_000 }), 54 | Collaboration.configure({ document: ydoc }), 55 | CollaborationCursor.configure({ provider }), 56 | ], 57 | }); 58 | 59 | useEffect(() => { 60 | // Update status changes 61 | const statusHandler = (event) => { 62 | setStatus(event.status); 63 | }; 64 | provider.on("status", statusHandler); 65 | return () => { 66 | provider.off("status", statusHandler); 67 | }; 68 | }, [provider]); 69 | 70 | // Save current user to localStorage and emit to editor 71 | useEffect(() => { 72 | if (editor && currentUser) { 73 | localStorage.setItem("currentUser", JSON.stringify(currentUser)); 74 | editor.chain().focus().updateUser(currentUser).run(); 75 | } 76 | }, [editor, currentUser]); 77 | 78 | const setName = useCallback(() => { 79 | const name = (window.prompt("Name", currentUser.name) || "") 80 | .trim() 81 | .slice(0, 32); 82 | if (name) { 83 | return setCurrentUser({ 84 | ...currentUser, 85 | name, 86 | }); 87 | } 88 | }, [currentUser]); 89 | 90 | if (!editor) { 91 | return null; 92 | } 93 | 94 | const menu = h( 95 | React.Fragment, 96 | null, 97 | editor && 98 | h( 99 | BubbleMenu, 100 | { 101 | className: "bubble-menu", 102 | tippyOptions: { 103 | duration: 100, 104 | }, 105 | editor: editor, 106 | }, 107 | h( 108 | "button", 109 | { 110 | onClick: () => editor.chain().focus().toggleBold().run(), 111 | className: editor.isActive("bold") ? "is-active" : "", 112 | }, 113 | "Bold", 114 | ), 115 | h( 116 | "button", 117 | { 118 | onClick: () => editor.chain().focus().toggleItalic().run(), 119 | className: editor.isActive("italic") ? "is-active" : "", 120 | }, 121 | "Italic", 122 | ), 123 | h( 124 | "button", 125 | { 126 | onClick: () => editor.chain().focus().toggleStrike().run(), 127 | className: editor.isActive("strike") ? "is-active" : "", 128 | }, 129 | "Strike", 130 | ), 131 | ), 132 | editor && 133 | h( 134 | FloatingMenu, 135 | { 136 | className: "floating-menu", 137 | tippyOptions: { 138 | duration: 100, 139 | }, 140 | editor: editor, 141 | }, 142 | h( 143 | "button", 144 | { 145 | onClick: () => 146 | editor 147 | .chain() 148 | .focus() 149 | .toggleHeading({ 150 | level: 1, 151 | }) 152 | .run(), 153 | className: editor.isActive("heading", { 154 | level: 1, 155 | }) 156 | ? "is-active" 157 | : "", 158 | }, 159 | "H1", 160 | ), 161 | h( 162 | "button", 163 | { 164 | onClick: () => 165 | editor 166 | .chain() 167 | .focus() 168 | .toggleHeading({ 169 | level: 2, 170 | }) 171 | .run(), 172 | className: editor.isActive("heading", { 173 | level: 2, 174 | }) 175 | ? "is-active" 176 | : "", 177 | }, 178 | "H2", 179 | ), 180 | h( 181 | "button", 182 | { 183 | onClick: () => editor.chain().focus().toggleBulletList().run(), 184 | className: editor.isActive("bulletList") ? "is-active" : "", 185 | }, 186 | "Bullet list", 187 | ), 188 | ), 189 | ); 190 | 191 | return h( 192 | "div", 193 | { className: "column-half" }, 194 | menu, 195 | h(EditorContent, { 196 | editor: editor, 197 | className: "main-group", 198 | }), 199 | h( 200 | "div", 201 | { 202 | className: "collab-status-group", 203 | "data-state": status === "connected" ? "online" : "offline", 204 | }, 205 | h( 206 | "label", 207 | null, 208 | status === "connected" 209 | ? `${editor.storage.collaborationCursor.users.length} user${editor.storage.collaborationCursor.users.length === 1 ? "" : "s"} online in ${room}` 210 | : "offline", 211 | ), 212 | h( 213 | "button", 214 | { 215 | style: { 216 | "--color": currentUser.color, 217 | }, 218 | onClick: setName, 219 | }, 220 | "\u270E ", 221 | currentUser.name, 222 | ), 223 | ), 224 | ); 225 | }; 226 | 227 | export default Editor; 228 | -------------------------------------------------------------------------------- /playground/public/tiptap/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tiptap + y-crossws 4 | 5 | 6 | 7 | 8 | 9 | 10 |
13 |
14 |
15 | 16 |
17 | 18 | 19 | Loading demo... 20 |
21 |
22 |
23 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /playground/public/tiptap/index.js: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/ueberdosis/tiptap/blob/main/demos/src/Demos/CollaborationSplitPane/React/index.jsx 2 | 3 | import { WebsocketProvider } from "y-websocket"; 4 | import * as Y from "yjs"; 5 | import Editor from "./editor.js"; 6 | import React from "react"; 7 | import { createRoot } from "react-dom/client"; 8 | 9 | const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:"; 10 | const wsUrl = `${wsProto}//${window.location.host}/_ws`; 11 | 12 | const room = `room-${new Date().toISOString().split("T")[0].replace(/-/g, ".")}`; 13 | 14 | // ydoc and provider for Editor A 15 | const ydocA = new Y.Doc(); 16 | 17 | const providerA = new WebsocketProvider(wsUrl, "tiptap", ydocA); 18 | 19 | // ydoc and provider for Editor B 20 | const ydocB = new Y.Doc(); 21 | const providerB = new WebsocketProvider(wsUrl, "tiptap", ydocB); 22 | 23 | const h = React.createElement; 24 | 25 | const App = () => { 26 | return h( 27 | "div", 28 | { className: "col-group" }, 29 | h(Editor, { 30 | provider: providerA, 31 | ydoc: ydocA, 32 | room: room, 33 | }), 34 | h(Editor, { 35 | provider: providerB, 36 | ydoc: ydocB, 37 | room: room, 38 | }), 39 | ); 40 | }; 41 | 42 | const root = createRoot(document.querySelector("#editor")); 43 | root.render(App()); 44 | -------------------------------------------------------------------------------- /playground/public/tiptap/style.css: -------------------------------------------------------------------------------- 1 | /* Source: https://github.com/ueberdosis/tiptap/blob/main/demos/src/Demos/CollaborationSplitPane/React/styles.scss */ 2 | 3 | /* Basic editor styles */ 4 | .tiptap { 5 | /* List styles */ 6 | /* Heading styles */ 7 | /* Code and preformatted text styles */ 8 | /* Highlight specific styles */ 9 | /* Task list specific styles */ 10 | /* Give a remote user a caret */ 11 | /* Render the username above the caret */ 12 | } 13 | .tiptap :first-child { 14 | margin-top: 0; 15 | } 16 | .tiptap ul, 17 | .tiptap ol { 18 | padding: 0 1rem; 19 | margin: 1.25rem 1rem 1.25rem 0.4rem; 20 | } 21 | .tiptap ul li p, 22 | .tiptap ol li p { 23 | margin-top: 0.25em; 24 | margin-bottom: 0.25em; 25 | } 26 | .tiptap h1, 27 | .tiptap h2, 28 | .tiptap h3, 29 | .tiptap h4, 30 | .tiptap h5, 31 | .tiptap h6 { 32 | line-height: 1.1; 33 | margin-top: 2.5rem; 34 | text-wrap: pretty; 35 | } 36 | .tiptap h1, 37 | .tiptap h2 { 38 | margin-top: 3.5rem; 39 | margin-bottom: 1.5rem; 40 | } 41 | .tiptap h1 { 42 | font-size: 1.4rem; 43 | } 44 | .tiptap h2 { 45 | font-size: 1.2rem; 46 | } 47 | .tiptap h3 { 48 | font-size: 1.1rem; 49 | } 50 | .tiptap h4, 51 | .tiptap h5, 52 | .tiptap h6 { 53 | font-size: 1rem; 54 | } 55 | .tiptap code { 56 | background-color: var(--purple-light); 57 | border-radius: 0.4rem; 58 | color: var(--black); 59 | font-size: 0.85rem; 60 | padding: 0.25em 0.3em; 61 | } 62 | .tiptap pre { 63 | background: var(--black); 64 | border-radius: 0.5rem; 65 | color: var(--white); 66 | font-family: "JetBrainsMono", monospace; 67 | margin: 1.5rem 0; 68 | padding: 0.75rem 1rem; 69 | } 70 | .tiptap pre code { 71 | background: none; 72 | color: inherit; 73 | font-size: 0.8rem; 74 | padding: 0; 75 | } 76 | .tiptap blockquote { 77 | border-left: 3px solid var(--gray-3); 78 | margin: 1.5rem 0; 79 | padding-left: 1rem; 80 | } 81 | .tiptap hr { 82 | border: none; 83 | border-top: 1px solid var(--gray-2); 84 | margin: 2rem 0; 85 | } 86 | .tiptap mark { 87 | background-color: #faf594; 88 | border-radius: 0.4rem; 89 | box-decoration-break: clone; 90 | padding: 0.1rem 0.3rem; 91 | } 92 | .tiptap ul[data-type="taskList"] { 93 | list-style: none; 94 | margin-left: 0; 95 | padding: 0; 96 | } 97 | .tiptap ul[data-type="taskList"] li { 98 | align-items: flex-start; 99 | display: flex; 100 | } 101 | .tiptap ul[data-type="taskList"] li > label { 102 | flex: 0 0 auto; 103 | margin-right: 0.5rem; 104 | user-select: none; 105 | } 106 | .tiptap ul[data-type="taskList"] li > div { 107 | flex: 1 1 auto; 108 | } 109 | .tiptap ul[data-type="taskList"] input[type="checkbox"] { 110 | cursor: pointer; 111 | } 112 | .tiptap ul[data-type="taskList"] ul[data-type="taskList"] { 113 | margin: 0; 114 | } 115 | .tiptap p { 116 | word-break: break-all; 117 | } 118 | .tiptap .collaboration-cursor__caret { 119 | border-left: 1px solid #0d0d0d; 120 | border-right: 1px solid #0d0d0d; 121 | margin-left: -1px; 122 | margin-right: -1px; 123 | pointer-events: none; 124 | position: relative; 125 | word-break: normal; 126 | } 127 | .tiptap .collaboration-cursor__label { 128 | border-radius: 3px 3px 3px 0; 129 | color: #0d0d0d; 130 | font-size: 12px; 131 | font-style: normal; 132 | font-weight: 600; 133 | left: -1px; 134 | line-height: normal; 135 | padding: 0.1rem 0.3rem; 136 | position: absolute; 137 | top: -1.4em; 138 | user-select: none; 139 | white-space: nowrap; 140 | } 141 | .col-group { 142 | display: flex; 143 | flex-direction: row; 144 | height: 100vh; 145 | } 146 | @media (max-width: 540px) { 147 | .col-group { 148 | flex-direction: column; 149 | } 150 | } 151 | /* Column-half */ 152 | body { 153 | overflow: hidden; 154 | } 155 | .column-half { 156 | display: flex; 157 | flex-direction: column; 158 | flex: 1; 159 | overflow: auto; 160 | } 161 | .column-half:last-child { 162 | border-left: 1px solid var(--gray-3); 163 | } 164 | @media (max-width: 540px) { 165 | .column-half:last-child { 166 | border-left: none; 167 | border-top: 1px solid var(--gray-3); 168 | } 169 | } 170 | .column-half > .main-group { 171 | flex-grow: 1; 172 | } 173 | /* Collaboration status */ 174 | .collab-status-group { 175 | align-items: center; 176 | background-color: var(--white); 177 | border-top: 1px solid var(--gray-3); 178 | bottom: 0; 179 | color: var(--gray-5); 180 | display: flex; 181 | flex-direction: row; 182 | font-size: 0.75rem; 183 | font-weight: 400; 184 | gap: 1rem; 185 | justify-content: space-between; 186 | padding: 0.375rem 0.5rem 0.375rem 1rem; 187 | position: sticky; 188 | width: 100%; 189 | z-index: 100; 190 | } 191 | .collab-status-group button { 192 | -webkit-box-orient: vertical; 193 | -webkit-line-clamp: 1; 194 | align-self: stretch; 195 | background: none; 196 | display: -webkit-box; 197 | flex-shrink: 1; 198 | font-size: 0.75rem; 199 | max-width: 100%; 200 | padding: 0.25rem 0.375rem; 201 | overflow: hidden; 202 | position: relative; 203 | text-overflow: ellipsis; 204 | white-space: nowrap; 205 | } 206 | .collab-status-group button::before { 207 | background-color: var(--color); 208 | border-radius: 0.375rem; 209 | content: ""; 210 | height: 100%; 211 | left: 0; 212 | opacity: 0.5; 213 | position: absolute; 214 | top: 0; 215 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 216 | width: 100%; 217 | z-index: -1; 218 | } 219 | .collab-status-group button:hover::before { 220 | opacity: 1; 221 | } 222 | .collab-status-group label { 223 | align-items: center; 224 | display: flex; 225 | flex-direction: row; 226 | flex-shrink: 0; 227 | gap: 0.375rem; 228 | line-height: 1.1; 229 | } 230 | .collab-status-group label::before { 231 | border-radius: 50%; 232 | content: " "; 233 | height: 0.35rem; 234 | width: 0.35rem; 235 | } 236 | .collab-status-group[data-state="online"] label::before { 237 | background-color: var(--green); 238 | } 239 | .collab-status-group[data-state="offline"] label::before { 240 | background-color: var(--red); 241 | } 242 | 243 | /* Bubble menu */ 244 | .bubble-menu { 245 | background-color: var(--white); 246 | border: 1px solid var(--gray-1); 247 | border-radius: 0.7rem; 248 | box-shadow: var(--shadow); 249 | display: flex; 250 | padding: 0.2rem; 251 | } 252 | .bubble-menu button { 253 | background-color: unset; 254 | } 255 | .bubble-menu button:hover { 256 | background-color: var(--gray-3); 257 | } 258 | .bubble-menu button.is-active { 259 | background-color: var(--purple); 260 | } 261 | .bubble-menu button.is-active:hover { 262 | background-color: var(--purple-contrast); 263 | } 264 | /* Floating menu */ 265 | .floating-menu { 266 | display: flex; 267 | background-color: var(--gray-3); 268 | padding: 0.1rem; 269 | border-radius: 0.5rem; 270 | } 271 | .floating-menu button { 272 | background-color: unset; 273 | padding: 0.275rem 0.425rem; 274 | border-radius: 0.3rem; 275 | } 276 | .floating-menu button:hover { 277 | background-color: var(--gray-3); 278 | } 279 | .floating-menu button.is-active { 280 | background-color: var(--white); 281 | color: var(--purple); 282 | } 283 | .floating-menu button.is-active:hover { 284 | color: var(--purple-contrast); 285 | } 286 | -------------------------------------------------------------------------------- /playground/public/tiptap/theme.css: -------------------------------------------------------------------------------- 1 | /* Shamefully stolen from https://embed.tiptap.dev/assets/helper-Bzo17db4.css to avoid tailwind build! */ 2 | 3 | :root { 4 | --white: #fff; 5 | --black: #2e2b29; 6 | --black-contrast: #110f0e; 7 | --gray-1: rgba(61, 37, 20, 0.05); 8 | --gray-2: rgba(61, 37, 20, 0.08); 9 | --gray-3: rgba(61, 37, 20, 0.12); 10 | --gray-4: rgba(53, 38, 28, 0.3); 11 | --gray-5: rgba(28, 25, 23, 0.6); 12 | --green: #22c55e; 13 | --purple: #6a00f5; 14 | --purple-contrast: #5800cc; 15 | --purple-light: rgba(88, 5, 255, 0.05); 16 | --yellow-contrast: #facc15; 17 | --yellow: rgba(250, 204, 21, 0.4); 18 | --yellow-light: #fffae5; 19 | --red: #ff5c33; 20 | --red-light: #ffebe5; 21 | --shadow: 0px 12px 33px 0px rgba(0, 0, 0, 0.06), 22 | 0px 3.618px 9.949px 0px rgba(0, 0, 0, 0.04); 23 | } 24 | *, 25 | *:before, 26 | *:after { 27 | box-sizing: border-box; 28 | } 29 | html { 30 | font-family: 31 | Inter, 32 | ui-sans-serif, 33 | system-ui, 34 | -apple-system, 35 | BlinkMacSystemFont, 36 | Segoe UI, 37 | Roboto, 38 | Helvetica Neue, 39 | Arial, 40 | Noto Sans, 41 | sans-serif, 42 | "Apple Color Emoji", 43 | "Segoe UI Emoji", 44 | Segoe UI Symbol, 45 | "Noto Color Emoji"; 46 | line-height: 1.5; 47 | -moz-osx-font-smoothing: grayscale; 48 | -webkit-font-smoothing: antialiased; 49 | } 50 | body { 51 | min-height: 25rem; 52 | margin: 0; 53 | } 54 | :first-child { 55 | margin-top: 0; 56 | } 57 | .tiptap { 58 | caret-color: var(--purple); 59 | margin: 1.5rem; 60 | } 61 | .tiptap:focus { 62 | outline: none; 63 | } 64 | ::-webkit-scrollbar { 65 | height: 14px; 66 | width: 14px; 67 | } 68 | ::-webkit-scrollbar-track { 69 | background-clip: padding-box; 70 | background-color: transparent; 71 | border: 4px solid transparent; 72 | border-radius: 8px; 73 | } 74 | ::-webkit-scrollbar-thumb { 75 | background-clip: padding-box; 76 | background-color: #0000; 77 | border: 4px solid rgba(0, 0, 0, 0); 78 | border-radius: 8px; 79 | } 80 | :hover::-webkit-scrollbar-thumb { 81 | background-color: #0000001a; 82 | } 83 | ::-webkit-scrollbar-thumb:hover { 84 | background-color: #00000026; 85 | } 86 | ::-webkit-scrollbar-button { 87 | display: none; 88 | height: 0; 89 | width: 0; 90 | } 91 | ::-webkit-scrollbar-corner { 92 | background-color: transparent; 93 | } 94 | button, 95 | input, 96 | select, 97 | textarea { 98 | background: var(--gray-2); 99 | border-radius: 0.5rem; 100 | border: none; 101 | color: var(--black); 102 | font-family: inherit; 103 | font-size: 0.875rem; 104 | font-weight: 500; 105 | line-height: 1.15; 106 | margin: none; 107 | padding: 0.375rem 0.625rem; 108 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 109 | } 110 | button:hover, 111 | input:hover, 112 | select:hover, 113 | textarea:hover { 114 | background-color: var(--gray-3); 115 | color: var(--black-contrast); 116 | } 117 | button[disabled], 118 | input[disabled], 119 | select[disabled], 120 | textarea[disabled] { 121 | background: var(--gray-1); 122 | color: var(--gray-4); 123 | } 124 | button:checked, 125 | input:checked, 126 | select:checked, 127 | textarea:checked { 128 | accent-color: var(--purple); 129 | } 130 | button.primary, 131 | input.primary, 132 | select.primary, 133 | textarea.primary { 134 | background: var(--black); 135 | color: var(--white); 136 | } 137 | button.primary:hover, 138 | input.primary:hover, 139 | select.primary:hover, 140 | textarea.primary:hover { 141 | background-color: var(--black-contrast); 142 | } 143 | button.primary[disabled], 144 | input.primary[disabled], 145 | select.primary[disabled], 146 | textarea.primary[disabled] { 147 | background: var(--gray-1); 148 | color: var(--gray-4); 149 | } 150 | button.is-active, 151 | input.is-active, 152 | select.is-active, 153 | textarea.is-active { 154 | background: var(--purple); 155 | color: var(--white); 156 | } 157 | button.is-active:hover, 158 | input.is-active:hover, 159 | select.is-active:hover, 160 | textarea.is-active:hover { 161 | background-color: var(--purple-contrast); 162 | color: var(--white); 163 | } 164 | button:not([disabled]), 165 | select:not([disabled]) { 166 | cursor: pointer; 167 | } 168 | input[type="text"], 169 | textarea { 170 | background-color: unset; 171 | border: 1px solid var(--gray-3); 172 | border-radius: 0.5rem; 173 | color: var(--black); 174 | } 175 | input[type="text"]::-moz-placeholder, 176 | textarea::-moz-placeholder { 177 | color: var(--gray-4); 178 | } 179 | input[type="text"]::placeholder, 180 | textarea::placeholder { 181 | color: var(--gray-4); 182 | } 183 | input[type="text"]:hover, 184 | textarea:hover { 185 | background-color: unset; 186 | border-color: var(--gray-4); 187 | } 188 | input[type="text"]:focus-visible, 189 | input[type="text"]:focus, 190 | textarea:focus-visible, 191 | textarea:focus { 192 | border-color: var(--purple); 193 | outline: none; 194 | } 195 | select { 196 | appearance: none; 197 | -webkit-appearance: none; 198 | -moz-appearance: none; 199 | background-image: url('data:image/svg+xml;utf8,'); 200 | background-repeat: no-repeat; 201 | background-position: right 0.1rem center; 202 | background-size: 1.25rem 1.25rem; 203 | padding-right: 1.25rem; 204 | } 205 | select:focus { 206 | outline: 0; 207 | } 208 | form { 209 | align-items: flex-start; 210 | display: flex; 211 | flex-direction: column; 212 | gap: 0.25rem; 213 | } 214 | .hint { 215 | align-items: center; 216 | background-color: var(--yellow-light); 217 | border-radius: 0.5rem; 218 | border: 1px solid var(--gray-2); 219 | display: flex; 220 | flex-direction: row; 221 | font-size: 0.75rem; 222 | gap: 0.25rem; 223 | line-height: 1.15; 224 | padding: 0.3rem 0.5rem; 225 | } 226 | .hint.purple-spinner, 227 | .hint.error { 228 | justify-content: center; 229 | text-align: center; 230 | width: 100%; 231 | } 232 | .hint .badge { 233 | background-color: var(--gray-1); 234 | border: 1px solid var(--gray-3); 235 | border-radius: 2rem; 236 | color: var(--gray-5); 237 | font-size: 0.625rem; 238 | font-weight: 700; 239 | line-height: 1; 240 | padding: 0.25rem 0.5rem; 241 | } 242 | .hint.purple-spinner { 243 | background-color: var(--purple-light); 244 | } 245 | .hint.purple-spinner:after { 246 | content: ""; 247 | background-image: url("data:image/svg+xml;utf8,"); 248 | background-size: cover; 249 | background-repeat: no-repeat; 250 | background-position: center; 251 | height: 1rem; 252 | width: 1rem; 253 | } 254 | .hint.error { 255 | background-color: var(--red-light); 256 | } 257 | .label, 258 | .label-small, 259 | .label-large { 260 | color: var(--black); 261 | font-size: 0.8125rem; 262 | font-weight: 500; 263 | line-height: 1.15; 264 | } 265 | .label-small { 266 | color: var(--gray-5); 267 | font-size: 0.75rem; 268 | font-weight: 400; 269 | } 270 | .label-large { 271 | font-size: 0.875rem; 272 | font-weight: 700; 273 | } 274 | hr { 275 | border: none; 276 | border-top: 1px solid var(--gray-3); 277 | margin: 0; 278 | width: 100%; 279 | } 280 | kbd { 281 | background-color: var(--gray-2); 282 | border: 1px solid var(--gray-2); 283 | border-radius: 0.25rem; 284 | font-size: 0.6rem; 285 | line-height: 1.15; 286 | padding: 0.1rem 0.25rem; 287 | text-transform: uppercase; 288 | } 289 | #app, 290 | .container { 291 | display: flex; 292 | flex-direction: column; 293 | } 294 | .button-group { 295 | display: flex; 296 | flex-wrap: wrap; 297 | gap: 0.25rem; 298 | } 299 | .control-group { 300 | align-items: flex-start; 301 | background-color: var(--white); 302 | display: flex; 303 | flex-direction: column; 304 | gap: 1rem; 305 | padding: 1.5rem; 306 | } 307 | .control-group .sticky { 308 | position: sticky; 309 | top: 0; 310 | } 311 | [data-node-view-wrapper] > .control-group { 312 | padding: 0; 313 | } 314 | .flex-row { 315 | display: flex; 316 | flex-direction: row; 317 | flex-wrap: wrap; 318 | gap: 1rem; 319 | justify-content: space-between; 320 | width: 100%; 321 | } 322 | .switch-group { 323 | align-items: center; 324 | background: var(--gray-2); 325 | border-radius: 0.5rem; 326 | display: flex; 327 | flex-direction: row; 328 | flex-wrap: wrap; 329 | flex: 0 1 auto; 330 | justify-content: flex-start; 331 | padding: 0.125rem; 332 | } 333 | .switch-group label { 334 | align-items: center; 335 | border-radius: 0.375rem; 336 | color: var(--gray-5); 337 | cursor: pointer; 338 | display: flex; 339 | flex-direction: row; 340 | font-size: 0.75rem; 341 | font-weight: 500; 342 | gap: 0.25rem; 343 | line-height: 1.15; 344 | min-height: 1.5rem; 345 | padding: 0 0.375rem; 346 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 347 | } 348 | .switch-group label:has(input:checked) { 349 | background-color: var(--white); 350 | color: var(--black-contrast); 351 | } 352 | .switch-group label:hover { 353 | color: var(--black); 354 | } 355 | .switch-group label input { 356 | display: none; 357 | margin: unset; 358 | } 359 | .output-group { 360 | background-color: var(--gray-1); 361 | display: flex; 362 | flex-direction: column; 363 | font-family: JetBrainsMono, monospace; 364 | font-size: 0.75rem; 365 | gap: 1rem; 366 | margin-top: 2.5rem; 367 | padding: 1.5rem; 368 | } 369 | .output-group label { 370 | color: var(--black); 371 | font-size: 0.875rem; 372 | font-weight: 700; 373 | line-height: 1.15; 374 | } 375 | -------------------------------------------------------------------------------- /playground/wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2024-09-25" 2 | 3 | name = "y-crossws" 4 | 5 | site = { bucket = "./public" } 6 | 7 | main = "./cf.ts" 8 | 9 | durable_objects.bindings = [{ name = "$DurableObject", class_name = "$DurableObject" }] 10 | 11 | migrations = [{ tag = "v1", new_classes = ["$DurableObject"] }] 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>unjs/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Server 2 | export { createHandler } from "./server.ts"; 3 | export type { YCrosswsHandler } from "./server.ts"; 4 | 5 | // Provider 6 | export { WebsocketProvider } from "./provider.ts"; 7 | export type { WebsocketProviderOptions } from "./provider.ts"; 8 | -------------------------------------------------------------------------------- /src/provider.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import * as syncProtocol from "y-protocols/sync"; 3 | import * as authProtocol from "y-protocols/auth"; 4 | import * as awarenessProtocol from "y-protocols/awareness"; 5 | import * as bc from "lib0/broadcastchannel"; 6 | import * as encoding from "lib0/encoding"; 7 | import * as decoding from "lib0/decoding"; 8 | import { ObservableV2 } from "lib0/observable"; 9 | 10 | type Fn = (...args: any[]) => any; 11 | 12 | export interface WebsocketProviderOptions { 13 | /** 14 | * URL parameters 15 | * @default {} 16 | */ 17 | params?: Record; 18 | /** 19 | * Specify websocket protocols 20 | * @default [] 21 | */ 22 | protocols?: Array; 23 | /** 24 | * WebSocket polyfill 25 | * @default WebSocket 26 | */ 27 | WebSocketPolyfill?: typeof WebSocket; 28 | /** 29 | * Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential. 30 | * @default 2500 31 | */ 32 | maxBackoffTime?: number; 33 | /** 34 | * Request server state every `resyncInterval` milliseconds 35 | * @default -1 36 | */ 37 | resyncInterval?: number; 38 | /** 39 | * Whether to connect to other peers or not 40 | * @default true 41 | */ 42 | connect?: boolean; 43 | /** 44 | * Awareness instance 45 | */ 46 | awareness?: awarenessProtocol.Awareness; 47 | /** 48 | * Disable cross-tab BroadcastChannel communication 49 | * @default false 50 | */ 51 | disableBc?: boolean; 52 | } 53 | 54 | // TODO: this should depend on awareness.outdatedTime 55 | const messageReconnectTimeout = 30_000; 56 | 57 | // encoder, decoder, provider, emitSynced, messageType 58 | type MessageHandler = ( 59 | encoder: encoding.Encoder, 60 | decoder: decoding.Decoder, 61 | provider: WebsocketProvider, 62 | emitSynced: boolean, 63 | messageType: number, 64 | ) => void; 65 | 66 | const messageSync = 0; 67 | const messageAwareness = 1; 68 | // const messageAuth = 2; 69 | const messageQueryAwareness = 3; 70 | 71 | const messageHandlers: [ 72 | MessageHandler, 73 | MessageHandler, 74 | MessageHandler, 75 | MessageHandler, 76 | ] = [ 77 | // 0: messageSync 78 | (encoder, decoder, provider, emitSynced, _messageType) => { 79 | encoding.writeVarUint(encoder, messageSync); 80 | const syncMessageType = syncProtocol.readSyncMessage( 81 | decoder, 82 | encoder, 83 | provider.doc, 84 | provider, 85 | ); 86 | if ( 87 | emitSynced && 88 | syncMessageType === syncProtocol.messageYjsSyncStep2 && 89 | !provider.synced 90 | ) { 91 | provider.synced = true; 92 | } 93 | }, 94 | // 1: messageAwareness 95 | (encoder, decoder, provider, _emitSynced, _messageType) => { 96 | awarenessProtocol.applyAwarenessUpdate( 97 | provider.awareness, 98 | decoding.readVarUint8Array(decoder), 99 | provider, 100 | ); 101 | }, 102 | // 2: messageAuth 103 | (_encoder, decoder, provider, _emitSynced, _messageType) => { 104 | authProtocol.readAuthMessage(decoder, provider.doc, (_ydoc, reason) => { 105 | console.warn( 106 | `[y-crossws-provider] Permission denied to access ${provider.url}.\n${reason}`, 107 | ); 108 | }); 109 | }, 110 | // 3: messageQueryAwareness 111 | (encoder, _decoder, provider, _emitSynced, _messageType) => { 112 | encoding.writeVarUint(encoder, messageAwareness); 113 | encoding.writeVarUint8Array( 114 | encoder, 115 | awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ 116 | ...provider.awareness.getStates().keys(), 117 | ]), 118 | ); 119 | }, 120 | ]; 121 | 122 | /** 123 | * Websocket Provider for Yjs. Creates a websocket connection to sync the shared document. 124 | * The document name is attached to the provided url. I.e. the following example 125 | * creates a websocket connection to http://localhost:1234/my-document-name 126 | * 127 | * @example 128 | * import * as Y from 'yjs' 129 | * import { WebsocketProvider } from 'y-crossws' 130 | * const doc = new Y.Doc() 131 | * const provider = new WebsocketProvider('http://localhost:1234', 'my-document-name', doc) 132 | */ 133 | export class WebsocketProvider extends ObservableV2 { 134 | serverUrl: string; 135 | bcChannel: string; 136 | maxBackoffTime: number; 137 | params: Record; 138 | protocols: string[]; 139 | roomname: string; 140 | disableBc: boolean; 141 | shouldConnect: boolean; 142 | 143 | doc: Y.Doc; 144 | awareness: awarenessProtocol.Awareness; 145 | 146 | ws?: WebSocket; 147 | wsconnected: boolean = false; 148 | wsconnecting: boolean = false; 149 | wsUnsuccessfulReconnects: number = 0; 150 | wsLastMessageReceived: number = 0; 151 | bcconnected: boolean = false; 152 | messageHandlers: Array; 153 | 154 | _WS: typeof WebSocket; 155 | _synced: boolean = false; 156 | _resyncInterval: number | ReturnType = 0; 157 | _checkInterval?: ReturnType; 158 | _bcSubscriber: (data: ArrayBuffer, origin: any) => void; 159 | _updateHandler: (update: Uint8Array, origin: any) => void; 160 | _awarenessUpdateHandler: (update: any, origin: any) => void; 161 | _exitHandler: () => void; 162 | 163 | constructor( 164 | serverUrl: string, 165 | roomname: string, 166 | doc: Y.Doc, 167 | { 168 | connect = true, 169 | awareness = new awarenessProtocol.Awareness(doc), 170 | params = {}, 171 | protocols = [], 172 | WebSocketPolyfill = WebSocket, 173 | resyncInterval = -1, 174 | maxBackoffTime = 2500, 175 | disableBc = false, 176 | }: WebsocketProviderOptions = {}, 177 | ) { 178 | super(); 179 | 180 | this.serverUrl = serverUrl.replace(/\/$/, ""); 181 | this.bcChannel = this.serverUrl + "/" + roomname; 182 | this.maxBackoffTime = maxBackoffTime; 183 | this.params = params; 184 | this.protocols = protocols; 185 | this.roomname = roomname; 186 | this.doc = doc; 187 | this._WS = WebSocketPolyfill; 188 | this.awareness = awareness; 189 | this.disableBc = disableBc; 190 | this.shouldConnect = connect; 191 | this.messageHandlers = [...messageHandlers]; 192 | 193 | if (resyncInterval > 0) { 194 | this._resyncInterval = setInterval(() => { 195 | if (this.ws?.readyState === WebSocket.OPEN) { 196 | // Resend sync step 1 197 | const encoder = encoding.createEncoder(); 198 | encoding.writeVarUint(encoder, messageSync); 199 | syncProtocol.writeSyncStep1(encoder, doc); 200 | this.ws.send(encoding.toUint8Array(encoder)); 201 | } 202 | }, resyncInterval); 203 | } 204 | 205 | this._bcSubscriber = (data, origin) => { 206 | if (origin !== this) { 207 | const encoder = readMessage(this, new Uint8Array(data), false); 208 | if (encoding.length(encoder) > 1) { 209 | bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this); 210 | } 211 | } 212 | }; 213 | 214 | this._updateHandler = (update, origin) => { 215 | if (origin !== this) { 216 | const encoder = encoding.createEncoder(); 217 | encoding.writeVarUint(encoder, messageSync); 218 | syncProtocol.writeUpdate(encoder, update); 219 | broadcastMessage(this, encoding.toUint8Array(encoder)); 220 | } 221 | }; 222 | 223 | this.doc.on("update", this._updateHandler); 224 | 225 | this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => { 226 | // eslint-disable-next-line unicorn/prefer-spread 227 | const changedClients = added.concat(updated).concat(removed); 228 | const encoder = encoding.createEncoder(); 229 | encoding.writeVarUint(encoder, messageAwareness); 230 | encoding.writeVarUint8Array( 231 | encoder, 232 | awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients), 233 | ); 234 | broadcastMessage(this, encoding.toUint8Array(encoder)); 235 | }; 236 | 237 | this._exitHandler = () => { 238 | awarenessProtocol.removeAwarenessStates( 239 | this.awareness, 240 | [doc.clientID], 241 | "app closed", 242 | ); 243 | }; 244 | 245 | if (typeof globalThis.process?.on === "function") { 246 | globalThis.process.on("exit", this._exitHandler); 247 | } 248 | 249 | awareness.on("update", this._awarenessUpdateHandler); 250 | 251 | this._checkInterval = setInterval(() => { 252 | if ( 253 | this.wsconnected && 254 | messageReconnectTimeout < Date.now() - this.wsLastMessageReceived 255 | ) { 256 | // no message received in a long time - not even your own awareness 257 | // updates (which are updated every 15 seconds) 258 | this.ws?.close(); 259 | } 260 | }, messageReconnectTimeout / 10); 261 | 262 | if (connect) { 263 | this.connect(); 264 | } 265 | } 266 | 267 | get url() { 268 | const encodedParams = 269 | Object.keys(this.params).length > 0 270 | ? `?${new URLSearchParams(this.params).toString()}` 271 | : ""; 272 | return this.serverUrl + "/" + this.roomname + encodedParams; 273 | } 274 | 275 | get synced(): boolean { 276 | return this._synced; 277 | } 278 | 279 | set synced(state: boolean) { 280 | if (this._synced !== state) { 281 | this._synced = state; 282 | this.emit("synced", [state]); 283 | this.emit("sync", [state]); 284 | } 285 | } 286 | 287 | destroy() { 288 | if (this._resyncInterval !== 0) { 289 | clearInterval(this._resyncInterval); 290 | } 291 | clearInterval(this._checkInterval); 292 | this.disconnect(); 293 | if (typeof globalThis.process?.on === "function") { 294 | process.off("exit", this._exitHandler); 295 | } 296 | this.awareness.off("update", this._awarenessUpdateHandler); 297 | this.doc.off("update", this._updateHandler); 298 | super.destroy(); 299 | } 300 | 301 | connectBc() { 302 | if (this.disableBc) { 303 | return; 304 | } 305 | if (!this.bcconnected) { 306 | bc.subscribe(this.bcChannel, this._bcSubscriber); 307 | this.bcconnected = true; 308 | } 309 | // Send sync step1 to bc 310 | // Write sync step 1 311 | const encoderSync = encoding.createEncoder(); 312 | encoding.writeVarUint(encoderSync, messageSync); 313 | syncProtocol.writeSyncStep1(encoderSync, this.doc); 314 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this); 315 | // Broadcast local state 316 | const encoderState = encoding.createEncoder(); 317 | encoding.writeVarUint(encoderState, messageSync); 318 | syncProtocol.writeSyncStep2(encoderState, this.doc); 319 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this); 320 | // Write queryAwareness 321 | const encoderAwarenessQuery = encoding.createEncoder(); 322 | encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness); 323 | bc.publish( 324 | this.bcChannel, 325 | encoding.toUint8Array(encoderAwarenessQuery), 326 | this, 327 | ); 328 | // Broadcast local awareness state 329 | const encoderAwarenessState = encoding.createEncoder(); 330 | encoding.writeVarUint(encoderAwarenessState, messageAwareness); 331 | encoding.writeVarUint8Array( 332 | encoderAwarenessState, 333 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 334 | this.doc.clientID, 335 | ]), 336 | ); 337 | bc.publish( 338 | this.bcChannel, 339 | encoding.toUint8Array(encoderAwarenessState), 340 | this, 341 | ); 342 | } 343 | 344 | disconnectBc() { 345 | // Broadcast message with local awareness state set to null (indicating disconnect) 346 | const encoder = encoding.createEncoder(); 347 | encoding.writeVarUint(encoder, messageAwareness); 348 | encoding.writeVarUint8Array( 349 | encoder, 350 | awarenessProtocol.encodeAwarenessUpdate( 351 | this.awareness, 352 | [this.doc.clientID], 353 | new Map(), 354 | ), 355 | ); 356 | broadcastMessage(this, encoding.toUint8Array(encoder)); 357 | if (this.bcconnected) { 358 | bc.unsubscribe(this.bcChannel, this._bcSubscriber); 359 | this.bcconnected = false; 360 | } 361 | } 362 | 363 | disconnect() { 364 | this.shouldConnect = false; 365 | this.disconnectBc(); 366 | if (this.ws !== null) { 367 | this.ws?.close(); 368 | } 369 | } 370 | 371 | connect() { 372 | this.shouldConnect = true; 373 | if (!this.wsconnected && this.ws === null) { 374 | setupWS(this); 375 | this.connectBc(); 376 | } 377 | } 378 | } 379 | 380 | function setupWS(provider: WebsocketProvider) { 381 | if (provider.shouldConnect && provider.ws === null) { 382 | const _WebSocket = provider._WS || globalThis.WebSocket; 383 | const websocket = new _WebSocket(provider.url, provider.protocols); 384 | websocket.binaryType = "arraybuffer"; 385 | 386 | provider.ws = websocket; 387 | provider.wsconnecting = true; 388 | provider.wsconnected = false; 389 | provider.synced = false; 390 | 391 | websocket.addEventListener("message", (event) => { 392 | provider.wsLastMessageReceived = Date.now(); 393 | const encoder = readMessage(provider, new Uint8Array(event.data), true); 394 | if (encoding.length(encoder) > 1) { 395 | websocket.send(encoding.toUint8Array(encoder)); 396 | } 397 | }); 398 | 399 | websocket.addEventListener("error", (event) => { 400 | provider.emit("connection-error", [event, provider]); 401 | }); 402 | 403 | websocket.addEventListener("close", (event) => { 404 | provider.emit("connection-close", [event, provider]); 405 | provider.ws = undefined; 406 | provider.wsconnecting = false; 407 | if (provider.wsconnected) { 408 | provider.wsconnected = false; 409 | provider.synced = false; 410 | // Update awareness (all users except local left) 411 | awarenessProtocol.removeAwarenessStates( 412 | provider.awareness, 413 | [...provider.awareness.getStates().keys()].filter( 414 | (client) => client !== provider.doc.clientID, 415 | ), 416 | provider, 417 | ); 418 | provider.emit("status", [{ status: "disconnected" }]); 419 | } else { 420 | provider.wsUnsuccessfulReconnects++; 421 | } 422 | // Start with no reconnect timeout and increase timeout by 423 | // using exponential backoff starting with 100ms 424 | setTimeout( 425 | setupWS, 426 | Math.min( 427 | Math.pow(2, provider.wsUnsuccessfulReconnects) * 100, 428 | provider.maxBackoffTime, 429 | ), 430 | provider, 431 | ); 432 | }); 433 | 434 | websocket.addEventListener("open", () => { 435 | provider.wsLastMessageReceived = Date.now(); 436 | provider.wsconnecting = false; 437 | provider.wsconnected = true; 438 | provider.wsUnsuccessfulReconnects = 0; 439 | provider.emit("status", [{ status: "connected" }]); 440 | // Always send sync step 1 when connected 441 | const encoder = encoding.createEncoder(); 442 | encoding.writeVarUint(encoder, messageSync); 443 | syncProtocol.writeSyncStep1(encoder, provider.doc); 444 | websocket.send(encoding.toUint8Array(encoder)); 445 | // Broadcast local awareness state 446 | if (provider.awareness.getLocalState() !== null) { 447 | const encoderAwarenessState = encoding.createEncoder(); 448 | encoding.writeVarUint(encoderAwarenessState, messageAwareness); 449 | encoding.writeVarUint8Array( 450 | encoderAwarenessState, 451 | awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ 452 | provider.doc.clientID, 453 | ]), 454 | ); 455 | websocket.send(encoding.toUint8Array(encoderAwarenessState)); 456 | } 457 | }); 458 | 459 | provider.emit("status", [{ status: "connecting" }]); 460 | } 461 | } 462 | 463 | function broadcastMessage(provider: WebsocketProvider, buf: ArrayBuffer) { 464 | if (!provider.wsconnected) { 465 | return; 466 | } 467 | const ws = provider.ws; 468 | if (ws && ws?.readyState === ws.OPEN) { 469 | ws.send(buf); 470 | } 471 | bc.publish(provider.bcChannel, buf, provider); 472 | } 473 | 474 | function readMessage( 475 | provider: WebsocketProvider, 476 | buf: Uint8Array, 477 | emitSynced: boolean, 478 | ): encoding.Encoder { 479 | const decoder = decoding.createDecoder(buf); 480 | const encoder = encoding.createEncoder(); 481 | const messageType = decoding.readVarUint(decoder); 482 | const messageHandler = provider.messageHandlers[messageType]; 483 | if (messageHandler) { 484 | messageHandler(encoder, decoder, provider, emitSynced, messageType); 485 | } else { 486 | console.error("[y-crossws-provider] Unable to compute message"); 487 | } 488 | return encoder; 489 | } 490 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import type * as crossws from "crossws"; 2 | import * as Y from "yjs"; 3 | import * as syncProtocol from "y-protocols/sync"; 4 | import * as awarenessProtocol from "y-protocols/awareness"; 5 | import * as encoding from "lib0/encoding"; 6 | import * as decoding from "lib0/decoding"; 7 | 8 | export function createHandler(opts: YCrosswsOptions = {}): YCrosswsHandler { 9 | const yc = new YCrossws(opts); 10 | const hooks: Partial = { 11 | open(peer) { 12 | yc.onOpen(peer); 13 | }, 14 | message(peer, message) { 15 | yc.onMessage(peer, message); 16 | }, 17 | close(peer) { 18 | yc.onClose(peer); 19 | }, 20 | }; 21 | return { 22 | hooks: hooks as crossws.Hooks, 23 | }; 24 | } 25 | 26 | export class YCrossws { 27 | opts: YCrosswsOptions; 28 | persistence?: Persistence; 29 | docs: Map = new Map(); 30 | 31 | constructor(opts: YCrosswsOptions = {}) { 32 | this.opts = opts; 33 | } 34 | 35 | // --- crossws hooks --- 36 | 37 | onOpen(peer: crossws.Peer) { 38 | const doc = this.getDoc(peer); 39 | // Send sync step 1 40 | const encoder = encoding.createEncoder(); 41 | encoding.writeVarUint(encoder, messageSync); 42 | syncProtocol.writeSyncStep1(encoder, doc); 43 | peer.send(encoding.toUint8Array(encoder)); 44 | const awarenessStates = doc.awareness.getStates(); 45 | if (awarenessStates.size > 0) { 46 | const encoder = encoding.createEncoder(); 47 | encoding.writeVarUint(encoder, messageAwareness); 48 | encoding.writeVarUint8Array( 49 | encoder, 50 | awarenessProtocol.encodeAwarenessUpdate(doc.awareness, [ 51 | ...awarenessStates.keys(), 52 | ]), 53 | ); 54 | peer.send(encoding.toUint8Array(encoder)); 55 | } 56 | } 57 | 58 | onMessage(peer: crossws.Peer, message: crossws.Message) { 59 | const doc = this.getDoc(peer); 60 | try { 61 | const encoder = encoding.createEncoder(); 62 | const data = message.uint8Array(); 63 | const decoder = decoding.createDecoder(data); 64 | const messageType = decoding.readVarUint(decoder); 65 | switch (messageType) { 66 | case messageSync: { 67 | encoding.writeVarUint(encoder, messageSync); 68 | syncProtocol.readSyncMessage(decoder, encoder, doc, peer); 69 | // If the `encoder` only contains the type of reply message and no 70 | // message, there is no need to send the message. When `encoder` only 71 | // contains the type of reply, its length is 1. 72 | if (encoding.length(encoder) > 1) { 73 | peer.send(encoding.toUint8Array(encoder)); 74 | } 75 | break; 76 | } 77 | case messageAwareness: { 78 | awarenessProtocol.applyAwarenessUpdate( 79 | doc.awareness, 80 | decoding.readVarUint8Array(decoder), 81 | peer, 82 | ); 83 | break; 84 | } 85 | } 86 | } catch (error) { 87 | console.error(error); 88 | // @ts-expect-error 89 | doc.emit("error", [error]); 90 | } 91 | } 92 | 93 | onClose(peer: crossws.Peer) { 94 | const doc = this.getDoc(peer); 95 | if (doc.peerIds.has(peer)) { 96 | const controlledIds = doc.peerIds.get(peer) || []; 97 | doc.peerIds.delete(peer); 98 | awarenessProtocol.removeAwarenessStates( 99 | doc.awareness, 100 | [...controlledIds], 101 | undefined, 102 | ); 103 | if (doc.peerIds.size === 0 && this.persistence) { 104 | // If persisted, we store state and destroy ydocument 105 | this.persistence.writeState(doc.name, doc).then(() => { 106 | doc.destroy(); 107 | }); 108 | this.docs.delete(doc.name); 109 | } 110 | } 111 | // peer.close(); // TODO 112 | } 113 | 114 | // --- yjs hooks --- 115 | 116 | onDocUpdate( 117 | update: Uint8Array, 118 | _peer: crossws.Peer, 119 | doc: Y.Doc, 120 | _transaction: Y.Transaction, 121 | ) { 122 | const encoder = encoding.createEncoder(); 123 | encoding.writeVarUint(encoder, messageSync); 124 | syncProtocol.writeUpdate(encoder, update); 125 | const message = encoding.toUint8Array(encoder); 126 | for (const peer of (doc as SharedDoc).peerIds.keys()) { 127 | peer.send(message); 128 | } 129 | } 130 | 131 | // --- utils --- 132 | 133 | getDoc(peer: crossws.Peer): SharedDoc { 134 | if ((peer as any)._ycdoc) { 135 | return (peer as any)._ycdoc; 136 | } 137 | const docName = new URL(peer.request?.url!).pathname.slice(1); 138 | let doc = this.docs.get(docName); 139 | if (!doc) { 140 | doc = new SharedDoc(docName, this); 141 | doc.gc = true; 142 | this.persistence?.bindState(docName, doc); 143 | this.docs.set(docName, doc); 144 | } 145 | if (!doc.peerIds.has(peer)) { 146 | doc.peerIds.set(peer, new Set()); 147 | } 148 | (peer as any)._ycdoc = doc; 149 | return doc; 150 | } 151 | } 152 | 153 | // --------- Doc --------- 154 | 155 | export class SharedDoc extends Y.Doc { 156 | name: string; 157 | yc: YCrossws; 158 | awareness: awarenessProtocol.Awareness; 159 | peerIds: Map> = new Map(); 160 | 161 | constructor(name: string, yc: YCrossws) { 162 | super(); 163 | this.name = name; 164 | this.yc = yc; 165 | this.awareness = new awarenessProtocol.Awareness(this); 166 | this.awareness.setLocalState(null); 167 | this.awareness.on("update", this.onAwarenessUpdate.bind(this)); 168 | this.on("update", yc.onDocUpdate.bind(yc)); 169 | } 170 | 171 | onAwarenessUpdate(changes: AwarenessChanges, peer?: crossws.Peer) { 172 | // Update peerIds map 173 | if (peer) { 174 | const peerControlledIDs = this.peerIds.get(peer); 175 | if (peerControlledIDs !== undefined) { 176 | for (const clientID of changes.added) { 177 | peerControlledIDs.add(clientID); 178 | } 179 | for (const clientID of changes.removed) { 180 | peerControlledIDs.delete(clientID); 181 | } 182 | } 183 | } 184 | // Broadcast awareness update 185 | const encoder = encoding.createEncoder(); 186 | encoding.writeVarUint(encoder, messageAwareness); 187 | encoding.writeVarUint8Array( 188 | encoder, 189 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 190 | ...changes.added, 191 | ...changes.updated, 192 | ...changes.removed, 193 | ]), 194 | ); 195 | const buff = encoding.toUint8Array(encoder); 196 | for (const peer of this.peerIds.keys()) { 197 | peer.send(buff); 198 | } 199 | } 200 | } 201 | 202 | // --------- constants --------- 203 | 204 | export const messageSync = 0; 205 | export const messageAwareness = 1; 206 | // export const messageAuth = 2; 207 | 208 | // --------- types --------- 209 | 210 | export interface YCrosswsOptions {} 211 | 212 | export interface YCrosswsHandler { 213 | hooks: crossws.Hooks; 214 | } 215 | 216 | type AwarenessChanges = { 217 | added: number[]; 218 | updated: number[]; 219 | removed: number[]; 220 | }; 221 | 222 | export interface Persistence { 223 | bindState: (a: string, doc: SharedDoc) => void; 224 | writeState: (a: string, doc: SharedDoc) => Promise; 225 | provider: any; 226 | } 227 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "noEmit": true, 10 | "allowImportingTsExtensions": true, 11 | "types": ["@cloudflare/workers-types", "@types/deno", "@types/node"], 12 | "paths": { 13 | "y-crossws": ["./src/index.ts"], 14 | "y-crossws/provider": ["./src/provider.ts"] 15 | } 16 | }, 17 | "include": ["src", "playground"] 18 | } 19 | --------------------------------------------------------------------------------