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,76 @@
# @hocuspocus/extension-redis
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-redis.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-redis)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-redis.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-redis.svg)](https://www.npmjs.com/package/@hocuspocus/extension-redis)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Scale [Hocuspocus](https://github.com/ueberdosis/hocuspocus) horizontally across multiple server instances. Uses Redis pub/sub to broadcast document updates and awareness between servers, so clients connected to different instances stay in sync on the same document.
## Installation
```bash
npm install @hocuspocus/extension-redis
```
## Usage
Point every Hocuspocus instance at the same Redis:
```js
import { Server } from "@hocuspocus/server"
import { Redis } from "@hocuspocus/extension-redis"
const server = new Server({
port: 1234,
extensions: [
new Redis({
host: "127.0.0.1",
port: 6379,
}),
],
})
server.listen()
```
### Bring your own Redis client
If you already have an `ioredis` client (including Cluster), pass it in directly instead of host/port:
```js
import RedisClient from "ioredis"
new Redis({
redis: new RedisClient({ host: "127.0.0.1", port: 6379 }),
})
```
Redis handles real-time sync — you still need a persistence extension (e.g. [`@hocuspocus/extension-sqlite`](../extension-sqlite), [`@hocuspocus/extension-s3`](../extension-s3), or your own [`Database`](../extension-database)) to store documents long-term.
### Consistent reads on load
When a document is loaded on one instance while another instance already has it
open (and possibly modified in memory, not yet persisted), the load blocks until
the other instance has sent its current state — so a `DirectConnection` or a
freshly connected client observes the latest collaborative content rather than
just what was read from storage. If no other instance has the document open, the
load is not delayed.
The wait is bounded by `awaitInitialSyncTimeout` (ms, default `1000`). Set it to
`0` to disable the wait and restore the previous fire-and-forget behavior:
```js
new Redis({
host: "127.0.0.1",
port: 6379,
awaitInitialSyncTimeout: 1000,
})
```
## Documentation
Full scaling architecture and multi-instance deployment guide: [tiptap.dev/docs/hocuspocus/server/extensions/redis](https://tiptap.dev/docs/hocuspocus/server/extensions/redis).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,48 @@
{
"name": "@hocuspocus/extension-redis",
"version": "4.7.0",
"description": "Scale Hocuspocus horizontally with Redis",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"redis",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-redis.cjs",
"module": "dist/hocuspocus-redis.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-redis.esm.js",
"require": "./dist/hocuspocus-redis.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/common": "workspace:^",
"@hocuspocus/server": "workspace:^",
"@sesamecare-oss/redlock": "^1.4.0",
"ioredis": "~5.6.1",
"kleur": "^4.1.5"
},
"peerDependencies": {
"y-protocols": "^1.0.6",
"yjs": "^13.6.8"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,839 @@
import crypto from "node:crypto";
import { SkipFurtherHooksError } from "@hocuspocus/common";
import type {
afterLoadDocumentPayload,
afterStoreDocumentPayload,
afterUnloadDocumentPayload,
beforeBroadcastStatelessPayload,
beforeUnloadDocumentPayload,
Document,
Extension,
Hocuspocus,
onAwarenessUpdatePayload,
onChangePayload,
onConfigurePayload,
onStoreDocumentPayload,
RedisTransactionOrigin,
} from "@hocuspocus/server";
import {
IncomingMessage,
isTransactionOrigin,
MessageReceiver,
MessageType,
OutgoingMessage,
} from "@hocuspocus/server";
import {
ExecutionError,
type ExecutionResult,
type Lock,
Redlock,
} from "@sesamecare-oss/redlock";
import type {
Cluster,
ClusterNode,
ClusterOptions,
RedisOptions,
} from "ioredis";
import RedisClient from "ioredis";
import type { Awareness } from "y-protocols/awareness";
import { messageYjsSyncStep2, messageYjsUpdate } from "y-protocols/sync";
export type RedisInstance = RedisClient | Cluster;
export interface Configuration {
/**
* Redis port
*/
port: number;
/**
* Redis host
*/
host: string;
/**
* Redis Cluster
*/
nodes?: ClusterNode[];
/**
* Duplicate from an existed Redis instance
*/
redis?: RedisInstance;
/**
* Redis instance creator
*/
createClient?: () => RedisInstance;
/**
* Options passed directly to Redis constructor
*
* https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options
*/
options?: ClusterOptions | RedisOptions;
/**
* An unique instance name, required to filter messages in Redis.
* If none is provided an unique id is generated.
*/
identifier: string;
/**
* Namespace for Redis keys, if none is provided 'hocuspocus' is used
*/
prefix: string;
/**
* The maximum time for the Redis lock in ms (in case it can’t be released).
*/
lockTimeout: number;
/**
* A delay before onDisconnect is executed. This allows last minute updates'
* sync messages to be received by the subscription before it's closed.
*/
disconnectDelay: number;
/**
* The maximum time (in ms) `afterLoadDocument` waits for another instance to
* send its state when loading a document that is already open elsewhere.
*
* When a document is loaded on this instance while another instance already
* has it open (and possibly modified in memory, not yet persisted), we
* publish a SyncStep1 and block the load until the peer replies with its
* state (SyncStep2) — or this timeout elapses. This guarantees that callers
* (most importantly a `DirectConnection`) observe the latest collaborative
* state instead of just what was loaded from storage.
*
* If no other instance is subscribed to the document, the wait is skipped
* entirely, so a cold/lone load is not delayed.
*
* Set to `0` to disable the wait and restore the legacy fire-and-forget
* behavior.
*/
awaitInitialSyncTimeout: number;
}
/**
* Publishes for one document, accumulated over a single event loop turn.
*
* The two kinds are independent, not alternatives: a turn can carry a sync step,
* an awareness update, or both, and each is sent on its own.
*/
type PendingPublish = {
document: Document;
/** A SyncStep1 is pending. A flag, not a count: state vectors are idempotent. */
syncStepPending: boolean;
/** Clients whose awareness changed this turn; empty when none did. */
awarenessClients: Set<number>;
/** Set alongside `awarenessClients`; the awareness they belong to. */
awareness?: Awareness;
cancel: () => void;
};
export class Redis implements Extension {
/**
* Make sure to give that extension a higher priority, so
* the `onStoreDocument` hook is able to intercept the chain,
* before documents are stored to the database.
*/
priority = 1000;
configuration: Configuration = {
port: 6379,
host: "127.0.0.1",
prefix: "hocuspocus",
identifier: `host-${crypto.randomUUID()}`,
lockTimeout: 1000,
disconnectDelay: 1000,
awaitInitialSyncTimeout: 1000,
};
redisTransactionOrigin: RedisTransactionOrigin = {
source: "redis",
};
pub: RedisInstance;
sub: RedisInstance;
instance!: Hocuspocus;
redlock: Redlock;
locks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();
messagePrefix: Buffer;
/**
* Resolves once this instance is subscribed to its own reply channel.
* Awaited before a document announces itself, so a peer's reply can never
* arrive on a channel we are not listening to yet.
*/
private replySubscription: Promise<void>;
private pendingAfterStoreDocumentResolves = new Map<
string,
{ timeout: NodeJS.Timeout; resolve: () => void }
>();
/**
* Documents this instance has loaded and subscribed to, keyed by name.
*
* This mirrors `instance.documents` but is populated at the very start of
* `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in
* `instance.documents` (which only happens once loading, including this
* hook, has fully resolved). That early registration is what lets
* `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still
* blocking the load on it.
*/
private documents = new Map<string, Document>();
/**
* Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.
* Invoked by `handleIncomingMessage` as soon as a peer's state arrives.
*/
private pendingInitialSyncResolves = new Map<string, () => void>();
/**
* Publishes coalesced into the current event loop turn, keyed by document
* name. See `schedulePublish`.
*/
private pendingPublishes = new Map<string, PendingPublish>();
public constructor(configuration: Partial<Configuration>) {
this.configuration = {
...this.configuration,
...configuration,
};
// Create Redis instance
const { port, host, options, nodes, redis, createClient } =
this.configuration;
if (typeof createClient === "function") {
this.pub = createClient();
this.sub = createClient();
} else if (redis) {
this.pub = redis.duplicate();
this.sub = redis.duplicate();
} else if (nodes && nodes.length > 0) {
this.pub = new RedisClient.Cluster(nodes, options);
this.sub = new RedisClient.Cluster(nodes, options);
} else {
this.pub = new RedisClient(port, host, options ?? {});
this.sub = new RedisClient(port, host, options ?? {});
}
this.sub.on("messageBuffer", this.handleIncomingMessage);
this.redlock = new Redlock([this.pub], {
retryCount: 0,
});
const identifierBuffer = Buffer.from(
this.configuration.identifier,
"utf-8",
);
this.messagePrefix = Buffer.concat([
Buffer.from([identifierBuffer.length]),
identifierBuffer,
]);
// Subscribed once for the lifetime of the extension rather than per
// document: `handleIncomingMessage` resolves the document from the message
// payload, so a single channel per instance receives every reply addressed
// to us. Started here instead of in `onConfigure` because the server does
// not await that hook; `afterLoadDocument` awaits this promise, so no
// reply can be missed while the subscription is still pending.
this.replySubscription = new Promise<void>((resolve, reject) => {
this.sub.subscribe(
this.replyKey(this.configuration.identifier),
(error: any) => {
if (error) {
reject(error);
return;
}
resolve();
},
);
});
// The error is re-thrown to whoever awaits the promise in
// `afterLoadDocument`; this handler only keeps it from being reported as
// an unhandled rejection when no document is ever loaded.
this.replySubscription.catch(() => {});
}
async onConfigure({ instance }: onConfigurePayload) {
this.instance = instance;
}
private getKey(documentName: string) {
return `${this.configuration.prefix}:${documentName}`;
}
private pubKey(documentName: string) {
return this.getKey(documentName);
}
private subKey(documentName: string) {
return this.getKey(documentName);
}
private lockKey(documentName: string) {
return `${this.getKey(documentName)}:lock`;
}
/**
* The channel an instance receives replies to its own requests on.
*
* Separated from the prefix by `#` rather than `:`, so it can never collide
* with the document channel of a document that happens to be named
* `reply:<identifier>`.
*/
private replyKey(identifier: string) {
return `${this.configuration.prefix}#reply:${identifier}`;
}
private encodeMessage(message: Uint8Array) {
return Buffer.concat([this.messagePrefix, Buffer.from(message)]);
}
private decodeMessage(buffer: Buffer): [identifier: string, message: Buffer] {
const identifierLength = buffer[0];
const identifier = buffer.toString("utf-8", 1, identifierLength + 1);
return [identifier, buffer.slice(identifierLength + 1)];
}
/**
* Once a document is loaded, subscribe to the channel in Redis.
*/
public async afterLoadDocument({
documentName,
document,
}: afterLoadDocumentPayload) {
// Track the document locally right away so `handleIncomingMessage` can
// apply inbound messages to it even before Hocuspocus has finished
// loading it (it only lands in `instance.documents` after this hook
// resolves). Without this, the SyncStep2 reply we request below would be
// dropped while we wait for it.
this.documents.set(documentName, document);
try {
// Replies to the SyncStep1 we are about to publish are addressed to
// this instance's reply channel, so that subscription has to be live
// first.
await this.replySubscription;
await new Promise<void>((resolve, reject) => {
// On document creation the node will connect to pub and sub channels
// for the document.
this.sub.subscribe(this.subKey(documentName), (error: any) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await this.syncInitialStateFromPeers(documentName, document);
} catch (error) {
// Loading failed: stop tracking the document so future messages aren't
// applied to a doc that never finished loading, release any pending
// wait, and best-effort unsubscribe (afterUnloadDocument does not run
// for a load that never completed).
this.documents.delete(documentName);
this.pendingInitialSyncResolves.delete(documentName);
this.sub.unsubscribe(this.subKey(documentName), () => {});
throw error;
}
}
/**
* Announce our state to other instances and — when another instance already
* has this document open — block until it has sent us its state (or the
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
*/
private async syncInitialStateFromPeers(
documentName: string,
document: Document,
) {
const timeout = this.configuration.awaitInitialSyncTimeout;
const waitForPeers =
timeout > 0 && (await this.hasOtherSubscribers(documentName));
// Announce our state and request awareness. Awaited so a Redis publish
// failure surfaces as a load error instead of an unhandled rejection.
await Promise.all([
this.publishFirstSyncStep(documentName, document),
this.requestAwarenessFromOtherInstances(documentName),
]);
// Skip the wait when it's disabled or nobody else has the document open:
// there is no peer to sync from, so blocking would only add latency.
if (!waitForPeers) {
return;
}
// A peer has the document open. Block until it replies with its state
// (a SyncStep2/Update applied via handleIncomingMessage resolves this) or
// the timeout elapses. The reply cannot arrive before the SyncStep1 we
// just published is delivered, so registering the resolver now — right
// after the awaited publish, before yielding to the event loop — cannot
// miss it.
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.pendingInitialSyncResolves.delete(documentName);
resolve();
}, timeout);
this.pendingInitialSyncResolves.set(documentName, () => {
clearTimeout(timer);
this.pendingInitialSyncResolves.delete(documentName);
resolve();
});
});
}
/**
* Whether another instance is currently subscribed to a document's channel.
* We are subscribed ourselves, so a subscriber count greater than one means
* at least one peer has the document open.
*/
private async hasOtherSubscribers(documentName: string): Promise<boolean> {
try {
const result = (await this.pub.pubsub(
"NUMSUB",
this.subKey(documentName),
)) as [string, number | string];
return Number(result?.[1] ?? 0) > 1;
} catch {
// NUMSUB may be unavailable in some setups (e.g. certain clusters).
// Assume peers might exist so correctness is preserved; the timeout
// still bounds the wait.
return true;
}
}
/**
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
* applied.
*/
private resolveInitialSync(documentName: string) {
this.pendingInitialSyncResolves.get(documentName)?.();
}
/**
* Peek whether a message carries another instance's document state
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
* *requests* state, so it must not release an initial-sync wait.
*
* Carrying state is necessary but not sufficient to release that wait: the
* state also has to have been computed for *our* state vector, which is what
* arriving on our reply channel means. See `handleIncomingMessage`.
*/
private messageCarriesPeerState(message: IncomingMessage): boolean {
const { pos } = message.decoder;
try {
const type = message.readVarUint();
if (type !== MessageType.Sync && type !== MessageType.SyncReply) {
return false;
}
const syncType = message.readVarUint();
return syncType === messageYjsSyncStep2 || syncType === messageYjsUpdate;
} catch {
return false;
} finally {
message.decoder.pos = pos;
}
}
/**
* Coalesce this document's Redis publishes into the current event loop turn.
*
* Both publishers are per-change and neither is covered by the server's
* `flushDelay` batching: `onChange` runs synchronously by design (it also
* drives the store hooks) and `onAwarenessUpdate` is dispatched from a raw
* awareness listener rather than from `Document`'s batched broadcast. Left
* uncoalesced, a document with many writing clients publishes one SyncStep1
* per change and every peer answers each one with a full
* `encodeStateAsUpdate` diff — which is O(document), so this costs far more
* per change than the WebSocket fan-out ever did.
*
* Collapsing is lossless. A SyncStep1 carries the document's *current* state
* vector, so the last one of a turn is a superset of the ones it replaces
* and a peer's reply to it covers every skipped change. Awareness collapses
* to the latest state of each changed client, exactly as `Document` does for
* its own fan-out.
*/
private schedulePublish(
documentName: string,
document: Document,
): PendingPublish {
const existing = this.pendingPublishes.get(documentName);
if (existing) {
if (existing.document === document) {
return existing;
}
// The document was reloaded under the same name. `afterUnloadDocument`
// returns early on a fast reconnect, so it cannot be relied on to have
// cleared this. Whatever is buffered describes the previous instance —
// its state vector and its awareness — so drop it rather than publish
// it on behalf of the new one.
existing.cancel();
this.pendingPublishes.delete(documentName);
}
const handle = setImmediate(() => {
this.flushPublish(documentName).catch((error) => {
console.error(
`Redis: failed to publish for document "${documentName}"`,
error,
);
});
});
const pending: PendingPublish = {
document,
syncStepPending: false,
awarenessClients: new Set<number>(),
cancel: () => clearImmediate(handle),
};
this.pendingPublishes.set(documentName, pending);
return pending;
}
private cancelPendingPublish(documentName: string) {
const pending = this.pendingPublishes.get(documentName);
if (pending) {
pending.cancel();
this.pendingPublishes.delete(documentName);
}
}
/**
* Send everything accumulated for this document in the current turn.
*/
private async flushPublish(documentName: string) {
const pending = this.pendingPublishes.get(documentName);
if (!pending) {
return;
}
this.pendingPublishes.delete(documentName);
// Both are issued before anything is awaited, so a failing sync step
// cannot swallow the awareness update — the entry is already dropped from
// the map at this point, and those clients would never be retried.
const publishes: Array<Promise<unknown>> = [];
if (pending.syncStepPending) {
publishes.push(this.publishFirstSyncStep(documentName, pending.document));
}
// Same guard as the uncoalesced path: publishing awareness for a document
// nobody is connected to throws (see hocuspocus#1027).
if (
pending.awarenessClients.size > 0 &&
pending.awareness &&
pending.document.connections.size > 0
) {
const message = new OutgoingMessage(
documentName,
).createAwarenessUpdateMessage(
pending.awareness,
Array.from(pending.awarenessClients),
);
publishes.push(
this.pub.publish(
this.pubKey(documentName),
this.encodeMessage(message.toUint8Array()),
),
);
}
await Promise.all(publishes);
}
/**
* Publish the first sync step through Redis.
*/
private async publishFirstSyncStep(documentName: string, document: Document) {
const syncMessage = new OutgoingMessage(documentName)
.createSyncMessage()
.writeFirstSyncStepFor(document);
return this.pub.publish(
this.pubKey(documentName),
this.encodeMessage(syncMessage.toUint8Array()),
);
}
/**
* Let’s ask Redis who is connected already.
*/
private async requestAwarenessFromOtherInstances(documentName: string) {
const awarenessMessage = new OutgoingMessage(
documentName,
).writeQueryAwareness();
return this.pub.publish(
this.pubKey(documentName),
this.encodeMessage(awarenessMessage.toUint8Array()),
);
}
/**
* Before the document is stored, make sure to set a lock in Redis.
* That’s meant to avoid conflicts with other instances trying to store the document.
*/
async onStoreDocument({ documentName }: onStoreDocumentPayload) {
// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,
// to avoid conflict with other instances storing the same document.
const resource = this.lockKey(documentName);
const ttl = this.configuration.lockTimeout;
try {
const lock = await this.redlock.acquire([resource], ttl);
const oldLock = this.locks.get(resource);
if (oldLock?.release) {
await oldLock.release;
}
this.locks.set(resource, { lock });
} catch (error: any) {
//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349
if (
error instanceof ExecutionError &&
error.message ===
"The operation was unable to achieve a quorum during its retry window."
) {
// Expected behavior: Could not acquire lock, another instance locked it already.
// Skip further hooks and retry — the data is safe on the other instance.
throw new SkipFurtherHooksError(
"Another instance is already storing this document",
);
}
//unexpected error
console.error("unexpected error:", error);
throw error;
}
}
/**
* Release the Redis lock, so other instances can store documents.
*/
async afterStoreDocument({
documentName,
lastTransactionOrigin,
}: afterStoreDocumentPayload) {
const lockKey = this.lockKey(documentName);
const lock = this.locks.get(lockKey);
if (lock) {
try {
// Always try to unlock and clean up the lock
lock.release = lock.lock.release();
await lock.release;
} catch {
// Lock will expire on its own after timeout
} finally {
this.locks.delete(lockKey);
}
}
// if the change was initiated by a directConnection (or otherwise local source), we need to delay this hook to make sure sync can finish first.
// for provider connections, this usually happens in the onDisconnect hook
if (
isTransactionOrigin(lastTransactionOrigin) &&
lastTransactionOrigin.source === "local"
) {
const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve();
this.pendingAfterStoreDocumentResolves.delete(documentName);
}
let resolveFunction: () => void = () => {};
const delayedPromise = new Promise<void>((resolve) => {
resolveFunction = resolve;
});
const timeout = setTimeout(() => {
this.pendingAfterStoreDocumentResolves.delete(documentName);
resolveFunction();
}, this.configuration.disconnectDelay);
this.pendingAfterStoreDocumentResolves.set(documentName, {
timeout,
resolve: resolveFunction,
});
await delayedPromise;
}
}
/**
* Handle awareness update messages received directly by this Hocuspocus instance.
*/
async onAwarenessUpdate({
documentName,
awareness,
added,
updated,
removed,
document,
}: onAwarenessUpdatePayload) {
// Do not publish if there is no connection: it fixes this issue: "https://github.com/ueberdosis/hocuspocus/issues/1027"
const connections = document?.connections.size || 0;
if (connections === 0) {
return; // avoids exception
}
const pending = this.schedulePublish(documentName, document);
pending.awareness = awareness;
for (const clientId of added.concat(updated, removed)) {
pending.awarenessClients.add(clientId);
}
}
/**
* Handle incoming messages published on subscribed document channels.
* Note that this will also include messages from ourselves as it is not possible
* in Redis to filter these.
*/
private handleIncomingMessage = async (channel: Buffer, data: Buffer) => {
const [identifier, messageBuffer] = this.decodeMessage(data);
if (identifier === this.configuration.identifier) {
return;
}
const message = new IncomingMessage(messageBuffer);
const documentName = message.readVarString();
message.writeVarString(documentName);
// Prefer the extension-local map: it also contains documents that are
// still loading (not yet in `instance.documents`), which is exactly when
// a blocking `afterLoadDocument` needs to receive the peer's reply.
const document =
this.documents.get(documentName) ??
this.instance.documents.get(documentName);
if (!document) {
return;
}
const carriesPeerState = this.messageCarriesPeerState(message);
// State that reached us on our own reply channel was computed for our
// state vector; state overheard anywhere else was computed for somebody
// else and says nothing about whether we have caught up.
const addressedToUs =
channel.toString("utf-8") ===
this.replyKey(this.configuration.identifier);
const receiver = new MessageReceiver(message, this.redisTransactionOrigin);
await receiver.apply(document, undefined, (reply) => {
// Replies go to the requester alone, never to the document channel.
// A SyncStep2 is `encodeStateAsUpdate(document, requesterStateVector)`:
// the structs that one requester is missing plus the *entire* delete
// set. An instance whose state is behind the requester's applies those
// deletes without receiving the structs that replaced the deleted
// content, leaving it with a document that never existed — which it
// then broadcasts to its own clients and can persist (#1151).
return this.pub.publish(
this.replyKey(identifier),
this.encodeMessage(reply),
);
});
// A peer answered our SyncStep1 with its state — the document has now
// caught up, so any blocking initial-sync wait can be released.
if (carriesPeerState && addressedToUs) {
this.resolveInitialSync(documentName);
}
};
/**
* if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
*/
public async onChange(data: onChangePayload): Promise<any> {
if (
isTransactionOrigin(data.transactionOrigin) &&
data.transactionOrigin.source === "redis"
) {
return;
}
this.schedulePublish(data.documentName, data.document).syncStepPending =
true;
}
/**
* Delay unloading to allow syncs to finish
*/
async beforeUnloadDocument(data: beforeUnloadDocumentPayload) {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, this.configuration.disconnectDelay);
});
}
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
if (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)
this.resolveInitialSync(data.documentName);
this.documents.delete(data.documentName);
// Nothing should be pending: the coalescing window is a single event loop
// turn and `beforeUnloadDocument` already waits `disconnectDelay` to let
// syncs finish. This is a safety net so a timer cannot fire against a
// document that is on its way out.
this.cancelPendingPublish(data.documentName);
this.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {
if (error) {
console.error(error);
}
});
}
async beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {
const message = new OutgoingMessage(
data.documentName,
).writeBroadcastStateless(data.payload);
return this.pub.publish(
this.pubKey(data.documentName),
this.encodeMessage(message.toUint8Array()),
);
}
/**
* Kill the Redlock connection immediately.
*/
async onDestroy() {
// Cancelled rather than flushed, because flushing could not achieve
// anything: a pending publish is a SyncStep1, i.e. a state vector, which
// only *asks* peers to reply. Our own changes reach them one round trip
// later, when we answer the SyncStep1 they send back — and the subscriber
// is disconnected a few lines below, so that answer would never come.
// Durability on shutdown comes from the fact that we flush per setImmediate,
// which runs before the delay in beforeUnloadDocument.
for (const documentName of Array.from(this.pendingPublishes.keys())) {
this.cancelPendingPublish(documentName);
}
await this.redlock.quit();
this.pub.disconnect(false);
this.sub.disconnect(false);
}
}

View File

@@ -0,0 +1 @@
export * from "./Redis.ts";