├── .gitignore ├── Dockerfile ├── rollup.config.mjs ├── .vscode └── launch.json ├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json ├── README.md └── src └── y-websocket.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tmp -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine 2 | 3 | RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app 4 | WORKDIR /home/node/app 5 | COPY package*.json ./ 6 | USER node 7 | RUN npm install 8 | COPY --chown=node:node . . 9 | EXPOSE 1234 10 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | export default [{ 2 | input: ['./src/y-websocket.js'], 3 | external: id => /^(lib0|yjs|@y|y-protocols|ws|http)/.test(id), 4 | output: [{ 5 | dir: 'dist', 6 | format: 'cjs', 7 | sourcemap: true, 8 | entryFileNames: '[name].cjs', 9 | chunkFileNames: '[name]-[hash].cjs' 10 | }] 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "websocket server", 11 | "program": "${workspaceFolder}/bin/server.js" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "lib": ["ES2021", "dom"], 5 | "module": "node16", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "emitDeclarationOnly": true, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "moduleResolution": "nodenext", 16 | "paths": { } 17 | }, 18 | "include": ["./src/**/*.js"], 19 | "exclude": ["./node_modules/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | branches: 10 | - '**' 11 | paths-ignore: 12 | - 'dist/**' 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 10 21 | strategy: 22 | matrix: 23 | node-version: [18.x, 16.x, 14.x] 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | 32 | - name: 📥 Install 33 | run: npm install 34 | 35 | - name: Unit tests 36 | run: npm test 37 | 38 | - name: ESLint checks 39 | run: npm run lint 40 | 41 | - name: Build 42 | run: npm run dist 43 | 44 | - name: Preversion 45 | run: npm run preversion -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Kevin Jahns . 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@y/websocket", 3 | "version": "4.0.0-3", 4 | "description": "Websockets provider for Yjs", 5 | "main": "./dist/y-websocket.cjs", 6 | "module": "./src/y-websocket.js", 7 | "types": "./dist/src/y-websocket.d.ts", 8 | "type": "module", 9 | "sideEffects": false, 10 | "funding": { 11 | "type": "GitHub Sponsors ❤", 12 | "url": "https://github.com/sponsors/dmonad" 13 | }, 14 | "scripts": { 15 | "clean": "rm -rf dist", 16 | "dist": "npm run clean && rollup -c && tsc --skipLibCheck", 17 | "lint": "standard && tsc --skipLibCheck", 18 | "test": "npm run lint", 19 | "preversion": "npm run dist && test -e dist/y-websocket.d.ts && test -e dist/y-websocket.cjs" 20 | }, 21 | "files": [ 22 | "dist/*", 23 | "src/*" 24 | ], 25 | "exports": { 26 | "./package.json": "./package.json", 27 | ".": { 28 | "module": "./src/y-websocket.js", 29 | "import": "./src/y-websocket.js", 30 | "require": "./dist/y-websocket.cjs", 31 | "types": "./dist/y-websocket.d.ts", 32 | "default": "./src/y-websocket.js" 33 | } 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/yjs/y-websocket.git" 38 | }, 39 | "keywords": [ 40 | "Yjs" 41 | ], 42 | "author": "Kevin Jahns ", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/yjs/y-websocket/issues" 46 | }, 47 | "homepage": "https://github.com/yjs/y-websocket#readme", 48 | "standard": { 49 | "ignore": [ 50 | "/dist", 51 | "/node_modules" 52 | ] 53 | }, 54 | "dependencies": { 55 | "@y/protocols": "^1.0.6-3", 56 | "lib0": "^0.2.115-6" 57 | }, 58 | "devDependencies": { 59 | "@types/node": "^22.14.0", 60 | "rollup": "^4.43.0", 61 | "standard": "^17.1.2", 62 | "typescript": "^5.8.3", 63 | "@y/y": "^14.0.0-16" 64 | }, 65 | "peerDependencies": { 66 | "@y/y": "^14.0.0-16" 67 | }, 68 | "engines": { 69 | "npm": ">=8.0.0", 70 | "node": ">=16.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # y-websocket :tophat: 2 | > WebSocket Provider for Yjs 3 | 4 | The Websocket Provider implements a classical client server model. Clients 5 | connect to a single endpoint over Websocket. The server distributes awareness 6 | information and document updates among clients. 7 | 8 | This repository contains a simple in-memory backend that can persist to 9 | databases, but it can't be scaled easily. The 10 | [y-redis](https://github.com/yjs/y-redis/) repository contains an alternative 11 | backend that is scalable, provides auth*, and can persist to different backends. 12 | 13 | The Websocket Provider is a solid choice if you want a central source that 14 | handles authentication and authorization. Websockets also send header 15 | information and cookies, so you can use existing authentication mechanisms with 16 | this server. 17 | 18 | * Supports cross-tab communication. When you open the same document in the same 19 | browser, changes on the document are exchanged via cross-tab communication 20 | ([Broadcast 21 | Channel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) 22 | and 23 | [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) 24 | as fallback). 25 | * Supports exchange of awareness information (e.g. cursors). 26 | 27 | ## Quick Start 28 | 29 | ### Install dependencies 30 | 31 | ```sh 32 | npm i y-websocket 33 | ``` 34 | 35 | ### Start a y-websocket server 36 | 37 | There are multiple y-websocket compatible backends for `y-websocket`: 38 | 39 | * [@y/websocket-server](https://github.com/yjs/y-websocket-server/) 40 | * hocuspocus 41 | - y-sweet 42 | - y-redis 43 | - ypy-websocket 44 | - pycrdt-websocket 45 | - [yrs-warp](https://github.com/y-crdt/yrs-warp) 46 | - ... 47 | 48 | The fastest way to get started is to run the [@y/websocket-server](https://github.com/yjs/y-websocket-server/) 49 | backend. This package was previously included in y-websocket and now lives in a 50 | forkable repository. 51 | 52 | Install and start y-websocket-server: 53 | 54 | ```sh 55 | npm install @y/websocket-server 56 | HOST=localhost PORT=1234 npx y-websocket 57 | ``` 58 | 59 | ### Client Code: 60 | 61 | ```js 62 | import * as Y from '@y/y' 63 | import { WebsocketProvider } from 'y-websocket' 64 | 65 | const doc = new Y.Doc() 66 | const wsProvider = new WebsocketProvider('ws://localhost:1234', 'my-roomname', doc) 67 | 68 | wsProvider.on('status', event => { 69 | console.log(event.status) // logs "connected" or "disconnected" 70 | }) 71 | ``` 72 | 73 | #### Client Code in Node.js 74 | 75 | The WebSocket provider requires a [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) object to create connection to a server. You can polyfill WebSocket support in Node.js using the [`ws` package](https://www.npmjs.com/package/ws). 76 | 77 | ```js 78 | const wsProvider = new WebsocketProvider('ws://localhost:1234', 'my-roomname', doc, { WebSocketPolyfill: require('ws') }) 79 | ``` 80 | 81 | ## API 82 | 83 | ```js 84 | import { WebsocketProvider } from 'y-websocket' 85 | ``` 86 | 87 |
88 | wsProvider = new WebsocketProvider(serverUrl: string, room: string, ydoc: Y.Doc [, wsOpts: WsOpts]) 89 |
Create a new websocket-provider instance. As long as this provider, or the connected ydoc, is not destroyed, the changes will be synced to other clients via the connected server. Optionally, you may specify a configuration object. The following default values of wsOpts can be overwritten.
90 |
91 | 92 | ```js 93 | wsOpts = { 94 | // Set this to `false` if you want to connect manually using wsProvider.connect() 95 | connect: true, 96 | // Specify a query-string / url parameters that will be url-encoded and attached to the `serverUrl` 97 | // I.e. params = { auth: "bearer" } will be transformed to "?auth=bearer" 98 | params: {}, // Object 99 | // You may polyill the Websocket object (https://developer.mozilla.org/en-US/docs/Web/API/WebSocket). 100 | // E.g. In nodejs, you could specify WebsocketPolyfill = require('ws') 101 | WebsocketPolyfill: Websocket, 102 | // Specify an existing Awareness instance - see https://github.com/yjs/y-protocols 103 | awareness: new awarenessProtocol.Awareness(ydoc), 104 | // Specify the maximum amount to wait between reconnects (we use exponential backoff). 105 | maxBackoffTime: 2500 106 | } 107 | ``` 108 | 109 |
110 | wsProvider.wsconnected: boolean 111 |
True if this instance is currently connected to the server.
112 | wsProvider.wsconnecting: boolean 113 |
True if this instance is currently connecting to the server.
114 | wsProvider.shouldConnect: boolean 115 |
If false, the client will not try to reconnect.
116 | wsProvider.bcconnected: boolean 117 |
True if this instance is currently communicating to other browser-windows via BroadcastChannel.
118 | wsProvider.synced: boolean 119 |
True if this instance is currently connected and synced with the server.
120 | wsProvider.params : boolean 121 |
The specified url parameters. This can be safely updated, the new values 122 | will be used when a new connction is established. If this contains an 123 | auth token, it should be updated regularly.
124 | wsProvider.disconnect() 125 |
Disconnect from the server and don't try to reconnect.
126 | wsProvider.connect() 127 |
Establish a websocket connection to the websocket-server. Call this if you recently disconnected or if you set wsOpts.connect = false.
128 | wsProvider.destroy() 129 |
Destroy this wsProvider instance. Disconnects from the server and removes all event handlers.
130 | wsProvider.on('sync', function(isSynced: boolean)) 131 |
Add an event listener for the sync event that is fired when the client received content from the server.
132 | wsProvider.on('status', function({ status: 'disconnected' | 'connecting' | 'connected' })) 133 |
Receive updates about the current connection status.
134 | wsProvider.on('connection-close', function(WSClosedEvent)) 135 |
Fires when the underlying websocket connection is closed. It forwards the websocket event to this event handler.
136 | wsProvider.on('connection-error', function(WSErrorEvent)) 137 |
Fires when the underlying websocket connection closes with an error. It forwards the websocket event to this event handler.
138 |
139 | 140 | ## License 141 | 142 | [The MIT License](./LICENSE) © Kevin Jahns 143 | -------------------------------------------------------------------------------- /src/y-websocket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module provider/websocket 3 | */ 4 | 5 | /* eslint-env browser */ 6 | 7 | import * as Y from '@y/y' // eslint-disable-line 8 | import * as bc from 'lib0/broadcastchannel' 9 | import * as time from 'lib0/time' 10 | import * as encoding from 'lib0/encoding' 11 | import * as decoding from 'lib0/decoding' 12 | import * as syncProtocol from '@y/protocols/sync' 13 | import * as authProtocol from '@y/protocols/auth' 14 | import * as awarenessProtocol from '@y/protocols/awareness' 15 | import { ObservableV2 } from 'lib0/observable' 16 | import * as math from 'lib0/math' 17 | import * as url from 'lib0/url' 18 | import * as env from 'lib0/environment' 19 | 20 | export const messageSync = 0 21 | export const messageQueryAwareness = 3 22 | export const messageAwareness = 1 23 | export const messageAuth = 2 24 | 25 | /** 26 | * encoder, decoder, provider, emitSynced, messageType 27 | * @type {Array} 28 | */ 29 | const messageHandlers = [] 30 | 31 | messageHandlers[messageSync] = ( 32 | encoder, 33 | decoder, 34 | provider, 35 | emitSynced, 36 | _messageType 37 | ) => { 38 | encoding.writeVarUint(encoder, messageSync) 39 | const syncMessageType = syncProtocol.readSyncMessage( 40 | decoder, 41 | encoder, 42 | provider.doc, 43 | provider 44 | ) 45 | if ( 46 | emitSynced && syncMessageType === syncProtocol.messageYjsSyncStep2 && 47 | !provider.synced 48 | ) { 49 | provider.synced = true 50 | } 51 | } 52 | 53 | messageHandlers[messageQueryAwareness] = ( 54 | encoder, 55 | _decoder, 56 | provider, 57 | _emitSynced, 58 | _messageType 59 | ) => { 60 | encoding.writeVarUint(encoder, messageAwareness) 61 | encoding.writeVarUint8Array( 62 | encoder, 63 | awarenessProtocol.encodeAwarenessUpdate( 64 | provider.awareness, 65 | Array.from(provider.awareness.getStates().keys()) 66 | ) 67 | ) 68 | } 69 | 70 | messageHandlers[messageAwareness] = ( 71 | _encoder, 72 | decoder, 73 | provider, 74 | _emitSynced, 75 | _messageType 76 | ) => { 77 | awarenessProtocol.applyAwarenessUpdate( 78 | provider.awareness, 79 | decoding.readVarUint8Array(decoder), 80 | provider 81 | ) 82 | } 83 | 84 | messageHandlers[messageAuth] = ( 85 | _encoder, 86 | decoder, 87 | provider, 88 | _emitSynced, 89 | _messageType 90 | ) => { 91 | authProtocol.readAuthMessage( 92 | decoder, 93 | provider.doc, 94 | (_ydoc, reason) => permissionDeniedHandler(provider, reason) 95 | ) 96 | } 97 | 98 | // @todo - this should depend on awareness.outdatedTime 99 | const messageReconnectTimeout = 30000 100 | 101 | /** 102 | * @param {WebsocketProvider} provider 103 | * @param {string} reason 104 | */ 105 | const permissionDeniedHandler = (provider, reason) => 106 | console.warn(`Permission denied to access ${provider.url}.\n${reason}`) 107 | 108 | /** 109 | * @param {WebsocketProvider} provider 110 | * @param {Uint8Array} buf 111 | * @param {boolean} emitSynced 112 | * @return {encoding.Encoder} 113 | */ 114 | const readMessage = (provider, buf, emitSynced) => { 115 | const decoder = decoding.createDecoder(buf) 116 | const encoder = encoding.createEncoder() 117 | const messageType = decoding.readVarUint(decoder) 118 | const messageHandler = provider.messageHandlers[messageType] 119 | if (/** @type {any} */ (messageHandler)) { 120 | messageHandler(encoder, decoder, provider, emitSynced, messageType) 121 | } else { 122 | console.error('Unable to compute message') 123 | } 124 | return encoder 125 | } 126 | 127 | /** 128 | * Outsource this function so that a new websocket connection is created immediately. 129 | * I suspect that the `ws.onclose` event is not always fired if there are network issues. 130 | * 131 | * @param {WebsocketProvider} provider 132 | * @param {WebSocket} ws 133 | * @param {CloseEvent | null} event 134 | */ 135 | const closeWebsocketConnection = (provider, ws, event) => { 136 | if (ws === provider.ws) { 137 | provider.emit('connection-close', [event, provider]) 138 | provider.ws = null 139 | ws.close() 140 | provider.wsconnecting = false 141 | if (provider.wsconnected) { 142 | provider.wsconnected = false 143 | provider.synced = false 144 | // update awareness (all users except local left) 145 | awarenessProtocol.removeAwarenessStates( 146 | provider.awareness, 147 | Array.from(provider.awareness.getStates().keys()).filter((client) => 148 | client !== provider.awareness.clientID 149 | ), 150 | provider 151 | ) 152 | provider.emit('status', [{ 153 | status: 'disconnected' 154 | }]) 155 | } else { 156 | provider.wsUnsuccessfulReconnects++ 157 | } 158 | // Start with no reconnect timeout and increase timeout by 159 | // using exponential backoff starting with 100ms 160 | setTimeout( 161 | setupWS, 162 | math.min( 163 | math.pow(2, provider.wsUnsuccessfulReconnects) * 100, 164 | provider.maxBackoffTime 165 | ), 166 | provider 167 | ) 168 | } 169 | } 170 | 171 | /** 172 | * @param {WebsocketProvider} provider 173 | */ 174 | const setupWS = (provider) => { 175 | if (provider.shouldConnect && provider.ws === null) { 176 | const websocket = new provider._WS(provider.url, provider.protocols) 177 | websocket.binaryType = 'arraybuffer' 178 | provider.ws = websocket 179 | provider.wsconnecting = true 180 | provider.wsconnected = false 181 | provider.synced = false 182 | 183 | websocket.onmessage = (event) => { 184 | provider.wsLastMessageReceived = time.getUnixTime() 185 | const encoder = readMessage(provider, new Uint8Array(event.data), true) 186 | if (encoding.length(encoder) > 1) { 187 | websocket.send(encoding.toUint8Array(encoder)) 188 | } 189 | } 190 | websocket.onerror = (event) => { 191 | provider.emit('connection-error', [event, provider]) 192 | } 193 | websocket.onclose = (event) => { 194 | closeWebsocketConnection(provider, websocket, event) 195 | } 196 | websocket.onopen = () => { 197 | provider.wsLastMessageReceived = time.getUnixTime() 198 | provider.wsconnecting = false 199 | provider.wsconnected = true 200 | provider.wsUnsuccessfulReconnects = 0 201 | provider.emit('status', [{ 202 | status: 'connected' 203 | }]) 204 | // always send sync step 1 when connected 205 | const encoder = encoding.createEncoder() 206 | encoding.writeVarUint(encoder, messageSync) 207 | syncProtocol.writeSyncStep1(encoder, provider.doc) 208 | websocket.send(encoding.toUint8Array(encoder)) 209 | // broadcast local awareness state 210 | if (provider.awareness.getLocalState() !== null) { 211 | const encoderAwarenessState = encoding.createEncoder() 212 | encoding.writeVarUint(encoderAwarenessState, messageAwareness) 213 | encoding.writeVarUint8Array( 214 | encoderAwarenessState, 215 | awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ 216 | provider.doc.clientID 217 | ]) 218 | ) 219 | websocket.send(encoding.toUint8Array(encoderAwarenessState)) 220 | } 221 | } 222 | provider.emit('status', [{ 223 | status: 'connecting' 224 | }]) 225 | } 226 | } 227 | 228 | /** 229 | * @param {WebsocketProvider} provider 230 | * @param {Uint8Array} buf 231 | */ 232 | const broadcastMessage = (provider, buf) => { 233 | const ws = provider.ws 234 | if (provider.wsconnected && ws && ws.readyState === ws.OPEN) { 235 | ws.send(buf) 236 | } 237 | if (provider.bcconnected) { 238 | bc.publish(provider.bcChannel, buf, provider) 239 | } 240 | } 241 | 242 | /** 243 | * Websocket Provider for Yjs. Creates a websocket connection to sync the shared document. 244 | * The document name is attached to the provided url. I.e. the following example 245 | * creates a websocket connection to http://localhost:1234/my-document-name 246 | * 247 | * @example 248 | * import * as Y from '@y/y' 249 | * import { WebsocketProvider } from 'y-websocket' 250 | * const doc = new Y.Doc() 251 | * const provider = new WebsocketProvider('http://localhost:1234', 'my-document-name', doc) 252 | * 253 | * @extends {ObservableV2<{ 'connection-close': (event: CloseEvent | null, provider: WebsocketProvider) => any, 'status': (event: { status: 'connected' | 'disconnected' | 'connecting' }) => any, 'connection-error': (event: Event, provider: WebsocketProvider) => any, 'sync': (state: boolean) => any }>} 254 | */ 255 | export class WebsocketProvider extends ObservableV2 { 256 | /** 257 | * @param {string} serverUrl 258 | * @param {string} roomname 259 | * @param {Y.Doc} doc 260 | * @param {object} opts 261 | * @param {boolean} [opts.connect] 262 | * @param {awarenessProtocol.Awareness} [opts.awareness] 263 | * @param {Object} [opts.params] specify url parameters 264 | * @param {Array} [opts.protocols] specify websocket protocols 265 | * @param {typeof WebSocket} [opts.WebSocketPolyfill] Optionall provide a WebSocket polyfill 266 | * @param {number} [opts.resyncInterval] Request server state every `resyncInterval` milliseconds 267 | * @param {number} [opts.maxBackoffTime] Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential backoff) 268 | * @param {boolean} [opts.disableBc] Disable cross-tab BroadcastChannel communication 269 | */ 270 | constructor (serverUrl, roomname, doc, { 271 | connect = true, 272 | awareness = new awarenessProtocol.Awareness(doc), 273 | params = {}, 274 | protocols = [], 275 | WebSocketPolyfill = WebSocket, 276 | resyncInterval = -1, 277 | maxBackoffTime = 2500, 278 | disableBc = false 279 | } = {}) { 280 | super() 281 | // ensure that serverUrl does not end with / 282 | while (serverUrl[serverUrl.length - 1] === '/') { 283 | serverUrl = serverUrl.slice(0, serverUrl.length - 1) 284 | } 285 | this.serverUrl = serverUrl 286 | this.bcChannel = serverUrl + '/' + roomname 287 | this.maxBackoffTime = maxBackoffTime 288 | /** 289 | * The specified url parameters. This can be safely updated. The changed parameters will be used 290 | * when a new connection is established. 291 | * @type {Object} 292 | */ 293 | this.params = params 294 | this.protocols = protocols 295 | this.roomname = roomname 296 | this.doc = doc 297 | this._WS = WebSocketPolyfill 298 | this.awareness = awareness 299 | this.wsconnected = false 300 | this.wsconnecting = false 301 | this.bcconnected = false 302 | this.disableBc = disableBc 303 | this.wsUnsuccessfulReconnects = 0 304 | this.messageHandlers = messageHandlers.slice() 305 | /** 306 | * @type {boolean} 307 | */ 308 | this._synced = false 309 | /** 310 | * @type {WebSocket?} 311 | */ 312 | this.ws = null 313 | this.wsLastMessageReceived = 0 314 | /** 315 | * Whether to connect to other peers or not 316 | * @type {boolean} 317 | */ 318 | this.shouldConnect = connect 319 | 320 | /** 321 | * @type {number} 322 | */ 323 | this._resyncInterval = 0 324 | if (resyncInterval > 0) { 325 | this._resyncInterval = /** @type {any} */ (setInterval(() => { 326 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 327 | // resend sync step 1 328 | const encoder = encoding.createEncoder() 329 | encoding.writeVarUint(encoder, messageSync) 330 | syncProtocol.writeSyncStep1(encoder, doc) 331 | this.ws.send(encoding.toUint8Array(encoder)) 332 | } 333 | }, resyncInterval)) 334 | } 335 | 336 | /** 337 | * @param {ArrayBuffer} data 338 | * @param {any} origin 339 | */ 340 | this._bcSubscriber = (data, origin) => { 341 | if (origin !== this) { 342 | const encoder = readMessage(this, new Uint8Array(data), false) 343 | if (encoding.length(encoder) > 1) { 344 | bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this) 345 | } 346 | } 347 | } 348 | /** 349 | * Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel) 350 | * @param {Uint8Array} update 351 | * @param {any} origin 352 | */ 353 | this._updateHandler = (update, origin) => { 354 | if (origin !== this) { 355 | const encoder = encoding.createEncoder() 356 | encoding.writeVarUint(encoder, messageSync) 357 | syncProtocol.writeUpdate(encoder, update) 358 | broadcastMessage(this, encoding.toUint8Array(encoder)) 359 | } 360 | } 361 | this.doc.on('update', this._updateHandler) 362 | /** 363 | * @param {any} changed 364 | * @param {any} _origin 365 | */ 366 | this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => { 367 | const changedClients = added.concat(updated).concat(removed) 368 | const encoder = encoding.createEncoder() 369 | encoding.writeVarUint(encoder, messageAwareness) 370 | encoding.writeVarUint8Array( 371 | encoder, 372 | awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients) 373 | ) 374 | broadcastMessage(this, encoding.toUint8Array(encoder)) 375 | } 376 | this._exitHandler = () => { 377 | awarenessProtocol.removeAwarenessStates( 378 | this.awareness, 379 | [doc.clientID], 380 | 'app closed' 381 | ) 382 | } 383 | if (env.isNode && typeof process !== 'undefined') { 384 | process.on('exit', this._exitHandler) 385 | } 386 | awareness.on('update', this._awarenessUpdateHandler) 387 | this._checkInterval = /** @type {any} */ (setInterval(() => { 388 | if ( 389 | this.wsconnected && 390 | messageReconnectTimeout < 391 | time.getUnixTime() - this.wsLastMessageReceived 392 | ) { 393 | // no message received in a long time - not even your own awareness 394 | // updates (which are updated every 15 seconds) 395 | closeWebsocketConnection(this, /** @type {WebSocket} */ (this.ws), null) 396 | } 397 | }, messageReconnectTimeout / 10)) 398 | if (connect) { 399 | this.connect() 400 | } 401 | } 402 | 403 | get url () { 404 | const encodedParams = url.encodeQueryParams(this.params) 405 | return this.serverUrl + '/' + this.roomname + 406 | (encodedParams.length === 0 ? '' : '?' + encodedParams) 407 | } 408 | 409 | /** 410 | * @type {boolean} 411 | */ 412 | get synced () { 413 | return this._synced 414 | } 415 | 416 | set synced (state) { 417 | if (this._synced !== state) { 418 | this._synced = state 419 | // @ts-ignore 420 | this.emit('synced', [state]) 421 | this.emit('sync', [state]) 422 | } 423 | } 424 | 425 | destroy () { 426 | if (this._resyncInterval !== 0) { 427 | clearInterval(this._resyncInterval) 428 | } 429 | clearInterval(this._checkInterval) 430 | this.disconnect() 431 | if (env.isNode && typeof process !== 'undefined') { 432 | process.off('exit', this._exitHandler) 433 | } 434 | this.awareness.off('update', this._awarenessUpdateHandler) 435 | this.doc.off('update', this._updateHandler) 436 | super.destroy() 437 | } 438 | 439 | connectBc () { 440 | if (this.disableBc) { 441 | return 442 | } 443 | if (!this.bcconnected) { 444 | bc.subscribe(this.bcChannel, this._bcSubscriber) 445 | this.bcconnected = true 446 | } 447 | // send sync step1 to bc 448 | // write sync step 1 449 | const encoderSync = encoding.createEncoder() 450 | encoding.writeVarUint(encoderSync, messageSync) 451 | syncProtocol.writeSyncStep1(encoderSync, this.doc) 452 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this) 453 | // broadcast local state 454 | const encoderState = encoding.createEncoder() 455 | encoding.writeVarUint(encoderState, messageSync) 456 | syncProtocol.writeSyncStep2(encoderState, this.doc) 457 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this) 458 | // write queryAwareness 459 | const encoderAwarenessQuery = encoding.createEncoder() 460 | encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness) 461 | bc.publish( 462 | this.bcChannel, 463 | encoding.toUint8Array(encoderAwarenessQuery), 464 | this 465 | ) 466 | // broadcast local awareness state 467 | const encoderAwarenessState = encoding.createEncoder() 468 | encoding.writeVarUint(encoderAwarenessState, messageAwareness) 469 | encoding.writeVarUint8Array( 470 | encoderAwarenessState, 471 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 472 | this.awareness.clientID 473 | ]) 474 | ) 475 | bc.publish( 476 | this.bcChannel, 477 | encoding.toUint8Array(encoderAwarenessState), 478 | this 479 | ) 480 | } 481 | 482 | disconnectBc () { 483 | // broadcast message with local awareness state set to null (indicating disconnect) 484 | const encoder = encoding.createEncoder() 485 | encoding.writeVarUint(encoder, messageAwareness) 486 | encoding.writeVarUint8Array( 487 | encoder, 488 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 489 | this.awareness.clientID 490 | ], new Map()) 491 | ) 492 | broadcastMessage(this, encoding.toUint8Array(encoder)) 493 | if (this.bcconnected) { 494 | bc.unsubscribe(this.bcChannel, this._bcSubscriber) 495 | this.bcconnected = false 496 | } 497 | } 498 | 499 | disconnect () { 500 | this.shouldConnect = false 501 | this.disconnectBc() 502 | if (this.ws !== null) { 503 | closeWebsocketConnection(this, this.ws, null) 504 | } 505 | } 506 | 507 | connect () { 508 | this.shouldConnect = true 509 | if (!this.wsconnected && this.ws === null) { 510 | setupWS(this) 511 | this.connectBc() 512 | } 513 | } 514 | } 515 | --------------------------------------------------------------------------------