@hocuspocus/extension-redis 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/hocuspocus-redis.cjs +86 -11
- package/dist/hocuspocus-redis.cjs.map +1 -1
- package/dist/hocuspocus-redis.esm.js +87 -12
- package/dist/hocuspocus-redis.esm.js.map +1 -1
- package/dist/index.d.ts +58 -1
- package/package.json +4 -4
- package/src/Redis.ts +187 -14
package/README.md
CHANGED
|
@@ -47,6 +47,26 @@ new Redis({
|
|
|
47
47
|
|
|
48
48
|
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.
|
|
49
49
|
|
|
50
|
+
### Consistent reads on load
|
|
51
|
+
|
|
52
|
+
When a document is loaded on one instance while another instance already has it
|
|
53
|
+
open (and possibly modified in memory, not yet persisted), the load blocks until
|
|
54
|
+
the other instance has sent its current state — so a `DirectConnection` or a
|
|
55
|
+
freshly connected client observes the latest collaborative content rather than
|
|
56
|
+
just what was read from storage. If no other instance has the document open, the
|
|
57
|
+
load is not delayed.
|
|
58
|
+
|
|
59
|
+
The wait is bounded by `awaitInitialSyncTimeout` (ms, default `1000`). Set it to
|
|
60
|
+
`0` to disable the wait and restore the previous fire-and-forget behavior:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
new Redis({
|
|
64
|
+
host: "127.0.0.1",
|
|
65
|
+
port: 6379,
|
|
66
|
+
awaitInitialSyncTimeout: 1000,
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
50
70
|
## Documentation
|
|
51
71
|
|
|
52
72
|
Full scaling architecture and multi-instance deployment guide: [tiptap.dev/docs/hocuspocus/server/extensions/redis](https://tiptap.dev/docs/hocuspocus/server/extensions/redis).
|
|
@@ -30,6 +30,7 @@ let node_crypto = require("node:crypto");
|
|
|
30
30
|
node_crypto = __toESM(node_crypto);
|
|
31
31
|
let _hocuspocus_common = require("@hocuspocus/common");
|
|
32
32
|
let _hocuspocus_server = require("@hocuspocus/server");
|
|
33
|
+
let y_protocols_sync = require("y-protocols/sync");
|
|
33
34
|
let _sesamecare_oss_redlock = require("@sesamecare-oss/redlock");
|
|
34
35
|
let ioredis = require("ioredis");
|
|
35
36
|
ioredis = __toESM(ioredis);
|
|
@@ -44,22 +45,27 @@ var Redis = class {
|
|
|
44
45
|
prefix: "hocuspocus",
|
|
45
46
|
identifier: `host-${node_crypto.default.randomUUID()}`,
|
|
46
47
|
lockTimeout: 1e3,
|
|
47
|
-
disconnectDelay: 1e3
|
|
48
|
+
disconnectDelay: 1e3,
|
|
49
|
+
awaitInitialSyncTimeout: 1e3
|
|
48
50
|
};
|
|
49
51
|
this.redisTransactionOrigin = { source: "redis" };
|
|
50
52
|
this.locks = /* @__PURE__ */ new Map();
|
|
51
53
|
this.pendingAfterStoreDocumentResolves = /* @__PURE__ */ new Map();
|
|
54
|
+
this.documents = /* @__PURE__ */ new Map();
|
|
55
|
+
this.pendingInitialSyncResolves = /* @__PURE__ */ new Map();
|
|
52
56
|
this.handleIncomingMessage = async (channel, data) => {
|
|
53
57
|
const [identifier, messageBuffer] = this.decodeMessage(data);
|
|
54
58
|
if (identifier === this.configuration.identifier) return;
|
|
55
59
|
const message = new _hocuspocus_server.IncomingMessage(messageBuffer);
|
|
56
60
|
const documentName = message.readVarString();
|
|
57
61
|
message.writeVarString(documentName);
|
|
58
|
-
const document = this.instance.documents.get(documentName);
|
|
62
|
+
const document = this.documents.get(documentName) ?? this.instance.documents.get(documentName);
|
|
59
63
|
if (!document) return;
|
|
64
|
+
const carriesPeerState = this.messageCarriesPeerState(message);
|
|
60
65
|
await new _hocuspocus_server.MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
|
|
61
66
|
return this.pub.publish(this.pubKey(document.name), this.encodeMessage(reply));
|
|
62
67
|
});
|
|
68
|
+
if (carriesPeerState) this.resolveInitialSync(documentName);
|
|
63
69
|
};
|
|
64
70
|
this.configuration = {
|
|
65
71
|
...this.configuration,
|
|
@@ -110,19 +116,86 @@ var Redis = class {
|
|
|
110
116
|
* Once a document is loaded, subscribe to the channel in Redis.
|
|
111
117
|
*/
|
|
112
118
|
async afterLoadDocument({ documentName, document }) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
this.documents.set(documentName, document);
|
|
120
|
+
try {
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
this.sub.subscribe(this.subKey(documentName), (error) => {
|
|
123
|
+
if (error) {
|
|
124
|
+
reject(error);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
resolve();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
await this.syncInitialStateFromPeers(documentName, document);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.documents.delete(documentName);
|
|
133
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
134
|
+
this.sub.unsubscribe(this.subKey(documentName), () => {});
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Announce our state to other instances and — when another instance already
|
|
140
|
+
* has this document open — block until it has sent us its state (or the
|
|
141
|
+
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
|
|
142
|
+
*/
|
|
143
|
+
async syncInitialStateFromPeers(documentName, document) {
|
|
144
|
+
const timeout = this.configuration.awaitInitialSyncTimeout;
|
|
145
|
+
const waitForPeers = timeout > 0 && await this.hasOtherSubscribers(documentName);
|
|
146
|
+
await Promise.all([this.publishFirstSyncStep(documentName, document), this.requestAwarenessFromOtherInstances(documentName)]);
|
|
147
|
+
if (!waitForPeers) return;
|
|
148
|
+
await new Promise((resolve) => {
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
151
|
+
resolve();
|
|
152
|
+
}, timeout);
|
|
153
|
+
this.pendingInitialSyncResolves.set(documentName, () => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
156
|
+
resolve();
|
|
122
157
|
});
|
|
123
158
|
});
|
|
124
159
|
}
|
|
125
160
|
/**
|
|
161
|
+
* Whether another instance is currently subscribed to a document's channel.
|
|
162
|
+
* We are subscribed ourselves, so a subscriber count greater than one means
|
|
163
|
+
* at least one peer has the document open.
|
|
164
|
+
*/
|
|
165
|
+
async hasOtherSubscribers(documentName) {
|
|
166
|
+
try {
|
|
167
|
+
const result = await this.pub.pubsub("NUMSUB", this.subKey(documentName));
|
|
168
|
+
return Number(result?.[1] ?? 0) > 1;
|
|
169
|
+
} catch {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
|
|
175
|
+
* applied.
|
|
176
|
+
*/
|
|
177
|
+
resolveInitialSync(documentName) {
|
|
178
|
+
this.pendingInitialSyncResolves.get(documentName)?.();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Peek whether a message carries another instance's document state
|
|
182
|
+
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
|
|
183
|
+
* *requests* state, so it must not release an initial-sync wait.
|
|
184
|
+
*/
|
|
185
|
+
messageCarriesPeerState(message) {
|
|
186
|
+
const { pos } = message.decoder;
|
|
187
|
+
try {
|
|
188
|
+
const type = message.readVarUint();
|
|
189
|
+
if (type !== _hocuspocus_server.MessageType.Sync && type !== _hocuspocus_server.MessageType.SyncReply) return false;
|
|
190
|
+
const syncType = message.readVarUint();
|
|
191
|
+
return syncType === y_protocols_sync.messageYjsSyncStep2 || syncType === y_protocols_sync.messageYjsUpdate;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
} finally {
|
|
195
|
+
message.decoder.pos = pos;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
126
199
|
* Publish the first sync step through Redis.
|
|
127
200
|
*/
|
|
128
201
|
async publishFirstSyncStep(documentName, document) {
|
|
@@ -216,6 +289,8 @@ var Redis = class {
|
|
|
216
289
|
}
|
|
217
290
|
async afterUnloadDocument(data) {
|
|
218
291
|
if (data.instance.documents.has(data.documentName)) return;
|
|
292
|
+
this.resolveInitialSync(data.documentName);
|
|
293
|
+
this.documents.delete(data.documentName);
|
|
219
294
|
this.sub.unsubscribe(this.subKey(data.documentName), (error) => {
|
|
220
295
|
if (error) console.error(error);
|
|
221
296
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hocuspocus-redis.cjs","names":["crypto","IncomingMessage","MessageReceiver","RedisClient","Redlock","OutgoingMessage","ExecutionError","SkipFurtherHooksError"],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tafterLoadDocumentPayload,\n\tafterStoreDocumentPayload,\n\tafterUnloadDocumentPayload,\n\tbeforeBroadcastStatelessPayload,\n\tbeforeUnloadDocumentPayload,\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tonAwarenessUpdatePayload,\n\tonChangePayload,\n\tonConfigurePayload,\n\tonStoreDocumentPayload,\n\tRedisTransactionOrigin,\n} from \"@hocuspocus/server\";\nimport { SkipFurtherHooksError } from \"@hocuspocus/common\";\nimport {\n\tIncomingMessage,\n\tisTransactionOrigin,\n\tMessageReceiver,\n\tOutgoingMessage,\n} from \"@hocuspocus/server\";\nimport {\n\tExecutionError,\n\ttype ExecutionResult,\n\ttype Lock,\n\tRedlock,\n} from \"@sesamecare-oss/redlock\";\nimport type {\n\tCluster,\n\tClusterNode,\n\tClusterOptions,\n\tRedisOptions,\n} from \"ioredis\";\nimport RedisClient from \"ioredis\";\nexport type RedisInstance = RedisClient | Cluster;\nexport interface Configuration {\n\t/**\n\t * Redis port\n\t */\n\tport: number;\n\t/**\n\t * Redis host\n\t */\n\thost: string;\n\t/**\n\t * Redis Cluster\n\t */\n\tnodes?: ClusterNode[];\n\t/**\n\t * Duplicate from an existed Redis instance\n\t */\n\tredis?: RedisInstance;\n\t/**\n\t * Redis instance creator\n\t */\n\tcreateClient?: () => RedisInstance;\n\t/**\n\t * Options passed directly to Redis constructor\n\t *\n\t * https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options\n\t */\n\toptions?: ClusterOptions | RedisOptions;\n\t/**\n\t * An unique instance name, required to filter messages in Redis.\n\t * If none is provided an unique id is generated.\n\t */\n\tidentifier: string;\n\t/**\n\t * Namespace for Redis keys, if none is provided 'hocuspocus' is used\n\t */\n\tprefix: string;\n\t/**\n\t * The maximum time for the Redis lock in ms (in case it can’t be released).\n\t */\n\tlockTimeout: number;\n\t/**\n\t * A delay before onDisconnect is executed. This allows last minute updates'\n\t * sync messages to be received by the subscription before it's closed.\n\t */\n\tdisconnectDelay: number;\n}\n\nexport class Redis implements Extension {\n\t/**\n\t * Make sure to give that extension a higher priority, so\n\t * the `onStoreDocument` hook is able to intercept the chain,\n\t * before documents are stored to the database.\n\t */\n\tpriority = 1000;\n\n\tconfiguration: Configuration = {\n\t\tport: 6379,\n\t\thost: \"127.0.0.1\",\n\t\tprefix: \"hocuspocus\",\n\t\tidentifier: `host-${crypto.randomUUID()}`,\n\t\tlockTimeout: 1000,\n\t\tdisconnectDelay: 1000,\n\t};\n\n\tredisTransactionOrigin: RedisTransactionOrigin = {\n\t\tsource: \"redis\",\n\t};\n\n\tpub: RedisInstance;\n\n\tsub: RedisInstance;\n\n\tinstance!: Hocuspocus;\n\n\tredlock: Redlock;\n\n\tlocks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();\n\n\tmessagePrefix: Buffer;\n\n\tprivate pendingAfterStoreDocumentResolves = new Map<\n\t\tstring,\n\t\t{ timeout: NodeJS.Timeout; resolve: () => void }\n\t>();\n\n\tpublic constructor(configuration: Partial<Configuration>) {\n\t\tthis.configuration = {\n\t\t\t...this.configuration,\n\t\t\t...configuration,\n\t\t};\n\n\t\t// Create Redis instance\n\t\tconst { port, host, options, nodes, redis, createClient } =\n\t\t\tthis.configuration;\n\n\t\tif (typeof createClient === \"function\") {\n\t\t\tthis.pub = createClient();\n\t\t\tthis.sub = createClient();\n\t\t} else if (redis) {\n\t\t\tthis.pub = redis.duplicate();\n\t\t\tthis.sub = redis.duplicate();\n\t\t} else if (nodes && nodes.length > 0) {\n\t\t\tthis.pub = new RedisClient.Cluster(nodes, options);\n\t\t\tthis.sub = new RedisClient.Cluster(nodes, options);\n\t\t} else {\n\t\t\tthis.pub = new RedisClient(port, host, options ?? {});\n\t\t\tthis.sub = new RedisClient(port, host, options ?? {});\n\t\t}\n\n\t\tthis.sub.on(\"messageBuffer\", this.handleIncomingMessage);\n\n\t\tthis.redlock = new Redlock([this.pub], {\n\t\t\tretryCount: 0,\n\t\t});\n\n\t\tconst identifierBuffer = Buffer.from(\n\t\t\tthis.configuration.identifier,\n\t\t\t\"utf-8\",\n\t\t);\n\t\tthis.messagePrefix = Buffer.concat([\n\t\t\tBuffer.from([identifierBuffer.length]),\n\t\t\tidentifierBuffer,\n\t\t]);\n\t}\n\n\tasync onConfigure({ instance }: onConfigurePayload) {\n\t\tthis.instance = instance;\n\t}\n\n\tprivate getKey(documentName: string) {\n\t\treturn `${this.configuration.prefix}:${documentName}`;\n\t}\n\n\tprivate pubKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate subKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate lockKey(documentName: string) {\n\t\treturn `${this.getKey(documentName)}:lock`;\n\t}\n\n\tprivate encodeMessage(message: Uint8Array) {\n\t\treturn Buffer.concat([this.messagePrefix, Buffer.from(message)]);\n\t}\n\n\tprivate decodeMessage(buffer: Buffer) {\n\t\tconst identifierLength = buffer[0];\n\t\tconst identifier = buffer.toString(\"utf-8\", 1, identifierLength + 1);\n\n\t\treturn [identifier, buffer.slice(identifierLength + 1)];\n\t}\n\n\t/**\n\t * Once a document is loaded, subscribe to the channel in Redis.\n\t */\n\tpublic async afterLoadDocument({\n\t\tdocumentName,\n\t\tdocument,\n\t}: afterLoadDocumentPayload) {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\t// On document creation the node will connect to pub and sub channels\n\t\t\t// for the document.\n\t\t\tthis.sub.subscribe(this.subKey(documentName), async (error: any) => {\n\t\t\t\tif (error) {\n\t\t\t\t\treject(error);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tthis.publishFirstSyncStep(documentName, document);\n\t\t\t\tthis.requestAwarenessFromOtherInstances(documentName);\n\n\t\t\t\tresolve(undefined);\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Publish the first sync step through Redis.\n\t */\n\tprivate async publishFirstSyncStep(documentName: string, document: Document) {\n\t\tconst syncMessage = new OutgoingMessage(documentName)\n\t\t\t.createSyncMessage()\n\t\t\t.writeFirstSyncStepFor(document);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(syncMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Let’s ask Redis who is connected already.\n\t */\n\tprivate async requestAwarenessFromOtherInstances(documentName: string) {\n\t\tconst awarenessMessage = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).writeQueryAwareness();\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(awarenessMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Before the document is stored, make sure to set a lock in Redis.\n\t * That’s meant to avoid conflicts with other instances trying to store the document.\n\t */\n\tasync onStoreDocument({ documentName }: onStoreDocumentPayload) {\n\t\t// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,\n\t\t// to avoid conflict with other instances storing the same document.\n\t\tconst resource = this.lockKey(documentName);\n\t\tconst ttl = this.configuration.lockTimeout;\n\t\ttry {\n\t\t\tconst lock = await this.redlock.acquire([resource], ttl);\n\t\t\tconst oldLock = this.locks.get(resource);\n\t\t\tif (oldLock?.release) {\n\t\t\t\tawait oldLock.release;\n\t\t\t}\n\t\t\tthis.locks.set(resource, { lock });\n\t\t} catch (error: any) {\n\t\t\t//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349\n\t\t\tif (\n\t\t\t\terror instanceof ExecutionError &&\n\t\t\t\terror.message ===\n\t\t\t\t\t\"The operation was unable to achieve a quorum during its retry window.\"\n\t\t\t) {\n\t\t\t\t// Expected behavior: Could not acquire lock, another instance locked it already.\n\t\t\t\t// Skip further hooks and retry — the data is safe on the other instance.\n\t\t\t\tthrow new SkipFurtherHooksError(\"Another instance is already storing this document\");\n\t\t\t}\n\t\t\t//unexpected error\n\t\t\tconsole.error(\"unexpected error:\", error);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/**\n\t * Release the Redis lock, so other instances can store documents.\n\t */\n\tasync afterStoreDocument({\n\t\tdocumentName,\n\t\tlastTransactionOrigin,\n\t}: afterStoreDocumentPayload) {\n\t\tconst lockKey = this.lockKey(documentName);\n\t\tconst lock = this.locks.get(lockKey);\n\t\tif (lock) {\n\t\t\ttry {\n\t\t\t\t// Always try to unlock and clean up the lock\n\t\t\t\tlock.release = lock.lock.release();\n\t\t\t\tawait lock.release;\n\t\t\t} catch {\n\t\t\t\t// Lock will expire on its own after timeout\n\t\t\t} finally {\n\t\t\t\tthis.locks.delete(lockKey);\n\t\t\t}\n\t\t}\n\n\t\t// 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.\n\t\t// for provider connections, this usually happens in the onDisconnect hook\n\t\tif (\n\t\t\tisTransactionOrigin(lastTransactionOrigin) &&\n\t\t\tlastTransactionOrigin.source === \"local\"\n\t\t) {\n\t\t\tconst pending = this.pendingAfterStoreDocumentResolves.get(documentName);\n\n\t\t\tif (pending) {\n\t\t\t\tclearTimeout(pending.timeout);\n\t\t\t\tpending.resolve();\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t}\n\n\t\t\tlet resolveFunction: () => void = () => {};\n\t\t\tconst delayedPromise = new Promise<void>((resolve) => {\n\t\t\t\tresolveFunction = resolve;\n\t\t\t});\n\n\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t\tresolveFunction();\n\t\t\t}, this.configuration.disconnectDelay);\n\n\t\t\tthis.pendingAfterStoreDocumentResolves.set(documentName, {\n\t\t\t\ttimeout,\n\t\t\t\tresolve: resolveFunction,\n\t\t\t});\n\n\t\t\tawait delayedPromise;\n\t\t}\n\t}\n\n\t/**\n\t * Handle awareness update messages received directly by this Hocuspocus instance.\n\t */\n\tasync onAwarenessUpdate({\n\t\tdocumentName,\n\t\tawareness,\n\t\tadded,\n\t\tupdated,\n\t\tremoved,\n\t\tdocument,\n\t}: onAwarenessUpdatePayload) {\n\t\t// Do not publish if there is no connection: it fixes this issue: \"https://github.com/ueberdosis/hocuspocus/issues/1027\"\n\t\tconst connections = document?.connections.size || 0;\n\t\tif (connections === 0) {\n\t\t\treturn; // avoids exception\n\t\t}\n\t\tconst changedClients = added.concat(updated, removed);\n\t\tconst message = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).createAwarenessUpdateMessage(awareness, changedClients);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Handle incoming messages published on subscribed document channels.\n\t * Note that this will also include messages from ourselves as it is not possible\n\t * in Redis to filter these.\n\t */\n\tprivate handleIncomingMessage = async (channel: Buffer, data: Buffer) => {\n\t\tconst [identifier, messageBuffer] = this.decodeMessage(data);\n\n\t\tif (identifier === this.configuration.identifier) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst message = new IncomingMessage(messageBuffer);\n\t\tconst documentName = message.readVarString();\n\t\tmessage.writeVarString(documentName);\n\n\t\tconst document = this.instance.documents.get(documentName);\n\n\t\tif (!document) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst receiver = new MessageReceiver(message, this.redisTransactionOrigin);\n\t\tawait receiver.apply(document, undefined, (reply) => {\n\t\t\treturn this.pub.publish(\n\t\t\t\tthis.pubKey(document.name),\n\t\t\t\tthis.encodeMessage(reply),\n\t\t\t);\n\t\t});\n\t};\n\n\t/**\n\t * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.\n\t */\n\tpublic async onChange(data: onChangePayload): Promise<any> {\n\t\tif (\n\t\t\tisTransactionOrigin(data.transactionOrigin) &&\n\t\t\tdata.transactionOrigin.source === \"redis\"\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\treturn this.publishFirstSyncStep(data.documentName, data.document);\n\t}\n\n\t/**\n\t * Delay unloading to allow syncs to finish\n\t */\n\tasync beforeUnloadDocument(data: beforeUnloadDocumentPayload) {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tsetTimeout(() => {\n\t\t\t\tresolve();\n\t\t\t}, this.configuration.disconnectDelay);\n\t\t});\n\t}\n\n\tasync afterUnloadDocument(data: afterUnloadDocumentPayload) {\n\t\tif (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)\n\n\t\tthis.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {\n\t\t\tif (error) {\n\t\t\t\tconsole.error(error);\n\t\t\t}\n\t\t});\n\t}\n\n\tasync beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {\n\t\tconst message = new OutgoingMessage(\n\t\t\tdata.documentName,\n\t\t).writeBroadcastStateless(data.payload);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(data.documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Kill the Redlock connection immediately.\n\t */\n\tasync onDestroy() {\n\t\tawait this.redlock.quit();\n\t\tthis.pub.disconnect(false);\n\t\tthis.sub.disconnect(false);\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoFA,IAAa,QAAb,MAAwC;CAsCvC,AAAO,YAAY,eAAuC;kBAhC/C;uBAEoB;GAC9B,MAAM;GACN,MAAM;GACN,QAAQ;GACR,YAAY,QAAQA,oBAAO,YAAY;GACvC,aAAa;GACb,iBAAiB;GACjB;gCAEgD,EAChD,QAAQ,SACR;+BAUO,IAAI,KAAiE;2DAIjC,IAAI,KAG7C;+BAoP6B,OAAO,SAAiB,SAAiB;GACxE,MAAM,CAAC,YAAY,iBAAiB,KAAK,cAAc,KAAK;AAE5D,OAAI,eAAe,KAAK,cAAc,WACrC;GAGD,MAAM,UAAU,IAAIC,mCAAgB,cAAc;GAClD,MAAM,eAAe,QAAQ,eAAe;AAC5C,WAAQ,eAAe,aAAa;GAEpC,MAAM,WAAW,KAAK,SAAS,UAAU,IAAI,aAAa;AAE1D,OAAI,CAAC,SACJ;AAID,SADiB,IAAIC,mCAAgB,SAAS,KAAK,uBAAuB,CAC3D,MAAM,UAAU,SAAY,UAAU;AACpD,WAAO,KAAK,IAAI,QACf,KAAK,OAAO,SAAS,KAAK,EAC1B,KAAK,cAAc,MAAM,CACzB;KACA;;AAxQF,OAAK,gBAAgB;GACpB,GAAG,KAAK;GACR,GAAG;GACH;EAGD,MAAM,EAAE,MAAM,MAAM,SAAS,OAAO,OAAO,iBAC1C,KAAK;AAEN,MAAI,OAAO,iBAAiB,YAAY;AACvC,QAAK,MAAM,cAAc;AACzB,QAAK,MAAM,cAAc;aACf,OAAO;AACjB,QAAK,MAAM,MAAM,WAAW;AAC5B,QAAK,MAAM,MAAM,WAAW;aAClB,SAAS,MAAM,SAAS,GAAG;AACrC,QAAK,MAAM,IAAIC,gBAAY,QAAQ,OAAO,QAAQ;AAClD,QAAK,MAAM,IAAIA,gBAAY,QAAQ,OAAO,QAAQ;SAC5C;AACN,QAAK,MAAM,IAAIA,gBAAY,MAAM,MAAM,WAAW,EAAE,CAAC;AACrD,QAAK,MAAM,IAAIA,gBAAY,MAAM,MAAM,WAAW,EAAE,CAAC;;AAGtD,OAAK,IAAI,GAAG,iBAAiB,KAAK,sBAAsB;AAExD,OAAK,UAAU,IAAIC,gCAAQ,CAAC,KAAK,IAAI,EAAE,EACtC,YAAY,GACZ,CAAC;EAEF,MAAM,mBAAmB,OAAO,KAC/B,KAAK,cAAc,YACnB,QACA;AACD,OAAK,gBAAgB,OAAO,OAAO,CAClC,OAAO,KAAK,CAAC,iBAAiB,OAAO,CAAC,EACtC,iBACA,CAAC;;CAGH,MAAM,YAAY,EAAE,YAAgC;AACnD,OAAK,WAAW;;CAGjB,AAAQ,OAAO,cAAsB;AACpC,SAAO,GAAG,KAAK,cAAc,OAAO,GAAG;;CAGxC,AAAQ,OAAO,cAAsB;AACpC,SAAO,KAAK,OAAO,aAAa;;CAGjC,AAAQ,OAAO,cAAsB;AACpC,SAAO,KAAK,OAAO,aAAa;;CAGjC,AAAQ,QAAQ,cAAsB;AACrC,SAAO,GAAG,KAAK,OAAO,aAAa,CAAC;;CAGrC,AAAQ,cAAc,SAAqB;AAC1C,SAAO,OAAO,OAAO,CAAC,KAAK,eAAe,OAAO,KAAK,QAAQ,CAAC,CAAC;;CAGjE,AAAQ,cAAc,QAAgB;EACrC,MAAM,mBAAmB,OAAO;AAGhC,SAAO,CAFY,OAAO,SAAS,SAAS,GAAG,mBAAmB,EAAE,EAEhD,OAAO,MAAM,mBAAmB,EAAE,CAAC;;;;;CAMxD,MAAa,kBAAkB,EAC9B,cACA,YAC4B;AAC5B,SAAO,IAAI,SAAS,SAAS,WAAW;AAGvC,QAAK,IAAI,UAAU,KAAK,OAAO,aAAa,EAAE,OAAO,UAAe;AACnE,QAAI,OAAO;AACV,YAAO,MAAM;AACb;;AAGD,SAAK,qBAAqB,cAAc,SAAS;AACjD,SAAK,mCAAmC,aAAa;AAErD,YAAQ,OAAU;KACjB;IACD;;;;;CAMH,MAAc,qBAAqB,cAAsB,UAAoB;EAC5E,MAAM,cAAc,IAAIC,mCAAgB,aAAa,CACnD,mBAAmB,CACnB,sBAAsB,SAAS;AAEjC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,YAAY,cAAc,CAAC,CAC9C;;;;;CAMF,MAAc,mCAAmC,cAAsB;EACtE,MAAM,mBAAmB,IAAIA,mCAC5B,aACA,CAAC,qBAAqB;AAEvB,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,iBAAiB,cAAc,CAAC,CACnD;;;;;;CAOF,MAAM,gBAAgB,EAAE,gBAAwC;EAG/D,MAAM,WAAW,KAAK,QAAQ,aAAa;EAC3C,MAAM,MAAM,KAAK,cAAc;AAC/B,MAAI;GACH,MAAM,OAAO,MAAM,KAAK,QAAQ,QAAQ,CAAC,SAAS,EAAE,IAAI;GACxD,MAAM,UAAU,KAAK,MAAM,IAAI,SAAS;AACxC,OAAI,SAAS,QACZ,OAAM,QAAQ;AAEf,QAAK,MAAM,IAAI,UAAU,EAAE,MAAM,CAAC;WAC1B,OAAY;AAEpB,OACC,iBAAiBC,0CACjB,MAAM,YACL,wEAID,OAAM,IAAIC,yCAAsB,oDAAoD;AAGrF,WAAQ,MAAM,qBAAqB,MAAM;AACzC,SAAM;;;;;;CAOR,MAAM,mBAAmB,EACxB,cACA,yBAC6B;EAC7B,MAAM,UAAU,KAAK,QAAQ,aAAa;EAC1C,MAAM,OAAO,KAAK,MAAM,IAAI,QAAQ;AACpC,MAAI,KACH,KAAI;AAEH,QAAK,UAAU,KAAK,KAAK,SAAS;AAClC,SAAM,KAAK;UACJ,WAEE;AACT,QAAK,MAAM,OAAO,QAAQ;;AAM5B,kDACqB,sBAAsB,IAC1C,sBAAsB,WAAW,SAChC;GACD,MAAM,UAAU,KAAK,kCAAkC,IAAI,aAAa;AAExE,OAAI,SAAS;AACZ,iBAAa,QAAQ,QAAQ;AAC7B,YAAQ,SAAS;AACjB,SAAK,kCAAkC,OAAO,aAAa;;GAG5D,IAAI,wBAAoC;GACxC,MAAM,iBAAiB,IAAI,SAAe,YAAY;AACrD,sBAAkB;KACjB;GAEF,MAAM,UAAU,iBAAiB;AAChC,SAAK,kCAAkC,OAAO,aAAa;AAC3D,qBAAiB;MACf,KAAK,cAAc,gBAAgB;AAEtC,QAAK,kCAAkC,IAAI,cAAc;IACxD;IACA,SAAS;IACT,CAAC;AAEF,SAAM;;;;;;CAOR,MAAM,kBAAkB,EACvB,cACA,WACA,OACA,SACA,SACA,YAC4B;AAG5B,OADoB,UAAU,YAAY,QAAQ,OAC9B,EACnB;EAED,MAAM,iBAAiB,MAAM,OAAO,SAAS,QAAQ;EACrD,MAAM,UAAU,IAAIF,mCACnB,aACA,CAAC,6BAA6B,WAAW,eAAe;AAEzD,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAqCF,MAAa,SAAS,MAAqC;AAC1D,kDACqB,KAAK,kBAAkB,IAC3C,KAAK,kBAAkB,WAAW,QAElC;AAGD,SAAO,KAAK,qBAAqB,KAAK,cAAc,KAAK,SAAS;;;;;CAMnE,MAAM,qBAAqB,MAAmC;AAC7D,SAAO,IAAI,SAAe,YAAY;AACrC,oBAAiB;AAChB,aAAS;MACP,KAAK,cAAc,gBAAgB;IACrC;;CAGH,MAAM,oBAAoB,MAAkC;AAC3D,MAAI,KAAK,SAAS,UAAU,IAAI,KAAK,aAAa,CAAE;AAEpD,OAAK,IAAI,YAAY,KAAK,OAAO,KAAK,aAAa,GAAG,UAAe;AACpE,OAAI,MACH,SAAQ,MAAM,MAAM;IAEpB;;CAGH,MAAM,yBAAyB,MAAuC;EACrE,MAAM,UAAU,IAAIA,mCACnB,KAAK,aACL,CAAC,wBAAwB,KAAK,QAAQ;AAEvC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,KAAK,aAAa,EAC9B,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAMF,MAAM,YAAY;AACjB,QAAM,KAAK,QAAQ,MAAM;AACzB,OAAK,IAAI,WAAW,MAAM;AAC1B,OAAK,IAAI,WAAW,MAAM"}
|
|
1
|
+
{"version":3,"file":"hocuspocus-redis.cjs","names":["crypto","IncomingMessage","MessageReceiver","RedisClient","Redlock","MessageType","messageYjsSyncStep2","messageYjsUpdate","OutgoingMessage","ExecutionError","SkipFurtherHooksError"],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tafterLoadDocumentPayload,\n\tafterStoreDocumentPayload,\n\tafterUnloadDocumentPayload,\n\tbeforeBroadcastStatelessPayload,\n\tbeforeUnloadDocumentPayload,\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tonAwarenessUpdatePayload,\n\tonChangePayload,\n\tonConfigurePayload,\n\tonStoreDocumentPayload,\n\tRedisTransactionOrigin,\n} from \"@hocuspocus/server\";\nimport { SkipFurtherHooksError } from \"@hocuspocus/common\";\nimport {\n\tIncomingMessage,\n\tisTransactionOrigin,\n\tMessageReceiver,\n\tMessageType,\n\tOutgoingMessage,\n} from \"@hocuspocus/server\";\nimport {\n\tmessageYjsSyncStep2,\n\tmessageYjsUpdate,\n} from \"y-protocols/sync\";\nimport {\n\tExecutionError,\n\ttype ExecutionResult,\n\ttype Lock,\n\tRedlock,\n} from \"@sesamecare-oss/redlock\";\nimport type {\n\tCluster,\n\tClusterNode,\n\tClusterOptions,\n\tRedisOptions,\n} from \"ioredis\";\nimport RedisClient from \"ioredis\";\nexport type RedisInstance = RedisClient | Cluster;\nexport interface Configuration {\n\t/**\n\t * Redis port\n\t */\n\tport: number;\n\t/**\n\t * Redis host\n\t */\n\thost: string;\n\t/**\n\t * Redis Cluster\n\t */\n\tnodes?: ClusterNode[];\n\t/**\n\t * Duplicate from an existed Redis instance\n\t */\n\tredis?: RedisInstance;\n\t/**\n\t * Redis instance creator\n\t */\n\tcreateClient?: () => RedisInstance;\n\t/**\n\t * Options passed directly to Redis constructor\n\t *\n\t * https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options\n\t */\n\toptions?: ClusterOptions | RedisOptions;\n\t/**\n\t * An unique instance name, required to filter messages in Redis.\n\t * If none is provided an unique id is generated.\n\t */\n\tidentifier: string;\n\t/**\n\t * Namespace for Redis keys, if none is provided 'hocuspocus' is used\n\t */\n\tprefix: string;\n\t/**\n\t * The maximum time for the Redis lock in ms (in case it can’t be released).\n\t */\n\tlockTimeout: number;\n\t/**\n\t * A delay before onDisconnect is executed. This allows last minute updates'\n\t * sync messages to be received by the subscription before it's closed.\n\t */\n\tdisconnectDelay: number;\n\t/**\n\t * The maximum time (in ms) `afterLoadDocument` waits for another instance to\n\t * send its state when loading a document that is already open elsewhere.\n\t *\n\t * When a document is loaded on this instance while another instance already\n\t * has it open (and possibly modified in memory, not yet persisted), we\n\t * publish a SyncStep1 and block the load until the peer replies with its\n\t * state (SyncStep2) — or this timeout elapses. This guarantees that callers\n\t * (most importantly a `DirectConnection`) observe the latest collaborative\n\t * state instead of just what was loaded from storage.\n\t *\n\t * If no other instance is subscribed to the document, the wait is skipped\n\t * entirely, so a cold/lone load is not delayed.\n\t *\n\t * Set to `0` to disable the wait and restore the legacy fire-and-forget\n\t * behavior.\n\t */\n\tawaitInitialSyncTimeout: number;\n}\n\nexport class Redis implements Extension {\n\t/**\n\t * Make sure to give that extension a higher priority, so\n\t * the `onStoreDocument` hook is able to intercept the chain,\n\t * before documents are stored to the database.\n\t */\n\tpriority = 1000;\n\n\tconfiguration: Configuration = {\n\t\tport: 6379,\n\t\thost: \"127.0.0.1\",\n\t\tprefix: \"hocuspocus\",\n\t\tidentifier: `host-${crypto.randomUUID()}`,\n\t\tlockTimeout: 1000,\n\t\tdisconnectDelay: 1000,\n\t\tawaitInitialSyncTimeout: 1000,\n\t};\n\n\tredisTransactionOrigin: RedisTransactionOrigin = {\n\t\tsource: \"redis\",\n\t};\n\n\tpub: RedisInstance;\n\n\tsub: RedisInstance;\n\n\tinstance!: Hocuspocus;\n\n\tredlock: Redlock;\n\n\tlocks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();\n\n\tmessagePrefix: Buffer;\n\n\tprivate pendingAfterStoreDocumentResolves = new Map<\n\t\tstring,\n\t\t{ timeout: NodeJS.Timeout; resolve: () => void }\n\t>();\n\n\t/**\n\t * Documents this instance has loaded and subscribed to, keyed by name.\n\t *\n\t * This mirrors `instance.documents` but is populated at the very start of\n\t * `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in\n\t * `instance.documents` (which only happens once loading, including this\n\t * hook, has fully resolved). That early registration is what lets\n\t * `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still\n\t * blocking the load on it.\n\t */\n\tprivate documents = new Map<string, Document>();\n\n\t/**\n\t * Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.\n\t * Invoked by `handleIncomingMessage` as soon as a peer's state arrives.\n\t */\n\tprivate pendingInitialSyncResolves = new Map<string, () => void>();\n\n\tpublic constructor(configuration: Partial<Configuration>) {\n\t\tthis.configuration = {\n\t\t\t...this.configuration,\n\t\t\t...configuration,\n\t\t};\n\n\t\t// Create Redis instance\n\t\tconst { port, host, options, nodes, redis, createClient } =\n\t\t\tthis.configuration;\n\n\t\tif (typeof createClient === \"function\") {\n\t\t\tthis.pub = createClient();\n\t\t\tthis.sub = createClient();\n\t\t} else if (redis) {\n\t\t\tthis.pub = redis.duplicate();\n\t\t\tthis.sub = redis.duplicate();\n\t\t} else if (nodes && nodes.length > 0) {\n\t\t\tthis.pub = new RedisClient.Cluster(nodes, options);\n\t\t\tthis.sub = new RedisClient.Cluster(nodes, options);\n\t\t} else {\n\t\t\tthis.pub = new RedisClient(port, host, options ?? {});\n\t\t\tthis.sub = new RedisClient(port, host, options ?? {});\n\t\t}\n\n\t\tthis.sub.on(\"messageBuffer\", this.handleIncomingMessage);\n\n\t\tthis.redlock = new Redlock([this.pub], {\n\t\t\tretryCount: 0,\n\t\t});\n\n\t\tconst identifierBuffer = Buffer.from(\n\t\t\tthis.configuration.identifier,\n\t\t\t\"utf-8\",\n\t\t);\n\t\tthis.messagePrefix = Buffer.concat([\n\t\t\tBuffer.from([identifierBuffer.length]),\n\t\t\tidentifierBuffer,\n\t\t]);\n\t}\n\n\tasync onConfigure({ instance }: onConfigurePayload) {\n\t\tthis.instance = instance;\n\t}\n\n\tprivate getKey(documentName: string) {\n\t\treturn `${this.configuration.prefix}:${documentName}`;\n\t}\n\n\tprivate pubKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate subKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate lockKey(documentName: string) {\n\t\treturn `${this.getKey(documentName)}:lock`;\n\t}\n\n\tprivate encodeMessage(message: Uint8Array) {\n\t\treturn Buffer.concat([this.messagePrefix, Buffer.from(message)]);\n\t}\n\n\tprivate decodeMessage(buffer: Buffer) {\n\t\tconst identifierLength = buffer[0];\n\t\tconst identifier = buffer.toString(\"utf-8\", 1, identifierLength + 1);\n\n\t\treturn [identifier, buffer.slice(identifierLength + 1)];\n\t}\n\n\t/**\n\t * Once a document is loaded, subscribe to the channel in Redis.\n\t */\n\tpublic async afterLoadDocument({\n\t\tdocumentName,\n\t\tdocument,\n\t}: afterLoadDocumentPayload) {\n\t\t// Track the document locally right away so `handleIncomingMessage` can\n\t\t// apply inbound messages to it even before Hocuspocus has finished\n\t\t// loading it (it only lands in `instance.documents` after this hook\n\t\t// resolves). Without this, the SyncStep2 reply we request below would be\n\t\t// dropped while we wait for it.\n\t\tthis.documents.set(documentName, document);\n\n\t\ttry {\n\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t// On document creation the node will connect to pub and sub channels\n\t\t\t\t// for the document.\n\t\t\t\tthis.sub.subscribe(this.subKey(documentName), (error: any) => {\n\t\t\t\t\tif (error) {\n\t\t\t\t\t\treject(error);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tresolve();\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tawait this.syncInitialStateFromPeers(documentName, document);\n\t\t} catch (error) {\n\t\t\t// Loading failed: stop tracking the document so future messages aren't\n\t\t\t// applied to a doc that never finished loading, release any pending\n\t\t\t// wait, and best-effort unsubscribe (afterUnloadDocument does not run\n\t\t\t// for a load that never completed).\n\t\t\tthis.documents.delete(documentName);\n\t\t\tthis.pendingInitialSyncResolves.delete(documentName);\n\t\t\tthis.sub.unsubscribe(this.subKey(documentName), () => {});\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/**\n\t * Announce our state to other instances and — when another instance already\n\t * has this document open — block until it has sent us its state (or the\n\t * configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.\n\t */\n\tprivate async syncInitialStateFromPeers(\n\t\tdocumentName: string,\n\t\tdocument: Document,\n\t) {\n\t\tconst timeout = this.configuration.awaitInitialSyncTimeout;\n\n\t\tconst waitForPeers =\n\t\t\ttimeout > 0 && (await this.hasOtherSubscribers(documentName));\n\n\t\t// Announce our state and request awareness. Awaited so a Redis publish\n\t\t// failure surfaces as a load error instead of an unhandled rejection.\n\t\tawait Promise.all([\n\t\t\tthis.publishFirstSyncStep(documentName, document),\n\t\t\tthis.requestAwarenessFromOtherInstances(documentName),\n\t\t]);\n\n\t\t// Skip the wait when it's disabled or nobody else has the document open:\n\t\t// there is no peer to sync from, so blocking would only add latency.\n\t\tif (!waitForPeers) {\n\t\t\treturn;\n\t\t}\n\n\t\t// A peer has the document open. Block until it replies with its state\n\t\t// (a SyncStep2/Update applied via handleIncomingMessage resolves this) or\n\t\t// the timeout elapses. The reply cannot arrive before the SyncStep1 we\n\t\t// just published is delivered, so registering the resolver now — right\n\t\t// after the awaited publish, before yielding to the event loop — cannot\n\t\t// miss it.\n\t\tawait new Promise<void>((resolve) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.pendingInitialSyncResolves.delete(documentName);\n\t\t\t\tresolve();\n\t\t\t}, timeout);\n\n\t\t\tthis.pendingInitialSyncResolves.set(documentName, () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tthis.pendingInitialSyncResolves.delete(documentName);\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Whether another instance is currently subscribed to a document's channel.\n\t * We are subscribed ourselves, so a subscriber count greater than one means\n\t * at least one peer has the document open.\n\t */\n\tprivate async hasOtherSubscribers(documentName: string): Promise<boolean> {\n\t\ttry {\n\t\t\tconst result = (await this.pub.pubsub(\n\t\t\t\t\"NUMSUB\",\n\t\t\t\tthis.subKey(documentName),\n\t\t\t)) as [string, number | string];\n\t\t\treturn Number(result?.[1] ?? 0) > 1;\n\t\t} catch {\n\t\t\t// NUMSUB may be unavailable in some setups (e.g. certain clusters).\n\t\t\t// Assume peers might exist so correctness is preserved; the timeout\n\t\t\t// still bounds the wait.\n\t\t\treturn true;\n\t\t}\n\t}\n\n\t/**\n\t * Resolve a pending `afterLoadDocument` wait once a peer's state has been\n\t * applied.\n\t */\n\tprivate resolveInitialSync(documentName: string) {\n\t\tthis.pendingInitialSyncResolves.get(documentName)?.();\n\t}\n\n\t/**\n\t * Peek whether a message carries another instance's document state\n\t * (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only\n\t * *requests* state, so it must not release an initial-sync wait.\n\t */\n\tprivate messageCarriesPeerState(message: IncomingMessage): boolean {\n\t\tconst { pos } = message.decoder;\n\t\ttry {\n\t\t\tconst type = message.readVarUint();\n\t\t\tif (type !== MessageType.Sync && type !== MessageType.SyncReply) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst syncType = message.readVarUint();\n\t\t\treturn (\n\t\t\t\tsyncType === messageYjsSyncStep2 || syncType === messageYjsUpdate\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tmessage.decoder.pos = pos;\n\t\t}\n\t}\n\n\t/**\n\t * Publish the first sync step through Redis.\n\t */\n\tprivate async publishFirstSyncStep(documentName: string, document: Document) {\n\t\tconst syncMessage = new OutgoingMessage(documentName)\n\t\t\t.createSyncMessage()\n\t\t\t.writeFirstSyncStepFor(document);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(syncMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Let’s ask Redis who is connected already.\n\t */\n\tprivate async requestAwarenessFromOtherInstances(documentName: string) {\n\t\tconst awarenessMessage = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).writeQueryAwareness();\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(awarenessMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Before the document is stored, make sure to set a lock in Redis.\n\t * That’s meant to avoid conflicts with other instances trying to store the document.\n\t */\n\tasync onStoreDocument({ documentName }: onStoreDocumentPayload) {\n\t\t// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,\n\t\t// to avoid conflict with other instances storing the same document.\n\t\tconst resource = this.lockKey(documentName);\n\t\tconst ttl = this.configuration.lockTimeout;\n\t\ttry {\n\t\t\tconst lock = await this.redlock.acquire([resource], ttl);\n\t\t\tconst oldLock = this.locks.get(resource);\n\t\t\tif (oldLock?.release) {\n\t\t\t\tawait oldLock.release;\n\t\t\t}\n\t\t\tthis.locks.set(resource, { lock });\n\t\t} catch (error: any) {\n\t\t\t//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349\n\t\t\tif (\n\t\t\t\terror instanceof ExecutionError &&\n\t\t\t\terror.message ===\n\t\t\t\t\t\"The operation was unable to achieve a quorum during its retry window.\"\n\t\t\t) {\n\t\t\t\t// Expected behavior: Could not acquire lock, another instance locked it already.\n\t\t\t\t// Skip further hooks and retry — the data is safe on the other instance.\n\t\t\t\tthrow new SkipFurtherHooksError(\"Another instance is already storing this document\");\n\t\t\t}\n\t\t\t//unexpected error\n\t\t\tconsole.error(\"unexpected error:\", error);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/**\n\t * Release the Redis lock, so other instances can store documents.\n\t */\n\tasync afterStoreDocument({\n\t\tdocumentName,\n\t\tlastTransactionOrigin,\n\t}: afterStoreDocumentPayload) {\n\t\tconst lockKey = this.lockKey(documentName);\n\t\tconst lock = this.locks.get(lockKey);\n\t\tif (lock) {\n\t\t\ttry {\n\t\t\t\t// Always try to unlock and clean up the lock\n\t\t\t\tlock.release = lock.lock.release();\n\t\t\t\tawait lock.release;\n\t\t\t} catch {\n\t\t\t\t// Lock will expire on its own after timeout\n\t\t\t} finally {\n\t\t\t\tthis.locks.delete(lockKey);\n\t\t\t}\n\t\t}\n\n\t\t// 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.\n\t\t// for provider connections, this usually happens in the onDisconnect hook\n\t\tif (\n\t\t\tisTransactionOrigin(lastTransactionOrigin) &&\n\t\t\tlastTransactionOrigin.source === \"local\"\n\t\t) {\n\t\t\tconst pending = this.pendingAfterStoreDocumentResolves.get(documentName);\n\n\t\t\tif (pending) {\n\t\t\t\tclearTimeout(pending.timeout);\n\t\t\t\tpending.resolve();\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t}\n\n\t\t\tlet resolveFunction: () => void = () => {};\n\t\t\tconst delayedPromise = new Promise<void>((resolve) => {\n\t\t\t\tresolveFunction = resolve;\n\t\t\t});\n\n\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t\tresolveFunction();\n\t\t\t}, this.configuration.disconnectDelay);\n\n\t\t\tthis.pendingAfterStoreDocumentResolves.set(documentName, {\n\t\t\t\ttimeout,\n\t\t\t\tresolve: resolveFunction,\n\t\t\t});\n\n\t\t\tawait delayedPromise;\n\t\t}\n\t}\n\n\t/**\n\t * Handle awareness update messages received directly by this Hocuspocus instance.\n\t */\n\tasync onAwarenessUpdate({\n\t\tdocumentName,\n\t\tawareness,\n\t\tadded,\n\t\tupdated,\n\t\tremoved,\n\t\tdocument,\n\t}: onAwarenessUpdatePayload) {\n\t\t// Do not publish if there is no connection: it fixes this issue: \"https://github.com/ueberdosis/hocuspocus/issues/1027\"\n\t\tconst connections = document?.connections.size || 0;\n\t\tif (connections === 0) {\n\t\t\treturn; // avoids exception\n\t\t}\n\t\tconst changedClients = added.concat(updated, removed);\n\t\tconst message = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).createAwarenessUpdateMessage(awareness, changedClients);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Handle incoming messages published on subscribed document channels.\n\t * Note that this will also include messages from ourselves as it is not possible\n\t * in Redis to filter these.\n\t */\n\tprivate handleIncomingMessage = async (channel: Buffer, data: Buffer) => {\n\t\tconst [identifier, messageBuffer] = this.decodeMessage(data);\n\n\t\tif (identifier === this.configuration.identifier) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst message = new IncomingMessage(messageBuffer);\n\t\tconst documentName = message.readVarString();\n\t\tmessage.writeVarString(documentName);\n\n\t\t// Prefer the extension-local map: it also contains documents that are\n\t\t// still loading (not yet in `instance.documents`), which is exactly when\n\t\t// a blocking `afterLoadDocument` needs to receive the peer's reply.\n\t\tconst document =\n\t\t\tthis.documents.get(documentName) ??\n\t\t\tthis.instance.documents.get(documentName);\n\n\t\tif (!document) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst carriesPeerState = this.messageCarriesPeerState(message);\n\n\t\tconst receiver = new MessageReceiver(message, this.redisTransactionOrigin);\n\t\tawait receiver.apply(document, undefined, (reply) => {\n\t\t\treturn this.pub.publish(\n\t\t\t\tthis.pubKey(document.name),\n\t\t\t\tthis.encodeMessage(reply),\n\t\t\t);\n\t\t});\n\n\t\t// A peer answered our SyncStep1 with its state — the document has now\n\t\t// caught up, so any blocking initial-sync wait can be released.\n\t\tif (carriesPeerState) {\n\t\t\tthis.resolveInitialSync(documentName);\n\t\t}\n\t};\n\n\t/**\n\t * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.\n\t */\n\tpublic async onChange(data: onChangePayload): Promise<any> {\n\t\tif (\n\t\t\tisTransactionOrigin(data.transactionOrigin) &&\n\t\t\tdata.transactionOrigin.source === \"redis\"\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\treturn this.publishFirstSyncStep(data.documentName, data.document);\n\t}\n\n\t/**\n\t * Delay unloading to allow syncs to finish\n\t */\n\tasync beforeUnloadDocument(data: beforeUnloadDocumentPayload) {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tsetTimeout(() => {\n\t\t\t\tresolve();\n\t\t\t}, this.configuration.disconnectDelay);\n\t\t});\n\t}\n\n\tasync afterUnloadDocument(data: afterUnloadDocumentPayload) {\n\t\tif (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)\n\n\t\tthis.resolveInitialSync(data.documentName);\n\t\tthis.documents.delete(data.documentName);\n\n\t\tthis.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {\n\t\t\tif (error) {\n\t\t\t\tconsole.error(error);\n\t\t\t}\n\t\t});\n\t}\n\n\tasync beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {\n\t\tconst message = new OutgoingMessage(\n\t\t\tdata.documentName,\n\t\t).writeBroadcastStateless(data.payload);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(data.documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Kill the Redlock connection immediately.\n\t */\n\tasync onDestroy() {\n\t\tawait this.redlock.quit();\n\t\tthis.pub.disconnect(false);\n\t\tthis.sub.disconnect(false);\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { SkipFurtherHooksError } from "@hocuspocus/common";
|
|
3
|
-
import { IncomingMessage, MessageReceiver, OutgoingMessage, isTransactionOrigin } from "@hocuspocus/server";
|
|
3
|
+
import { IncomingMessage, MessageReceiver, MessageType, OutgoingMessage, isTransactionOrigin } from "@hocuspocus/server";
|
|
4
|
+
import { messageYjsSyncStep2, messageYjsUpdate } from "y-protocols/sync";
|
|
4
5
|
import { ExecutionError, Redlock } from "@sesamecare-oss/redlock";
|
|
5
6
|
import RedisClient from "ioredis";
|
|
6
7
|
|
|
@@ -14,22 +15,27 @@ var Redis = class {
|
|
|
14
15
|
prefix: "hocuspocus",
|
|
15
16
|
identifier: `host-${crypto.randomUUID()}`,
|
|
16
17
|
lockTimeout: 1e3,
|
|
17
|
-
disconnectDelay: 1e3
|
|
18
|
+
disconnectDelay: 1e3,
|
|
19
|
+
awaitInitialSyncTimeout: 1e3
|
|
18
20
|
};
|
|
19
21
|
this.redisTransactionOrigin = { source: "redis" };
|
|
20
22
|
this.locks = /* @__PURE__ */ new Map();
|
|
21
23
|
this.pendingAfterStoreDocumentResolves = /* @__PURE__ */ new Map();
|
|
24
|
+
this.documents = /* @__PURE__ */ new Map();
|
|
25
|
+
this.pendingInitialSyncResolves = /* @__PURE__ */ new Map();
|
|
22
26
|
this.handleIncomingMessage = async (channel, data) => {
|
|
23
27
|
const [identifier, messageBuffer] = this.decodeMessage(data);
|
|
24
28
|
if (identifier === this.configuration.identifier) return;
|
|
25
29
|
const message = new IncomingMessage(messageBuffer);
|
|
26
30
|
const documentName = message.readVarString();
|
|
27
31
|
message.writeVarString(documentName);
|
|
28
|
-
const document = this.instance.documents.get(documentName);
|
|
32
|
+
const document = this.documents.get(documentName) ?? this.instance.documents.get(documentName);
|
|
29
33
|
if (!document) return;
|
|
34
|
+
const carriesPeerState = this.messageCarriesPeerState(message);
|
|
30
35
|
await new MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
|
|
31
36
|
return this.pub.publish(this.pubKey(document.name), this.encodeMessage(reply));
|
|
32
37
|
});
|
|
38
|
+
if (carriesPeerState) this.resolveInitialSync(documentName);
|
|
33
39
|
};
|
|
34
40
|
this.configuration = {
|
|
35
41
|
...this.configuration,
|
|
@@ -80,19 +86,86 @@ var Redis = class {
|
|
|
80
86
|
* Once a document is loaded, subscribe to the channel in Redis.
|
|
81
87
|
*/
|
|
82
88
|
async afterLoadDocument({ documentName, document }) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
this.documents.set(documentName, document);
|
|
90
|
+
try {
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
this.sub.subscribe(this.subKey(documentName), (error) => {
|
|
93
|
+
if (error) {
|
|
94
|
+
reject(error);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
resolve();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
await this.syncInitialStateFromPeers(documentName, document);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
this.documents.delete(documentName);
|
|
103
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
104
|
+
this.sub.unsubscribe(this.subKey(documentName), () => {});
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Announce our state to other instances and — when another instance already
|
|
110
|
+
* has this document open — block until it has sent us its state (or the
|
|
111
|
+
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
|
|
112
|
+
*/
|
|
113
|
+
async syncInitialStateFromPeers(documentName, document) {
|
|
114
|
+
const timeout = this.configuration.awaitInitialSyncTimeout;
|
|
115
|
+
const waitForPeers = timeout > 0 && await this.hasOtherSubscribers(documentName);
|
|
116
|
+
await Promise.all([this.publishFirstSyncStep(documentName, document), this.requestAwarenessFromOtherInstances(documentName)]);
|
|
117
|
+
if (!waitForPeers) return;
|
|
118
|
+
await new Promise((resolve) => {
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
121
|
+
resolve();
|
|
122
|
+
}, timeout);
|
|
123
|
+
this.pendingInitialSyncResolves.set(documentName, () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
126
|
+
resolve();
|
|
92
127
|
});
|
|
93
128
|
});
|
|
94
129
|
}
|
|
95
130
|
/**
|
|
131
|
+
* Whether another instance is currently subscribed to a document's channel.
|
|
132
|
+
* We are subscribed ourselves, so a subscriber count greater than one means
|
|
133
|
+
* at least one peer has the document open.
|
|
134
|
+
*/
|
|
135
|
+
async hasOtherSubscribers(documentName) {
|
|
136
|
+
try {
|
|
137
|
+
const result = await this.pub.pubsub("NUMSUB", this.subKey(documentName));
|
|
138
|
+
return Number(result?.[1] ?? 0) > 1;
|
|
139
|
+
} catch {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
|
|
145
|
+
* applied.
|
|
146
|
+
*/
|
|
147
|
+
resolveInitialSync(documentName) {
|
|
148
|
+
this.pendingInitialSyncResolves.get(documentName)?.();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Peek whether a message carries another instance's document state
|
|
152
|
+
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
|
|
153
|
+
* *requests* state, so it must not release an initial-sync wait.
|
|
154
|
+
*/
|
|
155
|
+
messageCarriesPeerState(message) {
|
|
156
|
+
const { pos } = message.decoder;
|
|
157
|
+
try {
|
|
158
|
+
const type = message.readVarUint();
|
|
159
|
+
if (type !== MessageType.Sync && type !== MessageType.SyncReply) return false;
|
|
160
|
+
const syncType = message.readVarUint();
|
|
161
|
+
return syncType === messageYjsSyncStep2 || syncType === messageYjsUpdate;
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
} finally {
|
|
165
|
+
message.decoder.pos = pos;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
96
169
|
* Publish the first sync step through Redis.
|
|
97
170
|
*/
|
|
98
171
|
async publishFirstSyncStep(documentName, document) {
|
|
@@ -186,6 +259,8 @@ var Redis = class {
|
|
|
186
259
|
}
|
|
187
260
|
async afterUnloadDocument(data) {
|
|
188
261
|
if (data.instance.documents.has(data.documentName)) return;
|
|
262
|
+
this.resolveInitialSync(data.documentName);
|
|
263
|
+
this.documents.delete(data.documentName);
|
|
189
264
|
this.sub.unsubscribe(this.subKey(data.documentName), (error) => {
|
|
190
265
|
if (error) console.error(error);
|
|
191
266
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hocuspocus-redis.esm.js","names":[],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tafterLoadDocumentPayload,\n\tafterStoreDocumentPayload,\n\tafterUnloadDocumentPayload,\n\tbeforeBroadcastStatelessPayload,\n\tbeforeUnloadDocumentPayload,\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tonAwarenessUpdatePayload,\n\tonChangePayload,\n\tonConfigurePayload,\n\tonStoreDocumentPayload,\n\tRedisTransactionOrigin,\n} from \"@hocuspocus/server\";\nimport { SkipFurtherHooksError } from \"@hocuspocus/common\";\nimport {\n\tIncomingMessage,\n\tisTransactionOrigin,\n\tMessageReceiver,\n\tOutgoingMessage,\n} from \"@hocuspocus/server\";\nimport {\n\tExecutionError,\n\ttype ExecutionResult,\n\ttype Lock,\n\tRedlock,\n} from \"@sesamecare-oss/redlock\";\nimport type {\n\tCluster,\n\tClusterNode,\n\tClusterOptions,\n\tRedisOptions,\n} from \"ioredis\";\nimport RedisClient from \"ioredis\";\nexport type RedisInstance = RedisClient | Cluster;\nexport interface Configuration {\n\t/**\n\t * Redis port\n\t */\n\tport: number;\n\t/**\n\t * Redis host\n\t */\n\thost: string;\n\t/**\n\t * Redis Cluster\n\t */\n\tnodes?: ClusterNode[];\n\t/**\n\t * Duplicate from an existed Redis instance\n\t */\n\tredis?: RedisInstance;\n\t/**\n\t * Redis instance creator\n\t */\n\tcreateClient?: () => RedisInstance;\n\t/**\n\t * Options passed directly to Redis constructor\n\t *\n\t * https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options\n\t */\n\toptions?: ClusterOptions | RedisOptions;\n\t/**\n\t * An unique instance name, required to filter messages in Redis.\n\t * If none is provided an unique id is generated.\n\t */\n\tidentifier: string;\n\t/**\n\t * Namespace for Redis keys, if none is provided 'hocuspocus' is used\n\t */\n\tprefix: string;\n\t/**\n\t * The maximum time for the Redis lock in ms (in case it can’t be released).\n\t */\n\tlockTimeout: number;\n\t/**\n\t * A delay before onDisconnect is executed. This allows last minute updates'\n\t * sync messages to be received by the subscription before it's closed.\n\t */\n\tdisconnectDelay: number;\n}\n\nexport class Redis implements Extension {\n\t/**\n\t * Make sure to give that extension a higher priority, so\n\t * the `onStoreDocument` hook is able to intercept the chain,\n\t * before documents are stored to the database.\n\t */\n\tpriority = 1000;\n\n\tconfiguration: Configuration = {\n\t\tport: 6379,\n\t\thost: \"127.0.0.1\",\n\t\tprefix: \"hocuspocus\",\n\t\tidentifier: `host-${crypto.randomUUID()}`,\n\t\tlockTimeout: 1000,\n\t\tdisconnectDelay: 1000,\n\t};\n\n\tredisTransactionOrigin: RedisTransactionOrigin = {\n\t\tsource: \"redis\",\n\t};\n\n\tpub: RedisInstance;\n\n\tsub: RedisInstance;\n\n\tinstance!: Hocuspocus;\n\n\tredlock: Redlock;\n\n\tlocks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();\n\n\tmessagePrefix: Buffer;\n\n\tprivate pendingAfterStoreDocumentResolves = new Map<\n\t\tstring,\n\t\t{ timeout: NodeJS.Timeout; resolve: () => void }\n\t>();\n\n\tpublic constructor(configuration: Partial<Configuration>) {\n\t\tthis.configuration = {\n\t\t\t...this.configuration,\n\t\t\t...configuration,\n\t\t};\n\n\t\t// Create Redis instance\n\t\tconst { port, host, options, nodes, redis, createClient } =\n\t\t\tthis.configuration;\n\n\t\tif (typeof createClient === \"function\") {\n\t\t\tthis.pub = createClient();\n\t\t\tthis.sub = createClient();\n\t\t} else if (redis) {\n\t\t\tthis.pub = redis.duplicate();\n\t\t\tthis.sub = redis.duplicate();\n\t\t} else if (nodes && nodes.length > 0) {\n\t\t\tthis.pub = new RedisClient.Cluster(nodes, options);\n\t\t\tthis.sub = new RedisClient.Cluster(nodes, options);\n\t\t} else {\n\t\t\tthis.pub = new RedisClient(port, host, options ?? {});\n\t\t\tthis.sub = new RedisClient(port, host, options ?? {});\n\t\t}\n\n\t\tthis.sub.on(\"messageBuffer\", this.handleIncomingMessage);\n\n\t\tthis.redlock = new Redlock([this.pub], {\n\t\t\tretryCount: 0,\n\t\t});\n\n\t\tconst identifierBuffer = Buffer.from(\n\t\t\tthis.configuration.identifier,\n\t\t\t\"utf-8\",\n\t\t);\n\t\tthis.messagePrefix = Buffer.concat([\n\t\t\tBuffer.from([identifierBuffer.length]),\n\t\t\tidentifierBuffer,\n\t\t]);\n\t}\n\n\tasync onConfigure({ instance }: onConfigurePayload) {\n\t\tthis.instance = instance;\n\t}\n\n\tprivate getKey(documentName: string) {\n\t\treturn `${this.configuration.prefix}:${documentName}`;\n\t}\n\n\tprivate pubKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate subKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate lockKey(documentName: string) {\n\t\treturn `${this.getKey(documentName)}:lock`;\n\t}\n\n\tprivate encodeMessage(message: Uint8Array) {\n\t\treturn Buffer.concat([this.messagePrefix, Buffer.from(message)]);\n\t}\n\n\tprivate decodeMessage(buffer: Buffer) {\n\t\tconst identifierLength = buffer[0];\n\t\tconst identifier = buffer.toString(\"utf-8\", 1, identifierLength + 1);\n\n\t\treturn [identifier, buffer.slice(identifierLength + 1)];\n\t}\n\n\t/**\n\t * Once a document is loaded, subscribe to the channel in Redis.\n\t */\n\tpublic async afterLoadDocument({\n\t\tdocumentName,\n\t\tdocument,\n\t}: afterLoadDocumentPayload) {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\t// On document creation the node will connect to pub and sub channels\n\t\t\t// for the document.\n\t\t\tthis.sub.subscribe(this.subKey(documentName), async (error: any) => {\n\t\t\t\tif (error) {\n\t\t\t\t\treject(error);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tthis.publishFirstSyncStep(documentName, document);\n\t\t\t\tthis.requestAwarenessFromOtherInstances(documentName);\n\n\t\t\t\tresolve(undefined);\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Publish the first sync step through Redis.\n\t */\n\tprivate async publishFirstSyncStep(documentName: string, document: Document) {\n\t\tconst syncMessage = new OutgoingMessage(documentName)\n\t\t\t.createSyncMessage()\n\t\t\t.writeFirstSyncStepFor(document);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(syncMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Let’s ask Redis who is connected already.\n\t */\n\tprivate async requestAwarenessFromOtherInstances(documentName: string) {\n\t\tconst awarenessMessage = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).writeQueryAwareness();\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(awarenessMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Before the document is stored, make sure to set a lock in Redis.\n\t * That’s meant to avoid conflicts with other instances trying to store the document.\n\t */\n\tasync onStoreDocument({ documentName }: onStoreDocumentPayload) {\n\t\t// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,\n\t\t// to avoid conflict with other instances storing the same document.\n\t\tconst resource = this.lockKey(documentName);\n\t\tconst ttl = this.configuration.lockTimeout;\n\t\ttry {\n\t\t\tconst lock = await this.redlock.acquire([resource], ttl);\n\t\t\tconst oldLock = this.locks.get(resource);\n\t\t\tif (oldLock?.release) {\n\t\t\t\tawait oldLock.release;\n\t\t\t}\n\t\t\tthis.locks.set(resource, { lock });\n\t\t} catch (error: any) {\n\t\t\t//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349\n\t\t\tif (\n\t\t\t\terror instanceof ExecutionError &&\n\t\t\t\terror.message ===\n\t\t\t\t\t\"The operation was unable to achieve a quorum during its retry window.\"\n\t\t\t) {\n\t\t\t\t// Expected behavior: Could not acquire lock, another instance locked it already.\n\t\t\t\t// Skip further hooks and retry — the data is safe on the other instance.\n\t\t\t\tthrow new SkipFurtherHooksError(\"Another instance is already storing this document\");\n\t\t\t}\n\t\t\t//unexpected error\n\t\t\tconsole.error(\"unexpected error:\", error);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/**\n\t * Release the Redis lock, so other instances can store documents.\n\t */\n\tasync afterStoreDocument({\n\t\tdocumentName,\n\t\tlastTransactionOrigin,\n\t}: afterStoreDocumentPayload) {\n\t\tconst lockKey = this.lockKey(documentName);\n\t\tconst lock = this.locks.get(lockKey);\n\t\tif (lock) {\n\t\t\ttry {\n\t\t\t\t// Always try to unlock and clean up the lock\n\t\t\t\tlock.release = lock.lock.release();\n\t\t\t\tawait lock.release;\n\t\t\t} catch {\n\t\t\t\t// Lock will expire on its own after timeout\n\t\t\t} finally {\n\t\t\t\tthis.locks.delete(lockKey);\n\t\t\t}\n\t\t}\n\n\t\t// 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.\n\t\t// for provider connections, this usually happens in the onDisconnect hook\n\t\tif (\n\t\t\tisTransactionOrigin(lastTransactionOrigin) &&\n\t\t\tlastTransactionOrigin.source === \"local\"\n\t\t) {\n\t\t\tconst pending = this.pendingAfterStoreDocumentResolves.get(documentName);\n\n\t\t\tif (pending) {\n\t\t\t\tclearTimeout(pending.timeout);\n\t\t\t\tpending.resolve();\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t}\n\n\t\t\tlet resolveFunction: () => void = () => {};\n\t\t\tconst delayedPromise = new Promise<void>((resolve) => {\n\t\t\t\tresolveFunction = resolve;\n\t\t\t});\n\n\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t\tresolveFunction();\n\t\t\t}, this.configuration.disconnectDelay);\n\n\t\t\tthis.pendingAfterStoreDocumentResolves.set(documentName, {\n\t\t\t\ttimeout,\n\t\t\t\tresolve: resolveFunction,\n\t\t\t});\n\n\t\t\tawait delayedPromise;\n\t\t}\n\t}\n\n\t/**\n\t * Handle awareness update messages received directly by this Hocuspocus instance.\n\t */\n\tasync onAwarenessUpdate({\n\t\tdocumentName,\n\t\tawareness,\n\t\tadded,\n\t\tupdated,\n\t\tremoved,\n\t\tdocument,\n\t}: onAwarenessUpdatePayload) {\n\t\t// Do not publish if there is no connection: it fixes this issue: \"https://github.com/ueberdosis/hocuspocus/issues/1027\"\n\t\tconst connections = document?.connections.size || 0;\n\t\tif (connections === 0) {\n\t\t\treturn; // avoids exception\n\t\t}\n\t\tconst changedClients = added.concat(updated, removed);\n\t\tconst message = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).createAwarenessUpdateMessage(awareness, changedClients);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Handle incoming messages published on subscribed document channels.\n\t * Note that this will also include messages from ourselves as it is not possible\n\t * in Redis to filter these.\n\t */\n\tprivate handleIncomingMessage = async (channel: Buffer, data: Buffer) => {\n\t\tconst [identifier, messageBuffer] = this.decodeMessage(data);\n\n\t\tif (identifier === this.configuration.identifier) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst message = new IncomingMessage(messageBuffer);\n\t\tconst documentName = message.readVarString();\n\t\tmessage.writeVarString(documentName);\n\n\t\tconst document = this.instance.documents.get(documentName);\n\n\t\tif (!document) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst receiver = new MessageReceiver(message, this.redisTransactionOrigin);\n\t\tawait receiver.apply(document, undefined, (reply) => {\n\t\t\treturn this.pub.publish(\n\t\t\t\tthis.pubKey(document.name),\n\t\t\t\tthis.encodeMessage(reply),\n\t\t\t);\n\t\t});\n\t};\n\n\t/**\n\t * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.\n\t */\n\tpublic async onChange(data: onChangePayload): Promise<any> {\n\t\tif (\n\t\t\tisTransactionOrigin(data.transactionOrigin) &&\n\t\t\tdata.transactionOrigin.source === \"redis\"\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\treturn this.publishFirstSyncStep(data.documentName, data.document);\n\t}\n\n\t/**\n\t * Delay unloading to allow syncs to finish\n\t */\n\tasync beforeUnloadDocument(data: beforeUnloadDocumentPayload) {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tsetTimeout(() => {\n\t\t\t\tresolve();\n\t\t\t}, this.configuration.disconnectDelay);\n\t\t});\n\t}\n\n\tasync afterUnloadDocument(data: afterUnloadDocumentPayload) {\n\t\tif (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)\n\n\t\tthis.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {\n\t\t\tif (error) {\n\t\t\t\tconsole.error(error);\n\t\t\t}\n\t\t});\n\t}\n\n\tasync beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {\n\t\tconst message = new OutgoingMessage(\n\t\t\tdata.documentName,\n\t\t).writeBroadcastStateless(data.payload);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(data.documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Kill the Redlock connection immediately.\n\t */\n\tasync onDestroy() {\n\t\tawait this.redlock.quit();\n\t\tthis.pub.disconnect(false);\n\t\tthis.sub.disconnect(false);\n\t}\n}\n"],"mappings":";;;;;;;AAoFA,IAAa,QAAb,MAAwC;CAsCvC,AAAO,YAAY,eAAuC;kBAhC/C;uBAEoB;GAC9B,MAAM;GACN,MAAM;GACN,QAAQ;GACR,YAAY,QAAQ,OAAO,YAAY;GACvC,aAAa;GACb,iBAAiB;GACjB;gCAEgD,EAChD,QAAQ,SACR;+BAUO,IAAI,KAAiE;2DAIjC,IAAI,KAG7C;+BAoP6B,OAAO,SAAiB,SAAiB;GACxE,MAAM,CAAC,YAAY,iBAAiB,KAAK,cAAc,KAAK;AAE5D,OAAI,eAAe,KAAK,cAAc,WACrC;GAGD,MAAM,UAAU,IAAI,gBAAgB,cAAc;GAClD,MAAM,eAAe,QAAQ,eAAe;AAC5C,WAAQ,eAAe,aAAa;GAEpC,MAAM,WAAW,KAAK,SAAS,UAAU,IAAI,aAAa;AAE1D,OAAI,CAAC,SACJ;AAID,SADiB,IAAI,gBAAgB,SAAS,KAAK,uBAAuB,CAC3D,MAAM,UAAU,SAAY,UAAU;AACpD,WAAO,KAAK,IAAI,QACf,KAAK,OAAO,SAAS,KAAK,EAC1B,KAAK,cAAc,MAAM,CACzB;KACA;;AAxQF,OAAK,gBAAgB;GACpB,GAAG,KAAK;GACR,GAAG;GACH;EAGD,MAAM,EAAE,MAAM,MAAM,SAAS,OAAO,OAAO,iBAC1C,KAAK;AAEN,MAAI,OAAO,iBAAiB,YAAY;AACvC,QAAK,MAAM,cAAc;AACzB,QAAK,MAAM,cAAc;aACf,OAAO;AACjB,QAAK,MAAM,MAAM,WAAW;AAC5B,QAAK,MAAM,MAAM,WAAW;aAClB,SAAS,MAAM,SAAS,GAAG;AACrC,QAAK,MAAM,IAAI,YAAY,QAAQ,OAAO,QAAQ;AAClD,QAAK,MAAM,IAAI,YAAY,QAAQ,OAAO,QAAQ;SAC5C;AACN,QAAK,MAAM,IAAI,YAAY,MAAM,MAAM,WAAW,EAAE,CAAC;AACrD,QAAK,MAAM,IAAI,YAAY,MAAM,MAAM,WAAW,EAAE,CAAC;;AAGtD,OAAK,IAAI,GAAG,iBAAiB,KAAK,sBAAsB;AAExD,OAAK,UAAU,IAAI,QAAQ,CAAC,KAAK,IAAI,EAAE,EACtC,YAAY,GACZ,CAAC;EAEF,MAAM,mBAAmB,OAAO,KAC/B,KAAK,cAAc,YACnB,QACA;AACD,OAAK,gBAAgB,OAAO,OAAO,CAClC,OAAO,KAAK,CAAC,iBAAiB,OAAO,CAAC,EACtC,iBACA,CAAC;;CAGH,MAAM,YAAY,EAAE,YAAgC;AACnD,OAAK,WAAW;;CAGjB,AAAQ,OAAO,cAAsB;AACpC,SAAO,GAAG,KAAK,cAAc,OAAO,GAAG;;CAGxC,AAAQ,OAAO,cAAsB;AACpC,SAAO,KAAK,OAAO,aAAa;;CAGjC,AAAQ,OAAO,cAAsB;AACpC,SAAO,KAAK,OAAO,aAAa;;CAGjC,AAAQ,QAAQ,cAAsB;AACrC,SAAO,GAAG,KAAK,OAAO,aAAa,CAAC;;CAGrC,AAAQ,cAAc,SAAqB;AAC1C,SAAO,OAAO,OAAO,CAAC,KAAK,eAAe,OAAO,KAAK,QAAQ,CAAC,CAAC;;CAGjE,AAAQ,cAAc,QAAgB;EACrC,MAAM,mBAAmB,OAAO;AAGhC,SAAO,CAFY,OAAO,SAAS,SAAS,GAAG,mBAAmB,EAAE,EAEhD,OAAO,MAAM,mBAAmB,EAAE,CAAC;;;;;CAMxD,MAAa,kBAAkB,EAC9B,cACA,YAC4B;AAC5B,SAAO,IAAI,SAAS,SAAS,WAAW;AAGvC,QAAK,IAAI,UAAU,KAAK,OAAO,aAAa,EAAE,OAAO,UAAe;AACnE,QAAI,OAAO;AACV,YAAO,MAAM;AACb;;AAGD,SAAK,qBAAqB,cAAc,SAAS;AACjD,SAAK,mCAAmC,aAAa;AAErD,YAAQ,OAAU;KACjB;IACD;;;;;CAMH,MAAc,qBAAqB,cAAsB,UAAoB;EAC5E,MAAM,cAAc,IAAI,gBAAgB,aAAa,CACnD,mBAAmB,CACnB,sBAAsB,SAAS;AAEjC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,YAAY,cAAc,CAAC,CAC9C;;;;;CAMF,MAAc,mCAAmC,cAAsB;EACtE,MAAM,mBAAmB,IAAI,gBAC5B,aACA,CAAC,qBAAqB;AAEvB,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,iBAAiB,cAAc,CAAC,CACnD;;;;;;CAOF,MAAM,gBAAgB,EAAE,gBAAwC;EAG/D,MAAM,WAAW,KAAK,QAAQ,aAAa;EAC3C,MAAM,MAAM,KAAK,cAAc;AAC/B,MAAI;GACH,MAAM,OAAO,MAAM,KAAK,QAAQ,QAAQ,CAAC,SAAS,EAAE,IAAI;GACxD,MAAM,UAAU,KAAK,MAAM,IAAI,SAAS;AACxC,OAAI,SAAS,QACZ,OAAM,QAAQ;AAEf,QAAK,MAAM,IAAI,UAAU,EAAE,MAAM,CAAC;WAC1B,OAAY;AAEpB,OACC,iBAAiB,kBACjB,MAAM,YACL,wEAID,OAAM,IAAI,sBAAsB,oDAAoD;AAGrF,WAAQ,MAAM,qBAAqB,MAAM;AACzC,SAAM;;;;;;CAOR,MAAM,mBAAmB,EACxB,cACA,yBAC6B;EAC7B,MAAM,UAAU,KAAK,QAAQ,aAAa;EAC1C,MAAM,OAAO,KAAK,MAAM,IAAI,QAAQ;AACpC,MAAI,KACH,KAAI;AAEH,QAAK,UAAU,KAAK,KAAK,SAAS;AAClC,SAAM,KAAK;UACJ,WAEE;AACT,QAAK,MAAM,OAAO,QAAQ;;AAM5B,MACC,oBAAoB,sBAAsB,IAC1C,sBAAsB,WAAW,SAChC;GACD,MAAM,UAAU,KAAK,kCAAkC,IAAI,aAAa;AAExE,OAAI,SAAS;AACZ,iBAAa,QAAQ,QAAQ;AAC7B,YAAQ,SAAS;AACjB,SAAK,kCAAkC,OAAO,aAAa;;GAG5D,IAAI,wBAAoC;GACxC,MAAM,iBAAiB,IAAI,SAAe,YAAY;AACrD,sBAAkB;KACjB;GAEF,MAAM,UAAU,iBAAiB;AAChC,SAAK,kCAAkC,OAAO,aAAa;AAC3D,qBAAiB;MACf,KAAK,cAAc,gBAAgB;AAEtC,QAAK,kCAAkC,IAAI,cAAc;IACxD;IACA,SAAS;IACT,CAAC;AAEF,SAAM;;;;;;CAOR,MAAM,kBAAkB,EACvB,cACA,WACA,OACA,SACA,SACA,YAC4B;AAG5B,OADoB,UAAU,YAAY,QAAQ,OAC9B,EACnB;EAED,MAAM,iBAAiB,MAAM,OAAO,SAAS,QAAQ;EACrD,MAAM,UAAU,IAAI,gBACnB,aACA,CAAC,6BAA6B,WAAW,eAAe;AAEzD,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAqCF,MAAa,SAAS,MAAqC;AAC1D,MACC,oBAAoB,KAAK,kBAAkB,IAC3C,KAAK,kBAAkB,WAAW,QAElC;AAGD,SAAO,KAAK,qBAAqB,KAAK,cAAc,KAAK,SAAS;;;;;CAMnE,MAAM,qBAAqB,MAAmC;AAC7D,SAAO,IAAI,SAAe,YAAY;AACrC,oBAAiB;AAChB,aAAS;MACP,KAAK,cAAc,gBAAgB;IACrC;;CAGH,MAAM,oBAAoB,MAAkC;AAC3D,MAAI,KAAK,SAAS,UAAU,IAAI,KAAK,aAAa,CAAE;AAEpD,OAAK,IAAI,YAAY,KAAK,OAAO,KAAK,aAAa,GAAG,UAAe;AACpE,OAAI,MACH,SAAQ,MAAM,MAAM;IAEpB;;CAGH,MAAM,yBAAyB,MAAuC;EACrE,MAAM,UAAU,IAAI,gBACnB,KAAK,aACL,CAAC,wBAAwB,KAAK,QAAQ;AAEvC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,KAAK,aAAa,EAC9B,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAMF,MAAM,YAAY;AACjB,QAAM,KAAK,QAAQ,MAAM;AACzB,OAAK,IAAI,WAAW,MAAM;AAC1B,OAAK,IAAI,WAAW,MAAM"}
|
|
1
|
+
{"version":3,"file":"hocuspocus-redis.esm.js","names":[],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tafterLoadDocumentPayload,\n\tafterStoreDocumentPayload,\n\tafterUnloadDocumentPayload,\n\tbeforeBroadcastStatelessPayload,\n\tbeforeUnloadDocumentPayload,\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tonAwarenessUpdatePayload,\n\tonChangePayload,\n\tonConfigurePayload,\n\tonStoreDocumentPayload,\n\tRedisTransactionOrigin,\n} from \"@hocuspocus/server\";\nimport { SkipFurtherHooksError } from \"@hocuspocus/common\";\nimport {\n\tIncomingMessage,\n\tisTransactionOrigin,\n\tMessageReceiver,\n\tMessageType,\n\tOutgoingMessage,\n} from \"@hocuspocus/server\";\nimport {\n\tmessageYjsSyncStep2,\n\tmessageYjsUpdate,\n} from \"y-protocols/sync\";\nimport {\n\tExecutionError,\n\ttype ExecutionResult,\n\ttype Lock,\n\tRedlock,\n} from \"@sesamecare-oss/redlock\";\nimport type {\n\tCluster,\n\tClusterNode,\n\tClusterOptions,\n\tRedisOptions,\n} from \"ioredis\";\nimport RedisClient from \"ioredis\";\nexport type RedisInstance = RedisClient | Cluster;\nexport interface Configuration {\n\t/**\n\t * Redis port\n\t */\n\tport: number;\n\t/**\n\t * Redis host\n\t */\n\thost: string;\n\t/**\n\t * Redis Cluster\n\t */\n\tnodes?: ClusterNode[];\n\t/**\n\t * Duplicate from an existed Redis instance\n\t */\n\tredis?: RedisInstance;\n\t/**\n\t * Redis instance creator\n\t */\n\tcreateClient?: () => RedisInstance;\n\t/**\n\t * Options passed directly to Redis constructor\n\t *\n\t * https://github.com/luin/ioredis/blob/master/API.md#new-redisport-host-options\n\t */\n\toptions?: ClusterOptions | RedisOptions;\n\t/**\n\t * An unique instance name, required to filter messages in Redis.\n\t * If none is provided an unique id is generated.\n\t */\n\tidentifier: string;\n\t/**\n\t * Namespace for Redis keys, if none is provided 'hocuspocus' is used\n\t */\n\tprefix: string;\n\t/**\n\t * The maximum time for the Redis lock in ms (in case it can’t be released).\n\t */\n\tlockTimeout: number;\n\t/**\n\t * A delay before onDisconnect is executed. This allows last minute updates'\n\t * sync messages to be received by the subscription before it's closed.\n\t */\n\tdisconnectDelay: number;\n\t/**\n\t * The maximum time (in ms) `afterLoadDocument` waits for another instance to\n\t * send its state when loading a document that is already open elsewhere.\n\t *\n\t * When a document is loaded on this instance while another instance already\n\t * has it open (and possibly modified in memory, not yet persisted), we\n\t * publish a SyncStep1 and block the load until the peer replies with its\n\t * state (SyncStep2) — or this timeout elapses. This guarantees that callers\n\t * (most importantly a `DirectConnection`) observe the latest collaborative\n\t * state instead of just what was loaded from storage.\n\t *\n\t * If no other instance is subscribed to the document, the wait is skipped\n\t * entirely, so a cold/lone load is not delayed.\n\t *\n\t * Set to `0` to disable the wait and restore the legacy fire-and-forget\n\t * behavior.\n\t */\n\tawaitInitialSyncTimeout: number;\n}\n\nexport class Redis implements Extension {\n\t/**\n\t * Make sure to give that extension a higher priority, so\n\t * the `onStoreDocument` hook is able to intercept the chain,\n\t * before documents are stored to the database.\n\t */\n\tpriority = 1000;\n\n\tconfiguration: Configuration = {\n\t\tport: 6379,\n\t\thost: \"127.0.0.1\",\n\t\tprefix: \"hocuspocus\",\n\t\tidentifier: `host-${crypto.randomUUID()}`,\n\t\tlockTimeout: 1000,\n\t\tdisconnectDelay: 1000,\n\t\tawaitInitialSyncTimeout: 1000,\n\t};\n\n\tredisTransactionOrigin: RedisTransactionOrigin = {\n\t\tsource: \"redis\",\n\t};\n\n\tpub: RedisInstance;\n\n\tsub: RedisInstance;\n\n\tinstance!: Hocuspocus;\n\n\tredlock: Redlock;\n\n\tlocks = new Map<string, { lock: Lock; release?: Promise<ExecutionResult> }>();\n\n\tmessagePrefix: Buffer;\n\n\tprivate pendingAfterStoreDocumentResolves = new Map<\n\t\tstring,\n\t\t{ timeout: NodeJS.Timeout; resolve: () => void }\n\t>();\n\n\t/**\n\t * Documents this instance has loaded and subscribed to, keyed by name.\n\t *\n\t * This mirrors `instance.documents` but is populated at the very start of\n\t * `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in\n\t * `instance.documents` (which only happens once loading, including this\n\t * hook, has fully resolved). That early registration is what lets\n\t * `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still\n\t * blocking the load on it.\n\t */\n\tprivate documents = new Map<string, Document>();\n\n\t/**\n\t * Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.\n\t * Invoked by `handleIncomingMessage` as soon as a peer's state arrives.\n\t */\n\tprivate pendingInitialSyncResolves = new Map<string, () => void>();\n\n\tpublic constructor(configuration: Partial<Configuration>) {\n\t\tthis.configuration = {\n\t\t\t...this.configuration,\n\t\t\t...configuration,\n\t\t};\n\n\t\t// Create Redis instance\n\t\tconst { port, host, options, nodes, redis, createClient } =\n\t\t\tthis.configuration;\n\n\t\tif (typeof createClient === \"function\") {\n\t\t\tthis.pub = createClient();\n\t\t\tthis.sub = createClient();\n\t\t} else if (redis) {\n\t\t\tthis.pub = redis.duplicate();\n\t\t\tthis.sub = redis.duplicate();\n\t\t} else if (nodes && nodes.length > 0) {\n\t\t\tthis.pub = new RedisClient.Cluster(nodes, options);\n\t\t\tthis.sub = new RedisClient.Cluster(nodes, options);\n\t\t} else {\n\t\t\tthis.pub = new RedisClient(port, host, options ?? {});\n\t\t\tthis.sub = new RedisClient(port, host, options ?? {});\n\t\t}\n\n\t\tthis.sub.on(\"messageBuffer\", this.handleIncomingMessage);\n\n\t\tthis.redlock = new Redlock([this.pub], {\n\t\t\tretryCount: 0,\n\t\t});\n\n\t\tconst identifierBuffer = Buffer.from(\n\t\t\tthis.configuration.identifier,\n\t\t\t\"utf-8\",\n\t\t);\n\t\tthis.messagePrefix = Buffer.concat([\n\t\t\tBuffer.from([identifierBuffer.length]),\n\t\t\tidentifierBuffer,\n\t\t]);\n\t}\n\n\tasync onConfigure({ instance }: onConfigurePayload) {\n\t\tthis.instance = instance;\n\t}\n\n\tprivate getKey(documentName: string) {\n\t\treturn `${this.configuration.prefix}:${documentName}`;\n\t}\n\n\tprivate pubKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate subKey(documentName: string) {\n\t\treturn this.getKey(documentName);\n\t}\n\n\tprivate lockKey(documentName: string) {\n\t\treturn `${this.getKey(documentName)}:lock`;\n\t}\n\n\tprivate encodeMessage(message: Uint8Array) {\n\t\treturn Buffer.concat([this.messagePrefix, Buffer.from(message)]);\n\t}\n\n\tprivate decodeMessage(buffer: Buffer) {\n\t\tconst identifierLength = buffer[0];\n\t\tconst identifier = buffer.toString(\"utf-8\", 1, identifierLength + 1);\n\n\t\treturn [identifier, buffer.slice(identifierLength + 1)];\n\t}\n\n\t/**\n\t * Once a document is loaded, subscribe to the channel in Redis.\n\t */\n\tpublic async afterLoadDocument({\n\t\tdocumentName,\n\t\tdocument,\n\t}: afterLoadDocumentPayload) {\n\t\t// Track the document locally right away so `handleIncomingMessage` can\n\t\t// apply inbound messages to it even before Hocuspocus has finished\n\t\t// loading it (it only lands in `instance.documents` after this hook\n\t\t// resolves). Without this, the SyncStep2 reply we request below would be\n\t\t// dropped while we wait for it.\n\t\tthis.documents.set(documentName, document);\n\n\t\ttry {\n\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t// On document creation the node will connect to pub and sub channels\n\t\t\t\t// for the document.\n\t\t\t\tthis.sub.subscribe(this.subKey(documentName), (error: any) => {\n\t\t\t\t\tif (error) {\n\t\t\t\t\t\treject(error);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tresolve();\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tawait this.syncInitialStateFromPeers(documentName, document);\n\t\t} catch (error) {\n\t\t\t// Loading failed: stop tracking the document so future messages aren't\n\t\t\t// applied to a doc that never finished loading, release any pending\n\t\t\t// wait, and best-effort unsubscribe (afterUnloadDocument does not run\n\t\t\t// for a load that never completed).\n\t\t\tthis.documents.delete(documentName);\n\t\t\tthis.pendingInitialSyncResolves.delete(documentName);\n\t\t\tthis.sub.unsubscribe(this.subKey(documentName), () => {});\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/**\n\t * Announce our state to other instances and — when another instance already\n\t * has this document open — block until it has sent us its state (or the\n\t * configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.\n\t */\n\tprivate async syncInitialStateFromPeers(\n\t\tdocumentName: string,\n\t\tdocument: Document,\n\t) {\n\t\tconst timeout = this.configuration.awaitInitialSyncTimeout;\n\n\t\tconst waitForPeers =\n\t\t\ttimeout > 0 && (await this.hasOtherSubscribers(documentName));\n\n\t\t// Announce our state and request awareness. Awaited so a Redis publish\n\t\t// failure surfaces as a load error instead of an unhandled rejection.\n\t\tawait Promise.all([\n\t\t\tthis.publishFirstSyncStep(documentName, document),\n\t\t\tthis.requestAwarenessFromOtherInstances(documentName),\n\t\t]);\n\n\t\t// Skip the wait when it's disabled or nobody else has the document open:\n\t\t// there is no peer to sync from, so blocking would only add latency.\n\t\tif (!waitForPeers) {\n\t\t\treturn;\n\t\t}\n\n\t\t// A peer has the document open. Block until it replies with its state\n\t\t// (a SyncStep2/Update applied via handleIncomingMessage resolves this) or\n\t\t// the timeout elapses. The reply cannot arrive before the SyncStep1 we\n\t\t// just published is delivered, so registering the resolver now — right\n\t\t// after the awaited publish, before yielding to the event loop — cannot\n\t\t// miss it.\n\t\tawait new Promise<void>((resolve) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.pendingInitialSyncResolves.delete(documentName);\n\t\t\t\tresolve();\n\t\t\t}, timeout);\n\n\t\t\tthis.pendingInitialSyncResolves.set(documentName, () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tthis.pendingInitialSyncResolves.delete(documentName);\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Whether another instance is currently subscribed to a document's channel.\n\t * We are subscribed ourselves, so a subscriber count greater than one means\n\t * at least one peer has the document open.\n\t */\n\tprivate async hasOtherSubscribers(documentName: string): Promise<boolean> {\n\t\ttry {\n\t\t\tconst result = (await this.pub.pubsub(\n\t\t\t\t\"NUMSUB\",\n\t\t\t\tthis.subKey(documentName),\n\t\t\t)) as [string, number | string];\n\t\t\treturn Number(result?.[1] ?? 0) > 1;\n\t\t} catch {\n\t\t\t// NUMSUB may be unavailable in some setups (e.g. certain clusters).\n\t\t\t// Assume peers might exist so correctness is preserved; the timeout\n\t\t\t// still bounds the wait.\n\t\t\treturn true;\n\t\t}\n\t}\n\n\t/**\n\t * Resolve a pending `afterLoadDocument` wait once a peer's state has been\n\t * applied.\n\t */\n\tprivate resolveInitialSync(documentName: string) {\n\t\tthis.pendingInitialSyncResolves.get(documentName)?.();\n\t}\n\n\t/**\n\t * Peek whether a message carries another instance's document state\n\t * (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only\n\t * *requests* state, so it must not release an initial-sync wait.\n\t */\n\tprivate messageCarriesPeerState(message: IncomingMessage): boolean {\n\t\tconst { pos } = message.decoder;\n\t\ttry {\n\t\t\tconst type = message.readVarUint();\n\t\t\tif (type !== MessageType.Sync && type !== MessageType.SyncReply) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst syncType = message.readVarUint();\n\t\t\treturn (\n\t\t\t\tsyncType === messageYjsSyncStep2 || syncType === messageYjsUpdate\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tmessage.decoder.pos = pos;\n\t\t}\n\t}\n\n\t/**\n\t * Publish the first sync step through Redis.\n\t */\n\tprivate async publishFirstSyncStep(documentName: string, document: Document) {\n\t\tconst syncMessage = new OutgoingMessage(documentName)\n\t\t\t.createSyncMessage()\n\t\t\t.writeFirstSyncStepFor(document);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(syncMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Let’s ask Redis who is connected already.\n\t */\n\tprivate async requestAwarenessFromOtherInstances(documentName: string) {\n\t\tconst awarenessMessage = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).writeQueryAwareness();\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(awarenessMessage.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Before the document is stored, make sure to set a lock in Redis.\n\t * That’s meant to avoid conflicts with other instances trying to store the document.\n\t */\n\tasync onStoreDocument({ documentName }: onStoreDocumentPayload) {\n\t\t// Attempt to acquire a lock and read lastReceivedTimestamp from Redis,\n\t\t// to avoid conflict with other instances storing the same document.\n\t\tconst resource = this.lockKey(documentName);\n\t\tconst ttl = this.configuration.lockTimeout;\n\t\ttry {\n\t\t\tconst lock = await this.redlock.acquire([resource], ttl);\n\t\t\tconst oldLock = this.locks.get(resource);\n\t\t\tif (oldLock?.release) {\n\t\t\t\tawait oldLock.release;\n\t\t\t}\n\t\t\tthis.locks.set(resource, { lock });\n\t\t} catch (error: any) {\n\t\t\t//based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349\n\t\t\tif (\n\t\t\t\terror instanceof ExecutionError &&\n\t\t\t\terror.message ===\n\t\t\t\t\t\"The operation was unable to achieve a quorum during its retry window.\"\n\t\t\t) {\n\t\t\t\t// Expected behavior: Could not acquire lock, another instance locked it already.\n\t\t\t\t// Skip further hooks and retry — the data is safe on the other instance.\n\t\t\t\tthrow new SkipFurtherHooksError(\"Another instance is already storing this document\");\n\t\t\t}\n\t\t\t//unexpected error\n\t\t\tconsole.error(\"unexpected error:\", error);\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t/**\n\t * Release the Redis lock, so other instances can store documents.\n\t */\n\tasync afterStoreDocument({\n\t\tdocumentName,\n\t\tlastTransactionOrigin,\n\t}: afterStoreDocumentPayload) {\n\t\tconst lockKey = this.lockKey(documentName);\n\t\tconst lock = this.locks.get(lockKey);\n\t\tif (lock) {\n\t\t\ttry {\n\t\t\t\t// Always try to unlock and clean up the lock\n\t\t\t\tlock.release = lock.lock.release();\n\t\t\t\tawait lock.release;\n\t\t\t} catch {\n\t\t\t\t// Lock will expire on its own after timeout\n\t\t\t} finally {\n\t\t\t\tthis.locks.delete(lockKey);\n\t\t\t}\n\t\t}\n\n\t\t// 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.\n\t\t// for provider connections, this usually happens in the onDisconnect hook\n\t\tif (\n\t\t\tisTransactionOrigin(lastTransactionOrigin) &&\n\t\t\tlastTransactionOrigin.source === \"local\"\n\t\t) {\n\t\t\tconst pending = this.pendingAfterStoreDocumentResolves.get(documentName);\n\n\t\t\tif (pending) {\n\t\t\t\tclearTimeout(pending.timeout);\n\t\t\t\tpending.resolve();\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t}\n\n\t\t\tlet resolveFunction: () => void = () => {};\n\t\t\tconst delayedPromise = new Promise<void>((resolve) => {\n\t\t\t\tresolveFunction = resolve;\n\t\t\t});\n\n\t\t\tconst timeout = setTimeout(() => {\n\t\t\t\tthis.pendingAfterStoreDocumentResolves.delete(documentName);\n\t\t\t\tresolveFunction();\n\t\t\t}, this.configuration.disconnectDelay);\n\n\t\t\tthis.pendingAfterStoreDocumentResolves.set(documentName, {\n\t\t\t\ttimeout,\n\t\t\t\tresolve: resolveFunction,\n\t\t\t});\n\n\t\t\tawait delayedPromise;\n\t\t}\n\t}\n\n\t/**\n\t * Handle awareness update messages received directly by this Hocuspocus instance.\n\t */\n\tasync onAwarenessUpdate({\n\t\tdocumentName,\n\t\tawareness,\n\t\tadded,\n\t\tupdated,\n\t\tremoved,\n\t\tdocument,\n\t}: onAwarenessUpdatePayload) {\n\t\t// Do not publish if there is no connection: it fixes this issue: \"https://github.com/ueberdosis/hocuspocus/issues/1027\"\n\t\tconst connections = document?.connections.size || 0;\n\t\tif (connections === 0) {\n\t\t\treturn; // avoids exception\n\t\t}\n\t\tconst changedClients = added.concat(updated, removed);\n\t\tconst message = new OutgoingMessage(\n\t\t\tdocumentName,\n\t\t).createAwarenessUpdateMessage(awareness, changedClients);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Handle incoming messages published on subscribed document channels.\n\t * Note that this will also include messages from ourselves as it is not possible\n\t * in Redis to filter these.\n\t */\n\tprivate handleIncomingMessage = async (channel: Buffer, data: Buffer) => {\n\t\tconst [identifier, messageBuffer] = this.decodeMessage(data);\n\n\t\tif (identifier === this.configuration.identifier) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst message = new IncomingMessage(messageBuffer);\n\t\tconst documentName = message.readVarString();\n\t\tmessage.writeVarString(documentName);\n\n\t\t// Prefer the extension-local map: it also contains documents that are\n\t\t// still loading (not yet in `instance.documents`), which is exactly when\n\t\t// a blocking `afterLoadDocument` needs to receive the peer's reply.\n\t\tconst document =\n\t\t\tthis.documents.get(documentName) ??\n\t\t\tthis.instance.documents.get(documentName);\n\n\t\tif (!document) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst carriesPeerState = this.messageCarriesPeerState(message);\n\n\t\tconst receiver = new MessageReceiver(message, this.redisTransactionOrigin);\n\t\tawait receiver.apply(document, undefined, (reply) => {\n\t\t\treturn this.pub.publish(\n\t\t\t\tthis.pubKey(document.name),\n\t\t\t\tthis.encodeMessage(reply),\n\t\t\t);\n\t\t});\n\n\t\t// A peer answered our SyncStep1 with its state — the document has now\n\t\t// caught up, so any blocking initial-sync wait can be released.\n\t\tif (carriesPeerState) {\n\t\t\tthis.resolveInitialSync(documentName);\n\t\t}\n\t};\n\n\t/**\n\t * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.\n\t */\n\tpublic async onChange(data: onChangePayload): Promise<any> {\n\t\tif (\n\t\t\tisTransactionOrigin(data.transactionOrigin) &&\n\t\t\tdata.transactionOrigin.source === \"redis\"\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\treturn this.publishFirstSyncStep(data.documentName, data.document);\n\t}\n\n\t/**\n\t * Delay unloading to allow syncs to finish\n\t */\n\tasync beforeUnloadDocument(data: beforeUnloadDocumentPayload) {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tsetTimeout(() => {\n\t\t\t\tresolve();\n\t\t\t}, this.configuration.disconnectDelay);\n\t\t});\n\t}\n\n\tasync afterUnloadDocument(data: afterUnloadDocumentPayload) {\n\t\tif (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)\n\n\t\tthis.resolveInitialSync(data.documentName);\n\t\tthis.documents.delete(data.documentName);\n\n\t\tthis.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {\n\t\t\tif (error) {\n\t\t\t\tconsole.error(error);\n\t\t\t}\n\t\t});\n\t}\n\n\tasync beforeBroadcastStateless(data: beforeBroadcastStatelessPayload) {\n\t\tconst message = new OutgoingMessage(\n\t\t\tdata.documentName,\n\t\t).writeBroadcastStateless(data.payload);\n\n\t\treturn this.pub.publish(\n\t\t\tthis.pubKey(data.documentName),\n\t\t\tthis.encodeMessage(message.toUint8Array()),\n\t\t);\n\t}\n\n\t/**\n\t * Kill the Redlock connection immediately.\n\t */\n\tasync onDestroy() {\n\t\tawait this.redlock.quit();\n\t\tthis.pub.disconnect(false);\n\t\tthis.sub.disconnect(false);\n\t}\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -49,6 +49,24 @@ interface Configuration {
|
|
|
49
49
|
* sync messages to be received by the subscription before it's closed.
|
|
50
50
|
*/
|
|
51
51
|
disconnectDelay: number;
|
|
52
|
+
/**
|
|
53
|
+
* The maximum time (in ms) `afterLoadDocument` waits for another instance to
|
|
54
|
+
* send its state when loading a document that is already open elsewhere.
|
|
55
|
+
*
|
|
56
|
+
* When a document is loaded on this instance while another instance already
|
|
57
|
+
* has it open (and possibly modified in memory, not yet persisted), we
|
|
58
|
+
* publish a SyncStep1 and block the load until the peer replies with its
|
|
59
|
+
* state (SyncStep2) — or this timeout elapses. This guarantees that callers
|
|
60
|
+
* (most importantly a `DirectConnection`) observe the latest collaborative
|
|
61
|
+
* state instead of just what was loaded from storage.
|
|
62
|
+
*
|
|
63
|
+
* If no other instance is subscribed to the document, the wait is skipped
|
|
64
|
+
* entirely, so a cold/lone load is not delayed.
|
|
65
|
+
*
|
|
66
|
+
* Set to `0` to disable the wait and restore the legacy fire-and-forget
|
|
67
|
+
* behavior.
|
|
68
|
+
*/
|
|
69
|
+
awaitInitialSyncTimeout: number;
|
|
52
70
|
}
|
|
53
71
|
declare class Redis implements Extension {
|
|
54
72
|
/**
|
|
@@ -69,6 +87,22 @@ declare class Redis implements Extension {
|
|
|
69
87
|
}>;
|
|
70
88
|
messagePrefix: Buffer;
|
|
71
89
|
private pendingAfterStoreDocumentResolves;
|
|
90
|
+
/**
|
|
91
|
+
* Documents this instance has loaded and subscribed to, keyed by name.
|
|
92
|
+
*
|
|
93
|
+
* This mirrors `instance.documents` but is populated at the very start of
|
|
94
|
+
* `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in
|
|
95
|
+
* `instance.documents` (which only happens once loading, including this
|
|
96
|
+
* hook, has fully resolved). That early registration is what lets
|
|
97
|
+
* `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still
|
|
98
|
+
* blocking the load on it.
|
|
99
|
+
*/
|
|
100
|
+
private documents;
|
|
101
|
+
/**
|
|
102
|
+
* Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.
|
|
103
|
+
* Invoked by `handleIncomingMessage` as soon as a peer's state arrives.
|
|
104
|
+
*/
|
|
105
|
+
private pendingInitialSyncResolves;
|
|
72
106
|
constructor(configuration: Partial<Configuration>);
|
|
73
107
|
onConfigure({
|
|
74
108
|
instance
|
|
@@ -85,7 +119,30 @@ declare class Redis implements Extension {
|
|
|
85
119
|
afterLoadDocument({
|
|
86
120
|
documentName,
|
|
87
121
|
document
|
|
88
|
-
}: afterLoadDocumentPayload): Promise<
|
|
122
|
+
}: afterLoadDocumentPayload): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Announce our state to other instances and — when another instance already
|
|
125
|
+
* has this document open — block until it has sent us its state (or the
|
|
126
|
+
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
|
|
127
|
+
*/
|
|
128
|
+
private syncInitialStateFromPeers;
|
|
129
|
+
/**
|
|
130
|
+
* Whether another instance is currently subscribed to a document's channel.
|
|
131
|
+
* We are subscribed ourselves, so a subscriber count greater than one means
|
|
132
|
+
* at least one peer has the document open.
|
|
133
|
+
*/
|
|
134
|
+
private hasOtherSubscribers;
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
|
|
137
|
+
* applied.
|
|
138
|
+
*/
|
|
139
|
+
private resolveInitialSync;
|
|
140
|
+
/**
|
|
141
|
+
* Peek whether a message carries another instance's document state
|
|
142
|
+
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
|
|
143
|
+
* *requests* state, so it must not release an initial-sync wait.
|
|
144
|
+
*/
|
|
145
|
+
private messageCarriesPeerState;
|
|
89
146
|
/**
|
|
90
147
|
* Publish the first sync step through Redis.
|
|
91
148
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hocuspocus/extension-redis",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Scale Hocuspocus horizontally with Redis",
|
|
5
5
|
"homepage": "https://hocuspocus.dev",
|
|
6
6
|
"keywords": [
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"dist"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@hocuspocus/common": "^4.
|
|
32
|
-
"@hocuspocus/server": "^4.
|
|
31
|
+
"@hocuspocus/common": "^4.4.0",
|
|
32
|
+
"@hocuspocus/server": "^4.4.0",
|
|
33
33
|
"@sesamecare-oss/redlock": "^1.4.0",
|
|
34
34
|
"ioredis": "~5.6.1",
|
|
35
35
|
"kleur": "^4.1.4"
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"y-protocols": "^1.0.6",
|
|
39
39
|
"yjs": "^13.6.8"
|
|
40
40
|
},
|
|
41
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "3634ee9ad71f7c9800a500f2556e42b1d14cf203",
|
|
42
42
|
"repository": {
|
|
43
43
|
"url": "https://github.com/ueberdosis/hocuspocus"
|
|
44
44
|
},
|
package/src/Redis.ts
CHANGED
|
@@ -19,8 +19,13 @@ import {
|
|
|
19
19
|
IncomingMessage,
|
|
20
20
|
isTransactionOrigin,
|
|
21
21
|
MessageReceiver,
|
|
22
|
+
MessageType,
|
|
22
23
|
OutgoingMessage,
|
|
23
24
|
} from "@hocuspocus/server";
|
|
25
|
+
import {
|
|
26
|
+
messageYjsSyncStep2,
|
|
27
|
+
messageYjsUpdate,
|
|
28
|
+
} from "y-protocols/sync";
|
|
24
29
|
import {
|
|
25
30
|
ExecutionError,
|
|
26
31
|
type ExecutionResult,
|
|
@@ -80,6 +85,24 @@ export interface Configuration {
|
|
|
80
85
|
* sync messages to be received by the subscription before it's closed.
|
|
81
86
|
*/
|
|
82
87
|
disconnectDelay: number;
|
|
88
|
+
/**
|
|
89
|
+
* The maximum time (in ms) `afterLoadDocument` waits for another instance to
|
|
90
|
+
* send its state when loading a document that is already open elsewhere.
|
|
91
|
+
*
|
|
92
|
+
* When a document is loaded on this instance while another instance already
|
|
93
|
+
* has it open (and possibly modified in memory, not yet persisted), we
|
|
94
|
+
* publish a SyncStep1 and block the load until the peer replies with its
|
|
95
|
+
* state (SyncStep2) — or this timeout elapses. This guarantees that callers
|
|
96
|
+
* (most importantly a `DirectConnection`) observe the latest collaborative
|
|
97
|
+
* state instead of just what was loaded from storage.
|
|
98
|
+
*
|
|
99
|
+
* If no other instance is subscribed to the document, the wait is skipped
|
|
100
|
+
* entirely, so a cold/lone load is not delayed.
|
|
101
|
+
*
|
|
102
|
+
* Set to `0` to disable the wait and restore the legacy fire-and-forget
|
|
103
|
+
* behavior.
|
|
104
|
+
*/
|
|
105
|
+
awaitInitialSyncTimeout: number;
|
|
83
106
|
}
|
|
84
107
|
|
|
85
108
|
export class Redis implements Extension {
|
|
@@ -97,6 +120,7 @@ export class Redis implements Extension {
|
|
|
97
120
|
identifier: `host-${crypto.randomUUID()}`,
|
|
98
121
|
lockTimeout: 1000,
|
|
99
122
|
disconnectDelay: 1000,
|
|
123
|
+
awaitInitialSyncTimeout: 1000,
|
|
100
124
|
};
|
|
101
125
|
|
|
102
126
|
redisTransactionOrigin: RedisTransactionOrigin = {
|
|
@@ -120,6 +144,24 @@ export class Redis implements Extension {
|
|
|
120
144
|
{ timeout: NodeJS.Timeout; resolve: () => void }
|
|
121
145
|
>();
|
|
122
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Documents this instance has loaded and subscribed to, keyed by name.
|
|
149
|
+
*
|
|
150
|
+
* This mirrors `instance.documents` but is populated at the very start of
|
|
151
|
+
* `afterLoadDocument` — i.e. *before* Hocuspocus registers the document in
|
|
152
|
+
* `instance.documents` (which only happens once loading, including this
|
|
153
|
+
* hook, has fully resolved). That early registration is what lets
|
|
154
|
+
* `handleIncomingMessage` apply a peer's SyncStep2 reply while we are still
|
|
155
|
+
* blocking the load on it.
|
|
156
|
+
*/
|
|
157
|
+
private documents = new Map<string, Document>();
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Resolvers for in-flight `afterLoadDocument` waits, keyed by document name.
|
|
161
|
+
* Invoked by `handleIncomingMessage` as soon as a peer's state arrives.
|
|
162
|
+
*/
|
|
163
|
+
private pendingInitialSyncResolves = new Map<string, () => void>();
|
|
164
|
+
|
|
123
165
|
public constructor(configuration: Partial<Configuration>) {
|
|
124
166
|
this.configuration = {
|
|
125
167
|
...this.configuration,
|
|
@@ -198,23 +240,138 @@ export class Redis implements Extension {
|
|
|
198
240
|
documentName,
|
|
199
241
|
document,
|
|
200
242
|
}: afterLoadDocumentPayload) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
243
|
+
// Track the document locally right away so `handleIncomingMessage` can
|
|
244
|
+
// apply inbound messages to it even before Hocuspocus has finished
|
|
245
|
+
// loading it (it only lands in `instance.documents` after this hook
|
|
246
|
+
// resolves). Without this, the SyncStep2 reply we request below would be
|
|
247
|
+
// dropped while we wait for it.
|
|
248
|
+
this.documents.set(documentName, document);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await new Promise<void>((resolve, reject) => {
|
|
252
|
+
// On document creation the node will connect to pub and sub channels
|
|
253
|
+
// for the document.
|
|
254
|
+
this.sub.subscribe(this.subKey(documentName), (error: any) => {
|
|
255
|
+
if (error) {
|
|
256
|
+
reject(error);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
resolve();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await this.syncInitialStateFromPeers(documentName, document);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
// Loading failed: stop tracking the document so future messages aren't
|
|
267
|
+
// applied to a doc that never finished loading, release any pending
|
|
268
|
+
// wait, and best-effort unsubscribe (afterUnloadDocument does not run
|
|
269
|
+
// for a load that never completed).
|
|
270
|
+
this.documents.delete(documentName);
|
|
271
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
272
|
+
this.sub.unsubscribe(this.subKey(documentName), () => {});
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Announce our state to other instances and — when another instance already
|
|
279
|
+
* has this document open — block until it has sent us its state (or the
|
|
280
|
+
* configured timeout elapses). See {@link Configuration.awaitInitialSyncTimeout}.
|
|
281
|
+
*/
|
|
282
|
+
private async syncInitialStateFromPeers(
|
|
283
|
+
documentName: string,
|
|
284
|
+
document: Document,
|
|
285
|
+
) {
|
|
286
|
+
const timeout = this.configuration.awaitInitialSyncTimeout;
|
|
287
|
+
|
|
288
|
+
const waitForPeers =
|
|
289
|
+
timeout > 0 && (await this.hasOtherSubscribers(documentName));
|
|
290
|
+
|
|
291
|
+
// Announce our state and request awareness. Awaited so a Redis publish
|
|
292
|
+
// failure surfaces as a load error instead of an unhandled rejection.
|
|
293
|
+
await Promise.all([
|
|
294
|
+
this.publishFirstSyncStep(documentName, document),
|
|
295
|
+
this.requestAwarenessFromOtherInstances(documentName),
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
// Skip the wait when it's disabled or nobody else has the document open:
|
|
299
|
+
// there is no peer to sync from, so blocking would only add latency.
|
|
300
|
+
if (!waitForPeers) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// A peer has the document open. Block until it replies with its state
|
|
305
|
+
// (a SyncStep2/Update applied via handleIncomingMessage resolves this) or
|
|
306
|
+
// the timeout elapses. The reply cannot arrive before the SyncStep1 we
|
|
307
|
+
// just published is delivered, so registering the resolver now — right
|
|
308
|
+
// after the awaited publish, before yielding to the event loop — cannot
|
|
309
|
+
// miss it.
|
|
310
|
+
await new Promise<void>((resolve) => {
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
313
|
+
resolve();
|
|
314
|
+
}, timeout);
|
|
315
|
+
|
|
316
|
+
this.pendingInitialSyncResolves.set(documentName, () => {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
this.pendingInitialSyncResolves.delete(documentName);
|
|
319
|
+
resolve();
|
|
214
320
|
});
|
|
215
321
|
});
|
|
216
322
|
}
|
|
217
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Whether another instance is currently subscribed to a document's channel.
|
|
326
|
+
* We are subscribed ourselves, so a subscriber count greater than one means
|
|
327
|
+
* at least one peer has the document open.
|
|
328
|
+
*/
|
|
329
|
+
private async hasOtherSubscribers(documentName: string): Promise<boolean> {
|
|
330
|
+
try {
|
|
331
|
+
const result = (await this.pub.pubsub(
|
|
332
|
+
"NUMSUB",
|
|
333
|
+
this.subKey(documentName),
|
|
334
|
+
)) as [string, number | string];
|
|
335
|
+
return Number(result?.[1] ?? 0) > 1;
|
|
336
|
+
} catch {
|
|
337
|
+
// NUMSUB may be unavailable in some setups (e.g. certain clusters).
|
|
338
|
+
// Assume peers might exist so correctness is preserved; the timeout
|
|
339
|
+
// still bounds the wait.
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a pending `afterLoadDocument` wait once a peer's state has been
|
|
346
|
+
* applied.
|
|
347
|
+
*/
|
|
348
|
+
private resolveInitialSync(documentName: string) {
|
|
349
|
+
this.pendingInitialSyncResolves.get(documentName)?.();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Peek whether a message carries another instance's document state
|
|
354
|
+
* (SyncStep2 or Update) without consuming the decoder. A SyncStep1 only
|
|
355
|
+
* *requests* state, so it must not release an initial-sync wait.
|
|
356
|
+
*/
|
|
357
|
+
private messageCarriesPeerState(message: IncomingMessage): boolean {
|
|
358
|
+
const { pos } = message.decoder;
|
|
359
|
+
try {
|
|
360
|
+
const type = message.readVarUint();
|
|
361
|
+
if (type !== MessageType.Sync && type !== MessageType.SyncReply) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const syncType = message.readVarUint();
|
|
365
|
+
return (
|
|
366
|
+
syncType === messageYjsSyncStep2 || syncType === messageYjsUpdate
|
|
367
|
+
);
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
} finally {
|
|
371
|
+
message.decoder.pos = pos;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
218
375
|
/**
|
|
219
376
|
* Publish the first sync step through Redis.
|
|
220
377
|
*/
|
|
@@ -373,12 +530,19 @@ export class Redis implements Extension {
|
|
|
373
530
|
const documentName = message.readVarString();
|
|
374
531
|
message.writeVarString(documentName);
|
|
375
532
|
|
|
376
|
-
|
|
533
|
+
// Prefer the extension-local map: it also contains documents that are
|
|
534
|
+
// still loading (not yet in `instance.documents`), which is exactly when
|
|
535
|
+
// a blocking `afterLoadDocument` needs to receive the peer's reply.
|
|
536
|
+
const document =
|
|
537
|
+
this.documents.get(documentName) ??
|
|
538
|
+
this.instance.documents.get(documentName);
|
|
377
539
|
|
|
378
540
|
if (!document) {
|
|
379
541
|
return;
|
|
380
542
|
}
|
|
381
543
|
|
|
544
|
+
const carriesPeerState = this.messageCarriesPeerState(message);
|
|
545
|
+
|
|
382
546
|
const receiver = new MessageReceiver(message, this.redisTransactionOrigin);
|
|
383
547
|
await receiver.apply(document, undefined, (reply) => {
|
|
384
548
|
return this.pub.publish(
|
|
@@ -386,6 +550,12 @@ export class Redis implements Extension {
|
|
|
386
550
|
this.encodeMessage(reply),
|
|
387
551
|
);
|
|
388
552
|
});
|
|
553
|
+
|
|
554
|
+
// A peer answered our SyncStep1 with its state — the document has now
|
|
555
|
+
// caught up, so any blocking initial-sync wait can be released.
|
|
556
|
+
if (carriesPeerState) {
|
|
557
|
+
this.resolveInitialSync(documentName);
|
|
558
|
+
}
|
|
389
559
|
};
|
|
390
560
|
|
|
391
561
|
/**
|
|
@@ -416,6 +586,9 @@ export class Redis implements Extension {
|
|
|
416
586
|
async afterUnloadDocument(data: afterUnloadDocumentPayload) {
|
|
417
587
|
if (data.instance.documents.has(data.documentName)) return; // skip unsubscribe if the document is already loaded again (maybe fast reconnect)
|
|
418
588
|
|
|
589
|
+
this.resolveInitialSync(data.documentName);
|
|
590
|
+
this.documents.delete(data.documentName);
|
|
591
|
+
|
|
419
592
|
this.sub.unsubscribe(this.subKey(data.documentName), (error: any) => {
|
|
420
593
|
if (error) {
|
|
421
594
|
console.error(error);
|