@hocuspocus/server
Advanced tools
+21
| MIT License | ||
| Copyright (c) 2023, Tiptap GmbH | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+257
-142
@@ -1,10 +0,9 @@ | ||
| import { IncomingHttpHeaders, IncomingMessage as IncomingMessage$1, Server as Server$1, ServerResponse } from "node:http"; | ||
| import { CloseEvent } from "@hocuspocus/common"; | ||
| import WebSocket, { AddressInfo, ServerOptions, WebSocketServer } from "ws"; | ||
| import { Mutex } from "async-mutex"; | ||
| import * as Y from "yjs"; | ||
| import { Doc } from "yjs"; | ||
| import { URLSearchParams } from "node:url"; | ||
| import { IncomingMessage as IncomingMessage$1, Server as Server$1, ServerResponse } from "node:http"; | ||
| import { AddressInfo } from "node:net"; | ||
| //#region node_modules/lib0/observable.d.ts | ||
| //#region node_modules/.pnpm/lib0@0.2.117/node_modules/lib0/observable.d.ts | ||
| /** | ||
@@ -50,3 +49,3 @@ * Handles named events. | ||
| //#endregion | ||
| //#region node_modules/y-protocols/awareness.d.ts | ||
| //#region node_modules/.pnpm/y-protocols@1.0.7_yjs@13.6.29/node_modules/y-protocols/awareness.d.ts | ||
| /** | ||
@@ -133,8 +132,7 @@ * @typedef {Object} MetaClientState | ||
| callbacks: { | ||
| onUpdate: (document: Document, connection: Connection, update: Uint8Array) => void; | ||
| onUpdate: (document: Document, origin: unknown, update: Uint8Array) => void; | ||
| beforeBroadcastStateless: (document: Document, stateless: string) => void; | ||
| }; | ||
| connections: Map<WebSocket, { | ||
| connections: Map<Connection, { | ||
| clients: Set<any>; | ||
| connection: Connection; | ||
| }>; | ||
@@ -162,3 +160,3 @@ directConnectionsCount: number; | ||
| */ | ||
| onUpdate(callback: (document: Document, connection: Connection, update: Uint8Array) => void): Document; | ||
| onUpdate(callback: (document: Document, origin: unknown, update: Uint8Array) => void): Document; | ||
| /** | ||
@@ -194,3 +192,3 @@ * Set a callback that will be triggered before a stateless message is broadcasted | ||
| */ | ||
| getClients(connectionInstance: WebSocket): Set<any>; | ||
| getClients(connection: Connection): Set<any>; | ||
| /** | ||
@@ -220,11 +218,79 @@ * Has the document awareness states | ||
| //#endregion | ||
| //#region packages/server/src/ClientConnection.d.ts | ||
| /** | ||
| * The `ClientConnection` class is responsible for handling an incoming WebSocket | ||
| * | ||
| * TODO-refactor: | ||
| * - use event handlers instead of calling hooks directly, hooks should probably be called from Hocuspocus.ts | ||
| */ | ||
| declare class ClientConnection<Context = any> { | ||
| private readonly websocket; | ||
| private readonly request; | ||
| private readonly documentProvider; | ||
| private readonly hooks; | ||
| private readonly opts; | ||
| private readonly defaultContext; | ||
| private readonly documentConnections; | ||
| private readonly incomingMessageQueue; | ||
| private readonly documentConnectionsEstablished; | ||
| private readonly hookPayloads; | ||
| private readonly callbacks; | ||
| private readonly socketId; | ||
| timeout: number; | ||
| pingInterval: NodeJS.Timeout; | ||
| lastMessageReceivedAt: number; | ||
| /** | ||
| * The `ClientConnection` class receives incoming WebSocket connections, | ||
| * runs all hooks: | ||
| * | ||
| * - onConnect for all connections | ||
| * - onAuthenticate only if required | ||
| * | ||
| * … and if nothings fails it'll fully establish the connection and | ||
| * load the Document then. | ||
| */ | ||
| constructor(websocket: WebSocketLike, request: Request, documentProvider: { | ||
| createDocument: Hocuspocus["createDocument"]; | ||
| }, hooks: Hocuspocus["hooks"], opts: { | ||
| timeout: number; | ||
| }, defaultContext?: Context); | ||
| /** | ||
| * Handle WebSocket close event. Call this from your integration | ||
| * when the WebSocket connection closes. | ||
| */ | ||
| handleClose(event?: CloseEvent): void; | ||
| private close; | ||
| /** | ||
| * Close the connection if no messages have been received within the timeout period. | ||
| * This replaces application-level ping/pong to maintain backward compatibility | ||
| * with older provider versions that don't understand Ping/Pong message types. | ||
| * Awareness updates (~every 30s) keep active connections alive. | ||
| */ | ||
| private check; | ||
| /** | ||
| * Set a callback that will be triggered when the connection is closed | ||
| */ | ||
| onClose(callback: (document: Document, payload: onDisconnectPayload) => void): ClientConnection; | ||
| /** | ||
| * Create a new connection by the given request and document | ||
| */ | ||
| private createConnection; | ||
| private setUpNewConnection; | ||
| private handleQueueingMessage; | ||
| /** | ||
| * Handle an incoming WebSocket message. Call this from your integration | ||
| * when the WebSocket receives a binary message. | ||
| */ | ||
| handleMessage: (data: Uint8Array) => void; | ||
| } | ||
| //#endregion | ||
| //#region packages/server/src/DirectConnection.d.ts | ||
| declare class DirectConnection$1 implements DirectConnection { | ||
| declare class DirectConnection$1<Context = any> implements DirectConnection { | ||
| document: Document | null; | ||
| instance: Hocuspocus; | ||
| context: any; | ||
| context: Context; | ||
| /** | ||
| * Constructor. | ||
| */ | ||
| constructor(document: Document, instance: Hocuspocus, context?: any); | ||
| constructor(document: Document, instance: Hocuspocus, context?: Context); | ||
| transact(transaction: (document: Document) => void): Promise<void>; | ||
@@ -235,6 +301,11 @@ disconnect(): Promise<void>; | ||
| //#region packages/server/src/Server.d.ts | ||
| interface ServerConfiguration extends Configuration { | ||
| interface ServerConfiguration<Context = any> extends Configuration<Context> { | ||
| port?: number; | ||
| address?: string; | ||
| stopOnSignals?: boolean; | ||
| /** | ||
| * Options passed to the underlying WebSocket server (ws). | ||
| * Supports all ws ServerOptions, e.g. { maxPayload: 1024 * 1024 } | ||
| */ | ||
| websocketOptions?: Record<string, any>; | ||
| } | ||
@@ -246,14 +317,13 @@ declare const defaultServerConfiguration: { | ||
| }; | ||
| declare class Server { | ||
| declare class Server<Context = any> { | ||
| httpServer: Server$1; | ||
| webSocketServer: WebSocketServer; | ||
| hocuspocus: Hocuspocus; | ||
| configuration: ServerConfiguration; | ||
| constructor(configuration?: Partial<ServerConfiguration>, websocketOptions?: ServerOptions); | ||
| setupWebsocketConnection: () => void; | ||
| setupHttpUpgrade: () => void; | ||
| private crossws; | ||
| hocuspocus: Hocuspocus<Context>; | ||
| configuration: ServerConfiguration<Context>; | ||
| constructor(configuration?: Partial<ServerConfiguration<Context>>); | ||
| private setupHttpUpgrade; | ||
| requestHandler: (request: IncomingMessage$1, response: ServerResponse) => Promise<void>; | ||
| listen(port?: number, callback?: any): Promise<Hocuspocus>; | ||
| listen(port?: number, callback?: any): Promise<Hocuspocus<Context>>; | ||
| get address(): AddressInfo; | ||
| destroy(): Promise<any>; | ||
| destroy(): Promise<void>; | ||
| get URL(): string; | ||
@@ -278,4 +348,4 @@ get webSocketURL(): string; | ||
| }; | ||
| declare class Hocuspocus { | ||
| configuration: Configuration; | ||
| declare class Hocuspocus<Context = any> { | ||
| configuration: Configuration<Context>; | ||
| loadingDocuments: Map<string, Promise<Document>>; | ||
@@ -291,7 +361,7 @@ unloadingDocuments: Map<string, Promise<void>>; | ||
| }; | ||
| constructor(configuration?: Partial<Configuration>); | ||
| constructor(configuration?: Partial<Configuration<Context>>); | ||
| /** | ||
| * Configure Hocuspocus | ||
| */ | ||
| configure(configuration: Partial<Configuration>): Hocuspocus; | ||
| configure(configuration: Partial<Configuration<Context>>): Hocuspocus<Context>; | ||
| /** | ||
@@ -306,2 +376,8 @@ * Get the total number of active documents | ||
| /** | ||
| * Immediately execute all pending debounced onStoreDocument calls. | ||
| * Useful during shutdown to ensure documents are persisted and unloaded | ||
| * before the server exits, even when unloadImmediately is false. | ||
| */ | ||
| flushPendingStores(): void; | ||
| /** | ||
| * Force close one or more connections | ||
@@ -317,6 +393,6 @@ */ | ||
| * | ||
| * … and if nothing fails it’ll fully establish the connection and | ||
| * … and if nothing fails it'll fully establish the connection and | ||
| * load the Document then. | ||
| */ | ||
| handleConnection(incoming: WebSocket, request: IncomingMessage$1, defaultContext?: any): void; | ||
| handleConnection(incoming: WebSocket | WebSocketLike, request: Request, defaultContext?: Context): ClientConnection; | ||
| /** | ||
@@ -332,4 +408,4 @@ * Handle update of the given document | ||
| */ | ||
| createDocument(documentName: string, request: Partial<Pick<IncomingMessage$1, "headers" | "url">>, socketId: string, connection: ConnectionConfiguration, context?: any): Promise<Document>; | ||
| loadDocument(documentName: string, request: Partial<Pick<IncomingMessage$1, "headers" | "url">>, socketId: string, connectionConfig: ConnectionConfiguration, context?: any): Promise<Document>; | ||
| createDocument(documentName: string, request: Request, socketId: string, connection: ConnectionConfiguration, context?: Context): Promise<Document>; | ||
| loadDocument(documentName: string, request: Request, socketId: string, connectionConfig: ConnectionConfiguration, context?: Context): Promise<Document>; | ||
| storeDocumentHooks(document: Document, hookPayload: onStoreDocumentPayload, immediately?: boolean): Promise<any>; | ||
@@ -340,9 +416,32 @@ /** | ||
| */ | ||
| hooks<T extends HookName>(name: T, payload: HookPayloadByName[T], callback?: Function | null): Promise<any>; | ||
| hooks<T extends HookName>(name: T, payload: HookPayloadByName<Context>[T], callback?: Function | null): Promise<any>; | ||
| shouldUnloadDocument(document: Document): boolean; | ||
| unloadDocument(document: Document): Promise<any>; | ||
| openDirectConnection(documentName: string, context?: any): Promise<DirectConnection$1>; | ||
| openDirectConnection(documentName: string, context?: Context): Promise<DirectConnection$1<Context>>; | ||
| } | ||
| //#endregion | ||
| //#region packages/server/src/types.d.ts | ||
| interface ConnectionTransactionOrigin { | ||
| source: "connection"; | ||
| connection: Connection; | ||
| } | ||
| interface RedisTransactionOrigin { | ||
| source: "redis"; | ||
| } | ||
| interface LocalTransactionOrigin { | ||
| source: "local"; | ||
| skipStoreHooks?: boolean; | ||
| context?: any; | ||
| } | ||
| type TransactionOrigin = ConnectionTransactionOrigin | RedisTransactionOrigin | LocalTransactionOrigin; | ||
| declare function isTransactionOrigin(origin: unknown): origin is TransactionOrigin; | ||
| declare function shouldSkipStoreHooks(origin: unknown): boolean; | ||
| /** | ||
| * Minimal interface for any WebSocket-like object for WebSocket, Bun's ServerWebSocket, ws, Deno, etc. | ||
| */ | ||
| interface WebSocketLike { | ||
| send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; | ||
| close(code?: number, reason?: string): void; | ||
| readyState: number; | ||
| } | ||
| declare enum MessageType { | ||
@@ -359,3 +458,5 @@ Unknown = -1, | ||
| CLOSE = 7, | ||
| SyncStatus = 8 | ||
| SyncStatus = 8, | ||
| Ping = 9, | ||
| Pong = 10 | ||
| } | ||
@@ -371,3 +472,3 @@ interface AwarenessUpdate { | ||
| } | ||
| interface Extension { | ||
| interface Extension<Context = any> { | ||
| priority?: number; | ||
@@ -378,19 +479,19 @@ extensionName?: string; | ||
| onUpgrade?(data: onUpgradePayload): Promise<any>; | ||
| onConnect?(data: onConnectPayload): Promise<any>; | ||
| connected?(data: connectedPayload): Promise<any>; | ||
| onAuthenticate?(data: onAuthenticatePayload): Promise<any>; | ||
| onTokenSync?(data: onTokenSyncPayload): Promise<any>; | ||
| onCreateDocument?(data: onCreateDocumentPayload): Promise<any>; | ||
| onLoadDocument?(data: onLoadDocumentPayload): Promise<any>; | ||
| afterLoadDocument?(data: afterLoadDocumentPayload): Promise<any>; | ||
| beforeHandleMessage?(data: beforeHandleMessagePayload): Promise<any>; | ||
| beforeSync?(data: beforeSyncPayload): Promise<any>; | ||
| onConnect?(data: onConnectPayload<Context>): Promise<any>; | ||
| connected?(data: connectedPayload<Context>): Promise<any>; | ||
| onAuthenticate?(data: onAuthenticatePayload<Context>): Promise<any>; | ||
| onTokenSync?(data: onTokenSyncPayload<Context>): Promise<any>; | ||
| onCreateDocument?(data: onCreateDocumentPayload<Context>): Promise<any>; | ||
| onLoadDocument?(data: onLoadDocumentPayload<Context>): Promise<any>; | ||
| afterLoadDocument?(data: afterLoadDocumentPayload<Context>): Promise<any>; | ||
| beforeHandleMessage?(data: beforeHandleMessagePayload<Context>): Promise<any>; | ||
| beforeSync?(data: beforeSyncPayload<Context>): Promise<any>; | ||
| beforeBroadcastStateless?(data: beforeBroadcastStatelessPayload): Promise<any>; | ||
| onStateless?(payload: onStatelessPayload): Promise<any>; | ||
| onChange?(data: onChangePayload): Promise<any>; | ||
| onStoreDocument?(data: onStoreDocumentPayload): Promise<any>; | ||
| afterStoreDocument?(data: afterStoreDocumentPayload): Promise<any>; | ||
| onAwarenessUpdate?(data: onAwarenessUpdatePayload): Promise<any>; | ||
| onChange?(data: onChangePayload<Context>): Promise<any>; | ||
| onStoreDocument?(data: onStoreDocumentPayload<Context>): Promise<any>; | ||
| afterStoreDocument?(data: afterStoreDocumentPayload<Context>): Promise<any>; | ||
| onAwarenessUpdate?(data: onAwarenessUpdatePayload<Context>): Promise<any>; | ||
| onRequest?(data: onRequestPayload): Promise<any>; | ||
| onDisconnect?(data: onDisconnectPayload): Promise<any>; | ||
| onDisconnect?(data: onDisconnectPayload<Context>): Promise<any>; | ||
| beforeUnloadDocument?(data: beforeUnloadDocumentPayload): Promise<any>; | ||
@@ -401,23 +502,23 @@ afterUnloadDocument?(data: afterUnloadDocumentPayload): Promise<any>; | ||
| type HookName = "onConfigure" | "onListen" | "onUpgrade" | "onConnect" | "connected" | "onAuthenticate" | "onTokenSync" | "onCreateDocument" | "onLoadDocument" | "afterLoadDocument" | "beforeHandleMessage" | "beforeBroadcastStateless" | "beforeSync" | "onStateless" | "onChange" | "onStoreDocument" | "afterStoreDocument" | "onAwarenessUpdate" | "onRequest" | "onDisconnect" | "beforeUnloadDocument" | "afterUnloadDocument" | "onDestroy"; | ||
| type HookPayloadByName = { | ||
| type HookPayloadByName<Context = any> = { | ||
| onConfigure: onConfigurePayload; | ||
| onListen: onListenPayload; | ||
| onUpgrade: onUpgradePayload; | ||
| onConnect: onConnectPayload; | ||
| connected: connectedPayload; | ||
| onAuthenticate: onAuthenticatePayload; | ||
| onTokenSync: onTokenSyncPayload; | ||
| onCreateDocument: onCreateDocumentPayload; | ||
| onLoadDocument: onLoadDocumentPayload; | ||
| afterLoadDocument: afterLoadDocumentPayload; | ||
| beforeHandleMessage: beforeHandleMessagePayload; | ||
| onConnect: onConnectPayload<Context>; | ||
| connected: connectedPayload<Context>; | ||
| onAuthenticate: onAuthenticatePayload<Context>; | ||
| onTokenSync: onTokenSyncPayload<Context>; | ||
| onCreateDocument: onCreateDocumentPayload<Context>; | ||
| onLoadDocument: onLoadDocumentPayload<Context>; | ||
| afterLoadDocument: afterLoadDocumentPayload<Context>; | ||
| beforeHandleMessage: beforeHandleMessagePayload<Context>; | ||
| beforeBroadcastStateless: beforeBroadcastStatelessPayload; | ||
| beforeSync: beforeSyncPayload; | ||
| beforeSync: beforeSyncPayload<Context>; | ||
| onStateless: onStatelessPayload; | ||
| onChange: onChangePayload; | ||
| onStoreDocument: onStoreDocumentPayload; | ||
| afterStoreDocument: afterStoreDocumentPayload; | ||
| onAwarenessUpdate: onAwarenessUpdatePayload; | ||
| onChange: onChangePayload<Context>; | ||
| onStoreDocument: onStoreDocumentPayload<Context>; | ||
| afterStoreDocument: afterStoreDocumentPayload<Context>; | ||
| onAwarenessUpdate: onAwarenessUpdatePayload<Context>; | ||
| onRequest: onRequestPayload; | ||
| onDisconnect: onDisconnectPayload; | ||
| onDisconnect: onDisconnectPayload<Context>; | ||
| afterUnloadDocument: afterUnloadDocumentPayload; | ||
@@ -427,3 +528,3 @@ beforeUnloadDocument: beforeUnloadDocumentPayload; | ||
| }; | ||
| interface Configuration extends Extension { | ||
| interface Configuration<Context = any> extends Extension<Context> { | ||
| /** | ||
@@ -476,19 +577,20 @@ * A name for the instance, used for logging. | ||
| } | ||
| interface onAuthenticatePayload { | ||
| context: any; | ||
| interface onAuthenticatePayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| request: IncomingMessage$1; | ||
| request: Request; | ||
| socketId: string; | ||
| token: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| providerVersion: string | null; | ||
| } | ||
| interface onTokenSyncPayload { | ||
| context: any; | ||
| interface onTokenSyncPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -498,9 +600,9 @@ socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| } | ||
| interface onCreateDocumentPayload { | ||
| context: any; | ||
| interface onCreateDocumentPayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -510,29 +612,31 @@ socketId: string; | ||
| } | ||
| interface onConnectPayload { | ||
| context: any; | ||
| interface onConnectPayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| request: IncomingMessage$1; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| request: Request; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| providerVersion: string | null; | ||
| } | ||
| interface connectedPayload { | ||
| context: any; | ||
| interface connectedPayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| request: IncomingMessage$1; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| request: Request; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| providerVersion: string | null; | ||
| } | ||
| interface onLoadDocumentPayload { | ||
| context: any; | ||
| interface onLoadDocumentPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -542,8 +646,8 @@ socketId: string; | ||
| } | ||
| interface afterLoadDocumentPayload { | ||
| context: any; | ||
| interface afterLoadDocumentPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -553,32 +657,33 @@ socketId: string; | ||
| } | ||
| interface onChangePayload { | ||
| interface onChangePayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| update: Uint8Array; | ||
| socketId: string; | ||
| transactionOrigin: any; | ||
| transactionOrigin: unknown; | ||
| connection?: Connection<Context>; | ||
| } | ||
| interface beforeHandleMessagePayload { | ||
| interface beforeHandleMessagePayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| update: Uint8Array; | ||
| socketId: string; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| } | ||
| interface beforeSyncPayload { | ||
| interface beforeSyncPayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| /** | ||
@@ -604,22 +709,17 @@ * The y-protocols/sync message type | ||
| } | ||
| interface onStoreDocumentPayload { | ||
| interface onStoreDocumentPayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| document: Document; | ||
| lastContext: Context; | ||
| lastTransactionOrigin: unknown; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| transactionOrigin?: any; | ||
| } | ||
| interface afterStoreDocumentPayload extends onStoreDocumentPayload {} | ||
| interface onAwarenessUpdatePayload { | ||
| context: any; | ||
| interface afterStoreDocumentPayload<Context = any> extends onStoreDocumentPayload<Context> {} | ||
| interface onAwarenessUpdatePayload<Context = any> { | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| transactionOrigin: unknown; | ||
| connection?: Connection<Context>; | ||
| added: number[]; | ||
@@ -635,8 +735,8 @@ updated: number[]; | ||
| }[]; | ||
| interface fetchPayload { | ||
| context: any; | ||
| interface fetchPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -646,12 +746,12 @@ socketId: string; | ||
| } | ||
| interface storePayload extends onStoreDocumentPayload { | ||
| interface storePayload<Context = any> extends onStoreDocumentPayload<Context> { | ||
| state: Buffer; | ||
| } | ||
| interface onDisconnectPayload { | ||
| interface onDisconnectPayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -699,7 +799,7 @@ socketId: string; | ||
| //#region packages/server/src/Connection.d.ts | ||
| declare class Connection { | ||
| webSocket: WebSocket; | ||
| context: any; | ||
| declare class Connection<Context = any> { | ||
| webSocket: WebSocketLike; | ||
| context: Context; | ||
| document: Document; | ||
| request: IncomingMessage$1; | ||
| request: Request; | ||
| callbacks: { | ||
@@ -716,6 +816,15 @@ onClose: ((document: Document, event?: CloseEvent) => void)[]; | ||
| readOnly: boolean; | ||
| sessionId: string | null; | ||
| providerVersion: string | null; | ||
| /** | ||
| * The address string prefixed to outgoing messages. | ||
| * Session-aware clients get `documentName\0sessionId`; legacy clients get plain `documentName`. | ||
| */ | ||
| get messageAddress(): string; | ||
| private messageQueue; | ||
| private processingPromise; | ||
| /** | ||
| * Constructor. | ||
| */ | ||
| constructor(connection: WebSocket, request: IncomingMessage$1, document: Document, socketId: string, context: any, readOnly?: boolean); | ||
| constructor(connection: WebSocketLike, request: Request, document: Document, socketId: string, context: Context, readOnly?: boolean, sessionId?: string | null, providerVersion?: string | null); | ||
| /** | ||
@@ -744,5 +853,9 @@ * Set a callback that will be triggered when the connection is closed | ||
| /** | ||
| * Returns a promise that resolves when all queued messages have been processed. | ||
| */ | ||
| waitForPendingMessages(): Promise<void>; | ||
| /** | ||
| * Send the given message | ||
| */ | ||
| send(message: any): void; | ||
| send(message: Uint8Array): void; | ||
| /** | ||
@@ -770,19 +883,21 @@ * Send a stateless message with payload | ||
| handleMessage(data: Uint8Array): void; | ||
| private processMessages; | ||
| } | ||
| //#endregion | ||
| //#region node_modules/lib0/decoding.d.ts | ||
| //#region node_modules/.pnpm/lib0@0.2.117/node_modules/lib0/decoding.d.ts | ||
| /** | ||
| * A Decoder handles the decoding of an Uint8Array. | ||
| * @template {ArrayBufferLike} [Buf=ArrayBufferLike] | ||
| */ | ||
| declare class Decoder { | ||
| declare class Decoder<Buf extends ArrayBufferLike = ArrayBufferLike> { | ||
| /** | ||
| * @param {Uint8Array} uint8Array Binary data to decode | ||
| * @param {Uint8Array<Buf>} uint8Array Binary data to decode | ||
| */ | ||
| constructor(uint8Array: Uint8Array); | ||
| constructor(uint8Array: Uint8Array<Buf>); | ||
| /** | ||
| * Decoding target. | ||
| * | ||
| * @type {Uint8Array} | ||
| * @type {Uint8Array<Buf>} | ||
| */ | ||
| arr: Uint8Array; | ||
| arr: Uint8Array<Buf>; | ||
| /** | ||
@@ -796,3 +911,3 @@ * Current decoding position. | ||
| //#endregion | ||
| //#region node_modules/lib0/encoding.d.ts | ||
| //#region node_modules/.pnpm/lib0@0.2.117/node_modules/lib0/encoding.d.ts | ||
| /** | ||
@@ -803,3 +918,3 @@ * A BinaryEncoder handles the encoding to an Uint8Array. | ||
| cpos: number; | ||
| cbuf: Uint8Array; | ||
| cbuf: Uint8Array<ArrayBuffer>; | ||
| /** | ||
@@ -829,3 +944,3 @@ * @type {Array<Uint8Array>} | ||
| readVarString(): string; | ||
| toUint8Array(): Uint8Array<ArrayBufferLike>; | ||
| toUint8Array(): Uint8Array<ArrayBuffer>; | ||
| writeVarUint(type: MessageType): void; | ||
@@ -839,7 +954,7 @@ writeVarString(string: string): void; | ||
| message: IncomingMessage; | ||
| defaultTransactionOrigin?: string; | ||
| constructor(message: IncomingMessage, defaultTransactionOrigin?: string); | ||
| apply(document: Document, connection?: Connection, reply?: (message: Uint8Array) => void): void; | ||
| readSyncMessage(message: IncomingMessage, document: Document, connection?: Connection, reply?: (message: Uint8Array) => void, requestFirstSync?: boolean): 0 | 1 | 2; | ||
| applyQueryAwarenessMessage(document: Document, reply?: (message: Uint8Array) => void): void; | ||
| defaultTransactionOrigin?: TransactionOrigin; | ||
| constructor(message: IncomingMessage, defaultTransactionOrigin?: TransactionOrigin); | ||
| apply(document: Document, connection?: Connection, reply?: (message: Uint8Array) => void): Promise<void>; | ||
| readSyncMessage(message: IncomingMessage, document: Document, connection?: Connection, reply?: (message: Uint8Array) => void, requestFirstSync?: boolean): Promise<0 | 1 | 2>; | ||
| applyQueryAwarenessMessage(document: Document, connection?: Connection, reply?: (message: Uint8Array) => void): void; | ||
| } | ||
@@ -877,2 +992,2 @@ //#endregion | ||
| //#endregion | ||
| export { AwarenessUpdate, Configuration, Connection, ConnectionConfiguration, DirectConnection, Document, Extension, Hocuspocus, HookName, HookPayloadByName, IncomingMessage, MessageReceiver, MessageType, OutgoingMessage, Server, ServerConfiguration, StatesArray, afterLoadDocumentPayload, afterStoreDocumentPayload, afterUnloadDocumentPayload, beforeBroadcastStatelessPayload, beforeHandleMessagePayload, beforeSyncPayload, beforeUnloadDocumentPayload, connectedPayload, defaultConfiguration, defaultServerConfiguration, fetchPayload, onAuthenticatePayload, onAwarenessUpdatePayload, onChangePayload, onConfigurePayload, onConnectPayload, onCreateDocumentPayload, onDestroyPayload, onDisconnectPayload, onListenPayload, onLoadDocumentPayload, onRequestPayload, onStatelessPayload, onStoreDocumentPayload, onTokenSyncPayload, onUpgradePayload, storePayload, useDebounce }; | ||
| export { AwarenessUpdate, Configuration, Connection, ConnectionConfiguration, ConnectionTransactionOrigin, DirectConnection, Document, Extension, Hocuspocus, HookName, HookPayloadByName, IncomingMessage, LocalTransactionOrigin, MessageReceiver, MessageType, OutgoingMessage, RedisTransactionOrigin, Server, ServerConfiguration, StatesArray, TransactionOrigin, WebSocketLike, afterLoadDocumentPayload, afterStoreDocumentPayload, afterUnloadDocumentPayload, beforeBroadcastStatelessPayload, beforeHandleMessagePayload, beforeSyncPayload, beforeUnloadDocumentPayload, connectedPayload, defaultConfiguration, defaultServerConfiguration, fetchPayload, isTransactionOrigin, onAuthenticatePayload, onAwarenessUpdatePayload, onChangePayload, onConfigurePayload, onConnectPayload, onCreateDocumentPayload, onDestroyPayload, onDisconnectPayload, onListenPayload, onLoadDocumentPayload, onRequestPayload, onStatelessPayload, onStoreDocumentPayload, onTokenSyncPayload, onUpgradePayload, shouldSkipStoreHooks, storePayload, useDebounce }; |
+12
-7
| { | ||
| "name": "@hocuspocus/server", | ||
| "description": "plug & play collaboration backend", | ||
| "version": "3.4.6-rc.2", | ||
| "version": "4.0.0-rc.0", | ||
| "homepage": "https://hocuspocus.dev", | ||
@@ -32,12 +32,11 @@ "keywords": [ | ||
| "dependencies": { | ||
| "@hocuspocus/common": "^3.4.6-rc.2", | ||
| "@hocuspocus/common": "^4.0.0-rc.0", | ||
| "async-lock": "^1.3.1", | ||
| "async-mutex": "^0.5.0", | ||
| "crossws": "^0.4.4", | ||
| "kleur": "^4.1.4", | ||
| "lib0": "^0.2.47", | ||
| "ws": "^8.5.0" | ||
| "lib0": "^0.2.47" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/async-lock": "^1.1.3", | ||
| "@types/ws": "^8.5.3" | ||
| "@types/async-lock": "^1.1.3" | ||
| }, | ||
@@ -48,6 +47,12 @@ "peerDependencies": { | ||
| }, | ||
| "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d", | ||
| "gitHead": "3fe6c023877badd74d3ea3e50019b73660e028a4", | ||
| "repository": { | ||
| "url": "https://github.com/ueberdosis/hocuspocus" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public" | ||
| } | ||
| } |
+109
-86
| import crypto from "node:crypto"; | ||
| import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; | ||
| import type { URLSearchParams } from "node:url"; | ||
| import { | ||
@@ -13,3 +11,2 @@ type CloseEvent, | ||
| import * as decoding from "lib0/decoding"; | ||
| import type WebSocket from "ws"; | ||
| import Connection from "./Connection.ts"; | ||
@@ -22,2 +19,3 @@ import type Document from "./Document.ts"; | ||
| ConnectionConfiguration, | ||
| WebSocketLike, | ||
| beforeHandleMessagePayload, | ||
@@ -36,5 +34,6 @@ beforeSyncPayload, | ||
| */ | ||
| export class ClientConnection { | ||
| // this map indicates whether a `Connection` instance has already taken over for incoming message for the key (i.e. documentName) | ||
| private readonly documentConnections: Record<string, Connection> = {}; | ||
| export class ClientConnection<Context = any> { | ||
| // Map of established document connections, keyed by rawKey (composite or plain) | ||
| private readonly documentConnections: Record<string, Connection<Context>> = | ||
| {}; | ||
@@ -45,6 +44,6 @@ // While the connection will be establishing messages will | ||
| // While the connection is establishing, kee | ||
| // While the connection is establishing, keep track of which documents have received auth | ||
| private readonly documentConnectionsEstablished = new Set<string>(); | ||
| // hooks payload by Document | ||
| // Hook payloads keyed by rawKey (composite or plain) | ||
| private readonly hookPayloads: Record< | ||
@@ -54,8 +53,9 @@ string, | ||
| instance: Hocuspocus; | ||
| request: IncomingMessage; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| request: Request; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| context: any; | ||
| context: Context; | ||
| providerVersion: string | null; | ||
| } | ||
@@ -75,3 +75,3 @@ > = {}; | ||
| pongReceived = true; | ||
| lastMessageReceivedAt = Date.now(); | ||
@@ -85,8 +85,8 @@ /** | ||
| * | ||
| * … and if nothings fails it’ll fully establish the connection and | ||
| * … and if nothings fails it'll fully establish the connection and | ||
| * load the Document then. | ||
| */ | ||
| constructor( | ||
| private readonly websocket: WebSocket, | ||
| private readonly request: IncomingMessage, | ||
| private readonly websocket: WebSocketLike, | ||
| private readonly request: Request, | ||
| private readonly documentProvider: { | ||
@@ -100,20 +100,18 @@ createDocument: Hocuspocus["createDocument"]; | ||
| }, | ||
| private readonly defaultContext: any = {}, | ||
| private readonly defaultContext: Context = {} as Context, | ||
| ) { | ||
| this.timeout = opts.timeout; | ||
| this.pingInterval = setInterval(this.check, this.timeout); | ||
| websocket.on("pong", this.handlePong); | ||
| websocket.on("message", this.messageHandler); | ||
| websocket.once("close", this.handleWebsocketClose); | ||
| } | ||
| private handleWebsocketClose = (code: number, reason: Buffer) => { | ||
| this.close({ code, reason: reason.toString() }); | ||
| this.websocket.removeListener("message", this.messageHandler); | ||
| this.websocket.removeListener("pong", this.handlePong); | ||
| /** | ||
| * Handle WebSocket close event. Call this from your integration | ||
| * when the WebSocket connection closes. | ||
| */ | ||
| handleClose(event?: CloseEvent) { | ||
| this.close(event); | ||
| clearInterval(this.pingInterval); | ||
| }; | ||
| } | ||
| close(event?: CloseEvent) { | ||
| private close(event?: CloseEvent) { | ||
| Object.values(this.documentConnections).forEach((connection) => | ||
@@ -124,20 +122,10 @@ connection.close(event), | ||
| handlePong = () => { | ||
| this.pongReceived = true; | ||
| }; | ||
| /** | ||
| * Check if pong was received and close the connection otherwise | ||
| * @private | ||
| * Close the connection if no messages have been received within the timeout period. | ||
| * This replaces application-level ping/pong to maintain backward compatibility | ||
| * with older provider versions that don't understand Ping/Pong message types. | ||
| * Awareness updates (~every 30s) keep active connections alive. | ||
| */ | ||
| private check = () => { | ||
| if (!this.pongReceived) { | ||
| return this.close(ConnectionTimeout); | ||
| } | ||
| this.pongReceived = false; | ||
| try { | ||
| this.websocket.ping(); | ||
| } catch (error) { | ||
| if (Date.now() - this.lastMessageReceivedAt > this.timeout) { | ||
| this.close(ConnectionTimeout); | ||
@@ -162,6 +150,8 @@ } | ||
| private createConnection( | ||
| connection: WebSocket, | ||
| connection: WebSocketLike, | ||
| document: Document, | ||
| hookPayload: (typeof this.hookPayloads)[string], | ||
| sessionId: string | null, | ||
| providerVersion: string | null, | ||
| ): Connection { | ||
| const hookPayload = this.hookPayloads[document.name]; | ||
| const instance = new Connection( | ||
@@ -174,5 +164,13 @@ connection, | ||
| hookPayload.connectionConfig.readOnly, | ||
| sessionId, | ||
| providerVersion, | ||
| ); | ||
| instance.onClose(async (document, event) => { | ||
| // Wait for any pending message processing to complete before running | ||
| // disconnect hooks. This ensures that document updates from queued messages | ||
| // are applied (and their debounced onStoreDocument scheduled) before the | ||
| // disconnect handler checks whether to call executeNow. | ||
| await instance.waitForPendingMessages(); | ||
| const disconnectHookPayload: onDisconnectPayload = { | ||
@@ -243,5 +241,5 @@ instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class | ||
| // Once all hooks are run, we’ll fully establish the connection: | ||
| private setUpNewConnection = async (documentName: string) => { | ||
| const hookPayload = this.hookPayloads[documentName]; | ||
| // Once all hooks are run, we'll fully establish the connection: | ||
| private setUpNewConnection = async (rawKey: string, documentName: string, sessionId: string | null) => { | ||
| const hookPayload = this.hookPayloads[rawKey]; | ||
| // If no hook interrupts, create a document and connection | ||
@@ -255,9 +253,9 @@ const document = await this.documentProvider.createDocument( | ||
| ); | ||
| const connection = this.createConnection(this.websocket, document); | ||
| const connection = this.createConnection(this.websocket, document, hookPayload, sessionId, hookPayload.providerVersion); | ||
| connection.onClose((document, event) => { | ||
| delete this.hookPayloads[documentName]; | ||
| delete this.documentConnections[documentName]; | ||
| delete this.incomingMessageQueue[documentName]; | ||
| this.documentConnectionsEstablished.delete(documentName); | ||
| delete this.hookPayloads[rawKey]; | ||
| delete this.documentConnections[rawKey]; | ||
| delete this.incomingMessageQueue[rawKey]; | ||
| this.documentConnectionsEstablished.delete(rawKey); | ||
| }); | ||
@@ -272,7 +270,7 @@ | ||
| ...payload, | ||
| document, | ||
| connection, | ||
| document, | ||
| documentName, | ||
| }, | ||
| (contextAdditions: any) => { | ||
| (contextAdditions: Partial<Context>) => { | ||
| hookPayload.context = { | ||
@@ -291,3 +289,3 @@ ...hookPayload.context, | ||
| this.documentConnections[documentName] = connection; | ||
| this.documentConnections[rawKey] = connection; | ||
@@ -304,6 +302,5 @@ // If the WebSocket has already disconnected (wow, that was fast) – then | ||
| // There’s no need to queue messages anymore. | ||
| // Let’s work through queued messages. | ||
| this.incomingMessageQueue[documentName].forEach((input) => { | ||
| this.websocket.emit("message", input); | ||
| // Drain queued messages to the Connection. | ||
| this.incomingMessageQueue[rawKey]?.forEach((input) => { | ||
| connection.handleMessage(input); | ||
| }); | ||
@@ -320,7 +317,7 @@ | ||
| // This listener handles authentication messages and queues everything else. | ||
| private handleQueueingMessage = async (data: Uint8Array) => { | ||
| private handleQueueingMessage = async (data: Uint8Array, rawKey: string, documentName: string) => { | ||
| try { | ||
| const tmpMsg = new SocketIncomingMessage(data); | ||
| const documentName = decoding.readVarString(tmpMsg.decoder); | ||
| decoding.readVarString(tmpMsg.decoder); // skip the message address (already extracted) | ||
| const type = decoding.readVarUint(tmpMsg.decoder); | ||
@@ -331,11 +328,11 @@ | ||
| type === MessageType.Auth && | ||
| !this.documentConnectionsEstablished.has(documentName) | ||
| !this.documentConnectionsEstablished.has(rawKey) | ||
| ) | ||
| ) { | ||
| this.incomingMessageQueue[documentName].push(data); | ||
| this.incomingMessageQueue[rawKey].push(data); | ||
| return; | ||
| } | ||
| // Okay, we’ve got the authentication message we’re waiting for: | ||
| this.documentConnectionsEstablished.add(documentName); | ||
| // Okay, we've got the authentication message we're waiting for: | ||
| this.documentConnectionsEstablished.add(rawKey); | ||
@@ -347,4 +344,18 @@ // The 2nd integer contains the submessage type | ||
| // Try to read providerVersion (new protocol field) | ||
| let providerVersion: string | null = null; | ||
| if (decoding.hasContent(tmpMsg.decoder)) { | ||
| providerVersion = decoding.readVarString(tmpMsg.decoder); | ||
| } | ||
| // Extract sessionId from the rawKey (documentName\0sessionId) if present | ||
| const sepIdx = rawKey.indexOf('\0'); | ||
| const sessionId = sepIdx === -1 ? null : rawKey.substring(sepIdx + 1); | ||
| // Response uses rawKey so session-aware clients can route correctly | ||
| const responseAddress = rawKey; | ||
| try { | ||
| const hookPayload = this.hookPayloads[documentName]; | ||
| const hookPayload = this.hookPayloads[rawKey]; | ||
| hookPayload.providerVersion = providerVersion; | ||
@@ -354,4 +365,3 @@ await this.hooks( | ||
| { ...hookPayload, documentName }, | ||
| (contextAdditions: any) => { | ||
| // merge context from all hooks | ||
| (contextAdditions: Partial<Context>) => { | ||
| hookPayload.context = { | ||
@@ -371,5 +381,3 @@ ...hookPayload.context, | ||
| }, | ||
| (contextAdditions: any) => { | ||
| // Hooks are allowed to give us even more context and we’ll merge everything together. | ||
| // We’ll pass the context to other hooks then. | ||
| (contextAdditions: Partial<Context>) => { | ||
| hookPayload.context = { | ||
@@ -385,3 +393,3 @@ ...hookPayload.context, | ||
| // Let the client know that authentication was successful. | ||
| const message = new OutgoingMessage(documentName).writeAuthenticated( | ||
| const message = new OutgoingMessage(responseAddress).writeAuthenticated( | ||
| hookPayload.connectionConfig.readOnly, | ||
@@ -393,6 +401,6 @@ ); | ||
| // Time to actually establish the connection. | ||
| await this.setUpNewConnection(documentName); | ||
| await this.setUpNewConnection(rawKey, documentName, sessionId); | ||
| } catch (err: any) { | ||
| const error = err || Forbidden; | ||
| const message = new OutgoingMessage(documentName).writePermissionDenied( | ||
| const message = new OutgoingMessage(responseAddress).writePermissionDenied( | ||
| error.reason ?? "permission-denied", | ||
@@ -402,2 +410,8 @@ ); | ||
| this.websocket.send(message.toUint8Array()); | ||
| // Clean up all state for this document so a retry is treated | ||
| // as a fresh first connection attempt. | ||
| this.documentConnectionsEstablished.delete(rawKey); | ||
| delete this.hookPayloads[rawKey]; | ||
| delete this.incomingMessageQueue[rawKey]; | ||
| } | ||
@@ -412,25 +426,35 @@ | ||
| private messageHandler = (data: Uint8Array) => { | ||
| /** | ||
| * Handle an incoming WebSocket message. Call this from your integration | ||
| * when the WebSocket receives a binary message. | ||
| */ | ||
| handleMessage = (data: Uint8Array) => { | ||
| this.lastMessageReceivedAt = Date.now(); | ||
| try { | ||
| const tmpMsg = new SocketIncomingMessage(data); | ||
| const documentName = decoding.readVarString(tmpMsg.decoder); | ||
| const rawKey = decoding.readVarString(tmpMsg.decoder); | ||
| const connection = this.documentConnections[documentName]; | ||
| // Extract the plain documentName (the raw key may be documentName\0sessionId) | ||
| const sepIdx = rawKey.indexOf('\0'); | ||
| const documentName = sepIdx === -1 ? rawKey : rawKey.substring(0, sepIdx); | ||
| // Look up by rawKey first (session-aware providers), then fall back | ||
| // to plain documentName for backward compatibility with old providers | ||
| const connection = this.documentConnections[rawKey] | ||
| ?? this.documentConnections[documentName]; | ||
| if (connection) { | ||
| // forward the message to the connection | ||
| connection.handleMessage(data); | ||
| // we already have a `Connection` set up for this document | ||
| return; | ||
| } | ||
| const isFirst = this.incomingMessageQueue[documentName] === undefined; | ||
| const isFirst = this.incomingMessageQueue[rawKey] === undefined; | ||
| if (isFirst) { | ||
| this.incomingMessageQueue[documentName] = []; | ||
| if (this.hookPayloads[documentName]) { | ||
| this.incomingMessageQueue[rawKey] = []; | ||
| if (this.hookPayloads[rawKey]) { | ||
| throw new Error("first message, but hookPayloads exists"); | ||
| } | ||
| const hookPayload = { | ||
| this.hookPayloads[rawKey] = { | ||
| instance: this.documentProvider as Hocuspocus, | ||
@@ -448,8 +472,7 @@ request: this.request, | ||
| }, | ||
| providerVersion: null as string | null, | ||
| }; | ||
| this.hookPayloads[documentName] = hookPayload; | ||
| } | ||
| this.handleQueueingMessage(data); | ||
| this.handleQueueingMessage(data, rawKey, documentName); | ||
| } catch (closeError) { | ||
@@ -456,0 +479,0 @@ // catch is needed in case an invalid payload crashes the parsing of the Uint8Array |
+77
-41
@@ -1,2 +0,1 @@ | ||
| import type { IncomingMessage as HTTPIncomingMessage } from "node:http"; | ||
| import { | ||
@@ -7,3 +6,2 @@ type CloseEvent, | ||
| } from "@hocuspocus/common"; | ||
| import type WebSocket from "ws"; | ||
| import type Document from "./Document.ts"; | ||
@@ -14,2 +12,3 @@ import { IncomingMessage } from "./IncomingMessage.ts"; | ||
| import type { | ||
| WebSocketLike, | ||
| beforeSyncPayload, | ||
@@ -19,10 +18,10 @@ onStatelessPayload, | ||
| export class Connection { | ||
| webSocket: WebSocket; | ||
| export class Connection<Context = any> { | ||
| webSocket: WebSocketLike; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| request: HTTPIncomingMessage; | ||
| request: Request; | ||
@@ -45,12 +44,32 @@ callbacks = { | ||
| sessionId: string | null; | ||
| providerVersion: string | null; | ||
| /** | ||
| * The address string prefixed to outgoing messages. | ||
| * Session-aware clients get `documentName\0sessionId`; legacy clients get plain `documentName`. | ||
| */ | ||
| get messageAddress(): string { | ||
| return this.sessionId | ||
| ? `${this.document.name}\0${this.sessionId}` | ||
| : this.document.name; | ||
| } | ||
| private messageQueue: Uint8Array[] = []; | ||
| private processingPromise: Promise<void> = Promise.resolve(); | ||
| /** | ||
| * Constructor. | ||
| */ | ||
| constructor( | ||
| connection: WebSocket, | ||
| request: HTTPIncomingMessage, | ||
| connection: WebSocketLike, | ||
| request: Request, | ||
| document: Document, | ||
| socketId: string, | ||
| context: any, | ||
| context: Context, | ||
| readOnly = false, | ||
| sessionId?: string | null, | ||
| providerVersion?: string | null, | ||
| ) { | ||
@@ -63,4 +82,5 @@ this.webSocket = connection; | ||
| this.readOnly = readOnly; | ||
| this.sessionId = sessionId ?? null; | ||
| this.providerVersion = providerVersion ?? null; | ||
| this.webSocket.binaryType = "nodebuffer"; | ||
| this.document.addConnection(this); | ||
@@ -130,5 +150,12 @@ | ||
| /** | ||
| * Returns a promise that resolves when all queued messages have been processed. | ||
| */ | ||
| waitForPendingMessages(): Promise<void> { | ||
| return this.processingPromise; | ||
| } | ||
| /** | ||
| * Send the given message | ||
| */ | ||
| send(message: any): void { | ||
| send(message: Uint8Array): void { | ||
| if ( | ||
@@ -143,5 +170,3 @@ this.webSocket.readyState === WsReadyStates.Closing || | ||
| try { | ||
| this.webSocket.send(message, (error: any) => { | ||
| if (error != null) this.close(); | ||
| }); | ||
| this.webSocket.send(message); | ||
| } catch (exception) { | ||
@@ -156,3 +181,3 @@ this.close(); | ||
| public sendStateless(payload: string): void { | ||
| const message = new OutgoingMessage(this.document.name).writeStateless( | ||
| const message = new OutgoingMessage(this.messageAddress).writeStateless( | ||
| payload, | ||
@@ -169,3 +194,3 @@ ); | ||
| const message = new OutgoingMessage( | ||
| this.document.name, | ||
| this.messageAddress, | ||
| ).writeTokenSyncRequest(); | ||
@@ -187,3 +212,3 @@ | ||
| const closeMessage = new OutgoingMessage(this.document.name); | ||
| const closeMessage = new OutgoingMessage(this.messageAddress); | ||
| closeMessage.writeCloseMessage( | ||
@@ -206,3 +231,3 @@ event?.reason ?? "Server closed the connection", | ||
| const awarenessMessage = new OutgoingMessage( | ||
| this.document.name, | ||
| this.messageAddress, | ||
| ).createAwarenessUpdateMessage(this.document.awareness); | ||
@@ -218,26 +243,34 @@ | ||
| public handleMessage(data: Uint8Array): void { | ||
| const message = new IncomingMessage(data); | ||
| const documentName = message.readVarString(); | ||
| this.messageQueue.push(data); | ||
| if (documentName !== this.document.name) return; | ||
| if (this.messageQueue.length === 1) { | ||
| this.processingPromise = this.processMessages(); | ||
| } | ||
| } | ||
| message.writeVarString(documentName); | ||
| private async processMessages() { | ||
| while (this.messageQueue.length > 0) { | ||
| const rawUpdate = this.messageQueue.at(0) as Uint8Array; | ||
| this.callbacks | ||
| .beforeHandleMessage(this, data) | ||
| .then(() => { | ||
| try { | ||
| new MessageReceiver(message).apply(this.document, this); | ||
| } catch (e: any) { | ||
| console.error( | ||
| `closing connection ${this.socketId} (while handling ${documentName}) because of exception`, | ||
| e, | ||
| ); | ||
| this.close({ | ||
| code: "code" in e ? e.code : ResetConnection.code, | ||
| reason: "reason" in e ? e.reason : ResetConnection.reason, | ||
| }); | ||
| } | ||
| }) | ||
| .catch((e: any) => { | ||
| const message = new IncomingMessage(rawUpdate); | ||
| const rawKey = message.readVarString(); | ||
| // Accept messages addressed with either the plain documentName or documentName\0sessionId | ||
| const sepIdx = rawKey.indexOf('\0'); | ||
| const documentName = sepIdx === -1 ? rawKey : rawKey.substring(0, sepIdx); | ||
| if (documentName !== this.document.name) { | ||
| this.messageQueue.shift(); | ||
| continue; | ||
| } | ||
| // Write the correct address so replies reach the right provider | ||
| message.writeVarString(this.messageAddress); | ||
| try { | ||
| await this.callbacks.beforeHandleMessage(this, rawUpdate); | ||
| const receiver = new MessageReceiver(message); | ||
| await receiver.apply(this.document, this); | ||
| // biome-ignore lint/suspicious/noExplicitAny: <explanation> | ||
| } catch (e: any) { | ||
| console.error( | ||
@@ -248,6 +281,9 @@ `closing connection ${this.socketId} (while handling ${documentName}) because of exception`, | ||
| this.close({ | ||
| code: "code" in e ? e.code : ResetConnection.code, | ||
| code: "code" in e && typeof e.code === 'number' ? e.code : ResetConnection.code, | ||
| reason: "reason" in e ? e.reason : ResetConnection.reason, | ||
| }); | ||
| }); | ||
| } | ||
| this.messageQueue.shift(); | ||
| } | ||
| } | ||
@@ -254,0 +290,0 @@ } |
+23
-23
| import { URLSearchParams } from "node:url"; | ||
| import type Document from "./Document.ts"; | ||
| import type { Hocuspocus } from "./Hocuspocus.ts"; | ||
| import type { DirectConnection as DirectConnectionInterface } from "./types.ts"; | ||
| import type { | ||
| DirectConnection as DirectConnectionInterface, | ||
| LocalTransactionOrigin, | ||
| } from "./types.ts"; | ||
| export class DirectConnection implements DirectConnectionInterface { | ||
| export class DirectConnection<Context = any> | ||
| implements DirectConnectionInterface | ||
| { | ||
| document: Document | null = null; | ||
@@ -11,3 +16,3 @@ | ||
| context: any; | ||
| context: Context; | ||
@@ -17,6 +22,6 @@ /** | ||
| */ | ||
| constructor(document: Document, instance: Hocuspocus, context?: any) { | ||
| constructor(document: Document, instance: Hocuspocus, context?: Context) { | ||
| this.document = document; | ||
| this.instance = instance; | ||
| this.context = context; | ||
| this.context = (context ?? {}) as Context; | ||
@@ -31,17 +36,11 @@ this.document.addDirectConnection(); | ||
| transaction(this.document); | ||
| await this.instance.storeDocumentHooks( | ||
| this.document, | ||
| this.document.transact( | ||
| (x) => { | ||
| // biome-ignore lint/style/noNonNullAssertion: <explanation> | ||
| transaction(this.document!); | ||
| }, | ||
| { | ||
| clientsCount: this.document.getConnectionsCount(), | ||
| source: "local", | ||
| context: this.context, | ||
| document: this.document, | ||
| documentName: this.document.name, | ||
| instance: this.instance, | ||
| requestHeaders: {}, | ||
| requestParameters: new URLSearchParams(), | ||
| socketId: "server", | ||
| }, | ||
| true, | ||
| } satisfies LocalTransactionOrigin, | ||
| ); | ||
@@ -58,9 +57,10 @@ } | ||
| clientsCount: this.document.getConnectionsCount(), | ||
| context: this.context, | ||
| lastContext: this.context, | ||
| lastTransactionOrigin: { | ||
| source: "local", | ||
| context: this.context, | ||
| } satisfies LocalTransactionOrigin, | ||
| document: this.document, | ||
| documentName: this.document.name, | ||
| instance: this.instance, | ||
| requestHeaders: {}, | ||
| requestParameters: new URLSearchParams(), | ||
| socketId: "server", | ||
| }, | ||
@@ -84,3 +84,3 @@ true, | ||
| documentName: this.document.name, | ||
| requestHeaders: {}, | ||
| requestHeaders: new Headers(), | ||
| requestParameters: new URLSearchParams(), | ||
@@ -87,0 +87,0 @@ }); |
+35
-44
| import { Mutex } from "async-mutex"; | ||
| import type WebSocket from "ws"; | ||
| import { | ||
@@ -18,7 +17,3 @@ Awareness, | ||
| // eslint-disable-next-line @typescript-eslint/no-empty-function | ||
| onUpdate: ( | ||
| document: Document, | ||
| connection: Connection, | ||
| update: Uint8Array, | ||
| ) => {}, | ||
| onUpdate: (document: Document, origin: unknown, update: Uint8Array) => {}, | ||
| beforeBroadcastStateless: (document: Document, stateless: string) => {}, | ||
@@ -28,6 +23,5 @@ }; | ||
| connections: Map< | ||
| WebSocket, | ||
| Connection, | ||
| { | ||
| clients: Set<any>; | ||
| connection: Connection; | ||
| } | ||
@@ -70,3 +64,2 @@ > = new Map(); | ||
| isEmpty(fieldName: string): boolean { | ||
| // eslint-disable-next-line no-underscore-dangle | ||
| return !this.get(fieldName)._start && !this.get(fieldName)._map.size; | ||
@@ -90,7 +83,3 @@ } | ||
| onUpdate( | ||
| callback: ( | ||
| document: Document, | ||
| connection: Connection, | ||
| update: Uint8Array, | ||
| ) => void, | ||
| callback: (document: Document, origin: unknown, update: Uint8Array) => void, | ||
| ): Document { | ||
@@ -118,5 +107,4 @@ this.callbacks.onUpdate = callback; | ||
| addConnection(connection: Connection): Document { | ||
| this.connections.set(connection.webSocket, { | ||
| this.connections.set(connection, { | ||
| clients: new Set(), | ||
| connection, | ||
| }); | ||
@@ -131,3 +119,3 @@ | ||
| hasConnection(connection: Connection): boolean { | ||
| return this.connections.has(connection.webSocket); | ||
| return this.connections.has(connection); | ||
| } | ||
@@ -139,9 +127,12 @@ | ||
| removeConnection(connection: Connection): Document { | ||
| removeAwarenessStates( | ||
| this.awareness, | ||
| Array.from(this.getClients(connection.webSocket)), | ||
| null, | ||
| ); | ||
| const entry = this.connections.get(connection); | ||
| if (entry) { | ||
| removeAwarenessStates( | ||
| this.awareness, | ||
| Array.from(entry.clients), | ||
| null, | ||
| ); | ||
| } | ||
| this.connections.delete(connection.webSocket); | ||
| this.connections.delete(connection); | ||
@@ -176,3 +167,3 @@ return this; | ||
| getConnections(): Array<Connection> { | ||
| return Array.from(this.connections.values()).map((data) => data.connection); | ||
| return Array.from(this.connections.keys()); | ||
| } | ||
@@ -183,6 +174,6 @@ | ||
| */ | ||
| getClients(connectionInstance: WebSocket): Set<any> { | ||
| const connection = this.connections.get(connectionInstance); | ||
| getClients(connection: Connection): Set<any> { | ||
| const entry = this.connections.get(connection); | ||
| return connection?.clients === undefined ? new Set() : connection.clients; | ||
| return entry?.clients === undefined ? new Set() : entry.clients; | ||
| } | ||
@@ -201,3 +192,3 @@ | ||
| applyAwarenessUpdate(connection: Connection, update: Uint8Array): Document { | ||
| applyAwarenessUpdate(this.awareness, update, connection.webSocket); | ||
| applyAwarenessUpdate(this.awareness, update, connection); | ||
@@ -213,22 +204,22 @@ return this; | ||
| { added, updated, removed }: AwarenessUpdate, | ||
| connectionInstance: WebSocket, | ||
| originConnection: Connection | null, | ||
| ): Document { | ||
| const changedClients = added.concat(updated, removed); | ||
| if (connectionInstance !== null) { | ||
| const connection = this.connections.get(connectionInstance); | ||
| if (originConnection !== null) { | ||
| const entry = this.connections.get(originConnection); | ||
| if (connection) { | ||
| added.forEach((clientId: any) => connection.clients.add(clientId)); | ||
| removed.forEach((clientId: any) => connection.clients.delete(clientId)); | ||
| if (entry) { | ||
| added.forEach((clientId: any) => entry.clients.add(clientId)); | ||
| removed.forEach((clientId: any) => entry.clients.delete(clientId)); | ||
| } | ||
| } | ||
| this.getConnections().forEach((connection) => { | ||
| for (const connection of this.getConnections()) { | ||
| const awarenessMessage = new OutgoingMessage( | ||
| this.name, | ||
| connection.messageAddress, | ||
| ).createAwarenessUpdateMessage(this.awareness, changedClients); | ||
| connection.send(awarenessMessage.toUint8Array()); | ||
| }); | ||
| } | ||
@@ -241,12 +232,12 @@ return this; | ||
| */ | ||
| private handleUpdate(update: Uint8Array, connection: Connection): Document { | ||
| this.callbacks.onUpdate(this, connection, update); | ||
| private handleUpdate(update: Uint8Array, origin: unknown): Document { | ||
| this.callbacks.onUpdate(this, origin, update); | ||
| const message = new OutgoingMessage(this.name) | ||
| .createSyncMessage() | ||
| .writeUpdate(update); | ||
| for (const connection of this.getConnections()) { | ||
| const message = new OutgoingMessage(connection.messageAddress) | ||
| .createSyncMessage() | ||
| .writeUpdate(update); | ||
| this.getConnections().forEach((connection) => { | ||
| connection.send(message.toUint8Array()); | ||
| }); | ||
| } | ||
@@ -253,0 +244,0 @@ return this; |
+107
-68
| import crypto from "node:crypto"; | ||
| import type { IncomingMessage } from "node:http"; | ||
| import { ResetConnection, awarenessStatesToArray } from "@hocuspocus/common"; | ||
| import type WebSocket from "ws"; | ||
| import type { Doc } from "yjs"; | ||
| import { Doc } from "yjs"; | ||
| import { applyUpdate, encodeStateAsUpdate } from "yjs"; | ||
| import meta from "../package.json" assert { type: "json" }; | ||
| import { ClientConnection } from "./ClientConnection.ts"; | ||
| import type Connection from "./Connection.ts"; | ||
| import { DirectConnection } from "./DirectConnection.ts"; | ||
@@ -19,2 +16,3 @@ import Document from "./Document.ts"; | ||
| HookPayloadByName, | ||
| WebSocketLike, | ||
| beforeBroadcastStatelessPayload, | ||
@@ -25,2 +23,3 @@ onChangePayload, | ||
| } from "./types.ts"; | ||
| import { isTransactionOrigin, shouldSkipStoreHooks } from "./types.ts"; | ||
| import { useDebounce } from "./util/debounce.ts"; | ||
@@ -31,5 +30,5 @@ import { getParameters } from "./util/getParameters.ts"; | ||
| name: null, | ||
| timeout: 30000, | ||
| debounce: 2000, | ||
| maxDebounce: 10000, | ||
| timeout: 60_000, | ||
| debounce: 2_000, | ||
| maxDebounce: 10_000, | ||
| quiet: false, | ||
@@ -43,4 +42,4 @@ yDocOptions: { | ||
| export class Hocuspocus { | ||
| configuration: Configuration = { | ||
| export class Hocuspocus<Context = any> { | ||
| configuration: Configuration<Context> = { | ||
| ...defaultConfiguration, | ||
@@ -77,3 +76,3 @@ extensions: [], | ||
| constructor(configuration?: Partial<Configuration>) { | ||
| constructor(configuration?: Partial<Configuration<Context>>) { | ||
| if (configuration) { | ||
@@ -87,3 +86,5 @@ this.configure(configuration); | ||
| */ | ||
| configure(configuration: Partial<Configuration>): Hocuspocus { | ||
| configure( | ||
| configuration: Partial<Configuration<Context>>, | ||
| ): Hocuspocus<Context> { | ||
| this.configuration = { | ||
@@ -171,2 +172,19 @@ ...this.configuration, | ||
| /** | ||
| * Immediately execute all pending debounced onStoreDocument calls. | ||
| * Useful during shutdown to ensure documents are persisted and unloaded | ||
| * before the server exits, even when unloadImmediately is false. | ||
| */ | ||
| flushPendingStores() { | ||
| this.documents.forEach((document: Document) => { | ||
| const debounceId = `onStoreDocument-${document.name}`; | ||
| if ( | ||
| !document.isLoading && | ||
| this.debouncer.isDebounced(debounceId) | ||
| ) { | ||
| this.debouncer.executeNow(debounceId); | ||
| } | ||
| }); | ||
| } | ||
| /** | ||
| * Force close one or more connections | ||
@@ -184,3 +202,3 @@ */ | ||
| document.connections.forEach(({ connection }) => { | ||
| document.connections.forEach((_clients, connection) => { | ||
| connection.close(ResetConnection); | ||
@@ -198,10 +216,10 @@ }); | ||
| * | ||
| * … and if nothing fails it’ll fully establish the connection and | ||
| * … and if nothing fails it'll fully establish the connection and | ||
| * load the Document then. | ||
| */ | ||
| handleConnection( | ||
| incoming: WebSocket, | ||
| request: IncomingMessage, | ||
| defaultContext: any = {}, | ||
| ): void { | ||
| incoming: WebSocket | WebSocketLike, | ||
| request: Request, | ||
| defaultContext: Context = {} as Context, | ||
| ): ClientConnection { | ||
| const clientConnection = new ClientConnection( | ||
@@ -247,2 +265,4 @@ incoming, | ||
| ); | ||
| return clientConnection; | ||
| } | ||
@@ -258,33 +278,48 @@ | ||
| document: Document, | ||
| connection: Connection | undefined, | ||
| origin: unknown, | ||
| update: Uint8Array, | ||
| request?: IncomingMessage, | ||
| ) { | ||
| const hookPayload: onChangePayload | onStoreDocumentPayload = { | ||
| const connection = | ||
| isTransactionOrigin(origin) && origin.source === "connection" | ||
| ? origin.connection | ||
| : undefined; | ||
| const request = connection?.request; | ||
| const context = isTransactionOrigin(origin) | ||
| ? origin.source === "connection" | ||
| ? origin.connection.context | ||
| : origin.source === "local" | ||
| ? (origin.context ?? {}) | ||
| : {} | ||
| : {}; | ||
| const changePayload: onChangePayload = { | ||
| instance: this, | ||
| clientsCount: document.getConnectionsCount(), | ||
| context: connection?.context || {}, | ||
| document, | ||
| documentName: document.name, | ||
| requestHeaders: request?.headers ?? {}, | ||
| requestHeaders: request?.headers ?? new Headers(), | ||
| requestParameters: getParameters(request), | ||
| socketId: connection?.socketId ?? "", | ||
| update, | ||
| transactionOrigin: connection, | ||
| transactionOrigin: origin, | ||
| connection: connection, | ||
| context, | ||
| }; | ||
| this.hooks("onChange", hookPayload); | ||
| this.hooks("onChange", changePayload); | ||
| // If the update was received through other ways than the | ||
| // WebSocket connection, we don’t need to feel responsible for | ||
| // storing the content. | ||
| // also ignore changes incoming through redis connection, as this would be a breaking change (#730, #696, #606) | ||
| if ( | ||
| !connection || | ||
| (connection as unknown as string) === "__hocuspocus__redis__origin__" | ||
| ) { | ||
| if (shouldSkipStoreHooks(origin)) { | ||
| return; | ||
| } | ||
| this.storeDocumentHooks(document, hookPayload); | ||
| const storePayload: onStoreDocumentPayload = { | ||
| instance: this, | ||
| clientsCount: document.getConnectionsCount(), | ||
| document, | ||
| lastContext: context, | ||
| lastTransactionOrigin: origin, | ||
| documentName: document.name, | ||
| }; | ||
| this.storeDocumentHooks(document, storePayload); | ||
| } | ||
@@ -297,6 +332,6 @@ | ||
| documentName: string, | ||
| request: Partial<Pick<IncomingMessage, "headers" | "url">>, | ||
| request: Request, | ||
| socketId: string, | ||
| connection: ConnectionConfiguration, | ||
| context?: any, | ||
| context?: Context, | ||
| ): Promise<Document> { | ||
@@ -337,10 +372,12 @@ const existingLoadingDoc = this.loadingDocuments.get(documentName); | ||
| documentName: string, | ||
| request: Partial<Pick<IncomingMessage, "headers" | "url">>, | ||
| request: Request, | ||
| socketId: string, | ||
| connectionConfig: ConnectionConfiguration, | ||
| context?: any, | ||
| context?: Context, | ||
| ): Promise<Document> { | ||
| const requestHeaders = request.headers ?? {}; | ||
| const requestHeaders = request.headers; | ||
| const requestParameters = getParameters(request); | ||
| const resolvedContext = (context ?? {}) as Context; | ||
| const yDocOptions = await this.hooks("onCreateDocument", { | ||
@@ -351,3 +388,3 @@ documentName, | ||
| connectionConfig, | ||
| context, | ||
| context: resolvedContext, | ||
| socketId, | ||
@@ -364,3 +401,3 @@ instance: this, | ||
| instance: this, | ||
| context, | ||
| context: resolvedContext, | ||
| connectionConfig, | ||
@@ -378,11 +415,7 @@ document, | ||
| hookPayload, | ||
| (loadedDocument: Doc | undefined) => { | ||
| // if a hook returns a Y-Doc, encode the document state as update | ||
| // and apply it to the newly created document | ||
| // Note: instanceof doesn't work, because Doc !== Doc for some reason I don't understand | ||
| if ( | ||
| loadedDocument?.constructor.name === "Document" || | ||
| loadedDocument?.constructor.name === "Doc" | ||
| ) { | ||
| (loadedDocument: Doc | Uint8ArrayConstructor | undefined) => { | ||
| if (loadedDocument instanceof Doc) { | ||
| applyUpdate(document, encodeStateAsUpdate(loadedDocument)); | ||
| } else if (loadedDocument instanceof Uint8Array) { | ||
| applyUpdate(document, loadedDocument); | ||
| } | ||
@@ -400,11 +433,6 @@ }, | ||
| document.onUpdate( | ||
| (document: Document, connection: Connection, update: Uint8Array) => { | ||
| (document: Document, origin: unknown, update: Uint8Array) => { | ||
| document.lastChangeTime = Date.now(); | ||
| this.handleDocumentUpdate( | ||
| document, | ||
| connection, | ||
| update, | ||
| connection?.request, | ||
| ); | ||
| this.handleDocumentUpdate(document, origin, update); | ||
| }, | ||
@@ -427,10 +455,20 @@ ); | ||
| document.awareness.on("update", (update: AwarenessUpdate) => { | ||
| this.hooks("onAwarenessUpdate", { | ||
| ...hookPayload, | ||
| ...update, | ||
| awareness: document.awareness, | ||
| states: awarenessStatesToArray(document.awareness.getStates()), | ||
| }); | ||
| }); | ||
| document.awareness.on( | ||
| "update", | ||
| (update: AwarenessUpdate, origin: unknown) => { | ||
| this.hooks("onAwarenessUpdate", { | ||
| document, | ||
| documentName, | ||
| instance: this, | ||
| ...update, | ||
| transactionOrigin: origin, | ||
| connection: | ||
| isTransactionOrigin(origin) && origin.source === "connection" | ||
| ? origin.connection | ||
| : undefined, | ||
| awareness: document.awareness, | ||
| states: awarenessStatesToArray(document.awareness.getStates()), | ||
| }); | ||
| }, | ||
| ); | ||
@@ -479,3 +517,4 @@ return document; | ||
| name: T, | ||
| payload: HookPayloadByName[T], | ||
| payload: HookPayloadByName<Context>[T], | ||
| // biome-ignore lint/complexity/noBannedTypes: <explanation> | ||
| callback: Function | null = null, | ||
@@ -563,4 +602,4 @@ ): Promise<any> { | ||
| documentName: string, | ||
| context?: any, | ||
| ): Promise<DirectConnection> { | ||
| context?: Context, | ||
| ): Promise<DirectConnection<Context>> { | ||
| const connectionConfig: ConnectionConfiguration = { | ||
@@ -573,3 +612,3 @@ isAuthenticated: true, | ||
| documentName, | ||
| {}, // direct connection has no request params | ||
| new Request("http://localhost"), // direct connection has no request params | ||
| crypto.randomUUID(), | ||
@@ -580,4 +619,4 @@ connectionConfig, | ||
| return new DirectConnection(document, this, context); | ||
| return new DirectConnection<Context>(document, this, context); | ||
| } | ||
| } |
| import type { Decoder } from "lib0/decoding"; | ||
| import { | ||
| createDecoder, | ||
| readVarString, | ||
| readVarUint, | ||
| readVarUint8Array, | ||
| readVarString, | ||
| } from "lib0/decoding"; | ||
@@ -11,6 +11,6 @@ import type { Encoder } from "lib0/encoding"; | ||
| createEncoder, | ||
| length, | ||
| toUint8Array, | ||
| writeVarString, | ||
| writeVarUint, | ||
| writeVarString, | ||
| length, | ||
| } from "lib0/encoding"; | ||
@@ -17,0 +17,0 @@ import type { MessageType } from "./types.ts"; |
+41
-26
@@ -18,3 +18,3 @@ import { AuthMessageType } from "@hocuspocus/common"; | ||
| import { OutgoingMessage } from "./OutgoingMessage.ts"; | ||
| import { MessageType } from "./types.ts"; | ||
| import { MessageType, type TransactionOrigin } from "./types.ts"; | ||
@@ -24,5 +24,8 @@ export class MessageReceiver { | ||
| defaultTransactionOrigin?: string; | ||
| defaultTransactionOrigin?: TransactionOrigin; | ||
| constructor(message: IncomingMessage, defaultTransactionOrigin?: string) { | ||
| constructor( | ||
| message: IncomingMessage, | ||
| defaultTransactionOrigin?: TransactionOrigin, | ||
| ) { | ||
| this.message = message; | ||
@@ -32,3 +35,3 @@ this.defaultTransactionOrigin = defaultTransactionOrigin; | ||
| public apply( | ||
| public async apply( | ||
| document: Document, | ||
@@ -46,3 +49,3 @@ connection?: Connection, | ||
| message.writeVarUint(MessageType.Sync); | ||
| this.readSyncMessage( | ||
| await this.readSyncMessage( | ||
| message, | ||
@@ -59,3 +62,3 @@ document, | ||
| } else if (connection) { | ||
| // TODO: We should log this, shouldn’t we? | ||
| // TODO: We should log this, shouldn't we? | ||
| // this.logger.log({ | ||
@@ -76,3 +79,3 @@ // direction: 'out', | ||
| message.readVarUint8Array(), | ||
| connection?.webSocket, | ||
| connection ?? null, | ||
| ); | ||
@@ -83,3 +86,3 @@ | ||
| case MessageType.QueryAwareness: { | ||
| this.applyQueryAwarenessMessage(document, reply); | ||
| this.applyQueryAwarenessMessage(document, connection, reply); | ||
@@ -136,3 +139,3 @@ break; | ||
| readSyncMessage( | ||
| async readSyncMessage( | ||
| message: IncomingMessage, | ||
@@ -145,5 +148,6 @@ document: Document, | ||
| const type = message.readVarUint(); | ||
| const messageAddress = connection?.messageAddress ?? document.name; | ||
| if (connection) { | ||
| connection.callbacks.beforeSync(connection, { | ||
| await connection.callbacks.beforeSync(connection, { | ||
| type, | ||
@@ -160,3 +164,3 @@ payload: message.peekVarUint8Array(), | ||
| if (reply && requestFirstSync) { | ||
| const syncMessage = new OutgoingMessage(document.name) | ||
| const syncMessage = new OutgoingMessage(messageAddress) | ||
| .createSyncReplyMessage() | ||
@@ -167,3 +171,3 @@ .writeFirstSyncStepFor(document); | ||
| } else if (connection) { | ||
| const syncMessage = new OutgoingMessage(document.name) | ||
| const syncMessage = new OutgoingMessage(messageAddress) | ||
| .createSyncMessage() | ||
@@ -176,3 +180,3 @@ .writeFirstSyncStepFor(document); | ||
| } | ||
| case messageYjsSyncStep2: | ||
| case messageYjsSyncStep2: { | ||
| if (connection?.readOnly) { | ||
@@ -186,5 +190,5 @@ // We're in read-only mode, so we can't apply the update. | ||
| // no new changes in update | ||
| const ackMessage = new OutgoingMessage( | ||
| document.name, | ||
| ).writeSyncStatus(true); | ||
| const ackMessage = new OutgoingMessage(messageAddress).writeSyncStatus( | ||
| true, | ||
| ); | ||
@@ -194,5 +198,5 @@ connection.send(ackMessage.toUint8Array()); | ||
| // new changes in update that we can't apply, because readOnly | ||
| const ackMessage = new OutgoingMessage( | ||
| document.name, | ||
| ).writeSyncStatus(false); | ||
| const ackMessage = new OutgoingMessage(messageAddress).writeSyncStatus( | ||
| false, | ||
| ); | ||
@@ -207,3 +211,5 @@ connection.send(ackMessage.toUint8Array()); | ||
| document, | ||
| connection ?? this.defaultTransactionOrigin, | ||
| connection | ||
| ? { source: "connection" as const, connection } | ||
| : (this.defaultTransactionOrigin ?? { source: "local" as const }), | ||
| ); | ||
@@ -213,3 +219,3 @@ | ||
| connection.send( | ||
| new OutgoingMessage(document.name) | ||
| new OutgoingMessage(messageAddress) | ||
| .writeSyncStatus(true) | ||
@@ -220,6 +226,7 @@ .toUint8Array(), | ||
| break; | ||
| case messageYjsUpdate: | ||
| } | ||
| case messageYjsUpdate: { | ||
| if (connection?.readOnly) { | ||
| connection.send( | ||
| new OutgoingMessage(document.name) | ||
| new OutgoingMessage(messageAddress) | ||
| .writeSyncStatus(false) | ||
@@ -231,6 +238,12 @@ .toUint8Array(), | ||
| readUpdate(message.decoder, document, connection); | ||
| readUpdate( | ||
| message.decoder, | ||
| document, | ||
| connection | ||
| ? { source: "connection" as const, connection } | ||
| : (this.defaultTransactionOrigin ?? { source: "local" as const }), | ||
| ); | ||
| if (connection) { | ||
| connection.send( | ||
| new OutgoingMessage(document.name) | ||
| new OutgoingMessage(messageAddress) | ||
| .writeSyncStatus(true) | ||
@@ -241,2 +254,3 @@ .toUint8Array(), | ||
| break; | ||
| } | ||
| default: | ||
@@ -251,6 +265,7 @@ throw new Error(`Received a message with an unknown type: ${type}`); | ||
| document: Document, | ||
| connection?: Connection, | ||
| reply?: (message: Uint8Array) => void, | ||
| ) { | ||
| const message = new OutgoingMessage( | ||
| document.name, | ||
| connection?.messageAddress ?? document.name, | ||
| ).createAwarenessUpdateMessage(document.awareness); | ||
@@ -257,0 +272,0 @@ |
+55
-51
@@ -7,15 +7,20 @@ import type { | ||
| import { createServer } from "node:http"; | ||
| import type { AddressInfo } from "node:net"; | ||
| import type { ListenOptions } from "node:net"; | ||
| import crossws from "crossws/adapters/node"; | ||
| import kleur from "kleur"; | ||
| import type WebSocket from "ws"; | ||
| import { WebSocketServer } from "ws"; | ||
| import type { AddressInfo, ServerOptions } from "ws"; | ||
| import meta from "../package.json" assert { type: "json" }; | ||
| import { Hocuspocus, defaultConfiguration } from "./Hocuspocus.ts"; | ||
| import type { Configuration, onListenPayload } from "./types.ts"; | ||
| import type { Configuration, WebSocketLike, onListenPayload } from "./types.ts"; | ||
| export interface ServerConfiguration extends Configuration { | ||
| export interface ServerConfiguration<Context = any> | ||
| extends Configuration<Context> { | ||
| port?: number; | ||
| address?: string; | ||
| stopOnSignals?: boolean; | ||
| /** | ||
| * Options passed to the underlying WebSocket server (ws). | ||
| * Supports all ws ServerOptions, e.g. { maxPayload: 1024 * 1024 } | ||
| */ | ||
| websocketOptions?: Record<string, any>; | ||
| } | ||
@@ -29,10 +34,10 @@ | ||
| export class Server { | ||
| export class Server<Context = any> { | ||
| httpServer: HTTPServer; | ||
| webSocketServer: WebSocketServer; | ||
| private crossws: ReturnType<typeof crossws>; | ||
| hocuspocus: Hocuspocus; | ||
| hocuspocus: Hocuspocus<Context>; | ||
| configuration: ServerConfiguration = { | ||
| configuration: ServerConfiguration<Context> = { | ||
| ...defaultConfiguration, | ||
@@ -43,6 +48,3 @@ ...defaultServerConfiguration, | ||
| constructor( | ||
| configuration?: Partial<ServerConfiguration>, | ||
| websocketOptions: ServerOptions = {}, | ||
| ) { | ||
| constructor(configuration?: Partial<ServerConfiguration<Context>>) { | ||
| if (configuration) { | ||
@@ -59,34 +61,32 @@ this.configuration = { | ||
| this.httpServer = createServer(this.requestHandler); | ||
| this.webSocketServer = new WebSocketServer({ | ||
| noServer: true, | ||
| ...websocketOptions, | ||
| this.crossws = crossws({ | ||
| serverOptions: this.configuration.websocketOptions, | ||
| hooks: { | ||
| open: (peer) => { | ||
| const clientConnection = this.hocuspocus.handleConnection( | ||
| peer.websocket as unknown as WebSocketLike, | ||
| peer.request as Request, | ||
| ); | ||
| (peer as any)._hocuspocus = clientConnection; | ||
| }, | ||
| message: (peer, message) => { | ||
| (peer as any)._hocuspocus?.handleMessage(message.uint8Array()); | ||
| }, | ||
| close: (peer, event) => { | ||
| (peer as any)._hocuspocus?.handleClose({ | ||
| code: event.code, | ||
| reason: event.reason, | ||
| }); | ||
| }, | ||
| error: (peer, error) => { | ||
| console.error("WebSocket error for peer:", peer.id); | ||
| console.error(error); | ||
| }, | ||
| }, | ||
| }); | ||
| this.setupWebsocketConnection(); | ||
| this.setupHttpUpgrade(); | ||
| } | ||
| setupWebsocketConnection = () => { | ||
| this.webSocketServer.on( | ||
| "connection", | ||
| async (incoming: WebSocket, request: IncomingMessage) => { | ||
| incoming.setMaxListeners(Number.POSITIVE_INFINITY); | ||
| incoming.on("error", (error) => { | ||
| /** | ||
| * Handle a ws instance error, which is required to prevent | ||
| * the server from crashing when one happens | ||
| * See https://github.com/websockets/ws/issues/1777#issuecomment-660803472 | ||
| * @private | ||
| */ | ||
| console.error("Error emitted from webSocket instance:"); | ||
| console.error(error); | ||
| }); | ||
| this.hocuspocus.handleConnection(incoming, request); | ||
| }, | ||
| ); | ||
| }; | ||
| setupHttpUpgrade = () => { | ||
| private setupHttpUpgrade = () => { | ||
| this.httpServer.on("upgrade", async (request, socket, head) => { | ||
@@ -101,7 +101,4 @@ try { | ||
| // let the default websocket server handle the connection if | ||
| // prior hooks don't interfere | ||
| this.webSocketServer.handleUpgrade(request, socket, head, (ws) => { | ||
| this.webSocketServer.emit("connection", ws, request); | ||
| }); | ||
| // Let crossws handle the WebSocket upgrade | ||
| this.crossws.handleUpgrade(request, socket, head); | ||
| } catch (error) { | ||
@@ -144,3 +141,6 @@ // if a hook rejects and the error is empty, do nothing | ||
| async listen(port?: number, callback: any = null): Promise<Hocuspocus> { | ||
| async listen( | ||
| port?: number, | ||
| callback: any = null, | ||
| ): Promise<Hocuspocus<Context>> { | ||
| if (port) { | ||
@@ -206,4 +206,4 @@ this.configuration.port = port; | ||
| async destroy(): Promise<any> { | ||
| await new Promise(async (resolve) => { | ||
| async destroy(): Promise<void> { | ||
| await new Promise<void>((resolve) => { | ||
| this.httpServer.close(); | ||
@@ -214,10 +214,14 @@ | ||
| async afterUnloadDocument({ instance }) { | ||
| if (instance.getDocumentsCount() === 0) resolve(""); | ||
| if (instance.getDocumentsCount() === 0) resolve(); | ||
| }, | ||
| }); | ||
| this.webSocketServer.close(); | ||
| if (this.hocuspocus.getDocumentsCount() === 0) resolve(""); | ||
| // Close all existing connections - this will trigger the close hook | ||
| if (this.hocuspocus.getDocumentsCount() === 0) resolve(); | ||
| this.hocuspocus.closeConnections(); | ||
| // Flush any remaining debounced stores so documents unload | ||
| // promptly, even when unloadImmediately is false. | ||
| this.hocuspocus.flushPendingStores(); | ||
| } catch (error) { | ||
@@ -224,0 +228,0 @@ console.error(error); |
+145
-93
@@ -1,7 +0,2 @@ | ||
| import type { | ||
| IncomingHttpHeaders, | ||
| IncomingMessage, | ||
| ServerResponse, | ||
| } from "node:http"; | ||
| import type { URLSearchParams } from "node:url"; | ||
| import type { IncomingMessage, ServerResponse } from "node:http"; | ||
| import type { Awareness } from "y-protocols/awareness"; | ||
@@ -12,2 +7,56 @@ import type Connection from "./Connection.ts"; | ||
| export interface ConnectionTransactionOrigin { | ||
| source: "connection"; | ||
| connection: Connection; | ||
| } | ||
| export interface RedisTransactionOrigin { | ||
| source: "redis"; | ||
| } | ||
| export interface LocalTransactionOrigin { | ||
| source: "local"; | ||
| skipStoreHooks?: boolean; | ||
| context?: any; | ||
| } | ||
| export type TransactionOrigin = | ||
| | ConnectionTransactionOrigin | ||
| | RedisTransactionOrigin | ||
| | LocalTransactionOrigin; | ||
| export function isTransactionOrigin( | ||
| origin: unknown, | ||
| ): origin is TransactionOrigin { | ||
| return ( | ||
| typeof origin === "object" && | ||
| origin !== null && | ||
| "source" in origin && | ||
| ((origin as any).source === "connection" || | ||
| (origin as any).source === "redis" || | ||
| (origin as any).source === "local") | ||
| ); | ||
| } | ||
| export function shouldSkipStoreHooks(origin: unknown): boolean { | ||
| if (!isTransactionOrigin(origin)) return false; | ||
| switch (origin.source) { | ||
| case "connection": | ||
| return false; | ||
| case "redis": | ||
| return true; | ||
| case "local": | ||
| return origin.skipStoreHooks ?? false; | ||
| } | ||
| } | ||
| /** | ||
| * Minimal interface for any WebSocket-like object for WebSocket, Bun's ServerWebSocket, ws, Deno, etc. | ||
| */ | ||
| export interface WebSocketLike { | ||
| send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void; | ||
| close(code?: number, reason?: string): void; | ||
| readyState: number; | ||
| } | ||
| export enum MessageType { | ||
@@ -24,2 +73,4 @@ Unknown = -1, | ||
| SyncStatus = 8, | ||
| Ping = 9, | ||
| Pong = 10, | ||
| } | ||
@@ -38,3 +89,3 @@ | ||
| export interface Extension { | ||
| export interface Extension<Context = any> { | ||
| priority?: number; | ||
@@ -45,11 +96,11 @@ extensionName?: string; | ||
| onUpgrade?(data: onUpgradePayload): Promise<any>; | ||
| onConnect?(data: onConnectPayload): Promise<any>; | ||
| connected?(data: connectedPayload): Promise<any>; | ||
| onAuthenticate?(data: onAuthenticatePayload): Promise<any>; | ||
| onTokenSync?(data: onTokenSyncPayload): Promise<any>; | ||
| onCreateDocument?(data: onCreateDocumentPayload): Promise<any>; | ||
| onLoadDocument?(data: onLoadDocumentPayload): Promise<any>; | ||
| afterLoadDocument?(data: afterLoadDocumentPayload): Promise<any>; | ||
| beforeHandleMessage?(data: beforeHandleMessagePayload): Promise<any>; | ||
| beforeSync?(data: beforeSyncPayload): Promise<any>; | ||
| onConnect?(data: onConnectPayload<Context>): Promise<any>; | ||
| connected?(data: connectedPayload<Context>): Promise<any>; | ||
| onAuthenticate?(data: onAuthenticatePayload<Context>): Promise<any>; | ||
| onTokenSync?(data: onTokenSyncPayload<Context>): Promise<any>; | ||
| onCreateDocument?(data: onCreateDocumentPayload<Context>): Promise<any>; | ||
| onLoadDocument?(data: onLoadDocumentPayload<Context>): Promise<any>; | ||
| afterLoadDocument?(data: afterLoadDocumentPayload<Context>): Promise<any>; | ||
| beforeHandleMessage?(data: beforeHandleMessagePayload<Context>): Promise<any>; | ||
| beforeSync?(data: beforeSyncPayload<Context>): Promise<any>; | ||
| beforeBroadcastStateless?( | ||
@@ -59,8 +110,8 @@ data: beforeBroadcastStatelessPayload, | ||
| onStateless?(payload: onStatelessPayload): Promise<any>; | ||
| onChange?(data: onChangePayload): Promise<any>; | ||
| onStoreDocument?(data: onStoreDocumentPayload): Promise<any>; | ||
| afterStoreDocument?(data: afterStoreDocumentPayload): Promise<any>; | ||
| onAwarenessUpdate?(data: onAwarenessUpdatePayload): Promise<any>; | ||
| onChange?(data: onChangePayload<Context>): Promise<any>; | ||
| onStoreDocument?(data: onStoreDocumentPayload<Context>): Promise<any>; | ||
| afterStoreDocument?(data: afterStoreDocumentPayload<Context>): Promise<any>; | ||
| onAwarenessUpdate?(data: onAwarenessUpdatePayload<Context>): Promise<any>; | ||
| onRequest?(data: onRequestPayload): Promise<any>; | ||
| onDisconnect?(data: onDisconnectPayload): Promise<any>; | ||
| onDisconnect?(data: onDisconnectPayload<Context>): Promise<any>; | ||
| beforeUnloadDocument?(data: beforeUnloadDocumentPayload): Promise<any>; | ||
@@ -96,23 +147,23 @@ afterUnloadDocument?(data: afterUnloadDocumentPayload): Promise<any>; | ||
| export type HookPayloadByName = { | ||
| export type HookPayloadByName<Context = any> = { | ||
| onConfigure: onConfigurePayload; | ||
| onListen: onListenPayload; | ||
| onUpgrade: onUpgradePayload; | ||
| onConnect: onConnectPayload; | ||
| connected: connectedPayload; | ||
| onAuthenticate: onAuthenticatePayload; | ||
| onTokenSync: onTokenSyncPayload; | ||
| onCreateDocument: onCreateDocumentPayload; | ||
| onLoadDocument: onLoadDocumentPayload; | ||
| afterLoadDocument: afterLoadDocumentPayload; | ||
| beforeHandleMessage: beforeHandleMessagePayload; | ||
| onConnect: onConnectPayload<Context>; | ||
| connected: connectedPayload<Context>; | ||
| onAuthenticate: onAuthenticatePayload<Context>; | ||
| onTokenSync: onTokenSyncPayload<Context>; | ||
| onCreateDocument: onCreateDocumentPayload<Context>; | ||
| onLoadDocument: onLoadDocumentPayload<Context>; | ||
| afterLoadDocument: afterLoadDocumentPayload<Context>; | ||
| beforeHandleMessage: beforeHandleMessagePayload<Context>; | ||
| beforeBroadcastStateless: beforeBroadcastStatelessPayload; | ||
| beforeSync: beforeSyncPayload; | ||
| beforeSync: beforeSyncPayload<Context>; | ||
| onStateless: onStatelessPayload; | ||
| onChange: onChangePayload; | ||
| onStoreDocument: onStoreDocumentPayload; | ||
| afterStoreDocument: afterStoreDocumentPayload; | ||
| onAwarenessUpdate: onAwarenessUpdatePayload; | ||
| onChange: onChangePayload<Context>; | ||
| onStoreDocument: onStoreDocumentPayload<Context>; | ||
| afterStoreDocument: afterStoreDocumentPayload<Context>; | ||
| onAwarenessUpdate: onAwarenessUpdatePayload<Context>; | ||
| onRequest: onRequestPayload; | ||
| onDisconnect: onDisconnectPayload; | ||
| onDisconnect: onDisconnectPayload<Context>; | ||
| afterUnloadDocument: afterUnloadDocumentPayload; | ||
@@ -123,3 +174,3 @@ beforeUnloadDocument: beforeUnloadDocumentPayload; | ||
| export interface Configuration extends Extension { | ||
| export interface Configuration<Context = any> extends Extension<Context> { | ||
| /** | ||
@@ -175,20 +226,21 @@ * A name for the instance, used for logging. | ||
| export interface onAuthenticatePayload { | ||
| context: any; | ||
| export interface onAuthenticatePayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| request: IncomingMessage; | ||
| request: Request; | ||
| socketId: string; | ||
| token: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| providerVersion: string | null; | ||
| } | ||
| export interface onTokenSyncPayload { | ||
| context: any; | ||
| export interface onTokenSyncPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -198,10 +250,10 @@ socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| } | ||
| export interface onCreateDocumentPayload { | ||
| context: any; | ||
| export interface onCreateDocumentPayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -212,31 +264,33 @@ socketId: string; | ||
| export interface onConnectPayload { | ||
| context: any; | ||
| export interface onConnectPayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| request: IncomingMessage; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| request: Request; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| providerVersion: string | null; | ||
| } | ||
| export interface connectedPayload { | ||
| context: any; | ||
| export interface connectedPayload<Context = any> { | ||
| context: Context; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| request: IncomingMessage; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| request: Request; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| connectionConfig: ConnectionConfiguration; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| providerVersion: string | null; | ||
| } | ||
| export interface onLoadDocumentPayload { | ||
| context: any; | ||
| export interface onLoadDocumentPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -247,8 +301,8 @@ socketId: string; | ||
| export interface afterLoadDocumentPayload { | ||
| context: any; | ||
| export interface afterLoadDocumentPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -259,34 +313,35 @@ socketId: string; | ||
| export interface onChangePayload { | ||
| export interface onChangePayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| update: Uint8Array; | ||
| socketId: string; | ||
| transactionOrigin: any; | ||
| transactionOrigin: unknown; | ||
| connection?: Connection<Context>; | ||
| } | ||
| export interface beforeHandleMessagePayload { | ||
| export interface beforeHandleMessagePayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
| update: Uint8Array; | ||
| socketId: string; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| } | ||
| export interface beforeSyncPayload { | ||
| export interface beforeSyncPayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| connection: Connection; | ||
| connection: Connection<Context>; | ||
| /** | ||
@@ -314,25 +369,21 @@ * The y-protocols/sync message type | ||
| export interface onStoreDocumentPayload { | ||
| export interface onStoreDocumentPayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| document: Document; | ||
| lastContext: Context; | ||
| lastTransactionOrigin: unknown; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| transactionOrigin?: any; | ||
| } | ||
| // eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type | ||
| export interface afterStoreDocumentPayload extends onStoreDocumentPayload {} | ||
| export interface afterStoreDocumentPayload<Context = any> | ||
| extends onStoreDocumentPayload<Context> {} | ||
| export interface onAwarenessUpdatePayload { | ||
| context: any; | ||
| export interface onAwarenessUpdatePayload<Context = any> { | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestParameters: URLSearchParams; | ||
| socketId: string; | ||
| transactionOrigin: unknown; | ||
| connection?: Connection<Context>; | ||
| added: number[]; | ||
@@ -347,8 +398,8 @@ updated: number[]; | ||
| export interface fetchPayload { | ||
| context: any; | ||
| export interface fetchPayload<Context = any> { | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -359,13 +410,14 @@ socketId: string; | ||
| export interface storePayload extends onStoreDocumentPayload { | ||
| export interface storePayload<Context = any> | ||
| extends onStoreDocumentPayload<Context> { | ||
| state: Buffer; | ||
| } | ||
| export interface onDisconnectPayload { | ||
| export interface onDisconnectPayload<Context = any> { | ||
| clientsCount: number; | ||
| context: any; | ||
| context: Context; | ||
| document: Document; | ||
| documentName: string; | ||
| instance: Hocuspocus; | ||
| requestHeaders: IncomingHttpHeaders; | ||
| requestHeaders: Headers; | ||
| requestParameters: URLSearchParams; | ||
@@ -372,0 +424,0 @@ socketId: string; |
@@ -1,12 +0,14 @@ | ||
| import type { IncomingMessage } from "node:http"; | ||
| import { URLSearchParams } from "node:url"; | ||
| /** | ||
| * Get parameters by the given request | ||
| */ | ||
| export function getParameters( | ||
| request?: Pick<IncomingMessage, "url">, | ||
| ): URLSearchParams { | ||
| const query = request?.url?.split("?") || []; | ||
| return new URLSearchParams(query[1] ? query[1] : ""); | ||
| export function getParameters(request?: { url?: string }): URLSearchParams { | ||
| const url = request?.url; | ||
| if (!url) { | ||
| return new URLSearchParams(); | ||
| } | ||
| // Handle both full URLs (web Request) and path-only URLs (Node.js IncomingMessage) | ||
| const query = url.includes("://") | ||
| ? new URL(url).searchParams | ||
| : new URLSearchParams(url.split("?")[1] || ""); | ||
| return query; | ||
| } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
1380175
106.46%1
-50%21
5%16629
103.19%42
250%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed