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

This commit is contained in:
upstream-mirror
2026-09-15 17:21:35 +05:00
commit e0c588ca1c
265 changed files with 55259 additions and 0 deletions

2176
packages/server/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

107
packages/server/README.md Normal file
View File

@@ -0,0 +1,107 @@
# @hocuspocus/server
[![Version](https://img.shields.io/npm/v/@hocuspocus/server.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/server)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/server.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/server.svg)](https://www.npmjs.com/package/@hocuspocus/server)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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).

View 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"
}
}

View 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);
}
};
}

View 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;

View 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;
}
}
}

View 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;

View 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);
}
}

View 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);
}
}

View 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());
}
}
}

View 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);
}
}

View 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();
}
}

View 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";

View 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>;
}

View 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 };
};

View 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;
}