@hocuspocus/extension-redis 3.4.6-rc.2 → 4.0.0-rc.1

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023, Tiptap GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -28,6 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  //#endregion
29
29
  let node_crypto = require("node:crypto");
30
30
  node_crypto = __toESM(node_crypto);
31
+ let _hocuspocus_common = require("@hocuspocus/common");
31
32
  let _hocuspocus_server = require("@hocuspocus/server");
32
33
  let _sesamecare_oss_redlock = require("@sesamecare-oss/redlock");
33
34
  let ioredis = require("ioredis");
@@ -45,7 +46,7 @@ var Redis = class {
45
46
  lockTimeout: 1e3,
46
47
  disconnectDelay: 1e3
47
48
  };
48
- this.redisTransactionOrigin = "__hocuspocus__redis__origin__";
49
+ this.redisTransactionOrigin = { source: "redis" };
49
50
  this.locks = /* @__PURE__ */ new Map();
50
51
  this.pendingAfterStoreDocumentResolves = /* @__PURE__ */ new Map();
51
52
  this.handleIncomingMessage = async (channel, data) => {
@@ -56,7 +57,7 @@ var Redis = class {
56
57
  message.writeVarString(documentName);
57
58
  const document = this.instance.documents.get(documentName);
58
59
  if (!document) return;
59
- new _hocuspocus_server.MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
60
+ await new _hocuspocus_server.MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
60
61
  return this.pub.publish(this.pubKey(document.name), this.encodeMessage(reply));
61
62
  });
62
63
  };
@@ -148,7 +149,7 @@ var Redis = class {
148
149
  if (oldLock?.release) await oldLock.release;
149
150
  this.locks.set(resource, { lock });
150
151
  } catch (error) {
151
- if (error == "ExecutionError: The operation was unable to achieve a quorum during its retry window.") throw new Error("", { cause: "Could not acquire lock, another instance locked it already." });
152
+ if (error instanceof _sesamecare_oss_redlock.ExecutionError && error.message === "The operation was unable to achieve a quorum during its retry window.") throw new _hocuspocus_common.SkipFurtherHooksError("Another instance is already storing this document");
152
153
  console.error("unexpected error:", error);
153
154
  throw error;
154
155
  }
