UNPKG

tinybase

Version:

A reactive data store and sync engine.

737 lines (731 loc) 25.8 kB
/** * The synchronizer-ws-server module of the TinyBase project lets you create * a server that facilitates synchronization between clients. * @see Synchronization guide * @see Todo App v6 (collaboration) demo * @packageDocumentation * @module synchronizer-ws-server * @since v5.0.0 */ import type {Id, IdOrNull, Ids} from '../../common/index.d.ts'; import type {MergeableStore} from '../../mergeable-store/index.d.ts'; import type {Persister, Persists} from '../../persisters/index.d.ts'; import type {IdAddedOrRemoved} from '../../store/index.d.ts'; import type {WebSocketServer} from 'ws'; /** * The PathIdsListener type describes a function that is used to listen to * changes of active paths that a WsServer is handling. * * A WsServer listens to any path, allowing an app to have the concept of * distinct 'rooms' that only certain clients are participating in. As soon as a * single client connects to a new path, this listener will be called with the * Id of the new path and an `addedOrRemoved` value of `1`. * * When the final client disconnects from a path, it will be called again with * the Id of the deactivated path and an `addedOrRemoved` value of `-1`. * * A PathIdsListener is provided when using the addPathIdsListener method. See * that method for specific examples. * @param wsServer A reference to the WsServer. * @param pathId The Id of the path being added or removed. * @param addedOrRemoved Whether the path was added (`1`) or removed (`-1`). * @category Listener * @since v5.0.3 */ export type PathIdsListener = ( wsServer: WsServer, pathId: Id, addedOrRemoved: IdAddedOrRemoved, ) => void; /** * The ClientIdsListener type describes a function that is used to listen to * clients joining and leaving the active paths that a WsServer is handling. * * A WsServer listens to any path, allowing an app to have the concept of * distinct 'rooms' that only certain clients are participating in. As soon as a * new client connects to a path, this listener will be called with the Id of * the path, the Id of the new client, and an `addedOrRemoved` value of `1`. * * When the client disconnects from a path, it will be called again with the Id * of the path, the Id of the leaving client, and an `addedOrRemoved` value of * `-1`. * * A ClientIdsListener is provided when using the addClientIdsListener method. * See that method for specific examples. * @param wsServer A reference to the WsServer. * @param pathId The path that the client joined or left. * @param clientId The Id of the client being added or removed. * @param addedOrRemoved Whether the client was added (`1`) or removed (`-1`). * @category Listener * @since v5.0.3 */ export type ClientIdsListener = ( wsServer: WsServer, pathId: Id, clientId: Id, addedOrRemoved: IdAddedOrRemoved, ) => void; /** * The WsServerStats type describes the number of paths and clients that are * active on the WsServer. * * A WsServerStats object is returned from the getStats method. * @category Development * @since v5.0.0 */ export type WsServerStats = { /** * The number of paths currently being served by the WsServer. * @category Stat * @since v5.0.0 */ paths: number; /** * The number of clients currently being served by the WsServer. * @category Stat * @since v5.0.0 */ clients: number; }; /** * The WsServer interface represents an object that facilitates synchronization * between clients that are using WsSynchronizer instances. * * You should use the createWsServer function to create a WsServer object. * @category Server * @since v5.0.0 */ export interface WsServer { /** * The getWebSocketServer method returns a reference to the WebSocketServer * being used for this WsServer. * @returns The WebSocketServer reference. * @example * This example creates a WsServer and then gets the WebSocketServer * reference back out again. * * ```js * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocketServer} from 'ws'; * * const webSocketServer = new WebSocketServer({port: 8047}); * const server = createWsServer(webSocketServer); * * console.log(server.getWebSocketServer() == webSocketServer); * // -> true * * server.destroy(); * ``` * @category Getter * @since v5.0.0 */ getWebSocketServer(): WebSocketServer; /** * The getPathIds method returns the active paths that the WsServer is * handling. * * These will be all the paths that have at least one active client connected * to them. * @returns An array of the paths that have clients connected to them. * @example * This example creates a WsServer, sets some clients up to connect * to it, and then enumerates the paths being used. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * console.log(server.getPathIds()); * // -> [] * * const synchronizer1 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * const synchronizer2 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * const synchronizer3 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomB'), * ); * * console.log(server.getPathIds()); * // -> ['roomA', 'roomB'] * * synchronizer3.destroy(); * // ... * console.log(server.getPathIds()); * // -> ['roomA'] * * synchronizer1.destroy(); * synchronizer2.destroy(); * server.destroy(); * ``` * @category Getter * @since v5.0.0 */ getPathIds(): Ids; /** * The getClientIds method method returns the active clients that the WsServer * is handling for a given path. * @param pathId The path for which to return the list of active clients. * @returns An array of the clients connected to the given path. * @example * This example creates a WsServer, sets some clients up to connect * to it, and then gets the number of clients on the given paths. (The client * Ids themselves are unique, based on the `sec-websocket-key` header.) * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * * const synchronizer1 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * const synchronizer2 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * const synchronizer3 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomB'), * ); * * console.log(server.getClientIds('roomA').length); * // -> 2 * console.log(server.getClientIds('roomB').length); * // -> 1 * * synchronizer3.destroy(); * // ... * console.log(server.getClientIds('roomB').length); * // -> 0 * * synchronizer1.destroy(); * synchronizer2.destroy(); * server.destroy(); * ``` * @category Getter * @since v5.0.0 */ getClientIds(pathId: Id): Ids; /** * The addPathIdsListener method registers a listener function with the * WsServer that will be called whenever there is a change in the active paths * that a WsServer is handling. * * The provided listener is a PathIdsListener function, and will be called * with a reference to the WsServer and a callback you can use to get * information about the change. * @param listener The function that will be called whenever the path Ids * handled by the WsServer change. * @returns A unique Id for the listener that can later be used to remove it. * @example * This example creates a WsServer, and listens to changes to the active paths * when clients connect to and disconnect from it. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * const listenerId = server.addPathIdsListener( * (server, pathId, addedOrRemoved) => { * console.log(pathId + (addedOrRemoved == 1 ? ' added' : ' removed')); * console.log(server.getPathIds()); * }, * ); * * const synchronizer1 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * // -> 'roomA added' * // -> ['roomA'] * * const synchronizer2 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomB'), * ); * // -> 'roomB added' * // -> ['roomA', 'roomB'] * * synchronizer1.destroy(); * // ... * // -> 'roomA removed' * // -> ['roomB'] * * synchronizer2.destroy(); * // ... * // -> 'roomB removed' * // -> [] * * server.delListener(listenerId); * server.destroy(); * ``` * @category Listener * @since v5.0.0 */ addPathIdsListener(listener: PathIdsListener): Id; /** * The addClientIdsListener method registers a listener function with the * WsServer that will be called whenever there is a change in the clients * connected to a path that a WsServer is handling. * * The provided listener is a ClientIdsListener function, and will be called * with a reference to the WsServer, the Id of the path that the client joined * or left, and a callback you can use to get information about the change. * * You can either listen to a single path (by specifying its Id as the * method's first parameter) or changes to any path (by providing a `null` * wildcard). * @param pathId The path to listen to, or `null` as a wildcard. * @param listener The function that will be called whenever the client Ids on * a path handled by the WsServer change. * @returns A unique Id for the listener that can later be used to remove it. * @example * This example creates a WsServer, and listens to changes to the clients * connecting to and disconnecting from a specific path. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * const listenerId = server.addClientIdsListener( * 'roomA', * (server, pathId) => { * console.log( * `${server.getClientIds(pathId).length} client(s) in roomA`, * ); * }, * ); * * const synchronizer1 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * // -> '1 client(s) in roomA' * * const synchronizer2 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomB'), * ); * // The listener is not called. * * synchronizer1.destroy(); * // ... * // -> '0 client(s) in roomA' * * synchronizer2.destroy(); * * server.delListener(listenerId); * server.destroy(); * ``` * @example * This example creates a WsServer, and listens to changes to the clients * connecting to and disconnecting from any path. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * const listenerId = server.addClientIdsListener(null, (server, pathId) => { * console.log( * `${server.getClientIds(pathId).length} client(s) in ${pathId}`, * ); * }); * * const synchronizer1 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * // -> '1 client(s) in roomA' * * const synchronizer2 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomB'), * ); * // -> '1 client(s) in roomB' * * synchronizer1.destroy(); * // ... * // -> '0 client(s) in roomA' * * synchronizer2.destroy(); * // ... * // -> '0 client(s) in roomB' * * server.delListener(listenerId); * server.destroy(); * ``` * @category Listener * @since v5.0.0 */ addClientIdsListener(pathId: IdOrNull, listener: ClientIdsListener): Id; /** * The delListener method removes a listener that was previously added to the * WsServer. * * Use the Id returned by whichever method was used to add the listener. Note * that the WsServer may re-use this Id for future listeners added to it. * @param listenerId The Id of the listener to remove. * @returns A reference to the WsServer. * @example * This example registers a listener to a WsServer and then removes it. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * const listenerId = server.addPathIdsListener(() => { * console.log('Paths changed'); * }); * * const synchronizer = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047/roomA'), * ); * // -> 'Paths changed' * * server.delListener(listenerId); * * synchronizer.destroy(); * // -> undefined * // The listener is not called. * * server.destroy(); * ``` * @category Listener * @since v5.0.0 */ delListener(listenerId: Id): WsServer; /** * The getStats method provides a set of statistics about the WsServer, and is * used for debugging purposes. * * The WsServerStats object contains the number of paths and clients that are * active on the WsServer and is intended to be used during development. * @returns A WsServerStats object containing statistics. * @example * This example creates a WsServer that facilitates some synchronization, * demonstrating the statistics of the paths and clients handled as a result. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * const server = createWsServer(new WebSocketServer({port: 8047})); * * const synchronizer1 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047'), * ); * const synchronizer2 = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047'), * ); * * console.log(server.getStats()); * // -> {paths: 1, clients: 2} * * synchronizer1.destroy(); * synchronizer2.destroy(); * server.destroy(); * ``` * @category Development * @since v5.0.0 */ getStats(): WsServerStats; /** * The destroy method provides a way to clean up the server at the end of its * use. * * This closes the underlying WebSocketServer that was provided when the * WsServer was created. * @example * This example creates a WsServer and then destroys it again, closing the * underlying WebSocketServer. * * ```js * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocketServer} from 'ws'; * * const webSocketServer = new WebSocketServer({port: 8047}); * webSocketServer.on('close', () => { * console.log('WebSocketServer closed'); * }); * const server = createWsServer(webSocketServer); * * server.destroy(); * // ... * // -> 'WebSocketServer closed' * ``` * @category Getter * @since v5.0.0 */ destroy(): void; } /** * The createWsServer function creates a WsServer that facilitates * synchronization between clients that are using WsSynchronizer instances. * * This should be run in a server environment, and you must pass in a configured * WebSocketServer object in order to create it. * * If you want your server to persist data itself, you can use the optional * second parameter of this function, which allows you to create a Persister for * a new path - whenever a new path is accessed by a client. This Persister will * only exist when there are active clients on that particular path. The * creation callback can be asynchronous. * * You are responsible for creating a MergeableStore to pass to this Persister, * but starting and stopping its automatic saving and loading is taken care of * by the WsServer. As a result, the server MergeableStore will be kept in sync * with the clients on that path, and in turn with whatever persistence layer * you have configured. See the example below. * * It is not safe to add or manipulate data in the MergeableStore during the * `createPersisterForPath` function, since changes will probably be overwritten * when the Persister starts. If you wish to modify data - or upgrade a schema, * for example - you can have that function instead return an array containing * the Persister _and_ a callback that takes the MergeableStore. That callback * will get called after the Persister has started, and is an appropriate place * to manipulate data in a way that will be transmitted to clients. Again, see * the example below. * @param webSocketServer A WebSocketServer object from your server environment. * @param createPersisterForPath An optional function that will create a * Persister to synchronize with the clients on a given path (or a two-item * array of Persister and callback that lets you handle data after persistence * has started). * @param onIgnoredError An optional handler for the errors that the server * would otherwise ignore when trying to sync data. This is suitable for * debugging issues in a development environment. * @returns A reference to the new WsServer object. * @example * This example creates a WsServer that synchronizes two clients on a shared * path. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocketServer} from 'ws'; * * // Server * const server = createWsServer(new WebSocketServer({port: 8047})); * * // Client 1 * const clientStore1 = createMergeableStore(); * clientStore1.setCell('pets', 'fido', 'species', 'dog'); * const synchronizer1 = await createWsSynchronizer( * clientStore1, * new WebSocket('ws://localhost:8047/petShop'), * ); * await synchronizer1.startSync(); * // ... * * // Client 2 * const clientStore2 = createMergeableStore(); * clientStore2.setCell('pets', 'felix', 'species', 'cat'); * const synchronizer2 = await createWsSynchronizer( * clientStore2, * new WebSocket('ws://localhost:8047/petShop'), * ); * await synchronizer2.startSync(); * // ... * * console.log(clientStore1.getTables()); * // -> {pets: {fido: {species: 'dog'}, felix: {species: 'cat'}}} * * console.log(clientStore2.getTables()); * // -> {pets: {fido: {species: 'dog'}, felix: {species: 'cat'}}} * * synchronizer1.destroy(); * synchronizer2.destroy(); * server.destroy(); * ``` * @example * This longer example creates a WsServer that persists a MergeableStore to file * that is synchronized with two clients on a shared path. Later, when a third * client connects, it picks up the data the previous two were using. * * ```js * import {rmSync} from 'fs'; * import {createMergeableStore} from 'tinybase'; * import {createFilePersister} from 'tinybase/persisters/persister-file'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocketServer} from 'ws'; * * // Server * const server = createWsServer( * new WebSocketServer({port: 8047}), * (pathId) => * createFilePersister( * createMergeableStore(), * pathId.replace(/[^a-zA-Z0-9]/g, '-') + '.json', * ), * ); * * // Client 1 * const clientStore1 = createMergeableStore(); * clientStore1.setCell('pets', 'fido', 'species', 'dog'); * const synchronizer1 = await createWsSynchronizer( * clientStore1, * new WebSocket('ws://localhost:8047/petShop'), * ); * await synchronizer1.startSync(); * // ... * * // Client 2 * const clientStore2 = createMergeableStore(); * clientStore2.setCell('pets', 'felix', 'species', 'cat'); * const synchronizer2 = await createWsSynchronizer( * clientStore2, * new WebSocket('ws://localhost:8047/petShop'), * ); * await synchronizer2.startSync(); * // ... * * console.log(clientStore1.getTables()); * // -> {pets: {fido: {species: 'dog'}, felix: {species: 'cat'}}} * * console.log(clientStore2.getTables()); * // -> {pets: {fido: {species: 'dog'}, felix: {species: 'cat'}}} * * synchronizer1.destroy(); * synchronizer2.destroy(); * * // ... * // Client 3 connects later * const clientStore3 = createMergeableStore(); * const synchronizer3 = await createWsSynchronizer( * clientStore3, * new WebSocket('ws://localhost:8047/petShop'), * ); * await synchronizer3.startSync(); * // ... * * console.log(clientStore3.getTables()); * // -> {pets: {fido: {species: 'dog'}, felix: {species: 'cat'}}} * * synchronizer3.destroy(); * server.destroy(); * * // Remove file for the purposes of this demo. * rmSync('petShop.json'); * ``` * @example * This example creates a WsServer that persists a MergeableStore to file that * is synchronized with two clients on a shared path, but also which updates its * data once synchronization has started. * * ```js * import {rmSync} from 'fs'; * import {createMergeableStore} from 'tinybase'; * import {createFilePersister} from 'tinybase/persisters/persister-file'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocketServer} from 'ws'; * * // Server * const server = createWsServer( * new WebSocketServer({port: 8047}), * (pathId) => [ * createFilePersister( * createMergeableStore(), * pathId.replace(/[^a-zA-Z0-9]/g, '-') + '.json', * ), * (store) => store.setValue('pathId', pathId), * ], * ); * * const clientStore = createMergeableStore(); * clientStore.setCell('pets', 'fido', 'species', 'dog'); * const synchronizer = await createWsSynchronizer( * clientStore, * new WebSocket('ws://localhost:8047/petShop'), * ); * await synchronizer.startSync(); * // ... * * console.log(clientStore.getContent()); * // -> [{pets: {fido: {species: 'dog'}}}, {"pathId": "petShop"}] * * synchronizer.destroy(); * server.destroy(); * * // Remove file for the purposes of this demo. * rmSync('petShop.json'); * ``` * @example * This example creates a WsServer with a custom listener that displays * information about the address of the client that connects to it. * * ```js * import {createMergeableStore} from 'tinybase'; * import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; * import {createWsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; * import {WebSocket, WebSocketServer} from 'ws'; * * // On the server: * const webSocketServer = new WebSocketServer({port: 8047}); * webSocketServer.on('connection', (_, request) => { * if (request.headers.connection == 'Upgrade') { * console.log('Local client connected'); * } * }); * const server = createWsServer(webSocketServer); * * // On a client: * const synchronizer = await createWsSynchronizer( * createMergeableStore(), * new WebSocket('ws://localhost:8047'), * ); * // -> 'Local client connected' * * synchronizer.destroy(); * server.destroy(); * ``` * @category Creation * @since v5.0.0 */ export function createWsServer< PathPersister extends Persister< Persists.MergeableStoreOnly | Persists.StoreOrMergeableStore >, >( webSocketServer: WebSocketServer, createPersisterForPath?: ( pathId: Id, ) => | PathPersister | [PathPersister, (store: MergeableStore) => void] | Promise<PathPersister> | Promise<[PathPersister, (store: MergeableStore) => void]> | undefined, onIgnoredError?: (error: any) => void, ): WsServer;