UNPKG

y-socket.io-provider

Version:

Socket IO Provider for Yjs (Inspired by y-websocket and y-socket.io)

586 lines (539 loc) 18 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var Y = require('yjs'); var bc = require('lib0/broadcastchannel'); var AwarenessProtocol = require('y-protocols/awareness'); var observable = require('lib0/observable'); var socket_ioClient = require('socket.io-client'); function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n["default"] = e; return Object.freeze(n); } var Y__namespace = /*#__PURE__*/_interopNamespace(Y); var bc__namespace = /*#__PURE__*/_interopNamespace(bc); var AwarenessProtocol__namespace = /*#__PURE__*/_interopNamespace(AwarenessProtocol); // From https://github.com/ivan-topp/y-socket.io/blob/4484c4fa063060d011ae2d9abf572402e3b26e9d/src/client/provider.ts // export interface AwarenessChange { // /** // * The clients added // */ // added: number[]; // /** // * The clients updated // */ // updated: number[]; // /** // * The clients removed // */ // removed: number[]; // } /** * SocketIOProvider instance configuration. Here you can configure: * - autoConnect: (Optional) Will try to connect to the server when the instance is created if true; otherwise you have to call `provider.connect()` manually * - awareness: (Optional) Give an existing awareness * - resyncInterval: (Optional) Specify the number of milliseconds to set an interval to synchronize the document, * if it is greater than 0 enable the synchronization interval (by default is -1) * - disableBc: (Optional) This boolean disable the broadcast channel functionality, by default is false (broadcast channel enabled) * - onConnect: (Optional) Set a callback that will triggered immediately when the socket is connected * - onDisconnect: (Optional) Set a callback that will triggered immediately when the socket is disconnected * - onConnectError: (Optional) Set a callback that will triggered immediately when the occurs a socket connection error */ // export interface ProviderConfiguration { // /** // * (Optional) This boolean specify if the provider should connect when the instance is created, by default is true // */ // autoConnect?: boolean; // /** // * (Optional) An existent awareness, by default is a new AwarenessProtocol.Awareness instance // */ // awareness?: AwarenessProtocol.Awareness; // /** // * (optional) Specify the number of milliseconds to synchronize, by default is -1 (this disable resync interval) // */ // resyncInterval?: number; // /** // * (Optional) This boolean disable the broadcast channel functionality, by default is false (broadcast channel enabled) // */ // disableBc?: boolean; // /** // * (Optional) Add the authentication data // */ // auth?: { [key: string]: any }; // } /** * The socket io provider class to sync a document */ class SocketIOProvider extends observable.Observable { /** * The connection url to server. Example: `ws://localhost:3001` * @type {string} */ _url; /** * The name of the document room * @type {string} */ roomName; /** * The broadcast channel room * @type {string} * @private */ _broadcastChannel; /** * The socket connection * @type {Socket} */ socket; /** * The yjs document * @type {Y.Doc} */ doc; /** * The awareness * @type {AwarenessProtocol.Awareness} */ awareness; /** * Disable broadcast channel, by default is false * @type {boolean} */ disableBc; /** * The broadcast channel connection status indicator * @type {boolean} */ bcconnected; /** * The document's sync status indicator * @type {boolean} * @private */ _synced; /** * Interval to emit `sync-step-1` to sync changes * @type {NodeJS.Timer | null} * @private */ resyncInterval = null; /** * SocketIOProvider constructor * @constructor * @param {string} url The connection url from server * @param {string} roomName The document's room name * @param {Y.Doc} doc The yjs document * @param options Configuration options to the SocketIOProvider */ constructor( url, roomName, doc, { autoConnect = true, awareness = new AwarenessProtocol__namespace.Awareness(doc), resyncInterval = -1, disableBc = false, auth = {}, } ) { super(); while (url[url.length - 1] === "/") { url = url.slice(0, url.length - 1); } this._url = url; this.roomName = roomName; this.doc = doc; this.awareness = awareness; this._broadcastChannel = `${url}/${roomName}`; this.disableBc = disableBc; this.socket = socket_ioClient.io(`${this.url}/yjs|${roomName}`, { autoConnect: false, transports: ["websocket"], forceNew: true, auth: auth, }); this.doc.on("update", this.onUpdateDoc); this.socket.on("connect", () => this.onSocketConnection(resyncInterval)); this.socket.on("disconnect", (event) => this.onSocketDisconnection(event)); this.socket.on("connect_error", (error) => this.onSocketConnectionError(error) ); this.initSyncListeners(); this.initAwarenessListeners(); this.initSystemListeners(); awareness.on("update", this.awarenessUpdate); if (autoConnect) this.connect(); } /** * Broadcast channel room getter * @type {string} */ get broadcastChannel() { return this._broadcastChannel; } /** * URL getter * @type {string} */ get url() { return this._url; } /** * Synchronized state flag getter * @type {boolean} */ get synced() { return this._synced; } /** * Synchronized state flag setter */ set synced(state) { if (this._synced !== state) { this._synced = state; this.emit("synced", [state]); this.emit("sync", [state]); } } /** * This function initializes the socket event listeners to synchronize document changes. * * The synchronization protocol is as follows: * - A server emits the sync step one event (`sync-step-1`) which sends the document as a state vector * and the sync step two callback as an acknowledgment according to the socket io acknowledgments. * - When the client receives the `sync-step-1` event, it executes the `syncStep2` acknowledgment callback and sends * the difference between the received state vector and the local document (this difference is called an update). * - The second step of the sync is to apply the update sent in the `syncStep2` callback parameters from the client * to the document on the server side. * - There is another event (`sync-update`) that is emitted from the server, which sends an update for the document, * and when the client receives this event, it applies the received update to the local document. * - When an update is applied to a document, it will fire the document's "update" event, which * sends the update to the server. * @type {() => void} * @private */ initSyncListeners = () => { this.socket.on("sync-step-1", (stateVector, syncStep2) => { syncStep2(Y__namespace.encodeStateAsUpdate(this.doc, new Uint8Array(stateVector))); this.synced = true; }); this.socket.on("sync-update", this.onSocketSyncUpdate); }; /** * This function initializes socket event listeners to synchronize awareness changes. * * The awareness protocol is as follows: * - The server emits the `awareness-update` event by sending the awareness update. * - The client receives that event and applies the received update to the local awareness. * - When an update is applied to awareness, the awareness "update" event will fire, which * sends the update to the server. * @type {() => void} * @private */ initAwarenessListeners = () => { this.socket.on("awareness-update", (update) => { AwarenessProtocol__namespace.applyAwarenessUpdate( this.awareness, new Uint8Array(update), this ); }); }; /** * This function initialize the window or process events listener. Specifically set ups the * window `beforeunload` and process `exit` events to remove the client from the awareness. * @type {() => void} */ initSystemListeners = () => { if (typeof window !== "undefined") window.addEventListener("beforeunload", this.beforeUnloadHandler); else if (typeof process !== "undefined") process.on("exit", this.beforeUnloadHandler); }; /** * Connect provider's socket * @type {() => void} */ connect() { if (!this.socket.connected) { this.emit("status", [{ status: "connecting" }]); this.socket.connect(); if (!this.disableBc) this.connectBc(); this.synced = false; } } /** * This function runs when the socket connects and reconnects and emits the `sync-step-1` * and `awareness-update` socket events to start synchronization. * * Also starts the resync interval if is enabled. * @private * @param onConnect (Optional) A callback that will be triggered every time that socket is connected or reconnected * @param resyncInterval (Optional) A number of milliseconds for interval of synchronize */ onSocketConnection = (resyncInterval = -1) => { this.emit("status", [{ status: "connected" }]); this.socket.emit("sync-step-1", Y__namespace.encodeStateVector(this.doc), (update) => { Y__namespace.applyUpdate(this.doc, new Uint8Array(update), this); }); if (this.awareness.getLocalState() !== null) this.socket.emit( "awareness-update", AwarenessProtocol__namespace.encodeAwarenessUpdate(this.awareness, [ this.doc.clientID, ]) ); if (resyncInterval > 0) { this.resyncInterval = setInterval(() => { if (this.socket.disconnected) return; this.socket.emit( "sync-step-1", Y__namespace.encodeStateVector(this.doc), (update) => { Y__namespace.applyUpdate(this.doc, new Uint8Array(update), this); } ); }, resyncInterval); } }; /** * Disconnect provider's socket * @type {() => void} */ disconnect() { if (this.socket.connected) { this.disconnectBc(); this.socket.disconnect(); } } /** * This function runs when the socket is disconnected and emits the socket event `awareness-update` * which removes this client from awareness. */ onSocketDisconnection = (event) => { this.emit("connection-close", [event, this]); this.synced = false; AwarenessProtocol__namespace.removeAwarenessStates( this.awareness, Array.from(this.awareness.getStates().keys()).filter( (client) => client !== this.doc.clientID ), this ); this.emit("status", [{ status: "disconnected" }]); }; /** * This function is executed when the socket connection fails. */ onSocketConnectionError = (error) => { this.emit("connection-error", [error, this]); }; /** * Destroy the provider. This method clears the document, awareness, and window/process listeners and disconnects the socket. * @type {() => void} */ destroy() { if (this.resyncInterval != null) clearInterval(this.resyncInterval); this.disconnect(); if (typeof window !== "undefined") window.removeEventListener("beforeunload", this.beforeUnloadHandler); else if (typeof process !== "undefined") process.off("exit", this.beforeUnloadHandler); this.awareness.off("update", this.awarenessUpdate); this.doc.off("update", this.onUpdateDoc); super.destroy(); } /** * This function is executed when the document is updated, if the instance that * emit the change is not this, it emit the changes by socket and broadcast channel. * @private * @param {Uint8Array} update Document update * @param {SocketIOProvider} origin The SocketIOProvider instance that emits the change. * @type {(update: Uint8Array, origin: SocketIOProvider) => void} */ onUpdateDoc = (update, origin) => { if (origin !== this) { this.socket.emit("sync-update", update); if (this.bcconnected) { bc__namespace.publish( this._broadcastChannel, { type: "sync-update", data: update, }, this ); } } }; /** * This function is called when the server emits the `sync-update` event and applies the received update to the local document. * @private * @param {Uint8Array}update A document update received by the `sync-update` socket event * @type {(update: Uint8Array) => void} */ onSocketSyncUpdate = (update) => { Y__namespace.applyUpdate(this.doc, new Uint8Array(update), this); }; /** * This function is executed when the local awareness changes and this broadcasts the changes per socket and broadcast channel. * @private * @param {{ added: number[], updated: number[], removed: number[] }} awarenessChanges The clients added, updated and removed * @param {SocketIOProvider | null} origin The SocketIOProvider instance that emits the change. * @type {({ added, updated, removed }: { added: number[], updated: number[], removed: number[] }, origin: SocketIOProvider | null) => void} */ awarenessUpdate = ({ added, updated, removed }, origin) => { const changedClients = added.concat(updated).concat(removed); this.socket.emit( "awareness-update", AwarenessProtocol__namespace.encodeAwarenessUpdate(this.awareness, changedClients) ); if (this.bcconnected) { bc__namespace.publish( this._broadcastChannel, { type: "awareness-update", data: AwarenessProtocol__namespace.encodeAwarenessUpdate( this.awareness, changedClients ), }, this ); } }; /** * This function is executed when the windows will be unloaded or the process will be closed and this * will remove the local client from awareness. * @private * @type {() => void} */ beforeUnloadHandler = () => { AwarenessProtocol__namespace.removeAwarenessStates( this.awareness, [this.doc.clientID], "window unload" ); }; /** * This function subscribes the provider to the broadcast channel and initiates synchronization by broadcast channel. * @type {() => void} */ connectBc = () => { if (!this.bcconnected) { bc__namespace.subscribe(this._broadcastChannel, this.onBroadcastChannelMessage); this.bcconnected = true; } bc__namespace.publish( this._broadcastChannel, { type: "sync-step-1", data: Y__namespace.encodeStateVector(this.doc) }, this ); bc__namespace.publish( this._broadcastChannel, { type: "sync-step-2", data: Y__namespace.encodeStateAsUpdate(this.doc) }, this ); bc__namespace.publish( this._broadcastChannel, { type: "query-awareness", data: null }, this ); bc__namespace.publish( this._broadcastChannel, { type: "awareness-update", data: AwarenessProtocol__namespace.encodeAwarenessUpdate(this.awareness, [ this.doc.clientID, ]), }, this ); }; /** * This function unsubscribes the provider from the broadcast channel and before unsubscribing, updates the awareness. * @type {() => void} */ disconnectBc = () => { bc__namespace.publish( this._broadcastChannel, { type: "awareness-update", data: AwarenessProtocol__namespace.encodeAwarenessUpdate( this.awareness, [this.doc.clientID], new Map() ), }, this ); if (this.bcconnected) { bc__namespace.unsubscribe(this._broadcastChannel, this.onBroadcastChannelMessage); this.bcconnected = false; } }; /** * This method handles messages received by the broadcast channel and responds to them. * @param {{ type: string, data: any }} message The object message received by broadcast channel * @param {SocketIOProvider} origin The SocketIOProvider instance that emits the change * @type {(message: { type: string, data: any }, origin: SocketIOProvider) => void} */ onBroadcastChannelMessage = (message, origin) => { if (origin !== this && message.type.length > 0) { switch (message.type) { case "sync-step-1": bc__namespace.publish( this._broadcastChannel, { type: "sync-step-2", data: Y__namespace.encodeStateAsUpdate(this.doc, message.data), }, this ); break; case "sync-step-2": Y__namespace.applyUpdate(this.doc, new Uint8Array(message.data), this); break; case "sync-update": Y__namespace.applyUpdate(this.doc, new Uint8Array(message.data), this); break; case "query-awareness": bc__namespace.publish( this._broadcastChannel, { type: "awareness-update", data: AwarenessProtocol__namespace.encodeAwarenessUpdate( this.awareness, Array.from(this.awareness.getStates().keys()) ), }, this ); break; case "awareness-update": AwarenessProtocol__namespace.applyAwarenessUpdate( this.awareness, new Uint8Array(message.data), this ); break; } } }; } exports.SocketIOProvider = SocketIOProvider; //# sourceMappingURL=y-socket.io-provider.cjs.map