UNPKG

y-websocket

Version:
573 lines (545 loc) 19.3 kB
/** * @module provider/websocket */ /* eslint-env browser */ import * as Y from 'yjs' // eslint-disable-line import * as bc from 'lib0/broadcastchannel' import * as time from 'lib0/time' import * as encoding from 'lib0/encoding' import * as decoding from 'lib0/decoding' import * as syncProtocol from 'y-protocols/sync' import * as authProtocol from 'y-protocols/auth' import * as awarenessProtocol from 'y-protocols/awareness' import { ObservableV2 } from 'lib0/observable' import * as math from 'lib0/math' import * as url from 'lib0/url' import * as env from 'lib0/environment' export const messageSync = 0 export const messageQueryAwareness = 3 export const messageAwareness = 1 export const messageAuth = 2 /** * encoder, decoder, provider, emitSynced, messageType * @type {Array<function(encoding.Encoder, decoding.Decoder, WebsocketProvider, boolean, number):void>} */ const messageHandlers = [] messageHandlers[messageSync] = ( encoder, decoder, provider, emitSynced, _messageType ) => { encoding.writeVarUint(encoder, messageSync) const syncMessageType = syncProtocol.readSyncMessage( decoder, encoder, provider.doc, provider ) if ( emitSynced && syncMessageType === syncProtocol.messageYjsSyncStep2 && !provider.synced ) { provider.synced = true } } messageHandlers[messageQueryAwareness] = ( encoder, _decoder, provider, _emitSynced, _messageType ) => { encoding.writeVarUint(encoder, messageAwareness) encoding.writeVarUint8Array( encoder, awarenessProtocol.encodeAwarenessUpdate( provider.awareness, Array.from(provider.awareness.getStates().keys()) ) ) } messageHandlers[messageAwareness] = ( _encoder, decoder, provider, _emitSynced, _messageType ) => { awarenessProtocol.applyAwarenessUpdate( provider.awareness, decoding.readVarUint8Array(decoder), provider ) } messageHandlers[messageAuth] = ( _encoder, decoder, provider, _emitSynced, _messageType ) => { authProtocol.readAuthMessage( decoder, provider.doc, (_ydoc, reason) => permissionDeniedHandler(provider, reason) ) } // @todo - this should depend on awareness.outdatedTime const messageReconnectTimeout = 30000 /** * @param {WebsocketProvider} provider * @param {string} reason */ const permissionDeniedHandler = (provider, reason) => console.warn(`Permission denied to access ${provider.url}.\n${reason}`) /** * @param {WebsocketProvider} provider * @param {Uint8Array} buf * @param {boolean} emitSynced * @return {encoding.Encoder} */ const readMessage = (provider, buf, emitSynced) => { const decoder = decoding.createDecoder(buf) const encoder = encoding.createEncoder() const messageType = decoding.readVarUint(decoder) const messageHandler = provider.messageHandlers[messageType] if (/** @type {any} */ (messageHandler)) { messageHandler(encoder, decoder, provider, emitSynced, messageType) } else { console.error('Unable to compute message') } return encoder } /** * The default `shouldReconnect` implementation. Close codes 4000-4999 are reserved for private * use (RFC 6455). By convention, the 4400-4499 sub-range mirrors the HTTP 4xx class: the server * made a deliberate decision that reconnecting can't fix - e.g. the permission to access the * document was revoked, or the document doesn't exist anymore. Every other close code is treated * as transient, including 4500-4599, which is the matching "try again later" range. * * This is intentionally written as the negation of the positive range test. A WebSocket polyfill * that doesn't report a close code must not be mistaken for a permanent error. * * @param {CloseEvent} event * @return {boolean} */ const defaultShouldReconnect = (event) => !(event.code >= 4400 && event.code < 4500) /** * Outsource this function so that a new websocket connection is created immediately. * I suspect that the `ws.onclose` event is not always fired if there are network issues. * * `event` is `null` when we closed the connection ourselves - `provider.disconnect()`, or the * "no message received" watchdog. We always reconnect in that case: a local close is not a * signal from the server, and `provider.shouldConnect` already reflects the user's intention. * * @param {WebsocketProvider} provider * @param {WebSocket} ws * @param {CloseEvent | null} event */ const closeWebsocketConnection = (provider, ws, event) => { if (ws !== null && ws === provider.ws) { provider.emit('connection-close', [event, provider]) provider.ws = null // detach the handlers so that a socket that is still flushing buffered frames (e.g. a Node // `ws` socket in CLOSING state) cannot mutate the provider state anymore ws.onmessage = null ws.onopen = null ws.onclose = null // `onerror` is swallowed instead of detached: closing a socket that is still connecting is // reported as an error event, and in nodejs an error event without a listener is rethrown as // an uncaught exception ws.onerror = () => {} ws.close() provider.wsconnecting = false if (provider.wsconnected) { provider.wsconnected = false provider.synced = false // update awareness (all users except local left) awarenessProtocol.removeAwarenessStates( provider.awareness, Array.from(provider.awareness.getStates().keys()).filter((client) => client !== provider.doc.clientID ), provider ) provider.emit('status', [{ status: 'disconnected' }]) } // Every closed connection counts as an unsuccessful attempt. The counter is reset once a // connection synced successfully (see the `synced` setter). Hence a server that accepts the // connection and then closes it is backed off just like a server that refuses it. provider.wsUnsuccessfulReconnects++ /** * @type {{ code: number, reason: string } | null} */ let terminalClose = null if (event != null && !provider.shouldReconnect(event, provider)) { // The server signaled that reconnecting is pointless. We go through the existing // `shouldConnect` mechanism, so that the `setupWS` scheduled below turns into a no-op, // while a deliberate `provider.connect()` can still resume the connection. provider.shouldConnect = false terminalClose = { code: event.code, reason: event.reason } } // Increase the timeout using exponential backoff, starting with 200ms setTimeout( setupWS, math.min( math.pow(2, provider.wsUnsuccessfulReconnects) * 100, provider.maxBackoffTime ), provider ) // emitted last, so that a `closed` handler may call `provider.connect()` synchronously if (terminalClose !== null) { provider.emit('closed', [terminalClose, provider]) } } } /** * @param {WebsocketProvider} provider */ const setupWS = (provider) => { if (provider.shouldConnect && provider.ws === null) { const websocket = new provider._WS(provider.url, provider.protocols) websocket.binaryType = 'arraybuffer' provider.ws = websocket provider.wsconnecting = true provider.wsconnected = false provider.synced = false websocket.onmessage = (event) => { if (provider.ws !== websocket) return provider.wsLastMessageReceived = time.getUnixTime() const encoder = readMessage(provider, new Uint8Array(event.data), true) if (encoding.length(encoder) > 1) { websocket.send(encoding.toUint8Array(encoder)) } } websocket.onerror = (event) => { if (provider.ws !== websocket) return provider.emit('connection-error', [event, provider]) } websocket.onclose = (event) => { closeWebsocketConnection(provider, websocket, event) } websocket.onopen = () => { if (provider.ws !== websocket) return provider.wsLastMessageReceived = time.getUnixTime() provider.wsconnecting = false provider.wsconnected = true provider.emit('status', [{ status: 'connected' }]) // always send sync step 1 when connected const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) syncProtocol.writeSyncStep1(encoder, provider.doc) websocket.send(encoding.toUint8Array(encoder)) // broadcast local awareness state if (provider.awareness.getLocalState() !== null) { const encoderAwarenessState = encoding.createEncoder() encoding.writeVarUint(encoderAwarenessState, messageAwareness) encoding.writeVarUint8Array( encoderAwarenessState, awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ provider.doc.clientID ]) ) websocket.send(encoding.toUint8Array(encoderAwarenessState)) } } provider.emit('status', [{ status: 'connecting' }]) } } /** * @param {WebsocketProvider} provider * @param {ArrayBuffer} buf */ const broadcastMessage = (provider, buf) => { const ws = provider.ws if (provider.wsconnected && ws && ws.readyState === ws.OPEN) { ws.send(buf) } if (provider.bcconnected) { bc.publish(provider.bcChannel, buf, provider) } } /** * Websocket Provider for Yjs. Creates a websocket connection to sync the shared document. * The document name is attached to the provided url. I.e. the following example * creates a websocket connection to http://localhost:1234/my-document-name * * @example * import * as Y from 'yjs' * import { WebsocketProvider } from 'y-websocket' * const doc = new Y.Doc() * const provider = new WebsocketProvider('http://localhost:1234', 'my-document-name', doc) * * @extends {ObservableV2<{ 'connection-close': (event: CloseEvent | null, provider: WebsocketProvider) => any, 'closed': (event: { code: number, reason: string }, provider: WebsocketProvider) => any, 'status': (event: { status: 'connected' | 'disconnected' | 'connecting' }) => any, 'connection-error': (event: Event, provider: WebsocketProvider) => any, 'sync': (state: boolean) => any }>} */ export class WebsocketProvider extends ObservableV2 { /** * @param {string} serverUrl * @param {string} roomname * @param {Y.Doc} doc * @param {object} opts * @param {boolean} [opts.connect] * @param {awarenessProtocol.Awareness} [opts.awareness] * @param {Object<string,string>} [opts.params] specify url parameters * @param {Array<string>} [opts.protocols] specify websocket protocols * @param {typeof WebSocket} [opts.WebSocketPolyfill] Optionall provide a WebSocket polyfill * @param {number} [opts.resyncInterval] Request server state every `resyncInterval` milliseconds * @param {number} [opts.maxBackoffTime] Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential backoff) * @param {boolean} [opts.disableBc] Disable cross-tab BroadcastChannel communication * @param {(event: CloseEvent, provider: WebsocketProvider) => boolean} [opts.shouldReconnect] Decide whether to reconnect after the server closed the connection. By default, close codes in the 4400-4499 range are permanent - we stop reconnecting and emit a `closed` event. This is never called for connections that were closed locally (e.g. via `provider.disconnect()`). */ constructor (serverUrl, roomname, doc, { connect = true, awareness = new awarenessProtocol.Awareness(doc), params = {}, protocols = [], WebSocketPolyfill = WebSocket, resyncInterval = -1, maxBackoffTime = 2500, disableBc = false, shouldReconnect = defaultShouldReconnect } = {}) { super() // ensure that serverUrl does not end with / while (serverUrl[serverUrl.length - 1] === '/') { serverUrl = serverUrl.slice(0, serverUrl.length - 1) } this.serverUrl = serverUrl this.bcChannel = serverUrl + '/' + roomname this.maxBackoffTime = maxBackoffTime /** * Decides whether to reconnect after the server closed the connection. This can be safely * updated. The new predicate is used for the next close event. * @type {(event: CloseEvent, provider: WebsocketProvider) => boolean} */ this.shouldReconnect = shouldReconnect /** * The specified url parameters. This can be safely updated. The changed parameters will be used * when a new connection is established. * @type {Object<string,string>} */ this.params = params this.protocols = protocols this.roomname = roomname this.doc = doc this._WS = WebSocketPolyfill this.awareness = awareness this.wsconnected = false this.wsconnecting = false this.bcconnected = false this.disableBc = disableBc this.wsUnsuccessfulReconnects = 0 this.messageHandlers = messageHandlers.slice() /** * @type {boolean} */ this._synced = false /** * @type {WebSocket?} */ this.ws = null this.wsLastMessageReceived = 0 /** * Whether to connect to other peers or not * @type {boolean} */ this.shouldConnect = connect /** * @type {number} */ this._resyncInterval = 0 if (resyncInterval > 0) { this._resyncInterval = /** @type {any} */ (setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { // resend sync step 1 const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) syncProtocol.writeSyncStep1(encoder, doc) this.ws.send(encoding.toUint8Array(encoder)) } }, resyncInterval)) } /** * @param {ArrayBuffer} data * @param {any} origin */ this._bcSubscriber = (data, origin) => { if (origin !== this) { const encoder = readMessage(this, new Uint8Array(data), false) if (encoding.length(encoder) > 1) { bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this) } } } /** * Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel) * @param {Uint8Array} update * @param {any} origin */ this._updateHandler = (update, origin) => { if (origin !== this) { const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) syncProtocol.writeUpdate(encoder, update) broadcastMessage(this, encoding.toUint8Array(encoder)) } } this.doc.on('update', this._updateHandler) /** * @param {any} changed * @param {any} _origin */ this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => { const changedClients = added.concat(updated).concat(removed) const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageAwareness) encoding.writeVarUint8Array( encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients) ) broadcastMessage(this, encoding.toUint8Array(encoder)) } this._exitHandler = () => { awarenessProtocol.removeAwarenessStates( this.awareness, [doc.clientID], 'app closed' ) } if (env.isNode && typeof process !== 'undefined') { process.on('exit', this._exitHandler) } awareness.on('update', this._awarenessUpdateHandler) this._checkInterval = /** @type {any} */ (setInterval(() => { if ( this.wsconnected && messageReconnectTimeout < time.getUnixTime() - this.wsLastMessageReceived ) { // no message received in a long time - not even your own awareness // updates (which are updated every 15 seconds) closeWebsocketConnection(this, /** @type {WebSocket} */ (this.ws), null) } }, messageReconnectTimeout / 10)) if (connect) { this.connect() } } get url () { const encodedParams = url.encodeQueryParams(this.params) return this.serverUrl + '/' + this.roomname + (encodedParams.length === 0 ? '' : '?' + encodedParams) } /** * @type {boolean} */ get synced () { return this._synced } set synced (state) { if (this._synced !== state) { this._synced = state if (state) { // a connection that synced did useful work - reset the reconnect backoff this.wsUnsuccessfulReconnects = 0 } // @ts-ignore this.emit('synced', [state]) this.emit('sync', [state]) } } destroy () { if (this._resyncInterval !== 0) { clearInterval(this._resyncInterval) } clearInterval(this._checkInterval) this.disconnect() if (env.isNode && typeof process !== 'undefined') { process.off('exit', this._exitHandler) } this.awareness.off('update', this._awarenessUpdateHandler) this.doc.off('update', this._updateHandler) super.destroy() } connectBc () { if (this.disableBc) { return } if (!this.bcconnected) { bc.subscribe(this.bcChannel, this._bcSubscriber) this.bcconnected = true } // send sync step1 to bc // write sync step 1 const encoderSync = encoding.createEncoder() encoding.writeVarUint(encoderSync, messageSync) syncProtocol.writeSyncStep1(encoderSync, this.doc) bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this) // broadcast local state const encoderState = encoding.createEncoder() encoding.writeVarUint(encoderState, messageSync) syncProtocol.writeSyncStep2(encoderState, this.doc) bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this) // write queryAwareness const encoderAwarenessQuery = encoding.createEncoder() encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness) bc.publish( this.bcChannel, encoding.toUint8Array(encoderAwarenessQuery), this ) // broadcast local awareness state const encoderAwarenessState = encoding.createEncoder() encoding.writeVarUint(encoderAwarenessState, messageAwareness) encoding.writeVarUint8Array( encoderAwarenessState, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ this.doc.clientID ]) ) bc.publish( this.bcChannel, encoding.toUint8Array(encoderAwarenessState), this ) } disconnectBc () { // broadcast message with local awareness state set to null (indicating disconnect) const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageAwareness) encoding.writeVarUint8Array( encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ this.doc.clientID ], new Map()) ) broadcastMessage(this, encoding.toUint8Array(encoder)) if (this.bcconnected) { bc.unsubscribe(this.bcChannel, this._bcSubscriber) this.bcconnected = false } } disconnect () { this.shouldConnect = false this.disconnectBc() if (this.ws !== null) { closeWebsocketConnection(this, this.ws, null) } } connect () { this.shouldConnect = true if (!this.wsconnected && this.ws === null) { setupWS(this) this.connectBc() } } }