├── .editorconfig ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── index.html ├── org ├── architecture.png └── websocket_hibernation.png ├── package-lock.json ├── package.json ├── src ├── durable │ └── YjsProvider.ts ├── index.ts ├── util.ts └── zooId.ts ├── tsconfig.json ├── vite-env.d.ts ├── vite.config.ts ├── worker-configuration.d.ts └── wrangler.jsonc /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | 174 | # other 175 | 176 | .DS_Store 177 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "yjs-provider", 5 | "type": "node", 6 | "request": "attach", 7 | "websocketAddress": "ws://localhost:9229/yjs-provider", 8 | "resolveSourceMapLocations": null, 9 | "attachExistingChildren": false, 10 | "autoAttachChildProcesses": false, 11 | "sourceMaps": true 12 | } 13 | ], 14 | "compounds": [ 15 | { 16 | "name": "Debug Workers", 17 | "configurations": ["yjs-provider"], 18 | "stopAll": true 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "wrangler.json": "jsonc" 4 | } 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Timo Wilhelm . 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yjs WebSocket Provider 2 | 3 | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TimoWilhelm/yjs-cf-ws-provider) 4 | 5 | > [!NOTE] 6 | > This project is intended for learning purposes and demonstration of the Cloudflare Workers and Durable Objects APIs. 7 | > If you are looking for a production-ready solution, check out [PartyKit](https://docs.partykit.io/guides/deploy-to-cloudflare/) which also 8 | > supports the [Yjs API](https://docs.partykit.io/reference/y-partykit-api/). 9 | 10 | This project implements a Serverless Yjs WebSocket provider using Cloudflare Workers + Durable Objects to relay messages between clients. It is fully compatible with the [Yjs WebSocket Connector](https://github.com/yjs/y-websocket). 11 | 12 | ![Architecture Diagram](org/architecture.png) 13 | 14 | This project uses the Cloudflare Durable Objects WebSocket Hibernation API to terminate WebSocket connections to avoid incurring duration charges when the connection is idle. 15 | 16 | ![WebSocket Hibernation](org/websocket_hibernation.png) 17 | 18 | It also periodically saves the Yjs document state to a Cloudflare R2 storage bucket and clears the partial updates from the Durable Object storage. The vacuum interval can be configured with the YJS_VACUUM_INTERVAL_IN_MS environment variable. The default is 30 seconds. 19 | 20 | ## Run locally 21 | 22 | ```bash 23 | npm install 24 | npm run dev 25 | ``` 26 | 27 | This will start a local server using the Wrangler CLI and serve a demo app. You can open a browser 28 | to the URL that is displayed in the console to test it with a simple [TipTap](https://tiptap.dev/) 29 | editor. 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Collaborative Editor Demo App 9 | 72 | 73 | 74 |
75 | 76 | Demo App for Durable Object Yjs WebSocket Provider 77 | 78 |
79 | Status: Disconnected 80 |
81 | 82 |
83 | 84 | 85 |
86 | 87 |
88 |
89 | Show QR Code 90 | 91 |
92 |
93 | 94 |
95 | 96 |
97 |
Connected Clients:
98 |
99 |
100 |
101 | 102 | 230 | 231 | 232 | -------------------------------------------------------------------------------- /org/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/yjs-cf-ws-provider/59c35864875a3bb9b7337fb89e7cbb38123d9a14/org/architecture.png -------------------------------------------------------------------------------- /org/websocket_hibernation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TimoWilhelm/yjs-cf-ws-provider/59c35864875a3bb9b7337fb89e7cbb38123d9a14/org/websocket_hibernation.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yjs-provider", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "npm run build && vite preview", 10 | "deploy": "npm run build && wrangler deploy", 11 | "types": "wrangler types --include-runtime=false" 12 | }, 13 | "devDependencies": { 14 | "@cloudflare/vite-plugin": "^1.0.12", 15 | "@cloudflare/vitest-pool-workers": "^0.8.19", 16 | "@cloudflare/workers-types": "^4.20250426.0", 17 | "@outerbase/browsable-durable-object": "^0.1.1", 18 | "@tiptap/core": "^2.11.7", 19 | "@tiptap/extension-collaboration": "^2.11.7", 20 | "@tiptap/extension-collaboration-cursor": "^2.11.7", 21 | "@tiptap/starter-kit": "^2.11.7", 22 | "hono": "^4.7.8", 23 | "lib0": "^0.2.104", 24 | "qrcode": "^1.5.4", 25 | "temporal-polyfill": "^0.3.0", 26 | "typescript": "^5.5.2", 27 | "vite": "^6.3.3", 28 | "vitest": "~3.0.7", 29 | "wrangler": "^4.14.4", 30 | "y-indexeddb": "^9.0.12", 31 | "y-prosemirror": "^1.3.4", 32 | "y-protocols": "^1.0.6", 33 | "y-websocket": "^3.0.0", 34 | "yjs": "^13.6.26" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/durable/YjsProvider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Partially adapted from https://github.com/yjs/y-websocket/ 3 | * 4 | * The MIT License (MIT) 5 | * 6 | * Copyright (c) 2025 Kevin Jahns . 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 | */ 26 | 27 | import { DurableObject } from 'cloudflare:workers'; 28 | import * as decoding from 'lib0/decoding'; 29 | import * as encoding from 'lib0/encoding'; 30 | import { equalityDeep } from 'lib0/function'; 31 | import { Temporal } from 'temporal-polyfill'; 32 | import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'; 33 | import * as Y from 'yjs'; 34 | import z from 'zod'; 35 | import { Browsable } from '@outerbase/browsable-durable-object'; 36 | 37 | type DbUpdate = { 38 | id: number; 39 | data: ArrayBuffer; 40 | }; 41 | 42 | interface SessionInfo { 43 | readonly: boolean; 44 | } 45 | 46 | const enum MESSAGE_TYPE { 47 | SYNC = 0, 48 | AWARENESS = 1, 49 | } 50 | 51 | const enum SYNC_MESSAGE_TYPE { 52 | STEP1 = 0, 53 | STEP2 = 1, 54 | UPDATE = 2, 55 | } 56 | 57 | @Browsable() 58 | export class YjsProvider extends DurableObject { 59 | private sessions = new Map< 60 | WebSocket, 61 | { 62 | controlledIds: Set; 63 | context: SessionInfo; 64 | } 65 | >(); 66 | 67 | private stateAsUpdateV2: Uint8Array = new Uint8Array(); 68 | 69 | private readonly awareness = new Awareness(new Y.Doc()); 70 | 71 | private readonly vacuumInterval: Temporal.Duration; 72 | 73 | constructor(ctx: DurableObjectState, env: Env) { 74 | super(ctx, env); 75 | 76 | this.setup(); 77 | 78 | const vacuumIntervalInMs = z.coerce.number().positive().optional().parse(env.YJS_VACUUM_INTERVAL_IN_MS); 79 | 80 | this.vacuumInterval = 81 | vacuumIntervalInMs === undefined 82 | ? Temporal.Duration.from({ seconds: 30 }) 83 | : Temporal.Duration.from({ milliseconds: vacuumIntervalInMs }); 84 | 85 | this.ctx.getWebSockets().forEach((ws) => { 86 | const meta = ws.deserializeAttachment(); 87 | this.sessions.set(ws, { ...meta }); 88 | }); 89 | 90 | // hydrate DO state 91 | void this.ctx.blockConcurrencyWhile(async () => { 92 | const updates = [] as Uint8Array[]; 93 | 94 | const result = await env.R2_YJS_BUCKET.get(`state:${this.ctx.id.toString()}`); 95 | if (result) { 96 | const baseUpdate = new Uint8Array(await result.arrayBuffer()); 97 | updates.push(baseUpdate); 98 | } 99 | 100 | const cursor = this.ctx.storage.sql.exec('SELECT * FROM doc_updates'); 101 | 102 | for (const row of cursor) { 103 | updates.push(new Uint8Array(row.data)); 104 | } 105 | 106 | this.stateAsUpdateV2 = Y.mergeUpdatesV2(updates); 107 | }); 108 | } 109 | 110 | public async fetch(request: Request): Promise { 111 | this.setup(); 112 | 113 | // setup alarm to vacuum storage 114 | const alarm = await this.ctx.storage.getAlarm(); 115 | if (alarm === null) { 116 | await this.ctx.storage.setAlarm(Temporal.Now.instant().add(this.vacuumInterval).epochMilliseconds); 117 | } 118 | 119 | const url = new URL(request.url); 120 | if (url.pathname !== '/ws') { 121 | return new Response('Not found', { status: 404 }); 122 | } 123 | 124 | if (request.headers.get('upgrade') !== 'websocket') { 125 | return new Response('Invalid Upgrade header', { status: 400 }); 126 | } 127 | 128 | return this.acceptWebsocket({ readonly: false }); 129 | } 130 | 131 | public async alarm(): Promise { 132 | this.setup(); 133 | await this.vacuum(); 134 | } 135 | 136 | public getSnapshot(): ReadableStream { 137 | this.setup(); 138 | 139 | return new ReadableStream({ 140 | start: (controller) => { 141 | controller.enqueue(this.stateAsUpdateV2); 142 | controller.close(); 143 | }, 144 | }); 145 | } 146 | 147 | public async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { 148 | this.setup(); 149 | 150 | if (typeof message === 'string') { 151 | return; 152 | } 153 | 154 | try { 155 | const decoder = decoding.createDecoder(new Uint8Array(message)); 156 | const messageType = decoding.readVarUint(decoder); 157 | 158 | switch (messageType) { 159 | case MESSAGE_TYPE.SYNC: { 160 | const syncMessageType = decoding.readVarUint(decoder); 161 | switch (syncMessageType) { 162 | case SYNC_MESSAGE_TYPE.STEP1: { 163 | const encodedTargetStateVector = decoding.readVarUint8Array(decoder); 164 | 165 | const updateV2 = Y.diffUpdateV2(this.stateAsUpdateV2, encodedTargetStateVector); 166 | const updateV1 = Y.convertUpdateFormatV2ToV1(updateV2); 167 | 168 | const encoder = encoding.createEncoder(); 169 | encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC); 170 | encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP2); 171 | encoding.writeVarUint8Array(encoder, updateV1); 172 | 173 | // If the `encoder` only contains the type of reply message and no 174 | // message, there is no need to send the message. When `encoder` only 175 | // contains the type of reply, its length is 1. 176 | if (encoding.length(encoder) > 1) { 177 | await this.send(ws, encoding.toUint8Array(encoder)); 178 | } 179 | 180 | break; 181 | } 182 | case SYNC_MESSAGE_TYPE.STEP2: 183 | case SYNC_MESSAGE_TYPE.UPDATE: { 184 | const session = this.sessions.get(ws); 185 | if (session === undefined) { 186 | console.warn('Ignoring update from unknown session'); 187 | return; 188 | } 189 | 190 | if (session.context.readonly) { 191 | // ignore updates from readonly clients 192 | console.warn('Ignoring update from readonly client'); 193 | return; 194 | } 195 | 196 | try { 197 | const update = decoding.readVarUint8Array(decoder); 198 | await this.handleUpdateV1(update); 199 | } catch (err) { 200 | console.error('Error while handling a Yjs update', err); 201 | } 202 | break; 203 | } 204 | default: 205 | throw new Error('Unknown sync message type'); 206 | } 207 | break; 208 | } 209 | case MESSAGE_TYPE.AWARENESS: { 210 | await this.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), ws); 211 | break; 212 | } 213 | default: 214 | throw new Error('Unknown message type'); 215 | } 216 | } catch (err) { 217 | console.error(err); 218 | } 219 | } 220 | 221 | public async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { 222 | this.setup(); 223 | 224 | console.log('WebSocket closed:', code, reason, wasClean); 225 | await this.handleClose(ws); 226 | } 227 | 228 | public async webSocketError(ws: WebSocket, err: unknown): Promise { 229 | this.setup(); 230 | 231 | console.error('WebSocket error:', err); 232 | await this.handleClose(ws); 233 | } 234 | 235 | private async handleUpdateV1(updateV1: Uint8Array) { 236 | const updateV2 = Y.convertUpdateFormatV1ToV2(updateV1); 237 | 238 | // persist update 239 | this.ctx.storage.sql.exec>(`INSERT INTO doc_updates (data) VALUES (?)`, [updateV2.buffer]); 240 | 241 | // merge update 242 | this.stateAsUpdateV2 = Y.mergeUpdatesV2([this.stateAsUpdateV2, updateV2]); 243 | 244 | // setup alarm to vacuum storage 245 | const alarm = await this.ctx.storage.getAlarm(); 246 | if (alarm === null) { 247 | await this.ctx.storage.setAlarm(Temporal.Now.instant().add(this.vacuumInterval).epochMilliseconds); 248 | } 249 | 250 | // broadcast update 251 | const encoder = encoding.createEncoder(); 252 | encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC); 253 | encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.UPDATE); 254 | encoding.writeVarUint8Array(encoder, updateV1); 255 | const message = encoding.toUint8Array(encoder); 256 | await this.broadcast(message); 257 | } 258 | 259 | private async handleAwarenessChange( 260 | { added, updated, removed }: { added: Array; updated: Array; removed: Array }, 261 | ws: WebSocket | null 262 | ) { 263 | const changedClients = [...added, ...updated, ...removed]; 264 | 265 | if (ws !== null) { 266 | const session = this.sessions.get(ws); 267 | 268 | if (session === undefined) { 269 | console.warn('Ignoring awareness change from unknown session'); 270 | return; 271 | } 272 | 273 | added.forEach((clientID) => { 274 | session.controlledIds.add(clientID); 275 | }); 276 | 277 | removed.forEach((clientID) => { 278 | session.controlledIds.delete(clientID); 279 | }); 280 | } 281 | 282 | // broadcast awareness update 283 | const encoder = encoding.createEncoder(); 284 | encoding.writeVarUint(encoder, MESSAGE_TYPE.AWARENESS); 285 | encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients)); 286 | await this.broadcast(encoding.toUint8Array(encoder)); 287 | } 288 | 289 | private async handleSession(webSocket: WebSocket, sessionInfo: SessionInfo) { 290 | webSocket.serializeAttachment({ 291 | ...webSocket.deserializeAttachment(), 292 | sessionInfo, 293 | }); 294 | 295 | this.sessions.set(webSocket, { controlledIds: new Set(), context: sessionInfo }); 296 | 297 | // send sync step 1 to get client updates 298 | const stateVector = Y.encodeStateVectorFromUpdateV2(this.stateAsUpdateV2); 299 | const encoder = encoding.createEncoder(); 300 | encoding.writeVarUint(encoder, MESSAGE_TYPE.SYNC); 301 | encoding.writeVarUint(encoder, SYNC_MESSAGE_TYPE.STEP1); 302 | encoding.writeVarUint8Array(encoder, stateVector); 303 | await this.send(webSocket, encoding.toUint8Array(encoder)); 304 | 305 | // send awareness update 306 | const awarenessStates = this.awareness.getStates(); 307 | if (awarenessStates.size > 0) { 308 | const awarenessEncoder = encoding.createEncoder(); 309 | encoding.writeVarUint(awarenessEncoder, MESSAGE_TYPE.AWARENESS); 310 | encoding.writeVarUint8Array(awarenessEncoder, encodeAwarenessUpdate(this.awareness, Array.from(awarenessStates.keys()))); 311 | await this.send(webSocket, encoding.toUint8Array(awarenessEncoder)); 312 | } 313 | } 314 | 315 | private async acceptWebsocket(sessionInfo: SessionInfo): Promise { 316 | const pair = new WebSocketPair(); 317 | 318 | this.ctx.acceptWebSocket(pair[1]); 319 | await this.handleSession(pair[1], sessionInfo); 320 | 321 | return new Response(null, { 322 | status: 101, 323 | webSocket: pair[0], 324 | }); 325 | } 326 | 327 | private async handleClose(webSocket: WebSocket) { 328 | webSocket.close(1011); // ensure websocket is closed 329 | 330 | const session = this.sessions.get(webSocket); 331 | if (session === undefined) { 332 | console.warn('Ignoring close from unknown session'); 333 | return; 334 | } 335 | 336 | await this.removeAwarenessStates(this.awareness, Array.from(session.controlledIds), webSocket); 337 | 338 | this.sessions.delete(webSocket); 339 | 340 | if (this.sessions.size === 0) { 341 | await this.vacuum(); 342 | } 343 | } 344 | 345 | private async send(ws: WebSocket, message: Uint8Array): Promise { 346 | try { 347 | ws.send(message); 348 | } catch { 349 | await this.handleClose(ws); 350 | } 351 | } 352 | 353 | private async broadcast(message: Uint8Array): Promise { 354 | await Promise.all(Array.from(this.sessions.keys()).map((ws) => this.send(ws, message))); 355 | } 356 | 357 | // https://github.com/yjs/y-protocols/blob/ba21a9c92990743554e47223c49513630b7eadda/awareness.js#L167 358 | private async removeAwarenessStates(awareness: Awareness, clients: number[], origin: WebSocket) { 359 | const removed = []; 360 | for (let i = 0; i < clients.length; i += 1) { 361 | const clientID = clients[i]; 362 | if (awareness.states.has(clientID)) { 363 | awareness.states.delete(clientID); 364 | if (clientID === awareness.clientID) { 365 | const curMeta = awareness.meta.get(clientID)!; 366 | awareness.meta.set(clientID, { 367 | clock: curMeta.clock + 1, 368 | lastUpdated: Temporal.Now.instant().epochMilliseconds, 369 | }); 370 | } 371 | removed.push(clientID); 372 | } 373 | } 374 | if (removed.length > 0) { 375 | await this.handleAwarenessChange( 376 | { 377 | added: [], 378 | updated: [], 379 | removed, 380 | }, 381 | origin 382 | ); 383 | } 384 | } 385 | 386 | // https://github.com/yjs/y-protocols/blob/ba21a9c92990743554e47223c49513630b7eadda/awareness.js#L241 387 | private async applyAwarenessUpdate(awareness: Awareness, update: Uint8Array, origin: WebSocket) { 388 | const decoder = decoding.createDecoder(update); 389 | const timestamp = Temporal.Now.instant().epochMilliseconds; 390 | const added = []; 391 | const updated = []; 392 | const filteredUpdated = []; 393 | const removed = []; 394 | const len = decoding.readVarUint(decoder); 395 | for (let i = 0; i < len; i += 1) { 396 | const clientID = decoding.readVarUint(decoder); 397 | let clock = decoding.readVarUint(decoder); 398 | const state = JSON.parse(decoding.readVarString(decoder)) as { [x: string]: unknown } | null; 399 | 400 | const session = this.sessions.get(origin); 401 | if (session === undefined) { 402 | console.warn('Ignoring awareness update from unknown session'); 403 | return; 404 | } 405 | 406 | const clientMeta = awareness.meta.get(clientID); 407 | const prevState = awareness.states.get(clientID); 408 | const currClock = clientMeta === undefined ? 0 : clientMeta.clock; 409 | if (currClock < clock || (currClock === clock && state === null && awareness.states.has(clientID))) { 410 | if (state === null) { 411 | // never let a remote client remove this local state 412 | if (clientID === awareness.clientID && awareness.getLocalState() !== null) { 413 | // remote client removed the local state. Do not remote state. Broadcast a message indicating 414 | // that this client still exists by increasing the clock 415 | clock += 1; 416 | } else { 417 | awareness.states.delete(clientID); 418 | } 419 | } else { 420 | awareness.states.set(clientID, state); 421 | } 422 | awareness.meta.set(clientID, { 423 | clock, 424 | lastUpdated: timestamp, 425 | }); 426 | if (clientMeta === undefined && state !== null) { 427 | added.push(clientID); 428 | } else if (clientMeta !== undefined && state === null) { 429 | removed.push(clientID); 430 | } else if (state !== null) { 431 | if (!equalityDeep(state, prevState)) { 432 | filteredUpdated.push(clientID); 433 | } 434 | updated.push(clientID); 435 | } 436 | } 437 | } 438 | 439 | if (added.length > 0 || updated.length > 0 || removed.length > 0) { 440 | await this.handleAwarenessChange( 441 | { 442 | added, 443 | updated, 444 | removed, 445 | }, 446 | origin 447 | ); 448 | } 449 | } 450 | 451 | private setup() { 452 | this.ctx.storage.sql.exec(` 453 | CREATE TABLE IF NOT EXISTS doc_updates( 454 | id INTEGER PRIMARY KEY AUTOINCREMENT, 455 | data BLOB 456 | );`); 457 | } 458 | 459 | private async vacuum() { 460 | // Merge updates is fast but does not perform perform garbage-collection 461 | // so here we load the updates into a Yjs document before persisting them. 462 | const doc = new Y.Doc({ gc: true }); 463 | Y.applyUpdateV2(doc, this.stateAsUpdateV2); 464 | this.stateAsUpdateV2 = Y.encodeStateAsUpdateV2(doc); 465 | doc.destroy(); 466 | 467 | // Persist merged update 468 | await this.env.R2_YJS_BUCKET.put(`state:${this.ctx.id.toString()}`, this.stateAsUpdateV2); 469 | 470 | // Clear partial updates 471 | this.ctx.storage.sql.exec('DELETE FROM doc_updates;'); 472 | 473 | if (this.sessions.size === 0) { 474 | await this.ctx.storage.deleteAll(); 475 | } 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { studio } from '@outerbase/browsable-durable-object'; 3 | 4 | const app = new Hono<{ Bindings: Env }>(); 5 | 6 | app.get('/yjs/snapshot/:roomId', async (c) => { 7 | const roomId = c.req.param('roomId'); 8 | 9 | const id = c.env.DURABLE_YJSPROVIDER.idFromName(roomId); 10 | const stub = c.env.DURABLE_YJSPROVIDER.get(id); 11 | 12 | const snapshot = await stub.getSnapshot(); 13 | return new Response(snapshot, { 14 | status: 200, 15 | headers: { 16 | 'Content-Type': 'application/octet-stream', 17 | 'Cache-Control': 'no-store', // Prevent caching of the snapshot 18 | }, 19 | }); 20 | }); 21 | 22 | app.get('/yjs/ws/:roomId', (c) => { 23 | const roomId = c.req.param('roomId'); 24 | 25 | const id = c.env.DURABLE_YJSPROVIDER.idFromName(roomId); 26 | const stub = c.env.DURABLE_YJSPROVIDER.get(id); 27 | 28 | const doUrl = new URL('https://example.com/ws'); 29 | 30 | const req = new Request(doUrl, { headers: c.req.raw.headers }); 31 | return stub.fetch(req); 32 | }); 33 | 34 | if (import.meta.env.DEV) { 35 | app.all('/studio', (c) => { 36 | return studio(c.req.raw, c.env.DURABLE_YJSPROVIDER); 37 | }); 38 | } 39 | 40 | export default { 41 | async fetch(request, env, ctx): Promise { 42 | return app.fetch(request, env, ctx); 43 | }, 44 | } satisfies ExportedHandler; 45 | 46 | export { YjsProvider } from './durable/YjsProvider'; 47 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function stringToColor(value: string) { 2 | let hash = 0; 3 | value.split('').forEach((char) => { 4 | hash = char.charCodeAt(0) + ((hash << 5) - hash); 5 | }); 6 | let color = '#'; 7 | for (let i = 0; i < 3; i++) { 8 | const value = (hash >> (i * 8)) & 0xff; 9 | color += value.toString(16).padStart(2, '0'); 10 | } 11 | return color; 12 | } 13 | -------------------------------------------------------------------------------- /src/zooId.ts: -------------------------------------------------------------------------------- 1 | function pickRandom(array: Array): T { 2 | return array[Math.trunc(Math.random() * array.length)]; 3 | } 4 | 5 | export function zooId(options?: { separator?: string; capitalize?: boolean }): string { 6 | const { separator = ' ', capitalize = false } = options || {}; 7 | 8 | let adjective = pickRandom(adjectives); 9 | let animal = pickRandom(animals); 10 | 11 | if (capitalize) { 12 | adjective = capitalizeWord(adjective); 13 | animal = capitalizeWord(animal); 14 | } 15 | 16 | return `${adjective}${separator}${animal}`; 17 | } 18 | 19 | const capitalizeWord = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); 20 | 21 | const adjectives = [ 22 | 'adventurous', 23 | 'agreeable', 24 | 'ambitious', 25 | 'bright', 26 | 'charming', 27 | 'compassionate', 28 | 'considerate', 29 | 'courageous', 30 | 'courteous', 31 | 'diligent', 32 | 'enthusiastic', 33 | 'generous', 34 | 'happy', 35 | 'helpful', 36 | 'inventive', 37 | 'likable', 38 | 'loyal', 39 | 'reliable', 40 | 'resourceful', 41 | 'sincere', 42 | 'sympathetic', 43 | 'trustworthy', 44 | 'witty', 45 | ]; 46 | 47 | const animals = [ 48 | 'badger', 49 | 'bat', 50 | 'bear', 51 | 'bird', 52 | 'bobcat', 53 | 'bulldog', 54 | 'bullfrog', 55 | 'cat', 56 | 'catfish', 57 | 'cheetah', 58 | 'chicken', 59 | 'chipmunk', 60 | 'cobra', 61 | 'cow', 62 | 'crab', 63 | 'deer', 64 | 'dingo', 65 | 'dodo', 66 | 'dog', 67 | 'dolphin', 68 | 'donkey', 69 | 'dragon', 70 | 'dragonfly', 71 | 'duck', 72 | 'eagle', 73 | 'eel', 74 | 'elephant', 75 | 'emu', 76 | 'falcon', 77 | 'fireant', 78 | 'fish', 79 | 'fly', 80 | 'fox', 81 | 'frog', 82 | 'gecko', 83 | 'goat', 84 | 'goose', 85 | 'grasshopper', 86 | 'horse', 87 | 'husky', 88 | 'impala', 89 | 'insect', 90 | 'jellyfish', 91 | 'kangaroo', 92 | 'ladybug', 93 | 'lion', 94 | 'lizard', 95 | 'mayfly', 96 | 'mole', 97 | 'moose', 98 | 'moth', 99 | 'mouse', 100 | 'mule', 101 | 'newt', 102 | 'octopus', 103 | 'otter', 104 | 'owl', 105 | 'panda', 106 | 'panther', 107 | 'parrot', 108 | 'penguin', 109 | 'puma', 110 | 'pug', 111 | 'quail', 112 | 'rabbit', 113 | 'rattlesnake', 114 | 'robin', 115 | 'seahorse', 116 | 'sheep', 117 | 'shrimp', 118 | 'skunk', 119 | 'sloth', 120 | 'snail', 121 | 'snake', 122 | 'squid', 123 | 'starfish', 124 | 'stingray', 125 | 'swan', 126 | 'termite', 127 | 'tiger', 128 | 'treefrog', 129 | 'turkey', 130 | 'turtle', 131 | 'walrus', 132 | 'wasp', 133 | 'wolverine', 134 | 'wombat', 135 | 'yak', 136 | 'zebra', 137 | ]; 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 6 | "target": "es2021", 7 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | "lib": ["es2021"], 9 | /* Specify what JSX code is generated. */ 10 | "jsx": "react-jsx", 11 | 12 | /* Specify what module code is generated. */ 13 | "module": "es2022", 14 | /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "moduleResolution": "bundler", 16 | /* Specify type package names to be included without being referenced in a source file. */ 17 | "types": ["@cloudflare/workers-types/2023-07-01"], 18 | /* Enable importing .json files */ 19 | "resolveJsonModule": true, 20 | 21 | /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 22 | "allowJs": true, 23 | /* Enable error reporting in type-checked JavaScript files. */ 24 | "checkJs": false, 25 | 26 | /* Disable emitting files from a compilation. */ 27 | "noEmit": true, 28 | 29 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 30 | "isolatedModules": true, 31 | /* Allow 'import x from y' when a module doesn't have a default export. */ 32 | "allowSyntheticDefaultImports": true, 33 | /* Ensure that casing is correct in imports. */ 34 | "forceConsistentCasingInFileNames": true, 35 | 36 | /* Enable all strict type-checking options. */ 37 | "strict": true, 38 | 39 | /* Skip type checking all .d.ts files. */ 40 | "skipLibCheck": true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ViteTypeOptions { 4 | strictImportMetaEnv: unknown 5 | } 6 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { cloudflare } from '@cloudflare/vite-plugin'; 3 | 4 | export default defineConfig({ 5 | plugins: [cloudflare({ 6 | 7 | })], 8 | resolve: { 9 | conditions: ['workerd', 'edge'], 10 | }, 11 | build: { 12 | target: 'esnext', 13 | minify: true, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 720a0cd39038affd5d7f537441c8a864) 3 | declare namespace Cloudflare { 4 | interface Env { 5 | YJS_VACUUM_INTERVAL_IN_MS: "30000"; 6 | DURABLE_YJSPROVIDER: DurableObjectNamespace; 7 | R2_YJS_BUCKET: R2Bucket; 8 | } 9 | } 10 | interface Env extends Cloudflare.Env {} 11 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "yjs-provider", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-04-26", 10 | "assets": { 11 | "directory": "dist" 12 | }, 13 | "vars": { 14 | "YJS_VACUUM_INTERVAL_IN_MS": "30000" 15 | }, 16 | 17 | "r2_buckets": [ 18 | { 19 | "binding": "R2_YJS_BUCKET", 20 | "bucket_name": "yjs-bucket" 21 | } 22 | ], 23 | 24 | "durable_objects": { 25 | "bindings": [{ 26 | "name": "DURABLE_YJSPROVIDER", 27 | "class_name": "YjsProvider" 28 | }] 29 | }, 30 | 31 | "migrations": [ 32 | { 33 | "tag": "v1", 34 | "new_sqlite_classes": ["YjsProvider"] 35 | } 36 | ] 37 | } 38 | --------------------------------------------------------------------------------