snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
2176
packages/server/CHANGELOG.md
Normal file
2176
packages/server/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
107
packages/server/README.md
Normal file
107
packages/server/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @hocuspocus/server
|
||||
[](https://www.npmjs.com/package/@hocuspocus/server)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/server)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
The collaborative editing backend for [Tiptap](https://github.com/ueberdosis/tiptap). Built on [Y.js](https://github.com/yjs/yjs), runs on Node.js (22+), Bun, Deno, and Cloudflare Workers.
|
||||
|
||||
> [!TIP]
|
||||
> Help us **chart the future of Hocuspocus** by telling us about your needs in a short survey. Plus, you’ll get a chance to earn a $50 gift card.
|
||||
> **[Take the survey →](https://docs.google.com/forms/d/e/1FAIpQLSeUS8S1iZTud58GBSD_d4c8lfUvkZHvzU99kWxP1wnNZnPKpw/viewform?usp=header)**
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/server
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Minimal WebSocket server on port `1234`:
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
Hook into the document lifecycle:
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
|
||||
async onAuthenticate({ token }) {
|
||||
if (token !== "super-secret-token") {
|
||||
throw new Error("Not authorized!")
|
||||
}
|
||||
},
|
||||
|
||||
async onLoadDocument({ documentName }) {
|
||||
// return a Y.Doc for new documents, or nothing to use the default empty doc
|
||||
},
|
||||
|
||||
async onStoreDocument({ documentName, document }) {
|
||||
// persist the Y.Doc binary state wherever you like
|
||||
},
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
For a database-backed server, combine with an extension like [`@hocuspocus/extension-sqlite`](../extension-sqlite) or [`@hocuspocus/extension-database`](../extension-database).
|
||||
|
||||
## Resource limits
|
||||
|
||||
Messages received from a client before authentication completes are buffered in
|
||||
memory. To prevent an unauthenticated client from exhausting server memory, the
|
||||
server bounds this buffer per connection and closes connections that exceed the
|
||||
limits. The defaults are safe for typical clients (which authenticate before
|
||||
sending document data); raise them only if you have a specific need.
|
||||
|
||||
```js
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
|
||||
// Max bytes buffered across all documents of one unauthenticated connection.
|
||||
maxUnauthenticatedQueueSize: 5 * 1024 * 1024, // default: 5 MiB
|
||||
|
||||
// Max number of messages buffered for one unauthenticated connection.
|
||||
maxUnauthenticatedQueueMessages: 1_000, // default: 1000
|
||||
|
||||
// Max number of documents one connection may open before authenticating any.
|
||||
maxPendingDocuments: 100, // default: 100
|
||||
|
||||
// Connections that don't authenticate any document within `timeout` ms are
|
||||
// closed. This deadline is not refreshed by inbound traffic.
|
||||
timeout: 60_000, // default: 60s
|
||||
})
|
||||
```
|
||||
|
||||
## Non-Node.js runtimes
|
||||
|
||||
Use the `Hocuspocus` class directly to attach to any `WebSocketLike` instance (Bun, Deno, Cloudflare Workers, Express, etc.):
|
||||
|
||||
```js
|
||||
import { Hocuspocus } from "@hocuspocus/server"
|
||||
|
||||
const hocuspocus = new Hocuspocus({ /* config */ })
|
||||
|
||||
// pass any WebSocket-like instance + request + optional context:
|
||||
hocuspocus.handleConnection(ws, request, context)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full reference, hooks, extensions, and scaling guides: [tiptap.dev/docs/hocuspocus](https://tiptap.dev/docs/hocuspocus).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
52
packages/server/package.json
Normal file
52
packages/server/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@hocuspocus/server",
|
||||
"description": "plug & play collaboration backend",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"yjs",
|
||||
"yjs-websocket",
|
||||
"prosemirror"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-server.cjs",
|
||||
"module": "dist/hocuspocus-server.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-server.esm.js",
|
||||
"require": "./dist/hocuspocus-server.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "workspace:^",
|
||||
"async-mutex": "^0.5.0",
|
||||
"crossws": "^0.4.4",
|
||||
"kleur": "^4.1.5",
|
||||
"lib0": "^0.2.117"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
641
packages/server/src/ClientConnection.ts
Normal file
641
packages/server/src/ClientConnection.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
type CloseEvent,
|
||||
ConnectionTimeout,
|
||||
Forbidden,
|
||||
ResetConnection,
|
||||
Unauthorized,
|
||||
WsReadyStates,
|
||||
} from "@hocuspocus/common";
|
||||
import * as decoding from "lib0/decoding";
|
||||
import Connection from "./Connection.ts";
|
||||
import type Document from "./Document.ts";
|
||||
import type { Hocuspocus } from "./Hocuspocus.ts";
|
||||
import { IncomingMessage as SocketIncomingMessage } from "./IncomingMessage.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type {
|
||||
ConnectionConfiguration,
|
||||
WebSocketLike,
|
||||
afterHandleMessagePayload,
|
||||
beforeHandleMessagePayload,
|
||||
beforeSyncPayload,
|
||||
onDisconnectPayload,
|
||||
} from "./types.ts";
|
||||
import { MessageType } from "./types.ts";
|
||||
import { getParameters } from "./util/getParameters.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
|
||||
*/
|
||||
export class ClientConnection<Context = any> {
|
||||
// Map of established document connections, keyed by rawKey (composite or plain)
|
||||
private readonly documentConnections: Record<string, Connection<Context>> =
|
||||
{};
|
||||
|
||||
// While the connection will be establishing messages will
|
||||
// be queued and handled later.
|
||||
private readonly incomingMessageQueue: Record<string, Uint8Array[]> = {};
|
||||
|
||||
// Tracks the number of bytes buffered per document key, so the total amount
|
||||
// of unauthenticated buffered data can be bounded (see GHSA-xwhh-v746-pj9m).
|
||||
private readonly incomingMessageQueueBytes: Record<string, number> = {};
|
||||
|
||||
// While the connection is establishing, keep track of which documents have received auth
|
||||
private readonly documentConnectionsEstablished = new Set<string>();
|
||||
|
||||
// Hook payloads keyed by rawKey (composite or plain)
|
||||
private readonly hookPayloads: Record<
|
||||
string,
|
||||
{
|
||||
instance: Hocuspocus;
|
||||
request: Request;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
context: Context;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
> = {};
|
||||
|
||||
private readonly callbacks = {
|
||||
onClose: [(document: Document, payload: onDisconnectPayload) => {}],
|
||||
};
|
||||
|
||||
// Every new connection gets a unique identifier.
|
||||
private readonly socketId = crypto.randomUUID();
|
||||
|
||||
timeout: number;
|
||||
|
||||
private readonly maxUnauthenticatedQueueSize: number;
|
||||
|
||||
private readonly maxUnauthenticatedQueueMessages: number;
|
||||
|
||||
private readonly maxPendingDocuments: number;
|
||||
|
||||
pingInterval: NodeJS.Timeout;
|
||||
|
||||
lastMessageReceivedAt = Date.now();
|
||||
|
||||
// When the connection was opened. Used to enforce a pre-authentication
|
||||
// deadline that inbound traffic cannot refresh.
|
||||
private readonly connectionEstablishedAt = Date.now();
|
||||
|
||||
// Set once any document on this connection has successfully authenticated.
|
||||
// Sticky on purpose: once a client has proven itself, the normal idle-timeout
|
||||
// logic applies for the rest of the socket's life, even if it later closes all
|
||||
// of its documents.
|
||||
private hasAuthenticated = false;
|
||||
|
||||
// Set once the connection has been proactively torn down (timeout or a limit
|
||||
// breach). Guards against processing — and re-logging — frames that were
|
||||
// already in flight when we decided to close the socket.
|
||||
private terminated = false;
|
||||
|
||||
/**
|
||||
* 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(
|
||||
private readonly websocket: WebSocketLike,
|
||||
private readonly request: Request,
|
||||
private readonly documentProvider: {
|
||||
createDocument: Hocuspocus["createDocument"];
|
||||
},
|
||||
// TODO: change to events
|
||||
private readonly hooks: Hocuspocus["hooks"],
|
||||
private readonly opts: {
|
||||
timeout: number;
|
||||
maxUnauthenticatedQueueSize?: number;
|
||||
maxUnauthenticatedQueueMessages?: number;
|
||||
maxPendingDocuments?: number;
|
||||
},
|
||||
private readonly defaultContext: Context = {} as Context,
|
||||
) {
|
||||
this.timeout = opts.timeout;
|
||||
this.maxUnauthenticatedQueueSize =
|
||||
opts.maxUnauthenticatedQueueSize ?? 5 * 1024 * 1024;
|
||||
this.maxUnauthenticatedQueueMessages =
|
||||
opts.maxUnauthenticatedQueueMessages ?? 1_000;
|
||||
this.maxPendingDocuments = opts.maxPendingDocuments ?? 100;
|
||||
this.pingInterval = setInterval(this.check, this.timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket close event. Call this from your integration
|
||||
* when the WebSocket connection closes.
|
||||
*/
|
||||
handleClose(event?: CloseEvent) {
|
||||
this.close(event);
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
private close(event?: CloseEvent) {
|
||||
Object.values(this.documentConnections).forEach((connection) =>
|
||||
connection.close(event),
|
||||
);
|
||||
|
||||
// Release any buffered pre-authentication state. Without this, a
|
||||
// timed-out or rejected connection would keep its queued messages and
|
||||
// hook payloads in memory until the process exits (GHSA-xwhh-v746-pj9m).
|
||||
for (const rawKey of Object.keys(this.incomingMessageQueue)) {
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
}
|
||||
for (const rawKey of Object.keys(this.hookPayloads)) {
|
||||
delete this.hookPayloads[rawKey];
|
||||
}
|
||||
this.documentConnectionsEstablished.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the connection: release the established document connections and
|
||||
* the buffered pre-authentication state, then close the underlying socket.
|
||||
* Closing the socket is essential — otherwise an unauthenticated client can
|
||||
* keep the socket (and its resources) open even after a timeout fires.
|
||||
*/
|
||||
private terminate(event?: CloseEvent) {
|
||||
if (this.terminated) {
|
||||
return;
|
||||
}
|
||||
this.terminated = true;
|
||||
|
||||
this.close(event);
|
||||
|
||||
if (
|
||||
this.websocket.readyState !== WsReadyStates.Closing &&
|
||||
this.websocket.readyState !== WsReadyStates.Closed
|
||||
) {
|
||||
this.websocket.close(event?.code, event?.reason);
|
||||
}
|
||||
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = () => {
|
||||
// Until the connection has authenticated at least one document, use an
|
||||
// absolute deadline based on when the connection opened. Inbound traffic
|
||||
// updates `lastMessageReceivedAt`, so a flood of unauthenticated frames
|
||||
// could otherwise refresh the timeout forever and keep the socket alive.
|
||||
// Once authenticated, fall back to the normal idle behavior — even if the
|
||||
// client later closes all of its documents — so an established client that
|
||||
// keeps its socket open is not closed based on the original open time.
|
||||
const referenceTime = this.hasAuthenticated
|
||||
? this.lastMessageReceivedAt
|
||||
: this.connectionEstablishedAt;
|
||||
|
||||
if (Date.now() - referenceTime > this.timeout) {
|
||||
this.terminate(ConnectionTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
// Total bytes currently buffered for unauthenticated documents on this
|
||||
// connection. Derived on demand so the various cleanup paths only need to
|
||||
// delete their keys without keeping a running counter in sync.
|
||||
private getQueuedBytes(): number {
|
||||
let total = 0;
|
||||
for (const rawKey of Object.keys(this.incomingMessageQueueBytes)) {
|
||||
total += this.incomingMessageQueueBytes[rawKey];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Total number of messages currently buffered for unauthenticated documents.
|
||||
private getQueuedMessageCount(): number {
|
||||
let total = 0;
|
||||
for (const rawKey of Object.keys(this.incomingMessageQueue)) {
|
||||
total += this.incomingMessageQueue[rawKey].length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Number of distinct documents on this connection that have not yet finished
|
||||
// authenticating. Each holds its own queue and hook payload, so this is capped
|
||||
// to prevent a single connection from amplifying memory usage by fanning out
|
||||
// across many document names. We count by `isAuthenticated` (not by whether an
|
||||
// Auth frame has been seen) so that sending an Auth frame first — which marks
|
||||
// the document as "establishing" but keeps its hook payload alive while a slow
|
||||
// `onAuthenticate` runs — cannot be used to bypass the limit.
|
||||
private getPendingDocumentCount(): number {
|
||||
let total = 0;
|
||||
for (const rawKey of Object.keys(this.hookPayloads)) {
|
||||
if (!this.hookPayloads[rawKey].connectionConfig.isAuthenticated) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when the connection is closed
|
||||
*/
|
||||
public onClose(
|
||||
callback: (document: Document, payload: onDisconnectPayload) => void,
|
||||
): ClientConnection {
|
||||
this.callbacks.onClose.push(callback);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection by the given request and document
|
||||
*/
|
||||
private createConnection(
|
||||
connection: WebSocketLike,
|
||||
document: Document,
|
||||
hookPayload: (typeof this.hookPayloads)[string],
|
||||
sessionId: string | null,
|
||||
providerVersion: string | null,
|
||||
): Connection {
|
||||
const instance = new Connection(
|
||||
connection,
|
||||
hookPayload.request,
|
||||
document,
|
||||
hookPayload.socketId,
|
||||
hookPayload.context,
|
||||
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 = {
|
||||
instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
socketId: hookPayload.socketId,
|
||||
documentName: document.name,
|
||||
requestHeaders: hookPayload.request.headers,
|
||||
requestParameters: getParameters(hookPayload.request),
|
||||
};
|
||||
|
||||
await this.hooks("onDisconnect", disconnectHookPayload);
|
||||
this.callbacks.onClose.forEach((callback) =>
|
||||
callback(document, disconnectHookPayload),
|
||||
);
|
||||
});
|
||||
|
||||
instance.onStatelessCallback(async (payload) => {
|
||||
try {
|
||||
return await this.hooks("onStateless", payload);
|
||||
} catch (error: any) {
|
||||
if (error?.message) {
|
||||
// if a hook rejects and the error is empty, do nothing
|
||||
// this is only meant to prevent later hooks and the
|
||||
// default handler to do something. if an error is present
|
||||
// just rethrow it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
instance.beforeHandleMessage((connection, update) => {
|
||||
const beforeHandleMessagePayload: beforeHandleMessagePayload = {
|
||||
instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
socketId: hookPayload.socketId,
|
||||
connection,
|
||||
documentName: document.name,
|
||||
requestHeaders: hookPayload.request.headers,
|
||||
requestParameters: getParameters(hookPayload.request),
|
||||
update,
|
||||
};
|
||||
|
||||
return this.hooks("beforeHandleMessage", beforeHandleMessagePayload);
|
||||
});
|
||||
|
||||
instance.afterHandleMessage((connection, update) => {
|
||||
const afterHandleMessagePayload: afterHandleMessagePayload = {
|
||||
instance: this.documentProvider as Hocuspocus,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
socketId: hookPayload.socketId,
|
||||
connection,
|
||||
documentName: document.name,
|
||||
requestHeaders: hookPayload.request.headers,
|
||||
requestParameters: getParameters(hookPayload.request),
|
||||
update,
|
||||
};
|
||||
|
||||
return this.hooks("afterHandleMessage", afterHandleMessagePayload);
|
||||
});
|
||||
|
||||
instance.beforeSync((connection, payload) => {
|
||||
const beforeSyncPayload: beforeSyncPayload = {
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
documentName: document.name,
|
||||
connection,
|
||||
type: payload.type,
|
||||
payload: payload.payload,
|
||||
};
|
||||
|
||||
return this.hooks("beforeSync", beforeSyncPayload);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// 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
|
||||
const document = await this.documentProvider.createDocument(
|
||||
documentName,
|
||||
hookPayload.request,
|
||||
hookPayload.socketId,
|
||||
hookPayload.connectionConfig,
|
||||
hookPayload.context,
|
||||
);
|
||||
const connection = this.createConnection(this.websocket, document, hookPayload, sessionId, hookPayload.providerVersion);
|
||||
|
||||
connection.onClose((document, event) => {
|
||||
delete this.hookPayloads[rawKey];
|
||||
delete this.documentConnections[rawKey];
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
this.documentConnectionsEstablished.delete(rawKey);
|
||||
});
|
||||
|
||||
connection.onTokenSyncCallback(async (payload) => {
|
||||
try {
|
||||
return await this.hooks(
|
||||
"onTokenSync",
|
||||
{
|
||||
...hookPayload,
|
||||
...payload,
|
||||
document,
|
||||
connection,
|
||||
documentName,
|
||||
},
|
||||
(contextAdditions: Partial<Context>) => {
|
||||
hookPayload.context = {
|
||||
...hookPayload.context,
|
||||
...contextAdditions,
|
||||
};
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const error = { ...Unauthorized, ...err };
|
||||
connection.close({ code: error.code, reason: error.reason });
|
||||
}
|
||||
});
|
||||
|
||||
this.documentConnections[rawKey] = connection;
|
||||
|
||||
// If the WebSocket has already disconnected (wow, that was fast) – then
|
||||
// immediately call close to cleanup the connection and document in memory.
|
||||
if (
|
||||
this.websocket.readyState === WsReadyStates.Closing ||
|
||||
this.websocket.readyState === WsReadyStates.Closed
|
||||
) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain queued messages to the Connection. Once drained, the buffered
|
||||
// data is no longer needed (subsequent messages route straight to the
|
||||
// Connection), so release it and stop counting it against the queue
|
||||
// limits.
|
||||
this.incomingMessageQueue[rawKey]?.forEach((input) => {
|
||||
connection.handleMessage(input);
|
||||
});
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
|
||||
await this.hooks("connected", {
|
||||
...hookPayload,
|
||||
documentName,
|
||||
context: hookPayload.context,
|
||||
connection,
|
||||
});
|
||||
};
|
||||
|
||||
// This listener handles authentication messages and queues everything else.
|
||||
private handleQueueingMessage = async (data: Uint8Array, rawKey: string, documentName: string) => {
|
||||
try {
|
||||
const tmpMsg = new SocketIncomingMessage(data);
|
||||
|
||||
decoding.readVarString(tmpMsg.decoder); // skip the message address (already extracted)
|
||||
const type = decoding.readVarUint(tmpMsg.decoder);
|
||||
|
||||
if (
|
||||
!(
|
||||
type === MessageType.Auth &&
|
||||
!this.documentConnectionsEstablished.has(rawKey)
|
||||
)
|
||||
) {
|
||||
// Bound the amount of data buffered before authentication. An
|
||||
// unauthenticated client must not be able to grow this queue without
|
||||
// limit (GHSA-xwhh-v746-pj9m).
|
||||
const queuedBytes = this.getQueuedBytes();
|
||||
const queuedMessages = this.getQueuedMessageCount();
|
||||
const exceedsBytes =
|
||||
queuedBytes + data.byteLength > this.maxUnauthenticatedQueueSize;
|
||||
const exceedsMessages =
|
||||
queuedMessages + 1 > this.maxUnauthenticatedQueueMessages;
|
||||
|
||||
if (exceedsBytes || exceedsMessages) {
|
||||
console.warn(
|
||||
exceedsBytes
|
||||
? `Hocuspocus: closing connection ${this.socketId}: unauthenticated message queue size limit exceeded for "${documentName}" (${queuedBytes + data.byteLength} > maxUnauthenticatedQueueSize ${this.maxUnauthenticatedQueueSize} bytes).`
|
||||
: `Hocuspocus: closing connection ${this.socketId}: unauthenticated message queue length limit exceeded for "${documentName}" (${queuedMessages + 1} > maxUnauthenticatedQueueMessages ${this.maxUnauthenticatedQueueMessages}).`,
|
||||
);
|
||||
this.terminate(ResetConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.incomingMessageQueue[rawKey].push(data);
|
||||
this.incomingMessageQueueBytes[rawKey] =
|
||||
(this.incomingMessageQueueBytes[rawKey] ?? 0) + data.byteLength;
|
||||
return;
|
||||
}
|
||||
|
||||
// Okay, we've got the authentication message we're waiting for:
|
||||
this.documentConnectionsEstablished.add(rawKey);
|
||||
|
||||
// The 2nd integer contains the submessage type
|
||||
// which will always be authentication when sent from client -> server
|
||||
decoding.readVarUint(tmpMsg.decoder);
|
||||
const token = decoding.readVarString(tmpMsg.decoder);
|
||||
|
||||
// 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[rawKey];
|
||||
hookPayload.providerVersion = providerVersion;
|
||||
|
||||
await this.hooks(
|
||||
"onConnect",
|
||||
{ ...hookPayload, documentName },
|
||||
(contextAdditions: Partial<Context>) => {
|
||||
hookPayload.context = {
|
||||
...hookPayload.context,
|
||||
...contextAdditions,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await this.hooks(
|
||||
"onAuthenticate",
|
||||
{
|
||||
token,
|
||||
...hookPayload,
|
||||
documentName,
|
||||
},
|
||||
(contextAdditions: Partial<Context>) => {
|
||||
hookPayload.context = {
|
||||
...hookPayload.context,
|
||||
...contextAdditions,
|
||||
};
|
||||
},
|
||||
);
|
||||
// All `onAuthenticate` hooks passed.
|
||||
hookPayload.connectionConfig.isAuthenticated = true;
|
||||
this.hasAuthenticated = true;
|
||||
|
||||
// Let the client know that authentication was successful.
|
||||
const message = new OutgoingMessage(responseAddress).writeAuthenticated(
|
||||
hookPayload.connectionConfig.readOnly,
|
||||
);
|
||||
|
||||
this.websocket.send(message.toUint8Array());
|
||||
|
||||
// Time to actually establish the connection.
|
||||
await this.setUpNewConnection(rawKey, documentName, sessionId);
|
||||
} catch (err: any) {
|
||||
const error = err || Forbidden;
|
||||
const message = new OutgoingMessage(responseAddress).writePermissionDenied(
|
||||
error.reason ?? "permission-denied",
|
||||
);
|
||||
|
||||
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];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
}
|
||||
|
||||
// Catch errors due to failed decoding of data
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.websocket.close(ResetConnection.code, ResetConnection.reason);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket message. Call this from your integration
|
||||
* when the WebSocket receives a binary message.
|
||||
*/
|
||||
handleMessage = (data: Uint8Array) => {
|
||||
// Ignore anything that arrives after we've decided to tear the connection
|
||||
// down (e.g. frames that were already in flight when a limit was breached).
|
||||
if (this.terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastMessageReceivedAt = Date.now();
|
||||
|
||||
try {
|
||||
const tmpMsg = new SocketIncomingMessage(data);
|
||||
|
||||
const rawKey = decoding.readVarString(tmpMsg.decoder);
|
||||
|
||||
// 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) {
|
||||
connection.handleMessage(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirst = this.incomingMessageQueue[rawKey] === undefined;
|
||||
if (isFirst) {
|
||||
// Cap the number of documents a single connection can open before
|
||||
// authenticating any of them. Each pending document allocates its own
|
||||
// queue and hook payload, so this prevents memory amplification by
|
||||
// fanning out across many document names (GHSA-xwhh-v746-pj9m).
|
||||
if (this.getPendingDocumentCount() >= this.maxPendingDocuments) {
|
||||
console.warn(
|
||||
`Hocuspocus: closing connection ${this.socketId}: too many pending unauthenticated documents (maxPendingDocuments ${this.maxPendingDocuments}). Last requested document: "${documentName}".`,
|
||||
);
|
||||
this.terminate(ResetConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.incomingMessageQueue[rawKey] = [];
|
||||
if (this.hookPayloads[rawKey]) {
|
||||
throw new Error("first message, but hookPayloads exists");
|
||||
}
|
||||
|
||||
this.hookPayloads[rawKey] = {
|
||||
instance: this.documentProvider as Hocuspocus,
|
||||
request: this.request,
|
||||
connectionConfig: {
|
||||
readOnly: false,
|
||||
isAuthenticated: false,
|
||||
},
|
||||
requestHeaders: this.request.headers,
|
||||
requestParameters: getParameters(this.request),
|
||||
socketId: this.socketId,
|
||||
context: {
|
||||
...this.defaultContext,
|
||||
},
|
||||
providerVersion: null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
this.handleQueueingMessage(data, rawKey, documentName);
|
||||
} catch (closeError) {
|
||||
// catch is needed in case an invalid payload crashes the parsing of the Uint8Array
|
||||
console.error(closeError);
|
||||
this.websocket.close(Unauthorized.code, Unauthorized.reason);
|
||||
}
|
||||
};
|
||||
}
|
||||
305
packages/server/src/Connection.ts
Normal file
305
packages/server/src/Connection.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
type CloseEvent,
|
||||
ResetConnection,
|
||||
WsReadyStates,
|
||||
} from "@hocuspocus/common";
|
||||
import type Document from "./Document.ts";
|
||||
import { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { MessageReceiver } from "./MessageReceiver.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type {
|
||||
WebSocketLike,
|
||||
beforeSyncPayload,
|
||||
onStatelessPayload,
|
||||
} from "./types.ts";
|
||||
|
||||
export class Connection<Context = any> {
|
||||
webSocket: WebSocketLike;
|
||||
|
||||
context: Context;
|
||||
|
||||
document: Document;
|
||||
|
||||
request: Request;
|
||||
|
||||
callbacks = {
|
||||
onClose: [(document: Document, event?: CloseEvent) => {}],
|
||||
beforeHandleMessage: (connection: Connection, update: Uint8Array) =>
|
||||
Promise.resolve(),
|
||||
afterHandleMessage: (connection: Connection, update: Uint8Array) =>
|
||||
Promise.resolve(),
|
||||
beforeSync: (
|
||||
connection: Connection,
|
||||
payload: Pick<beforeSyncPayload, "type" | "payload">,
|
||||
) => Promise.resolve(),
|
||||
statelessCallback: (payload: onStatelessPayload) => Promise.resolve(),
|
||||
onTokenSyncCallback: (payload: { token: string }) => Promise.resolve(),
|
||||
};
|
||||
|
||||
socketId: string;
|
||||
|
||||
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 {
|
||||
return this.sessionId
|
||||
? `${this.document.name}\0${this.sessionId}`
|
||||
: this.document.name;
|
||||
}
|
||||
|
||||
private messageQueue: Uint8Array[] = [];
|
||||
|
||||
private processingPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
constructor(
|
||||
connection: WebSocketLike,
|
||||
request: Request,
|
||||
document: Document,
|
||||
socketId: string,
|
||||
context: Context,
|
||||
readOnly = false,
|
||||
sessionId?: string | null,
|
||||
providerVersion?: string | null,
|
||||
) {
|
||||
this.webSocket = connection;
|
||||
this.context = context;
|
||||
this.document = document;
|
||||
this.request = request;
|
||||
this.socketId = socketId;
|
||||
this.readOnly = readOnly;
|
||||
this.sessionId = sessionId ?? null;
|
||||
this.providerVersion = providerVersion ?? null;
|
||||
|
||||
this.document.addConnection(this);
|
||||
|
||||
this.sendCurrentAwareness();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when the connection is closed
|
||||
*/
|
||||
onClose(
|
||||
callback: (document: Document, event?: CloseEvent) => void,
|
||||
): Connection {
|
||||
this.callbacks.onClose.push(callback);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when an stateless message is received
|
||||
*/
|
||||
onStatelessCallback(
|
||||
callback: (payload: onStatelessPayload) => Promise<void>,
|
||||
): Connection {
|
||||
this.callbacks.statelessCallback = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before an message is handled
|
||||
*/
|
||||
beforeHandleMessage(
|
||||
callback: (connection: Connection, update: Uint8Array) => Promise<any>,
|
||||
): Connection {
|
||||
this.callbacks.beforeHandleMessage = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered after a message has been handled
|
||||
*/
|
||||
afterHandleMessage(
|
||||
callback: (connection: Connection, update: Uint8Array) => Promise<any>,
|
||||
): Connection {
|
||||
this.callbacks.afterHandleMessage = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before a sync message is handled
|
||||
*/
|
||||
beforeSync(
|
||||
callback: (
|
||||
connection: Connection,
|
||||
payload: Pick<beforeSyncPayload, "type" | "payload">,
|
||||
) => Promise<any>,
|
||||
): Connection {
|
||||
this.callbacks.beforeSync = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when on token sync message is received
|
||||
*/
|
||||
onTokenSyncCallback(
|
||||
callback: (payload: { token: string }) => Promise<void>,
|
||||
): Connection {
|
||||
this.callbacks.onTokenSyncCallback = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when all queued messages have been processed.
|
||||
*/
|
||||
waitForPendingMessages(): Promise<void> {
|
||||
return this.processingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the given message
|
||||
*/
|
||||
send(message: Uint8Array): void {
|
||||
if (
|
||||
this.webSocket.readyState === WsReadyStates.Closing ||
|
||||
this.webSocket.readyState === WsReadyStates.Closed
|
||||
) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.webSocket.send(message);
|
||||
} catch (exception) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a stateless message with payload
|
||||
*/
|
||||
public sendStateless(payload: string): void {
|
||||
const message = new OutgoingMessage(this.messageAddress).writeStateless(
|
||||
payload,
|
||||
);
|
||||
|
||||
this.send(message.toUint8Array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Request current token from the client
|
||||
*/
|
||||
public requestToken(): void {
|
||||
const message = new OutgoingMessage(
|
||||
this.messageAddress,
|
||||
).writeTokenSyncRequest();
|
||||
|
||||
this.send(message.toUint8Array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful wrapper around the WebSocket close method.
|
||||
*/
|
||||
close(event?: CloseEvent): void {
|
||||
if (this.document.hasConnection(this)) {
|
||||
this.document.removeConnection(this);
|
||||
this.callbacks.onClose.forEach(
|
||||
(callback: (arg0: Document, arg1?: CloseEvent) => any) =>
|
||||
callback(this.document, event),
|
||||
);
|
||||
|
||||
const closeMessage = new OutgoingMessage(this.messageAddress);
|
||||
closeMessage.writeCloseMessage(
|
||||
event?.reason ?? "Server closed the connection",
|
||||
);
|
||||
this.send(closeMessage.toUint8Array());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the current document awareness to the client, if any
|
||||
* @private
|
||||
*/
|
||||
private sendCurrentAwareness(): void {
|
||||
if (!this.document.hasAwarenessStates()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const awarenessMessage = new OutgoingMessage(
|
||||
this.messageAddress,
|
||||
).createAwarenessUpdateMessage(this.document.awareness);
|
||||
|
||||
this.send(awarenessMessage.toUint8Array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming message
|
||||
* @public
|
||||
*/
|
||||
public handleMessage(data: Uint8Array): void {
|
||||
this.messageQueue.push(data);
|
||||
|
||||
if (this.messageQueue.length === 1) {
|
||||
this.processingPromise = this.processMessages();
|
||||
}
|
||||
}
|
||||
|
||||
private async processMessages() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
const rawUpdate = this.messageQueue.at(0) as Uint8Array;
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
await receiver.apply(this.document, this);
|
||||
} finally {
|
||||
try {
|
||||
await this.callbacks.afterHandleMessage(this, rawUpdate);
|
||||
} catch (afterError) {
|
||||
console.error(
|
||||
`afterHandleMessage hook failed (while handling ${documentName})`,
|
||||
afterError,
|
||||
);
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
} catch (e: any) {
|
||||
console.error(
|
||||
`closing connection ${this.socketId} (while handling ${documentName}) because of exception`,
|
||||
e,
|
||||
);
|
||||
this.close({
|
||||
code: "code" in e && typeof e.code === 'number' ? e.code : ResetConnection.code,
|
||||
reason: "reason" in e ? e.reason : ResetConnection.reason,
|
||||
});
|
||||
this.messageQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageQueue.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Connection;
|
||||
103
packages/server/src/DirectConnection.ts
Normal file
103
packages/server/src/DirectConnection.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { URLSearchParams } from "node:url";
|
||||
import type Document from "./Document.ts";
|
||||
import type { Hocuspocus } from "./Hocuspocus.ts";
|
||||
import type {
|
||||
DirectConnection as DirectConnectionInterface,
|
||||
DisconnectOptions,
|
||||
LocalTransactionOrigin,
|
||||
} from "./types.ts";
|
||||
|
||||
export class DirectConnection<Context = any>
|
||||
implements DirectConnectionInterface
|
||||
{
|
||||
document: Document | null = null;
|
||||
|
||||
instance!: Hocuspocus;
|
||||
|
||||
context: Context;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
constructor(document: Document, instance: Hocuspocus, context?: Context) {
|
||||
this.document = document;
|
||||
this.instance = instance;
|
||||
this.context = (context ?? {}) as Context;
|
||||
|
||||
this.document.addDirectConnection();
|
||||
}
|
||||
|
||||
async transact(transaction: (document: Document) => void) {
|
||||
if (!this.document) {
|
||||
throw new Error("direct connection closed");
|
||||
}
|
||||
|
||||
this.document.transact(
|
||||
(x) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
transaction(this.document!);
|
||||
},
|
||||
{
|
||||
source: "local",
|
||||
context: this.context,
|
||||
} satisfies LocalTransactionOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
async disconnect(options?: DisconnectOptions) {
|
||||
if (this.document) {
|
||||
// Defaults to true regardless of the server-wide `unloadImmediately`
|
||||
// setting, so the historical "durable on disconnect" behavior is kept
|
||||
// unless a caller explicitly opts into keeping the document warm.
|
||||
const unloadImmediately = options?.unloadImmediately ?? true;
|
||||
|
||||
this.document?.removeDirectConnection();
|
||||
|
||||
// With unloadImmediately the document is persisted synchronously.
|
||||
// Otherwise the store is debounced, so the document stays warm in memory
|
||||
// and a follow-up direct connection can reuse it (coalescing writes),
|
||||
// mirroring how websocket connections behave on close.
|
||||
await this.instance.storeDocumentHooks(
|
||||
this.document,
|
||||
{
|
||||
clientsCount: this.document.getConnectionsCount(),
|
||||
lastContext: this.context,
|
||||
lastTransactionOrigin: {
|
||||
source: "local",
|
||||
context: this.context,
|
||||
} satisfies LocalTransactionOrigin,
|
||||
document: this.document,
|
||||
documentName: this.document.name,
|
||||
instance: this.instance,
|
||||
},
|
||||
unloadImmediately,
|
||||
);
|
||||
|
||||
// If the direct connection was the only connection to the document
|
||||
// then we should trigger the onDisconnect hook for this doc. We only
|
||||
// unload it right away when unloadImmediately is set; otherwise the
|
||||
// debounced store above unloads it once it has flushed.
|
||||
if (
|
||||
this.document.getConnectionsCount() === 0 &&
|
||||
!this.document.saveMutex.isLocked()
|
||||
) {
|
||||
await this.instance.hooks("onDisconnect", {
|
||||
instance: this.instance,
|
||||
clientsCount: this.document.getConnectionsCount(),
|
||||
context: this.context,
|
||||
document: this.document,
|
||||
socketId: "server",
|
||||
documentName: this.document.name,
|
||||
requestHeaders: new Headers(),
|
||||
requestParameters: new URLSearchParams(),
|
||||
});
|
||||
|
||||
if (unloadImmediately) {
|
||||
await this.instance.unloadDocument(this.document);
|
||||
}
|
||||
}
|
||||
|
||||
this.document = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
487
packages/server/src/Document.ts
Normal file
487
packages/server/src/Document.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { Mutex } from "async-mutex";
|
||||
import {
|
||||
Awareness,
|
||||
applyAwarenessUpdate,
|
||||
removeAwarenessStates,
|
||||
} from "y-protocols/awareness";
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate, mergeUpdates } from "yjs";
|
||||
import type Connection from "./Connection.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type {
|
||||
AwarenessUpdate,
|
||||
ConnectionTransactionOrigin,
|
||||
FlushOptions,
|
||||
TransactionOrigin,
|
||||
} from "./types.ts";
|
||||
import { isTransactionOrigin } from "./types.ts";
|
||||
|
||||
export class Document extends Doc {
|
||||
awareness: Awareness;
|
||||
|
||||
callbacks = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onUpdate: (document: Document, origin: unknown, update: Uint8Array) => {},
|
||||
beforeBroadcastStateless: (document: Document, stateless: string) => {},
|
||||
beforeHandleAwareness: (
|
||||
document: Document,
|
||||
states: Map<number, Record<string, any>>,
|
||||
transactionOrigin: unknown,
|
||||
) => Promise.resolve(),
|
||||
};
|
||||
|
||||
connections: Map<
|
||||
Connection,
|
||||
{
|
||||
clients: Set<any>;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
// The number of direct (non-websocket) connections to this document
|
||||
directConnectionsCount = 0;
|
||||
|
||||
name: string;
|
||||
|
||||
isLoading: boolean;
|
||||
|
||||
isDestroyed = false;
|
||||
|
||||
saveMutex = new Mutex();
|
||||
|
||||
lastChangeTime = 0;
|
||||
|
||||
/**
|
||||
* Batch outgoing broadcasts over this window (ms), or false to send each one
|
||||
* immediately. See `scheduleFlush`.
|
||||
*/
|
||||
flushDelay: FlushOptions["flushDelay"];
|
||||
|
||||
/**
|
||||
* Flush early once this many bytes of document updates are buffered.
|
||||
*/
|
||||
flushMaxBytes: FlushOptions["flushMaxBytes"];
|
||||
|
||||
private pendingUpdates: Array<Uint8Array> = [];
|
||||
|
||||
private pendingUpdateBytes = 0;
|
||||
|
||||
private pendingAwarenessClients: Set<number> = new Set();
|
||||
|
||||
private cancelFlush: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
constructor(
|
||||
name: string,
|
||||
yDocOptions?: object,
|
||||
options?: Partial<FlushOptions>,
|
||||
) {
|
||||
super(yDocOptions);
|
||||
|
||||
this.name = name;
|
||||
this.flushDelay = options?.flushDelay ?? 0;
|
||||
this.flushMaxBytes = options?.flushMaxBytes ?? 1024 * 1024;
|
||||
|
||||
this.awareness = new Awareness(this);
|
||||
this.awareness.setLocalState(null);
|
||||
|
||||
this.awareness.on("update", this.handleAwarenessUpdate.bind(this));
|
||||
this.on("update", this.handleUpdate.bind(this));
|
||||
|
||||
this.isLoading = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Document (XMLFragment or Map) is empty
|
||||
*/
|
||||
isEmpty(fieldName: string): boolean {
|
||||
return !this.get(fieldName)._start && !this.get(fieldName)._map.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the given document(s) into this one
|
||||
*/
|
||||
merge(documents: Doc | Array<Doc>): Document {
|
||||
(Array.isArray(documents) ? documents : [documents]).forEach((document) => {
|
||||
applyUpdate(this, encodeStateAsUpdate(document));
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when the document is updated
|
||||
*/
|
||||
onUpdate(
|
||||
callback: (document: Document, origin: unknown, update: Uint8Array) => void,
|
||||
): Document {
|
||||
this.callbacks.onUpdate = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before a stateless message is broadcasted
|
||||
*/
|
||||
beforeBroadcastStateless(
|
||||
callback: (document: Document, stateless: string) => void,
|
||||
): Document {
|
||||
this.callbacks.beforeBroadcastStateless = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before an inbound awareness update
|
||||
* is applied to this document's awareness state. The callback receives the
|
||||
* document, the decoded per-client states as a mutable `Map`, and the
|
||||
* `TransactionOrigin` that will be forwarded to `applyAwarenessUpdate`.
|
||||
* Use `isTransactionOrigin(origin)` to discriminate sources. Mutate the
|
||||
* map in place (set/delete/field changes) to rewrite the update, or throw
|
||||
* to reject it entirely.
|
||||
*/
|
||||
beforeHandleAwareness(
|
||||
callback: (
|
||||
document: Document,
|
||||
states: Map<number, Record<string, any>>,
|
||||
transactionOrigin: unknown,
|
||||
) => Promise<any>,
|
||||
): Document {
|
||||
this.callbacks.beforeHandleAwareness = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a connection and a set of clients on this document keyed by the
|
||||
* underlying websocket connection
|
||||
*/
|
||||
addConnection(connection: Connection): Document {
|
||||
this.connections.set(connection, {
|
||||
clients: new Set(),
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given connection registered on this document
|
||||
*/
|
||||
hasConnection(connection: Connection): boolean {
|
||||
return this.connections.has(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given connection from this document
|
||||
*/
|
||||
removeConnection(connection: Connection): Document {
|
||||
const entry = this.connections.get(connection);
|
||||
if (entry) {
|
||||
removeAwarenessStates(this.awareness, Array.from(entry.clients), null);
|
||||
}
|
||||
|
||||
this.connections.delete(connection);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
addDirectConnection(): Document {
|
||||
this.directConnectionsCount += 1;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
removeDirectConnection(): Document {
|
||||
if (this.directConnectionsCount > 0) {
|
||||
this.directConnectionsCount -= 1;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active connections for this document
|
||||
*/
|
||||
getConnectionsCount(): number {
|
||||
return this.connections.size + this.directConnectionsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of registered connections
|
||||
*/
|
||||
getConnections(): Array<Connection> {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client ids for the given connection instance
|
||||
*/
|
||||
getClients(connection: Connection): Set<any> {
|
||||
const entry = this.connections.get(connection);
|
||||
|
||||
return entry?.clients === undefined ? new Set() : entry.clients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the document awareness states
|
||||
*/
|
||||
hasAwarenessStates(): boolean {
|
||||
return this.awareness.getStates().size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given awareness update
|
||||
*/
|
||||
applyAwarenessUpdate(connection: Connection, update: Uint8Array): Document {
|
||||
applyAwarenessUpdate(this.awareness, update, {
|
||||
source: "connection",
|
||||
connection,
|
||||
} satisfies ConnectionTransactionOrigin);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an awareness update and sync changes to clients
|
||||
* @private
|
||||
*/
|
||||
private handleAwarenessUpdate(
|
||||
{ added, updated, removed }: AwarenessUpdate,
|
||||
origin: TransactionOrigin | null,
|
||||
): Document {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
|
||||
const originConnection: Connection | null =
|
||||
isTransactionOrigin(origin) && origin.source === "connection"
|
||||
? origin.connection
|
||||
: null;
|
||||
|
||||
if (originConnection !== null) {
|
||||
const entry = this.connections.get(originConnection);
|
||||
|
||||
if (entry) {
|
||||
added.forEach((clientId: any) => entry.clients.add(clientId));
|
||||
removed.forEach((clientId: any) => entry.clients.delete(clientId));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.flushDelay === false) {
|
||||
this.broadcastAwarenessUpdate(changedClients);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
for (const clientId of changedClients) {
|
||||
this.pendingAwarenessClients.add(clientId);
|
||||
}
|
||||
|
||||
this.scheduleFlush();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one message to every connection, encoding it once per run of
|
||||
* connections that share a message address.
|
||||
*
|
||||
* Connections that share a message address receive byte-identical payloads,
|
||||
* so consecutive connections with the same address reuse one buffer instead
|
||||
* of re-encoding per connection. With session awareness disabled (the
|
||||
* default) every connection on a document shares the plain document name,
|
||||
* which collapses the whole broadcast to a single encode.
|
||||
*
|
||||
* A one-entry cache rather than a Map keyed by address: it makes the common
|
||||
* cases optimal (all addresses equal, or all distinct) at no cost, and only
|
||||
* gives up dedup when addresses interleave, where it still matches the
|
||||
* previous encode-per-connection behaviour.
|
||||
*/
|
||||
private broadcast(
|
||||
encode: (messageAddress: string) => Uint8Array,
|
||||
filter?: (connection: Connection) => boolean,
|
||||
): void {
|
||||
let cachedAddress: string | undefined;
|
||||
let cachedMessage: Uint8Array | undefined;
|
||||
|
||||
for (const connection of this.getConnections()) {
|
||||
if (filter !== undefined && !filter(connection)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address = connection.messageAddress;
|
||||
|
||||
if (cachedMessage === undefined || address !== cachedAddress) {
|
||||
cachedAddress = address;
|
||||
cachedMessage = encode(address);
|
||||
}
|
||||
|
||||
connection.send(cachedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastAwarenessUpdate(changedClients: Array<number>): void {
|
||||
this.broadcast((address) =>
|
||||
new OutgoingMessage(address)
|
||||
.createAwarenessUpdateMessage(this.awareness, changedClients)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an updated document and sync changes to clients
|
||||
*/
|
||||
private handleUpdate(update: Uint8Array, origin: unknown): Document {
|
||||
// Stays synchronous even when broadcasts are batched: this drives
|
||||
// `onChange`, the `onStoreDocument` debounce and the Redis publish in
|
||||
// extension-redis. Deferring it would change persistence and cluster
|
||||
// sync timing, not just the wire traffic.
|
||||
this.callbacks.onUpdate(this, origin, update);
|
||||
|
||||
if (this.flushDelay === false) {
|
||||
this.broadcastUpdate(update);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
this.pendingUpdates.push(update);
|
||||
this.pendingUpdateBytes += update.length;
|
||||
|
||||
if (this.pendingUpdateBytes >= this.flushMaxBytes) {
|
||||
this.flush();
|
||||
} else {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private broadcastUpdate(update: Uint8Array): void {
|
||||
this.broadcast((address) =>
|
||||
new OutgoingMessage(address)
|
||||
.createSyncMessage()
|
||||
.writeUpdate(update)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-window batching: the first buffered change starts the window and
|
||||
* everything until it closes goes out together, so the added latency stays
|
||||
* capped at `flushDelay` instead of growing while clients keep typing.
|
||||
*
|
||||
* `flushDelay: 0` means "coalesce whatever arrives in this event loop turn".
|
||||
* setImmediate runs right after the current poll phase, so a burst of socket
|
||||
* reads is merged without adding any delay. `setTimeout(…, 0)` would not do
|
||||
* that: it clamps to 1ms and runs in the timers phase.
|
||||
*/
|
||||
private scheduleFlush(): void {
|
||||
if (this.cancelFlush !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
this.cancelFlush = null;
|
||||
this.flush();
|
||||
};
|
||||
|
||||
if (this.flushDelay === 0 && typeof setImmediate === "function") {
|
||||
const handle = setImmediate(run);
|
||||
|
||||
this.cancelFlush = () => clearImmediate(handle);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = setTimeout(run, this.flushDelay as number);
|
||||
|
||||
this.cancelFlush = () => clearTimeout(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send everything buffered for the current flush window right away. Document
|
||||
* updates are merged into a single message; awareness collapses to the latest
|
||||
* state of each changed client, so an add and a removal within one window
|
||||
* resolve to the correct end state rather than both being sent.
|
||||
*
|
||||
* Safe to call when nothing is pending.
|
||||
*/
|
||||
public flush(): Document {
|
||||
if (this.cancelFlush !== null) {
|
||||
this.cancelFlush();
|
||||
this.cancelFlush = null;
|
||||
}
|
||||
|
||||
if (this.pendingUpdates.length > 0) {
|
||||
const update =
|
||||
this.pendingUpdates.length === 1
|
||||
? this.pendingUpdates[0]
|
||||
: mergeUpdates(this.pendingUpdates);
|
||||
|
||||
this.pendingUpdates = [];
|
||||
this.pendingUpdateBytes = 0;
|
||||
|
||||
this.broadcastUpdate(update);
|
||||
}
|
||||
|
||||
if (this.pendingAwarenessClients.size > 0) {
|
||||
const clients = Array.from(this.pendingAwarenessClients);
|
||||
|
||||
this.pendingAwarenessClients.clear();
|
||||
|
||||
this.broadcastAwarenessUpdate(clients);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast stateless message to all connections
|
||||
*
|
||||
* Shares one encoded buffer per message address like the update and awareness
|
||||
* broadcasts. Not batched, though: stateless payloads are opaque discrete
|
||||
* events, so unlike updates they cannot be merged and unlike awareness they
|
||||
* do not collapse to a latest state — deferring them would delay delivery
|
||||
* without reducing the number of messages.
|
||||
*/
|
||||
public broadcastStateless(
|
||||
payload: string,
|
||||
filter?: (conn: Connection) => boolean,
|
||||
): void {
|
||||
this.callbacks.beforeBroadcastStateless(this, payload);
|
||||
|
||||
this.relayStateless(payload, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a stateless payload to this instance's connections *without* running
|
||||
* the `beforeBroadcastStateless` hook.
|
||||
*
|
||||
* For relaying a payload that another server instance already broadcast: the
|
||||
* hook ran there, and running it again here would fire it once per instance.
|
||||
* Prefer `broadcastStateless` unless you are that relay.
|
||||
*/
|
||||
public relayStateless(
|
||||
payload: string,
|
||||
filter?: (conn: Connection) => boolean,
|
||||
): void {
|
||||
this.broadcast(
|
||||
(address) =>
|
||||
new OutgoingMessage(address).writeStateless(payload).toUint8Array(),
|
||||
filter,
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Send what is still buffered instead of dropping it, then stop batching:
|
||||
// `super.destroy()` tears the awareness down, which broadcasts one last
|
||||
// time, and that has to reach clients here just as it does on a document
|
||||
// that never batched. Usually a no-op, because a document is only
|
||||
// unloaded once its last connection is gone.
|
||||
this.flush();
|
||||
this.flushDelay = false;
|
||||
|
||||
super.destroy();
|
||||
this.isDestroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default Document;
|
||||
665
packages/server/src/Hocuspocus.ts
Normal file
665
packages/server/src/Hocuspocus.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import crypto from "node:crypto";
|
||||
import { awarenessStatesToArray, ResetConnection, SkipFurtherHooksError } from "@hocuspocus/common";
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from "yjs";
|
||||
import meta from "../package.json" with { type: "json" };
|
||||
|
||||
import { ClientConnection } from "./ClientConnection.ts";
|
||||
import { DirectConnection } from "./DirectConnection.ts";
|
||||
import Document from "./Document.ts";
|
||||
import type { Server } from "./Server.ts";
|
||||
import type {
|
||||
AwarenessUpdate,
|
||||
beforeBroadcastStatelessPayload,
|
||||
Configuration,
|
||||
ConnectionConfiguration,
|
||||
HookName,
|
||||
HookPayloadByName,
|
||||
onChangePayload,
|
||||
onDisconnectPayload,
|
||||
onStoreDocumentPayload,
|
||||
WebSocketLike,
|
||||
} from "./types.ts";
|
||||
import { isTransactionOrigin, shouldSkipStoreHooks } from "./types.ts";
|
||||
import { useDebounce } from "./util/debounce.ts";
|
||||
import { getParameters } from "./util/getParameters.ts";
|
||||
|
||||
export const defaultConfiguration = {
|
||||
name: null,
|
||||
timeout: 60_000,
|
||||
debounce: 2_000,
|
||||
maxDebounce: 10_000,
|
||||
quiet: false,
|
||||
yDocOptions: {
|
||||
gc: true,
|
||||
gcFilter: () => true,
|
||||
},
|
||||
unloadImmediately: true,
|
||||
maxUnauthenticatedQueueSize: 5 * 1024 * 1024,
|
||||
maxUnauthenticatedQueueMessages: 1_000,
|
||||
maxPendingDocuments: 100,
|
||||
flushDelay: 0 as false | number,
|
||||
flushMaxBytes: 1024 * 1024,
|
||||
};
|
||||
|
||||
export class Hocuspocus<Context = any> {
|
||||
configuration: Configuration<Context> = {
|
||||
...defaultConfiguration,
|
||||
extensions: [],
|
||||
onConfigure: () => new Promise((r) => r(null)),
|
||||
onListen: () => new Promise((r) => r(null)),
|
||||
onUpgrade: () => new Promise((r) => r(null)),
|
||||
onConnect: () => new Promise((r) => r(null)),
|
||||
connected: () => new Promise((r) => r(null)),
|
||||
beforeHandleMessage: () => new Promise((r) => r(null)),
|
||||
afterHandleMessage: () => new Promise((r) => r(null)),
|
||||
beforeHandleAwareness: () => new Promise<void>((r) => r()),
|
||||
beforeSync: () => new Promise((r) => r(null)),
|
||||
beforeBroadcastStateless: () => new Promise((r) => r(null)),
|
||||
onStateless: () => new Promise((r) => r(null)),
|
||||
onChange: () => new Promise((r) => r(null)),
|
||||
onCreateDocument: () => new Promise((r) => r(null)),
|
||||
onLoadDocument: () => new Promise((r) => r(null)),
|
||||
onStoreDocument: () => new Promise((r) => r(null)),
|
||||
afterStoreDocument: () => new Promise((r) => r(null)),
|
||||
onAwarenessUpdate: () => new Promise((r) => r(null)),
|
||||
onRequest: () => new Promise((r) => r(null)),
|
||||
onDisconnect: () => new Promise((r) => r(null)),
|
||||
onDestroy: () => new Promise((r) => r(null)),
|
||||
};
|
||||
|
||||
loadingDocuments: Map<string, Promise<Document>> = new Map();
|
||||
unloadingDocuments: Map<string, Promise<void>> = new Map();
|
||||
|
||||
documents: Map<string, Document> = new Map();
|
||||
|
||||
server?: Server;
|
||||
|
||||
debouncer = useDebounce();
|
||||
|
||||
constructor(configuration?: Partial<Configuration<Context>>) {
|
||||
if (configuration) {
|
||||
this.configure(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Hocuspocus
|
||||
*/
|
||||
configure(
|
||||
configuration: Partial<Configuration<Context>>,
|
||||
): Hocuspocus<Context> {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
this.configuration.extensions.sort((a, b) => {
|
||||
const one = typeof a.priority === "undefined" ? 100 : a.priority;
|
||||
const two = typeof b.priority === "undefined" ? 100 : b.priority;
|
||||
|
||||
if (one > two) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (one < two) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
this.configuration.extensions.push({
|
||||
onConfigure: this.configuration.onConfigure,
|
||||
onListen: this.configuration.onListen,
|
||||
onUpgrade: this.configuration.onUpgrade,
|
||||
onConnect: this.configuration.onConnect,
|
||||
connected: this.configuration.connected,
|
||||
onAuthenticate: this.configuration.onAuthenticate,
|
||||
onTokenSync: this.configuration.onTokenSync,
|
||||
onLoadDocument: this.configuration.onLoadDocument,
|
||||
afterLoadDocument: this.configuration.afterLoadDocument,
|
||||
beforeHandleMessage: this.configuration.beforeHandleMessage,
|
||||
afterHandleMessage: this.configuration.afterHandleMessage,
|
||||
beforeHandleAwareness: this.configuration.beforeHandleAwareness,
|
||||
beforeBroadcastStateless: this.configuration.beforeBroadcastStateless,
|
||||
beforeSync: this.configuration.beforeSync,
|
||||
onStateless: this.configuration.onStateless,
|
||||
onChange: this.configuration.onChange,
|
||||
onStoreDocument: this.configuration.onStoreDocument,
|
||||
afterStoreDocument: this.configuration.afterStoreDocument,
|
||||
onAwarenessUpdate: this.configuration.onAwarenessUpdate,
|
||||
onRequest: this.configuration.onRequest,
|
||||
beforeUnloadDocument: this.configuration.beforeUnloadDocument,
|
||||
afterUnloadDocument: this.configuration.afterUnloadDocument,
|
||||
onDisconnect: this.configuration.onDisconnect,
|
||||
onDestroy: this.configuration.onDestroy,
|
||||
});
|
||||
|
||||
this.hooks("onConfigure", {
|
||||
configuration: this.configuration,
|
||||
version: meta.version,
|
||||
instance: this,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of active documents
|
||||
*/
|
||||
getDocumentsCount(): number {
|
||||
return this.documents.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of active connections
|
||||
*/
|
||||
getConnectionsCount(): number {
|
||||
const uniqueSocketIds = new Set<string>();
|
||||
const totalDirectConnections = Array.from(this.documents.values()).reduce(
|
||||
(acc, document) => {
|
||||
// Accumulate unique socket IDs
|
||||
document.getConnections().forEach(({ socketId }) => {
|
||||
uniqueSocketIds.add(socketId);
|
||||
});
|
||||
// Accumulate direct connections
|
||||
return acc + document.directConnectionsCount;
|
||||
},
|
||||
0,
|
||||
);
|
||||
// Return the sum of unique socket IDs and direct connections
|
||||
return uniqueSocketIds.size + totalDirectConnections;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
closeConnections(documentName?: string) {
|
||||
// Iterate through all connections for all documents
|
||||
// and invoke their close method, which is a graceful
|
||||
// disconnect wrapper around the underlying websocket.close
|
||||
this.documents.forEach((document: Document) => {
|
||||
// If a documentName was specified, bail if it doesn't match
|
||||
if (documentName && document.name !== documentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.connections.forEach((_clients, connection) => {
|
||||
connection.close(ResetConnection);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `handleConnection` method receives incoming WebSocket connections,
|
||||
* runs all hooks:
|
||||
*
|
||||
* - onConnect for all connections
|
||||
* - onAuthenticate only if required
|
||||
*
|
||||
* … and if nothing fails it'll fully establish the connection and
|
||||
* load the Document then.
|
||||
*/
|
||||
handleConnection(
|
||||
incoming: WebSocket | WebSocketLike,
|
||||
request: Request,
|
||||
defaultContext: Context = {} as Context,
|
||||
): ClientConnection {
|
||||
const clientConnection = new ClientConnection(
|
||||
incoming,
|
||||
request,
|
||||
this,
|
||||
this.hooks.bind(this),
|
||||
{
|
||||
timeout: this.configuration.timeout,
|
||||
maxUnauthenticatedQueueSize:
|
||||
this.configuration.maxUnauthenticatedQueueSize,
|
||||
maxUnauthenticatedQueueMessages:
|
||||
this.configuration.maxUnauthenticatedQueueMessages,
|
||||
maxPendingDocuments: this.configuration.maxPendingDocuments,
|
||||
},
|
||||
defaultContext,
|
||||
);
|
||||
clientConnection.onClose(
|
||||
(document: Document, hookPayload: onDisconnectPayload) => {
|
||||
// Check if there are still no connections to the document, as these hooks
|
||||
// may take some time to resolve (e.g. database queries). If a
|
||||
// new connection were to come in during that time it would rely on the
|
||||
// document in the map that we remove now.
|
||||
if (document.getConnectionsCount() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If it’s the last connection, we need to make sure to store the
|
||||
// document. Use the debouncer executeNow helper, to run scheduled
|
||||
// onStoreDocument immediately and clear running timers.
|
||||
// If there is no scheduled run for this document there is no point in
|
||||
// triggering onStoreDocument hook, as everything seems to be stored already.
|
||||
// Only run this if the document has finished loading earlier (i.e. not to persist the empty
|
||||
// ydoc if the onLoadDocument hook returned an error)
|
||||
if (
|
||||
!document.isLoading &&
|
||||
this.debouncer.isDebounced(`onStoreDocument-${document.name}`)
|
||||
) {
|
||||
if (this.configuration.unloadImmediately) {
|
||||
this.debouncer.executeNow(`onStoreDocument-${document.name}`);
|
||||
}
|
||||
} else {
|
||||
// Remove document from memory immediately
|
||||
this.unloadDocument(document);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return clientConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update of the given document
|
||||
*
|
||||
* "connection" is not necessarily type "Connection", it's the Yjs "origin" (which is "Connection" if
|
||||
* the update is incoming from the provider, but can be anything if the updates is originated from an extension.
|
||||
*/
|
||||
private handleDocumentUpdate(
|
||||
document: Document,
|
||||
origin: unknown,
|
||||
update: Uint8Array,
|
||||
) {
|
||||
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(),
|
||||
document,
|
||||
documentName: document.name,
|
||||
requestHeaders: request?.headers ?? new Headers(),
|
||||
requestParameters: getParameters(request),
|
||||
socketId: connection?.socketId ?? "",
|
||||
update,
|
||||
transactionOrigin: origin,
|
||||
connection: connection,
|
||||
context,
|
||||
};
|
||||
|
||||
this.hooks("onChange", changePayload);
|
||||
|
||||
if (shouldSkipStoreHooks(origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storePayload: onStoreDocumentPayload = {
|
||||
instance: this,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
document,
|
||||
lastContext: context,
|
||||
lastTransactionOrigin: origin,
|
||||
documentName: document.name,
|
||||
};
|
||||
|
||||
this.storeDocumentHooks(document, storePayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new document by the given request
|
||||
*/
|
||||
public async createDocument(
|
||||
documentName: string,
|
||||
request: Request,
|
||||
socketId: string,
|
||||
connection: ConnectionConfiguration,
|
||||
context?: Context,
|
||||
): Promise<Document> {
|
||||
if (!documentName.trim()) {
|
||||
throw new Error("Document name must not be empty");
|
||||
}
|
||||
|
||||
const existingLoadingDoc = this.loadingDocuments.get(documentName);
|
||||
|
||||
if (existingLoadingDoc) {
|
||||
return existingLoadingDoc;
|
||||
}
|
||||
|
||||
const existingDoc = this.documents.get(documentName);
|
||||
if (existingDoc) {
|
||||
return Promise.resolve(existingDoc);
|
||||
}
|
||||
|
||||
const loadDocPromise = this.loadDocument(
|
||||
documentName,
|
||||
request,
|
||||
socketId,
|
||||
connection,
|
||||
context,
|
||||
);
|
||||
|
||||
this.loadingDocuments.set(documentName, loadDocPromise);
|
||||
|
||||
try {
|
||||
const doc = await loadDocPromise;
|
||||
this.documents.set(documentName, doc);
|
||||
this.loadingDocuments.delete(documentName);
|
||||
return doc;
|
||||
} catch (e) {
|
||||
this.loadingDocuments.delete(documentName);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async loadDocument(
|
||||
documentName: string,
|
||||
request: Request,
|
||||
socketId: string,
|
||||
connectionConfig: ConnectionConfiguration,
|
||||
context?: Context,
|
||||
): Promise<Document> {
|
||||
const requestHeaders = request.headers;
|
||||
const requestParameters = getParameters(request);
|
||||
|
||||
const resolvedContext = (context ?? {}) as Context;
|
||||
|
||||
const yDocOptions = await this.hooks("onCreateDocument", {
|
||||
documentName,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
connectionConfig,
|
||||
context: resolvedContext,
|
||||
socketId,
|
||||
instance: this,
|
||||
});
|
||||
|
||||
const document = new Document(
|
||||
documentName,
|
||||
{
|
||||
...this.configuration.yDocOptions,
|
||||
...yDocOptions,
|
||||
},
|
||||
{
|
||||
flushDelay: this.configuration.flushDelay,
|
||||
flushMaxBytes: this.configuration.flushMaxBytes,
|
||||
},
|
||||
);
|
||||
|
||||
const hookPayload = {
|
||||
instance: this,
|
||||
context: resolvedContext,
|
||||
connectionConfig,
|
||||
document,
|
||||
documentName,
|
||||
socketId,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.hooks(
|
||||
"onLoadDocument",
|
||||
hookPayload,
|
||||
(loadedDocument: Doc | Uint8Array | undefined) => {
|
||||
// the hook returned the document it was given, and encoding a
|
||||
// document's own state to apply it back can never add content
|
||||
if (loadedDocument === document) return;
|
||||
|
||||
if (loadedDocument instanceof Doc) {
|
||||
applyUpdate(document, encodeStateAsUpdate(loadedDocument));
|
||||
} else if (loadedDocument instanceof Uint8Array) {
|
||||
applyUpdate(document, loadedDocument);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
this.closeConnections(documentName);
|
||||
// the document is not in `this.documents` yet — `createDocument` only
|
||||
// registers it once this promise resolves — so `unloadDocument` would
|
||||
// bail out before destroying it
|
||||
document.destroy();
|
||||
throw e;
|
||||
}
|
||||
|
||||
document.isLoading = false;
|
||||
|
||||
document.onUpdate(
|
||||
(document: Document, origin: unknown, update: Uint8Array) => {
|
||||
document.lastChangeTime = Date.now();
|
||||
|
||||
this.handleDocumentUpdate(document, origin, update);
|
||||
},
|
||||
);
|
||||
|
||||
await this.hooks("afterLoadDocument", hookPayload);
|
||||
|
||||
document.beforeBroadcastStateless(
|
||||
(document: Document, stateless: string) => {
|
||||
const hookPayload: beforeBroadcastStatelessPayload = {
|
||||
document,
|
||||
documentName: document.name,
|
||||
payload: stateless,
|
||||
};
|
||||
|
||||
this.hooks("beforeBroadcastStateless", hookPayload);
|
||||
},
|
||||
);
|
||||
|
||||
document.beforeHandleAwareness((document, states, transactionOrigin) => {
|
||||
const connection =
|
||||
isTransactionOrigin(transactionOrigin) &&
|
||||
transactionOrigin.source === "connection"
|
||||
? transactionOrigin.connection
|
||||
: undefined;
|
||||
const request = connection?.request;
|
||||
return this.hooks("beforeHandleAwareness", {
|
||||
awareness: document.awareness,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: connection?.context,
|
||||
document,
|
||||
documentName: document.name,
|
||||
instance: this,
|
||||
requestHeaders: request?.headers ?? new Headers(),
|
||||
requestParameters: request
|
||||
? getParameters(request)
|
||||
: new URLSearchParams(),
|
||||
socketId: connection?.socketId ?? "",
|
||||
transactionOrigin,
|
||||
connection,
|
||||
states,
|
||||
});
|
||||
});
|
||||
|
||||
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()),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
storeDocumentHooks(
|
||||
document: Document,
|
||||
hookPayload: onStoreDocumentPayload,
|
||||
immediately?: boolean,
|
||||
) {
|
||||
const debounceId = `onStoreDocument-${document.name}`;
|
||||
return this.debouncer.debounce(
|
||||
debounceId,
|
||||
async () => {
|
||||
try {
|
||||
await document.saveMutex.runExclusive(async () => {
|
||||
await this.hooks("onStoreDocument", hookPayload);
|
||||
await this.hooks("afterStoreDocument", hookPayload);
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error instanceof SkipFurtherHooksError) {
|
||||
// Another extension handled this — proceed to unload
|
||||
setTimeout(() => {
|
||||
if (this.shouldUnloadDocument(document)) {
|
||||
this.unloadDocument(document);
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Caught error during storeDocumentHooks. Document stays in memory to avoid data loss",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.shouldUnloadDocument(document)) {
|
||||
this.unloadDocument(document);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
immediately ? 0 : this.configuration.debounce,
|
||||
this.configuration.maxDebounce,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given hook on all configured extensions.
|
||||
* Runs the given callback after each hook.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
hooks<T extends HookName>(
|
||||
name: T,
|
||||
payload: HookPayloadByName<Context>[T],
|
||||
// biome-ignore lint/complexity/noBannedTypes: <explanation>
|
||||
callback: Function | null = null,
|
||||
): Promise<any> {
|
||||
const { extensions } = this.configuration;
|
||||
|
||||
// create a new `thenable` chain
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve
|
||||
let chain = Promise.resolve();
|
||||
|
||||
extensions
|
||||
// get me all extensions which have the given hook
|
||||
.filter((extension) => typeof extension[name] === "function")
|
||||
// run through all the configured hooks
|
||||
.forEach((extension) => {
|
||||
chain = chain
|
||||
.then(() => (extension[name] as any)?.(payload))
|
||||
.catch((error) => {
|
||||
// make sure to log error messages
|
||||
if (error?.message) {
|
||||
console.error(`[${name}]`, error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
chain = chain.then((...args: any[]) => callback(...args));
|
||||
}
|
||||
});
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
shouldUnloadDocument(document: Document): boolean {
|
||||
const hasPendingWork =
|
||||
this.debouncer.isDebounced(`onStoreDocument-${document.name}`) ||
|
||||
this.debouncer.isCurrentlyExecuting(`onStoreDocument-${document.name}`) ||
|
||||
document.saveMutex.isLocked();
|
||||
|
||||
return hasPendingWork === false && document.getConnectionsCount() === 0;
|
||||
}
|
||||
|
||||
async unloadDocument(document: Document): Promise<any> {
|
||||
const documentName = document.name;
|
||||
|
||||
if (!this.shouldUnloadDocument(document)) return;
|
||||
|
||||
if (!this.documents.has(documentName)) return;
|
||||
|
||||
if (this.unloadingDocuments.has(documentName))
|
||||
return this.unloadingDocuments.get(documentName);
|
||||
|
||||
// we need to make sure that the logic runs just once, even if multiple clients disconnect together
|
||||
const actualUnloadingLogic = async () => {
|
||||
try {
|
||||
await this.hooks("beforeUnloadDocument", {
|
||||
instance: this,
|
||||
documentName,
|
||||
document,
|
||||
});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// need sync check here as well, to avoid timing problems
|
||||
if (!this.shouldUnloadDocument(document)) return;
|
||||
|
||||
this.documents.delete(documentName);
|
||||
document.destroy();
|
||||
await this.hooks("afterUnloadDocument", { instance: this, documentName });
|
||||
};
|
||||
|
||||
const unloading = actualUnloadingLogic();
|
||||
|
||||
this.unloadingDocuments.set(documentName, Promise.resolve(unloading));
|
||||
|
||||
await unloading;
|
||||
|
||||
this.unloadingDocuments.delete(documentName);
|
||||
}
|
||||
|
||||
async openDirectConnection(
|
||||
documentName: string,
|
||||
context?: Context,
|
||||
): Promise<DirectConnection<Context>> {
|
||||
const connectionConfig: ConnectionConfiguration = {
|
||||
isAuthenticated: true,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const document: Document = await this.createDocument(
|
||||
documentName,
|
||||
new Request("http://localhost"), // direct connection has no request params
|
||||
crypto.randomUUID(),
|
||||
connectionConfig,
|
||||
context,
|
||||
);
|
||||
|
||||
return new DirectConnection<Context>(document, this, context);
|
||||
}
|
||||
}
|
||||
80
packages/server/src/IncomingMessage.ts
Normal file
80
packages/server/src/IncomingMessage.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Decoder } from "lib0/decoding";
|
||||
import {
|
||||
createDecoder,
|
||||
readVarString,
|
||||
readVarUint,
|
||||
readVarUint8Array,
|
||||
} from "lib0/decoding";
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import {
|
||||
createEncoder,
|
||||
length,
|
||||
toUint8Array,
|
||||
writeVarString,
|
||||
writeVarUint,
|
||||
} from "lib0/encoding";
|
||||
import type { MessageType } from "./types.ts";
|
||||
|
||||
export class IncomingMessage {
|
||||
/**
|
||||
* Access to the received message.
|
||||
*/
|
||||
decoder: Decoder;
|
||||
|
||||
/**
|
||||
* Private encoder; can be undefined.
|
||||
*
|
||||
* Lazy creation of the encoder speeds up IncomingMessages that need only a decoder.
|
||||
*/
|
||||
private encoderInternal?: Encoder;
|
||||
|
||||
constructor(input: any) {
|
||||
if (!(input instanceof Uint8Array)) {
|
||||
input = new Uint8Array(input);
|
||||
}
|
||||
|
||||
this.decoder = createDecoder(input);
|
||||
}
|
||||
|
||||
get encoder() {
|
||||
if (!this.encoderInternal) {
|
||||
this.encoderInternal = createEncoder();
|
||||
}
|
||||
return this.encoderInternal;
|
||||
}
|
||||
|
||||
readVarUint8Array() {
|
||||
return readVarUint8Array(this.decoder);
|
||||
}
|
||||
|
||||
peekVarUint8Array() {
|
||||
const { pos } = this.decoder;
|
||||
const result = readVarUint8Array(this.decoder);
|
||||
this.decoder.pos = pos;
|
||||
return result;
|
||||
}
|
||||
|
||||
readVarUint() {
|
||||
return readVarUint(this.decoder);
|
||||
}
|
||||
|
||||
readVarString() {
|
||||
return readVarString(this.decoder);
|
||||
}
|
||||
|
||||
toUint8Array() {
|
||||
return toUint8Array(this.encoder);
|
||||
}
|
||||
|
||||
writeVarUint(type: MessageType) {
|
||||
writeVarUint(this.encoder, type);
|
||||
}
|
||||
|
||||
writeVarString(string: string) {
|
||||
writeVarString(this.encoder, string);
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return length(this.encoder);
|
||||
}
|
||||
}
|
||||
304
packages/server/src/MessageReceiver.ts
Normal file
304
packages/server/src/MessageReceiver.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { AuthMessageType } from "@hocuspocus/common";
|
||||
import * as decoding from "lib0/decoding";
|
||||
import { readVarString } from "lib0/decoding";
|
||||
import {
|
||||
Awareness,
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate,
|
||||
} from "y-protocols/awareness";
|
||||
import {
|
||||
messageYjsSyncStep1,
|
||||
messageYjsSyncStep2,
|
||||
messageYjsUpdate,
|
||||
readSyncStep1,
|
||||
readSyncStep2,
|
||||
readUpdate,
|
||||
} from "y-protocols/sync";
|
||||
import * as Y from "yjs";
|
||||
import type Connection from "./Connection.ts";
|
||||
import type Document from "./Document.ts";
|
||||
import type { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import {
|
||||
MessageType,
|
||||
type ConnectionTransactionOrigin,
|
||||
type TransactionOrigin,
|
||||
} from "./types.ts";
|
||||
|
||||
export class MessageReceiver {
|
||||
message: IncomingMessage;
|
||||
|
||||
defaultTransactionOrigin?: TransactionOrigin;
|
||||
|
||||
constructor(
|
||||
message: IncomingMessage,
|
||||
defaultTransactionOrigin?: TransactionOrigin,
|
||||
) {
|
||||
this.message = message;
|
||||
this.defaultTransactionOrigin = defaultTransactionOrigin;
|
||||
}
|
||||
|
||||
public async apply(
|
||||
document: Document,
|
||||
connection?: Connection,
|
||||
reply?: (message: Uint8Array) => void,
|
||||
) {
|
||||
const { message } = this;
|
||||
const type = message.readVarUint();
|
||||
const emptyMessageLength = message.length;
|
||||
|
||||
switch (type) {
|
||||
case MessageType.Sync:
|
||||
case MessageType.SyncReply: {
|
||||
message.writeVarUint(MessageType.Sync);
|
||||
await this.readSyncMessage(
|
||||
message,
|
||||
document,
|
||||
connection,
|
||||
reply,
|
||||
type !== MessageType.SyncReply,
|
||||
);
|
||||
|
||||
if (message.length > emptyMessageLength + 1) {
|
||||
if (reply) {
|
||||
reply(message.toUint8Array());
|
||||
} else if (connection) {
|
||||
connection.send(message.toUint8Array());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.Awareness: {
|
||||
let update = message.readVarUint8Array();
|
||||
|
||||
const origin: TransactionOrigin = connection
|
||||
? ({
|
||||
source: "connection",
|
||||
connection,
|
||||
} satisfies ConnectionTransactionOrigin)
|
||||
: (this.defaultTransactionOrigin ?? { source: "local" });
|
||||
|
||||
// Decode the inbound update into a scratch Awareness so the hook
|
||||
// chain sees a high-level Map<clientId, state>. Mutations to that
|
||||
// map (including `set`, `delete`, and field changes on each state
|
||||
// object) are picked up by the re-encode below and forwarded as
|
||||
// the broadcast payload. Hooks may also throw to reject the
|
||||
// update entirely.
|
||||
const scratchDoc = new Y.Doc();
|
||||
const scratch = new Awareness(scratchDoc);
|
||||
try {
|
||||
applyAwarenessUpdate(scratch, update, null);
|
||||
|
||||
await document.callbacks.beforeHandleAwareness(
|
||||
document,
|
||||
scratch.getStates(),
|
||||
origin,
|
||||
);
|
||||
|
||||
update = encodeAwarenessUpdate(scratch, [
|
||||
...scratch.getStates().keys(),
|
||||
]);
|
||||
} finally {
|
||||
scratch.destroy();
|
||||
scratchDoc.destroy();
|
||||
}
|
||||
|
||||
applyAwarenessUpdate(document.awareness, update, origin);
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.QueryAwareness: {
|
||||
this.applyQueryAwarenessMessage(document, connection, reply);
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.Stateless: {
|
||||
connection?.callbacks.statelessCallback({
|
||||
connection,
|
||||
documentName: document.name,
|
||||
document,
|
||||
payload: readVarString(message.decoder),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.BroadcastStateless: {
|
||||
// Server-internal opcode used by @hocuspocus/extension-redis to
|
||||
// fan a stateless payload across server instances. The Redis path
|
||||
// invokes MessageReceiver without a `connection`, so a defined
|
||||
// `connection` here means this frame came from a WebSocket client
|
||||
// — which is never legitimate. Clients must use MessageType.Stateless
|
||||
// (opcode 5); the onStateless hook is the authorization point and
|
||||
// may call Document.broadcastStateless() to fan out if appropriate.
|
||||
if (connection) {
|
||||
throw new Error(
|
||||
"BroadcastStateless is a server-internal opcode and cannot be sent from a client",
|
||||
);
|
||||
}
|
||||
const msg = message.readVarString();
|
||||
// `relayStateless`, not `broadcastStateless`: the originating
|
||||
// instance already ran the beforeBroadcastStateless hook. It shares
|
||||
// one encoded buffer across connections instead of encoding per
|
||||
// connection, which matters here because this path fans out on
|
||||
// every instance of a cluster.
|
||||
document.relayStateless(msg);
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageType.CLOSE: {
|
||||
connection?.close({
|
||||
code: 1000,
|
||||
reason: "provider_initiated",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageType.Auth: {
|
||||
const authType = message.readVarUint();
|
||||
if (authType === AuthMessageType.Token) {
|
||||
connection?.callbacks.onTokenSyncCallback({
|
||||
token: message.readVarString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
console.error(
|
||||
"Received an authentication message on a connection that is already fully authenticated. Probably your provider has been destroyed + recreated really fast.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unable to handle message of type ${type}: no handler defined! Are your provider/server versions aligned?`,
|
||||
);
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
async readSyncMessage(
|
||||
message: IncomingMessage,
|
||||
document: Document,
|
||||
connection?: Connection,
|
||||
reply?: (message: Uint8Array) => void,
|
||||
requestFirstSync = true,
|
||||
) {
|
||||
const type = message.readVarUint();
|
||||
const messageAddress = connection?.messageAddress ?? document.name;
|
||||
|
||||
if (connection) {
|
||||
await connection.callbacks.beforeSync(connection, {
|
||||
type,
|
||||
payload: message.peekVarUint8Array(),
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case messageYjsSyncStep1: {
|
||||
readSyncStep1(message.decoder, message.encoder, document);
|
||||
|
||||
// When the server receives SyncStep1, it should reply with SyncStep2 immediately followed by SyncStep1.
|
||||
if (reply && requestFirstSync) {
|
||||
const syncMessage = new OutgoingMessage(messageAddress)
|
||||
.createSyncReplyMessage()
|
||||
.writeFirstSyncStepFor(document);
|
||||
|
||||
reply(syncMessage.toUint8Array());
|
||||
} else if (connection) {
|
||||
const syncMessage = new OutgoingMessage(messageAddress)
|
||||
.createSyncMessage()
|
||||
.writeFirstSyncStepFor(document);
|
||||
|
||||
connection.send(syncMessage.toUint8Array());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case messageYjsSyncStep2: {
|
||||
if (connection?.readOnly) {
|
||||
// We're in read-only mode, so we can't apply the update.
|
||||
// Let's use snapshotContainsUpdate to see if the update actually contains changes.
|
||||
// If not, we can still ack the update
|
||||
const snapshot = Y.snapshot(document);
|
||||
const update = decoding.readVarUint8Array(message.decoder);
|
||||
if (Y.snapshotContainsUpdate(snapshot, update)) {
|
||||
// no new changes in update
|
||||
const ackMessage = new OutgoingMessage(
|
||||
messageAddress,
|
||||
).writeSyncStatus(true);
|
||||
|
||||
connection.send(ackMessage.toUint8Array());
|
||||
} else {
|
||||
// new changes in update that we can't apply, because readOnly
|
||||
const ackMessage = new OutgoingMessage(
|
||||
messageAddress,
|
||||
).writeSyncStatus(false);
|
||||
|
||||
connection.send(ackMessage.toUint8Array());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
readSyncStep2(
|
||||
message.decoder,
|
||||
document,
|
||||
connection
|
||||
? { source: "connection" as const, connection }
|
||||
: (this.defaultTransactionOrigin ?? { source: "local" as const }),
|
||||
);
|
||||
|
||||
if (connection) {
|
||||
connection.send(
|
||||
new OutgoingMessage(messageAddress)
|
||||
.writeSyncStatus(true)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case messageYjsUpdate: {
|
||||
if (connection?.readOnly) {
|
||||
connection.send(
|
||||
new OutgoingMessage(messageAddress)
|
||||
.writeSyncStatus(false)
|
||||
.toUint8Array(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
readUpdate(
|
||||
message.decoder,
|
||||
document,
|
||||
connection
|
||||
? { source: "connection" as const, connection }
|
||||
: (this.defaultTransactionOrigin ?? { source: "local" as const }),
|
||||
);
|
||||
if (connection) {
|
||||
connection.send(
|
||||
new OutgoingMessage(messageAddress)
|
||||
.writeSyncStatus(true)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Received a message with an unknown type: ${type}`);
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
applyQueryAwarenessMessage(
|
||||
document: Document,
|
||||
connection?: Connection,
|
||||
reply?: (message: Uint8Array) => void,
|
||||
) {
|
||||
const message = new OutgoingMessage(
|
||||
connection?.messageAddress ?? document.name,
|
||||
).createAwarenessUpdateMessage(document.awareness);
|
||||
|
||||
if (reply) {
|
||||
reply(message.toUint8Array());
|
||||
}
|
||||
}
|
||||
}
|
||||
159
packages/server/src/OutgoingMessage.ts
Normal file
159
packages/server/src/OutgoingMessage.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import {
|
||||
createEncoder,
|
||||
toUint8Array,
|
||||
writeVarString,
|
||||
writeVarUint,
|
||||
writeVarUint8Array,
|
||||
} from "lib0/encoding";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import { encodeAwarenessUpdate } from "y-protocols/awareness";
|
||||
import { writeSyncStep1, writeUpdate } from "y-protocols/sync";
|
||||
|
||||
import { writeAuthenticated, writePermissionDenied, writeTokenSyncRequest } from "@hocuspocus/common";
|
||||
import type Document from "./Document.ts";
|
||||
import { MessageType } from "./types.ts";
|
||||
|
||||
export class OutgoingMessage {
|
||||
encoder: Encoder;
|
||||
|
||||
type?: number;
|
||||
|
||||
category?: string;
|
||||
|
||||
constructor(documentName: string) {
|
||||
this.encoder = createEncoder();
|
||||
|
||||
writeVarString(this.encoder, documentName);
|
||||
}
|
||||
|
||||
createSyncMessage(): OutgoingMessage {
|
||||
this.type = MessageType.Sync;
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Sync);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
createSyncReplyMessage(): OutgoingMessage {
|
||||
this.type = MessageType.SyncReply;
|
||||
|
||||
writeVarUint(this.encoder, MessageType.SyncReply);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
createAwarenessUpdateMessage(
|
||||
awareness: Awareness,
|
||||
changedClients?: Array<any>,
|
||||
): OutgoingMessage {
|
||||
this.type = MessageType.Awareness;
|
||||
this.category = "Update";
|
||||
|
||||
const message = encodeAwarenessUpdate(
|
||||
awareness,
|
||||
changedClients || Array.from(awareness.getStates().keys()),
|
||||
);
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Awareness);
|
||||
writeVarUint8Array(this.encoder, message);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeQueryAwareness(): OutgoingMessage {
|
||||
this.type = MessageType.QueryAwareness;
|
||||
this.category = "Update";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.QueryAwareness);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeTokenSyncRequest(): OutgoingMessage {
|
||||
this.type = MessageType.Auth;
|
||||
this.category = "TokenSync";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Auth);
|
||||
writeTokenSyncRequest(this.encoder);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeAuthenticated(readonly: boolean): OutgoingMessage {
|
||||
this.type = MessageType.Auth;
|
||||
this.category = "Authenticated";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Auth);
|
||||
writeAuthenticated(this.encoder, readonly ? "readonly" : "read-write");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writePermissionDenied(reason: string): OutgoingMessage {
|
||||
this.type = MessageType.Auth;
|
||||
this.category = "PermissionDenied";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Auth);
|
||||
writePermissionDenied(this.encoder, reason);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeFirstSyncStepFor(document: Document): OutgoingMessage {
|
||||
this.category = "SyncStep1";
|
||||
|
||||
writeSyncStep1(this.encoder, document);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeUpdate(update: Uint8Array): OutgoingMessage {
|
||||
this.category = "Update";
|
||||
|
||||
writeUpdate(this.encoder, update);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeStateless(payload: string): OutgoingMessage {
|
||||
this.category = "Stateless";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Stateless);
|
||||
writeVarString(this.encoder, payload);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeBroadcastStateless(payload: string): OutgoingMessage {
|
||||
this.category = "Stateless";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.BroadcastStateless);
|
||||
writeVarString(this.encoder, payload);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// TODO: should this be write* or create* as method name?
|
||||
writeSyncStatus(updateSaved: boolean): OutgoingMessage {
|
||||
this.category = "SyncStatus";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.SyncStatus);
|
||||
writeVarUint(this.encoder, updateSaved ? 1 : 0);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeCloseMessage(reason: string): OutgoingMessage {
|
||||
this.type = MessageType.CLOSE;
|
||||
|
||||
writeVarUint(this.encoder, MessageType.CLOSE);
|
||||
writeVarString(this.encoder, reason);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toUint8Array(): Uint8Array {
|
||||
return toUint8Array(this.encoder);
|
||||
}
|
||||
}
|
||||
285
packages/server/src/Server.ts
Normal file
285
packages/server/src/Server.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
Server as HTTPServer,
|
||||
IncomingMessage,
|
||||
ServerResponse,
|
||||
} from "node:http";
|
||||
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 meta from "../package.json" assert { type: "json" };
|
||||
import { Hocuspocus, defaultConfiguration } from "./Hocuspocus.ts";
|
||||
import type { Configuration, WebSocketLike, onListenPayload } from "./types.ts";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export const defaultServerConfiguration = {
|
||||
port: 80,
|
||||
stopOnSignals: true,
|
||||
};
|
||||
|
||||
export class Server<Context = any> {
|
||||
httpServer: HTTPServer;
|
||||
|
||||
private crossws: ReturnType<typeof crossws>;
|
||||
|
||||
hocuspocus: Hocuspocus<Context>;
|
||||
|
||||
configuration: ServerConfiguration<Context> = {
|
||||
...defaultConfiguration,
|
||||
...defaultServerConfiguration,
|
||||
extensions: [],
|
||||
};
|
||||
|
||||
constructor(configuration?: Partial<ServerConfiguration<Context>>) {
|
||||
if (configuration) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
this.hocuspocus = new Hocuspocus(this.configuration);
|
||||
this.hocuspocus.server = this;
|
||||
|
||||
this.httpServer = createServer(this.requestHandler);
|
||||
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.setupHttpUpgrade();
|
||||
}
|
||||
|
||||
private setupHttpUpgrade = () => {
|
||||
this.httpServer.on("upgrade", async (request, socket, head) => {
|
||||
try {
|
||||
await this.hocuspocus.hooks("onUpgrade", {
|
||||
request,
|
||||
socket,
|
||||
head,
|
||||
instance: this.hocuspocus,
|
||||
});
|
||||
|
||||
// Let crossws handle the WebSocket upgrade
|
||||
this.crossws.handleUpgrade(request, socket, head);
|
||||
} catch (error) {
|
||||
// if a hook rejects and the error is empty, do nothing
|
||||
// this is only meant to prevent later hooks and the
|
||||
// default handler to do something. if a error is present
|
||||
// just rethrow it
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestHandler = async (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => {
|
||||
try {
|
||||
await this.hocuspocus.hooks("onRequest", {
|
||||
request,
|
||||
response,
|
||||
instance: this.hocuspocus,
|
||||
});
|
||||
|
||||
// default response if all prior hooks don't interfere
|
||||
response.writeHead(200, { "Content-Type": "text/plain" });
|
||||
response.end("Welcome to Hocuspocus!");
|
||||
} catch (error) {
|
||||
// if a hook rejects and the error is empty, do nothing
|
||||
// this is only meant to prevent later hooks and the
|
||||
// default handler to do something. if a error is present
|
||||
// just rethrow it
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async listen(
|
||||
port?: number,
|
||||
callback: any = null,
|
||||
): Promise<Hocuspocus<Context>> {
|
||||
if (port) {
|
||||
this.configuration.port = port;
|
||||
}
|
||||
|
||||
if (typeof callback === "function") {
|
||||
this.hocuspocus.configuration.extensions.push({
|
||||
onListen: callback,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.configuration.stopOnSignals) {
|
||||
const signalHandler = async () => {
|
||||
await this.destroy();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", signalHandler);
|
||||
process.on("SIGQUIT", signalHandler);
|
||||
process.on("SIGTERM", signalHandler);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return new Promise((resolve: Function, reject: Function) => {
|
||||
const listenOptions: ListenOptions = {
|
||||
port: this.configuration.port,
|
||||
host: this.configuration.address,
|
||||
};
|
||||
|
||||
this.httpServer.listen(
|
||||
listenOptions,
|
||||
async () => {
|
||||
if (
|
||||
!this.configuration.quiet &&
|
||||
String(process.env.NODE_ENV) !== "testing"
|
||||
) {
|
||||
this.showStartScreen();
|
||||
}
|
||||
|
||||
const onListenPayload = {
|
||||
instance: this.hocuspocus,
|
||||
configuration: this.configuration,
|
||||
port: this.address.port,
|
||||
} as onListenPayload;
|
||||
|
||||
try {
|
||||
await this.hocuspocus.hooks("onListen", onListenPayload);
|
||||
resolve(this.hocuspocus);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get address(): AddressInfo {
|
||||
return (this.httpServer.address() || {
|
||||
port: this.configuration.port,
|
||||
address: this.configuration.address ?? "0.0.0.0",
|
||||
family: "IPv4",
|
||||
}) as AddressInfo;
|
||||
}
|
||||
|
||||
private destroyPromise?: Promise<void>;
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
// Guard against multiple shutdowns (e.g. repeated SIGINT signals).
|
||||
// Subsequent callers await the same in-flight shutdown rather than
|
||||
// re-running close hooks on already-closed resources.
|
||||
this.destroyPromise ??= this.runDestroy();
|
||||
|
||||
return this.destroyPromise;
|
||||
}
|
||||
|
||||
private async runDestroy(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.httpServer.close();
|
||||
|
||||
try {
|
||||
this.configuration.extensions.push({
|
||||
async afterUnloadDocument({ instance }) {
|
||||
if (instance.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) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
await this.hocuspocus.hooks("onDestroy", { instance: this.hocuspocus });
|
||||
}
|
||||
|
||||
get URL(): string {
|
||||
return `${this.configuration.address ?? "0.0.0.0"}:${this.address.port}`;
|
||||
}
|
||||
|
||||
get webSocketURL(): string {
|
||||
return `ws://${this.URL}`;
|
||||
}
|
||||
|
||||
get httpURL(): string {
|
||||
return `http://${this.URL}`;
|
||||
}
|
||||
|
||||
private showStartScreen() {
|
||||
const name = this.configuration.name ? ` (${this.configuration.name})` : "";
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
` ${kleur.cyan(`Hocuspocus v${meta.version}${name}`)}${kleur.green(" running at:")}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
console.log(` > HTTP: ${kleur.cyan(`${this.httpURL}`)}`);
|
||||
console.log(` > WebSocket: ${this.webSocketURL}`);
|
||||
|
||||
const extensions = this.configuration?.extensions
|
||||
.map((extension) => {
|
||||
return extension.extensionName ?? extension.constructor?.name;
|
||||
})
|
||||
.filter((name) => name)
|
||||
.filter((name) => name !== "Object");
|
||||
|
||||
if (!extensions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" Extensions:");
|
||||
|
||||
extensions.forEach((name) => {
|
||||
console.log(` - ${name}`);
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ${kleur.green("Ready.")}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
9
packages/server/src/index.ts
Normal file
9
packages/server/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./Connection.ts";
|
||||
export * from "./Document.ts";
|
||||
export * from "./Hocuspocus.ts";
|
||||
export * from "./IncomingMessage.ts";
|
||||
export * from "./MessageReceiver.ts";
|
||||
export * from "./OutgoingMessage.ts";
|
||||
export * from "./Server.ts";
|
||||
export * from "./types.ts";
|
||||
export * from "./util/debounce.ts";
|
||||
619
packages/server/src/types.ts
Normal file
619
packages/server/src/types.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type Connection from "./Connection.ts";
|
||||
import type Document from "./Document.ts";
|
||||
import type { Hocuspocus } from "./Hocuspocus.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 {
|
||||
Unknown = -1,
|
||||
Sync = 0,
|
||||
Awareness = 1,
|
||||
Auth = 2,
|
||||
QueryAwareness = 3,
|
||||
SyncReply = 4, // same as Sync, but won't trigger another 'SyncStep1'
|
||||
Stateless = 5,
|
||||
BroadcastStateless = 6,
|
||||
CLOSE = 7,
|
||||
SyncStatus = 8,
|
||||
Ping = 9,
|
||||
Pong = 10,
|
||||
}
|
||||
|
||||
export interface AwarenessUpdate {
|
||||
added: Array<any>;
|
||||
updated: Array<any>;
|
||||
removed: Array<any>;
|
||||
}
|
||||
|
||||
export interface ConnectionConfiguration {
|
||||
readOnly: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export interface Extension<Context = any> {
|
||||
priority?: number;
|
||||
extensionName?: string;
|
||||
onConfigure?(data: onConfigurePayload): Promise<any>;
|
||||
onListen?(data: onListenPayload): Promise<any>;
|
||||
onUpgrade?(data: onUpgradePayload): 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>;
|
||||
/**
|
||||
* Fired after an inbound message has been handled (i.e. after the Yjs
|
||||
* update has been applied to the document). Fires whether the apply
|
||||
* resolved or threw, but not when `beforeHandleMessage` rejected the
|
||||
* message.
|
||||
*/
|
||||
afterHandleMessage?(data: afterHandleMessagePayload<Context>): Promise<any>;
|
||||
/**
|
||||
* Fired before an inbound awareness update is applied to the document's
|
||||
* awareness state. The hook receives the decoded per-client `states` as a
|
||||
* mutable `Map` keyed by Yjs clientId. Mutate the map and the contained
|
||||
* state objects in place to rewrite fields, drop peers (`states.delete`),
|
||||
* or add synthetic ones (`states.set`); mutations are reflected in the
|
||||
* broadcast. Throw to reject the update without applying anything.
|
||||
*
|
||||
* Multiple extensions chain naturally: each extension sees the map as
|
||||
* mutated by previous extensions and can mutate it further.
|
||||
*/
|
||||
beforeHandleAwareness?(
|
||||
data: beforeHandleAwarenessPayload<Context>,
|
||||
): Promise<any>;
|
||||
beforeSync?(data: beforeSyncPayload<Context>): Promise<any>;
|
||||
beforeBroadcastStateless?(
|
||||
data: beforeBroadcastStatelessPayload,
|
||||
): Promise<any>;
|
||||
onStateless?(payload: onStatelessPayload): 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<Context>): Promise<any>;
|
||||
beforeUnloadDocument?(data: beforeUnloadDocumentPayload): Promise<any>;
|
||||
afterUnloadDocument?(data: afterUnloadDocumentPayload): Promise<any>;
|
||||
onDestroy?(data: onDestroyPayload): Promise<any>;
|
||||
}
|
||||
|
||||
export type HookName =
|
||||
| "onConfigure"
|
||||
| "onListen"
|
||||
| "onUpgrade"
|
||||
| "onConnect"
|
||||
| "connected"
|
||||
| "onAuthenticate"
|
||||
| "onTokenSync"
|
||||
| "onCreateDocument"
|
||||
| "onLoadDocument"
|
||||
| "afterLoadDocument"
|
||||
| "beforeHandleMessage"
|
||||
| "afterHandleMessage"
|
||||
| "beforeHandleAwareness"
|
||||
| "beforeBroadcastStateless"
|
||||
| "beforeSync"
|
||||
| "onStateless"
|
||||
| "onChange"
|
||||
| "onStoreDocument"
|
||||
| "afterStoreDocument"
|
||||
| "onAwarenessUpdate"
|
||||
| "onRequest"
|
||||
| "onDisconnect"
|
||||
| "beforeUnloadDocument"
|
||||
| "afterUnloadDocument"
|
||||
| "onDestroy";
|
||||
|
||||
export type HookPayloadByName<Context = any> = {
|
||||
onConfigure: onConfigurePayload;
|
||||
onListen: onListenPayload;
|
||||
onUpgrade: onUpgradePayload;
|
||||
onConnect: onConnectPayload<Context>;
|
||||
connected: connectedPayload<Context>;
|
||||
onAuthenticate: onAuthenticatePayload<Context>;
|
||||
onTokenSync: onTokenSyncPayload<Context>;
|
||||
onCreateDocument: onCreateDocumentPayload<Context>;
|
||||
onLoadDocument: onLoadDocumentPayload<Context>;
|
||||
afterLoadDocument: afterLoadDocumentPayload<Context>;
|
||||
beforeHandleMessage: beforeHandleMessagePayload<Context>;
|
||||
afterHandleMessage: afterHandleMessagePayload<Context>;
|
||||
beforeHandleAwareness: beforeHandleAwarenessPayload<Context>;
|
||||
beforeBroadcastStateless: beforeBroadcastStatelessPayload;
|
||||
beforeSync: beforeSyncPayload<Context>;
|
||||
onStateless: onStatelessPayload;
|
||||
onChange: onChangePayload<Context>;
|
||||
onStoreDocument: onStoreDocumentPayload<Context>;
|
||||
afterStoreDocument: afterStoreDocumentPayload<Context>;
|
||||
onAwarenessUpdate: onAwarenessUpdatePayload<Context>;
|
||||
onRequest: onRequestPayload;
|
||||
onDisconnect: onDisconnectPayload<Context>;
|
||||
afterUnloadDocument: afterUnloadDocumentPayload;
|
||||
beforeUnloadDocument: beforeUnloadDocumentPayload;
|
||||
onDestroy: onDestroyPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Controls how a document batches its outgoing broadcasts. Shared by the server
|
||||
* configuration and the `Document` constructor so both stay in step.
|
||||
*/
|
||||
export interface FlushOptions {
|
||||
/**
|
||||
* Batch outgoing broadcasts per document over a window of this many
|
||||
* milliseconds, instead of sending one message per connection per change.
|
||||
* Updates within a window are merged into a single message and awareness
|
||||
* collapses to the latest state of each changed client, so a document with
|
||||
* many connected clients sends `connections` messages per window rather than
|
||||
* `changes × connections`.
|
||||
*
|
||||
* The default `0` flushes at the end of the current event loop turn. It adds
|
||||
* no delay — a burst of updates that arrived together is coalesced before
|
||||
* anything is written to a socket — and with a single writer no merge happens
|
||||
* at all, because there is only one buffered update to send. On a document
|
||||
* with many writing clients the difference is large: 2000 clients each
|
||||
* writing once produces 2000 broadcast messages per turn instead of
|
||||
* 2000 × 2000.
|
||||
*
|
||||
* A positive value additionally merges across turns, trading up to that much
|
||||
* latency for fewer messages. That is only a win when several changes
|
||||
* genuinely land per window — with a handful of clients typing it is pure
|
||||
* added delay, so prefer `0` unless you have measured otherwise.
|
||||
*
|
||||
* `false` restores the pre-batching behaviour and sends every broadcast
|
||||
* synchronously, inside the same tick that applied the change.
|
||||
*
|
||||
* Applies to the WebSocket fan-out only. `onChange`, the `onStoreDocument`
|
||||
* debounce and the Redis publish still run synchronously per change.
|
||||
*
|
||||
* @default 0 (coalesce within the current event loop turn)
|
||||
*/
|
||||
flushDelay: false | number;
|
||||
|
||||
/**
|
||||
* Flush a document's batched broadcasts early once this many bytes of updates
|
||||
* are buffered, bounding both the memory held per window and the cost of the
|
||||
* merge. Ignored when `flushDelay` is false.
|
||||
*
|
||||
* @default 1_048_576 (1 MiB)
|
||||
*/
|
||||
flushMaxBytes: number;
|
||||
}
|
||||
|
||||
export interface Configuration<Context = any>
|
||||
extends Extension<Context>,
|
||||
FlushOptions {
|
||||
/**
|
||||
* A name for the instance, used for logging.
|
||||
*/
|
||||
name: string | null;
|
||||
/**
|
||||
* A list of hocuspocus extensions.
|
||||
*/
|
||||
extensions: Array<Extension>;
|
||||
/**
|
||||
* Defines in which interval the server sends a ping, and closes the connection when no pong is sent back.
|
||||
*/
|
||||
timeout: number;
|
||||
/**
|
||||
* Debounces the call of the `onStoreDocument` hook for the given amount of time in ms.
|
||||
* Otherwise every single update would be persisted.
|
||||
*/
|
||||
debounce: number;
|
||||
/**
|
||||
* Makes sure to call `onStoreDocument` at least in the given amount of time (ms).
|
||||
*/
|
||||
maxDebounce: number;
|
||||
/**
|
||||
* By default, the servers show a start screen. If passed false, the server will start quietly.
|
||||
*/
|
||||
quiet: boolean;
|
||||
/**
|
||||
* If set to false, respects the debounce time of `onStoreDocument` before unloading a document.
|
||||
* Otherwise, the document will be unloaded immediately.
|
||||
*
|
||||
* This prevents a client from DOSing the server by repeatedly connecting and disconnecting when
|
||||
* your onStoreDocument is rate-limited.
|
||||
*/
|
||||
unloadImmediately: boolean;
|
||||
|
||||
/**
|
||||
* Maximum number of bytes that may be buffered, across all documents, for a single
|
||||
* connection while it is still unauthenticated. Messages received before authentication
|
||||
* completes are queued in memory; without a limit an unauthenticated client could exhaust
|
||||
* server memory (see GHSA-xwhh-v746-pj9m). When the limit is exceeded the connection is closed.
|
||||
*
|
||||
* @default 5_242_880 (5 MiB)
|
||||
*/
|
||||
maxUnauthenticatedQueueSize: number;
|
||||
/**
|
||||
* Maximum number of messages that may be buffered, across all documents, for a single
|
||||
* connection while it is still unauthenticated. When the limit is exceeded the connection
|
||||
* is closed.
|
||||
*
|
||||
* @default 1_000
|
||||
*/
|
||||
maxUnauthenticatedQueueMessages: number;
|
||||
/**
|
||||
* Maximum number of distinct documents a single connection may open before any of them is
|
||||
* authenticated. Each pending document holds its own queue and hook payload, so an
|
||||
* unauthenticated client fanning out across many document names could otherwise exhaust
|
||||
* memory even while staying under the per-connection queue limits. When the limit is
|
||||
* exceeded the connection is closed.
|
||||
*
|
||||
* @default 100
|
||||
*/
|
||||
maxPendingDocuments: number;
|
||||
|
||||
/**
|
||||
* options to pass to the ydoc document
|
||||
*/
|
||||
yDocOptions: {
|
||||
gc: boolean; // enable or disable garbage collection (see https://github.com/yjs/yjs/blob/main/INTERNALS.md#deletions)
|
||||
gcFilter: () => boolean; // will be called before garbage collecting ; return false to keep it
|
||||
};
|
||||
}
|
||||
|
||||
export interface onStatelessPayload {
|
||||
connection: Connection;
|
||||
documentName: string;
|
||||
document: Document;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface onAuthenticatePayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
request: Request;
|
||||
socketId: string;
|
||||
token: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
|
||||
export interface onTokenSyncPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
token: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
connection: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface onCreateDocumentPayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface onConnectPayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
request: Request;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
|
||||
export interface connectedPayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
request: Request;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
connection: Connection<Context>;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
|
||||
export interface onLoadDocumentPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface afterLoadDocumentPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface onChangePayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
update: Uint8Array;
|
||||
socketId: string;
|
||||
transactionOrigin: unknown;
|
||||
connection?: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface beforeHandleMessagePayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
update: Uint8Array;
|
||||
socketId: string;
|
||||
connection: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface afterHandleMessagePayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
update: Uint8Array;
|
||||
socketId: string;
|
||||
connection: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface beforeHandleAwarenessPayload<Context = any> {
|
||||
awareness: Awareness;
|
||||
clientsCount: number;
|
||||
/**
|
||||
* Connection context populated by `onAuthenticate`. `undefined` when the
|
||||
* update did not originate from a client connection (e.g. server-internal
|
||||
* writes via `DirectConnection`).
|
||||
*/
|
||||
context: Context | undefined;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
/**
|
||||
* Per-client awareness states decoded from the inbound update, keyed by
|
||||
* Yjs clientId. Mutate this map in place to rewrite the update: change
|
||||
* fields on a state object, `states.delete(clientId)` to drop a peer, or
|
||||
* `states.set(clientId, ...)` to add or replace one. The encoded update
|
||||
* sent to peers reflects whatever the map looks like after every hook in
|
||||
* the chain has run.
|
||||
*/
|
||||
states: Map<number, Record<string, any>>;
|
||||
socketId: string;
|
||||
/**
|
||||
* The `TransactionOrigin` that will be passed to `applyAwarenessUpdate`.
|
||||
* Use `isTransactionOrigin(origin)` to discriminate sources. Matches the
|
||||
* `transactionOrigin` shape of `onAwarenessUpdatePayload`.
|
||||
*/
|
||||
transactionOrigin: unknown;
|
||||
/**
|
||||
* Convenience shortcut: `origin.connection` when `transactionOrigin` is a
|
||||
* `ConnectionTransactionOrigin`, otherwise `undefined`. Matches the
|
||||
* `connection?` shape of `onAwarenessUpdatePayload`.
|
||||
*/
|
||||
connection?: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface beforeSyncPayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
connection: Connection<Context>;
|
||||
/**
|
||||
* The y-protocols/sync message type
|
||||
* @example
|
||||
* 0: SyncStep1
|
||||
* 1: SyncStep2
|
||||
* 2: YjsUpdate
|
||||
*
|
||||
* @see https://github.com/yjs/y-protocols/blob/master/sync.js#L13-L40
|
||||
*/
|
||||
type: number;
|
||||
/**
|
||||
* The payload of the y-sync message.
|
||||
*/
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface beforeBroadcastStatelessPayload {
|
||||
document: Document;
|
||||
documentName: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface onStoreDocumentPayload<Context = any> {
|
||||
clientsCount: number;
|
||||
document: Document;
|
||||
lastContext: Context;
|
||||
lastTransactionOrigin: unknown;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type
|
||||
export interface afterStoreDocumentPayload<Context = any>
|
||||
extends onStoreDocumentPayload<Context> {}
|
||||
|
||||
export interface onAwarenessUpdatePayload<Context = any> {
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
transactionOrigin: unknown;
|
||||
connection?: Connection<Context>;
|
||||
added: number[];
|
||||
updated: number[];
|
||||
removed: number[];
|
||||
awareness: Awareness;
|
||||
states: StatesArray;
|
||||
}
|
||||
|
||||
export type StatesArray = { clientId: number; [key: string | number]: any }[];
|
||||
|
||||
export interface fetchPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface storePayload<Context = any>
|
||||
extends onStoreDocumentPayload<Context> {
|
||||
state: Buffer;
|
||||
}
|
||||
|
||||
export interface onDisconnectPayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
export interface onRequestPayload {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
export interface onUpgradePayload {
|
||||
request: IncomingMessage;
|
||||
socket: any;
|
||||
head: any;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
export interface onListenPayload {
|
||||
instance: Hocuspocus;
|
||||
configuration: Configuration;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface onDestroyPayload {
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
export interface onConfigurePayload {
|
||||
instance: Hocuspocus;
|
||||
configuration: Configuration;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface afterUnloadDocumentPayload {
|
||||
instance: Hocuspocus;
|
||||
documentName: string;
|
||||
}
|
||||
|
||||
export interface beforeUnloadDocumentPayload {
|
||||
instance: Hocuspocus;
|
||||
documentName: string;
|
||||
document: Document;
|
||||
}
|
||||
|
||||
export interface DisconnectOptions {
|
||||
/**
|
||||
* When `true` (the default) the document is persisted immediately (no debouncing)
|
||||
* and unloaded from memory as the connection closes. When `false` the store is
|
||||
* scheduled via the debounce timer and the document is kept warm in memory, so a
|
||||
* follow-up direct connection reuses it and repeated writes coalesce. With
|
||||
* `false`, persistence is no longer guaranteed by the time `disconnect()`
|
||||
* resolves.
|
||||
*/
|
||||
unloadImmediately?: boolean;
|
||||
}
|
||||
|
||||
export interface DirectConnection {
|
||||
transact(transaction: (document: Document) => void): Promise<void>;
|
||||
disconnect(options?: DisconnectOptions): Promise<void>;
|
||||
}
|
||||
77
packages/server/src/util/debounce.ts
Normal file
77
packages/server/src/util/debounce.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const useDebounce = () => {
|
||||
const timers: Map<
|
||||
string,
|
||||
{
|
||||
timeout: NodeJS.Timeout;
|
||||
start: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
func: () => any | Promise<() => any>;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
const runningExecutions: Map<string, Promise<any>> = new Map();
|
||||
|
||||
const debounce = async (
|
||||
id: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
func: () => any | Promise<() => any>,
|
||||
debounce: number,
|
||||
maxDebounce: number,
|
||||
) => {
|
||||
const old = timers.get(id);
|
||||
const start = old?.start || Date.now();
|
||||
|
||||
const run = async () => {
|
||||
timers.delete(id);
|
||||
|
||||
if (runningExecutions.has(id)) {
|
||||
// wait for previous execution to finish
|
||||
await runningExecutions.get(id);
|
||||
}
|
||||
|
||||
const execution = func();
|
||||
|
||||
runningExecutions.set(id, execution);
|
||||
const executionResult = await execution;
|
||||
runningExecutions.delete(id);
|
||||
|
||||
return executionResult;
|
||||
};
|
||||
|
||||
if (old?.timeout) {
|
||||
clearTimeout(old.timeout);
|
||||
}
|
||||
|
||||
if (debounce === 0) {
|
||||
return run();
|
||||
}
|
||||
|
||||
if (Date.now() - start >= maxDebounce) {
|
||||
return run();
|
||||
}
|
||||
|
||||
timers.set(id, {
|
||||
start,
|
||||
timeout: setTimeout(run, debounce),
|
||||
func: run,
|
||||
});
|
||||
};
|
||||
|
||||
const executeNow = (id: string) => {
|
||||
const old = timers.get(id);
|
||||
if (old) {
|
||||
clearTimeout(old.timeout);
|
||||
return old.func();
|
||||
}
|
||||
};
|
||||
|
||||
const isDebounced = (id: string): boolean => {
|
||||
return timers.has(id);
|
||||
};
|
||||
|
||||
const isCurrentlyExecuting = (id: string): boolean => {
|
||||
return runningExecutions.has(id);
|
||||
};
|
||||
|
||||
return { debounce, isDebounced, isCurrentlyExecuting, executeNow };
|
||||
};
|
||||
14
packages/server/src/util/getParameters.ts
Normal file
14
packages/server/src/util/getParameters.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Get parameters by the given request
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user