@@ -156,7 +157,7 @@ var Redis = class {
156
157
  /**
157
158
  * Release the Redis lock, so other instances can store documents.
158
159
  */
159
- async afterStoreDocument({ documentName, socketId }) {
160
+ async afterStoreDocument({ documentName, lastTransactionOrigin }) {
160
161
  const lockKey = this.lockKey(documentName);
161
162
  const lock = this.locks.get(lockKey);
162
163
  if (lock) try {
@@ -165,7 +166,7 @@ var Redis = class {
165
166
  } catch {} finally {
166
167
  this.locks.delete(lockKey);
167
168
  }
168
- if (socketId === "server") {
169
+ if ((0, _hocuspocus_server.isTransactionOrigin)(lastTransactionOrigin) && lastTransactionOrigin.source === "local") {
169
170
  const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
170
171
  if (pending) {
171
172
  clearTimeout(pending.timeout);
@@ -200,7 +201,8 @@ var Redis = class {
200
201
  * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
201
202
  */
202
203
  async onChange(data) {
203
- if (data.transactionOrigin !== this.redisTransactionOrigin) return this.publishFirstSyncStep(data.documentName, data.document);
204
+ if ((0, _hocuspocus_server.isTransactionOrigin)(data.transactionOrigin) && data.transactionOrigin.source === "redis") return;
205
+ return this.publishFirstSyncStep(data.documentName, data.document);
204
206
  }
205
207
  /**
206
208
  * Delay unloading to allow syncs to finish
@@ -1 +1 @@
1
- {"version":3,"file":"hocuspocus-redis.cjs","names":["crypto","IncomingMessage","MessageReceiver","RedisClient","Redlock","OutgoingMessage"],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tafterLoadDocumentPayload,\n\tafterStoreDocumentPayload,\n\tafterUnloadDocumentPayload,\n\tbeforeBroadcastStatelessPayload,\n\tbeforeUnloadDocumentPayload,\n\tonAwarenessUpdatePayload,\n\tonChangePayload,\n\tonConfigurePayload,\n\tonStoreDocumentPayload,\n} from \"@hocuspocus/server\";\nimport {\n\tIncomingMessage,\n\tMessageReceiver,\n\tOutgoingMessage,\n} from \"@hocuspocus/server\";\nimport {\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 = \"__hocuspocus__redis__origin__\";\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\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) {\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 ==\n\t\t\t\t\"ExecutionError: 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// No further `onStoreDocument` hooks will be executed; should throw a silent error with no message.\n\t\t\t\tthrow new Error(\"\", {\n\t\t\t\t\tcause: \"Could not acquire lock, another instance locked it already.\",\n\t\t\t\t});\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\tsocketId,\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\t\t// if the change was initiated by a directConnection, 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 (socketId === \"server\") {\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\tnew MessageReceiver(message, this.redisTransactionOrigin).apply(\n\t\t\tdocument,\n\t\t\tundefined,\n\t\t\t(reply) => {\n\t\t\t\treturn this.pub.publish(\n\t\t\t\t\tthis.pubKey(document.name),\n\t\t\t\t\tthis.encodeMessage(reply),\n\t\t\t\t);\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 (data.transactionOrigin !== this.redisTransactionOrigin) {\n\t\t\treturn this.publishFirstSyncStep(data.documentName, data.document);\n\t\t}\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","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,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
- import { IncomingMessage, MessageReceiver, OutgoingMessage } from "@hocuspocus/server";
3
- import { Redlock } from "@sesamecare-oss/redlock";
2
+ import { SkipFurtherHooksError } from "@hocuspocus/common";
3
+ import { IncomingMessage, MessageReceiver, OutgoingMessage, isTransactionOrigin } from "@hocuspocus/server";
4
+ import { ExecutionError, Redlock } from "@sesamecare-oss/redlock";
4
5
  import RedisClient from "ioredis";
5
6
 
6
7
  //#region packages/extension-redis/src/Redis.ts
@@ -15,7 +16,7 @@ var Redis = class {
15
16
  lockTimeout: 1e3,
16
17
  disconnectDelay: 1e3
17
18
  };
18
- this.redisTransactionOrigin = "__hocuspocus__redis__origin__";
19
+ this.redisTransactionOrigin = { source: "redis" };
19
20
  this.locks = /* @__PURE__ */ new Map();
20
21
  this.pendingAfterStoreDocumentResolves = /* @__PURE__ */ new Map();
21
22
  this.handleIncomingMessage = async (channel, data) => {
@@ -26,7 +27,7 @@ var Redis = class {
26
27
  message.writeVarString(documentName);
27
28
  const document = this.instance.documents.get(documentName);
28
29
  if (!document) return;
29
- new MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
30
+ await new MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
30
31
  return this.pub.publish(this.pubKey(document.name), this.encodeMessage(reply));
31
32
  });
32
33
  };
@@ -118,7 +119,7 @@ var Redis = class {
118
119
  if (oldLock?.release) await oldLock.release;
119
120
  this.locks.set(resource, { lock });
120
121
  } catch (error) {
121
- if (error == "ExecutionError: The operation was unable to achieve a quorum during its retry window.") throw new Error("", { cause: "Could not acquire lock, another instance locked it already." });
122
+ if (error instanceof ExecutionError && error.message === "The operation was unable to achieve a quorum during its retry window.") throw new SkipFurtherHooksError("Another instance is already storing this document");
122
123
  console.error("unexpected error:", error);
123
124
  throw error;
124
125
  }
@@ -126,7 +127,7 @@ var Redis = class {
126
127
  /**
127
128
  * Release the Redis lock, so other instances can store documents.
128
129
  */
129
- async afterStoreDocument({ documentName, socketId }) {
130
+ async afterStoreDocument({ documentName, lastTransactionOrigin }) {
130
131
  const lockKey = this.lockKey(documentName);
131
132
  const lock = this.locks.get(lockKey);
132
133
  if (lock) try {
@@ -135,7 +136,7 @@ var Redis = class {
135
136
  } catch {} finally {
136
137
  this.locks.delete(lockKey);
137
138
  }
138
- if (socketId === "server") {
139
+ if (isTransactionOrigin(lastTransactionOrigin) && lastTransactionOrigin.source === "local") {
139
140
  const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
140
141
  if (pending) {
141
142
  clearTimeout(pending.timeout);
@@ -170,7 +171,8 @@ var Redis = class {
170
171
  * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
171
172
  */
172
173
  async onChange(data) {
173
- if (data.transactionOrigin !== this.redisTransactionOrigin) return this.publishFirstSyncStep(data.documentName, data.document);
174
+ if (isTransactionOrigin(data.transactionOrigin) && data.transactionOrigin.source === "redis") return;
175
+ return this.publishFirstSyncStep(data.documentName, data.document);
174
176
  }
175
177
  /**
176
178
  * Delay unloading to allow syncs to finish
@@ -1 +1 @@
1
- {"version":3,"file":"hocuspocus-redis.esm.js","names":[],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tafterLoadDocumentPayload,\n\tafterStoreDocumentPayload,\n\tafterUnloadDocumentPayload,\n\tbeforeBroadcastStatelessPayload,\n\tbeforeUnloadDocumentPayload,\n\tonAwarenessUpdatePayload,\n\tonChangePayload,\n\tonConfigurePayload,\n\tonStoreDocumentPayload,\n} from \"@hocuspocus/server\";\nimport {\n\tIncomingMessage,\n\tMessageReceiver,\n\tOutgoingMessage,\n} from \"@hocuspocus/server\";\nimport {\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 = \"__hocuspocus__redis__origin__\";\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\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) {\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 ==\n\t\t\t\t\"ExecutionError: 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// No further `onStoreDocument` hooks will be executed; should throw a silent error with no message.\n\t\t\t\tthrow new Error(\"\", {\n\t\t\t\t\tcause: \"Could not acquire lock, another instance locked it already.\",\n\t\t\t\t});\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\tsocketId,\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\t\t// if the change was initiated by a directConnection, 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 (socketId === \"server\") {\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\tnew MessageReceiver(message, this.redisTransactionOrigin).apply(\n\t\t\tdocument,\n\t\t\tundefined,\n\t\t\t(reply) => {\n\t\t\t\treturn this.pub.publish(\n\t\t\t\t\tthis.pubKey(document.name),\n\t\t\t\t\tthis.encodeMessage(reply),\n\t\t\t\t);\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 (data.transactionOrigin !== this.redisTransactionOrigin) {\n\t\t\treturn this.publishFirstSyncStep(data.documentName, data.document);\n\t\t}\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\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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"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Extension, Hocuspocus, afterLoadDocumentPayload, afterStoreDocumentPayload, afterUnloadDocumentPayload, beforeBroadcastStatelessPayload, beforeUnloadDocumentPayload, onAwarenessUpdatePayload, onChangePayload, onConfigurePayload, onStoreDocumentPayload } from "@hocuspocus/server";
1
+ import { Extension, Hocuspocus, RedisTransactionOrigin, afterLoadDocumentPayload, afterStoreDocumentPayload, afterUnloadDocumentPayload, beforeBroadcastStatelessPayload, beforeUnloadDocumentPayload, onAwarenessUpdatePayload, onChangePayload, onConfigurePayload, onStoreDocumentPayload } from "@hocuspocus/server";
2
2
  import { ExecutionResult, Lock, Redlock } from "@sesamecare-oss/redlock";
3
3
  import RedisClient, { Cluster, ClusterNode, ClusterOptions, RedisOptions } from "ioredis";
4
4
 
@@ -58,7 +58,7 @@ declare class Redis implements Extension {
58
58
  */
59
59
  priority: number;
60
60
  configuration: Configuration;
61
- redisTransactionOrigin: string;
61
+ redisTransactionOrigin: RedisTransactionOrigin;
62
62
  pub: RedisInstance;
63
63
  sub: RedisInstance;
64
64
  instance: Hocuspocus;
@@ -106,7 +106,7 @@ declare class Redis implements Extension {
106
106
  */
107
107
  afterStoreDocument({
108
108
  documentName,
109
- socketId
109
+ lastTransactionOrigin
110
110
  }: afterStoreDocumentPayload): Promise<void>;
111
111
  /**
112
112
  * Handle awareness update messages received directly by this Hocuspocus instance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hocuspocus/extension-redis",
3
- "version": "3.4.6-rc.2",
3
+ "version": "4.0.0-rc.1",
4
4
  "description": "Scale Hocuspocus horizontally with Redis",
5
5
  "homepage": "https://hocuspocus.dev",
6
6
  "keywords": [
@@ -31,9 +31,10 @@
31
31
  "@types/lodash.debounce": "^4.0.6"
32
32
  },
33
33
  "dependencies": {
34
- "@hocuspocus/server": "^3.4.6-rc.2",
34
+ "@hocuspocus/common": "^4.0.0-rc.1",
35
+ "@hocuspocus/server": "^4.0.0-rc.1",
35
36
  "@sesamecare-oss/redlock": "^1.4.0",
36
- "ioredis": "^5.6.1",
37
+ "ioredis": "~5.6.1",
37
38
  "kleur": "^4.1.4",
38
39
  "lodash.debounce": "^4.0.8"
39
40
  },
@@ -41,8 +42,11 @@
41
42
  "y-protocols": "^1.0.6",
42
43
  "yjs": "^13.6.8"
43
44
  },
44
- "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
45
+ "gitHead": "730ac02724fcc44bbbfcc5849df4b0cafb996450",
45
46
  "repository": {
46
47
  "url": "https://github.com/ueberdosis/hocuspocus"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
47
51
  }
48
52
  }
package/src/Redis.ts CHANGED
@@ -1,24 +1,28 @@
1
1
  import crypto from "node:crypto";
2
2
  import type {
3
- Document,
4
- Extension,
5
- Hocuspocus,
6
3
  afterLoadDocumentPayload,
7
4
  afterStoreDocumentPayload,
8
5
  afterUnloadDocumentPayload,
9
6
  beforeBroadcastStatelessPayload,
10
7
  beforeUnloadDocumentPayload,
8
+ Document,
9
+ Extension,
10
+ Hocuspocus,
11
11
  onAwarenessUpdatePayload,
12
12
  onChangePayload,
13
13
  onConfigurePayload,
14
14
  onStoreDocumentPayload,
15
+ RedisTransactionOrigin,
15
16
  } from "@hocuspocus/server";
17
+ import { SkipFurtherHooksError } from "@hocuspocus/common";
16
18
  import {
17
19
  IncomingMessage,
20
+ isTransactionOrigin,
18
21
  MessageReceiver,
19
22
  OutgoingMessage,
20
23
  } from "@hocuspocus/server";
21
24
  import {
25
+ ExecutionError,
22
26
  type ExecutionResult,
23
27
  type Lock,
24
28
  Redlock,
@@ -95,7 +99,9 @@ export class Redis implements Extension {
95
99
  disconnectDelay: 1000,
96
100
  };
97
101
 
98
- redisTransactionOrigin = "__hocuspocus__redis__origin__";
102
+ redisTransactionOrigin: RedisTransactionOrigin = {
103
+ source: "redis",
104
+ };
99
105
 
100
106
  pub: RedisInstance;
101
107
 
@@ -137,6 +143,7 @@ export class Redis implements Extension {
137
143
  this.pub = new RedisClient(port, host, options ?? {});
138
144
  this.sub = new RedisClient(port, host, options ?? {});
139
145
  }
146
+
140
147
  this.sub.on("messageBuffer", this.handleIncomingMessage);
141
148
 
142
149
  this.redlock = new Redlock([this.pub], {
@@ -252,17 +259,16 @@ export class Redis implements Extension {
252
259
  await oldLock.release;
253
260
  }
254
261
  this.locks.set(resource, { lock });
255
- } catch (error) {
262
+ } catch (error: any) {
256
263
  //based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349
257
264
  if (
258
- error ==
259
- "ExecutionError: The operation was unable to achieve a quorum during its retry window."
265
+ error instanceof ExecutionError &&
266
+ error.message ===
267
+ "The operation was unable to achieve a quorum during its retry window."
260
268
  ) {
261
269
  // Expected behavior: Could not acquire lock, another instance locked it already.
262
- // No further `onStoreDocument` hooks will be executed; should throw a silent error with no message.
263
- throw new Error("", {
264
- cause: "Could not acquire lock, another instance locked it already.",
265
- });
270
+ // Skip further hooks and retry the data is safe on the other instance.
271
+ throw new SkipFurtherHooksError("Another instance is already storing this document");
266
272
  }
267
273
  //unexpected error
268
274
  console.error("unexpected error:", error);
@@ -275,7 +281,7 @@ export class Redis implements Extension {
275
281
  */
276
282
  async afterStoreDocument({
277
283
  documentName,
278
- socketId,
284
+ lastTransactionOrigin,
279
285
  }: afterStoreDocumentPayload) {
280
286
  const lockKey = this.lockKey(documentName);
281
287
  const lock = this.locks.get(lockKey);
@@ -290,9 +296,13 @@ export class Redis implements Extension {
290
296
  this.locks.delete(lockKey);
291
297
  }
292
298
  }
293
- // if the change was initiated by a directConnection, we need to delay this hook to make sure sync can finish first.
299
+
300
+ // 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.
294
301
  // for provider connections, this usually happens in the onDisconnect hook
295
- if (socketId === "server") {
302
+ if (
303
+ isTransactionOrigin(lastTransactionOrigin) &&
304
+ lastTransactionOrigin.source === "local"
305
+ ) {
296
306
  const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
297
307
 
298
308
  if (pending) {
@@ -369,25 +379,27 @@ export class Redis implements Extension {
369
379
  return;
370
380
  }
371
381
 
372
- new MessageReceiver(message, this.redisTransactionOrigin).apply(
373
- document,
374
- undefined,
375
- (reply) => {
376
- return this.pub.publish(
377
- this.pubKey(document.name),
378
- this.encodeMessage(reply),
379
- );
380
- },
381
- );
382
+ const receiver = new MessageReceiver(message, this.redisTransactionOrigin);
383
+ await receiver.apply(document, undefined, (reply) => {
384
+ return this.pub.publish(
385
+ this.pubKey(document.name),
386
+ this.encodeMessage(reply),
387
+ );
388
+ });
382
389
  };
383
390
 
384
391
  /**
385
392
  * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
386
393
  */
387
394
  public async onChange(data: onChangePayload): Promise<any> {
388
- if (data.transactionOrigin !== this.redisTransactionOrigin) {
389
- return this.publishFirstSyncStep(data.documentName, data.document);
395
+ if (
396
+ isTransactionOrigin(data.transactionOrigin) &&
397
+ data.transactionOrigin.source === "redis"
398
+ ) {
399
+ return;
390
400
  }
401
+
402
+ return this.publishFirstSyncStep(data.documentName, data.document);
391
403
  }
392
404
 
393
405
  /**