@hocuspocus/extension-redis 4.2.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 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
- return new Promise((resolve, reject) => {
114
- this.sub.subscribe(this.subKey(documentName), async (error) => {
115
- if (error) {
116
- reject(error);
117
- return;
118
- }
119
- this.publishFirstSyncStep(documentName, document);
120
- this.requestAwarenessFromOtherInstances(documentName);
121
- resolve(void 0);
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":"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"}
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2GA,IAAa,QAAb,MAAwC;CAyDvC,AAAO,YAAY,eAAuC;kBAnD/C;uBAEoB;GAC9B,MAAM;GACN,MAAM;GACN,QAAQ;GACR,YAAY,QAAQA,oBAAO,YAAY;GACvC,aAAa;GACb,iBAAiB;GACjB,yBAAyB;GACzB;gCAEgD,EAChD,QAAQ,SACR;+BAUO,IAAI,KAAiE;2DAIjC,IAAI,KAG7C;mCAYiB,IAAI,KAAuB;oDAMV,IAAI,KAAyB;+BAuWlC,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;GAKpC,MAAM,WACL,KAAK,UAAU,IAAI,aAAa,IAChC,KAAK,SAAS,UAAU,IAAI,aAAa;AAE1C,OAAI,CAAC,SACJ;GAGD,MAAM,mBAAmB,KAAK,wBAAwB,QAAQ;AAG9D,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;AAIF,OAAI,iBACH,MAAK,mBAAmB,aAAa;;AAvYtC,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;AAM5B,OAAK,UAAU,IAAI,cAAc,SAAS;AAE1C,MAAI;AACH,SAAM,IAAI,SAAe,SAAS,WAAW;AAG5C,SAAK,IAAI,UAAU,KAAK,OAAO,aAAa,GAAG,UAAe;AAC7D,SAAI,OAAO;AACV,aAAO,MAAM;AACb;;AAGD,cAAS;MACR;KACD;AAEF,SAAM,KAAK,0BAA0B,cAAc,SAAS;WACpD,OAAO;AAKf,QAAK,UAAU,OAAO,aAAa;AACnC,QAAK,2BAA2B,OAAO,aAAa;AACpD,QAAK,IAAI,YAAY,KAAK,OAAO,aAAa,QAAQ,GAAG;AACzD,SAAM;;;;;;;;CASR,MAAc,0BACb,cACA,UACC;EACD,MAAM,UAAU,KAAK,cAAc;EAEnC,MAAM,eACL,UAAU,KAAM,MAAM,KAAK,oBAAoB,aAAa;AAI7D,QAAM,QAAQ,IAAI,CACjB,KAAK,qBAAqB,cAAc,SAAS,EACjD,KAAK,mCAAmC,aAAa,CACrD,CAAC;AAIF,MAAI,CAAC,aACJ;AASD,QAAM,IAAI,SAAe,YAAY;GACpC,MAAM,QAAQ,iBAAiB;AAC9B,SAAK,2BAA2B,OAAO,aAAa;AACpD,aAAS;MACP,QAAQ;AAEX,QAAK,2BAA2B,IAAI,oBAAoB;AACvD,iBAAa,MAAM;AACnB,SAAK,2BAA2B,OAAO,aAAa;AACpD,aAAS;KACR;IACD;;;;;;;CAQH,MAAc,oBAAoB,cAAwC;AACzE,MAAI;GACH,MAAM,SAAU,MAAM,KAAK,IAAI,OAC9B,UACA,KAAK,OAAO,aAAa,CACzB;AACD,UAAO,OAAO,SAAS,MAAM,EAAE,GAAG;UAC3B;AAIP,UAAO;;;;;;;CAQT,AAAQ,mBAAmB,cAAsB;AAChD,OAAK,2BAA2B,IAAI,aAAa,IAAI;;;;;;;CAQtD,AAAQ,wBAAwB,SAAmC;EAClE,MAAM,EAAE,QAAQ,QAAQ;AACxB,MAAI;GACH,MAAM,OAAO,QAAQ,aAAa;AAClC,OAAI,SAASC,+BAAY,QAAQ,SAASA,+BAAY,UACrD,QAAO;GAER,MAAM,WAAW,QAAQ,aAAa;AACtC,UACC,aAAaC,wCAAuB,aAAaC;UAE3C;AACP,UAAO;YACE;AACT,WAAQ,QAAQ,MAAM;;;;;;CAOxB,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;;;;;CAkDF,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,mBAAmB,KAAK,aAAa;AAC1C,OAAK,UAAU,OAAO,KAAK,aAAa;AAExC,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,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
- return new Promise((resolve, reject) => {
84
- this.sub.subscribe(this.subKey(documentName), async (error) => {
85
- if (error) {
86
- reject(error);
87
- return;
88
- }
89
- this.publishFirstSyncStep(documentName, document);
90
- this.requestAwarenessFromOtherInstances(documentName);
91
- resolve(void 0);
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":"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"}
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<unknown>;
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.2.0",
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.2.0",
32
- "@hocuspocus/server": "^4.2.0",
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": "94fa0c7114e69c3660b7bf621fe1fdfa6a358de9",
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
- return new Promise((resolve, reject) => {
202
- // On document creation the node will connect to pub and sub channels
203
- // for the document.
204
- this.sub.subscribe(this.subKey(documentName), async (error: any) => {
205
- if (error) {
206
- reject(error);
207
- return;
208
- }
209
-
210
- this.publishFirstSyncStep(documentName, document);
211
- this.requestAwarenessFromOtherInstances(documentName);
212
-
213
- resolve(undefined);
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
- const document = this.instance.documents.get(documentName);
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);