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:
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";
|
||||
Reference in New Issue
Block a user