snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export * from "./HocuspocusProvider.ts";
export * from "./HocuspocusProviderWebsocket.ts";
export * from "./types.ts";

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

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