snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
This commit is contained in:
1273
packages/cli/CHANGELOG.md
Normal file
1273
packages/cli/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/cli/README.md
Normal file
18
packages/cli/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @hocuspocus/cli
|
||||
[](https://www.npmjs.com/package/@hocuspocus/cli)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/cli)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
A CLI tool to start a local [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server – useful for quickly spinning up a collaboration backend for development, demos, or simple deployments, with optional SQLite, S3, logger, and webhook extensions.
|
||||
|
||||
```bash
|
||||
npx @hocuspocus/cli
|
||||
```
|
||||
|
||||
## Official Documentation
|
||||
Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus).
|
||||
|
||||
## License
|
||||
Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
35
packages/cli/package.json
Normal file
35
packages/cli/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@hocuspocus/cli",
|
||||
"description": "a CLI tool to start a local Hocuspocus server",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"yjs",
|
||||
"yjs-websocket"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"hocuspocus": "./src/index.js",
|
||||
"@hocuspocus/cli": "./src/index.js"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-logger": "workspace:^",
|
||||
"@hocuspocus/extension-s3": "workspace:^",
|
||||
"@hocuspocus/extension-sqlite": "workspace:^",
|
||||
"@hocuspocus/extension-webhook": "workspace:^",
|
||||
"@hocuspocus/server": "workspace:^",
|
||||
"meow": "^14.0.0"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
150
packages/cli/src/index.js
Executable file
150
packages/cli/src/index.js
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Logger } from "@hocuspocus/extension-logger";
|
||||
import { S3 } from "@hocuspocus/extension-s3";
|
||||
import { SQLite } from "@hocuspocus/extension-sqlite";
|
||||
import { Webhook } from "@hocuspocus/extension-webhook";
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import meow from "meow";
|
||||
|
||||
export const cli = meow(
|
||||
`
|
||||
Usage
|
||||
$ hocuspocus [options]
|
||||
|
||||
Options
|
||||
--port=, -p Set the port, defaults to 1234.
|
||||
--webhook=, -w Configure a custom webhook.
|
||||
--sqlite=, -s Store data in a SQLite database, defaults to :memory:.
|
||||
--s3 Store data in S3 or S3-compatible storage.
|
||||
--s3-bucket= S3 bucket name (required when using --s3).
|
||||
--s3-region= S3 region, defaults to us-east-1.
|
||||
--s3-prefix= S3 key prefix for documents.
|
||||
--s3-endpoint= S3 endpoint URL (for S3-compatible services like MinIO).
|
||||
--version Show the current version number.
|
||||
|
||||
Examples
|
||||
$ hocuspocus --port 8080
|
||||
$ hocuspocus --webhook http://localhost/webhooks/hocuspocus
|
||||
$ hocuspocus --sqlite
|
||||
$ hocuspocus --sqlite database/default.sqlite
|
||||
$ hocuspocus --s3 --s3-bucket my-docs
|
||||
$ hocuspocus --s3 --s3-bucket my-docs --s3-region eu-west-1
|
||||
$ hocuspocus --s3 --s3-bucket my-docs --s3-endpoint http://localhost:9000
|
||||
|
||||
Environment Variables (for S3)
|
||||
AWS_ACCESS_KEY_ID AWS access key ID
|
||||
AWS_SECRET_ACCESS_KEY AWS secret access key
|
||||
AWS_REGION AWS region (alternative to --s3-region)
|
||||
`,
|
||||
{
|
||||
importMeta: import.meta,
|
||||
flags: {
|
||||
port: {
|
||||
type: "string",
|
||||
shortFlag: "p",
|
||||
default: "1234",
|
||||
},
|
||||
webhook: {
|
||||
type: "string",
|
||||
shortFlag: "w",
|
||||
default: "",
|
||||
},
|
||||
sqlite: {
|
||||
type: "string",
|
||||
shortFlag: "s",
|
||||
default: "",
|
||||
},
|
||||
s3: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
s3Bucket: {
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
s3Region: {
|
||||
type: "string",
|
||||
default: "us-east-1",
|
||||
},
|
||||
s3Prefix: {
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
s3Endpoint: {
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getConfiguredWebhookExtension = () => {
|
||||
return cli.flags.webhook
|
||||
? new Webhook({
|
||||
url: cli.flags.webhook,
|
||||
})
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export const getConfiguredSQLiteExtension = () => {
|
||||
if (cli.flags.sqlite) {
|
||||
return new SQLite({
|
||||
database: cli.flags.sqlite,
|
||||
});
|
||||
}
|
||||
if (process.argv.includes("--sqlite")) {
|
||||
return new SQLite();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getConfiguredS3Extension = () => {
|
||||
if (!cli.flags.s3) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bucket = cli.flags.s3Bucket || process.env.S3_BUCKET;
|
||||
if (!bucket) {
|
||||
console.error(
|
||||
"❌ S3 bucket is required. Use --s3-bucket or set S3_BUCKET environment variable.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = {
|
||||
bucket,
|
||||
region: cli.flags.s3Region || process.env.AWS_REGION || "us-east-1",
|
||||
};
|
||||
|
||||
if (cli.flags.s3Prefix) {
|
||||
config.prefix = cli.flags.s3Prefix;
|
||||
}
|
||||
|
||||
if (cli.flags.s3Endpoint) {
|
||||
config.endpoint = cli.flags.s3Endpoint;
|
||||
config.forcePathStyle = true;
|
||||
}
|
||||
|
||||
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
config.credentials = {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
return new S3(config);
|
||||
};
|
||||
|
||||
const server = new Server({
|
||||
port: Number.parseInt(cli.flags.port, 10),
|
||||
extensions: [
|
||||
new Logger(),
|
||||
getConfiguredWebhookExtension(),
|
||||
getConfiguredSQLiteExtension(),
|
||||
getConfiguredS3Extension(),
|
||||
].filter((extension) => extension),
|
||||
});
|
||||
|
||||
server.listen();
|
||||
1195
packages/common/CHANGELOG.md
Normal file
1195
packages/common/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/common/README.md
Normal file
14
packages/common/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @hocuspocus/common
|
||||
[](https://www.npmjs.com/package/@hocuspocus/common)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/common)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
Shared types and utilities used across [Hocuspocus](https://github.com/ueberdosis/hocuspocus) packages – including message/sync protocol helpers and common enums. This package is an internal building block; most users will depend on `@hocuspocus/server` or `@hocuspocus/provider` instead.
|
||||
|
||||
## Official Documentation
|
||||
Documentation can be found in the [GitHub repository](https://github.com/ueberdosis/hocuspocus).
|
||||
|
||||
## License
|
||||
Hocuspocus is open-sourced software licensed under the [MIT license](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
38
packages/common/package.json
Normal file
38
packages/common/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@hocuspocus/common",
|
||||
"description": "shared code for multiple Hocuspocus packages",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-common.cjs",
|
||||
"module": "dist/hocuspocus-common.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-common.esm.js",
|
||||
"require": "./dist/hocuspocus-common.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"gitHead": "cd788b6a315f608ef531524409abdce1e6790726",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.117"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
49
packages/common/src/CloseEvents.ts
Normal file
49
packages/common/src/CloseEvents.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface CloseEvent {
|
||||
code: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server is terminating the connection because a data frame was received
|
||||
* that is too large.
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
||||
*/
|
||||
export const MessageTooBig: CloseEvent = {
|
||||
code: 1009,
|
||||
reason: "Message Too Big",
|
||||
};
|
||||
|
||||
/**
|
||||
* The server successfully processed the request, asks that the requester reset
|
||||
* its document view, and is not returning any content.
|
||||
*/
|
||||
export const ResetConnection: CloseEvent = {
|
||||
code: 4205,
|
||||
reason: "Reset Connection",
|
||||
};
|
||||
|
||||
/**
|
||||
* Similar to Forbidden, but specifically for use when authentication is required and has
|
||||
* failed or has not yet been provided.
|
||||
*/
|
||||
export const Unauthorized: CloseEvent = {
|
||||
code: 4401,
|
||||
reason: "Unauthorized",
|
||||
};
|
||||
|
||||
/**
|
||||
* The request contained valid data and was understood by the server, but the server
|
||||
* is refusing action.
|
||||
*/
|
||||
export const Forbidden: CloseEvent = {
|
||||
code: 4403,
|
||||
reason: "Forbidden",
|
||||
};
|
||||
|
||||
/**
|
||||
* The server timed out waiting for the request.
|
||||
*/
|
||||
export const ConnectionTimeout: CloseEvent = {
|
||||
code: 4408,
|
||||
reason: "Connection Timeout",
|
||||
};
|
||||
6
packages/common/src/SkipFurtherHooksError.ts
Normal file
6
packages/common/src/SkipFurtherHooksError.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class SkipFurtherHooksError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? "Further hooks skipped");
|
||||
this.name = "SkipFurtherHooksError";
|
||||
}
|
||||
}
|
||||
62
packages/common/src/auth.ts
Normal file
62
packages/common/src/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as encoding from "lib0/encoding";
|
||||
import * as decoding from "lib0/decoding";
|
||||
import type {AuthorizedScope} from "../../provider/src"
|
||||
|
||||
export enum AuthMessageType {
|
||||
Token = 0,
|
||||
PermissionDenied = 1,
|
||||
Authenticated = 2,
|
||||
}
|
||||
|
||||
export const writeAuthentication = (
|
||||
encoder: encoding.Encoder,
|
||||
auth: string,
|
||||
) => {
|
||||
encoding.writeVarUint(encoder, AuthMessageType.Token);
|
||||
encoding.writeVarString(encoder, auth);
|
||||
};
|
||||
|
||||
export const writePermissionDenied = (
|
||||
encoder: encoding.Encoder,
|
||||
reason: string,
|
||||
) => {
|
||||
encoding.writeVarUint(encoder, AuthMessageType.PermissionDenied);
|
||||
encoding.writeVarString(encoder, reason);
|
||||
};
|
||||
|
||||
export const writeAuthenticated = (
|
||||
encoder: encoding.Encoder,
|
||||
scope: AuthorizedScope,
|
||||
) => {
|
||||
encoding.writeVarUint(encoder, AuthMessageType.Authenticated);
|
||||
encoding.writeVarString(encoder, scope);
|
||||
};
|
||||
|
||||
export const writeTokenSyncRequest = (
|
||||
encoder: encoding.Encoder,
|
||||
) => {
|
||||
encoding.writeVarUint(encoder, AuthMessageType.Token);
|
||||
};
|
||||
|
||||
export const readAuthMessage = (
|
||||
decoder: decoding.Decoder,
|
||||
sendToken: () => void,
|
||||
permissionDeniedHandler: (reason: string) => void,
|
||||
authenticatedHandler: (scope: string) => void,
|
||||
) => {
|
||||
switch (decoding.readVarUint(decoder)) {
|
||||
case AuthMessageType.Token: {
|
||||
sendToken();
|
||||
break;
|
||||
}
|
||||
case AuthMessageType.PermissionDenied: {
|
||||
permissionDeniedHandler(decoding.readVarString(decoder));
|
||||
break;
|
||||
}
|
||||
case AuthMessageType.Authenticated: {
|
||||
authenticatedHandler(decoding.readVarString(decoder));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
};
|
||||
10
packages/common/src/awarenessStatesToArray.ts
Normal file
10
packages/common/src/awarenessStatesToArray.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const awarenessStatesToArray = (
|
||||
states: Map<number, Record<string, any>>,
|
||||
) => {
|
||||
return Array.from(states.entries()).map(([key, value]) => {
|
||||
return {
|
||||
clientId: key,
|
||||
...value,
|
||||
};
|
||||
});
|
||||
};
|
||||
6
packages/common/src/index.ts
Normal file
6
packages/common/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./auth.ts";
|
||||
export * from "./CloseEvents.ts";
|
||||
export * from "./awarenessStatesToArray.ts";
|
||||
export * from "./routingKey.ts";
|
||||
export * from "./SkipFurtherHooksError.ts";
|
||||
export * from "./types.ts";
|
||||
11
packages/common/src/routingKey.ts
Normal file
11
packages/common/src/routingKey.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const SEPARATOR = '\0';
|
||||
|
||||
export function makeRoutingKey(documentName: string, sessionId: string): string {
|
||||
return documentName + SEPARATOR + sessionId;
|
||||
}
|
||||
|
||||
export function parseRoutingKey(key: string): { documentName: string; sessionId: string | null } {
|
||||
const idx = key.indexOf(SEPARATOR);
|
||||
if (idx === -1) return { documentName: key, sessionId: null };
|
||||
return { documentName: key.substring(0, idx), sessionId: key.substring(idx + 1) };
|
||||
}
|
||||
10
packages/common/src/types.ts
Normal file
10
packages/common/src/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* State of the WebSocket connection.
|
||||
* https://developer.mozilla.org/de/docs/Web/API/WebSocket/readyState
|
||||
*/
|
||||
export enum WsReadyStates {
|
||||
Connecting = 0,
|
||||
Open = 1,
|
||||
Closing = 2,
|
||||
Closed = 3,
|
||||
}
|
||||
1260
packages/extension-database/CHANGELOG.md
Normal file
1260
packages/extension-database/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
51
packages/extension-database/README.md
Normal file
51
packages/extension-database/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# @hocuspocus/extension-database
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-database)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-database)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
A generic persistence base class for [Hocuspocus](https://github.com/ueberdosis/hocuspocus). Provide your own `fetch` and `store` functions to plug any database into the collaboration backend — Postgres, MySQL, MongoDB, a REST API, etc.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-database
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Plug in any storage backend by implementing two async functions:
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { Database } from "@hocuspocus/extension-database"
|
||||
|
||||
const server = new Server({
|
||||
extensions: [
|
||||
new Database({
|
||||
fetch: async ({ documentName }) => {
|
||||
// return a Uint8Array of the stored Y.js update, or null for new documents
|
||||
return await yourDb.findDocument(documentName)
|
||||
},
|
||||
store: async ({ documentName, state }) => {
|
||||
// persist the binary Y.js state wherever you like
|
||||
await yourDb.upsertDocument(documentName, state)
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
`state` is a `Buffer` containing the encoded Y.js update. Store it as-is and return it unchanged from `fetch`.
|
||||
|
||||
For dedicated storage drivers, see [`@hocuspocus/extension-sqlite`](../extension-sqlite) or [`@hocuspocus/extension-s3`](../extension-s3), both of which extend this base class.
|
||||
|
||||
## Documentation
|
||||
|
||||
Persistence patterns and performance tips: [tiptap.dev/docs/hocuspocus/guides/persistence](https://tiptap.dev/docs/hocuspocus/guides/persistence).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
42
packages/extension-database/package.json
Normal file
42
packages/extension-database/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-database",
|
||||
"description": "a generic Hocuspocus persistence driver for the database",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-database.cjs",
|
||||
"module": "dist/hocuspocus-database.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-database.esm.js",
|
||||
"require": "./dist/hocuspocus-database.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
}
|
||||
}
|
||||
62
packages/extension-database/src/Database.ts
Normal file
62
packages/extension-database/src/Database.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
Extension,
|
||||
fetchPayload,
|
||||
onChangePayload,
|
||||
onLoadDocumentPayload,
|
||||
onStoreDocumentPayload,
|
||||
storePayload,
|
||||
} from "@hocuspocus/server";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export interface DatabaseConfiguration {
|
||||
/**
|
||||
* Pass a Promise to retrieve updates from your database. The Promise should resolve to
|
||||
* an array of items with Y.js-compatible binary data.
|
||||
*/
|
||||
fetch: (data: fetchPayload) => Promise<Uint8Array | null>;
|
||||
/**
|
||||
* Pass a function to store updates in your database.
|
||||
*/
|
||||
store: (data: storePayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export class Database implements Extension {
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
configuration: DatabaseConfiguration = {
|
||||
fetch: async () => null,
|
||||
store: async () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor(configuration: Partial<DatabaseConfiguration>) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored data from the database.
|
||||
*/
|
||||
async onLoadDocument(data: onLoadDocumentPayload): Promise<any> {
|
||||
const update = await this.configuration.fetch(data);
|
||||
|
||||
if (update) {
|
||||
Y.applyUpdate(data.document, update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new updates in the database.
|
||||
*/
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
await this.configuration.store({
|
||||
...data,
|
||||
state: Buffer.from(Y.encodeStateAsUpdate(data.document)),
|
||||
});
|
||||
}
|
||||
}
|
||||
1
packages/extension-database/src/index.ts
Normal file
1
packages/extension-database/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Database.ts";
|
||||
1732
packages/extension-logger/CHANGELOG.md
Normal file
1732
packages/extension-logger/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
56
packages/extension-logger/README.md
Normal file
56
packages/extension-logger/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @hocuspocus/extension-logger
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-logger)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-logger)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
Logs connection, document, and lifecycle events for a [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server to the console. Useful during development and debugging to see when clients connect, documents load and store, and changes come in.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-logger
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { Logger } from "@hocuspocus/extension-logger"
|
||||
|
||||
const server = new Server({
|
||||
extensions: [new Logger()],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
### Silence specific hooks
|
||||
|
||||
Pass booleans per hook to quiet down the ones you don't care about:
|
||||
|
||||
```js
|
||||
new Logger({
|
||||
onConnect: false,
|
||||
onDisconnect: false,
|
||||
onChange: false,
|
||||
})
|
||||
```
|
||||
|
||||
### Use a custom log function
|
||||
|
||||
Route output to your own logger (pino, winston, structured JSON, etc.):
|
||||
|
||||
```js
|
||||
new Logger({
|
||||
log: (...args) => myLogger.info({ msg: args.join(" ") }),
|
||||
})
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full options reference: [tiptap.dev/docs/hocuspocus/server/extensions/logger](https://tiptap.dev/docs/hocuspocus/server/extensions/logger).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
41
packages/extension-logger/package.json
Normal file
41
packages/extension-logger/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-logger",
|
||||
"version": "4.7.0",
|
||||
"description": "hocuspocus logging extension",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"logging",
|
||||
"logger",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-logger.cjs",
|
||||
"module": "dist/hocuspocus-logger.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-logger.esm.js",
|
||||
"require": "./dist/hocuspocus-logger.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "workspace:^"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
164
packages/extension-logger/src/Logger.ts
Normal file
164
packages/extension-logger/src/Logger.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onConfigurePayload,
|
||||
onConnectPayload,
|
||||
onDestroyPayload,
|
||||
onDisconnectPayload,
|
||||
onLoadDocumentPayload,
|
||||
onRequestPayload,
|
||||
onStoreDocumentPayload,
|
||||
onUpgradePayload,
|
||||
} from "@hocuspocus/server";
|
||||
|
||||
export interface LoggerConfiguration {
|
||||
/**
|
||||
* Prepend all logging message with a string.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
prefix: null | string;
|
||||
/**
|
||||
* Whether to log something for the `onLoadDocument` hook.
|
||||
*/
|
||||
onLoadDocument: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onChange` hook.
|
||||
*/
|
||||
onChange: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onStoreDocument` hook.
|
||||
*/
|
||||
onStoreDocument: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onConnect` hook.
|
||||
*/
|
||||
onConnect: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onDisconnect` hook.
|
||||
*/
|
||||
onDisconnect: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onUpgrade` hook.
|
||||
*/
|
||||
onUpgrade: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onRequest` hook.
|
||||
*/
|
||||
onRequest: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onDestroy` hook.
|
||||
*/
|
||||
onDestroy: boolean;
|
||||
/**
|
||||
* Whether to log something for the `onConfigure` hook.
|
||||
*/
|
||||
onConfigure: boolean;
|
||||
/**
|
||||
* A log function, if none is provided output will go to console
|
||||
*/
|
||||
log: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
export class Logger implements Extension {
|
||||
name: string | null = null;
|
||||
|
||||
configuration: LoggerConfiguration = {
|
||||
prefix: null,
|
||||
onLoadDocument: true,
|
||||
onChange: true,
|
||||
onStoreDocument: true,
|
||||
onConnect: true,
|
||||
onDisconnect: true,
|
||||
onUpgrade: true,
|
||||
onRequest: true,
|
||||
onDestroy: true,
|
||||
onConfigure: true,
|
||||
log: console.log, // eslint-disable-line
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor(configuration?: Partial<LoggerConfiguration>) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
async onConfigure(data: onConfigurePayload) {
|
||||
this.name = data.instance.configuration.name;
|
||||
|
||||
if (!this.configuration.onConfigure) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.configuration.prefix) {
|
||||
console.warn(
|
||||
"[hocuspocus warn] The Logger 'prefix' is deprecated. Pass a 'name' to the Hocuspocus configuration instead.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
if (this.configuration.onLoadDocument) {
|
||||
this.log(`Loaded document "${data.documentName}".`);
|
||||
}
|
||||
}
|
||||
|
||||
async onChange(data: onChangePayload) {
|
||||
if (this.configuration.onChange) {
|
||||
this.log(`Document "${data.documentName}" changed.`);
|
||||
}
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
if (this.configuration.onStoreDocument) {
|
||||
this.log(`Store "${data.documentName}".`);
|
||||
}
|
||||
}
|
||||
|
||||
async onConnect(data: onConnectPayload) {
|
||||
if (this.configuration.onConnect) {
|
||||
this.log(`New connection to "${data.documentName}".`);
|
||||
}
|
||||
}
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
if (this.configuration.onDisconnect) {
|
||||
this.log(`Connection to "${data.documentName}" closed.`);
|
||||
}
|
||||
}
|
||||
|
||||
async onUpgrade(data: onUpgradePayload) {
|
||||
if (this.configuration.onUpgrade) {
|
||||
this.log("Upgrading connection …");
|
||||
}
|
||||
}
|
||||
|
||||
async onRequest(data: onRequestPayload) {
|
||||
if (this.configuration.onRequest) {
|
||||
this.log(`Incoming HTTP Request to ${data.request.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onDestroy(data: onDestroyPayload) {
|
||||
if (this.configuration.onDestroy) {
|
||||
this.log("Shut down.");
|
||||
}
|
||||
}
|
||||
|
||||
private log(message: string) {
|
||||
const date = new Date().toISOString();
|
||||
let meta = `${date}`;
|
||||
|
||||
if (this.name) {
|
||||
meta = `${this.name} ${meta}`;
|
||||
}
|
||||
|
||||
message = `[${meta}] ${message}`;
|
||||
|
||||
this.configuration.log(message);
|
||||
}
|
||||
}
|
||||
1
packages/extension-logger/src/index.ts
Normal file
1
packages/extension-logger/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Logger.ts";
|
||||
1683
packages/extension-redis/CHANGELOG.md
Normal file
1683
packages/extension-redis/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
76
packages/extension-redis/README.md
Normal file
76
packages/extension-redis/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# @hocuspocus/extension-redis
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-redis)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-redis)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
Scale [Hocuspocus](https://github.com/ueberdosis/hocuspocus) horizontally across multiple server instances. Uses Redis pub/sub to broadcast document updates and awareness between servers, so clients connected to different instances stay in sync on the same document.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-redis
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Point every Hocuspocus instance at the same Redis:
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { Redis } from "@hocuspocus/extension-redis"
|
||||
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
extensions: [
|
||||
new Redis({
|
||||
host: "127.0.0.1",
|
||||
port: 6379,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
### Bring your own Redis client
|
||||
|
||||
If you already have an `ioredis` client (including Cluster), pass it in directly instead of host/port:
|
||||
|
||||
```js
|
||||
import RedisClient from "ioredis"
|
||||
|
||||
new Redis({
|
||||
redis: new RedisClient({ host: "127.0.0.1", port: 6379 }),
|
||||
})
|
||||
```
|
||||
|
||||
Redis handles real-time sync — you still need a persistence extension (e.g. [`@hocuspocus/extension-sqlite`](../extension-sqlite), [`@hocuspocus/extension-s3`](../extension-s3), or your own [`Database`](../extension-database)) to store documents long-term.
|
||||
|
||||
### Consistent reads on load
|
||||
|
||||
When a document is loaded on one instance while another instance already has it
|
||||
open (and possibly modified in memory, not yet persisted), the load blocks until
|
||||
the other instance has sent its current state — so a `DirectConnection` or a
|
||||
freshly connected client observes the latest collaborative content rather than
|
||||
just what was read from storage. If no other instance has the document open, the
|
||||
load is not delayed.
|
||||
|
||||
The wait is bounded by `awaitInitialSyncTimeout` (ms, default `1000`). Set it to
|
||||
`0` to disable the wait and restore the previous fire-and-forget behavior:
|
||||
|
||||
```js
|
||||
new Redis({
|
||||
host: "127.0.0.1",
|
||||
port: 6379,
|
||||
awaitInitialSyncTimeout: 1000,
|
||||
})
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full scaling architecture and multi-instance deployment guide: [tiptap.dev/docs/hocuspocus/server/extensions/redis](https://tiptap.dev/docs/hocuspocus/server/extensions/redis).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
48
packages/extension-redis/package.json
Normal file
48
packages/extension-redis/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-redis",
|
||||
"version": "4.7.0",
|
||||
"description": "Scale Hocuspocus horizontally with Redis",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"redis",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-redis.cjs",
|
||||
"module": "dist/hocuspocus-redis.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-redis.esm.js",
|
||||
"require": "./dist/hocuspocus-redis.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "workspace:^",
|
||||
"@hocuspocus/server": "workspace:^",
|
||||
"@sesamecare-oss/redlock": "^1.4.0",
|
||||
"ioredis": "~5.6.1",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
839
packages/extension-redis/src/Redis.ts
Normal file
839
packages/extension-redis/src/Redis.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
import crypto from "node:crypto";
|
||||
import { SkipFurtherHooksError } from "@hocuspocus/common";
|
||||
import type {
|
||||
afterLoadDocumentPayload,
|
||||
afterStoreDocumentPayload,
|
||||
afterUnloadDocumentPayload,
|
||||
beforeBroadcastStatelessPayload,
|
||||
beforeUnloadDocumentPayload,
|
||||
Document,
|
||||
Extension,
|
||||
Hocuspocus,
|
||||
onAwarenessUpdatePayload,
|
||||
onChangePayload,
|
||||
onConfigurePayload,
|
||||
onStoreDocumentPayload,
|
||||
RedisTransactionOrigin,
|
||||
} from "@hocuspocus/server";
|
||||
import {
|
||||
IncomingMessage,
|
||||
isTransactionOrigin,
|
||||
MessageReceiver,
|
||||
MessageType,
|
||||
OutgoingMessage,
|
||||
} from "@hocuspocus/server";
|
||||
import {
|
||||
ExecutionError,
|
||||
type ExecutionResult,
|
||||
type Lock,
|
||||
Redlock,
|
||||
} from "@sesamecare-oss/redlock";
|
||||
import type {
|
||||
Cluster,
|
||||
ClusterNode,
|
||||
ClusterOptions,
|
||||
RedisOptions,
|
||||
} from "ioredis";
|
||||
import RedisClient from "ioredis";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import { messageYjsSyncStep2, messageYjsUpdate } from "y-protocols/sync";
|
||||
export type RedisInstance = RedisClient | Cluster;
|
||||
export interface Configuration {
|
||||
/**
|
||||
* Redis port
|
||||
*/
|
||||
port: number;
|
||||
/**
|
||||
* Redis host
|
||||
*/
|
||||
host: string;
|
||||
/**
|
||||
* Redis Cluster
|
||||
*/
|
||||
nodes?: ClusterNode[];
|
||||
/**
|
||||
* Duplicate from an existed Redis instance
|
||||
*/
|
||||
redis?: RedisInstance;
|
||||
/**
|
||||
* Redis instance creator
|
||||
*/
|
||||
createClient?: () => RedisInstance;
|
||||
/**
|
||||
* Options passed directly to Redis constructor
|
||||
*
|
||||
* https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options
|
||||
*/
|
||||
options?: ClusterOptions | RedisOptions;
|
||||
/**
|
||||
* An unique instance name, required to filter messages in Redis.
|
||||
* If none is provided an unique id is generated.
|
||||
*/
|
||||
identifier: string;
|
||||
/**
|
||||
* Namespace for Redis keys, if none is provided 'hocuspocus' is used
|
||||
*/
|
||||
prefix: string;
|
||||
/**
|
||||
* The maximum time for the Redis lock in ms (in case it can’t be released).
|
||||
*/
|
||||
lockTimeout: number;
|
||||
/**
|
||||
* A delay before onDisconnect is executed. This allows last minute updates'
|
||||
* sync messages to be received by the subscription before it's closed.
|
||||
*/
|
||||
disconnectDelay: number;
|
||||
/**
|
||||
* The maximum time (in ms) `afterLoadDocument` waits for another instance to
|
||||
* send its state when loading a document that is already open elsewhere.
|
||||
*
|
||||
* When a document is loaded on this instance while another instance already
|
||||
* has it open (and possibly modified in memory, not yet persisted), we
|
||||
* publish a SyncStep1 and block the load until the peer replies with its
|
||||
* state (SyncStep2) — or this timeout elapses. This guarantees that callers
|
||||
* (most importantly a `DirectConnection`) observe the latest collaborative
|
||||
* state instead of just what was loaded from storage.
|
||||
*
|
||||
* If no other instance is subscribed to the document, the wait is skipped
|
||||
* entirely, so a cold/lone load is not delayed.
|
||||
*
|
||||
* Set to `0` to disable the wait and restore the legacy fire-and-forget
|
||||
* behavior.
|
||||
*/
|
||||
awaitInitialSyncTimeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes for one document, accumulated over a single event loop turn.
|
||||
*
|
||||
* The two kinds are independent, not alternatives: a turn can carry a sync step,
|
||||
* an awareness update, or both, and each is sent on its own.
|
||||
*/
|
||||
type PendingPublish = {
|
||||
document: Document;
|
||||
|
||||
/** A SyncStep1 is pending. A flag, not a count: state vectors are idempotent. */
|
||||
syncStepPending: boolean;
|
||||
|
||||
/** Clients whose awareness changed this turn; empty when none did. */
|
||||
awarenessClients: Set<number>;
|
||||
|
||||
/** Set alongside `awarenessClients`; the awareness they belong to. */
|
||||
awareness?: Awareness;
|
||||
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export class Redis implements Extension {
|
||||
/**
|
||||
* Make sure to give that extension a higher priority, so
|
||||
* the `onStoreDocument` hook is able to intercept the chain,
|
||||
* before documents are stored to the database.
|
||||
*/
|
||||
priority = 1000;
|
||||
|
||||
configuration: Configuration = {
|
||||
port: 6379,
|
||||
host: "127.0.0.1",
|
||||
prefix: "hocuspocus",
|
||||
identifier: `host-${crypto.randomUUID()}`,
|
||||
lockTimeout: 1000,
|
||||
disconnectDelay: 1000,
|
||||
awaitInitialSyncTimeout: 1000,
|
||||
};
|
||||
|
||||
redisTransactionOrigin: RedisTransactionOrigin = {
|
||||
source: "redis",
|
||||
};
|
||||
|
||||
pub: RedisInstance;
|
||||
|
||||
sub: RedisInstance;
|
||||
|
||||
instance!: Hocuspocus;
|
||||
|
||||
redlock: Redlock;
|
||||
|
||||
locks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();
|
||||
|
||||
messagePrefix: Buffer;
|
||||
|
||||
/**
|
||||
* Resolves once this instance is subscribed to its own reply channel.
|
||||
* Awaited before a document announces itself, so a peer's reply can never
|
||||
* arrive on a channel we are not listening to yet.
|
||||
*/
|
||||
private replySubscription: Promise<void>;
|
||||
|
||||
private pendingAfterStoreDocumentResolves = new Map<
|
||||
string,
|
||||
{ timeout: NodeJS.Timeout; resolve: () => void }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Documents this instance has loaded and subscribed to, keyed by name.
|
||||
*
|
||||
* This mirrors `instance.documents` but is populated at the very start of
|
||||
* `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in
|
||||
* `instance.documents` (which only happens once loading, including this
|
||||
* hook, has fully resolved). That early registration is what lets
|
||||
* `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still
|
||||
* blocking the load on it.
|
||||
*/
|
||||
private documents = new Map<string, Document>();
|
||||
|
||||
/**
|
||||
* Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.
|
||||
* Invoked by `handleIncomingMessage` as soon as a peer's state arrives.
|
||||
*/
|
||||
private pendingInitialSyncResolves = new Map<string, () => void>();
|
||||
|
||||
/**
|
||||
* Publishes coalesced into the current event loop turn, keyed by document
|
||||
* name. See `schedulePublish`.
|
||||
*/
|
||||
private pendingPublishes = new Map<string, PendingPublish>();
|
||||
|
||||
public constructor(configuration: Partial<Configuration>) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
// Create Redis instance
|
||||
const { port, host, options, nodes, redis, createClient } =
|
||||
this.configuration;
|
||||
|
||||
if (typeof createClient === "function") {
|
||||
this.pub = createClient();
|
||||
this.sub = createClient();
|
||||
} else if (redis) {
|
||||
this.pub = redis.duplicate();
|
||||
this.sub = redis.duplicate();
|
||||
} else if (nodes && nodes.length > 0) {
|
||||
this.pub = new RedisClient.Cluster(nodes, options);
|
||||
this.sub = new RedisClient.Cluster(nodes, options);
|
||||
} else {
|
||||
this.pub = new RedisClient(port, host, options ?? {});
|
||||
this.sub = new RedisClient(port, host, options ?? {});
|
||||
}
|
||||
|
||||
this.sub.on("messageBuffer", this.handleIncomingMessage);
|
||||
|
||||
this.redlock = new Redlock([this.pub], {
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
const identifierBuffer = Buffer.from(
|
||||
this.configuration.identifier,
|
||||
"utf-8",
|
||||
);
|
||||
this.messagePrefix = Buffer.concat([
|
||||
Buffer.from([identifierBuffer.length]),
|
||||
identifierBuffer,
|
||||
]);
|
||||
|
||||
// Subscribed once for the lifetime of the extension rather than per
|
||||
// document: `handleIncomingMessage` resolves the document from the message
|
||||
// payload, so a single channel per instance receives every reply addressed
|
||||
// to us. Started here instead of in `onConfigure` because the server does
|
||||
// not await that hook; `afterLoadDocument` awaits this promise, so no
|
||||
// reply can be missed while the subscription is still pending.
|
||||
this.replySubscription = new Promise<void>((resolve, reject) => {
|
||||
this.sub.subscribe(
|
||||
this.replyKey(this.configuration.identifier),
|
||||
(error: any) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The error is re-thrown to whoever awaits the promise in
|
||||
// `afterLoadDocument`; this handler only keeps it from being reported as
|
||||
// an unhandled rejection when no document is ever loaded.
|
||||
this.replySubscription.catch(() => {});
|
||||
}
|
||||
|
||||
async onConfigure({ instance }: onConfigurePayload) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
private getKey(documentName: string) {
|
||||
return `${this.configuration.prefix}:${documentName}`;
|
||||
}
|
||||
|
||||
private pubKey(documentName: string) {
|
||||
return this.getKey(documentName);
|
||||
}
|
||||
|
||||
private subKey(documentName: string) {
|
||||
return this.getKey(documentName);
|
||||
}
|
||||
|
||||
private lockKey(documentName: string) {
|
||||
return `${this.getKey(documentName)}:lock`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel an instance receives replies to its own requests on.
|
||||
*
|
||||
* Separated from the prefix by `#` rather than `:`, so it can never collide
|
||||
* with the document channel of a document that happens to be named
|
||||
* `reply:<identifier>`.
|
||||
*/
|
||||
private replyKey(identifier: string) {
|
||||
return `${this.configuration.prefix}#reply:${identifier}`;
|
||||
}
|
||||
|
||||
private encodeMessage(message: Uint8Array) {
|
||||
return Buffer.concat([this.messagePrefix, Buffer.from(message)]);
|
||||
}
|
||||
|
||||
private decodeMessage(buffer: Buffer): [identifier: string, message: Buffer] {
|
||||
const identifierLength = buffer[0];
|
||||
const identifier = buffer.toString("utf-8", 1, identifierLength + 1);
|
||||
|
||||
return [identifier, buffer.slice(identifierLength + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a document is loaded, subscribe to the channel in Redis.
|
||||
*/
|
||||
public async afterLoadDocument({
|
||||
documentName,
|
||||
document,
|
||||
}: afterLoadDocumentPayload) {
|
||||
// Track the document locally right away so `handleIncomingMessage` can
|
||||
// apply inbound messages to it even before Hocuspocus has finished
|
||||
// loading it (it only lands in `instance.documents` after this hook
|
||||
// resolves). Without this, the SyncStep2 reply we request below would be
|
||||
// dropped while we wait for it.
|
||||
this.documents.set(documentName, document);
|
||||
|
||||
try {
|
||||
// Replies to the SyncStep1 we are about to publish are addressed to
|
||||
// this instance's reply channel, so that subscription has to be live
|
||||
// first.
|
||||
await this.replySubscription;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// On document creation the node will connect to pub and sub channels
|
||||
// for the document.
|
||||
this.sub.subscribe(this.subKey(documentName), (error: any) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await this.syncInitialStateFromPeers(documentName, document);
|
||||
} catch (error) {
|
||||
// Loading failed: stop tracking the document so future messages aren't
|
||||
// applied to a doc that never finished loading, release any pending
|
||||
// wait, and best-effort unsubscribe (afterUnloadDocument does not run
|
||||
// for a load that never completed).
|
||||
this.documents.delete(documentName);
|
||||
this.pendingInitialSyncResolves.delete(documentName);
|
||||
this.sub.unsubscribe(this.subKey(documentName), () => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce our state to other instances and — when another instance already
|
||||
* has this document open — block until it has sent us its state (or the
|
||||
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
|
||||
*/
|
||||
private async syncInitialStateFromPeers(
|
||||
documentName: string,
|
||||
document: Document,
|
||||
) {
|
||||
const timeout = this.configuration.awaitInitialSyncTimeout;
|
||||
|
||||
const waitForPeers =
|
||||
timeout > 0 && (await this.hasOtherSubscribers(documentName));
|
||||
|
||||
// Announce our state and request awareness. Awaited so a Redis publish
|
||||
// failure surfaces as a load error instead of an unhandled rejection.
|
||||
await Promise.all([
|
||||
this.publishFirstSyncStep(documentName, document),
|
||||
this.requestAwarenessFromOtherInstances(documentName),
|
||||
]);
|
||||
|
||||
// Skip the wait when it's disabled or nobody else has the document open:
|
||||
// there is no peer to sync from, so blocking would only add latency.
|
||||
if (!waitForPeers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A peer has the document open. Block until it replies with its state
|
||||
// (a SyncStep2/Update applied via handleIncomingMessage resolves this) or
|
||||
// the timeout elapses. The reply cannot arrive before the SyncStep1 we
|
||||
// just published is delivered, so registering the resolver now — right
|
||||
// after the awaited publish, before yielding to the event loop — cannot
|
||||
// miss it.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingInitialSyncResolves.delete(documentName);
|
||||
resolve();
|
||||
}, timeout);
|
||||
|
||||
this.pendingInitialSyncResolves.set(documentName, () => {
|
||||
clearTimeout(timer);
|
||||
this.pendingInitialSyncResolves.delete(documentName);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether another instance is currently subscribed to a document's channel.
|
||||
* We are subscribed ourselves, so a subscriber count greater than one means
|
||||
* at least one peer has the document open.
|
||||
*/
|
||||
private async hasOtherSubscribers(documentName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = (await this.pub.pubsub(
|
||||
"NUMSUB",
|
||||
this.subKey(documentName),
|
||||
)) as [string, number | string];
|
||||
return Number(result?.[1] ?? 0) > 1;
|
||||
} catch {
|
||||
// NUMSUB may be unavailable in some setups (e.g. certain clusters).
|
||||
// Assume peers might exist so correctness is preserved; the timeout
|
||||
// still bounds the wait.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
|
||||
* applied.
|
||||
*/
|
||||
private resolveInitialSync(documentName: string) {
|
||||
this.pendingInitialSyncResolves.get(documentName)?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek whether a message carries another instance's document state
|
||||
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
|
||||
* *requests* state, so it must not release an initial-sync wait.
|
||||
*
|
||||
* Carrying state is necessary but not sufficient to release that wait: the
|
||||
* state also has to have been computed for *our* state vector, which is what
|
||||
* arriving on our reply channel means. See `handleIncomingMessage`.
|
||||
*/
|
||||
private messageCarriesPeerState(message: IncomingMessage): boolean {
|
||||
const { pos } = message.decoder;
|
||||
try {
|
||||
const type = message.readVarUint();
|
||||
if (type !== MessageType.Sync && type !== MessageType.SyncReply) {
|
||||
return false;
|
||||
}
|
||||
const syncType = message.readVarUint();
|
||||
return syncType === messageYjsSyncStep2 || syncType === messageYjsUpdate;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
message.decoder.pos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce this document's Redis publishes into the current event loop turn.
|
||||
*
|
||||
* Both publishers are per-change and neither is covered by the server's
|
||||
* `flushDelay` batching: `onChange` runs synchronously by design (it also
|
||||
* drives the store hooks) and `onAwarenessUpdate` is dispatched from a raw
|
||||
* awareness listener rather than from `Document`'s batched broadcast. Left
|
||||
* uncoalesced, a document with many writing clients publishes one SyncStep1
|
||||
* per change and every peer answers each one with a full
|
||||
* `encodeStateAsUpdate` diff — which is O(document), so this costs far more
|
||||
* per change than the WebSocket fan-out ever did.
|
||||
*
|
||||
* Collapsing is lossless. A SyncStep1 carries the document's *current* state
|
||||
* vector, so the last one of a turn is a superset of the ones it replaces
|
||||
* and a peer's reply to it covers every skipped change. Awareness collapses
|
||||
* to the latest state of each changed client, exactly as `Document` does for
|
||||
* its own fan-out.
|
||||
*/
|
||||
private schedulePublish(
|
||||
documentName: string,
|
||||
document: Document,
|
||||
): PendingPublish {
|
||||
const existing = this.pendingPublishes.get(documentName);
|
||||
|
||||
if (existing) {
|
||||
if (existing.document === document) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// The document was reloaded under the same name. `afterUnloadDocument`
|
||||
// returns early on a fast reconnect, so it cannot be relied on to have
|
||||
// cleared this. Whatever is buffered describes the previous instance —
|
||||
// its state vector and its awareness — so drop it rather than publish
|
||||
// it on behalf of the new one.
|
||||
existing.cancel();
|
||||
this.pendingPublishes.delete(documentName);
|
||||
}
|
||||
|
||||
const handle = setImmediate(() => {
|
||||
this.flushPublish(documentName).catch((error) => {
|
||||
console.error(
|
||||
`Redis: failed to publish for document "${documentName}"`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const pending: PendingPublish = {
|
||||
document,
|
||||
syncStepPending: false,
|
||||
awarenessClients: new Set<number>(),
|
||||
cancel: () => clearImmediate(handle),
|
||||
};
|
||||
|
||||
this.pendingPublishes.set(documentName, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
private cancelPendingPublish(documentName: string) {
|
||||
const pending = this.pendingPublishes.get(documentName);
|
||||
|
||||
if (pending) {
|
||||
pending.cancel();
|
||||
this.pendingPublishes.delete(documentName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send everything accumulated for this document in the current turn.
|
||||
*/
|
||||
private async flushPublish(documentName: string) {
|
||||
const pending = this.pendingPublishes.get(documentName);
|
||||
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingPublishes.delete(documentName);
|
||||
|
||||
// Both are issued before anything is awaited, so a failing sync step
|
||||
// cannot swallow the awareness update — the entry is already dropped from
|
||||
// the map at this point, and those clients would never be retried.
|
||||
const publishes: Array<Promise<unknown>> = [];
|
||||
|
||||
if (pending.syncStepPending) {
|
||||
publishes.push(this.publishFirstSyncStep(documentName, pending.document));
|
||||
}
|
||||
|
||||
// Same guard as the uncoalesced path: publishing awareness for a document
|
||||
// nobody is connected to throws (see hocuspocus#1027).
|
||||
if (
|
||||
pending.awarenessClients.size > 0 &&
|
||||
pending.awareness &&
|
||||
pending.document.connections.size > 0
|
||||
) {
|
||||
const message = new OutgoingMessage(
|
||||
documentName,
|
||||
).createAwarenessUpdateMessage(
|
||||
pending.awareness,
|
||||
Array.from(pending.awarenessClients),
|
||||
);
|
||||
|
||||
publishes.push(
|
||||
this.pub.publish(
|
||||
this.pubKey(documentName),
|
||||
this.encodeMessage(message.toUint8Array()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(publishes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the first sync step through Redis.
|
||||
*/
|
||||
private async publishFirstSyncStep(documentName: string, document: Document) {
|
||||
const syncMessage = new OutgoingMessage(documentName)
|
||||
.createSyncMessage()
|
||||
.writeFirstSyncStepFor(document);
|
||||
|
||||
return this.pub.publish(
|
||||
this.pubKey(documentName),
|
||||
this.encodeMessage(syncMessage.toUint8Array()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Let’s ask Redis who is connected already.
|
||||
*/
|
||||
private async requestAwarenessFromOtherInstances(documentName: string) {
|
||||
const awarenessMessage = new OutgoingMessage(
|
||||
documentName,
|
||||
).writeQueryAwareness();
|
||||
|
||||
return this.pub.publish(
|
||||
this.pubKey(documentName),
|
||||
this.encodeMessage(awarenessMessage.toUint8Array()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before the document is stored, make sure to set a lock in Redis.
|
||||
* That’s meant to avoid conflicts with other instances trying to store the document.
|
||||
*/
|
||||
async onStoreDocument({ documentName }: onStoreDocumentPayload) {
|
||||
// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,
|
||||
// to avoid conflict with other instances storing the same document.
|
||||
const resource = this.lockKey(documentName);
|
||||
const ttl = this.configuration.lockTimeout;
|
||||
try {
|
||||
const lock = await this.redlock.acquire([resource], ttl);
|
||||
const oldLock = this.locks.get(resource);
|
||||
if (oldLock?.release) {
|
||||
await oldLock.release;
|
||||
}
|
||||
this.locks.set(resource, { lock });
|
||||
} catch (error: any) {
|
||||
//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349
|
||||
if (
|
||||
error instanceof ExecutionError &&
|
||||
error.message ===
|
||||
"The operation was unable to achieve a quorum during its retry window."
|
||||
) {
|
||||
// Expected behavior: Could not acquire lock, another instance locked it already.
|
||||
// Skip further hooks and retry — the data is safe on the other instance.
|
||||
throw new SkipFurtherHooksError(
|
||||
"Another instance is already storing this document",
|
||||
);
|
||||
}
|
||||
//unexpected error
|
||||
console.error("unexpected error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the Redis lock, so other instances can store documents.
|
||||
*/
|
||||
async afterStoreDocument({
|
||||
documentName,
|
||||
lastTransactionOrigin,
|
||||
}: afterStoreDocumentPayload) {
|
||||
const lockKey = this.lockKey(documentName);
|
||||
const lock = this.locks.get(lockKey);
|
||||
if (lock) {
|
||||
try {
|
||||
// Always try to unlock and clean up the lock
|
||||
lock.release = lock.lock.release();
|
||||
await lock.release;
|
||||
} catch {
|
||||
// Lock will expire on its own after timeout
|
||||
} finally {
|
||||
this.locks.delete(lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
// if the change was initiated by a directConnection (or otherwise local source), we need to delay this hook to make sure sync can finish first.
|
||||
// for provider connections, this usually happens in the onDisconnect hook
|
||||
if (
|
||||
isTransactionOrigin(lastTransactionOrigin) &&
|
||||
lastTransactionOrigin.source === "local"
|
||||
) {
|
||||
const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve();
|
||||
this.pendingAfterStoreDocumentResolves.delete(documentName);
|
||||
}
|
||||
|
||||
let resolveFunction: () => void = () => {};
|
||||
const delayedPromise = new Promise<void>((resolve) => {
|
||||
resolveFunction = resolve;
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingAfterStoreDocumentResolves.delete(documentName);
|
||||
resolveFunction();
|
||||
}, this.configuration.disconnectDelay);
|
||||
|
||||
this.pendingAfterStoreDocumentResolves.set(documentName, {
|
||||
timeout,
|
||||
resolve: resolveFunction,
|
||||
});
|
||||
|
||||
await delayedPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle awareness update messages received directly by this Hocuspocus instance.
|
||||
*/
|
||||
async onAwarenessUpdate({
|
||||
documentName,
|
||||
awareness,
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
document,
|
||||
}: onAwarenessUpdatePayload) {
|
||||
// Do not publish if there is no connection: it fixes this issue: "https://github.com/ueberdosis/hocuspocus/issues/1027"
|
||||
const connections = document?.connections.size || 0;
|
||||
if (connections === 0) {
|
||||
return; // avoids exception
|
||||
}
|
||||
|
||||
const pending = this.schedulePublish(documentName, document);
|
||||
|
||||
pending.awareness = awareness;
|
||||
|
||||
for (const clientId of added.concat(updated, removed)) {
|
||||
pending.awarenessClients.add(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages published on subscribed document channels.
|
||||
* Note that this will also include messages from ourselves as it is not possible
|
||||
* in Redis to filter these.
|
||||
*/
|
||||
private handleIncomingMessage = async (channel: Buffer, data: Buffer) => {
|
||||
const [identifier, messageBuffer] = this.decodeMessage(data);
|
||||
|
||||
if (identifier === this.configuration.identifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = new IncomingMessage(messageBuffer);
|
||||
const documentName = message.readVarString();
|
||||
message.writeVarString(documentName);
|
||||
|
||||
// Prefer the extension-local map: it also contains documents that are
|
||||
// still loading (not yet in `instance.documents`), which is exactly when
|
||||
// a blocking `afterLoadDocument` needs to receive the peer's reply.
|
||||
const document =
|
||||
this.documents.get(documentName) ??
|
||||
this.instance.documents.get(documentName);
|
||||
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const carriesPeerState = this.messageCarriesPeerState(message);
|
||||
|
||||
// State that reached us on our own reply channel was computed for our
|
||||
// state vector; state overheard anywhere else was computed for somebody
|
||||
// else and says nothing about whether we have caught up.
|
||||
const addressedToUs =
|
||||
channel.toString("utf-8") ===
|
||||
this.replyKey(this.configuration.identifier);
|
||||
|
||||
const receiver = new MessageReceiver(message, this.redisTransactionOrigin);
|
||||
await receiver.apply(document, undefined, (reply) => {
|
||||
// Replies go to the requester alone, never to the document channel.
|
||||
// A SyncStep2 is `encodeStateAsUpdate(document, requesterStateVector)`:
|
||||
// the structs that one requester is missing plus the *entire* delete
|
||||
// set. An instance whose state is behind the requester's applies those
|
||||
// deletes without receiving the structs that replaced the deleted
|
||||
// content, leaving it with a document that never existed — which it
|
||||
// then broadcasts to its own clients and can persist (#1151).
|
||||
return this.pub.publish(
|
||||
this.replyKey(identifier),
|
||||
this.encodeMessage(reply),
|
||||
);
|
||||
});
|
||||
|
||||
// A peer answered our SyncStep1 with its state — the document has now
|
||||
// caught up, so any blocking initial-sync wait can be released.
|
||||
if (carriesPeerState && addressedToUs) {
|
||||
this.resolveInitialSync(documentName);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
|
||||
*/
|
||||
public async onChange(data: onChangePayload): Promise<any> {
|
||||
if (
|
||||
isTransactionOrigin(data.transactionOrigin) &&
|
||||
data.transactionOrigin.source === "redis"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedulePublish(data.documentName, data.document).syncStepPending =
|
||||
true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay unloading to allow syncs to finish
|
||||
*/
|
||||
async beforeUnloadDocument(data: beforeUnloadDocumentPayload) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, this.configuration.disconnectDelay);
|
||||
});
|
||||
}
|
||||
|
||||
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
|
||||
if (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)
|
||||
|
||||
this.resolveInitialSync(data.documentName);
|
||||
this.documents.delete(data.documentName);
|
||||
// Nothing should be pending: the coalescing window is a single event loop
|
||||
// turn and `beforeUnloadDocument` already waits `disconnectDelay` to let
|
||||
// syncs finish. This is a safety net so a timer cannot fire against a
|
||||
// document that is on its way out.
|
||||
this.cancelPendingPublish(data.documentName);
|
||||
|
||||
this.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {
|
||||
const message = new OutgoingMessage(
|
||||
data.documentName,
|
||||
).writeBroadcastStateless(data.payload);
|
||||
|
||||
return this.pub.publish(
|
||||
this.pubKey(data.documentName),
|
||||
this.encodeMessage(message.toUint8Array()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the Redlock connection immediately.
|
||||
*/
|
||||
async onDestroy() {
|
||||
// Cancelled rather than flushed, because flushing could not achieve
|
||||
// anything: a pending publish is a SyncStep1, i.e. a state vector, which
|
||||
// only *asks* peers to reply. Our own changes reach them one round trip
|
||||
// later, when we answer the SyncStep1 they send back — and the subscriber
|
||||
// is disconnected a few lines below, so that answer would never come.
|
||||
// Durability on shutdown comes from the fact that we flush per setImmediate,
|
||||
// which runs before the delay in beforeUnloadDocument.
|
||||
for (const documentName of Array.from(this.pendingPublishes.keys())) {
|
||||
this.cancelPendingPublish(documentName);
|
||||
}
|
||||
|
||||
await this.redlock.quit();
|
||||
this.pub.disconnect(false);
|
||||
this.sub.disconnect(false);
|
||||
}
|
||||
}
|
||||
1
packages/extension-redis/src/index.ts
Normal file
1
packages/extension-redis/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Redis.ts";
|
||||
337
packages/extension-s3/CHANGELOG.md
Normal file
337
packages/extension-s3/CHANGELOG.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [4.7.0](https://github.com/ueberdosis/hocuspocus/compare/v4.6.0...v4.7.0) (2026-09-09)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.6.0](https://github.com/ueberdosis/hocuspocus/compare/v4.5.0...v4.6.0) (2026-08-10)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.5.0](https://github.com/ueberdosis/hocuspocus/compare/v4.4.0...v4.5.0) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update packages via audit --fix ([1dc9ca0](https://github.com/ueberdosis/hocuspocus/commit/1dc9ca0ff35f1033136473d134cee8cb6b336281))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.4.0](https://github.com/ueberdosis/hocuspocus/compare/v4.3.0...v4.4.0) (2026-07-13)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.3.0](https://github.com/ueberdosis/hocuspocus/compare/v4.2.0...v4.3.0) (2026-06-18)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.2.0](https://github.com/ueberdosis/hocuspocus/compare/v4.1.2...v4.2.0) (2026-06-12)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.1.2](https://github.com/ueberdosis/hocuspocus/compare/v4.1.1...v4.1.2) (2026-06-11)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.1.1](https://github.com/ueberdosis/hocuspocus/compare/v4.1.0...v4.1.1) (2026-06-10)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.1.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0...v4.1.0) (2026-05-20)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.7...v4.0.0) (2026-04-23)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.7](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.6...v4.0.0-rc.7) (2026-04-22)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.6](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.5...v4.0.0-rc.6) (2026-04-20)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-04-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-04-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-04-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.2](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.1...v4.0.0-rc.2) (2026-04-08)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.0...v4.0.0-rc.1) (2026-03-30)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0-rc.0) (2026-03-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* migrate to pnpm ([#1061](https://github.com/ueberdosis/hocuspocus/issues/1061)) ([dcaab57](https://github.com/ueberdosis/hocuspocus/commit/dcaab571bad694e48efd41bbb1d675855a7e2055))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.2](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.1...v4.0.0-rc.2) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.0...v4.0.0-rc.1) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v4.0.0-rc.0) (2026-02-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* migrate to pnpm ([#1061](https://github.com/ueberdosis/hocuspocus/issues/1061)) ([dcaab57](https://github.com/ueberdosis/hocuspocus/commit/dcaab571bad694e48efd41bbb1d675855a7e2055))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.6-rc.2](https://github.com/ueberdosis/hocuspocus/compare/v3.4.6-rc.1...v3.4.6-rc.2) (2026-02-03)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.6-rc.1](https://github.com/ueberdosis/hocuspocus/compare/v3.4.6-rc.0...v3.4.6-rc.1) (2026-02-03)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.6-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.5-rc.0...v3.4.6-rc.0) (2026-02-03)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.5-rc.0](https://github.com/ueberdosis/hocuspocus/compare/v3.4.4...v3.4.5-rc.0) (2026-02-03)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.4](https://github.com/ueberdosis/hocuspocus/compare/v3.4.3...v3.4.4) (2026-01-25)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.3](https://github.com/ueberdosis/hocuspocus/compare/v3.4.2...v3.4.3) (2025-12-15)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.2](https://github.com/ueberdosis/hocuspocus/compare/v3.4.1...v3.4.2) (2025-12-15)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.4.1](https://github.com/ueberdosis/hocuspocus/compare/v3.4.0...v3.4.1) (2025-12-09)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [3.4.0](https://github.com/ueberdosis/hocuspocus/compare/v3.3.2...v3.4.0) (2025-10-24)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.3.2](https://github.com/ueberdosis/hocuspocus/compare/v3.3.1...v3.3.2) (2025-10-23)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.3.1](https://github.com/ueberdosis/hocuspocus/compare/v3.3.0...v3.3.1) (2025-10-22)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [3.3.0](https://github.com/ueberdosis/hocuspocus/compare/v3.2.6...v3.3.0) (2025-10-22)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.2.6](https://github.com/ueberdosis/hocuspocus/compare/v3.2.5...v3.2.6) (2025-10-17)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.2.5](https://github.com/ueberdosis/hocuspocus/compare/v3.2.4...v3.2.5) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.2.4](https://github.com/ueberdosis/hocuspocus/compare/v3.2.3...v3.2.4) (2025-09-30)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/extension-s3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Changelog
|
||||
|
||||
## [3.2.3] - 2024-08-10
|
||||
|
||||
### Added
|
||||
- Initial release of @hocuspocus/extension-s3
|
||||
- Support for Amazon S3 and S3-compatible storage services (MinIO, DigitalOcean Spaces, etc.)
|
||||
- Configurable bucket, region, and key prefix
|
||||
- Support for custom S3 clients
|
||||
- IAM role support for AWS environments
|
||||
- Proper error handling for missing documents
|
||||
171
packages/extension-s3/README.md
Normal file
171
packages/extension-s3/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# @hocuspocus/extension-s3
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-s3)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-s3)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
A S3-compatible persistence driver for [Hocuspocus](https://github.com/ueberdosis/hocuspocus). Stores Y.js documents in Amazon S3 or S3-compatible storage (MinIO, DigitalOcean Spaces, Cloudflare R2, Backblaze B2, etc.).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-s3
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic AWS S3 Configuration
|
||||
|
||||
```javascript
|
||||
import { Server } from '@hocuspocus/server'
|
||||
import { S3 } from '@hocuspocus/extension-s3'
|
||||
|
||||
const server = new Server({
|
||||
extensions: [
|
||||
new S3({
|
||||
bucket: 'my-documents-bucket',
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'your-access-key',
|
||||
secretAccessKey: 'your-secret-key'
|
||||
}
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
### MinIO Configuration
|
||||
|
||||
For local development with MinIO, ensure you set `forcePathStyle: true`:
|
||||
|
||||
```javascript
|
||||
const server = new Server({
|
||||
extensions: [
|
||||
new S3({
|
||||
bucket: 'hocuspocus-documents',
|
||||
endpoint: 'http://localhost:9000',
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin'
|
||||
}
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
- **bucket** (required): S3 bucket name where documents will be stored
|
||||
- **region**: AWS region (default: 'us-east-1')
|
||||
- **prefix**: Key prefix for documents (default: 'hocuspocus-documents/')
|
||||
- **credentials**: AWS credentials object with accessKeyId and secretAccessKey
|
||||
- **endpoint**: S3 endpoint URL (for S3-compatible services)
|
||||
- **forcePathStyle**: Use path-style URLs (required for MinIO, default: false)
|
||||
- **s3Client**: Custom S3Client instance
|
||||
|
||||
## Document Storage
|
||||
|
||||
Documents are stored as binary files in S3 with the naming convention:
|
||||
`{prefix}{documentName}.bin`
|
||||
|
||||
## Scaling with Redis
|
||||
|
||||
For horizontal scaling, combine S3 with the Redis extension. Redis handles real-time synchronization while S3 provides persistent storage.
|
||||
|
||||
```javascript
|
||||
import { Server } from '@hocuspocus/server'
|
||||
import { Logger } from '@hocuspocus/extension-logger'
|
||||
import { Redis } from '@hocuspocus/extension-redis'
|
||||
import { S3 } from '@hocuspocus/extension-s3'
|
||||
|
||||
// Server 1
|
||||
const server1 = new Server({
|
||||
name: "server-1",
|
||||
port: 8001,
|
||||
extensions: [
|
||||
new Logger(),
|
||||
new Redis({
|
||||
host: "127.0.0.1",
|
||||
port: 6379,
|
||||
}),
|
||||
new S3({
|
||||
bucket: 'hocuspocus-documents',
|
||||
endpoint: 'http://localhost:9000', // MinIO
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin'
|
||||
}
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// Server 2 - same configuration with different name/port
|
||||
const server2 = new Server({
|
||||
name: "server-2",
|
||||
port: 8002,
|
||||
extensions: [/* same extensions */],
|
||||
})
|
||||
|
||||
server1.listen()
|
||||
server2.listen()
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
You can also use the S3 extension with the Hocuspocus CLI:
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
hocuspocus --s3 --s3-bucket my-documents
|
||||
|
||||
# MinIO setup (forcePathStyle automatically enabled)
|
||||
hocuspocus --s3 --s3-bucket hocuspocus-documents --s3-endpoint http://localhost:9000
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
For local development with MinIO (S3-compatible storage):
|
||||
|
||||
```bash
|
||||
# Set up development environment
|
||||
npm run dev:setup
|
||||
|
||||
# Test S3 configuration
|
||||
npm run dev:test-s3
|
||||
|
||||
# Run S3 playground examples
|
||||
npm run playground:s3
|
||||
```
|
||||
|
||||
## Complete Documentation
|
||||
|
||||
For detailed documentation, examples, best practices, and troubleshooting, see:
|
||||
[📚 S3 Extension Documentation](https://tiptap.dev/docs/hocuspocus/server/extensions/s3)
|
||||
|
||||
## IAM Permissions
|
||||
|
||||
Your AWS credentials or IAM role needs the following permissions:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:GetObject", "s3:PutObject", "s3:HeadObject"],
|
||||
"Resource": "arn:aws:s3:::your-bucket-name/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:HeadBucket"],
|
||||
"Resource": "arn:aws:s3:::your-bucket-name"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
42
packages/extension-s3/package.json
Normal file
42
packages/extension-s3/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-s3",
|
||||
"description": "a S3-compatible persistence driver for Hocuspocus",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"yjs",
|
||||
"s3",
|
||||
"persistence"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-s3.cjs",
|
||||
"module": "dist/hocuspocus-s3.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-s3.esm.js",
|
||||
"require": "./dist/hocuspocus-s3.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.990.0",
|
||||
"@hocuspocus/extension-database": "workspace:^",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
}
|
||||
}
|
||||
197
packages/extension-s3/src/S3.ts
Normal file
197
packages/extension-s3/src/S3.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { DatabaseConfiguration } from "@hocuspocus/extension-database";
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
HeadObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import kleur from "kleur";
|
||||
|
||||
export interface S3Configuration extends DatabaseConfiguration {
|
||||
/**
|
||||
* AWS S3 region
|
||||
*/
|
||||
region?: string;
|
||||
/**
|
||||
* S3 bucket name
|
||||
*/
|
||||
bucket: string;
|
||||
/**
|
||||
* S3 key prefix for documents (optional)
|
||||
*/
|
||||
prefix?: string;
|
||||
/**
|
||||
* AWS credentials
|
||||
*/
|
||||
credentials?: {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
};
|
||||
/**
|
||||
* S3 endpoint URL (for S3-compatible services like MinIO)
|
||||
*/
|
||||
endpoint?: string;
|
||||
/**
|
||||
* Force path style URLs (required for MinIO)
|
||||
*/
|
||||
forcePathStyle?: boolean;
|
||||
/**
|
||||
* Custom S3 client
|
||||
*/
|
||||
s3Client?: S3Client;
|
||||
}
|
||||
|
||||
export class S3 extends Database {
|
||||
private s3Client?: S3Client;
|
||||
|
||||
configuration: S3Configuration = {
|
||||
region: "us-east-1",
|
||||
bucket: "",
|
||||
prefix: "hocuspocus-documents/",
|
||||
forcePathStyle: false,
|
||||
fetch: async ({ documentName }) => {
|
||||
const key = this.getObjectKey(documentName);
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.configuration.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await this.s3Client!.send(command);
|
||||
|
||||
if (response.Body) {
|
||||
// Convert stream to Uint8Array
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = response.Body.transformToWebStream().getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
// Combine all chunks into a single Uint8Array
|
||||
const totalLength = chunks.reduce(
|
||||
(acc, chunk) => acc + chunk.length,
|
||||
0,
|
||||
);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.name === "NoSuchKey" ||
|
||||
error.$metadata?.httpStatusCode === 404
|
||||
) {
|
||||
// Document doesn't exist yet, return null
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
store: async ({ documentName, state }) => {
|
||||
const key = this.getObjectKey(documentName);
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.configuration.bucket,
|
||||
Key: key,
|
||||
Body: state,
|
||||
ContentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
await this.s3Client!.send(command);
|
||||
},
|
||||
};
|
||||
|
||||
constructor(configuration: Partial<S3Configuration>) {
|
||||
super({});
|
||||
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
// Validate required configuration
|
||||
if (!this.configuration.bucket) {
|
||||
throw new Error("S3 bucket name is required");
|
||||
}
|
||||
}
|
||||
|
||||
private getObjectKey(documentName: string): string {
|
||||
const prefix = this.configuration.prefix || "";
|
||||
return `${prefix}${documentName}.bin`;
|
||||
}
|
||||
|
||||
async onConfigure() {
|
||||
// Use custom S3 client if provided, otherwise create one
|
||||
if (this.configuration.s3Client) {
|
||||
this.s3Client = this.configuration.s3Client;
|
||||
} else {
|
||||
const clientConfig: any = {
|
||||
region: this.configuration.region,
|
||||
};
|
||||
|
||||
if (this.configuration.credentials) {
|
||||
clientConfig.credentials = this.configuration.credentials;
|
||||
}
|
||||
|
||||
if (this.configuration.endpoint) {
|
||||
clientConfig.endpoint = this.configuration.endpoint;
|
||||
clientConfig.forcePathStyle = this.configuration.forcePathStyle;
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client(clientConfig);
|
||||
}
|
||||
|
||||
// Test S3 connection by checking if bucket exists
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.configuration.bucket,
|
||||
Key: "test-connection", // This will likely return 404, but that's fine
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
} catch (error: any) {
|
||||
// 404 is expected for the test key, any other error indicates connection issues
|
||||
if (error.$metadata?.httpStatusCode !== 404) {
|
||||
// Don't show credential errors as connection failures in development
|
||||
if (error.message?.includes("Could not load credentials")) {
|
||||
console.warn(` ${kleur.yellow("S3 warning:")} ${error.message}`);
|
||||
console.warn(
|
||||
` ${kleur.yellow("Note:")} Ensure AWS credentials are properly configured for production use`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
` ${kleur.red("S3 connection failed:")} ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onListen() {
|
||||
const endpoint =
|
||||
this.configuration.endpoint ||
|
||||
`https://s3.${this.configuration.region}.amazonaws.com`;
|
||||
console.log(
|
||||
` ${kleur.green("S3 extension configured:")} bucket=${this.configuration.bucket}, endpoint=${endpoint}`,
|
||||
);
|
||||
|
||||
if (this.configuration.prefix) {
|
||||
console.log(
|
||||
` ${kleur.blue("S3 key prefix:")} ${this.configuration.prefix}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/extension-s3/src/index.ts
Normal file
1
packages/extension-s3/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./S3.ts";
|
||||
1273
packages/extension-sqlite/CHANGELOG.md
Normal file
1273
packages/extension-sqlite/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
55
packages/extension-sqlite/README.md
Normal file
55
packages/extension-sqlite/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# @hocuspocus/extension-sqlite
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-sqlite)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-sqlite)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
Persist [Hocuspocus](https://github.com/ueberdosis/hocuspocus) documents to a local SQLite database. Zero-config storage for development, self-hosted single-instance deployments, and small production setups. Uses [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-sqlite
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { SQLite } from "@hocuspocus/extension-sqlite"
|
||||
|
||||
const server = new Server({
|
||||
extensions: [
|
||||
new SQLite({ database: "db.sqlite" }),
|
||||
],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
`database` accepts:
|
||||
- a filename (persisted to disk, e.g. `"db.sqlite"`)
|
||||
- `":memory:"` for an in-memory database
|
||||
- `""` for an anonymous on-disk database
|
||||
|
||||
Omit the option entirely to default to `":memory:"`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `Could not locate the bindings file`
|
||||
|
||||
`better-sqlite3` ships prebuilt native addons, but they don't always cover the newest Node.js release on day one. Rebuild from source in your server project:
|
||||
|
||||
```bash
|
||||
npm rebuild better-sqlite3 --build-from-source
|
||||
```
|
||||
|
||||
Needs a C++ toolchain (Xcode Command Line Tools on macOS, `build-essential` on Debian/Ubuntu, Visual Studio Build Tools on Windows).
|
||||
|
||||
## Documentation
|
||||
|
||||
Schema, custom `fetch`/`store`, and migration guides: [tiptap.dev/docs/hocuspocus/server/extensions/sqlite](https://tiptap.dev/docs/hocuspocus/server/extensions/sqlite).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
44
packages/extension-sqlite/package.json
Normal file
44
packages/extension-sqlite/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-sqlite",
|
||||
"description": "a generic Hocuspocus persistence driver for the sqlite",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-sqlite.cjs",
|
||||
"module": "dist/hocuspocus-sqlite.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-sqlite.esm.js",
|
||||
"require": "./dist/hocuspocus-sqlite.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/extension-database": "workspace:^",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"kleur": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
}
|
||||
}
|
||||
81
packages/extension-sqlite/src/SQLite.ts
Normal file
81
packages/extension-sqlite/src/SQLite.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { DatabaseConfiguration } from "@hocuspocus/extension-database";
|
||||
import { Database } from "@hocuspocus/extension-database";
|
||||
import BetterSqlite3 from "better-sqlite3";
|
||||
import kleur from "kleur";
|
||||
|
||||
export const schema = `CREATE TABLE IF NOT EXISTS "documents" (
|
||||
"name" varchar(255) NOT NULL,
|
||||
"data" blob NOT NULL,
|
||||
UNIQUE(name)
|
||||
)`;
|
||||
|
||||
export const selectQuery = `
|
||||
SELECT data FROM "documents" WHERE name = $name ORDER BY rowid DESC
|
||||
`;
|
||||
|
||||
export const upsertQuery = `
|
||||
INSERT INTO "documents" ("name", "data") VALUES ($name, $data)
|
||||
ON CONFLICT(name) DO UPDATE SET data = $data
|
||||
`;
|
||||
|
||||
const SQLITE_INMEMORY = ":memory:";
|
||||
|
||||
export interface SQLiteConfiguration extends DatabaseConfiguration {
|
||||
/**
|
||||
* Valid values are filenames, ":memory:" for an anonymous in-memory database and an empty
|
||||
* string for an anonymous disk-based database. Anonymous databases are not persisted and
|
||||
* when closing the database handle, their contents are lost.
|
||||
*
|
||||
* https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#new-daboratabasepath-options
|
||||
*/
|
||||
database: string;
|
||||
/**
|
||||
* The database schema to create.
|
||||
*/
|
||||
schema: string;
|
||||
}
|
||||
|
||||
export class SQLite extends Database {
|
||||
db?: BetterSqlite3.Database;
|
||||
|
||||
configuration: SQLiteConfiguration = {
|
||||
database: SQLITE_INMEMORY,
|
||||
schema,
|
||||
fetch: async ({ documentName }) => {
|
||||
const row = this.db
|
||||
?.prepare(selectQuery)
|
||||
.get({ name: documentName }) as { data: Buffer } | undefined;
|
||||
|
||||
return row?.data ?? null;
|
||||
},
|
||||
store: async ({ documentName, state }) => {
|
||||
this.db?.prepare(upsertQuery).run({
|
||||
name: documentName,
|
||||
data: state,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
constructor(configuration?: Partial<SQLiteConfiguration>) {
|
||||
super({});
|
||||
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
async onConfigure() {
|
||||
this.db = new BetterSqlite3(this.configuration.database);
|
||||
this.db.exec(this.configuration.schema);
|
||||
}
|
||||
|
||||
async onListen() {
|
||||
if (this.configuration.database === SQLITE_INMEMORY) {
|
||||
console.warn(
|
||||
` ${kleur.yellow("The SQLite extension is configured as an in-memory database. All changes will be lost on restart!")}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/extension-sqlite/src/index.ts
Normal file
1
packages/extension-sqlite/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SQLite.ts";
|
||||
1593
packages/extension-throttle/CHANGELOG.md
Normal file
1593
packages/extension-throttle/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/extension-throttle/README.md
Normal file
48
packages/extension-throttle/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @hocuspocus/extension-throttle
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-throttle)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
Rate-limit and throttle connections to a [Hocuspocus](https://github.com/ueberdosis/hocuspocus) server by IP. Protects your collaboration backend from abuse, accidental reconnect loops, and misbehaving clients.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-throttle
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
With default thresholds (15 connection attempts per 60 seconds, 5 minute ban):
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { Throttle } from "@hocuspocus/extension-throttle"
|
||||
|
||||
const server = new Server({
|
||||
extensions: [new Throttle()],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
### Tune the limits
|
||||
|
||||
```js
|
||||
new Throttle({
|
||||
throttle: 30, // allowed connection attempts per window
|
||||
consideredSeconds: 60, // window length in seconds
|
||||
banTime: 5, // ban duration in minutes
|
||||
})
|
||||
```
|
||||
|
||||
Pass `throttle: false` (or `null`) to effectively disable the limiter without removing the extension.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full options reference: [tiptap.dev/docs/hocuspocus/server/extensions/throttle](https://tiptap.dev/docs/hocuspocus/server/extensions/throttle).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
40
packages/extension-throttle/package.json
Normal file
40
packages/extension-throttle/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-throttle",
|
||||
"version": "4.7.0",
|
||||
"description": "hocuspocus throttle extension",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"throttle",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-throttle.cjs",
|
||||
"module": "dist/hocuspocus-throttle.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-throttle.esm.js",
|
||||
"require": "./dist/hocuspocus-throttle.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "workspace:^"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
126
packages/extension-throttle/src/index.ts
Normal file
126
packages/extension-throttle/src/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { Extension, onConnectPayload } from "@hocuspocus/server";
|
||||
|
||||
export interface ThrottleConfiguration {
|
||||
throttle: number | null | false; // how many requests within `consideredSeconds` until we're rejecting requests (setting this to 15 means the 16th request will be rejected)
|
||||
consideredSeconds: number; // how many seconds to consider (default is last 60 seconds from the current connection attempt)
|
||||
banTime: number; // for how long to ban after receiving too many requests (in minutes!)
|
||||
cleanupInterval: number; // how often to clean up the records of IPs (this won't delete ips that are still blocked or recent enough by `consideredSeconds`)
|
||||
}
|
||||
|
||||
export class Throttle implements Extension {
|
||||
configuration: ThrottleConfiguration = {
|
||||
throttle: 15,
|
||||
banTime: 5,
|
||||
consideredSeconds: 60,
|
||||
cleanupInterval: 90,
|
||||
};
|
||||
|
||||
connectionsByIp: Map<string, Array<number>> = new Map();
|
||||
|
||||
bannedIps: Map<string, number> = new Map();
|
||||
|
||||
cleanupInterval?: NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor(configuration?: Partial<ThrottleConfiguration>) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
this.cleanupInterval = setInterval(
|
||||
this.clearMaps.bind(this),
|
||||
this.configuration.cleanupInterval * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
onDestroy() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public clearMaps() {
|
||||
this.connectionsByIp.forEach((value, key) => {
|
||||
const filteredValue = value.filter(
|
||||
(timestamp) =>
|
||||
timestamp + this.configuration.consideredSeconds * 1000 > Date.now(),
|
||||
);
|
||||
|
||||
if (filteredValue.length) {
|
||||
this.connectionsByIp.set(key, filteredValue);
|
||||
} else {
|
||||
this.connectionsByIp.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
this.bannedIps.forEach((value, key) => {
|
||||
if (!this.isBanned(key)) {
|
||||
this.bannedIps.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isBanned(ip: string) {
|
||||
const bannedAt = this.bannedIps.get(ip) || 0;
|
||||
return Date.now() < bannedAt + this.configuration.banTime * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle requests
|
||||
* @private
|
||||
*/
|
||||
private throttle(ip: string): boolean {
|
||||
if (!this.configuration.throttle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isBanned(ip)) return true;
|
||||
|
||||
this.bannedIps.delete(ip);
|
||||
|
||||
// add this connection try to the list of previous connections
|
||||
const previousConnections = this.connectionsByIp.get(ip) || [];
|
||||
previousConnections.push(Date.now());
|
||||
|
||||
// calculate the previous connections in the last considered time interval
|
||||
const previousConnectionsInTheConsideredInterval =
|
||||
previousConnections.filter(
|
||||
(timestamp) =>
|
||||
timestamp + this.configuration.consideredSeconds * 1000 > Date.now(),
|
||||
);
|
||||
|
||||
this.connectionsByIp.set(ip, previousConnectionsInTheConsideredInterval);
|
||||
|
||||
if (
|
||||
previousConnectionsInTheConsideredInterval.length >
|
||||
this.configuration.throttle
|
||||
) {
|
||||
this.bannedIps.set(ip, Date.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* onConnect hook
|
||||
* @param data
|
||||
*/
|
||||
onConnect(data: onConnectPayload): Promise<any> {
|
||||
const { request } = data;
|
||||
|
||||
// get the remote ip address from headers (web standard Request doesn't have socket)
|
||||
const ip =
|
||||
request.headers.get("x-real-ip") ||
|
||||
request.headers.get("x-forwarded-for") ||
|
||||
"";
|
||||
|
||||
// throttle the connection
|
||||
return this.throttle(<string>ip) ? Promise.reject() : Promise.resolve();
|
||||
}
|
||||
}
|
||||
1666
packages/extension-webhook/CHANGELOG.md
Normal file
1666
packages/extension-webhook/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/extension-webhook/README.md
Normal file
44
packages/extension-webhook/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @hocuspocus/extension-webhook
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
Send HTTP webhooks for [Hocuspocus](https://github.com/ueberdosis/hocuspocus) document lifecycle events — document changes, connects, disconnects, and creation. Lets you integrate the collaboration backend with your own services (search indexing, audit logs, downstream data pipelines) without writing a custom extension.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/extension-webhook
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
import { Webhook, Events } from "@hocuspocus/extension-webhook"
|
||||
|
||||
const server = new Server({
|
||||
extensions: [
|
||||
new Webhook({
|
||||
url: "https://example.com/hocuspocus-webhook",
|
||||
secret: "your-signing-secret",
|
||||
events: [Events.onChange, Events.onConnect, Events.onDisconnect, Events.onCreate],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
Requests are signed with HMAC-SHA256 using your `secret`, sent in the `X-Hocuspocus-Signature-256` header so the receiver can verify authenticity.
|
||||
|
||||
`onChange` events are debounced (default `2000ms`, max wait `10000ms`) to avoid a request per keystroke — tune with the `debounce` and `debounceMaxWait` options.
|
||||
|
||||
## Documentation
|
||||
|
||||
Payload shapes, signature verification, and debounce tuning: [tiptap.dev/docs/hocuspocus/server/extensions/webhook](https://tiptap.dev/docs/hocuspocus/server/extensions/webhook).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
47
packages/extension-webhook/package.json
Normal file
47
packages/extension-webhook/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@hocuspocus/extension-webhook",
|
||||
"version": "4.7.0",
|
||||
"description": "hocuspocus webhook extension",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"webhook",
|
||||
"api",
|
||||
"rest",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-webhook.cjs",
|
||||
"module": "dist/hocuspocus-webhook.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-webhook.esm.js",
|
||||
"require": "./dist/hocuspocus-webhook.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "workspace:^",
|
||||
"@hocuspocus/server": "workspace:^",
|
||||
"@hocuspocus/transformer": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
227
packages/extension-webhook/src/index.ts
Normal file
227
packages/extension-webhook/src/index.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { Forbidden } from "@hocuspocus/common";
|
||||
import type {
|
||||
Extension,
|
||||
onChangePayload,
|
||||
onConnectPayload,
|
||||
onDisconnectPayload,
|
||||
onLoadDocumentPayload,
|
||||
} from "@hocuspocus/server";
|
||||
import type { Transformer } from "@hocuspocus/transformer";
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
import type { Doc } from "yjs";
|
||||
|
||||
export enum Events {
|
||||
onChange = "change",
|
||||
onConnect = "connect",
|
||||
onCreate = "create",
|
||||
onDisconnect = "disconnect",
|
||||
}
|
||||
|
||||
export interface Configuration {
|
||||
debounce: number | false | null;
|
||||
debounceMaxWait: number;
|
||||
secret: string;
|
||||
transformer:
|
||||
| Transformer
|
||||
| {
|
||||
toYdoc: (document: any) => Doc;
|
||||
fromYdoc: (document: Doc) => any;
|
||||
};
|
||||
url: string;
|
||||
events: Array<Events>;
|
||||
}
|
||||
|
||||
export class Webhook implements Extension {
|
||||
configuration: Configuration = {
|
||||
debounce: 2000,
|
||||
debounceMaxWait: 10000,
|
||||
secret: "",
|
||||
transformer: TiptapTransformer,
|
||||
url: "",
|
||||
events: [Events.onChange],
|
||||
};
|
||||
|
||||
debounced: Map<string, { timeout: NodeJS.Timeout; start: number }> =
|
||||
new Map();
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
constructor(configuration?: Partial<Configuration>) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
if (!this.configuration.url) {
|
||||
throw new Error("url is required!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signature for the response body
|
||||
*/
|
||||
createSignature(body: string): string {
|
||||
const hmac = createHmac("sha256", this.configuration.secret);
|
||||
|
||||
return `sha256=${hmac.update(body).digest("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* debounce the given function, using the given identifier
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
debounce(id: string, func: Function) {
|
||||
const old = this.debounced.get(id);
|
||||
const start = old?.start || Date.now();
|
||||
|
||||
const run = () => {
|
||||
this.debounced.delete(id);
|
||||
func();
|
||||
};
|
||||
|
||||
if (old?.timeout) clearTimeout(old.timeout);
|
||||
if (Date.now() - start >= this.configuration.debounceMaxWait) return run();
|
||||
|
||||
this.debounced.set(id, {
|
||||
start,
|
||||
timeout: setTimeout(run, <number>this.configuration.debounce),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the given url containing the given data
|
||||
*/
|
||||
async sendRequest(event: Events, payload: any) {
|
||||
const json = JSON.stringify({ event, payload });
|
||||
|
||||
const response = await fetch(this.configuration.url, {
|
||||
method: "POST",
|
||||
body: json,
|
||||
headers: {
|
||||
"X-Hocuspocus-Signature-256": this.createSignature(json),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Webhook request to ${this.configuration.url} failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const data =
|
||||
contentType.includes("application/json") && text
|
||||
? JSON.parse(text)
|
||||
: text;
|
||||
|
||||
return { status: response.status, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* onChange hook
|
||||
*/
|
||||
async onChange(data: onChangePayload) {
|
||||
if (!this.configuration.events.includes(Events.onChange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await this.sendRequest(Events.onChange, {
|
||||
document: this.configuration.transformer.fromYdoc(data.document),
|
||||
documentName: data.documentName,
|
||||
context: data.context,
|
||||
requestHeaders: data.requestHeaders,
|
||||
requestParameters: Object.fromEntries(
|
||||
data.requestParameters.entries(),
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Caught error in extension-webhook: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.configuration.debounce) {
|
||||
return save();
|
||||
}
|
||||
|
||||
this.debounce(data.documentName, save);
|
||||
}
|
||||
|
||||
/**
|
||||
* onLoadDocument hook
|
||||
*/
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
if (!this.configuration.events.includes(Events.onCreate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest(Events.onCreate, {
|
||||
documentName: data.documentName,
|
||||
requestHeaders: data.requestHeaders,
|
||||
requestParameters: Object.fromEntries(data.requestParameters.entries()),
|
||||
});
|
||||
|
||||
const document = response.data;
|
||||
if (!document) return;
|
||||
|
||||
// eslint-disable-next-line guard-for-in,no-restricted-syntax
|
||||
for (const fieldName in document) {
|
||||
if (data.document.isEmpty(fieldName)) {
|
||||
data.document.merge(
|
||||
this.configuration.transformer.toYdoc(
|
||||
document[fieldName],
|
||||
fieldName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Caught error in extension-webhook: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onConnect hook
|
||||
*/
|
||||
async onConnect(data: onConnectPayload) {
|
||||
if (!this.configuration.events.includes(Events.onConnect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest(Events.onConnect, {
|
||||
documentName: data.documentName,
|
||||
requestHeaders: data.requestHeaders,
|
||||
requestParameters: Object.fromEntries(data.requestParameters.entries()),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
console.error(`Caught error in extension-webhook: ${e}`);
|
||||
throw Forbidden;
|
||||
}
|
||||
}
|
||||
|
||||
async onDisconnect(data: onDisconnectPayload) {
|
||||
if (!this.configuration.events.includes(Events.onDisconnect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendRequest(Events.onDisconnect, {
|
||||
documentName: data.documentName,
|
||||
requestHeaders: data.requestHeaders,
|
||||
requestParameters: Object.fromEntries(data.requestParameters.entries()),
|
||||
context: data.context,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Caught error in extension-webhook: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
packages/provider-react/CHANGELOG.md
Normal file
138
packages/provider-react/CHANGELOG.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [4.7.0](https://github.com/ueberdosis/hocuspocus/compare/v4.6.0...v4.7.0) (2026-09-09)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.6.0](https://github.com/ueberdosis/hocuspocus/compare/v4.5.0...v4.6.0) (2026-08-10)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.5.0](https://github.com/ueberdosis/hocuspocus/compare/v4.4.0...v4.5.0) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update packages via audit --fix ([1dc9ca0](https://github.com/ueberdosis/hocuspocus/commit/1dc9ca0ff35f1033136473d134cee8cb6b336281))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.4.0](https://github.com/ueberdosis/hocuspocus/compare/v4.3.0...v4.4.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `flushDelay` option for batching updates to reduce websocket traffic during heavy editing ([#1118](https://github.com/ueberdosis/hocuspocus/issues/1118)) ([75594c0](https://github.com/ueberdosis/hocuspocus/commit/75594c05d57d48f2f70d4c9440c28b8226bf95ac))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.3.0](https://github.com/ueberdosis/hocuspocus/compare/v4.2.0...v4.3.0) (2026-06-18)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.2.0](https://github.com/ueberdosis/hocuspocus/compare/v4.1.2...v4.2.0) (2026-06-12)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.1.2](https://github.com/ueberdosis/hocuspocus/compare/v4.1.1...v4.1.2) (2026-06-11)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [4.1.1](https://github.com/ueberdosis/hocuspocus/compare/v4.1.0...v4.1.1) (2026-06-10)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.1.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0...v4.1.0) (2026-05-20)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.7...v4.0.0) (2026-04-23)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.7](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.6...v4.0.0-rc.7) (2026-04-22)
|
||||
|
||||
**Note:** Version bump only for package @hocuspocus/provider-react
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.6](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.5...v4.0.0-rc.6) (2026-04-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* react provider in strict mode ([4f27b7f](https://github.com/ueberdosis/hocuspocus/commit/4f27b7f0e57423b08fabb59cfa6d0a2cc67bc996))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.5](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2026-04-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adds package readmes ([c7d63f7](https://github.com/ueberdosis/hocuspocus/commit/c7d63f79980de3c34e63b60073f22774bf27ac13))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.4](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2026-04-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* publishConfig for provider-react ([376765f](https://github.com/ueberdosis/hocuspocus/commit/376765f6623259c79940da67a1f970ac9a1e84b1))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [4.0.0-rc.3](https://github.com/ueberdosis/hocuspocus/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2026-04-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* first draft of @hocuspocus/provider-react package ([#1059](https://github.com/ueberdosis/hocuspocus/issues/1059)) ([cbc9977](https://github.com/ueberdosis/hocuspocus/commit/cbc99770096e8c674dae304c7e1a16e0fbec827b))
|
||||
70
packages/provider-react/README.md
Normal file
70
packages/provider-react/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# @hocuspocus/provider-react
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider-react)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider-react)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
React bindings for the [Hocuspocus provider](../provider). Wraps the provider in components and hooks so React handles the lifecycle — including StrictMode double-mounts. Built on `useSyncExternalStore`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/provider @hocuspocus/provider-react yjs
|
||||
```
|
||||
|
||||
Requires React 18 or 19.
|
||||
|
||||
## Usage
|
||||
|
||||
Wrap your collaborative subtree with `HocuspocusProviderWebsocketComponent` (shared WebSocket) and one or more `HocuspocusRoom`s (one per document). Inside the room, hooks give you the provider and its state:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
HocuspocusProviderWebsocketComponent,
|
||||
HocuspocusRoom,
|
||||
useHocuspocusProvider,
|
||||
useHocuspocusConnectionStatus,
|
||||
} from "@hocuspocus/provider-react"
|
||||
|
||||
function Editor() {
|
||||
const provider = useHocuspocusProvider()
|
||||
const status = useHocuspocusConnectionStatus()
|
||||
// wire provider.document / provider.awareness into your editor
|
||||
return <div>{status}</div>
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
|
||||
<HocuspocusRoom name="example-document" token="super-secret-token">
|
||||
<Editor />
|
||||
</HocuspocusRoom>
|
||||
</HocuspocusProviderWebsocketComponent>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
To use with Tiptap, pass `provider.document` into the `Collaboration` extension and `provider` into `CollaborationCaret` — see the [collaborative editing guide](https://tiptap.dev/docs/hocuspocus/guides/collaborative-editing) for a full example.
|
||||
|
||||
## Exports
|
||||
|
||||
**Components**
|
||||
|
||||
- `HocuspocusProviderWebsocketComponent` — manages the shared WebSocket
|
||||
- `HocuspocusRoom` — creates a per-document provider on the shared socket; StrictMode-safe
|
||||
|
||||
**Hooks** (must be used inside a `HocuspocusRoom`)
|
||||
|
||||
- `useHocuspocusProvider()` — the `HocuspocusProvider` instance
|
||||
- `useHocuspocusConnectionStatus()` — `'connecting' | 'connected' | 'disconnected'`
|
||||
- `useHocuspocusSyncStatus()` — `'synced' | 'syncing'`
|
||||
- `useHocuspocusAwareness()` — array of connected users' awareness state
|
||||
- `useHocuspocusEvent(name, handler)` — subscribe to any provider event
|
||||
|
||||
## Documentation
|
||||
|
||||
Full components, hooks, and patterns reference: [tiptap.dev/docs/hocuspocus/provider/react](https://tiptap.dev/docs/hocuspocus/provider/react).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
47
packages/provider-react/package.json
Normal file
47
packages/provider-react/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@hocuspocus/provider-react",
|
||||
"version": "4.7.0",
|
||||
"description": "React bindings for Hocuspocus provider",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"websocket",
|
||||
"provider",
|
||||
"react",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-provider-react.cjs",
|
||||
"module": "dist/hocuspocus-provider-react.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-provider-react.esm.js",
|
||||
"require": "./dist/hocuspocus-provider-react.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@hocuspocus/provider": "workspace:^",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"react": "^19.2.4"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { HocuspocusProviderWebsocket } from "@hocuspocus/provider";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { HocuspocusContext } from "./context.ts";
|
||||
import type { HocuspocusProviderWebsocketComponentProps } from "./types.ts";
|
||||
|
||||
/**
|
||||
* HocuspocusProviderWebsocketComponent manages the WebSocket connection that is shared across all rooms.
|
||||
*
|
||||
* This component creates a single WebSocket connection that can be used by multiple
|
||||
* HocuspocusRoom components, preventing connection overhead when switching between documents.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <HocuspocusProviderWebsocketComponent url="ws://localhost:1234">
|
||||
* <HocuspocusRoom name="document-1">
|
||||
* <Editor />
|
||||
* </HocuspocusRoom>
|
||||
* </HocuspocusProviderWebsocketComponent>
|
||||
* ```
|
||||
*/
|
||||
export function HocuspocusProviderWebsocketComponent({
|
||||
children,
|
||||
url,
|
||||
websocketProvider: externalWebsocketProvider,
|
||||
}: HocuspocusProviderWebsocketComponentProps) {
|
||||
const websocketRef = useRef<HocuspocusProviderWebsocket | null>(null);
|
||||
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Create WebSocket provider once on mount.
|
||||
// Safe in StrictMode: useRef persists across double-renders, so the instance
|
||||
// is created only once. The deferred destruction in the effect below ensures
|
||||
// the second mount cancels the pending destroy before it fires.
|
||||
if (!websocketRef.current && !externalWebsocketProvider) {
|
||||
websocketRef.current = new HocuspocusProviderWebsocket({
|
||||
url: url!,
|
||||
});
|
||||
}
|
||||
|
||||
const websocketProvider =
|
||||
externalWebsocketProvider ??
|
||||
(websocketRef.current as HocuspocusProviderWebsocket);
|
||||
|
||||
// Cleanup on unmount with deferred destruction to handle StrictMode double-mount
|
||||
useEffect(() => {
|
||||
if (destroyTimeoutRef.current) {
|
||||
clearTimeout(destroyTimeoutRef.current);
|
||||
destroyTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Only destroy if we created the websocket (not externally provided)
|
||||
if (!externalWebsocketProvider) {
|
||||
destroyTimeoutRef.current = setTimeout(() => {
|
||||
if (websocketRef.current) {
|
||||
websocketRef.current.destroy();
|
||||
websocketRef.current = null;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
}, [externalWebsocketProvider]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
websocketProvider,
|
||||
}),
|
||||
[websocketProvider],
|
||||
);
|
||||
|
||||
return (
|
||||
<HocuspocusContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HocuspocusContext.Provider>
|
||||
);
|
||||
}
|
||||
168
packages/provider-react/src/HocuspocusRoom.tsx
Normal file
168
packages/provider-react/src/HocuspocusRoom.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
|
||||
import type { HocuspocusProviderEvents, HocuspocusRoomProps } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Mapping from HocuspocusRoom `on*` prop names to provider event names.
|
||||
*/
|
||||
const EVENT_PROP_MAP: Record<string, keyof HocuspocusProviderEvents> = {
|
||||
onOpen: "open",
|
||||
onConnect: "connect",
|
||||
onClose: "close",
|
||||
onDisconnect: "disconnect",
|
||||
onStatus: "status",
|
||||
onSynced: "synced",
|
||||
onUnsyncedChanges: "unsyncedChanges",
|
||||
onMessage: "message",
|
||||
onOutgoingMessage: "outgoingMessage",
|
||||
onStateless: "stateless",
|
||||
onAuthenticated: "authenticated",
|
||||
onAuthenticationFailed: "authenticationFailed",
|
||||
onAwarenessUpdate: "awarenessUpdate",
|
||||
onAwarenessChange: "awarenessChange",
|
||||
onDestroy: "destroy",
|
||||
};
|
||||
|
||||
/**
|
||||
* HocuspocusRoom manages the connection to a specific document.
|
||||
*
|
||||
* It uses the shared WebSocket from HocuspocusProviderWebsocketComponent and creates a document-specific
|
||||
* provider that connects on mount and disconnects on unmount.
|
||||
*
|
||||
* This component handles React's StrictMode gracefully by using deferred destruction,
|
||||
* preventing unnecessary reconnections during development double-mounts.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <HocuspocusProviderWebsocketComponent url="ws://localhost:1234">
|
||||
* <HocuspocusRoom
|
||||
* name="document-1"
|
||||
* onAuthenticationFailed={(data) => console.error(data.reason)}
|
||||
* >
|
||||
* <Editor />
|
||||
* </HocuspocusRoom>
|
||||
* </HocuspocusProviderWebsocketComponent>
|
||||
* ```
|
||||
*/
|
||||
export function HocuspocusRoom({
|
||||
children,
|
||||
name,
|
||||
document,
|
||||
token,
|
||||
sessionAwareness,
|
||||
flushDelay,
|
||||
...eventHandlers
|
||||
}: HocuspocusRoomProps) {
|
||||
const hocuspocusContext = useContext(HocuspocusContext);
|
||||
|
||||
if (!hocuspocusContext) {
|
||||
throw new Error(
|
||||
"HocuspocusRoom must be used within a HocuspocusProviderWebsocketComponent",
|
||||
);
|
||||
}
|
||||
|
||||
const { websocketProvider } = hocuspocusContext;
|
||||
|
||||
const [provider, setProvider] = useState(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name,
|
||||
websocketProvider,
|
||||
document,
|
||||
token,
|
||||
sessionAwareness,
|
||||
flushDelay,
|
||||
}),
|
||||
);
|
||||
|
||||
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Recreate provider when name, document, or token changes.
|
||||
// Only compare `document`/`token` when the caller actually provided them —
|
||||
// otherwise the provider's constructor-initialized defaults (auto-created
|
||||
// Y.Doc, null token) will never match the undefined prop, causing a destroy
|
||||
// on every render (and in StrictMode, a stray CloseMessage for the current
|
||||
// doc once the provider is already attached).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: provider.configuration holds the previous values we compare against — not a reactive dependency
|
||||
useEffect(() => {
|
||||
const shouldRecreate =
|
||||
provider.configuration.name !== name ||
|
||||
(document !== undefined &&
|
||||
provider.configuration.document !== document) ||
|
||||
(token !== undefined && provider.configuration.token !== token) ||
|
||||
provider.configuration.websocketProvider !== websocketProvider;
|
||||
|
||||
if (shouldRecreate) {
|
||||
provider.destroy();
|
||||
setProvider(
|
||||
new HocuspocusProvider({
|
||||
name,
|
||||
websocketProvider,
|
||||
document,
|
||||
token,
|
||||
sessionAwareness,
|
||||
flushDelay,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [name, document, token, websocketProvider]);
|
||||
|
||||
// Attach/detach lifecycle with deferred destruction for StrictMode
|
||||
useEffect(() => {
|
||||
if (destroyTimeoutRef.current) {
|
||||
clearTimeout(destroyTimeoutRef.current);
|
||||
destroyTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
provider.attach();
|
||||
|
||||
return () => {
|
||||
destroyTimeoutRef.current = setTimeout(() => {
|
||||
provider.destroy();
|
||||
}, 0);
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
// Wire up on* event handler props with stable refs
|
||||
const handlersRef = useRef(eventHandlers);
|
||||
handlersRef.current = eventHandlers;
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
for (const [propName, eventName] of Object.entries(EVENT_PROP_MAP)) {
|
||||
const listener = (...args: unknown[]) => {
|
||||
const handler = handlersRef.current[
|
||||
propName as keyof typeof handlersRef.current
|
||||
] as ((...a: unknown[]) => void) | undefined;
|
||||
handler?.(...args);
|
||||
};
|
||||
provider.on(eventName, listener);
|
||||
cleanups.push(() => provider.off(eventName, listener));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
provider,
|
||||
}),
|
||||
[provider],
|
||||
);
|
||||
|
||||
return (
|
||||
<HocuspocusRoomContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HocuspocusRoomContext.Provider>
|
||||
);
|
||||
}
|
||||
18
packages/provider-react/src/context.ts
Normal file
18
packages/provider-react/src/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
import type {
|
||||
HocuspocusContextValue,
|
||||
HocuspocusRoomContextValue,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* Context for the WebSocket connection shared across rooms
|
||||
*/
|
||||
export const HocuspocusContext =
|
||||
createContext<HocuspocusContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Context for the room/document provider
|
||||
*/
|
||||
export const HocuspocusRoomContext =
|
||||
createContext<HocuspocusRoomContextValue | null>(null);
|
||||
5
packages/provider-react/src/hooks/index.ts
Normal file
5
packages/provider-react/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useHocuspocusAwareness } from "./useHocuspocusAwareness.ts";
|
||||
export { useHocuspocusConnectionStatus } from "./useHocuspocusConnectionStatus.ts";
|
||||
export { useHocuspocusEvent } from "./useHocuspocusEvent.ts";
|
||||
export { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
export { useHocuspocusSyncStatus } from "./useHocuspocusSyncStatus.ts";
|
||||
89
packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Normal file
89
packages/provider-react/src/hooks/useHocuspocusAwareness.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
import type { CollabUser } from "../types.ts";
|
||||
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
|
||||
/**
|
||||
* Subscribe to the list of connected users in the document.
|
||||
*
|
||||
* This hook uses the Yjs awareness protocol to track users currently
|
||||
* connected to the document.
|
||||
*
|
||||
* @returns Array of user objects with their awareness state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function UserList() {
|
||||
* const users = useHocuspocusAwareness()
|
||||
*
|
||||
* return (
|
||||
* <div className="avatars">
|
||||
* {users.map(user => (
|
||||
* <div
|
||||
* key={user.clientId}
|
||||
* style={{ backgroundColor: user.color }}
|
||||
* title={user.name}
|
||||
* >
|
||||
* {user.name?.[0]}
|
||||
* </div>
|
||||
* ))}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusAwareness(): CollabUser[] {
|
||||
const provider = useHocuspocusProvider();
|
||||
|
||||
const versionRef = useRef(0);
|
||||
const cacheRef = useRef<{ users: CollabUser[]; version: number }>({
|
||||
users: [],
|
||||
version: -1,
|
||||
});
|
||||
|
||||
// Reset cache when provider changes so we don't serve stale users
|
||||
// from a previous provider instance.
|
||||
useEffect(() => {
|
||||
versionRef.current++;
|
||||
cacheRef.current = { users: [], version: -1 };
|
||||
}, [provider]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
const onChange = () => {
|
||||
versionRef.current++;
|
||||
onStoreChange();
|
||||
};
|
||||
provider.awareness?.on("change", onChange);
|
||||
return () => {
|
||||
provider.awareness?.off("change", onChange);
|
||||
};
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
const awareness = provider.awareness;
|
||||
if (!awareness) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return cached value if awareness hasn't changed since last snapshot
|
||||
if (cacheRef.current.version === versionRef.current) {
|
||||
return cacheRef.current.users;
|
||||
}
|
||||
|
||||
const users: CollabUser[] = [];
|
||||
awareness.getStates().forEach((state, clientId) => {
|
||||
users.push({
|
||||
clientId,
|
||||
...state,
|
||||
});
|
||||
});
|
||||
|
||||
cacheRef.current = { users, version: versionRef.current };
|
||||
return users;
|
||||
}, [provider]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import type { ConnectionStatus } from "../types.ts";
|
||||
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
|
||||
/**
|
||||
* Subscribe to the connection status of the collaboration provider.
|
||||
*
|
||||
* This hook uses React's useSyncExternalStore for optimal integration with
|
||||
* concurrent rendering features.
|
||||
*
|
||||
* @returns The current connection status: 'connecting', 'connected', or 'disconnected'
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ConnectionIndicator() {
|
||||
* const status = useHocuspocusConnectionStatus()
|
||||
*
|
||||
* return (
|
||||
* <div className={`status-${status}`}>
|
||||
* {status === 'connected' ? 'Online' : status === 'connecting' ? 'Connecting...' : 'Offline'}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusConnectionStatus(): ConnectionStatus {
|
||||
const provider = useHocuspocusProvider();
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
provider.on("status", onStoreChange);
|
||||
return () => {
|
||||
provider.off("status", onStoreChange);
|
||||
};
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(
|
||||
(): ConnectionStatus =>
|
||||
provider.configuration.websocketProvider.status as ConnectionStatus,
|
||||
[provider],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
52
packages/provider-react/src/hooks/useHocuspocusEvent.ts
Normal file
52
packages/provider-react/src/hooks/useHocuspocusEvent.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { HocuspocusProviderEvents } from "../types.ts";
|
||||
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
|
||||
/**
|
||||
* Subscribe to events from the HocuspocusProvider for the current room.
|
||||
*
|
||||
* The handler is stored in a ref so the subscription stays stable even if
|
||||
* the handler identity changes between renders.
|
||||
*
|
||||
* Must be used within a HocuspocusRoom component.
|
||||
*
|
||||
* @param event - The event name to subscribe to
|
||||
* @param handler - Callback invoked when the event fires
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function AuthGuard() {
|
||||
* useHocuspocusEvent('authenticationFailed', (data) => {
|
||||
* console.error('Auth failed:', data.reason)
|
||||
* redirectToLogin()
|
||||
* })
|
||||
*
|
||||
* useHocuspocusEvent('close', (data) => {
|
||||
* console.log('Connection closed', data.event)
|
||||
* })
|
||||
*
|
||||
* return null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusEvent<E extends keyof HocuspocusProviderEvents>(
|
||||
event: E,
|
||||
handler: HocuspocusProviderEvents[E] extends undefined
|
||||
? () => void
|
||||
: (data: HocuspocusProviderEvents[E]) => void,
|
||||
): void {
|
||||
const provider = useHocuspocusProvider();
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (...args: unknown[]) => {
|
||||
(handlerRef.current as (...a: unknown[]) => void)(...args);
|
||||
};
|
||||
provider.on(event, listener);
|
||||
return () => {
|
||||
provider.off(event, listener);
|
||||
};
|
||||
}, [provider, event]);
|
||||
}
|
||||
37
packages/provider-react/src/hooks/useHocuspocusProvider.ts
Normal file
37
packages/provider-react/src/hooks/useHocuspocusProvider.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { HocuspocusRoomContext } from "../context.ts";
|
||||
|
||||
/**
|
||||
* Access the HocuspocusProvider instance for the current room.
|
||||
*
|
||||
* Must be used within a HocuspocusRoom component.
|
||||
*
|
||||
* @returns The HocuspocusProvider instance
|
||||
* @throws Error if used outside of HocuspocusRoom
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function Editor() {
|
||||
* const provider = useHocuspocusProvider()
|
||||
*
|
||||
* const editor = useEditor({
|
||||
* extensions: [
|
||||
* Collaboration.configure({ document: provider.document }),
|
||||
* CollaborationCursor.configure({ provider }),
|
||||
* ],
|
||||
* })
|
||||
*
|
||||
* return <EditorContent editor={editor} />
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusProvider() {
|
||||
const context = useContext(HocuspocusRoomContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useHocuspocusProvider must be used within a HocuspocusRoom");
|
||||
}
|
||||
|
||||
return context.provider;
|
||||
}
|
||||
51
packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts
Normal file
51
packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import type { SyncStatus } from "../types.ts";
|
||||
import { useHocuspocusProvider } from "./useHocuspocusProvider.ts";
|
||||
|
||||
/**
|
||||
* Subscribe to the sync status of local changes.
|
||||
*
|
||||
* This hook indicates whether local changes have been synced with the server.
|
||||
*
|
||||
* @returns The current sync status: 'synced' or 'syncing'
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SaveIndicator() {
|
||||
* const syncStatus = useHocuspocusSyncStatus()
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {syncStatus === 'syncing' ? 'Saving...' : 'All changes saved'}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHocuspocusSyncStatus(): SyncStatus {
|
||||
const provider = useHocuspocusProvider();
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) => {
|
||||
provider.on("synced", onStoreChange);
|
||||
provider.on("unsyncedChanges", onStoreChange);
|
||||
|
||||
return () => {
|
||||
provider.off("synced", onStoreChange);
|
||||
provider.off("unsyncedChanges", onStoreChange);
|
||||
};
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback((): SyncStatus => {
|
||||
// Check if there are unsynced changes
|
||||
if (provider.unsyncedChanges > 0) {
|
||||
return "syncing";
|
||||
}
|
||||
return "synced";
|
||||
}, [provider]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
29
packages/provider-react/src/index.ts
Normal file
29
packages/provider-react/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
// Contexts
|
||||
export { HocuspocusContext, HocuspocusRoomContext } from "./context.ts";
|
||||
|
||||
// Components
|
||||
export { HocuspocusProviderWebsocketComponent } from "./HocuspocusProviderWebsocketComponent.tsx";
|
||||
export { HocuspocusRoom } from "./HocuspocusRoom.tsx";
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useHocuspocusAwareness,
|
||||
useHocuspocusConnectionStatus,
|
||||
useHocuspocusEvent,
|
||||
useHocuspocusProvider,
|
||||
useHocuspocusSyncStatus,
|
||||
} from "./hooks/index.ts";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
CollabUser,
|
||||
ConnectionStatus,
|
||||
HocuspocusContextValue,
|
||||
HocuspocusProviderEvents,
|
||||
HocuspocusProviderWebsocketComponentProps,
|
||||
HocuspocusRoomContextValue,
|
||||
HocuspocusRoomProps,
|
||||
SyncStatus,
|
||||
} from "./types.ts";
|
||||
155
packages/provider-react/src/types.ts
Normal file
155
packages/provider-react/src/types.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
HocuspocusProvider,
|
||||
HocuspocusProviderWebsocket,
|
||||
onAuthenticatedParameters,
|
||||
onAuthenticationFailedParameters,
|
||||
onAwarenessChangeParameters,
|
||||
onAwarenessUpdateParameters,
|
||||
onCloseParameters,
|
||||
onDisconnectParameters,
|
||||
onMessageParameters,
|
||||
onOpenParameters,
|
||||
onOutgoingMessageParameters,
|
||||
onStatelessParameters,
|
||||
onStatusParameters,
|
||||
onSyncedParameters,
|
||||
onUnsyncedChangesParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import type { ReactNode } from "react";
|
||||
import type * as Y from "yjs";
|
||||
|
||||
/**
|
||||
* Configuration for HocuspocusProvider component.
|
||||
*
|
||||
* Provide either a `url` to create a managed WebSocket connection,
|
||||
* or a `websocketProvider` instance for full control — but not both.
|
||||
*/
|
||||
export type HocuspocusProviderWebsocketComponentProps = {
|
||||
children: ReactNode;
|
||||
} & (
|
||||
| {
|
||||
/**
|
||||
* URL of your @hocuspocus/server instance
|
||||
*/
|
||||
url: string;
|
||||
websocketProvider?: never;
|
||||
}
|
||||
| {
|
||||
url?: never;
|
||||
/**
|
||||
* Provide your own websocket instance for full control
|
||||
*/
|
||||
websocketProvider: HocuspocusProviderWebsocket;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Map of HocuspocusProvider event names to their payload types.
|
||||
* Used by `useHocuspocusEvent` and the `HocuspocusRoom` event handler props.
|
||||
*/
|
||||
export interface HocuspocusProviderEvents {
|
||||
open: onOpenParameters;
|
||||
connect: undefined;
|
||||
close: onCloseParameters;
|
||||
disconnect: onDisconnectParameters;
|
||||
status: onStatusParameters;
|
||||
synced: onSyncedParameters;
|
||||
unsyncedChanges: onUnsyncedChangesParameters;
|
||||
message: onMessageParameters;
|
||||
outgoingMessage: onOutgoingMessageParameters;
|
||||
stateless: onStatelessParameters;
|
||||
authenticated: onAuthenticatedParameters;
|
||||
authenticationFailed: onAuthenticationFailedParameters;
|
||||
awarenessUpdate: onAwarenessUpdateParameters;
|
||||
awarenessChange: onAwarenessChangeParameters;
|
||||
destroy: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for HocuspocusRoom component
|
||||
*/
|
||||
export interface HocuspocusRoomProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* The document name (required)
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Optional: bring your own Y.Doc
|
||||
*/
|
||||
document?: Y.Doc;
|
||||
/**
|
||||
* JWT token or function that returns a promise resolving to a token
|
||||
*/
|
||||
token?: string | (() => Promise<string>) | (() => string);
|
||||
/**
|
||||
* Enable session-aware multiplexing. When true, the provider embeds a unique
|
||||
* sessionId in the documentName field of every message, allowing multiple
|
||||
* providers with the same document name on a single WebSocket connection.
|
||||
*
|
||||
* Only set this to `true` when connecting to a v4 server that does
|
||||
* support session awareness.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
sessionAwareness?: boolean;
|
||||
|
||||
/**
|
||||
* Batch outgoing document and awareness updates over a short window (in
|
||||
* milliseconds) instead of sending one message per change, to reduce the
|
||||
* number of websocket messages during heavy editing. The added latency is
|
||||
* capped at `flushDelay`, so keep it small (e.g. 500).
|
||||
*
|
||||
* Default: false (send every change immediately)
|
||||
*/
|
||||
flushDelay?: false | number;
|
||||
|
||||
// Event handlers — all optional, called when the provider emits the corresponding event.
|
||||
onOpen?: (data: onOpenParameters) => void;
|
||||
onConnect?: () => void;
|
||||
onClose?: (data: onCloseParameters) => void;
|
||||
onDisconnect?: (data: onDisconnectParameters) => void;
|
||||
onStatus?: (data: onStatusParameters) => void;
|
||||
onSynced?: (data: onSyncedParameters) => void;
|
||||
onUnsyncedChanges?: (data: onUnsyncedChangesParameters) => void;
|
||||
onMessage?: (data: onMessageParameters) => void;
|
||||
onOutgoingMessage?: (data: onOutgoingMessageParameters) => void;
|
||||
onStateless?: (data: onStatelessParameters) => void;
|
||||
onAuthenticated?: (data: onAuthenticatedParameters) => void;
|
||||
onAuthenticationFailed?: (data: onAuthenticationFailedParameters) => void;
|
||||
onAwarenessUpdate?: (data: onAwarenessUpdateParameters) => void;
|
||||
onAwarenessChange?: (data: onAwarenessChangeParameters) => void;
|
||||
onDestroy?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for the WebSocket provider
|
||||
*/
|
||||
export interface HocuspocusContextValue {
|
||||
websocketProvider: HocuspocusProviderWebsocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for the room/document provider
|
||||
*/
|
||||
export interface HocuspocusRoomContextValue {
|
||||
provider: HocuspocusProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection status for the collaboration provider
|
||||
*/
|
||||
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
|
||||
|
||||
/**
|
||||
* Sync status indicating whether local changes are synced with server
|
||||
*/
|
||||
export type SyncStatus = "synced" | "syncing";
|
||||
|
||||
/**
|
||||
* User information from awareness
|
||||
*/
|
||||
export interface CollabUser {
|
||||
clientId: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
6
packages/provider-react/tsconfig.json
Normal file
6
packages/provider-react/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
1492
packages/provider/CHANGELOG.md
Normal file
1492
packages/provider/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
71
packages/provider/README.md
Normal file
71
packages/provider/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# @hocuspocus/provider
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/provider)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
The client-side provider for [Hocuspocus](https://github.com/ueberdosis/hocuspocus). Connects to a Hocuspocus server over WebSockets and syncs one or more [Y.js](https://github.com/yjs/yjs) documents — including awareness (presence), authentication, and stateless messaging.
|
||||
|
||||
> **Building a React app?** Use [`@hocuspocus/provider-react`](../provider-react) instead — it wraps the provider in components and hooks so React handles the lifecycle for you (including StrictMode double-mounts).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/provider yjs
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import * as Y from "yjs"
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider"
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
url: "ws://127.0.0.1:1234",
|
||||
name: "example-document",
|
||||
document: ydoc,
|
||||
})
|
||||
```
|
||||
|
||||
Changes to `ydoc` are now synced to every other client connected to the same `name`.
|
||||
|
||||
### Authenticating
|
||||
|
||||
Pass a `token` — it's forwarded to the server's `onAuthenticate` hook:
|
||||
|
||||
```js
|
||||
new HocuspocusProvider({
|
||||
url: "wss://collab.example.com",
|
||||
name: "example-document",
|
||||
document: ydoc,
|
||||
token: "super-secret-token",
|
||||
})
|
||||
```
|
||||
|
||||
### Sharing a socket across documents
|
||||
|
||||
Create a `HocuspocusProviderWebsocket` once, then reuse it for multiple documents:
|
||||
|
||||
```js
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
HocuspocusProviderWebsocket,
|
||||
} from "@hocuspocus/provider"
|
||||
|
||||
const socket = new HocuspocusProviderWebsocket({ url: "ws://127.0.0.1:1234" })
|
||||
|
||||
const doc1 = new HocuspocusProvider({ websocketProvider: socket, name: "doc-1" })
|
||||
const doc2 = new HocuspocusProvider({ websocketProvider: socket, name: "doc-2" })
|
||||
```
|
||||
|
||||
Call `provider.destroy()` to disconnect a single document. Call `socket.destroy()` to tear down the shared connection.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full configuration, events, and awareness reference: [tiptap.dev/docs/hocuspocus/provider](https://tiptap.dev/docs/hocuspocus/provider/overview).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
47
packages/provider/package.json
Normal file
47
packages/provider/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@hocuspocus/provider",
|
||||
"version": "4.7.0",
|
||||
"description": "hocuspocus provider",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"websocket",
|
||||
"provider",
|
||||
"yjs"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-provider.cjs",
|
||||
"module": "dist/hocuspocus-provider.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-provider.esm.js",
|
||||
"require": "./dist/hocuspocus-provider.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "workspace:^",
|
||||
"@lifeomic/attempt": "^3.1.0",
|
||||
"lib0": "^0.2.117"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"gitHead": "cd788b6a315f608ef531524409abdce1e6790726",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
44
packages/provider/src/EventEmitter.ts
Normal file
44
packages/provider/src/EventEmitter.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export default class EventEmitter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
public callbacks: { [key: string]: Function[] } = {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
public on(event: string, fn: Function): this {
|
||||
if (!this.callbacks[event]) {
|
||||
this.callbacks[event] = [];
|
||||
}
|
||||
|
||||
this.callbacks[event].push(fn);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected emit(event: string, ...args: any): this {
|
||||
const callbacks = this.callbacks[event];
|
||||
|
||||
if (callbacks) {
|
||||
callbacks.forEach((callback) => callback.apply(this, args));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
public off(event: string, fn?: Function): this {
|
||||
const callbacks = this.callbacks[event];
|
||||
|
||||
if (callbacks) {
|
||||
if (fn) {
|
||||
this.callbacks[event] = callbacks.filter((callback) => callback !== fn);
|
||||
} else {
|
||||
delete this.callbacks[event];
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.callbacks = {};
|
||||
}
|
||||
}
|
||||
760
packages/provider/src/HocuspocusProvider.ts
Normal file
760
packages/provider/src/HocuspocusProvider.ts
Normal file
@@ -0,0 +1,760 @@
|
||||
import {
|
||||
awarenessStatesToArray,
|
||||
makeRoutingKey,
|
||||
parseRoutingKey,
|
||||
} from "@hocuspocus/common";
|
||||
import { Awareness, removeAwarenessStates } from "y-protocols/awareness";
|
||||
import * as Y from "yjs";
|
||||
import EventEmitter from "./EventEmitter.ts";
|
||||
import type { CompleteHocuspocusProviderWebsocketConfiguration } from "./HocuspocusProviderWebsocket.ts";
|
||||
import { HocuspocusProviderWebsocket } from "./HocuspocusProviderWebsocket.ts";
|
||||
import { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { MessageReceiver } from "./MessageReceiver.ts";
|
||||
import { MessageSender } from "./MessageSender.ts";
|
||||
import { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.ts";
|
||||
import { AwarenessMessage } from "./OutgoingMessages/AwarenessMessage.ts";
|
||||
import { StatelessMessage } from "./OutgoingMessages/StatelessMessage.ts";
|
||||
import { SyncStepOneMessage } from "./OutgoingMessages/SyncStepOneMessage.ts";
|
||||
import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
|
||||
import type {
|
||||
AuthorizedScope,
|
||||
ConstructableOutgoingMessage,
|
||||
onAuthenticatedParameters,
|
||||
onAuthenticationFailedParameters,
|
||||
onAwarenessChangeParameters,
|
||||
onAwarenessUpdateParameters,
|
||||
onCloseParameters,
|
||||
onDisconnectParameters,
|
||||
onMessageParameters,
|
||||
onOpenParameters,
|
||||
onOutgoingMessageParameters,
|
||||
onStatelessParameters,
|
||||
onStatusParameters,
|
||||
onSyncedParameters,
|
||||
onUnsyncedChangesParameters,
|
||||
} from "./types.ts";
|
||||
|
||||
export type HocuspocusProviderConfiguration = Required<
|
||||
Pick<CompleteHocuspocusProviderConfiguration, "name">
|
||||
> &
|
||||
Partial<CompleteHocuspocusProviderConfiguration> &
|
||||
(
|
||||
| (Required<Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">> &
|
||||
Partial<
|
||||
Pick<
|
||||
CompleteHocuspocusProviderWebsocketConfiguration,
|
||||
"preserveTrailingSlash"
|
||||
>
|
||||
>)
|
||||
| Required<
|
||||
Pick<CompleteHocuspocusProviderConfiguration, "websocketProvider">
|
||||
>
|
||||
);
|
||||
|
||||
export interface CompleteHocuspocusProviderConfiguration {
|
||||
/**
|
||||
* The identifier/name of your document
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The actual Y.js document
|
||||
*/
|
||||
document: Y.Doc;
|
||||
|
||||
/**
|
||||
* An Awareness instance to keep the presence state of all clients.
|
||||
*
|
||||
* You can disable sharing awareness information by passing `null`.
|
||||
* Note that having no awareness information shared across all connections will break our ping checks
|
||||
* and thus trigger reconnects. You should always have at least one Provider with enabled awareness per
|
||||
* socket connection, or ensure that the Provider receives messages before running into `HocuspocusProviderWebsocket.messageReconnectTimeout`.
|
||||
*/
|
||||
awareness: Awareness | null;
|
||||
|
||||
/**
|
||||
* A token that’s sent to the backend for authentication purposes.
|
||||
*/
|
||||
token: string | (() => string) | (() => Promise<string>) | null;
|
||||
|
||||
/**
|
||||
* Hocuspocus websocket provider
|
||||
*/
|
||||
websocketProvider: HocuspocusProviderWebsocket;
|
||||
|
||||
/**
|
||||
* Enable session-aware multiplexing. When true, the provider embeds a unique
|
||||
* sessionId in the documentName field of every message, allowing multiple
|
||||
* providers with the same document name on a single WebSocket connection.
|
||||
*
|
||||
* Only set this to `true` when connecting to a v4 server that does
|
||||
* support session awareness.
|
||||
*
|
||||
* Default: false
|
||||
*/
|
||||
sessionAwareness: boolean;
|
||||
|
||||
/**
|
||||
* Force syncing the document in the defined interval.
|
||||
*/
|
||||
forceSyncInterval: false | number;
|
||||
|
||||
/**
|
||||
* Batch outgoing document and awareness updates over a short window (in
|
||||
* milliseconds) instead of sending one message per change. During heavy
|
||||
* editing this drastically reduces the number of websocket messages: Yjs
|
||||
* updates collected in the window are merged with `Y.mergeUpdates` into a
|
||||
* single message, and awareness collapses to the latest state of each
|
||||
* changed client.
|
||||
*
|
||||
* The window is a fixed batch, not a resetting debounce, so the added
|
||||
* latency is capped at `flushDelay` even while the user keeps typing. Keep
|
||||
* it small (e.g. 500) to avoid delaying what other clients see.
|
||||
*
|
||||
* Set to `false` (the default) to send every change immediately.
|
||||
*/
|
||||
flushDelay: false | number;
|
||||
|
||||
onAuthenticated: (data: onAuthenticatedParameters) => void;
|
||||
onAuthenticationFailed: (data: onAuthenticationFailedParameters) => void;
|
||||
onOpen: (data: onOpenParameters) => void;
|
||||
onConnect: () => void;
|
||||
onStatus: (data: onStatusParameters) => void;
|
||||
onMessage: (data: onMessageParameters) => void;
|
||||
onOutgoingMessage: (data: onOutgoingMessageParameters) => void;
|
||||
onSynced: (data: onSyncedParameters) => void;
|
||||
onDisconnect: (data: onDisconnectParameters) => void;
|
||||
onClose: (data: onCloseParameters) => void;
|
||||
onDestroy: () => void;
|
||||
onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
|
||||
onAwarenessChange: (data: onAwarenessChangeParameters) => void;
|
||||
onStateless: (data: onStatelessParameters) => void;
|
||||
onUnsyncedChanges: (data: onUnsyncedChangesParameters) => void;
|
||||
}
|
||||
|
||||
export class AwarenessError extends Error {
|
||||
code = 1001;
|
||||
}
|
||||
|
||||
export class HocuspocusProvider extends EventEmitter {
|
||||
public configuration: CompleteHocuspocusProviderConfiguration = {
|
||||
name: "",
|
||||
// @ts-expect-error
|
||||
document: undefined,
|
||||
// @ts-expect-error
|
||||
awareness: undefined,
|
||||
token: null,
|
||||
sessionAwareness: false,
|
||||
forceSyncInterval: false,
|
||||
flushDelay: false,
|
||||
onAuthenticated: () => null,
|
||||
onAuthenticationFailed: () => null,
|
||||
onOpen: () => null,
|
||||
onConnect: () => null,
|
||||
onMessage: () => null,
|
||||
onOutgoingMessage: () => null,
|
||||
onSynced: () => null,
|
||||
onStatus: () => null,
|
||||
onDisconnect: () => null,
|
||||
onClose: () => null,
|
||||
onDestroy: () => null,
|
||||
onAwarenessUpdate: () => null,
|
||||
onAwarenessChange: () => null,
|
||||
onStateless: () => null,
|
||||
onUnsyncedChanges: () => null,
|
||||
};
|
||||
|
||||
isSynced = false;
|
||||
|
||||
unsyncedChanges = 0;
|
||||
|
||||
isAuthenticated = false;
|
||||
|
||||
authorizedScope: AuthorizedScope | undefined = undefined;
|
||||
|
||||
// @internal
|
||||
manageSocket = false;
|
||||
|
||||
private _isAttached = false;
|
||||
|
||||
/**
|
||||
* Outgoing document updates buffered for the current `flushDelay` window.
|
||||
*/
|
||||
private pendingUpdates: Uint8Array[] = [];
|
||||
|
||||
/**
|
||||
* Awareness client ids whose latest state should be sent on the next flush.
|
||||
*/
|
||||
private pendingAwarenessClients = new Set<number>();
|
||||
|
||||
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Unique session identifier for this provider instance.
|
||||
* Used for multiplexing multiple providers with the same document name on a single WebSocket.
|
||||
*/
|
||||
sessionId: string = Math.random().toString(36).slice(2);
|
||||
|
||||
/**
|
||||
* The effective name used as the first VarString in messages.
|
||||
* When `sessionAwareness` is enabled, returns a composite key (documentName\0sessionId).
|
||||
* Otherwise, returns the plain document name.
|
||||
*/
|
||||
get effectiveName(): string {
|
||||
return this.configuration.sessionAwareness
|
||||
? makeRoutingKey(this.configuration.name, this.sessionId)
|
||||
: this.configuration.name;
|
||||
}
|
||||
|
||||
intervals: any = {
|
||||
forceSync: null,
|
||||
};
|
||||
|
||||
constructor(configuration: HocuspocusProviderConfiguration) {
|
||||
super();
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.configuration.document = configuration.document
|
||||
? configuration.document
|
||||
: new Y.Doc();
|
||||
this.configuration.awareness =
|
||||
configuration.awareness !== undefined
|
||||
? configuration.awareness
|
||||
: new Awareness(this.document);
|
||||
|
||||
this.on("open", this.configuration.onOpen);
|
||||
this.on("message", this.configuration.onMessage);
|
||||
this.on("outgoingMessage", this.configuration.onOutgoingMessage);
|
||||
this.on("synced", this.configuration.onSynced);
|
||||
this.on("destroy", this.configuration.onDestroy);
|
||||
this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
|
||||
this.on("awarenessChange", this.configuration.onAwarenessChange);
|
||||
this.on("stateless", this.configuration.onStateless);
|
||||
this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
|
||||
|
||||
this.on("authenticated", this.configuration.onAuthenticated);
|
||||
this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
|
||||
|
||||
this.awareness?.on("update", () => {
|
||||
this.emit("awarenessUpdate", {
|
||||
states: awarenessStatesToArray(this.awareness!.getStates()),
|
||||
});
|
||||
});
|
||||
|
||||
this.awareness?.on("change", () => {
|
||||
this.emit("awarenessChange", {
|
||||
states: awarenessStatesToArray(this.awareness!.getStates()),
|
||||
});
|
||||
});
|
||||
|
||||
this.document.on("update", this.boundDocumentUpdateHandler);
|
||||
this.awareness?.on("update", this.boundAwarenessUpdateHandler);
|
||||
|
||||
this.registerEventListeners();
|
||||
|
||||
if (
|
||||
this.configuration.forceSyncInterval &&
|
||||
typeof this.configuration.forceSyncInterval === "number"
|
||||
) {
|
||||
this.intervals.forceSync = setInterval(
|
||||
this.forceSync.bind(this),
|
||||
this.configuration.forceSyncInterval,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.manageSocket) {
|
||||
this.attach();
|
||||
}
|
||||
}
|
||||
|
||||
boundDocumentUpdateHandler = this.documentUpdateHandler.bind(this);
|
||||
|
||||
boundAwarenessUpdateHandler = this.awarenessUpdateHandler.bind(this);
|
||||
|
||||
boundPageHide = this.pageHide.bind(this);
|
||||
|
||||
boundOnOpen = this.onOpen.bind(this);
|
||||
|
||||
boundOnClose = this.onClose.bind(this);
|
||||
|
||||
forwardConnect = () => this.emit("connect");
|
||||
|
||||
forwardStatus = (e: onStatusParameters) => this.emit("status", e);
|
||||
|
||||
forwardClose = (e: onCloseParameters) => this.emit("close", e);
|
||||
|
||||
forwardDisconnect = (e: onDisconnectParameters) => this.emit("disconnect", e);
|
||||
|
||||
forwardDestroy = () => this.emit("destroy");
|
||||
|
||||
public setConfiguration(
|
||||
configuration: Partial<HocuspocusProviderConfiguration> = {},
|
||||
): void {
|
||||
if (!configuration.websocketProvider) {
|
||||
this.manageSocket = true;
|
||||
this.configuration.websocketProvider = new HocuspocusProviderWebsocket(
|
||||
configuration as CompleteHocuspocusProviderWebsocketConfiguration,
|
||||
);
|
||||
}
|
||||
|
||||
this.configuration = { ...this.configuration, ...configuration };
|
||||
}
|
||||
|
||||
get document() {
|
||||
return this.configuration.document;
|
||||
}
|
||||
|
||||
public get isAttached() {
|
||||
return this._isAttached;
|
||||
}
|
||||
|
||||
get awareness() {
|
||||
return this.configuration.awareness;
|
||||
}
|
||||
|
||||
get hasUnsyncedChanges(): boolean {
|
||||
return this.unsyncedChanges > 0;
|
||||
}
|
||||
|
||||
private resetUnsyncedChanges() {
|
||||
this.unsyncedChanges = 1;
|
||||
this.emit("unsyncedChanges", { number: this.unsyncedChanges });
|
||||
}
|
||||
|
||||
incrementUnsyncedChanges() {
|
||||
this.unsyncedChanges += 1;
|
||||
this.emit("unsyncedChanges", { number: this.unsyncedChanges });
|
||||
}
|
||||
|
||||
decrementUnsyncedChanges() {
|
||||
if (this.unsyncedChanges > 0) {
|
||||
this.unsyncedChanges -= 1;
|
||||
}
|
||||
|
||||
if (this.unsyncedChanges === 0) {
|
||||
this.synced = true;
|
||||
}
|
||||
|
||||
this.emit("unsyncedChanges", { number: this.unsyncedChanges });
|
||||
}
|
||||
|
||||
forceSync() {
|
||||
this.resetUnsyncedChanges();
|
||||
|
||||
this.send(SyncStepOneMessage, {
|
||||
document: this.document,
|
||||
documentName: this.effectiveName,
|
||||
});
|
||||
}
|
||||
|
||||
pageHide() {
|
||||
if (this.awareness) {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.document.clientID],
|
||||
"page hide",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
if (typeof window === "undefined" || !("addEventListener" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", this.boundPageHide);
|
||||
}
|
||||
|
||||
sendStateless(payload: string) {
|
||||
this.send(StatelessMessage, {
|
||||
documentName: this.effectiveName,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async sendToken() {
|
||||
let token: string | null;
|
||||
try {
|
||||
token = await this.getToken();
|
||||
} catch (error) {
|
||||
this.permissionDeniedHandler(
|
||||
`Failed to get token during sendToken(): ${error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.send(AuthenticationMessage, {
|
||||
token: token ?? "",
|
||||
documentName: this.effectiveName,
|
||||
});
|
||||
}
|
||||
|
||||
private get batchingEnabled(): boolean {
|
||||
return (
|
||||
!!this.configuration.flushDelay &&
|
||||
typeof this.configuration.flushDelay === "number"
|
||||
);
|
||||
}
|
||||
|
||||
documentUpdateHandler(update: Uint8Array, origin: any) {
|
||||
if (origin === this) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.batchingEnabled) {
|
||||
this.incrementUnsyncedChanges();
|
||||
this.send(UpdateMessage, { update, documentName: this.effectiveName });
|
||||
return;
|
||||
}
|
||||
|
||||
// Count one outstanding change per batch: the server acks once per
|
||||
// merged message, so incrementing per buffered update would never balance.
|
||||
if (this.pendingUpdates.length === 0) {
|
||||
this.incrementUnsyncedChanges();
|
||||
}
|
||||
|
||||
this.pendingUpdates.push(update);
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
awarenessUpdateHandler({ added, updated, removed }: any, origin: any) {
|
||||
if (origin === this) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedClients = added.concat(updated).concat(removed);
|
||||
|
||||
if (!this.batchingEnabled) {
|
||||
this.send(AwarenessMessage, {
|
||||
awareness: this.awareness,
|
||||
clients: changedClients,
|
||||
documentName: this.effectiveName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const client of changedClients) {
|
||||
this.pendingAwarenessClients.add(client);
|
||||
}
|
||||
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
private scheduleFlush() {
|
||||
// Fixed-window batching: the first pending change starts the timer and
|
||||
// everything until it fires is flushed together, so latency stays capped
|
||||
// at `flushDelay` instead of growing while the user keeps typing.
|
||||
if (this.flushTimeout !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushTimeout = setTimeout(() => {
|
||||
this.flushTimeout = null;
|
||||
this.flushPendingUpdates();
|
||||
}, this.configuration.flushDelay as number);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send everything buffered for the current `flushDelay` window right away.
|
||||
* Buffered document updates are merged into a single message and awareness
|
||||
* collapses to the latest state of each changed client. Safe to call when
|
||||
* nothing is pending.
|
||||
*/
|
||||
flushPendingUpdates() {
|
||||
if (this.flushTimeout !== null) {
|
||||
clearTimeout(this.flushTimeout);
|
||||
this.flushTimeout = null;
|
||||
}
|
||||
|
||||
if (this.pendingUpdates.length > 0) {
|
||||
const update =
|
||||
this.pendingUpdates.length === 1
|
||||
? this.pendingUpdates[0]
|
||||
: Y.mergeUpdates(this.pendingUpdates);
|
||||
|
||||
this.pendingUpdates = [];
|
||||
this.send(UpdateMessage, { update, documentName: this.effectiveName });
|
||||
}
|
||||
|
||||
if (this.pendingAwarenessClients.size > 0) {
|
||||
const clients = Array.from(this.pendingAwarenessClients);
|
||||
|
||||
this.pendingAwarenessClients.clear();
|
||||
this.send(AwarenessMessage, {
|
||||
awareness: this.awareness,
|
||||
clients,
|
||||
documentName: this.effectiveName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether a first handshake with the server has been established
|
||||
*
|
||||
* Note: this does not mean all updates from the client have been persisted to the backend. For this,
|
||||
* use `hasUnsyncedChanges`.
|
||||
*/
|
||||
get synced(): boolean {
|
||||
return this.isSynced;
|
||||
}
|
||||
|
||||
set synced(state) {
|
||||
if (this.isSynced === state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSynced = state;
|
||||
|
||||
if (state) {
|
||||
this.emit("synced", { state });
|
||||
}
|
||||
}
|
||||
|
||||
receiveStateless(payload: string) {
|
||||
this.emit("stateless", { payload });
|
||||
}
|
||||
|
||||
// not needed, but provides backward compatibility with e.g. lexical/yjs
|
||||
async connect() {
|
||||
if (this.manageSocket) {
|
||||
return this.configuration.websocketProvider.connect();
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"HocuspocusProvider::connect() is deprecated and does not do anything. Please connect/disconnect on the websocketProvider, or attach/deattach providers.",
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.manageSocket) {
|
||||
return this.configuration.websocketProvider.disconnect();
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"HocuspocusProvider::disconnect() is deprecated and does not do anything. Please connect/disconnect on the websocketProvider, or attach/deattach providers.",
|
||||
);
|
||||
}
|
||||
|
||||
async onOpen(event: Event) {
|
||||
this.isAuthenticated = false;
|
||||
|
||||
this.emit("open", { event });
|
||||
await this.sendToken();
|
||||
this.startSync();
|
||||
}
|
||||
|
||||
async getToken() {
|
||||
if (typeof this.configuration.token === "function") {
|
||||
const token = await this.configuration.token();
|
||||
return token;
|
||||
}
|
||||
|
||||
return this.configuration.token;
|
||||
}
|
||||
|
||||
startSync() {
|
||||
this.resetUnsyncedChanges();
|
||||
|
||||
this.send(SyncStepOneMessage, {
|
||||
document: this.document,
|
||||
documentName: this.effectiveName,
|
||||
});
|
||||
|
||||
if (this.awareness && this.awareness.getLocalState() !== null) {
|
||||
this.send(AwarenessMessage, {
|
||||
awareness: this.awareness,
|
||||
clients: [this.document.clientID],
|
||||
documentName: this.effectiveName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
send(message: ConstructableOutgoingMessage, args: any) {
|
||||
if (!this._isAttached) return;
|
||||
|
||||
const messageSender = new MessageSender(message, args);
|
||||
|
||||
this.emit("outgoingMessage", { message: messageSender.message });
|
||||
messageSender.send(this.configuration.websocketProvider);
|
||||
}
|
||||
|
||||
onMessage(event: MessageEvent) {
|
||||
const message = new IncomingMessage(event.data);
|
||||
|
||||
const rawKey = message.readVarString();
|
||||
// Extract actual documentName from potentially composite routing key
|
||||
const { documentName } = parseRoutingKey(rawKey);
|
||||
|
||||
message.writeVarString(this.effectiveName);
|
||||
|
||||
this.emit("message", { event, message: new IncomingMessage(event.data) });
|
||||
|
||||
new MessageReceiver(message).apply(this, true);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.isAuthenticated = false;
|
||||
this.synced = false;
|
||||
|
||||
// Drop anything buffered for batching; the reconnect sync handshake will
|
||||
// reconcile these changes from the document via state vectors.
|
||||
if (this.flushTimeout !== null) {
|
||||
clearTimeout(this.flushTimeout);
|
||||
this.flushTimeout = null;
|
||||
}
|
||||
this.pendingUpdates = [];
|
||||
this.pendingAwarenessClients.clear();
|
||||
|
||||
// update awareness (all users except local left)
|
||||
if (this.awareness) {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
Array.from(this.awareness.getStates().keys()).filter(
|
||||
(client) => client !== this.document.clientID,
|
||||
),
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.emit("destroy");
|
||||
|
||||
if (this.intervals.forceSync) {
|
||||
clearInterval(this.intervals.forceSync);
|
||||
}
|
||||
|
||||
if (this.awareness) {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.document.clientID],
|
||||
"provider destroy",
|
||||
);
|
||||
this.awareness.off("update", this.boundAwarenessUpdateHandler);
|
||||
this.awareness.destroy();
|
||||
}
|
||||
|
||||
// Send any buffered updates before the socket is torn down. The websocket
|
||||
// safely queues the message if it is no longer open.
|
||||
this.flushPendingUpdates();
|
||||
|
||||
this.document.off("update", this.boundDocumentUpdateHandler);
|
||||
|
||||
this.removeAllListeners();
|
||||
|
||||
this.detach();
|
||||
|
||||
if (this.manageSocket) {
|
||||
this.configuration.websocketProvider.destroy();
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !("removeEventListener" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("pagehide", this.boundPageHide);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.configuration.websocketProvider.off(
|
||||
"connect",
|
||||
this.configuration.onConnect,
|
||||
);
|
||||
this.configuration.websocketProvider.off("connect", this.forwardConnect);
|
||||
|
||||
this.configuration.websocketProvider.off("status", this.forwardStatus);
|
||||
this.configuration.websocketProvider.off(
|
||||
"status",
|
||||
this.configuration.onStatus,
|
||||
);
|
||||
|
||||
this.configuration.websocketProvider.off("open", this.boundOnOpen);
|
||||
this.configuration.websocketProvider.off("close", this.boundOnClose);
|
||||
this.configuration.websocketProvider.off(
|
||||
"close",
|
||||
this.configuration.onClose,
|
||||
);
|
||||
this.configuration.websocketProvider.off("close", this.forwardClose);
|
||||
this.configuration.websocketProvider.off(
|
||||
"disconnect",
|
||||
this.configuration.onDisconnect,
|
||||
);
|
||||
this.configuration.websocketProvider.off(
|
||||
"disconnect",
|
||||
this.forwardDisconnect,
|
||||
);
|
||||
this.configuration.websocketProvider.off(
|
||||
"destroy",
|
||||
this.configuration.onDestroy,
|
||||
);
|
||||
this.configuration.websocketProvider.off("destroy", this.forwardDestroy);
|
||||
|
||||
this.configuration.websocketProvider.detach(this);
|
||||
|
||||
this._isAttached = false;
|
||||
}
|
||||
|
||||
attach() {
|
||||
if (this._isAttached) return;
|
||||
|
||||
this.configuration.websocketProvider.on(
|
||||
"connect",
|
||||
this.configuration.onConnect,
|
||||
);
|
||||
this.configuration.websocketProvider.on("connect", this.forwardConnect);
|
||||
|
||||
this.configuration.websocketProvider.on(
|
||||
"status",
|
||||
this.configuration.onStatus,
|
||||
);
|
||||
this.configuration.websocketProvider.on("status", this.forwardStatus);
|
||||
|
||||
this.configuration.websocketProvider.on("open", this.boundOnOpen);
|
||||
|
||||
this.configuration.websocketProvider.on("close", this.boundOnClose);
|
||||
this.configuration.websocketProvider.on(
|
||||
"close",
|
||||
this.configuration.onClose,
|
||||
);
|
||||
this.configuration.websocketProvider.on("close", this.forwardClose);
|
||||
|
||||
this.configuration.websocketProvider.on(
|
||||
"disconnect",
|
||||
this.configuration.onDisconnect,
|
||||
);
|
||||
this.configuration.websocketProvider.on(
|
||||
"disconnect",
|
||||
this.forwardDisconnect,
|
||||
);
|
||||
|
||||
this.configuration.websocketProvider.on(
|
||||
"destroy",
|
||||
this.configuration.onDestroy,
|
||||
);
|
||||
this.configuration.websocketProvider.on("destroy", this.forwardDestroy);
|
||||
|
||||
this.configuration.websocketProvider.attach(this);
|
||||
|
||||
this._isAttached = true;
|
||||
}
|
||||
|
||||
permissionDeniedHandler(reason: string) {
|
||||
this.emit("authenticationFailed", { reason });
|
||||
this.isAuthenticated = false;
|
||||
}
|
||||
|
||||
authenticatedHandler(scope: string) {
|
||||
this.isAuthenticated = true;
|
||||
this.authorizedScope = scope as AuthorizedScope;
|
||||
|
||||
this.emit("authenticated", { scope });
|
||||
}
|
||||
|
||||
setAwarenessField(key: string, value: any) {
|
||||
if (!this.awareness) {
|
||||
throw new AwarenessError(
|
||||
`Cannot set awareness field "${key}" to ${JSON.stringify(value)}. You have disabled Awareness for this provider by explicitly passing awareness: null in the provider configuration.`,
|
||||
);
|
||||
}
|
||||
this.awareness.setLocalStateField(key, value);
|
||||
}
|
||||
}
|
||||
610
packages/provider/src/HocuspocusProviderWebsocket.ts
Normal file
610
packages/provider/src/HocuspocusProviderWebsocket.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import { WsReadyStates } from "@hocuspocus/common";
|
||||
import { retry } from "@lifeomic/attempt";
|
||||
import { createDecoder, readVarString, readVarUint } from "lib0/decoding";
|
||||
import * as encoding from "lib0/encoding";
|
||||
import * as time from "lib0/time";
|
||||
import EventEmitter from "./EventEmitter.ts";
|
||||
import type { HocuspocusProvider } from "./HocuspocusProvider.ts";
|
||||
import { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { CloseMessage } from "./OutgoingMessages/CloseMessage.ts";
|
||||
import {
|
||||
MessageType,
|
||||
type onAwarenessChangeParameters,
|
||||
type onAwarenessUpdateParameters,
|
||||
type onCloseParameters,
|
||||
type onDisconnectParameters,
|
||||
type onMaxAttemptsFailedParameters,
|
||||
type onMessageParameters,
|
||||
type onOpenParameters,
|
||||
type onOutgoingMessageParameters,
|
||||
type onStatusParameters,
|
||||
WebSocketStatus,
|
||||
} from "./types.ts";
|
||||
|
||||
export type HocuspocusWebSocket = WebSocket & { identifier: string };
|
||||
export type HocusPocusWebSocket = HocuspocusWebSocket;
|
||||
|
||||
export type HocuspocusProviderWebsocketConfiguration = Required<
|
||||
Pick<CompleteHocuspocusProviderWebsocketConfiguration, "url">
|
||||
> &
|
||||
Partial<CompleteHocuspocusProviderWebsocketConfiguration>;
|
||||
|
||||
export interface CompleteHocuspocusProviderWebsocketConfiguration {
|
||||
/**
|
||||
* Whether to connect automatically when creating the provider instance. Default=true
|
||||
*/
|
||||
autoConnect: boolean;
|
||||
|
||||
/**
|
||||
* URL of your @hocuspocus/server instance
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* By default, trailing slashes are removed from the URL. Set this to true
|
||||
* to preserve trailing slashes if your server configuration requires them.
|
||||
*/
|
||||
preserveTrailingSlash: boolean;
|
||||
|
||||
/**
|
||||
* An optional WebSocket polyfill, for example for Node.js
|
||||
*/
|
||||
WebSocketPolyfill: any;
|
||||
|
||||
/**
|
||||
* Disconnect when no message is received for the defined amount of milliseconds.
|
||||
*/
|
||||
messageReconnectTimeout: number;
|
||||
/**
|
||||
* The delay between each attempt in milliseconds. You can provide a factor to have the delay grow exponentially.
|
||||
*/
|
||||
delay: number;
|
||||
/**
|
||||
* The initialDelay is the amount of time to wait before making the first attempt. This option should typically be 0 since you typically want the first attempt to happen immediately.
|
||||
*/
|
||||
initialDelay: number;
|
||||
/**
|
||||
* The factor option is used to grow the delay exponentially.
|
||||
*/
|
||||
factor: number;
|
||||
/**
|
||||
* The maximum number of attempts or 0 if there is no limit on number of attempts.
|
||||
*/
|
||||
maxAttempts: number;
|
||||
/**
|
||||
* minDelay is used to set a lower bound of delay when jitter is enabled. This property has no effect if jitter is disabled.
|
||||
*/
|
||||
minDelay: number;
|
||||
/**
|
||||
* The maxDelay option is used to set an upper bound for the delay when factor is enabled. A value of 0 can be provided if there should be no upper bound when calculating delay.
|
||||
*/
|
||||
maxDelay: number;
|
||||
/**
|
||||
* If jitter is true then the calculated delay will be a random integer value between minDelay and the calculated delay for the current iteration.
|
||||
*/
|
||||
jitter: boolean;
|
||||
/**
|
||||
* A timeout in milliseconds. If timeout is non-zero then a timer is set using setTimeout. If the timeout is triggered then future attempts will be aborted.
|
||||
*/
|
||||
timeout: number;
|
||||
handleTimeout: (() => Promise<unknown>) | null;
|
||||
onOpen: (data: onOpenParameters) => void;
|
||||
onConnect: () => void;
|
||||
onMessage: (data: onMessageParameters) => void;
|
||||
onOutgoingMessage: (data: onOutgoingMessageParameters) => void;
|
||||
onStatus: (data: onStatusParameters) => void;
|
||||
onDisconnect: (data: onDisconnectParameters) => void;
|
||||
onClose: (data: onCloseParameters) => void;
|
||||
onMaxAttemptsFailed: (data: onMaxAttemptsFailedParameters) => void;
|
||||
onDestroy: () => void;
|
||||
onAwarenessUpdate: (data: onAwarenessUpdateParameters) => void;
|
||||
onAwarenessChange: (data: onAwarenessChangeParameters) => void;
|
||||
|
||||
/**
|
||||
* Map of attached providers keyed by documentName.
|
||||
*/
|
||||
providerMap: Map<string, HocuspocusProvider>;
|
||||
}
|
||||
|
||||
export class HocuspocusProviderWebsocket extends EventEmitter {
|
||||
private static readonly DEDUPLICATABLE_TYPES = new Set([
|
||||
MessageType.Awareness,
|
||||
MessageType.QueryAwareness,
|
||||
]);
|
||||
|
||||
private messageQueue: any[] = [];
|
||||
|
||||
public configuration: CompleteHocuspocusProviderWebsocketConfiguration = {
|
||||
url: "",
|
||||
autoConnect: true,
|
||||
preserveTrailingSlash: false,
|
||||
// @ts-expect-error
|
||||
document: undefined,
|
||||
WebSocketPolyfill: undefined,
|
||||
// TODO: this should depend on awareness.outdatedTime
|
||||
messageReconnectTimeout: 30000,
|
||||
// 1 second
|
||||
delay: 1000,
|
||||
// instant
|
||||
initialDelay: 0,
|
||||
// double the delay each time
|
||||
factor: 2,
|
||||
// unlimited retries
|
||||
maxAttempts: 0,
|
||||
// wait at least 1 second
|
||||
minDelay: 1000,
|
||||
// at least every 30 seconds
|
||||
maxDelay: 30000,
|
||||
// randomize
|
||||
jitter: true,
|
||||
// retry forever
|
||||
timeout: 0,
|
||||
onOpen: () => null,
|
||||
onConnect: () => null,
|
||||
onMessage: () => null,
|
||||
onOutgoingMessage: () => null,
|
||||
onStatus: () => null,
|
||||
onDisconnect: () => null,
|
||||
onClose: () => null,
|
||||
onMaxAttemptsFailed: () => null,
|
||||
onDestroy: () => null,
|
||||
onAwarenessUpdate: () => null,
|
||||
onAwarenessChange: () => null,
|
||||
handleTimeout: null,
|
||||
providerMap: new Map(),
|
||||
};
|
||||
|
||||
webSocket: HocusPocusWebSocket | null = null;
|
||||
|
||||
webSocketHandlers: { [key: string]: any } = {};
|
||||
|
||||
shouldConnect = true;
|
||||
|
||||
status = WebSocketStatus.Disconnected;
|
||||
|
||||
lastMessageReceived = 0;
|
||||
|
||||
identifier = 0;
|
||||
|
||||
intervals: any = {
|
||||
connectionChecker: null,
|
||||
};
|
||||
|
||||
connectionAttempt: {
|
||||
resolve: (value?: any) => void;
|
||||
reject: (reason?: any) => void;
|
||||
} | null = null;
|
||||
|
||||
constructor(configuration: HocuspocusProviderWebsocketConfiguration) {
|
||||
super();
|
||||
this.setConfiguration(configuration);
|
||||
|
||||
this.configuration.WebSocketPolyfill = configuration.WebSocketPolyfill
|
||||
? configuration.WebSocketPolyfill
|
||||
: WebSocket;
|
||||
|
||||
this.on("open", this.configuration.onOpen);
|
||||
this.on("open", this.onOpen.bind(this));
|
||||
this.on("connect", this.configuration.onConnect);
|
||||
this.on("message", this.configuration.onMessage);
|
||||
this.on("outgoingMessage", this.configuration.onOutgoingMessage);
|
||||
this.on("status", this.configuration.onStatus);
|
||||
this.on("disconnect", this.configuration.onDisconnect);
|
||||
this.on("close", this.configuration.onClose);
|
||||
this.on("maxAttemptsFailed", this.configuration.onMaxAttemptsFailed);
|
||||
this.on("destroy", this.configuration.onDestroy);
|
||||
this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
|
||||
this.on("awarenessChange", this.configuration.onAwarenessChange);
|
||||
|
||||
this.on("close", this.onClose.bind(this));
|
||||
this.on("message", this.onMessage.bind(this));
|
||||
|
||||
this.intervals.connectionChecker = setInterval(
|
||||
this.checkConnection.bind(this),
|
||||
this.configuration.messageReconnectTimeout / 10,
|
||||
);
|
||||
|
||||
if (this.shouldConnect) {
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
receivedOnOpenPayload?: Event | undefined = undefined;
|
||||
|
||||
async onOpen(event: Event) {
|
||||
this.status = WebSocketStatus.Connected;
|
||||
this.emit("status", { status: WebSocketStatus.Connected });
|
||||
|
||||
this.cancelWebsocketRetry = undefined;
|
||||
this.receivedOnOpenPayload = event;
|
||||
}
|
||||
|
||||
attach(provider: HocuspocusProvider) {
|
||||
const key = provider.effectiveName;
|
||||
const existing = this.configuration.providerMap.get(key);
|
||||
|
||||
if (existing && existing !== provider) {
|
||||
// Allow replacing a provider that hasn't authenticated (e.g., after auth failure retry)
|
||||
if (existing.isAuthenticated) {
|
||||
throw new Error(
|
||||
`Cannot attach two providers with the same effective name "${key}". ` +
|
||||
"Use sessionAwareness: true to multiplex providers with the same document name.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.configuration.providerMap.set(key, provider);
|
||||
|
||||
if (this.status === WebSocketStatus.Disconnected && this.shouldConnect) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
if (
|
||||
this.receivedOnOpenPayload &&
|
||||
this.status === WebSocketStatus.Connected
|
||||
) {
|
||||
provider.onOpen(this.receivedOnOpenPayload);
|
||||
}
|
||||
}
|
||||
|
||||
detach(provider: HocuspocusProvider) {
|
||||
const key = provider.effectiveName;
|
||||
if (this.configuration.providerMap.has(key)) {
|
||||
provider.send(CloseMessage, {
|
||||
documentName: key,
|
||||
});
|
||||
this.configuration.providerMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
public setConfiguration(
|
||||
configuration: Partial<HocuspocusProviderWebsocketConfiguration> = {},
|
||||
): void {
|
||||
this.configuration = { ...this.configuration, ...configuration };
|
||||
|
||||
if (!this.configuration.autoConnect) {
|
||||
this.shouldConnect = false;
|
||||
}
|
||||
}
|
||||
|
||||
cancelWebsocketRetry?: () => void;
|
||||
|
||||
async connect() {
|
||||
if (this.status === WebSocketStatus.Connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always cancel any previously initiated connection retryer instances
|
||||
if (this.cancelWebsocketRetry) {
|
||||
this.cancelWebsocketRetry();
|
||||
this.cancelWebsocketRetry = undefined;
|
||||
}
|
||||
|
||||
this.receivedOnOpenPayload = undefined;
|
||||
this.shouldConnect = true;
|
||||
|
||||
const abortableRetry = () => {
|
||||
let cancelAttempt = false;
|
||||
|
||||
const retryPromise = retry(this.createWebSocketConnection.bind(this), {
|
||||
delay: this.configuration.delay,
|
||||
initialDelay: this.configuration.initialDelay,
|
||||
factor: this.configuration.factor,
|
||||
maxAttempts: this.configuration.maxAttempts,
|
||||
minDelay: this.configuration.minDelay,
|
||||
maxDelay: this.configuration.maxDelay,
|
||||
jitter: this.configuration.jitter,
|
||||
timeout: this.configuration.timeout,
|
||||
handleTimeout: this.configuration.handleTimeout,
|
||||
beforeAttempt: (context) => {
|
||||
if (!this.shouldConnect || cancelAttempt) {
|
||||
context.abort();
|
||||
}
|
||||
},
|
||||
}).catch((error: any) => {
|
||||
// If we aborted the connection attempt then don’t report a failure
|
||||
// ref: https://github.com/lifeomic/attempt/blob/master/src/index.ts#L136
|
||||
if (error?.code !== "ATTEMPT_ABORTED") {
|
||||
// connect() is fire-and-forget; rethrowing becomes an unhandled rejection.
|
||||
this.emit("maxAttemptsFailed", { error });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
retryPromise,
|
||||
cancelFunc: () => {
|
||||
cancelAttempt = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const { retryPromise, cancelFunc } = abortableRetry();
|
||||
this.cancelWebsocketRetry = cancelFunc;
|
||||
|
||||
return retryPromise;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
attachWebSocketListeners(ws: HocusPocusWebSocket, reject: Function) {
|
||||
const { identifier } = ws;
|
||||
const onMessageHandler = (payload: any) => this.emit("message", payload);
|
||||
const onCloseHandler = (payload: any) =>
|
||||
this.emit("close", { event: payload });
|
||||
const onOpenHandler = (payload: any) => this.emit("open", payload);
|
||||
const onErrorHandler = (err: any) => {
|
||||
reject(err);
|
||||
};
|
||||
|
||||
this.webSocketHandlers[identifier] = {
|
||||
message: onMessageHandler,
|
||||
close: onCloseHandler,
|
||||
open: onOpenHandler,
|
||||
error: onErrorHandler,
|
||||
};
|
||||
|
||||
const handlers = this.webSocketHandlers[ws.identifier];
|
||||
|
||||
Object.keys(handlers).forEach((name) => {
|
||||
ws.addEventListener(name, handlers[name]);
|
||||
});
|
||||
}
|
||||
|
||||
cleanupWebSocket() {
|
||||
if (!this.webSocket) {
|
||||
return;
|
||||
}
|
||||
const { identifier } = this.webSocket;
|
||||
const handlers = this.webSocketHandlers[identifier];
|
||||
|
||||
Object.keys(handlers).forEach((name) => {
|
||||
this.webSocket?.removeEventListener(name, handlers[name]);
|
||||
delete this.webSocketHandlers[identifier];
|
||||
});
|
||||
this.webSocket.close();
|
||||
this.webSocket = null;
|
||||
}
|
||||
|
||||
createWebSocketConnection() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.webSocket) {
|
||||
this.messageQueue = [];
|
||||
this.cleanupWebSocket();
|
||||
}
|
||||
this.lastMessageReceived = 0;
|
||||
this.identifier += 1;
|
||||
|
||||
// Init the WebSocket connection
|
||||
const ws = new this.configuration.WebSocketPolyfill(this.url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.identifier = this.identifier;
|
||||
|
||||
this.attachWebSocketListeners(ws, reject);
|
||||
|
||||
this.webSocket = ws;
|
||||
|
||||
// Reset the status
|
||||
this.status = WebSocketStatus.Connecting;
|
||||
this.emit("status", { status: WebSocketStatus.Connecting });
|
||||
|
||||
// Store resolve/reject for later use
|
||||
this.connectionAttempt = {
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
onMessage(event: MessageEvent) {
|
||||
this.resolveConnectionAttempt();
|
||||
|
||||
this.lastMessageReceived = time.getUnixTime();
|
||||
|
||||
const data = new Uint8Array(event.data as ArrayBuffer);
|
||||
|
||||
// Check for connection-level Ping message (no document name prefix)
|
||||
// Ping messages are sent as just the message type byte (length 1)
|
||||
// We check length to avoid confusing with regular messages that happen to have
|
||||
// a document name length of 9 as the first byte
|
||||
if (data.length === 1 && data[0] === MessageType.Ping) {
|
||||
this.sendPong();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = new IncomingMessage(data);
|
||||
const rawKey = message.peekVarString();
|
||||
|
||||
const provider = this.configuration.providerMap.get(rawKey);
|
||||
provider?.onMessage(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send application-level Pong response to server Ping
|
||||
*/
|
||||
private sendPong() {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, MessageType.Pong);
|
||||
this.send(encoding.toUint8Array(encoder));
|
||||
}
|
||||
|
||||
resolveConnectionAttempt() {
|
||||
if (this.connectionAttempt) {
|
||||
this.connectionAttempt.resolve();
|
||||
this.connectionAttempt = null;
|
||||
|
||||
this.status = WebSocketStatus.Connected;
|
||||
this.emit("status", { status: WebSocketStatus.Connected });
|
||||
this.emit("connect");
|
||||
this.messageQueue.forEach((message) => this.send(message));
|
||||
this.messageQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
stopConnectionAttempt() {
|
||||
this.connectionAttempt = null;
|
||||
}
|
||||
|
||||
rejectConnectionAttempt(reason?: unknown) {
|
||||
this.connectionAttempt?.reject(reason);
|
||||
this.connectionAttempt = null;
|
||||
}
|
||||
|
||||
closeTries = 0;
|
||||
|
||||
checkConnection() {
|
||||
// Don’t check the connection when it’s not even established
|
||||
if (this.status !== WebSocketStatus.Connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don’t close the connection while waiting for the first message
|
||||
if (!this.lastMessageReceived) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don’t close the connection when a message was received recently
|
||||
if (
|
||||
this.configuration.messageReconnectTimeout >=
|
||||
time.getUnixTime() - this.lastMessageReceived
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No message received in a long time, not even your own
|
||||
// Awareness updates, which are updated every 15 seconds
|
||||
// if awareness is enabled.
|
||||
this.closeTries += 1;
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=247943
|
||||
if (this.closeTries > 2) {
|
||||
this.onClose({
|
||||
event: {
|
||||
code: 4408,
|
||||
reason: "forced",
|
||||
},
|
||||
});
|
||||
this.closeTries = 0;
|
||||
} else {
|
||||
this.webSocket?.close();
|
||||
this.messageQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
get serverUrl() {
|
||||
if (this.configuration.preserveTrailingSlash) {
|
||||
return this.configuration.url;
|
||||
}
|
||||
|
||||
// By default, ensure that the URL never ends with /
|
||||
let url = this.configuration.url;
|
||||
while (url[url.length - 1] === "/") {
|
||||
url = url.slice(0, url.length - 1);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
get url() {
|
||||
return this.serverUrl;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldConnect = false;
|
||||
|
||||
if (this.webSocket === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.webSocket.close();
|
||||
this.messageQueue = [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private parseQueuedMessage(
|
||||
message: Uint8Array,
|
||||
): { documentName: string; messageType: number } | null {
|
||||
try {
|
||||
const decoder = createDecoder(message);
|
||||
const documentName = readVarString(decoder);
|
||||
const messageType = readVarUint(decoder);
|
||||
return { documentName, messageType };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private addToQueue(message: any) {
|
||||
if (message instanceof Uint8Array) {
|
||||
const parsed = this.parseQueuedMessage(message);
|
||||
if (
|
||||
parsed &&
|
||||
HocuspocusProviderWebsocket.DEDUPLICATABLE_TYPES.has(parsed.messageType)
|
||||
) {
|
||||
this.messageQueue = this.messageQueue.filter((queued) => {
|
||||
if (!(queued instanceof Uint8Array)) return true;
|
||||
const queuedParsed = this.parseQueuedMessage(queued);
|
||||
if (!queuedParsed) return true;
|
||||
return !(
|
||||
queuedParsed.documentName === parsed.documentName &&
|
||||
queuedParsed.messageType === parsed.messageType
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
|
||||
send(message: any) {
|
||||
if (this.webSocket?.readyState === WsReadyStates.Open) {
|
||||
this.webSocket.send(message);
|
||||
} else {
|
||||
this.addToQueue(message);
|
||||
}
|
||||
}
|
||||
|
||||
onClose({ event }: onCloseParameters) {
|
||||
this.closeTries = 0;
|
||||
this.cleanupWebSocket();
|
||||
|
||||
if (this.connectionAttempt) {
|
||||
// That connection attempt failed. The socket can close without ever
|
||||
// emitting an error, so pass a reason along – it ends up as the error
|
||||
// of the `maxAttemptsFailed` event once the retries are exhausted.
|
||||
this.rejectConnectionAttempt(
|
||||
new Error(
|
||||
`WebSocket closed before the connection was established (code: ${event?.code}, reason: ${event?.reason || "none"})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Let’s update the connection status.
|
||||
this.status = WebSocketStatus.Disconnected;
|
||||
this.emit("status", { status: WebSocketStatus.Disconnected });
|
||||
this.emit("disconnect", { event });
|
||||
|
||||
// trigger connect if no retry is running and we want to have a connection
|
||||
if (!this.cancelWebsocketRetry && this.shouldConnect) {
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
}, this.configuration.delay);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.emit("destroy");
|
||||
|
||||
clearInterval(this.intervals.connectionChecker);
|
||||
|
||||
// If there is still a connection attempt outstanding then we should stop
|
||||
// it before calling disconnect, otherwise it will be rejected in the onClose
|
||||
// handler and trigger a retry
|
||||
this.stopConnectionAttempt();
|
||||
|
||||
this.disconnect();
|
||||
|
||||
this.removeAllListeners();
|
||||
|
||||
this.cleanupWebSocket();
|
||||
}
|
||||
}
|
||||
63
packages/provider/src/IncomingMessage.ts
Normal file
63
packages/provider/src/IncomingMessage.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Decoder } from "lib0/decoding";
|
||||
import {
|
||||
createDecoder,
|
||||
peekVarString,
|
||||
readVarUint,
|
||||
readVarUint8Array,
|
||||
readVarString,
|
||||
} from "lib0/decoding";
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import {
|
||||
createEncoder,
|
||||
writeVarUint,
|
||||
writeVarUint8Array,
|
||||
writeVarString,
|
||||
length,
|
||||
} from "lib0/encoding";
|
||||
import type { MessageType } from "./types.ts";
|
||||
|
||||
export class IncomingMessage {
|
||||
data: any;
|
||||
|
||||
encoder: Encoder;
|
||||
|
||||
decoder: Decoder;
|
||||
|
||||
constructor(data: any) {
|
||||
this.data = data;
|
||||
this.encoder = createEncoder();
|
||||
this.decoder = createDecoder(new Uint8Array(this.data));
|
||||
}
|
||||
|
||||
peekVarString(): string {
|
||||
return peekVarString(this.decoder);
|
||||
}
|
||||
|
||||
readVarUint(): MessageType {
|
||||
return readVarUint(this.decoder);
|
||||
}
|
||||
|
||||
readVarString(): string {
|
||||
return readVarString(this.decoder);
|
||||
}
|
||||
|
||||
readVarUint8Array() {
|
||||
return readVarUint8Array(this.decoder);
|
||||
}
|
||||
|
||||
writeVarUint(type: MessageType) {
|
||||
return writeVarUint(this.encoder, type);
|
||||
}
|
||||
|
||||
writeVarString(string: string) {
|
||||
return writeVarString(this.encoder, string);
|
||||
}
|
||||
|
||||
writeVarUint8Array(data: Uint8Array) {
|
||||
return writeVarUint8Array(this.encoder, data);
|
||||
}
|
||||
|
||||
length() {
|
||||
return length(this.encoder);
|
||||
}
|
||||
}
|
||||
136
packages/provider/src/MessageReceiver.ts
Normal file
136
packages/provider/src/MessageReceiver.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type CloseEvent, readAuthMessage } from "@hocuspocus/common";
|
||||
import { readVarInt, readVarString } from "lib0/decoding";
|
||||
import * as awarenessProtocol from "y-protocols/awareness";
|
||||
import { messageYjsSyncStep2, readSyncMessage } from "y-protocols/sync";
|
||||
import type { HocuspocusProvider } from "./HocuspocusProvider.ts";
|
||||
import type { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import { MessageType } from "./types.ts";
|
||||
|
||||
export class MessageReceiver {
|
||||
message: IncomingMessage;
|
||||
|
||||
constructor(message: IncomingMessage) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public apply(provider: HocuspocusProvider, emitSynced: boolean) {
|
||||
const { message } = this;
|
||||
const type = message.readVarUint();
|
||||
|
||||
const emptyMessageLength = message.length();
|
||||
|
||||
switch (type) {
|
||||
case MessageType.Sync:
|
||||
this.applySyncMessage(provider, emitSynced);
|
||||
break;
|
||||
|
||||
case MessageType.Awareness:
|
||||
this.applyAwarenessMessage(provider);
|
||||
break;
|
||||
|
||||
case MessageType.Auth:
|
||||
this.applyAuthMessage(provider);
|
||||
break;
|
||||
|
||||
case MessageType.QueryAwareness:
|
||||
this.applyQueryAwarenessMessage(provider);
|
||||
break;
|
||||
|
||||
case MessageType.Stateless:
|
||||
provider.receiveStateless(readVarString(message.decoder));
|
||||
break;
|
||||
|
||||
case MessageType.SyncStatus:
|
||||
this.applySyncStatusMessage(
|
||||
provider,
|
||||
readVarInt(message.decoder) === 1,
|
||||
);
|
||||
break;
|
||||
|
||||
case MessageType.CLOSE: {
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const event: CloseEvent = {
|
||||
code: 1000,
|
||||
reason: readVarString(message.decoder),
|
||||
};
|
||||
provider.onClose();
|
||||
provider.configuration.onClose({ event });
|
||||
provider.forwardClose({ event });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Can’t apply message of unknown type: ${type}`);
|
||||
}
|
||||
|
||||
// Reply
|
||||
if (message.length() > emptyMessageLength + 1) {
|
||||
// length of documentName (considered in emptyMessageLength plus length of yjs sync type, set in applySyncMessage)
|
||||
// @ts-expect-error
|
||||
provider.send(OutgoingMessage, { encoder: message.encoder });
|
||||
}
|
||||
}
|
||||
|
||||
private applySyncMessage(provider: HocuspocusProvider, emitSynced: boolean) {
|
||||
const { message } = this;
|
||||
|
||||
message.writeVarUint(MessageType.Sync);
|
||||
|
||||
// Apply update
|
||||
const syncMessageType = readSyncMessage(
|
||||
message.decoder,
|
||||
message.encoder,
|
||||
provider.document,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Synced once we receive Step2
|
||||
if (emitSynced && syncMessageType === messageYjsSyncStep2) {
|
||||
provider.synced = true;
|
||||
}
|
||||
}
|
||||
|
||||
applySyncStatusMessage(provider: HocuspocusProvider, applied: boolean) {
|
||||
if (applied) {
|
||||
provider.decrementUnsyncedChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private applyAwarenessMessage(provider: HocuspocusProvider) {
|
||||
if (!provider.awareness) return;
|
||||
|
||||
const { message } = this;
|
||||
|
||||
awarenessProtocol.applyAwarenessUpdate(
|
||||
provider.awareness,
|
||||
message.readVarUint8Array(),
|
||||
provider,
|
||||
);
|
||||
}
|
||||
|
||||
private applyAuthMessage(provider: HocuspocusProvider) {
|
||||
const { message } = this;
|
||||
|
||||
readAuthMessage(
|
||||
message.decoder,
|
||||
provider.sendToken.bind(provider),
|
||||
provider.permissionDeniedHandler.bind(provider),
|
||||
provider.authenticatedHandler.bind(provider),
|
||||
);
|
||||
}
|
||||
|
||||
private applyQueryAwarenessMessage(provider: HocuspocusProvider) {
|
||||
if (!provider.awareness) return;
|
||||
|
||||
const { message } = this;
|
||||
|
||||
message.writeVarUint(MessageType.Awareness);
|
||||
message.writeVarUint8Array(
|
||||
awarenessProtocol.encodeAwarenessUpdate(
|
||||
provider.awareness,
|
||||
Array.from(provider.awareness.getStates().keys()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
packages/provider/src/MessageSender.ts
Normal file
22
packages/provider/src/MessageSender.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import { toUint8Array } from "lib0/encoding";
|
||||
import type { ConstructableOutgoingMessage } from "./types.ts";
|
||||
|
||||
export class MessageSender {
|
||||
encoder: Encoder;
|
||||
|
||||
message: any;
|
||||
|
||||
constructor(Message: ConstructableOutgoingMessage, args: any = {}) {
|
||||
this.message = new Message();
|
||||
this.encoder = this.message.get(args);
|
||||
}
|
||||
|
||||
create() {
|
||||
return toUint8Array(this.encoder);
|
||||
}
|
||||
|
||||
send(webSocket: any) {
|
||||
webSocket?.send(this.create());
|
||||
}
|
||||
}
|
||||
27
packages/provider/src/OutgoingMessage.ts
Normal file
27
packages/provider/src/OutgoingMessage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import { createEncoder, toUint8Array } from "lib0/encoding";
|
||||
import type {
|
||||
MessageType,
|
||||
OutgoingMessageArguments,
|
||||
OutgoingMessageInterface,
|
||||
} from "./types.ts";
|
||||
|
||||
export class OutgoingMessage implements OutgoingMessageInterface {
|
||||
encoder: Encoder;
|
||||
|
||||
type?: MessageType;
|
||||
|
||||
description?: string;
|
||||
|
||||
constructor() {
|
||||
this.encoder = createEncoder();
|
||||
}
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
return args.encoder;
|
||||
}
|
||||
|
||||
toUint8Array() {
|
||||
return toUint8Array(this.encoder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { writeVarString, writeVarUint } from "lib0/encoding";
|
||||
import { writeAuthentication } from "@hocuspocus/common";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
import { version } from "../version.ts";
|
||||
|
||||
export class AuthenticationMessage extends OutgoingMessage {
|
||||
type = MessageType.Auth;
|
||||
|
||||
description = "Authentication";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
if (typeof args.token === "undefined") {
|
||||
throw new Error(
|
||||
"The authentication message requires `token` as an argument.",
|
||||
);
|
||||
}
|
||||
|
||||
writeVarString(this.encoder, args.documentName!);
|
||||
writeVarUint(this.encoder, this.type);
|
||||
writeAuthentication(this.encoder, args.token);
|
||||
writeVarString(this.encoder, version);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
41
packages/provider/src/OutgoingMessages/AwarenessMessage.ts
Normal file
41
packages/provider/src/OutgoingMessages/AwarenessMessage.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as encoding from "lib0/encoding";
|
||||
import { encodeAwarenessUpdate } from "y-protocols/awareness";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class AwarenessMessage extends OutgoingMessage {
|
||||
type = MessageType.Awareness;
|
||||
|
||||
description = "Awareness states update";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
if (typeof args.awareness === "undefined") {
|
||||
throw new Error(
|
||||
"The awareness message requires awareness as an argument",
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof args.clients === "undefined") {
|
||||
throw new Error("The awareness message requires clients as an argument");
|
||||
}
|
||||
|
||||
encoding.writeVarString(this.encoder, args.documentName!);
|
||||
encoding.writeVarUint(this.encoder, this.type);
|
||||
|
||||
let awarenessUpdate;
|
||||
if (args.states === undefined) {
|
||||
awarenessUpdate = encodeAwarenessUpdate(args.awareness, args.clients);
|
||||
} else {
|
||||
awarenessUpdate = encodeAwarenessUpdate(
|
||||
args.awareness,
|
||||
args.clients,
|
||||
args.states,
|
||||
);
|
||||
}
|
||||
|
||||
encoding.writeVarUint8Array(this.encoder, awarenessUpdate);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
17
packages/provider/src/OutgoingMessages/CloseMessage.ts
Normal file
17
packages/provider/src/OutgoingMessages/CloseMessage.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as encoding from "lib0/encoding";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class CloseMessage extends OutgoingMessage {
|
||||
type = MessageType.CLOSE;
|
||||
|
||||
description = "Ask the server to close the connection";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
encoding.writeVarString(this.encoder, args.documentName!);
|
||||
encoding.writeVarUint(this.encoder, this.type);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as encoding from "lib0/encoding";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class QueryAwarenessMessage extends OutgoingMessage {
|
||||
type = MessageType.QueryAwareness;
|
||||
|
||||
description = "Queries awareness states";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
encoding.writeVarString(this.encoder, args.documentName!);
|
||||
encoding.writeVarUint(this.encoder, this.type);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
18
packages/provider/src/OutgoingMessages/StatelessMessage.ts
Normal file
18
packages/provider/src/OutgoingMessages/StatelessMessage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { writeVarString, writeVarUint } from "lib0/encoding";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class StatelessMessage extends OutgoingMessage {
|
||||
type = MessageType.Stateless;
|
||||
|
||||
description = "A stateless message";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
writeVarString(this.encoder, args.documentName!);
|
||||
writeVarUint(this.encoder, this.type);
|
||||
writeVarString(this.encoder, args.payload ?? "");
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
25
packages/provider/src/OutgoingMessages/SyncStepOneMessage.ts
Normal file
25
packages/provider/src/OutgoingMessages/SyncStepOneMessage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as encoding from "lib0/encoding";
|
||||
import * as syncProtocol from "y-protocols/sync";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class SyncStepOneMessage extends OutgoingMessage {
|
||||
type = MessageType.Sync;
|
||||
|
||||
description = "First sync step";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
if (typeof args.document === "undefined") {
|
||||
throw new Error(
|
||||
"The sync step one message requires document as an argument",
|
||||
);
|
||||
}
|
||||
|
||||
encoding.writeVarString(this.encoder, args.documentName!);
|
||||
encoding.writeVarUint(this.encoder, this.type);
|
||||
syncProtocol.writeSyncStep1(this.encoder, args.document);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
25
packages/provider/src/OutgoingMessages/SyncStepTwoMessage.ts
Normal file
25
packages/provider/src/OutgoingMessages/SyncStepTwoMessage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as encoding from "lib0/encoding";
|
||||
import * as syncProtocol from "y-protocols/sync";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class SyncStepTwoMessage extends OutgoingMessage {
|
||||
type = MessageType.Sync;
|
||||
|
||||
description = "Second sync step";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
if (typeof args.document === "undefined") {
|
||||
throw new Error(
|
||||
"The sync step two message requires document as an argument",
|
||||
);
|
||||
}
|
||||
|
||||
encoding.writeVarString(this.encoder, args.documentName!);
|
||||
encoding.writeVarUint(this.encoder, this.type);
|
||||
syncProtocol.writeSyncStep2(this.encoder, args.document);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
20
packages/provider/src/OutgoingMessages/UpdateMessage.ts
Normal file
20
packages/provider/src/OutgoingMessages/UpdateMessage.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { writeVarString, writeVarUint } from "lib0/encoding";
|
||||
import { writeUpdate } from "y-protocols/sync";
|
||||
import type { OutgoingMessageArguments } from "../types.ts";
|
||||
import { MessageType } from "../types.ts";
|
||||
import { OutgoingMessage } from "../OutgoingMessage.ts";
|
||||
|
||||
export class UpdateMessage extends OutgoingMessage {
|
||||
type = MessageType.Sync;
|
||||
|
||||
description = "A document update";
|
||||
|
||||
get(args: Partial<OutgoingMessageArguments>) {
|
||||
writeVarString(this.encoder, args.documentName!);
|
||||
writeVarUint(this.encoder, this.type);
|
||||
|
||||
writeUpdate(this.encoder, args.update);
|
||||
|
||||
return this.encoder;
|
||||
}
|
||||
}
|
||||
3
packages/provider/src/index.ts
Normal file
3
packages/provider/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./HocuspocusProvider.ts";
|
||||
export * from "./HocuspocusProviderWebsocket.ts";
|
||||
export * from "./types.ts";
|
||||
121
packages/provider/src/types.ts
Normal file
121
packages/provider/src/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type * as Y from "yjs";
|
||||
import type { CloseEvent } from "@hocuspocus/common";
|
||||
import type { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import type { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type { AuthenticationMessage } from "./OutgoingMessages/AuthenticationMessage.ts";
|
||||
import type { AwarenessMessage } from "./OutgoingMessages/AwarenessMessage.ts";
|
||||
import type { QueryAwarenessMessage } from "./OutgoingMessages/QueryAwarenessMessage.ts";
|
||||
import type { SyncStepOneMessage } from "./OutgoingMessages/SyncStepOneMessage.ts";
|
||||
import type { SyncStepTwoMessage } from "./OutgoingMessages/SyncStepTwoMessage.ts";
|
||||
import type { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
|
||||
|
||||
export enum MessageType {
|
||||
Sync = 0,
|
||||
Awareness = 1,
|
||||
Auth = 2,
|
||||
QueryAwareness = 3,
|
||||
Stateless = 5,
|
||||
CLOSE = 7,
|
||||
SyncStatus = 8,
|
||||
Ping = 9,
|
||||
Pong = 10,
|
||||
}
|
||||
|
||||
export enum WebSocketStatus {
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Disconnected = "disconnected",
|
||||
}
|
||||
|
||||
export type AuthorizedScope = "read-write" | "readonly";
|
||||
|
||||
export interface OutgoingMessageInterface {
|
||||
encoder: Encoder;
|
||||
type?: MessageType;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OutgoingMessageArguments {
|
||||
documentName: string;
|
||||
token: string;
|
||||
document: Y.Doc;
|
||||
awareness: Awareness;
|
||||
clients: number[];
|
||||
states: Map<number, { [key: string]: any }>;
|
||||
update: any;
|
||||
payload: string;
|
||||
encoder: Encoder;
|
||||
}
|
||||
|
||||
export interface Constructable<T> {
|
||||
new (...args: any): T;
|
||||
}
|
||||
|
||||
export type ConstructableOutgoingMessage =
|
||||
| Constructable<AuthenticationMessage>
|
||||
| Constructable<AwarenessMessage>
|
||||
| Constructable<QueryAwarenessMessage>
|
||||
| Constructable<SyncStepOneMessage>
|
||||
| Constructable<SyncStepTwoMessage>
|
||||
| Constructable<UpdateMessage>;
|
||||
|
||||
export type onAuthenticationFailedParameters = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type onAuthenticatedParameters = {
|
||||
scope: AuthorizedScope;
|
||||
};
|
||||
|
||||
export type onOpenParameters = {
|
||||
event: Event;
|
||||
};
|
||||
|
||||
export type onMessageParameters = {
|
||||
event: MessageEvent;
|
||||
message: IncomingMessage;
|
||||
};
|
||||
|
||||
export type onOutgoingMessageParameters = {
|
||||
message: OutgoingMessage;
|
||||
};
|
||||
|
||||
export type onStatusParameters = {
|
||||
status: WebSocketStatus;
|
||||
};
|
||||
|
||||
export type onSyncedParameters = {
|
||||
state: boolean;
|
||||
};
|
||||
|
||||
export type onUnsyncedChangesParameters = {
|
||||
number: number;
|
||||
};
|
||||
|
||||
export type onDisconnectParameters = {
|
||||
event: CloseEvent;
|
||||
};
|
||||
|
||||
export type onCloseParameters = {
|
||||
event: CloseEvent;
|
||||
};
|
||||
|
||||
export type onMaxAttemptsFailedParameters = {
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type onAwarenessUpdateParameters = {
|
||||
states: StatesArray;
|
||||
};
|
||||
|
||||
export type onAwarenessChangeParameters = {
|
||||
states: StatesArray;
|
||||
};
|
||||
|
||||
export type onStatelessParameters = {
|
||||
payload: string;
|
||||
};
|
||||
|
||||
export type StatesArray = { clientId: number; [key: string | number]: any }[];
|
||||
6
packages/provider/src/version.ts
Normal file
6
packages/provider/src/version.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const version: string =
|
||||
// @ts-expect-error - __HOCUSPOCUS_VERSION__ is replaced at build time by rolldown
|
||||
typeof __HOCUSPOCUS_VERSION__ !== "undefined"
|
||||
? // @ts-expect-error - __HOCUSPOCUS_VERSION__ is replaced at build time by rolldown
|
||||
__HOCUSPOCUS_VERSION__
|
||||
: "unknown";
|
||||
2176
packages/server/CHANGELOG.md
Normal file
2176
packages/server/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
107
packages/server/README.md
Normal file
107
packages/server/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @hocuspocus/server
|
||||
[](https://www.npmjs.com/package/@hocuspocus/server)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/server)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
The collaborative editing backend for [Tiptap](https://github.com/ueberdosis/tiptap). Built on [Y.js](https://github.com/yjs/yjs), runs on Node.js (22+), Bun, Deno, and Cloudflare Workers.
|
||||
|
||||
> [!TIP]
|
||||
> Help us **chart the future of Hocuspocus** by telling us about your needs in a short survey. Plus, you’ll get a chance to earn a $50 gift card.
|
||||
> **[Take the survey →](https://docs.google.com/forms/d/e/1FAIpQLSeUS8S1iZTud58GBSD_d4c8lfUvkZHvzU99kWxP1wnNZnPKpw/viewform?usp=header)**
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hocuspocus/server
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Minimal WebSocket server on port `1234`:
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
Hook into the document lifecycle:
|
||||
|
||||
```js
|
||||
import { Server } from "@hocuspocus/server"
|
||||
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
|
||||
async onAuthenticate({ token }) {
|
||||
if (token !== "super-secret-token") {
|
||||
throw new Error("Not authorized!")
|
||||
}
|
||||
},
|
||||
|
||||
async onLoadDocument({ documentName }) {
|
||||
// return a Y.Doc for new documents, or nothing to use the default empty doc
|
||||
},
|
||||
|
||||
async onStoreDocument({ documentName, document }) {
|
||||
// persist the Y.Doc binary state wherever you like
|
||||
},
|
||||
})
|
||||
|
||||
server.listen()
|
||||
```
|
||||
|
||||
For a database-backed server, combine with an extension like [`@hocuspocus/extension-sqlite`](../extension-sqlite) or [`@hocuspocus/extension-database`](../extension-database).
|
||||
|
||||
## Resource limits
|
||||
|
||||
Messages received from a client before authentication completes are buffered in
|
||||
memory. To prevent an unauthenticated client from exhausting server memory, the
|
||||
server bounds this buffer per connection and closes connections that exceed the
|
||||
limits. The defaults are safe for typical clients (which authenticate before
|
||||
sending document data); raise them only if you have a specific need.
|
||||
|
||||
```js
|
||||
const server = new Server({
|
||||
port: 1234,
|
||||
|
||||
// Max bytes buffered across all documents of one unauthenticated connection.
|
||||
maxUnauthenticatedQueueSize: 5 * 1024 * 1024, // default: 5 MiB
|
||||
|
||||
// Max number of messages buffered for one unauthenticated connection.
|
||||
maxUnauthenticatedQueueMessages: 1_000, // default: 1000
|
||||
|
||||
// Max number of documents one connection may open before authenticating any.
|
||||
maxPendingDocuments: 100, // default: 100
|
||||
|
||||
// Connections that don't authenticate any document within `timeout` ms are
|
||||
// closed. This deadline is not refreshed by inbound traffic.
|
||||
timeout: 60_000, // default: 60s
|
||||
})
|
||||
```
|
||||
|
||||
## Non-Node.js runtimes
|
||||
|
||||
Use the `Hocuspocus` class directly to attach to any `WebSocketLike` instance (Bun, Deno, Cloudflare Workers, Express, etc.):
|
||||
|
||||
```js
|
||||
import { Hocuspocus } from "@hocuspocus/server"
|
||||
|
||||
const hocuspocus = new Hocuspocus({ /* config */ })
|
||||
|
||||
// pass any WebSocket-like instance + request + optional context:
|
||||
hocuspocus.handleConnection(ws, request, context)
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full reference, hooks, extensions, and scaling guides: [tiptap.dev/docs/hocuspocus](https://tiptap.dev/docs/hocuspocus).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).
|
||||
52
packages/server/package.json
Normal file
52
packages/server/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@hocuspocus/server",
|
||||
"description": "plug & play collaboration backend",
|
||||
"version": "4.7.0",
|
||||
"homepage": "https://hocuspocus.dev",
|
||||
"keywords": [
|
||||
"hocuspocus",
|
||||
"yjs",
|
||||
"yjs-websocket",
|
||||
"prosemirror"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/hocuspocus-server.cjs",
|
||||
"module": "dist/hocuspocus-server.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
"source": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/hocuspocus-server.esm.js",
|
||||
"require": "./dist/hocuspocus-server.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hocuspocus/common": "workspace:^",
|
||||
"async-mutex": "^0.5.0",
|
||||
"crossws": "^0.4.4",
|
||||
"kleur": "^4.1.5",
|
||||
"lib0": "^0.2.117"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
|
||||
"repository": {
|
||||
"url": "https://github.com/ueberdosis/hocuspocus"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
641
packages/server/src/ClientConnection.ts
Normal file
641
packages/server/src/ClientConnection.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
type CloseEvent,
|
||||
ConnectionTimeout,
|
||||
Forbidden,
|
||||
ResetConnection,
|
||||
Unauthorized,
|
||||
WsReadyStates,
|
||||
} from "@hocuspocus/common";
|
||||
import * as decoding from "lib0/decoding";
|
||||
import Connection from "./Connection.ts";
|
||||
import type Document from "./Document.ts";
|
||||
import type { Hocuspocus } from "./Hocuspocus.ts";
|
||||
import { IncomingMessage as SocketIncomingMessage } from "./IncomingMessage.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type {
|
||||
ConnectionConfiguration,
|
||||
WebSocketLike,
|
||||
afterHandleMessagePayload,
|
||||
beforeHandleMessagePayload,
|
||||
beforeSyncPayload,
|
||||
onDisconnectPayload,
|
||||
} from "./types.ts";
|
||||
import { MessageType } from "./types.ts";
|
||||
import { getParameters } from "./util/getParameters.ts";
|
||||
|
||||
/**
|
||||
* The `ClientConnection` class is responsible for handling an incoming WebSocket
|
||||
*
|
||||
* TODO-refactor:
|
||||
* - use event handlers instead of calling hooks directly, hooks should probably be called from Hocuspocus.ts
|
||||
*/
|
||||
export class ClientConnection<Context = any> {
|
||||
// Map of established document connections, keyed by rawKey (composite or plain)
|
||||
private readonly documentConnections: Record<string, Connection<Context>> =
|
||||
{};
|
||||
|
||||
// While the connection will be establishing messages will
|
||||
// be queued and handled later.
|
||||
private readonly incomingMessageQueue: Record<string, Uint8Array[]> = {};
|
||||
|
||||
// Tracks the number of bytes buffered per document key, so the total amount
|
||||
// of unauthenticated buffered data can be bounded (see GHSA-xwhh-v746-pj9m).
|
||||
private readonly incomingMessageQueueBytes: Record<string, number> = {};
|
||||
|
||||
// While the connection is establishing, keep track of which documents have received auth
|
||||
private readonly documentConnectionsEstablished = new Set<string>();
|
||||
|
||||
// Hook payloads keyed by rawKey (composite or plain)
|
||||
private readonly hookPayloads: Record<
|
||||
string,
|
||||
{
|
||||
instance: Hocuspocus;
|
||||
request: Request;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
context: Context;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
> = {};
|
||||
|
||||
private readonly callbacks = {
|
||||
onClose: [(document: Document, payload: onDisconnectPayload) => {}],
|
||||
};
|
||||
|
||||
// Every new connection gets a unique identifier.
|
||||
private readonly socketId = crypto.randomUUID();
|
||||
|
||||
timeout: number;
|
||||
|
||||
private readonly maxUnauthenticatedQueueSize: number;
|
||||
|
||||
private readonly maxUnauthenticatedQueueMessages: number;
|
||||
|
||||
private readonly maxPendingDocuments: number;
|
||||
|
||||
pingInterval: NodeJS.Timeout;
|
||||
|
||||
lastMessageReceivedAt = Date.now();
|
||||
|
||||
// When the connection was opened. Used to enforce a pre-authentication
|
||||
// deadline that inbound traffic cannot refresh.
|
||||
private readonly connectionEstablishedAt = Date.now();
|
||||
|
||||
// Set once any document on this connection has successfully authenticated.
|
||||
// Sticky on purpose: once a client has proven itself, the normal idle-timeout
|
||||
// logic applies for the rest of the socket's life, even if it later closes all
|
||||
// of its documents.
|
||||
private hasAuthenticated = false;
|
||||
|
||||
// Set once the connection has been proactively torn down (timeout or a limit
|
||||
// breach). Guards against processing — and re-logging — frames that were
|
||||
// already in flight when we decided to close the socket.
|
||||
private terminated = false;
|
||||
|
||||
/**
|
||||
* The `ClientConnection` class receives incoming WebSocket connections,
|
||||
* runs all hooks:
|
||||
*
|
||||
* - onConnect for all connections
|
||||
* - onAuthenticate only if required
|
||||
*
|
||||
* … and if nothings fails it'll fully establish the connection and
|
||||
* load the Document then.
|
||||
*/
|
||||
constructor(
|
||||
private readonly websocket: WebSocketLike,
|
||||
private readonly request: Request,
|
||||
private readonly documentProvider: {
|
||||
createDocument: Hocuspocus["createDocument"];
|
||||
},
|
||||
// TODO: change to events
|
||||
private readonly hooks: Hocuspocus["hooks"],
|
||||
private readonly opts: {
|
||||
timeout: number;
|
||||
maxUnauthenticatedQueueSize?: number;
|
||||
maxUnauthenticatedQueueMessages?: number;
|
||||
maxPendingDocuments?: number;
|
||||
},
|
||||
private readonly defaultContext: Context = {} as Context,
|
||||
) {
|
||||
this.timeout = opts.timeout;
|
||||
this.maxUnauthenticatedQueueSize =
|
||||
opts.maxUnauthenticatedQueueSize ?? 5 * 1024 * 1024;
|
||||
this.maxUnauthenticatedQueueMessages =
|
||||
opts.maxUnauthenticatedQueueMessages ?? 1_000;
|
||||
this.maxPendingDocuments = opts.maxPendingDocuments ?? 100;
|
||||
this.pingInterval = setInterval(this.check, this.timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket close event. Call this from your integration
|
||||
* when the WebSocket connection closes.
|
||||
*/
|
||||
handleClose(event?: CloseEvent) {
|
||||
this.close(event);
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
private close(event?: CloseEvent) {
|
||||
Object.values(this.documentConnections).forEach((connection) =>
|
||||
connection.close(event),
|
||||
);
|
||||
|
||||
// Release any buffered pre-authentication state. Without this, a
|
||||
// timed-out or rejected connection would keep its queued messages and
|
||||
// hook payloads in memory until the process exits (GHSA-xwhh-v746-pj9m).
|
||||
for (const rawKey of Object.keys(this.incomingMessageQueue)) {
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
}
|
||||
for (const rawKey of Object.keys(this.hookPayloads)) {
|
||||
delete this.hookPayloads[rawKey];
|
||||
}
|
||||
this.documentConnectionsEstablished.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the connection: release the established document connections and
|
||||
* the buffered pre-authentication state, then close the underlying socket.
|
||||
* Closing the socket is essential — otherwise an unauthenticated client can
|
||||
* keep the socket (and its resources) open even after a timeout fires.
|
||||
*/
|
||||
private terminate(event?: CloseEvent) {
|
||||
if (this.terminated) {
|
||||
return;
|
||||
}
|
||||
this.terminated = true;
|
||||
|
||||
this.close(event);
|
||||
|
||||
if (
|
||||
this.websocket.readyState !== WsReadyStates.Closing &&
|
||||
this.websocket.readyState !== WsReadyStates.Closed
|
||||
) {
|
||||
this.websocket.close(event?.code, event?.reason);
|
||||
}
|
||||
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection if no messages have been received within the timeout period.
|
||||
* This replaces application-level ping/pong to maintain backward compatibility
|
||||
* with older provider versions that don't understand Ping/Pong message types.
|
||||
* Awareness updates (~every 30s) keep active connections alive.
|
||||
*/
|
||||
private check = () => {
|
||||
// Until the connection has authenticated at least one document, use an
|
||||
// absolute deadline based on when the connection opened. Inbound traffic
|
||||
// updates `lastMessageReceivedAt`, so a flood of unauthenticated frames
|
||||
// could otherwise refresh the timeout forever and keep the socket alive.
|
||||
// Once authenticated, fall back to the normal idle behavior — even if the
|
||||
// client later closes all of its documents — so an established client that
|
||||
// keeps its socket open is not closed based on the original open time.
|
||||
const referenceTime = this.hasAuthenticated
|
||||
? this.lastMessageReceivedAt
|
||||
: this.connectionEstablishedAt;
|
||||
|
||||
if (Date.now() - referenceTime > this.timeout) {
|
||||
this.terminate(ConnectionTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
// Total bytes currently buffered for unauthenticated documents on this
|
||||
// connection. Derived on demand so the various cleanup paths only need to
|
||||
// delete their keys without keeping a running counter in sync.
|
||||
private getQueuedBytes(): number {
|
||||
let total = 0;
|
||||
for (const rawKey of Object.keys(this.incomingMessageQueueBytes)) {
|
||||
total += this.incomingMessageQueueBytes[rawKey];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Total number of messages currently buffered for unauthenticated documents.
|
||||
private getQueuedMessageCount(): number {
|
||||
let total = 0;
|
||||
for (const rawKey of Object.keys(this.incomingMessageQueue)) {
|
||||
total += this.incomingMessageQueue[rawKey].length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Number of distinct documents on this connection that have not yet finished
|
||||
// authenticating. Each holds its own queue and hook payload, so this is capped
|
||||
// to prevent a single connection from amplifying memory usage by fanning out
|
||||
// across many document names. We count by `isAuthenticated` (not by whether an
|
||||
// Auth frame has been seen) so that sending an Auth frame first — which marks
|
||||
// the document as "establishing" but keeps its hook payload alive while a slow
|
||||
// `onAuthenticate` runs — cannot be used to bypass the limit.
|
||||
private getPendingDocumentCount(): number {
|
||||
let total = 0;
|
||||
for (const rawKey of Object.keys(this.hookPayloads)) {
|
||||
if (!this.hookPayloads[rawKey].connectionConfig.isAuthenticated) {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when the connection is closed
|
||||
*/
|
||||
public onClose(
|
||||
callback: (document: Document, payload: onDisconnectPayload) => void,
|
||||
): ClientConnection {
|
||||
this.callbacks.onClose.push(callback);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection by the given request and document
|
||||
*/
|
||||
private createConnection(
|
||||
connection: WebSocketLike,
|
||||
document: Document,
|
||||
hookPayload: (typeof this.hookPayloads)[string],
|
||||
sessionId: string | null,
|
||||
providerVersion: string | null,
|
||||
): Connection {
|
||||
const instance = new Connection(
|
||||
connection,
|
||||
hookPayload.request,
|
||||
document,
|
||||
hookPayload.socketId,
|
||||
hookPayload.context,
|
||||
hookPayload.connectionConfig.readOnly,
|
||||
sessionId,
|
||||
providerVersion,
|
||||
);
|
||||
|
||||
instance.onClose(async (document, event) => {
|
||||
// Wait for any pending message processing to complete before running
|
||||
// disconnect hooks. This ensures that document updates from queued messages
|
||||
// are applied (and their debounced onStoreDocument scheduled) before the
|
||||
// disconnect handler checks whether to call executeNow.
|
||||
await instance.waitForPendingMessages();
|
||||
|
||||
const disconnectHookPayload: onDisconnectPayload = {
|
||||
instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
socketId: hookPayload.socketId,
|
||||
documentName: document.name,
|
||||
requestHeaders: hookPayload.request.headers,
|
||||
requestParameters: getParameters(hookPayload.request),
|
||||
};
|
||||
|
||||
await this.hooks("onDisconnect", disconnectHookPayload);
|
||||
this.callbacks.onClose.forEach((callback) =>
|
||||
callback(document, disconnectHookPayload),
|
||||
);
|
||||
});
|
||||
|
||||
instance.onStatelessCallback(async (payload) => {
|
||||
try {
|
||||
return await this.hooks("onStateless", payload);
|
||||
} catch (error: any) {
|
||||
if (error?.message) {
|
||||
// if a hook rejects and the error is empty, do nothing
|
||||
// this is only meant to prevent later hooks and the
|
||||
// default handler to do something. if an error is present
|
||||
// just rethrow it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
instance.beforeHandleMessage((connection, update) => {
|
||||
const beforeHandleMessagePayload: beforeHandleMessagePayload = {
|
||||
instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
socketId: hookPayload.socketId,
|
||||
connection,
|
||||
documentName: document.name,
|
||||
requestHeaders: hookPayload.request.headers,
|
||||
requestParameters: getParameters(hookPayload.request),
|
||||
update,
|
||||
};
|
||||
|
||||
return this.hooks("beforeHandleMessage", beforeHandleMessagePayload);
|
||||
});
|
||||
|
||||
instance.afterHandleMessage((connection, update) => {
|
||||
const afterHandleMessagePayload: afterHandleMessagePayload = {
|
||||
instance: this.documentProvider as Hocuspocus,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
socketId: hookPayload.socketId,
|
||||
connection,
|
||||
documentName: document.name,
|
||||
requestHeaders: hookPayload.request.headers,
|
||||
requestParameters: getParameters(hookPayload.request),
|
||||
update,
|
||||
};
|
||||
|
||||
return this.hooks("afterHandleMessage", afterHandleMessagePayload);
|
||||
});
|
||||
|
||||
instance.beforeSync((connection, payload) => {
|
||||
const beforeSyncPayload: beforeSyncPayload = {
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: hookPayload.context,
|
||||
document,
|
||||
documentName: document.name,
|
||||
connection,
|
||||
type: payload.type,
|
||||
payload: payload.payload,
|
||||
};
|
||||
|
||||
return this.hooks("beforeSync", beforeSyncPayload);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Once all hooks are run, we'll fully establish the connection:
|
||||
private setUpNewConnection = async (rawKey: string, documentName: string, sessionId: string | null) => {
|
||||
const hookPayload = this.hookPayloads[rawKey];
|
||||
// If no hook interrupts, create a document and connection
|
||||
const document = await this.documentProvider.createDocument(
|
||||
documentName,
|
||||
hookPayload.request,
|
||||
hookPayload.socketId,
|
||||
hookPayload.connectionConfig,
|
||||
hookPayload.context,
|
||||
);
|
||||
const connection = this.createConnection(this.websocket, document, hookPayload, sessionId, hookPayload.providerVersion);
|
||||
|
||||
connection.onClose((document, event) => {
|
||||
delete this.hookPayloads[rawKey];
|
||||
delete this.documentConnections[rawKey];
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
this.documentConnectionsEstablished.delete(rawKey);
|
||||
});
|
||||
|
||||
connection.onTokenSyncCallback(async (payload) => {
|
||||
try {
|
||||
return await this.hooks(
|
||||
"onTokenSync",
|
||||
{
|
||||
...hookPayload,
|
||||
...payload,
|
||||
document,
|
||||
connection,
|
||||
documentName,
|
||||
},
|
||||
(contextAdditions: Partial<Context>) => {
|
||||
hookPayload.context = {
|
||||
...hookPayload.context,
|
||||
...contextAdditions,
|
||||
};
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const error = { ...Unauthorized, ...err };
|
||||
connection.close({ code: error.code, reason: error.reason });
|
||||
}
|
||||
});
|
||||
|
||||
this.documentConnections[rawKey] = connection;
|
||||
|
||||
// If the WebSocket has already disconnected (wow, that was fast) – then
|
||||
// immediately call close to cleanup the connection and document in memory.
|
||||
if (
|
||||
this.websocket.readyState === WsReadyStates.Closing ||
|
||||
this.websocket.readyState === WsReadyStates.Closed
|
||||
) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain queued messages to the Connection. Once drained, the buffered
|
||||
// data is no longer needed (subsequent messages route straight to the
|
||||
// Connection), so release it and stop counting it against the queue
|
||||
// limits.
|
||||
this.incomingMessageQueue[rawKey]?.forEach((input) => {
|
||||
connection.handleMessage(input);
|
||||
});
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
|
||||
await this.hooks("connected", {
|
||||
...hookPayload,
|
||||
documentName,
|
||||
context: hookPayload.context,
|
||||
connection,
|
||||
});
|
||||
};
|
||||
|
||||
// This listener handles authentication messages and queues everything else.
|
||||
private handleQueueingMessage = async (data: Uint8Array, rawKey: string, documentName: string) => {
|
||||
try {
|
||||
const tmpMsg = new SocketIncomingMessage(data);
|
||||
|
||||
decoding.readVarString(tmpMsg.decoder); // skip the message address (already extracted)
|
||||
const type = decoding.readVarUint(tmpMsg.decoder);
|
||||
|
||||
if (
|
||||
!(
|
||||
type === MessageType.Auth &&
|
||||
!this.documentConnectionsEstablished.has(rawKey)
|
||||
)
|
||||
) {
|
||||
// Bound the amount of data buffered before authentication. An
|
||||
// unauthenticated client must not be able to grow this queue without
|
||||
// limit (GHSA-xwhh-v746-pj9m).
|
||||
const queuedBytes = this.getQueuedBytes();
|
||||
const queuedMessages = this.getQueuedMessageCount();
|
||||
const exceedsBytes =
|
||||
queuedBytes + data.byteLength > this.maxUnauthenticatedQueueSize;
|
||||
const exceedsMessages =
|
||||
queuedMessages + 1 > this.maxUnauthenticatedQueueMessages;
|
||||
|
||||
if (exceedsBytes || exceedsMessages) {
|
||||
console.warn(
|
||||
exceedsBytes
|
||||
? `Hocuspocus: closing connection ${this.socketId}: unauthenticated message queue size limit exceeded for "${documentName}" (${queuedBytes + data.byteLength} > maxUnauthenticatedQueueSize ${this.maxUnauthenticatedQueueSize} bytes).`
|
||||
: `Hocuspocus: closing connection ${this.socketId}: unauthenticated message queue length limit exceeded for "${documentName}" (${queuedMessages + 1} > maxUnauthenticatedQueueMessages ${this.maxUnauthenticatedQueueMessages}).`,
|
||||
);
|
||||
this.terminate(ResetConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.incomingMessageQueue[rawKey].push(data);
|
||||
this.incomingMessageQueueBytes[rawKey] =
|
||||
(this.incomingMessageQueueBytes[rawKey] ?? 0) + data.byteLength;
|
||||
return;
|
||||
}
|
||||
|
||||
// Okay, we've got the authentication message we're waiting for:
|
||||
this.documentConnectionsEstablished.add(rawKey);
|
||||
|
||||
// The 2nd integer contains the submessage type
|
||||
// which will always be authentication when sent from client -> server
|
||||
decoding.readVarUint(tmpMsg.decoder);
|
||||
const token = decoding.readVarString(tmpMsg.decoder);
|
||||
|
||||
// Try to read providerVersion (new protocol field)
|
||||
let providerVersion: string | null = null;
|
||||
if (decoding.hasContent(tmpMsg.decoder)) {
|
||||
providerVersion = decoding.readVarString(tmpMsg.decoder);
|
||||
}
|
||||
|
||||
// Extract sessionId from the rawKey (documentName\0sessionId) if present
|
||||
const sepIdx = rawKey.indexOf('\0');
|
||||
const sessionId = sepIdx === -1 ? null : rawKey.substring(sepIdx + 1);
|
||||
|
||||
// Response uses rawKey so session-aware clients can route correctly
|
||||
const responseAddress = rawKey;
|
||||
|
||||
try {
|
||||
const hookPayload = this.hookPayloads[rawKey];
|
||||
hookPayload.providerVersion = providerVersion;
|
||||
|
||||
await this.hooks(
|
||||
"onConnect",
|
||||
{ ...hookPayload, documentName },
|
||||
(contextAdditions: Partial<Context>) => {
|
||||
hookPayload.context = {
|
||||
...hookPayload.context,
|
||||
...contextAdditions,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await this.hooks(
|
||||
"onAuthenticate",
|
||||
{
|
||||
token,
|
||||
...hookPayload,
|
||||
documentName,
|
||||
},
|
||||
(contextAdditions: Partial<Context>) => {
|
||||
hookPayload.context = {
|
||||
...hookPayload.context,
|
||||
...contextAdditions,
|
||||
};
|
||||
},
|
||||
);
|
||||
// All `onAuthenticate` hooks passed.
|
||||
hookPayload.connectionConfig.isAuthenticated = true;
|
||||
this.hasAuthenticated = true;
|
||||
|
||||
// Let the client know that authentication was successful.
|
||||
const message = new OutgoingMessage(responseAddress).writeAuthenticated(
|
||||
hookPayload.connectionConfig.readOnly,
|
||||
);
|
||||
|
||||
this.websocket.send(message.toUint8Array());
|
||||
|
||||
// Time to actually establish the connection.
|
||||
await this.setUpNewConnection(rawKey, documentName, sessionId);
|
||||
} catch (err: any) {
|
||||
const error = err || Forbidden;
|
||||
const message = new OutgoingMessage(responseAddress).writePermissionDenied(
|
||||
error.reason ?? "permission-denied",
|
||||
);
|
||||
|
||||
this.websocket.send(message.toUint8Array());
|
||||
|
||||
// Clean up all state for this document so a retry is treated
|
||||
// as a fresh first connection attempt.
|
||||
this.documentConnectionsEstablished.delete(rawKey);
|
||||
delete this.hookPayloads[rawKey];
|
||||
delete this.incomingMessageQueue[rawKey];
|
||||
delete this.incomingMessageQueueBytes[rawKey];
|
||||
}
|
||||
|
||||
// Catch errors due to failed decoding of data
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.websocket.close(ResetConnection.code, ResetConnection.reason);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket message. Call this from your integration
|
||||
* when the WebSocket receives a binary message.
|
||||
*/
|
||||
handleMessage = (data: Uint8Array) => {
|
||||
// Ignore anything that arrives after we've decided to tear the connection
|
||||
// down (e.g. frames that were already in flight when a limit was breached).
|
||||
if (this.terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastMessageReceivedAt = Date.now();
|
||||
|
||||
try {
|
||||
const tmpMsg = new SocketIncomingMessage(data);
|
||||
|
||||
const rawKey = decoding.readVarString(tmpMsg.decoder);
|
||||
|
||||
// Extract the plain documentName (the raw key may be documentName\0sessionId)
|
||||
const sepIdx = rawKey.indexOf('\0');
|
||||
const documentName = sepIdx === -1 ? rawKey : rawKey.substring(0, sepIdx);
|
||||
|
||||
// Look up by rawKey first (session-aware providers), then fall back
|
||||
// to plain documentName for backward compatibility with old providers
|
||||
const connection = this.documentConnections[rawKey]
|
||||
?? this.documentConnections[documentName];
|
||||
if (connection) {
|
||||
connection.handleMessage(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirst = this.incomingMessageQueue[rawKey] === undefined;
|
||||
if (isFirst) {
|
||||
// Cap the number of documents a single connection can open before
|
||||
// authenticating any of them. Each pending document allocates its own
|
||||
// queue and hook payload, so this prevents memory amplification by
|
||||
// fanning out across many document names (GHSA-xwhh-v746-pj9m).
|
||||
if (this.getPendingDocumentCount() >= this.maxPendingDocuments) {
|
||||
console.warn(
|
||||
`Hocuspocus: closing connection ${this.socketId}: too many pending unauthenticated documents (maxPendingDocuments ${this.maxPendingDocuments}). Last requested document: "${documentName}".`,
|
||||
);
|
||||
this.terminate(ResetConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
this.incomingMessageQueue[rawKey] = [];
|
||||
if (this.hookPayloads[rawKey]) {
|
||||
throw new Error("first message, but hookPayloads exists");
|
||||
}
|
||||
|
||||
this.hookPayloads[rawKey] = {
|
||||
instance: this.documentProvider as Hocuspocus,
|
||||
request: this.request,
|
||||
connectionConfig: {
|
||||
readOnly: false,
|
||||
isAuthenticated: false,
|
||||
},
|
||||
requestHeaders: this.request.headers,
|
||||
requestParameters: getParameters(this.request),
|
||||
socketId: this.socketId,
|
||||
context: {
|
||||
...this.defaultContext,
|
||||
},
|
||||
providerVersion: null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
this.handleQueueingMessage(data, rawKey, documentName);
|
||||
} catch (closeError) {
|
||||
// catch is needed in case an invalid payload crashes the parsing of the Uint8Array
|
||||
console.error(closeError);
|
||||
this.websocket.close(Unauthorized.code, Unauthorized.reason);
|
||||
}
|
||||
};
|
||||
}
|
||||
305
packages/server/src/Connection.ts
Normal file
305
packages/server/src/Connection.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
type CloseEvent,
|
||||
ResetConnection,
|
||||
WsReadyStates,
|
||||
} from "@hocuspocus/common";
|
||||
import type Document from "./Document.ts";
|
||||
import { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { MessageReceiver } from "./MessageReceiver.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type {
|
||||
WebSocketLike,
|
||||
beforeSyncPayload,
|
||||
onStatelessPayload,
|
||||
} from "./types.ts";
|
||||
|
||||
export class Connection<Context = any> {
|
||||
webSocket: WebSocketLike;
|
||||
|
||||
context: Context;
|
||||
|
||||
document: Document;
|
||||
|
||||
request: Request;
|
||||
|
||||
callbacks = {
|
||||
onClose: [(document: Document, event?: CloseEvent) => {}],
|
||||
beforeHandleMessage: (connection: Connection, update: Uint8Array) =>
|
||||
Promise.resolve(),
|
||||
afterHandleMessage: (connection: Connection, update: Uint8Array) =>
|
||||
Promise.resolve(),
|
||||
beforeSync: (
|
||||
connection: Connection,
|
||||
payload: Pick<beforeSyncPayload, "type" | "payload">,
|
||||
) => Promise.resolve(),
|
||||
statelessCallback: (payload: onStatelessPayload) => Promise.resolve(),
|
||||
onTokenSyncCallback: (payload: { token: string }) => Promise.resolve(),
|
||||
};
|
||||
|
||||
socketId: string;
|
||||
|
||||
readOnly: boolean;
|
||||
|
||||
sessionId: string | null;
|
||||
|
||||
providerVersion: string | null;
|
||||
|
||||
/**
|
||||
* The address string prefixed to outgoing messages.
|
||||
* Session-aware clients get `documentName\0sessionId`; legacy clients get plain `documentName`.
|
||||
*/
|
||||
get messageAddress(): string {
|
||||
return this.sessionId
|
||||
? `${this.document.name}\0${this.sessionId}`
|
||||
: this.document.name;
|
||||
}
|
||||
|
||||
private messageQueue: Uint8Array[] = [];
|
||||
|
||||
private processingPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
constructor(
|
||||
connection: WebSocketLike,
|
||||
request: Request,
|
||||
document: Document,
|
||||
socketId: string,
|
||||
context: Context,
|
||||
readOnly = false,
|
||||
sessionId?: string | null,
|
||||
providerVersion?: string | null,
|
||||
) {
|
||||
this.webSocket = connection;
|
||||
this.context = context;
|
||||
this.document = document;
|
||||
this.request = request;
|
||||
this.socketId = socketId;
|
||||
this.readOnly = readOnly;
|
||||
this.sessionId = sessionId ?? null;
|
||||
this.providerVersion = providerVersion ?? null;
|
||||
|
||||
this.document.addConnection(this);
|
||||
|
||||
this.sendCurrentAwareness();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when the connection is closed
|
||||
*/
|
||||
onClose(
|
||||
callback: (document: Document, event?: CloseEvent) => void,
|
||||
): Connection {
|
||||
this.callbacks.onClose.push(callback);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when an stateless message is received
|
||||
*/
|
||||
onStatelessCallback(
|
||||
callback: (payload: onStatelessPayload) => Promise<void>,
|
||||
): Connection {
|
||||
this.callbacks.statelessCallback = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before an message is handled
|
||||
*/
|
||||
beforeHandleMessage(
|
||||
callback: (connection: Connection, update: Uint8Array) => Promise<any>,
|
||||
): Connection {
|
||||
this.callbacks.beforeHandleMessage = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered after a message has been handled
|
||||
*/
|
||||
afterHandleMessage(
|
||||
callback: (connection: Connection, update: Uint8Array) => Promise<any>,
|
||||
): Connection {
|
||||
this.callbacks.afterHandleMessage = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before a sync message is handled
|
||||
*/
|
||||
beforeSync(
|
||||
callback: (
|
||||
connection: Connection,
|
||||
payload: Pick<beforeSyncPayload, "type" | "payload">,
|
||||
) => Promise<any>,
|
||||
): Connection {
|
||||
this.callbacks.beforeSync = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when on token sync message is received
|
||||
*/
|
||||
onTokenSyncCallback(
|
||||
callback: (payload: { token: string }) => Promise<void>,
|
||||
): Connection {
|
||||
this.callbacks.onTokenSyncCallback = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when all queued messages have been processed.
|
||||
*/
|
||||
waitForPendingMessages(): Promise<void> {
|
||||
return this.processingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the given message
|
||||
*/
|
||||
send(message: Uint8Array): void {
|
||||
if (
|
||||
this.webSocket.readyState === WsReadyStates.Closing ||
|
||||
this.webSocket.readyState === WsReadyStates.Closed
|
||||
) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.webSocket.send(message);
|
||||
} catch (exception) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a stateless message with payload
|
||||
*/
|
||||
public sendStateless(payload: string): void {
|
||||
const message = new OutgoingMessage(this.messageAddress).writeStateless(
|
||||
payload,
|
||||
);
|
||||
|
||||
this.send(message.toUint8Array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Request current token from the client
|
||||
*/
|
||||
public requestToken(): void {
|
||||
const message = new OutgoingMessage(
|
||||
this.messageAddress,
|
||||
).writeTokenSyncRequest();
|
||||
|
||||
this.send(message.toUint8Array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful wrapper around the WebSocket close method.
|
||||
*/
|
||||
close(event?: CloseEvent): void {
|
||||
if (this.document.hasConnection(this)) {
|
||||
this.document.removeConnection(this);
|
||||
this.callbacks.onClose.forEach(
|
||||
(callback: (arg0: Document, arg1?: CloseEvent) => any) =>
|
||||
callback(this.document, event),
|
||||
);
|
||||
|
||||
const closeMessage = new OutgoingMessage(this.messageAddress);
|
||||
closeMessage.writeCloseMessage(
|
||||
event?.reason ?? "Server closed the connection",
|
||||
);
|
||||
this.send(closeMessage.toUint8Array());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the current document awareness to the client, if any
|
||||
* @private
|
||||
*/
|
||||
private sendCurrentAwareness(): void {
|
||||
if (!this.document.hasAwarenessStates()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const awarenessMessage = new OutgoingMessage(
|
||||
this.messageAddress,
|
||||
).createAwarenessUpdateMessage(this.document.awareness);
|
||||
|
||||
this.send(awarenessMessage.toUint8Array());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming message
|
||||
* @public
|
||||
*/
|
||||
public handleMessage(data: Uint8Array): void {
|
||||
this.messageQueue.push(data);
|
||||
|
||||
if (this.messageQueue.length === 1) {
|
||||
this.processingPromise = this.processMessages();
|
||||
}
|
||||
}
|
||||
|
||||
private async processMessages() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
const rawUpdate = this.messageQueue.at(0) as Uint8Array;
|
||||
|
||||
const message = new IncomingMessage(rawUpdate);
|
||||
const rawKey = message.readVarString();
|
||||
|
||||
// Accept messages addressed with either the plain documentName or documentName\0sessionId
|
||||
const sepIdx = rawKey.indexOf('\0');
|
||||
const documentName = sepIdx === -1 ? rawKey : rawKey.substring(0, sepIdx);
|
||||
if (documentName !== this.document.name) {
|
||||
this.messageQueue.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write the correct address so replies reach the right provider
|
||||
message.writeVarString(this.messageAddress);
|
||||
|
||||
try {
|
||||
await this.callbacks.beforeHandleMessage(this, rawUpdate);
|
||||
const receiver = new MessageReceiver(message);
|
||||
|
||||
try {
|
||||
await receiver.apply(this.document, this);
|
||||
} finally {
|
||||
try {
|
||||
await this.callbacks.afterHandleMessage(this, rawUpdate);
|
||||
} catch (afterError) {
|
||||
console.error(
|
||||
`afterHandleMessage hook failed (while handling ${documentName})`,
|
||||
afterError,
|
||||
);
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
} catch (e: any) {
|
||||
console.error(
|
||||
`closing connection ${this.socketId} (while handling ${documentName}) because of exception`,
|
||||
e,
|
||||
);
|
||||
this.close({
|
||||
code: "code" in e && typeof e.code === 'number' ? e.code : ResetConnection.code,
|
||||
reason: "reason" in e ? e.reason : ResetConnection.reason,
|
||||
});
|
||||
this.messageQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageQueue.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Connection;
|
||||
103
packages/server/src/DirectConnection.ts
Normal file
103
packages/server/src/DirectConnection.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { URLSearchParams } from "node:url";
|
||||
import type Document from "./Document.ts";
|
||||
import type { Hocuspocus } from "./Hocuspocus.ts";
|
||||
import type {
|
||||
DirectConnection as DirectConnectionInterface,
|
||||
DisconnectOptions,
|
||||
LocalTransactionOrigin,
|
||||
} from "./types.ts";
|
||||
|
||||
export class DirectConnection<Context = any>
|
||||
implements DirectConnectionInterface
|
||||
{
|
||||
document: Document | null = null;
|
||||
|
||||
instance!: Hocuspocus;
|
||||
|
||||
context: Context;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
constructor(document: Document, instance: Hocuspocus, context?: Context) {
|
||||
this.document = document;
|
||||
this.instance = instance;
|
||||
this.context = (context ?? {}) as Context;
|
||||
|
||||
this.document.addDirectConnection();
|
||||
}
|
||||
|
||||
async transact(transaction: (document: Document) => void) {
|
||||
if (!this.document) {
|
||||
throw new Error("direct connection closed");
|
||||
}
|
||||
|
||||
this.document.transact(
|
||||
(x) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
transaction(this.document!);
|
||||
},
|
||||
{
|
||||
source: "local",
|
||||
context: this.context,
|
||||
} satisfies LocalTransactionOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
async disconnect(options?: DisconnectOptions) {
|
||||
if (this.document) {
|
||||
// Defaults to true regardless of the server-wide `unloadImmediately`
|
||||
// setting, so the historical "durable on disconnect" behavior is kept
|
||||
// unless a caller explicitly opts into keeping the document warm.
|
||||
const unloadImmediately = options?.unloadImmediately ?? true;
|
||||
|
||||
this.document?.removeDirectConnection();
|
||||
|
||||
// With unloadImmediately the document is persisted synchronously.
|
||||
// Otherwise the store is debounced, so the document stays warm in memory
|
||||
// and a follow-up direct connection can reuse it (coalescing writes),
|
||||
// mirroring how websocket connections behave on close.
|
||||
await this.instance.storeDocumentHooks(
|
||||
this.document,
|
||||
{
|
||||
clientsCount: this.document.getConnectionsCount(),
|
||||
lastContext: this.context,
|
||||
lastTransactionOrigin: {
|
||||
source: "local",
|
||||
context: this.context,
|
||||
} satisfies LocalTransactionOrigin,
|
||||
document: this.document,
|
||||
documentName: this.document.name,
|
||||
instance: this.instance,
|
||||
},
|
||||
unloadImmediately,
|
||||
);
|
||||
|
||||
// If the direct connection was the only connection to the document
|
||||
// then we should trigger the onDisconnect hook for this doc. We only
|
||||
// unload it right away when unloadImmediately is set; otherwise the
|
||||
// debounced store above unloads it once it has flushed.
|
||||
if (
|
||||
this.document.getConnectionsCount() === 0 &&
|
||||
!this.document.saveMutex.isLocked()
|
||||
) {
|
||||
await this.instance.hooks("onDisconnect", {
|
||||
instance: this.instance,
|
||||
clientsCount: this.document.getConnectionsCount(),
|
||||
context: this.context,
|
||||
document: this.document,
|
||||
socketId: "server",
|
||||
documentName: this.document.name,
|
||||
requestHeaders: new Headers(),
|
||||
requestParameters: new URLSearchParams(),
|
||||
});
|
||||
|
||||
if (unloadImmediately) {
|
||||
await this.instance.unloadDocument(this.document);
|
||||
}
|
||||
}
|
||||
|
||||
this.document = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
487
packages/server/src/Document.ts
Normal file
487
packages/server/src/Document.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { Mutex } from "async-mutex";
|
||||
import {
|
||||
Awareness,
|
||||
applyAwarenessUpdate,
|
||||
removeAwarenessStates,
|
||||
} from "y-protocols/awareness";
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate, mergeUpdates } from "yjs";
|
||||
import type Connection from "./Connection.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import type {
|
||||
AwarenessUpdate,
|
||||
ConnectionTransactionOrigin,
|
||||
FlushOptions,
|
||||
TransactionOrigin,
|
||||
} from "./types.ts";
|
||||
import { isTransactionOrigin } from "./types.ts";
|
||||
|
||||
export class Document extends Doc {
|
||||
awareness: Awareness;
|
||||
|
||||
callbacks = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onUpdate: (document: Document, origin: unknown, update: Uint8Array) => {},
|
||||
beforeBroadcastStateless: (document: Document, stateless: string) => {},
|
||||
beforeHandleAwareness: (
|
||||
document: Document,
|
||||
states: Map<number, Record<string, any>>,
|
||||
transactionOrigin: unknown,
|
||||
) => Promise.resolve(),
|
||||
};
|
||||
|
||||
connections: Map<
|
||||
Connection,
|
||||
{
|
||||
clients: Set<any>;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
// The number of direct (non-websocket) connections to this document
|
||||
directConnectionsCount = 0;
|
||||
|
||||
name: string;
|
||||
|
||||
isLoading: boolean;
|
||||
|
||||
isDestroyed = false;
|
||||
|
||||
saveMutex = new Mutex();
|
||||
|
||||
lastChangeTime = 0;
|
||||
|
||||
/**
|
||||
* Batch outgoing broadcasts over this window (ms), or false to send each one
|
||||
* immediately. See `scheduleFlush`.
|
||||
*/
|
||||
flushDelay: FlushOptions["flushDelay"];
|
||||
|
||||
/**
|
||||
* Flush early once this many bytes of document updates are buffered.
|
||||
*/
|
||||
flushMaxBytes: FlushOptions["flushMaxBytes"];
|
||||
|
||||
private pendingUpdates: Array<Uint8Array> = [];
|
||||
|
||||
private pendingUpdateBytes = 0;
|
||||
|
||||
private pendingAwarenessClients: Set<number> = new Set();
|
||||
|
||||
private cancelFlush: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
constructor(
|
||||
name: string,
|
||||
yDocOptions?: object,
|
||||
options?: Partial<FlushOptions>,
|
||||
) {
|
||||
super(yDocOptions);
|
||||
|
||||
this.name = name;
|
||||
this.flushDelay = options?.flushDelay ?? 0;
|
||||
this.flushMaxBytes = options?.flushMaxBytes ?? 1024 * 1024;
|
||||
|
||||
this.awareness = new Awareness(this);
|
||||
this.awareness.setLocalState(null);
|
||||
|
||||
this.awareness.on("update", this.handleAwarenessUpdate.bind(this));
|
||||
this.on("update", this.handleUpdate.bind(this));
|
||||
|
||||
this.isLoading = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Document (XMLFragment or Map) is empty
|
||||
*/
|
||||
isEmpty(fieldName: string): boolean {
|
||||
return !this.get(fieldName)._start && !this.get(fieldName)._map.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the given document(s) into this one
|
||||
*/
|
||||
merge(documents: Doc | Array<Doc>): Document {
|
||||
(Array.isArray(documents) ? documents : [documents]).forEach((document) => {
|
||||
applyUpdate(this, encodeStateAsUpdate(document));
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered when the document is updated
|
||||
*/
|
||||
onUpdate(
|
||||
callback: (document: Document, origin: unknown, update: Uint8Array) => void,
|
||||
): Document {
|
||||
this.callbacks.onUpdate = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before a stateless message is broadcasted
|
||||
*/
|
||||
beforeBroadcastStateless(
|
||||
callback: (document: Document, stateless: string) => void,
|
||||
): Document {
|
||||
this.callbacks.beforeBroadcastStateless = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a callback that will be triggered before an inbound awareness update
|
||||
* is applied to this document's awareness state. The callback receives the
|
||||
* document, the decoded per-client states as a mutable `Map`, and the
|
||||
* `TransactionOrigin` that will be forwarded to `applyAwarenessUpdate`.
|
||||
* Use `isTransactionOrigin(origin)` to discriminate sources. Mutate the
|
||||
* map in place (set/delete/field changes) to rewrite the update, or throw
|
||||
* to reject it entirely.
|
||||
*/
|
||||
beforeHandleAwareness(
|
||||
callback: (
|
||||
document: Document,
|
||||
states: Map<number, Record<string, any>>,
|
||||
transactionOrigin: unknown,
|
||||
) => Promise<any>,
|
||||
): Document {
|
||||
this.callbacks.beforeHandleAwareness = callback;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a connection and a set of clients on this document keyed by the
|
||||
* underlying websocket connection
|
||||
*/
|
||||
addConnection(connection: Connection): Document {
|
||||
this.connections.set(connection, {
|
||||
clients: new Set(),
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given connection registered on this document
|
||||
*/
|
||||
hasConnection(connection: Connection): boolean {
|
||||
return this.connections.has(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given connection from this document
|
||||
*/
|
||||
removeConnection(connection: Connection): Document {
|
||||
const entry = this.connections.get(connection);
|
||||
if (entry) {
|
||||
removeAwarenessStates(this.awareness, Array.from(entry.clients), null);
|
||||
}
|
||||
|
||||
this.connections.delete(connection);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
addDirectConnection(): Document {
|
||||
this.directConnectionsCount += 1;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
removeDirectConnection(): Document {
|
||||
if (this.directConnectionsCount > 0) {
|
||||
this.directConnectionsCount -= 1;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active connections for this document
|
||||
*/
|
||||
getConnectionsCount(): number {
|
||||
return this.connections.size + this.directConnectionsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of registered connections
|
||||
*/
|
||||
getConnections(): Array<Connection> {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client ids for the given connection instance
|
||||
*/
|
||||
getClients(connection: Connection): Set<any> {
|
||||
const entry = this.connections.get(connection);
|
||||
|
||||
return entry?.clients === undefined ? new Set() : entry.clients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the document awareness states
|
||||
*/
|
||||
hasAwarenessStates(): boolean {
|
||||
return this.awareness.getStates().size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given awareness update
|
||||
*/
|
||||
applyAwarenessUpdate(connection: Connection, update: Uint8Array): Document {
|
||||
applyAwarenessUpdate(this.awareness, update, {
|
||||
source: "connection",
|
||||
connection,
|
||||
} satisfies ConnectionTransactionOrigin);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an awareness update and sync changes to clients
|
||||
* @private
|
||||
*/
|
||||
private handleAwarenessUpdate(
|
||||
{ added, updated, removed }: AwarenessUpdate,
|
||||
origin: TransactionOrigin | null,
|
||||
): Document {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
|
||||
const originConnection: Connection | null =
|
||||
isTransactionOrigin(origin) && origin.source === "connection"
|
||||
? origin.connection
|
||||
: null;
|
||||
|
||||
if (originConnection !== null) {
|
||||
const entry = this.connections.get(originConnection);
|
||||
|
||||
if (entry) {
|
||||
added.forEach((clientId: any) => entry.clients.add(clientId));
|
||||
removed.forEach((clientId: any) => entry.clients.delete(clientId));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.flushDelay === false) {
|
||||
this.broadcastAwarenessUpdate(changedClients);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
for (const clientId of changedClients) {
|
||||
this.pendingAwarenessClients.add(clientId);
|
||||
}
|
||||
|
||||
this.scheduleFlush();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one message to every connection, encoding it once per run of
|
||||
* connections that share a message address.
|
||||
*
|
||||
* Connections that share a message address receive byte-identical payloads,
|
||||
* so consecutive connections with the same address reuse one buffer instead
|
||||
* of re-encoding per connection. With session awareness disabled (the
|
||||
* default) every connection on a document shares the plain document name,
|
||||
* which collapses the whole broadcast to a single encode.
|
||||
*
|
||||
* A one-entry cache rather than a Map keyed by address: it makes the common
|
||||
* cases optimal (all addresses equal, or all distinct) at no cost, and only
|
||||
* gives up dedup when addresses interleave, where it still matches the
|
||||
* previous encode-per-connection behaviour.
|
||||
*/
|
||||
private broadcast(
|
||||
encode: (messageAddress: string) => Uint8Array,
|
||||
filter?: (connection: Connection) => boolean,
|
||||
): void {
|
||||
let cachedAddress: string | undefined;
|
||||
let cachedMessage: Uint8Array | undefined;
|
||||
|
||||
for (const connection of this.getConnections()) {
|
||||
if (filter !== undefined && !filter(connection)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address = connection.messageAddress;
|
||||
|
||||
if (cachedMessage === undefined || address !== cachedAddress) {
|
||||
cachedAddress = address;
|
||||
cachedMessage = encode(address);
|
||||
}
|
||||
|
||||
connection.send(cachedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastAwarenessUpdate(changedClients: Array<number>): void {
|
||||
this.broadcast((address) =>
|
||||
new OutgoingMessage(address)
|
||||
.createAwarenessUpdateMessage(this.awareness, changedClients)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an updated document and sync changes to clients
|
||||
*/
|
||||
private handleUpdate(update: Uint8Array, origin: unknown): Document {
|
||||
// Stays synchronous even when broadcasts are batched: this drives
|
||||
// `onChange`, the `onStoreDocument` debounce and the Redis publish in
|
||||
// extension-redis. Deferring it would change persistence and cluster
|
||||
// sync timing, not just the wire traffic.
|
||||
this.callbacks.onUpdate(this, origin, update);
|
||||
|
||||
if (this.flushDelay === false) {
|
||||
this.broadcastUpdate(update);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
this.pendingUpdates.push(update);
|
||||
this.pendingUpdateBytes += update.length;
|
||||
|
||||
if (this.pendingUpdateBytes >= this.flushMaxBytes) {
|
||||
this.flush();
|
||||
} else {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private broadcastUpdate(update: Uint8Array): void {
|
||||
this.broadcast((address) =>
|
||||
new OutgoingMessage(address)
|
||||
.createSyncMessage()
|
||||
.writeUpdate(update)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-window batching: the first buffered change starts the window and
|
||||
* everything until it closes goes out together, so the added latency stays
|
||||
* capped at `flushDelay` instead of growing while clients keep typing.
|
||||
*
|
||||
* `flushDelay: 0` means "coalesce whatever arrives in this event loop turn".
|
||||
* setImmediate runs right after the current poll phase, so a burst of socket
|
||||
* reads is merged without adding any delay. `setTimeout(…, 0)` would not do
|
||||
* that: it clamps to 1ms and runs in the timers phase.
|
||||
*/
|
||||
private scheduleFlush(): void {
|
||||
if (this.cancelFlush !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
this.cancelFlush = null;
|
||||
this.flush();
|
||||
};
|
||||
|
||||
if (this.flushDelay === 0 && typeof setImmediate === "function") {
|
||||
const handle = setImmediate(run);
|
||||
|
||||
this.cancelFlush = () => clearImmediate(handle);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = setTimeout(run, this.flushDelay as number);
|
||||
|
||||
this.cancelFlush = () => clearTimeout(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send everything buffered for the current flush window right away. Document
|
||||
* updates are merged into a single message; awareness collapses to the latest
|
||||
* state of each changed client, so an add and a removal within one window
|
||||
* resolve to the correct end state rather than both being sent.
|
||||
*
|
||||
* Safe to call when nothing is pending.
|
||||
*/
|
||||
public flush(): Document {
|
||||
if (this.cancelFlush !== null) {
|
||||
this.cancelFlush();
|
||||
this.cancelFlush = null;
|
||||
}
|
||||
|
||||
if (this.pendingUpdates.length > 0) {
|
||||
const update =
|
||||
this.pendingUpdates.length === 1
|
||||
? this.pendingUpdates[0]
|
||||
: mergeUpdates(this.pendingUpdates);
|
||||
|
||||
this.pendingUpdates = [];
|
||||
this.pendingUpdateBytes = 0;
|
||||
|
||||
this.broadcastUpdate(update);
|
||||
}
|
||||
|
||||
if (this.pendingAwarenessClients.size > 0) {
|
||||
const clients = Array.from(this.pendingAwarenessClients);
|
||||
|
||||
this.pendingAwarenessClients.clear();
|
||||
|
||||
this.broadcastAwarenessUpdate(clients);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast stateless message to all connections
|
||||
*
|
||||
* Shares one encoded buffer per message address like the update and awareness
|
||||
* broadcasts. Not batched, though: stateless payloads are opaque discrete
|
||||
* events, so unlike updates they cannot be merged and unlike awareness they
|
||||
* do not collapse to a latest state — deferring them would delay delivery
|
||||
* without reducing the number of messages.
|
||||
*/
|
||||
public broadcastStateless(
|
||||
payload: string,
|
||||
filter?: (conn: Connection) => boolean,
|
||||
): void {
|
||||
this.callbacks.beforeBroadcastStateless(this, payload);
|
||||
|
||||
this.relayStateless(payload, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a stateless payload to this instance's connections *without* running
|
||||
* the `beforeBroadcastStateless` hook.
|
||||
*
|
||||
* For relaying a payload that another server instance already broadcast: the
|
||||
* hook ran there, and running it again here would fire it once per instance.
|
||||
* Prefer `broadcastStateless` unless you are that relay.
|
||||
*/
|
||||
public relayStateless(
|
||||
payload: string,
|
||||
filter?: (conn: Connection) => boolean,
|
||||
): void {
|
||||
this.broadcast(
|
||||
(address) =>
|
||||
new OutgoingMessage(address).writeStateless(payload).toUint8Array(),
|
||||
filter,
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Send what is still buffered instead of dropping it, then stop batching:
|
||||
// `super.destroy()` tears the awareness down, which broadcasts one last
|
||||
// time, and that has to reach clients here just as it does on a document
|
||||
// that never batched. Usually a no-op, because a document is only
|
||||
// unloaded once its last connection is gone.
|
||||
this.flush();
|
||||
this.flushDelay = false;
|
||||
|
||||
super.destroy();
|
||||
this.isDestroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default Document;
|
||||
665
packages/server/src/Hocuspocus.ts
Normal file
665
packages/server/src/Hocuspocus.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import crypto from "node:crypto";
|
||||
import { awarenessStatesToArray, ResetConnection, SkipFurtherHooksError } from "@hocuspocus/common";
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from "yjs";
|
||||
import meta from "../package.json" with { type: "json" };
|
||||
|
||||
import { ClientConnection } from "./ClientConnection.ts";
|
||||
import { DirectConnection } from "./DirectConnection.ts";
|
||||
import Document from "./Document.ts";
|
||||
import type { Server } from "./Server.ts";
|
||||
import type {
|
||||
AwarenessUpdate,
|
||||
beforeBroadcastStatelessPayload,
|
||||
Configuration,
|
||||
ConnectionConfiguration,
|
||||
HookName,
|
||||
HookPayloadByName,
|
||||
onChangePayload,
|
||||
onDisconnectPayload,
|
||||
onStoreDocumentPayload,
|
||||
WebSocketLike,
|
||||
} from "./types.ts";
|
||||
import { isTransactionOrigin, shouldSkipStoreHooks } from "./types.ts";
|
||||
import { useDebounce } from "./util/debounce.ts";
|
||||
import { getParameters } from "./util/getParameters.ts";
|
||||
|
||||
export const defaultConfiguration = {
|
||||
name: null,
|
||||
timeout: 60_000,
|
||||
debounce: 2_000,
|
||||
maxDebounce: 10_000,
|
||||
quiet: false,
|
||||
yDocOptions: {
|
||||
gc: true,
|
||||
gcFilter: () => true,
|
||||
},
|
||||
unloadImmediately: true,
|
||||
maxUnauthenticatedQueueSize: 5 * 1024 * 1024,
|
||||
maxUnauthenticatedQueueMessages: 1_000,
|
||||
maxPendingDocuments: 100,
|
||||
flushDelay: 0 as false | number,
|
||||
flushMaxBytes: 1024 * 1024,
|
||||
};
|
||||
|
||||
export class Hocuspocus<Context = any> {
|
||||
configuration: Configuration<Context> = {
|
||||
...defaultConfiguration,
|
||||
extensions: [],
|
||||
onConfigure: () => new Promise((r) => r(null)),
|
||||
onListen: () => new Promise((r) => r(null)),
|
||||
onUpgrade: () => new Promise((r) => r(null)),
|
||||
onConnect: () => new Promise((r) => r(null)),
|
||||
connected: () => new Promise((r) => r(null)),
|
||||
beforeHandleMessage: () => new Promise((r) => r(null)),
|
||||
afterHandleMessage: () => new Promise((r) => r(null)),
|
||||
beforeHandleAwareness: () => new Promise<void>((r) => r()),
|
||||
beforeSync: () => new Promise((r) => r(null)),
|
||||
beforeBroadcastStateless: () => new Promise((r) => r(null)),
|
||||
onStateless: () => new Promise((r) => r(null)),
|
||||
onChange: () => new Promise((r) => r(null)),
|
||||
onCreateDocument: () => new Promise((r) => r(null)),
|
||||
onLoadDocument: () => new Promise((r) => r(null)),
|
||||
onStoreDocument: () => new Promise((r) => r(null)),
|
||||
afterStoreDocument: () => new Promise((r) => r(null)),
|
||||
onAwarenessUpdate: () => new Promise((r) => r(null)),
|
||||
onRequest: () => new Promise((r) => r(null)),
|
||||
onDisconnect: () => new Promise((r) => r(null)),
|
||||
onDestroy: () => new Promise((r) => r(null)),
|
||||
};
|
||||
|
||||
loadingDocuments: Map<string, Promise<Document>> = new Map();
|
||||
unloadingDocuments: Map<string, Promise<void>> = new Map();
|
||||
|
||||
documents: Map<string, Document> = new Map();
|
||||
|
||||
server?: Server;
|
||||
|
||||
debouncer = useDebounce();
|
||||
|
||||
constructor(configuration?: Partial<Configuration<Context>>) {
|
||||
if (configuration) {
|
||||
this.configure(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Hocuspocus
|
||||
*/
|
||||
configure(
|
||||
configuration: Partial<Configuration<Context>>,
|
||||
): Hocuspocus<Context> {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
|
||||
this.configuration.extensions.sort((a, b) => {
|
||||
const one = typeof a.priority === "undefined" ? 100 : a.priority;
|
||||
const two = typeof b.priority === "undefined" ? 100 : b.priority;
|
||||
|
||||
if (one > two) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (one < two) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
this.configuration.extensions.push({
|
||||
onConfigure: this.configuration.onConfigure,
|
||||
onListen: this.configuration.onListen,
|
||||
onUpgrade: this.configuration.onUpgrade,
|
||||
onConnect: this.configuration.onConnect,
|
||||
connected: this.configuration.connected,
|
||||
onAuthenticate: this.configuration.onAuthenticate,
|
||||
onTokenSync: this.configuration.onTokenSync,
|
||||
onLoadDocument: this.configuration.onLoadDocument,
|
||||
afterLoadDocument: this.configuration.afterLoadDocument,
|
||||
beforeHandleMessage: this.configuration.beforeHandleMessage,
|
||||
afterHandleMessage: this.configuration.afterHandleMessage,
|
||||
beforeHandleAwareness: this.configuration.beforeHandleAwareness,
|
||||
beforeBroadcastStateless: this.configuration.beforeBroadcastStateless,
|
||||
beforeSync: this.configuration.beforeSync,
|
||||
onStateless: this.configuration.onStateless,
|
||||
onChange: this.configuration.onChange,
|
||||
onStoreDocument: this.configuration.onStoreDocument,
|
||||
afterStoreDocument: this.configuration.afterStoreDocument,
|
||||
onAwarenessUpdate: this.configuration.onAwarenessUpdate,
|
||||
onRequest: this.configuration.onRequest,
|
||||
beforeUnloadDocument: this.configuration.beforeUnloadDocument,
|
||||
afterUnloadDocument: this.configuration.afterUnloadDocument,
|
||||
onDisconnect: this.configuration.onDisconnect,
|
||||
onDestroy: this.configuration.onDestroy,
|
||||
});
|
||||
|
||||
this.hooks("onConfigure", {
|
||||
configuration: this.configuration,
|
||||
version: meta.version,
|
||||
instance: this,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of active documents
|
||||
*/
|
||||
getDocumentsCount(): number {
|
||||
return this.documents.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of active connections
|
||||
*/
|
||||
getConnectionsCount(): number {
|
||||
const uniqueSocketIds = new Set<string>();
|
||||
const totalDirectConnections = Array.from(this.documents.values()).reduce(
|
||||
(acc, document) => {
|
||||
// Accumulate unique socket IDs
|
||||
document.getConnections().forEach(({ socketId }) => {
|
||||
uniqueSocketIds.add(socketId);
|
||||
});
|
||||
// Accumulate direct connections
|
||||
return acc + document.directConnectionsCount;
|
||||
},
|
||||
0,
|
||||
);
|
||||
// Return the sum of unique socket IDs and direct connections
|
||||
return uniqueSocketIds.size + totalDirectConnections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately execute all pending debounced onStoreDocument calls.
|
||||
* Useful during shutdown to ensure documents are persisted and unloaded
|
||||
* before the server exits, even when unloadImmediately is false.
|
||||
*/
|
||||
flushPendingStores() {
|
||||
this.documents.forEach((document: Document) => {
|
||||
const debounceId = `onStoreDocument-${document.name}`;
|
||||
if (!document.isLoading && this.debouncer.isDebounced(debounceId)) {
|
||||
this.debouncer.executeNow(debounceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force close one or more connections
|
||||
*/
|
||||
closeConnections(documentName?: string) {
|
||||
// Iterate through all connections for all documents
|
||||
// and invoke their close method, which is a graceful
|
||||
// disconnect wrapper around the underlying websocket.close
|
||||
this.documents.forEach((document: Document) => {
|
||||
// If a documentName was specified, bail if it doesn't match
|
||||
if (documentName && document.name !== documentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.connections.forEach((_clients, connection) => {
|
||||
connection.close(ResetConnection);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `handleConnection` method receives incoming WebSocket connections,
|
||||
* runs all hooks:
|
||||
*
|
||||
* - onConnect for all connections
|
||||
* - onAuthenticate only if required
|
||||
*
|
||||
* … and if nothing fails it'll fully establish the connection and
|
||||
* load the Document then.
|
||||
*/
|
||||
handleConnection(
|
||||
incoming: WebSocket | WebSocketLike,
|
||||
request: Request,
|
||||
defaultContext: Context = {} as Context,
|
||||
): ClientConnection {
|
||||
const clientConnection = new ClientConnection(
|
||||
incoming,
|
||||
request,
|
||||
this,
|
||||
this.hooks.bind(this),
|
||||
{
|
||||
timeout: this.configuration.timeout,
|
||||
maxUnauthenticatedQueueSize:
|
||||
this.configuration.maxUnauthenticatedQueueSize,
|
||||
maxUnauthenticatedQueueMessages:
|
||||
this.configuration.maxUnauthenticatedQueueMessages,
|
||||
maxPendingDocuments: this.configuration.maxPendingDocuments,
|
||||
},
|
||||
defaultContext,
|
||||
);
|
||||
clientConnection.onClose(
|
||||
(document: Document, hookPayload: onDisconnectPayload) => {
|
||||
// Check if there are still no connections to the document, as these hooks
|
||||
// may take some time to resolve (e.g. database queries). If a
|
||||
// new connection were to come in during that time it would rely on the
|
||||
// document in the map that we remove now.
|
||||
if (document.getConnectionsCount() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If it’s the last connection, we need to make sure to store the
|
||||
// document. Use the debouncer executeNow helper, to run scheduled
|
||||
// onStoreDocument immediately and clear running timers.
|
||||
// If there is no scheduled run for this document there is no point in
|
||||
// triggering onStoreDocument hook, as everything seems to be stored already.
|
||||
// Only run this if the document has finished loading earlier (i.e. not to persist the empty
|
||||
// ydoc if the onLoadDocument hook returned an error)
|
||||
if (
|
||||
!document.isLoading &&
|
||||
this.debouncer.isDebounced(`onStoreDocument-${document.name}`)
|
||||
) {
|
||||
if (this.configuration.unloadImmediately) {
|
||||
this.debouncer.executeNow(`onStoreDocument-${document.name}`);
|
||||
}
|
||||
} else {
|
||||
// Remove document from memory immediately
|
||||
this.unloadDocument(document);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return clientConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update of the given document
|
||||
*
|
||||
* "connection" is not necessarily type "Connection", it's the Yjs "origin" (which is "Connection" if
|
||||
* the update is incoming from the provider, but can be anything if the updates is originated from an extension.
|
||||
*/
|
||||
private handleDocumentUpdate(
|
||||
document: Document,
|
||||
origin: unknown,
|
||||
update: Uint8Array,
|
||||
) {
|
||||
const connection =
|
||||
isTransactionOrigin(origin) && origin.source === "connection"
|
||||
? origin.connection
|
||||
: undefined;
|
||||
const request = connection?.request;
|
||||
const context = isTransactionOrigin(origin)
|
||||
? origin.source === "connection"
|
||||
? origin.connection.context
|
||||
: origin.source === "local"
|
||||
? (origin.context ?? {})
|
||||
: {}
|
||||
: {};
|
||||
|
||||
const changePayload: onChangePayload = {
|
||||
instance: this,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
document,
|
||||
documentName: document.name,
|
||||
requestHeaders: request?.headers ?? new Headers(),
|
||||
requestParameters: getParameters(request),
|
||||
socketId: connection?.socketId ?? "",
|
||||
update,
|
||||
transactionOrigin: origin,
|
||||
connection: connection,
|
||||
context,
|
||||
};
|
||||
|
||||
this.hooks("onChange", changePayload);
|
||||
|
||||
if (shouldSkipStoreHooks(origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storePayload: onStoreDocumentPayload = {
|
||||
instance: this,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
document,
|
||||
lastContext: context,
|
||||
lastTransactionOrigin: origin,
|
||||
documentName: document.name,
|
||||
};
|
||||
|
||||
this.storeDocumentHooks(document, storePayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new document by the given request
|
||||
*/
|
||||
public async createDocument(
|
||||
documentName: string,
|
||||
request: Request,
|
||||
socketId: string,
|
||||
connection: ConnectionConfiguration,
|
||||
context?: Context,
|
||||
): Promise<Document> {
|
||||
if (!documentName.trim()) {
|
||||
throw new Error("Document name must not be empty");
|
||||
}
|
||||
|
||||
const existingLoadingDoc = this.loadingDocuments.get(documentName);
|
||||
|
||||
if (existingLoadingDoc) {
|
||||
return existingLoadingDoc;
|
||||
}
|
||||
|
||||
const existingDoc = this.documents.get(documentName);
|
||||
if (existingDoc) {
|
||||
return Promise.resolve(existingDoc);
|
||||
}
|
||||
|
||||
const loadDocPromise = this.loadDocument(
|
||||
documentName,
|
||||
request,
|
||||
socketId,
|
||||
connection,
|
||||
context,
|
||||
);
|
||||
|
||||
this.loadingDocuments.set(documentName, loadDocPromise);
|
||||
|
||||
try {
|
||||
const doc = await loadDocPromise;
|
||||
this.documents.set(documentName, doc);
|
||||
this.loadingDocuments.delete(documentName);
|
||||
return doc;
|
||||
} catch (e) {
|
||||
this.loadingDocuments.delete(documentName);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async loadDocument(
|
||||
documentName: string,
|
||||
request: Request,
|
||||
socketId: string,
|
||||
connectionConfig: ConnectionConfiguration,
|
||||
context?: Context,
|
||||
): Promise<Document> {
|
||||
const requestHeaders = request.headers;
|
||||
const requestParameters = getParameters(request);
|
||||
|
||||
const resolvedContext = (context ?? {}) as Context;
|
||||
|
||||
const yDocOptions = await this.hooks("onCreateDocument", {
|
||||
documentName,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
connectionConfig,
|
||||
context: resolvedContext,
|
||||
socketId,
|
||||
instance: this,
|
||||
});
|
||||
|
||||
const document = new Document(
|
||||
documentName,
|
||||
{
|
||||
...this.configuration.yDocOptions,
|
||||
...yDocOptions,
|
||||
},
|
||||
{
|
||||
flushDelay: this.configuration.flushDelay,
|
||||
flushMaxBytes: this.configuration.flushMaxBytes,
|
||||
},
|
||||
);
|
||||
|
||||
const hookPayload = {
|
||||
instance: this,
|
||||
context: resolvedContext,
|
||||
connectionConfig,
|
||||
document,
|
||||
documentName,
|
||||
socketId,
|
||||
requestHeaders,
|
||||
requestParameters,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.hooks(
|
||||
"onLoadDocument",
|
||||
hookPayload,
|
||||
(loadedDocument: Doc | Uint8Array | undefined) => {
|
||||
// the hook returned the document it was given, and encoding a
|
||||
// document's own state to apply it back can never add content
|
||||
if (loadedDocument === document) return;
|
||||
|
||||
if (loadedDocument instanceof Doc) {
|
||||
applyUpdate(document, encodeStateAsUpdate(loadedDocument));
|
||||
} else if (loadedDocument instanceof Uint8Array) {
|
||||
applyUpdate(document, loadedDocument);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
this.closeConnections(documentName);
|
||||
// the document is not in `this.documents` yet — `createDocument` only
|
||||
// registers it once this promise resolves — so `unloadDocument` would
|
||||
// bail out before destroying it
|
||||
document.destroy();
|
||||
throw e;
|
||||
}
|
||||
|
||||
document.isLoading = false;
|
||||
|
||||
document.onUpdate(
|
||||
(document: Document, origin: unknown, update: Uint8Array) => {
|
||||
document.lastChangeTime = Date.now();
|
||||
|
||||
this.handleDocumentUpdate(document, origin, update);
|
||||
},
|
||||
);
|
||||
|
||||
await this.hooks("afterLoadDocument", hookPayload);
|
||||
|
||||
document.beforeBroadcastStateless(
|
||||
(document: Document, stateless: string) => {
|
||||
const hookPayload: beforeBroadcastStatelessPayload = {
|
||||
document,
|
||||
documentName: document.name,
|
||||
payload: stateless,
|
||||
};
|
||||
|
||||
this.hooks("beforeBroadcastStateless", hookPayload);
|
||||
},
|
||||
);
|
||||
|
||||
document.beforeHandleAwareness((document, states, transactionOrigin) => {
|
||||
const connection =
|
||||
isTransactionOrigin(transactionOrigin) &&
|
||||
transactionOrigin.source === "connection"
|
||||
? transactionOrigin.connection
|
||||
: undefined;
|
||||
const request = connection?.request;
|
||||
return this.hooks("beforeHandleAwareness", {
|
||||
awareness: document.awareness,
|
||||
clientsCount: document.getConnectionsCount(),
|
||||
context: connection?.context,
|
||||
document,
|
||||
documentName: document.name,
|
||||
instance: this,
|
||||
requestHeaders: request?.headers ?? new Headers(),
|
||||
requestParameters: request
|
||||
? getParameters(request)
|
||||
: new URLSearchParams(),
|
||||
socketId: connection?.socketId ?? "",
|
||||
transactionOrigin,
|
||||
connection,
|
||||
states,
|
||||
});
|
||||
});
|
||||
|
||||
document.awareness.on(
|
||||
"update",
|
||||
(update: AwarenessUpdate, origin: unknown) => {
|
||||
this.hooks("onAwarenessUpdate", {
|
||||
document,
|
||||
documentName,
|
||||
instance: this,
|
||||
...update,
|
||||
transactionOrigin: origin,
|
||||
connection:
|
||||
isTransactionOrigin(origin) && origin.source === "connection"
|
||||
? origin.connection
|
||||
: undefined,
|
||||
awareness: document.awareness,
|
||||
states: awarenessStatesToArray(document.awareness.getStates()),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
storeDocumentHooks(
|
||||
document: Document,
|
||||
hookPayload: onStoreDocumentPayload,
|
||||
immediately?: boolean,
|
||||
) {
|
||||
const debounceId = `onStoreDocument-${document.name}`;
|
||||
return this.debouncer.debounce(
|
||||
debounceId,
|
||||
async () => {
|
||||
try {
|
||||
await document.saveMutex.runExclusive(async () => {
|
||||
await this.hooks("onStoreDocument", hookPayload);
|
||||
await this.hooks("afterStoreDocument", hookPayload);
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error instanceof SkipFurtherHooksError) {
|
||||
// Another extension handled this — proceed to unload
|
||||
setTimeout(() => {
|
||||
if (this.shouldUnloadDocument(document)) {
|
||||
this.unloadDocument(document);
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Caught error during storeDocumentHooks. Document stays in memory to avoid data loss",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.shouldUnloadDocument(document)) {
|
||||
this.unloadDocument(document);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
immediately ? 0 : this.configuration.debounce,
|
||||
this.configuration.maxDebounce,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given hook on all configured extensions.
|
||||
* Runs the given callback after each hook.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
hooks<T extends HookName>(
|
||||
name: T,
|
||||
payload: HookPayloadByName<Context>[T],
|
||||
// biome-ignore lint/complexity/noBannedTypes: <explanation>
|
||||
callback: Function | null = null,
|
||||
): Promise<any> {
|
||||
const { extensions } = this.configuration;
|
||||
|
||||
// create a new `thenable` chain
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/resolve
|
||||
let chain = Promise.resolve();
|
||||
|
||||
extensions
|
||||
// get me all extensions which have the given hook
|
||||
.filter((extension) => typeof extension[name] === "function")
|
||||
// run through all the configured hooks
|
||||
.forEach((extension) => {
|
||||
chain = chain
|
||||
.then(() => (extension[name] as any)?.(payload))
|
||||
.catch((error) => {
|
||||
// make sure to log error messages
|
||||
if (error?.message) {
|
||||
console.error(`[${name}]`, error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
chain = chain.then((...args: any[]) => callback(...args));
|
||||
}
|
||||
});
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
shouldUnloadDocument(document: Document): boolean {
|
||||
const hasPendingWork =
|
||||
this.debouncer.isDebounced(`onStoreDocument-${document.name}`) ||
|
||||
this.debouncer.isCurrentlyExecuting(`onStoreDocument-${document.name}`) ||
|
||||
document.saveMutex.isLocked();
|
||||
|
||||
return hasPendingWork === false && document.getConnectionsCount() === 0;
|
||||
}
|
||||
|
||||
async unloadDocument(document: Document): Promise<any> {
|
||||
const documentName = document.name;
|
||||
|
||||
if (!this.shouldUnloadDocument(document)) return;
|
||||
|
||||
if (!this.documents.has(documentName)) return;
|
||||
|
||||
if (this.unloadingDocuments.has(documentName))
|
||||
return this.unloadingDocuments.get(documentName);
|
||||
|
||||
// we need to make sure that the logic runs just once, even if multiple clients disconnect together
|
||||
const actualUnloadingLogic = async () => {
|
||||
try {
|
||||
await this.hooks("beforeUnloadDocument", {
|
||||
instance: this,
|
||||
documentName,
|
||||
document,
|
||||
});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// need sync check here as well, to avoid timing problems
|
||||
if (!this.shouldUnloadDocument(document)) return;
|
||||
|
||||
this.documents.delete(documentName);
|
||||
document.destroy();
|
||||
await this.hooks("afterUnloadDocument", { instance: this, documentName });
|
||||
};
|
||||
|
||||
const unloading = actualUnloadingLogic();
|
||||
|
||||
this.unloadingDocuments.set(documentName, Promise.resolve(unloading));
|
||||
|
||||
await unloading;
|
||||
|
||||
this.unloadingDocuments.delete(documentName);
|
||||
}
|
||||
|
||||
async openDirectConnection(
|
||||
documentName: string,
|
||||
context?: Context,
|
||||
): Promise<DirectConnection<Context>> {
|
||||
const connectionConfig: ConnectionConfiguration = {
|
||||
isAuthenticated: true,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const document: Document = await this.createDocument(
|
||||
documentName,
|
||||
new Request("http://localhost"), // direct connection has no request params
|
||||
crypto.randomUUID(),
|
||||
connectionConfig,
|
||||
context,
|
||||
);
|
||||
|
||||
return new DirectConnection<Context>(document, this, context);
|
||||
}
|
||||
}
|
||||
80
packages/server/src/IncomingMessage.ts
Normal file
80
packages/server/src/IncomingMessage.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Decoder } from "lib0/decoding";
|
||||
import {
|
||||
createDecoder,
|
||||
readVarString,
|
||||
readVarUint,
|
||||
readVarUint8Array,
|
||||
} from "lib0/decoding";
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import {
|
||||
createEncoder,
|
||||
length,
|
||||
toUint8Array,
|
||||
writeVarString,
|
||||
writeVarUint,
|
||||
} from "lib0/encoding";
|
||||
import type { MessageType } from "./types.ts";
|
||||
|
||||
export class IncomingMessage {
|
||||
/**
|
||||
* Access to the received message.
|
||||
*/
|
||||
decoder: Decoder;
|
||||
|
||||
/**
|
||||
* Private encoder; can be undefined.
|
||||
*
|
||||
* Lazy creation of the encoder speeds up IncomingMessages that need only a decoder.
|
||||
*/
|
||||
private encoderInternal?: Encoder;
|
||||
|
||||
constructor(input: any) {
|
||||
if (!(input instanceof Uint8Array)) {
|
||||
input = new Uint8Array(input);
|
||||
}
|
||||
|
||||
this.decoder = createDecoder(input);
|
||||
}
|
||||
|
||||
get encoder() {
|
||||
if (!this.encoderInternal) {
|
||||
this.encoderInternal = createEncoder();
|
||||
}
|
||||
return this.encoderInternal;
|
||||
}
|
||||
|
||||
readVarUint8Array() {
|
||||
return readVarUint8Array(this.decoder);
|
||||
}
|
||||
|
||||
peekVarUint8Array() {
|
||||
const { pos } = this.decoder;
|
||||
const result = readVarUint8Array(this.decoder);
|
||||
this.decoder.pos = pos;
|
||||
return result;
|
||||
}
|
||||
|
||||
readVarUint() {
|
||||
return readVarUint(this.decoder);
|
||||
}
|
||||
|
||||
readVarString() {
|
||||
return readVarString(this.decoder);
|
||||
}
|
||||
|
||||
toUint8Array() {
|
||||
return toUint8Array(this.encoder);
|
||||
}
|
||||
|
||||
writeVarUint(type: MessageType) {
|
||||
writeVarUint(this.encoder, type);
|
||||
}
|
||||
|
||||
writeVarString(string: string) {
|
||||
writeVarString(this.encoder, string);
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return length(this.encoder);
|
||||
}
|
||||
}
|
||||
304
packages/server/src/MessageReceiver.ts
Normal file
304
packages/server/src/MessageReceiver.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { AuthMessageType } from "@hocuspocus/common";
|
||||
import * as decoding from "lib0/decoding";
|
||||
import { readVarString } from "lib0/decoding";
|
||||
import {
|
||||
Awareness,
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate,
|
||||
} from "y-protocols/awareness";
|
||||
import {
|
||||
messageYjsSyncStep1,
|
||||
messageYjsSyncStep2,
|
||||
messageYjsUpdate,
|
||||
readSyncStep1,
|
||||
readSyncStep2,
|
||||
readUpdate,
|
||||
} from "y-protocols/sync";
|
||||
import * as Y from "yjs";
|
||||
import type Connection from "./Connection.ts";
|
||||
import type Document from "./Document.ts";
|
||||
import type { IncomingMessage } from "./IncomingMessage.ts";
|
||||
import { OutgoingMessage } from "./OutgoingMessage.ts";
|
||||
import {
|
||||
MessageType,
|
||||
type ConnectionTransactionOrigin,
|
||||
type TransactionOrigin,
|
||||
} from "./types.ts";
|
||||
|
||||
export class MessageReceiver {
|
||||
message: IncomingMessage;
|
||||
|
||||
defaultTransactionOrigin?: TransactionOrigin;
|
||||
|
||||
constructor(
|
||||
message: IncomingMessage,
|
||||
defaultTransactionOrigin?: TransactionOrigin,
|
||||
) {
|
||||
this.message = message;
|
||||
this.defaultTransactionOrigin = defaultTransactionOrigin;
|
||||
}
|
||||
|
||||
public async apply(
|
||||
document: Document,
|
||||
connection?: Connection,
|
||||
reply?: (message: Uint8Array) => void,
|
||||
) {
|
||||
const { message } = this;
|
||||
const type = message.readVarUint();
|
||||
const emptyMessageLength = message.length;
|
||||
|
||||
switch (type) {
|
||||
case MessageType.Sync:
|
||||
case MessageType.SyncReply: {
|
||||
message.writeVarUint(MessageType.Sync);
|
||||
await this.readSyncMessage(
|
||||
message,
|
||||
document,
|
||||
connection,
|
||||
reply,
|
||||
type !== MessageType.SyncReply,
|
||||
);
|
||||
|
||||
if (message.length > emptyMessageLength + 1) {
|
||||
if (reply) {
|
||||
reply(message.toUint8Array());
|
||||
} else if (connection) {
|
||||
connection.send(message.toUint8Array());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.Awareness: {
|
||||
let update = message.readVarUint8Array();
|
||||
|
||||
const origin: TransactionOrigin = connection
|
||||
? ({
|
||||
source: "connection",
|
||||
connection,
|
||||
} satisfies ConnectionTransactionOrigin)
|
||||
: (this.defaultTransactionOrigin ?? { source: "local" });
|
||||
|
||||
// Decode the inbound update into a scratch Awareness so the hook
|
||||
// chain sees a high-level Map<clientId, state>. Mutations to that
|
||||
// map (including `set`, `delete`, and field changes on each state
|
||||
// object) are picked up by the re-encode below and forwarded as
|
||||
// the broadcast payload. Hooks may also throw to reject the
|
||||
// update entirely.
|
||||
const scratchDoc = new Y.Doc();
|
||||
const scratch = new Awareness(scratchDoc);
|
||||
try {
|
||||
applyAwarenessUpdate(scratch, update, null);
|
||||
|
||||
await document.callbacks.beforeHandleAwareness(
|
||||
document,
|
||||
scratch.getStates(),
|
||||
origin,
|
||||
);
|
||||
|
||||
update = encodeAwarenessUpdate(scratch, [
|
||||
...scratch.getStates().keys(),
|
||||
]);
|
||||
} finally {
|
||||
scratch.destroy();
|
||||
scratchDoc.destroy();
|
||||
}
|
||||
|
||||
applyAwarenessUpdate(document.awareness, update, origin);
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.QueryAwareness: {
|
||||
this.applyQueryAwarenessMessage(document, connection, reply);
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.Stateless: {
|
||||
connection?.callbacks.statelessCallback({
|
||||
connection,
|
||||
documentName: document.name,
|
||||
document,
|
||||
payload: readVarString(message.decoder),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case MessageType.BroadcastStateless: {
|
||||
// Server-internal opcode used by @hocuspocus/extension-redis to
|
||||
// fan a stateless payload across server instances. The Redis path
|
||||
// invokes MessageReceiver without a `connection`, so a defined
|
||||
// `connection` here means this frame came from a WebSocket client
|
||||
// — which is never legitimate. Clients must use MessageType.Stateless
|
||||
// (opcode 5); the onStateless hook is the authorization point and
|
||||
// may call Document.broadcastStateless() to fan out if appropriate.
|
||||
if (connection) {
|
||||
throw new Error(
|
||||
"BroadcastStateless is a server-internal opcode and cannot be sent from a client",
|
||||
);
|
||||
}
|
||||
const msg = message.readVarString();
|
||||
// `relayStateless`, not `broadcastStateless`: the originating
|
||||
// instance already ran the beforeBroadcastStateless hook. It shares
|
||||
// one encoded buffer across connections instead of encoding per
|
||||
// connection, which matters here because this path fans out on
|
||||
// every instance of a cluster.
|
||||
document.relayStateless(msg);
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageType.CLOSE: {
|
||||
connection?.close({
|
||||
code: 1000,
|
||||
reason: "provider_initiated",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case MessageType.Auth: {
|
||||
const authType = message.readVarUint();
|
||||
if (authType === AuthMessageType.Token) {
|
||||
connection?.callbacks.onTokenSyncCallback({
|
||||
token: message.readVarString(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
console.error(
|
||||
"Received an authentication message on a connection that is already fully authenticated. Probably your provider has been destroyed + recreated really fast.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unable to handle message of type ${type}: no handler defined! Are your provider/server versions aligned?`,
|
||||
);
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
async readSyncMessage(
|
||||
message: IncomingMessage,
|
||||
document: Document,
|
||||
connection?: Connection,
|
||||
reply?: (message: Uint8Array) => void,
|
||||
requestFirstSync = true,
|
||||
) {
|
||||
const type = message.readVarUint();
|
||||
const messageAddress = connection?.messageAddress ?? document.name;
|
||||
|
||||
if (connection) {
|
||||
await connection.callbacks.beforeSync(connection, {
|
||||
type,
|
||||
payload: message.peekVarUint8Array(),
|
||||
});
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case messageYjsSyncStep1: {
|
||||
readSyncStep1(message.decoder, message.encoder, document);
|
||||
|
||||
// When the server receives SyncStep1, it should reply with SyncStep2 immediately followed by SyncStep1.
|
||||
if (reply && requestFirstSync) {
|
||||
const syncMessage = new OutgoingMessage(messageAddress)
|
||||
.createSyncReplyMessage()
|
||||
.writeFirstSyncStepFor(document);
|
||||
|
||||
reply(syncMessage.toUint8Array());
|
||||
} else if (connection) {
|
||||
const syncMessage = new OutgoingMessage(messageAddress)
|
||||
.createSyncMessage()
|
||||
.writeFirstSyncStepFor(document);
|
||||
|
||||
connection.send(syncMessage.toUint8Array());
|
||||
}
|
||||
break;
|
||||
}
|
||||
case messageYjsSyncStep2: {
|
||||
if (connection?.readOnly) {
|
||||
// We're in read-only mode, so we can't apply the update.
|
||||
// Let's use snapshotContainsUpdate to see if the update actually contains changes.
|
||||
// If not, we can still ack the update
|
||||
const snapshot = Y.snapshot(document);
|
||||
const update = decoding.readVarUint8Array(message.decoder);
|
||||
if (Y.snapshotContainsUpdate(snapshot, update)) {
|
||||
// no new changes in update
|
||||
const ackMessage = new OutgoingMessage(
|
||||
messageAddress,
|
||||
).writeSyncStatus(true);
|
||||
|
||||
connection.send(ackMessage.toUint8Array());
|
||||
} else {
|
||||
// new changes in update that we can't apply, because readOnly
|
||||
const ackMessage = new OutgoingMessage(
|
||||
messageAddress,
|
||||
).writeSyncStatus(false);
|
||||
|
||||
connection.send(ackMessage.toUint8Array());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
readSyncStep2(
|
||||
message.decoder,
|
||||
document,
|
||||
connection
|
||||
? { source: "connection" as const, connection }
|
||||
: (this.defaultTransactionOrigin ?? { source: "local" as const }),
|
||||
);
|
||||
|
||||
if (connection) {
|
||||
connection.send(
|
||||
new OutgoingMessage(messageAddress)
|
||||
.writeSyncStatus(true)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case messageYjsUpdate: {
|
||||
if (connection?.readOnly) {
|
||||
connection.send(
|
||||
new OutgoingMessage(messageAddress)
|
||||
.writeSyncStatus(false)
|
||||
.toUint8Array(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
readUpdate(
|
||||
message.decoder,
|
||||
document,
|
||||
connection
|
||||
? { source: "connection" as const, connection }
|
||||
: (this.defaultTransactionOrigin ?? { source: "local" as const }),
|
||||
);
|
||||
if (connection) {
|
||||
connection.send(
|
||||
new OutgoingMessage(messageAddress)
|
||||
.writeSyncStatus(true)
|
||||
.toUint8Array(),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Received a message with an unknown type: ${type}`);
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
applyQueryAwarenessMessage(
|
||||
document: Document,
|
||||
connection?: Connection,
|
||||
reply?: (message: Uint8Array) => void,
|
||||
) {
|
||||
const message = new OutgoingMessage(
|
||||
connection?.messageAddress ?? document.name,
|
||||
).createAwarenessUpdateMessage(document.awareness);
|
||||
|
||||
if (reply) {
|
||||
reply(message.toUint8Array());
|
||||
}
|
||||
}
|
||||
}
|
||||
159
packages/server/src/OutgoingMessage.ts
Normal file
159
packages/server/src/OutgoingMessage.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { Encoder } from "lib0/encoding";
|
||||
import {
|
||||
createEncoder,
|
||||
toUint8Array,
|
||||
writeVarString,
|
||||
writeVarUint,
|
||||
writeVarUint8Array,
|
||||
} from "lib0/encoding";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import { encodeAwarenessUpdate } from "y-protocols/awareness";
|
||||
import { writeSyncStep1, writeUpdate } from "y-protocols/sync";
|
||||
|
||||
import { writeAuthenticated, writePermissionDenied, writeTokenSyncRequest } from "@hocuspocus/common";
|
||||
import type Document from "./Document.ts";
|
||||
import { MessageType } from "./types.ts";
|
||||
|
||||
export class OutgoingMessage {
|
||||
encoder: Encoder;
|
||||
|
||||
type?: number;
|
||||
|
||||
category?: string;
|
||||
|
||||
constructor(documentName: string) {
|
||||
this.encoder = createEncoder();
|
||||
|
||||
writeVarString(this.encoder, documentName);
|
||||
}
|
||||
|
||||
createSyncMessage(): OutgoingMessage {
|
||||
this.type = MessageType.Sync;
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Sync);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
createSyncReplyMessage(): OutgoingMessage {
|
||||
this.type = MessageType.SyncReply;
|
||||
|
||||
writeVarUint(this.encoder, MessageType.SyncReply);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
createAwarenessUpdateMessage(
|
||||
awareness: Awareness,
|
||||
changedClients?: Array<any>,
|
||||
): OutgoingMessage {
|
||||
this.type = MessageType.Awareness;
|
||||
this.category = "Update";
|
||||
|
||||
const message = encodeAwarenessUpdate(
|
||||
awareness,
|
||||
changedClients || Array.from(awareness.getStates().keys()),
|
||||
);
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Awareness);
|
||||
writeVarUint8Array(this.encoder, message);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeQueryAwareness(): OutgoingMessage {
|
||||
this.type = MessageType.QueryAwareness;
|
||||
this.category = "Update";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.QueryAwareness);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeTokenSyncRequest(): OutgoingMessage {
|
||||
this.type = MessageType.Auth;
|
||||
this.category = "TokenSync";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Auth);
|
||||
writeTokenSyncRequest(this.encoder);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeAuthenticated(readonly: boolean): OutgoingMessage {
|
||||
this.type = MessageType.Auth;
|
||||
this.category = "Authenticated";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Auth);
|
||||
writeAuthenticated(this.encoder, readonly ? "readonly" : "read-write");
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writePermissionDenied(reason: string): OutgoingMessage {
|
||||
this.type = MessageType.Auth;
|
||||
this.category = "PermissionDenied";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Auth);
|
||||
writePermissionDenied(this.encoder, reason);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeFirstSyncStepFor(document: Document): OutgoingMessage {
|
||||
this.category = "SyncStep1";
|
||||
|
||||
writeSyncStep1(this.encoder, document);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeUpdate(update: Uint8Array): OutgoingMessage {
|
||||
this.category = "Update";
|
||||
|
||||
writeUpdate(this.encoder, update);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeStateless(payload: string): OutgoingMessage {
|
||||
this.category = "Stateless";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.Stateless);
|
||||
writeVarString(this.encoder, payload);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeBroadcastStateless(payload: string): OutgoingMessage {
|
||||
this.category = "Stateless";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.BroadcastStateless);
|
||||
writeVarString(this.encoder, payload);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
// TODO: should this be write* or create* as method name?
|
||||
writeSyncStatus(updateSaved: boolean): OutgoingMessage {
|
||||
this.category = "SyncStatus";
|
||||
|
||||
writeVarUint(this.encoder, MessageType.SyncStatus);
|
||||
writeVarUint(this.encoder, updateSaved ? 1 : 0);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
writeCloseMessage(reason: string): OutgoingMessage {
|
||||
this.type = MessageType.CLOSE;
|
||||
|
||||
writeVarUint(this.encoder, MessageType.CLOSE);
|
||||
writeVarString(this.encoder, reason);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toUint8Array(): Uint8Array {
|
||||
return toUint8Array(this.encoder);
|
||||
}
|
||||
}
|
||||
285
packages/server/src/Server.ts
Normal file
285
packages/server/src/Server.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
Server as HTTPServer,
|
||||
IncomingMessage,
|
||||
ServerResponse,
|
||||
} from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { ListenOptions } from "node:net";
|
||||
import crossws from "crossws/adapters/node";
|
||||
import kleur from "kleur";
|
||||
import meta from "../package.json" assert { type: "json" };
|
||||
import { Hocuspocus, defaultConfiguration } from "./Hocuspocus.ts";
|
||||
import type { Configuration, WebSocketLike, onListenPayload } from "./types.ts";
|
||||
|
||||
export interface ServerConfiguration<Context = any>
|
||||
extends Configuration<Context> {
|
||||
port?: number;
|
||||
address?: string;
|
||||
stopOnSignals?: boolean;
|
||||
/**
|
||||
* Options passed to the underlying WebSocket server (ws).
|
||||
* Supports all ws ServerOptions, e.g. { maxPayload: 1024 * 1024 }
|
||||
*/
|
||||
websocketOptions?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const defaultServerConfiguration = {
|
||||
port: 80,
|
||||
stopOnSignals: true,
|
||||
};
|
||||
|
||||
export class Server<Context = any> {
|
||||
httpServer: HTTPServer;
|
||||
|
||||
private crossws: ReturnType<typeof crossws>;
|
||||
|
||||
hocuspocus: Hocuspocus<Context>;
|
||||
|
||||
configuration: ServerConfiguration<Context> = {
|
||||
...defaultConfiguration,
|
||||
...defaultServerConfiguration,
|
||||
extensions: [],
|
||||
};
|
||||
|
||||
constructor(configuration?: Partial<ServerConfiguration<Context>>) {
|
||||
if (configuration) {
|
||||
this.configuration = {
|
||||
...this.configuration,
|
||||
...configuration,
|
||||
};
|
||||
}
|
||||
|
||||
this.hocuspocus = new Hocuspocus(this.configuration);
|
||||
this.hocuspocus.server = this;
|
||||
|
||||
this.httpServer = createServer(this.requestHandler);
|
||||
this.crossws = crossws({
|
||||
serverOptions: this.configuration.websocketOptions,
|
||||
hooks: {
|
||||
open: (peer) => {
|
||||
const clientConnection = this.hocuspocus.handleConnection(
|
||||
peer.websocket as unknown as WebSocketLike,
|
||||
peer.request as Request,
|
||||
);
|
||||
(peer as any)._hocuspocus = clientConnection;
|
||||
},
|
||||
message: (peer, message) => {
|
||||
(peer as any)._hocuspocus?.handleMessage(message.uint8Array());
|
||||
},
|
||||
close: (peer, event) => {
|
||||
(peer as any)._hocuspocus?.handleClose({
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
});
|
||||
},
|
||||
error: (peer, error) => {
|
||||
console.error("WebSocket error for peer:", peer.id);
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.setupHttpUpgrade();
|
||||
}
|
||||
|
||||
private setupHttpUpgrade = () => {
|
||||
this.httpServer.on("upgrade", async (request, socket, head) => {
|
||||
try {
|
||||
await this.hocuspocus.hooks("onUpgrade", {
|
||||
request,
|
||||
socket,
|
||||
head,
|
||||
instance: this.hocuspocus,
|
||||
});
|
||||
|
||||
// Let crossws handle the WebSocket upgrade
|
||||
this.crossws.handleUpgrade(request, socket, head);
|
||||
} catch (error) {
|
||||
// if a hook rejects and the error is empty, do nothing
|
||||
// this is only meant to prevent later hooks and the
|
||||
// default handler to do something. if a error is present
|
||||
// just rethrow it
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
requestHandler = async (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => {
|
||||
try {
|
||||
await this.hocuspocus.hooks("onRequest", {
|
||||
request,
|
||||
response,
|
||||
instance: this.hocuspocus,
|
||||
});
|
||||
|
||||
// default response if all prior hooks don't interfere
|
||||
response.writeHead(200, { "Content-Type": "text/plain" });
|
||||
response.end("Welcome to Hocuspocus!");
|
||||
} catch (error) {
|
||||
// if a hook rejects and the error is empty, do nothing
|
||||
// this is only meant to prevent later hooks and the
|
||||
// default handler to do something. if a error is present
|
||||
// just rethrow it
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async listen(
|
||||
port?: number,
|
||||
callback: any = null,
|
||||
): Promise<Hocuspocus<Context>> {
|
||||
if (port) {
|
||||
this.configuration.port = port;
|
||||
}
|
||||
|
||||
if (typeof callback === "function") {
|
||||
this.hocuspocus.configuration.extensions.push({
|
||||
onListen: callback,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.configuration.stopOnSignals) {
|
||||
const signalHandler = async () => {
|
||||
await this.destroy();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", signalHandler);
|
||||
process.on("SIGQUIT", signalHandler);
|
||||
process.on("SIGTERM", signalHandler);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
return new Promise((resolve: Function, reject: Function) => {
|
||||
const listenOptions: ListenOptions = {
|
||||
port: this.configuration.port,
|
||||
host: this.configuration.address,
|
||||
};
|
||||
|
||||
this.httpServer.listen(
|
||||
listenOptions,
|
||||
async () => {
|
||||
if (
|
||||
!this.configuration.quiet &&
|
||||
String(process.env.NODE_ENV) !== "testing"
|
||||
) {
|
||||
this.showStartScreen();
|
||||
}
|
||||
|
||||
const onListenPayload = {
|
||||
instance: this.hocuspocus,
|
||||
configuration: this.configuration,
|
||||
port: this.address.port,
|
||||
} as onListenPayload;
|
||||
|
||||
try {
|
||||
await this.hocuspocus.hooks("onListen", onListenPayload);
|
||||
resolve(this.hocuspocus);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
get address(): AddressInfo {
|
||||
return (this.httpServer.address() || {
|
||||
port: this.configuration.port,
|
||||
address: this.configuration.address ?? "0.0.0.0",
|
||||
family: "IPv4",
|
||||
}) as AddressInfo;
|
||||
}
|
||||
|
||||
private destroyPromise?: Promise<void>;
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
// Guard against multiple shutdowns (e.g. repeated SIGINT signals).
|
||||
// Subsequent callers await the same in-flight shutdown rather than
|
||||
// re-running close hooks on already-closed resources.
|
||||
this.destroyPromise ??= this.runDestroy();
|
||||
|
||||
return this.destroyPromise;
|
||||
}
|
||||
|
||||
private async runDestroy(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.httpServer.close();
|
||||
|
||||
try {
|
||||
this.configuration.extensions.push({
|
||||
async afterUnloadDocument({ instance }) {
|
||||
if (instance.getDocumentsCount() === 0) resolve();
|
||||
},
|
||||
});
|
||||
|
||||
// Close all existing connections - this will trigger the close hook
|
||||
if (this.hocuspocus.getDocumentsCount() === 0) resolve();
|
||||
|
||||
this.hocuspocus.closeConnections();
|
||||
|
||||
// Flush any remaining debounced stores so documents unload
|
||||
// promptly, even when unloadImmediately is false.
|
||||
this.hocuspocus.flushPendingStores();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
await this.hocuspocus.hooks("onDestroy", { instance: this.hocuspocus });
|
||||
}
|
||||
|
||||
get URL(): string {
|
||||
return `${this.configuration.address ?? "0.0.0.0"}:${this.address.port}`;
|
||||
}
|
||||
|
||||
get webSocketURL(): string {
|
||||
return `ws://${this.URL}`;
|
||||
}
|
||||
|
||||
get httpURL(): string {
|
||||
return `http://${this.URL}`;
|
||||
}
|
||||
|
||||
private showStartScreen() {
|
||||
const name = this.configuration.name ? ` (${this.configuration.name})` : "";
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
` ${kleur.cyan(`Hocuspocus v${meta.version}${name}`)}${kleur.green(" running at:")}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
console.log(` > HTTP: ${kleur.cyan(`${this.httpURL}`)}`);
|
||||
console.log(` > WebSocket: ${this.webSocketURL}`);
|
||||
|
||||
const extensions = this.configuration?.extensions
|
||||
.map((extension) => {
|
||||
return extension.extensionName ?? extension.constructor?.name;
|
||||
})
|
||||
.filter((name) => name)
|
||||
.filter((name) => name !== "Object");
|
||||
|
||||
if (!extensions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" Extensions:");
|
||||
|
||||
extensions.forEach((name) => {
|
||||
console.log(` - ${name}`);
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` ${kleur.green("Ready.")}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
9
packages/server/src/index.ts
Normal file
9
packages/server/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./Connection.ts";
|
||||
export * from "./Document.ts";
|
||||
export * from "./Hocuspocus.ts";
|
||||
export * from "./IncomingMessage.ts";
|
||||
export * from "./MessageReceiver.ts";
|
||||
export * from "./OutgoingMessage.ts";
|
||||
export * from "./Server.ts";
|
||||
export * from "./types.ts";
|
||||
export * from "./util/debounce.ts";
|
||||
619
packages/server/src/types.ts
Normal file
619
packages/server/src/types.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
import type Connection from "./Connection.ts";
|
||||
import type Document from "./Document.ts";
|
||||
import type { Hocuspocus } from "./Hocuspocus.ts";
|
||||
|
||||
export interface ConnectionTransactionOrigin {
|
||||
source: "connection";
|
||||
connection: Connection;
|
||||
}
|
||||
|
||||
export interface RedisTransactionOrigin {
|
||||
source: "redis";
|
||||
}
|
||||
|
||||
export interface LocalTransactionOrigin {
|
||||
source: "local";
|
||||
skipStoreHooks?: boolean;
|
||||
context?: any;
|
||||
}
|
||||
|
||||
export type TransactionOrigin =
|
||||
| ConnectionTransactionOrigin
|
||||
| RedisTransactionOrigin
|
||||
| LocalTransactionOrigin;
|
||||
|
||||
export function isTransactionOrigin(
|
||||
origin: unknown,
|
||||
): origin is TransactionOrigin {
|
||||
return (
|
||||
typeof origin === "object" &&
|
||||
origin !== null &&
|
||||
"source" in origin &&
|
||||
((origin as any).source === "connection" ||
|
||||
(origin as any).source === "redis" ||
|
||||
(origin as any).source === "local")
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldSkipStoreHooks(origin: unknown): boolean {
|
||||
if (!isTransactionOrigin(origin)) return false;
|
||||
switch (origin.source) {
|
||||
case "connection":
|
||||
return false;
|
||||
case "redis":
|
||||
return true;
|
||||
case "local":
|
||||
return origin.skipStoreHooks ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal interface for any WebSocket-like object for WebSocket, Bun's ServerWebSocket, ws, Deno, etc.
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
Unknown = -1,
|
||||
Sync = 0,
|
||||
Awareness = 1,
|
||||
Auth = 2,
|
||||
QueryAwareness = 3,
|
||||
SyncReply = 4, // same as Sync, but won't trigger another 'SyncStep1'
|
||||
Stateless = 5,
|
||||
BroadcastStateless = 6,
|
||||
CLOSE = 7,
|
||||
SyncStatus = 8,
|
||||
Ping = 9,
|
||||
Pong = 10,
|
||||
}
|
||||
|
||||
export interface AwarenessUpdate {
|
||||
added: Array<any>;
|
||||
updated: Array<any>;
|
||||
removed: Array<any>;
|
||||
}
|
||||
|
||||
export interface ConnectionConfiguration {
|
||||
readOnly: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export interface Extension<Context = any> {
|
||||
priority?: number;
|
||||
extensionName?: string;
|
||||
onConfigure?(data: onConfigurePayload): Promise<any>;
|
||||
onListen?(data: onListenPayload): Promise<any>;
|
||||
onUpgrade?(data: onUpgradePayload): Promise<any>;
|
||||
onConnect?(data: onConnectPayload<Context>): Promise<any>;
|
||||
connected?(data: connectedPayload<Context>): Promise<any>;
|
||||
onAuthenticate?(data: onAuthenticatePayload<Context>): Promise<any>;
|
||||
onTokenSync?(data: onTokenSyncPayload<Context>): Promise<any>;
|
||||
onCreateDocument?(data: onCreateDocumentPayload<Context>): Promise<any>;
|
||||
onLoadDocument?(data: onLoadDocumentPayload<Context>): Promise<any>;
|
||||
afterLoadDocument?(data: afterLoadDocumentPayload<Context>): Promise<any>;
|
||||
beforeHandleMessage?(data: beforeHandleMessagePayload<Context>): Promise<any>;
|
||||
/**
|
||||
* Fired after an inbound message has been handled (i.e. after the Yjs
|
||||
* update has been applied to the document). Fires whether the apply
|
||||
* resolved or threw, but not when `beforeHandleMessage` rejected the
|
||||
* message.
|
||||
*/
|
||||
afterHandleMessage?(data: afterHandleMessagePayload<Context>): Promise<any>;
|
||||
/**
|
||||
* Fired before an inbound awareness update is applied to the document's
|
||||
* awareness state. The hook receives the decoded per-client `states` as a
|
||||
* mutable `Map` keyed by Yjs clientId. Mutate the map and the contained
|
||||
* state objects in place to rewrite fields, drop peers (`states.delete`),
|
||||
* or add synthetic ones (`states.set`); mutations are reflected in the
|
||||
* broadcast. Throw to reject the update without applying anything.
|
||||
*
|
||||
* Multiple extensions chain naturally: each extension sees the map as
|
||||
* mutated by previous extensions and can mutate it further.
|
||||
*/
|
||||
beforeHandleAwareness?(
|
||||
data: beforeHandleAwarenessPayload<Context>,
|
||||
): Promise<any>;
|
||||
beforeSync?(data: beforeSyncPayload<Context>): Promise<any>;
|
||||
beforeBroadcastStateless?(
|
||||
data: beforeBroadcastStatelessPayload,
|
||||
): Promise<any>;
|
||||
onStateless?(payload: onStatelessPayload): Promise<any>;
|
||||
onChange?(data: onChangePayload<Context>): Promise<any>;
|
||||
onStoreDocument?(data: onStoreDocumentPayload<Context>): Promise<any>;
|
||||
afterStoreDocument?(data: afterStoreDocumentPayload<Context>): Promise<any>;
|
||||
onAwarenessUpdate?(data: onAwarenessUpdatePayload<Context>): Promise<any>;
|
||||
onRequest?(data: onRequestPayload): Promise<any>;
|
||||
onDisconnect?(data: onDisconnectPayload<Context>): Promise<any>;
|
||||
beforeUnloadDocument?(data: beforeUnloadDocumentPayload): Promise<any>;
|
||||
afterUnloadDocument?(data: afterUnloadDocumentPayload): Promise<any>;
|
||||
onDestroy?(data: onDestroyPayload): Promise<any>;
|
||||
}
|
||||
|
||||
export type HookName =
|
||||
| "onConfigure"
|
||||
| "onListen"
|
||||
| "onUpgrade"
|
||||
| "onConnect"
|
||||
| "connected"
|
||||
| "onAuthenticate"
|
||||
| "onTokenSync"
|
||||
| "onCreateDocument"
|
||||
| "onLoadDocument"
|
||||
| "afterLoadDocument"
|
||||
| "beforeHandleMessage"
|
||||
| "afterHandleMessage"
|
||||
| "beforeHandleAwareness"
|
||||
| "beforeBroadcastStateless"
|
||||
| "beforeSync"
|
||||
| "onStateless"
|
||||
| "onChange"
|
||||
| "onStoreDocument"
|
||||
| "afterStoreDocument"
|
||||
| "onAwarenessUpdate"
|
||||
| "onRequest"
|
||||
| "onDisconnect"
|
||||
| "beforeUnloadDocument"
|
||||
| "afterUnloadDocument"
|
||||
| "onDestroy";
|
||||
|
||||
export type HookPayloadByName<Context = any> = {
|
||||
onConfigure: onConfigurePayload;
|
||||
onListen: onListenPayload;
|
||||
onUpgrade: onUpgradePayload;
|
||||
onConnect: onConnectPayload<Context>;
|
||||
connected: connectedPayload<Context>;
|
||||
onAuthenticate: onAuthenticatePayload<Context>;
|
||||
onTokenSync: onTokenSyncPayload<Context>;
|
||||
onCreateDocument: onCreateDocumentPayload<Context>;
|
||||
onLoadDocument: onLoadDocumentPayload<Context>;
|
||||
afterLoadDocument: afterLoadDocumentPayload<Context>;
|
||||
beforeHandleMessage: beforeHandleMessagePayload<Context>;
|
||||
afterHandleMessage: afterHandleMessagePayload<Context>;
|
||||
beforeHandleAwareness: beforeHandleAwarenessPayload<Context>;
|
||||
beforeBroadcastStateless: beforeBroadcastStatelessPayload;
|
||||
beforeSync: beforeSyncPayload<Context>;
|
||||
onStateless: onStatelessPayload;
|
||||
onChange: onChangePayload<Context>;
|
||||
onStoreDocument: onStoreDocumentPayload<Context>;
|
||||
afterStoreDocument: afterStoreDocumentPayload<Context>;
|
||||
onAwarenessUpdate: onAwarenessUpdatePayload<Context>;
|
||||
onRequest: onRequestPayload;
|
||||
onDisconnect: onDisconnectPayload<Context>;
|
||||
afterUnloadDocument: afterUnloadDocumentPayload;
|
||||
beforeUnloadDocument: beforeUnloadDocumentPayload;
|
||||
onDestroy: onDestroyPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Controls how a document batches its outgoing broadcasts. Shared by the server
|
||||
* configuration and the `Document` constructor so both stay in step.
|
||||
*/
|
||||
export interface FlushOptions {
|
||||
/**
|
||||
* Batch outgoing broadcasts per document over a window of this many
|
||||
* milliseconds, instead of sending one message per connection per change.
|
||||
* Updates within a window are merged into a single message and awareness
|
||||
* collapses to the latest state of each changed client, so a document with
|
||||
* many connected clients sends `connections` messages per window rather than
|
||||
* `changes × connections`.
|
||||
*
|
||||
* The default `0` flushes at the end of the current event loop turn. It adds
|
||||
* no delay — a burst of updates that arrived together is coalesced before
|
||||
* anything is written to a socket — and with a single writer no merge happens
|
||||
* at all, because there is only one buffered update to send. On a document
|
||||
* with many writing clients the difference is large: 2000 clients each
|
||||
* writing once produces 2000 broadcast messages per turn instead of
|
||||
* 2000 × 2000.
|
||||
*
|
||||
* A positive value additionally merges across turns, trading up to that much
|
||||
* latency for fewer messages. That is only a win when several changes
|
||||
* genuinely land per window — with a handful of clients typing it is pure
|
||||
* added delay, so prefer `0` unless you have measured otherwise.
|
||||
*
|
||||
* `false` restores the pre-batching behaviour and sends every broadcast
|
||||
* synchronously, inside the same tick that applied the change.
|
||||
*
|
||||
* Applies to the WebSocket fan-out only. `onChange`, the `onStoreDocument`
|
||||
* debounce and the Redis publish still run synchronously per change.
|
||||
*
|
||||
* @default 0 (coalesce within the current event loop turn)
|
||||
*/
|
||||
flushDelay: false | number;
|
||||
|
||||
/**
|
||||
* Flush a document's batched broadcasts early once this many bytes of updates
|
||||
* are buffered, bounding both the memory held per window and the cost of the
|
||||
* merge. Ignored when `flushDelay` is false.
|
||||
*
|
||||
* @default 1_048_576 (1 MiB)
|
||||
*/
|
||||
flushMaxBytes: number;
|
||||
}
|
||||
|
||||
export interface Configuration<Context = any>
|
||||
extends Extension<Context>,
|
||||
FlushOptions {
|
||||
/**
|
||||
* A name for the instance, used for logging.
|
||||
*/
|
||||
name: string | null;
|
||||
/**
|
||||
* A list of hocuspocus extensions.
|
||||
*/
|
||||
extensions: Array<Extension>;
|
||||
/**
|
||||
* Defines in which interval the server sends a ping, and closes the connection when no pong is sent back.
|
||||
*/
|
||||
timeout: number;
|
||||
/**
|
||||
* Debounces the call of the `onStoreDocument` hook for the given amount of time in ms.
|
||||
* Otherwise every single update would be persisted.
|
||||
*/
|
||||
debounce: number;
|
||||
/**
|
||||
* Makes sure to call `onStoreDocument` at least in the given amount of time (ms).
|
||||
*/
|
||||
maxDebounce: number;
|
||||
/**
|
||||
* By default, the servers show a start screen. If passed false, the server will start quietly.
|
||||
*/
|
||||
quiet: boolean;
|
||||
/**
|
||||
* If set to false, respects the debounce time of `onStoreDocument` before unloading a document.
|
||||
* Otherwise, the document will be unloaded immediately.
|
||||
*
|
||||
* This prevents a client from DOSing the server by repeatedly connecting and disconnecting when
|
||||
* your onStoreDocument is rate-limited.
|
||||
*/
|
||||
unloadImmediately: boolean;
|
||||
|
||||
/**
|
||||
* Maximum number of bytes that may be buffered, across all documents, for a single
|
||||
* connection while it is still unauthenticated. Messages received before authentication
|
||||
* completes are queued in memory; without a limit an unauthenticated client could exhaust
|
||||
* server memory (see GHSA-xwhh-v746-pj9m). When the limit is exceeded the connection is closed.
|
||||
*
|
||||
* @default 5_242_880 (5 MiB)
|
||||
*/
|
||||
maxUnauthenticatedQueueSize: number;
|
||||
/**
|
||||
* Maximum number of messages that may be buffered, across all documents, for a single
|
||||
* connection while it is still unauthenticated. When the limit is exceeded the connection
|
||||
* is closed.
|
||||
*
|
||||
* @default 1_000
|
||||
*/
|
||||
maxUnauthenticatedQueueMessages: number;
|
||||
/**
|
||||
* Maximum number of distinct documents a single connection may open before any of them is
|
||||
* authenticated. Each pending document holds its own queue and hook payload, so an
|
||||
* unauthenticated client fanning out across many document names could otherwise exhaust
|
||||
* memory even while staying under the per-connection queue limits. When the limit is
|
||||
* exceeded the connection is closed.
|
||||
*
|
||||
* @default 100
|
||||
*/
|
||||
maxPendingDocuments: number;
|
||||
|
||||
/**
|
||||
* options to pass to the ydoc document
|
||||
*/
|
||||
yDocOptions: {
|
||||
gc: boolean; // enable or disable garbage collection (see https://github.com/yjs/yjs/blob/main/INTERNALS.md#deletions)
|
||||
gcFilter: () => boolean; // will be called before garbage collecting ; return false to keep it
|
||||
};
|
||||
}
|
||||
|
||||
export interface onStatelessPayload {
|
||||
connection: Connection;
|
||||
documentName: string;
|
||||
document: Document;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface onAuthenticatePayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
request: Request;
|
||||
socketId: string;
|
||||
token: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
|
||||
export interface onTokenSyncPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
token: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
connection: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface onCreateDocumentPayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface onConnectPayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
request: Request;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
|
||||
export interface connectedPayload<Context = any> {
|
||||
context: Context;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
request: Request;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
connection: Connection<Context>;
|
||||
providerVersion: string | null;
|
||||
}
|
||||
|
||||
export interface onLoadDocumentPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface afterLoadDocumentPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface onChangePayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
update: Uint8Array;
|
||||
socketId: string;
|
||||
transactionOrigin: unknown;
|
||||
connection?: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface beforeHandleMessagePayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
update: Uint8Array;
|
||||
socketId: string;
|
||||
connection: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface afterHandleMessagePayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
update: Uint8Array;
|
||||
socketId: string;
|
||||
connection: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface beforeHandleAwarenessPayload<Context = any> {
|
||||
awareness: Awareness;
|
||||
clientsCount: number;
|
||||
/**
|
||||
* Connection context populated by `onAuthenticate`. `undefined` when the
|
||||
* update did not originate from a client connection (e.g. server-internal
|
||||
* writes via `DirectConnection`).
|
||||
*/
|
||||
context: Context | undefined;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
/**
|
||||
* Per-client awareness states decoded from the inbound update, keyed by
|
||||
* Yjs clientId. Mutate this map in place to rewrite the update: change
|
||||
* fields on a state object, `states.delete(clientId)` to drop a peer, or
|
||||
* `states.set(clientId, ...)` to add or replace one. The encoded update
|
||||
* sent to peers reflects whatever the map looks like after every hook in
|
||||
* the chain has run.
|
||||
*/
|
||||
states: Map<number, Record<string, any>>;
|
||||
socketId: string;
|
||||
/**
|
||||
* The `TransactionOrigin` that will be passed to `applyAwarenessUpdate`.
|
||||
* Use `isTransactionOrigin(origin)` to discriminate sources. Matches the
|
||||
* `transactionOrigin` shape of `onAwarenessUpdatePayload`.
|
||||
*/
|
||||
transactionOrigin: unknown;
|
||||
/**
|
||||
* Convenience shortcut: `origin.connection` when `transactionOrigin` is a
|
||||
* `ConnectionTransactionOrigin`, otherwise `undefined`. Matches the
|
||||
* `connection?` shape of `onAwarenessUpdatePayload`.
|
||||
*/
|
||||
connection?: Connection<Context>;
|
||||
}
|
||||
|
||||
export interface beforeSyncPayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
connection: Connection<Context>;
|
||||
/**
|
||||
* The y-protocols/sync message type
|
||||
* @example
|
||||
* 0: SyncStep1
|
||||
* 1: SyncStep2
|
||||
* 2: YjsUpdate
|
||||
*
|
||||
* @see https://github.com/yjs/y-protocols/blob/master/sync.js#L13-L40
|
||||
*/
|
||||
type: number;
|
||||
/**
|
||||
* The payload of the y-sync message.
|
||||
*/
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface beforeBroadcastStatelessPayload {
|
||||
document: Document;
|
||||
documentName: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface onStoreDocumentPayload<Context = any> {
|
||||
clientsCount: number;
|
||||
document: Document;
|
||||
lastContext: Context;
|
||||
lastTransactionOrigin: unknown;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type
|
||||
export interface afterStoreDocumentPayload<Context = any>
|
||||
extends onStoreDocumentPayload<Context> {}
|
||||
|
||||
export interface onAwarenessUpdatePayload<Context = any> {
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
transactionOrigin: unknown;
|
||||
connection?: Connection<Context>;
|
||||
added: number[];
|
||||
updated: number[];
|
||||
removed: number[];
|
||||
awareness: Awareness;
|
||||
states: StatesArray;
|
||||
}
|
||||
|
||||
export type StatesArray = { clientId: number; [key: string | number]: any }[];
|
||||
|
||||
export interface fetchPayload<Context = any> {
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
connectionConfig: ConnectionConfiguration;
|
||||
}
|
||||
|
||||
export interface storePayload<Context = any>
|
||||
extends onStoreDocumentPayload<Context> {
|
||||
state: Buffer;
|
||||
}
|
||||
|
||||
export interface onDisconnectPayload<Context = any> {
|
||||
clientsCount: number;
|
||||
context: Context;
|
||||
document: Document;
|
||||
documentName: string;
|
||||
instance: Hocuspocus;
|
||||
requestHeaders: Headers;
|
||||
requestParameters: URLSearchParams;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
export interface onRequestPayload {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
export interface onUpgradePayload {
|
||||
request: IncomingMessage;
|
||||
socket: any;
|
||||
head: any;
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
export interface onListenPayload {
|
||||
instance: Hocuspocus;
|
||||
configuration: Configuration;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface onDestroyPayload {
|
||||
instance: Hocuspocus;
|
||||
}
|
||||
|
||||
export interface onConfigurePayload {
|
||||
instance: Hocuspocus;
|
||||
configuration: Configuration;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface afterUnloadDocumentPayload {
|
||||
instance: Hocuspocus;
|
||||
documentName: string;
|
||||
}
|
||||
|
||||
export interface beforeUnloadDocumentPayload {
|
||||
instance: Hocuspocus;
|
||||
documentName: string;
|
||||
document: Document;
|
||||
}
|
||||
|
||||
export interface DisconnectOptions {
|
||||
/**
|
||||
* When `true` (the default) the document is persisted immediately (no debouncing)
|
||||
* and unloaded from memory as the connection closes. When `false` the store is
|
||||
* scheduled via the debounce timer and the document is kept warm in memory, so a
|
||||
* follow-up direct connection reuses it and repeated writes coalesce. With
|
||||
* `false`, persistence is no longer guaranteed by the time `disconnect()`
|
||||
* resolves.
|
||||
*/
|
||||
unloadImmediately?: boolean;
|
||||
}
|
||||
|
||||
export interface DirectConnection {
|
||||
transact(transaction: (document: Document) => void): Promise<void>;
|
||||
disconnect(options?: DisconnectOptions): Promise<void>;
|
||||
}
|
||||
77
packages/server/src/util/debounce.ts
Normal file
77
packages/server/src/util/debounce.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const useDebounce = () => {
|
||||
const timers: Map<
|
||||
string,
|
||||
{
|
||||
timeout: NodeJS.Timeout;
|
||||
start: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
func: () => any | Promise<() => any>;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
const runningExecutions: Map<string, Promise<any>> = new Map();
|
||||
|
||||
const debounce = async (
|
||||
id: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
func: () => any | Promise<() => any>,
|
||||
debounce: number,
|
||||
maxDebounce: number,
|
||||
) => {
|
||||
const old = timers.get(id);
|
||||
const start = old?.start || Date.now();
|
||||
|
||||
const run = async () => {
|
||||
timers.delete(id);
|
||||
|
||||
if (runningExecutions.has(id)) {
|
||||
// wait for previous execution to finish
|
||||
await runningExecutions.get(id);
|
||||
}
|
||||
|
||||
const execution = func();
|
||||
|
||||
runningExecutions.set(id, execution);
|
||||
const executionResult = await execution;
|
||||
runningExecutions.delete(id);
|
||||
|
||||
return executionResult;
|
||||
};
|
||||
|
||||
if (old?.timeout) {
|
||||
clearTimeout(old.timeout);
|
||||
}
|
||||
|
||||
if (debounce === 0) {
|
||||
return run();
|
||||
}
|
||||
|
||||
if (Date.now() - start >= maxDebounce) {
|
||||
return run();
|
||||
}
|
||||
|
||||
timers.set(id, {
|
||||
start,
|
||||
timeout: setTimeout(run, debounce),
|
||||
func: run,
|
||||
});
|
||||
};
|
||||
|
||||
const executeNow = (id: string) => {
|
||||
const old = timers.get(id);
|
||||
if (old) {
|
||||
clearTimeout(old.timeout);
|
||||
return old.func();
|
||||
}
|
||||
};
|
||||
|
||||
const isDebounced = (id: string): boolean => {
|
||||
return timers.has(id);
|
||||
};
|
||||
|
||||
const isCurrentlyExecuting = (id: string): boolean => {
|
||||
return runningExecutions.has(id);
|
||||
};
|
||||
|
||||
return { debounce, isDebounced, isCurrentlyExecuting, executeNow };
|
||||
};
|
||||
14
packages/server/src/util/getParameters.ts
Normal file
14
packages/server/src/util/getParameters.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Get parameters by the given request
|
||||
*/
|
||||
export function getParameters(request?: { url?: string }): URLSearchParams {
|
||||
const url = request?.url;
|
||||
if (!url) {
|
||||
return new URLSearchParams();
|
||||
}
|
||||
// Handle both full URLs (web Request) and path-only URLs (Node.js IncomingMessage)
|
||||
const query = url.includes("://")
|
||||
? new URL(url).searchParams
|
||||
: new URLSearchParams(url.split("?")[1] || "");
|
||||
return query;
|
||||
}
|
||||
1269
packages/transformer/CHANGELOG.md
Normal file
1269
packages/transformer/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user