@hocuspocus/extension-redis 3.4.6-rc.1 → 4.0.0-rc.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/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.
@@ -45,7 +45,7 @@ var Redis = class {
45
45
  lockTimeout: 1e3,
46
46
  disconnectDelay: 1e3
47
47
  };
48
- this.redisTransactionOrigin = "__hocuspocus__redis__origin__";
48
+ this.redisTransactionOrigin = { source: "redis" };
49
49
  this.locks = /* @__PURE__ */ new Map();
50
50
  this.pendingAfterStoreDocumentResolves = /* @__PURE__ */ new Map();
51
51
  this.handleIncomingMessage = async (channel, data) => {
@@ -56,7 +56,7 @@ var Redis = class {
56
56
  message.writeVarString(documentName);
57
57
  const document = this.instance.documents.get(documentName);
58
58
  if (!document) return;
59
- new _hocuspocus_server.MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
59
+ await new _hocuspocus_server.MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
60
60
  return this.pub.publish(this.pubKey(document.name), this.encodeMessage(reply));
61
61
  });
62
62
  };
@@ -148,7 +148,7 @@ var Redis = class {
148
148
  if (oldLock?.release) await oldLock.release;
149
149
  this.locks.set(resource, { lock });
150
150
  } 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." });
151
+ if (error instanceof _sesamecare_oss_redlock.ExecutionError && error.message === "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
152
  console.error("unexpected error:", error);
153
153
  throw error;
154
154
  }
@@ -156,7 +156,7 @@ var Redis = class {
156
156
  /**
157
157
  * Release the Redis lock, so other instances can store documents.
158
158
  */
159
- async afterStoreDocument({ documentName, socketId }) {
159
+ async afterStoreDocument({ documentName, lastTransactionOrigin }) {
160
160
  const lockKey = this.lockKey(documentName);
161
161
  const lock = this.locks.get(lockKey);
162
162
  if (lock) try {
@@ -165,7 +165,7 @@ var Redis = class {
165
165
  } catch {} finally {
166
166
  this.locks.delete(lockKey);
167
167
  }
168
- if (socketId === "server") {
168
+ if ((0, _hocuspocus_server.isTransactionOrigin)(lastTransactionOrigin) && lastTransactionOrigin.source === "local") {
169
169
  const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
170
170
  if (pending) {
171
171
  clearTimeout(pending.timeout);
@@ -200,7 +200,8 @@ var Redis = class {
200
200
  * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
201
201
  */
202
202
  async onChange(data) {
203
- if (data.transactionOrigin !== this.redisTransactionOrigin) return this.publishFirstSyncStep(data.documentName, data.document);
203
+ if ((0, _hocuspocus_server.isTransactionOrigin)(data.transactionOrigin) && data.transactionOrigin.source === "redis") return;
204
+ return this.publishFirstSyncStep(data.documentName, data.document);
204
205
  }
205
206
  /**
206
207
  * 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"],"sources":["../src/Redis.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport type {\n\tDocument,\n\tExtension,\n\tHocuspocus,\n\tRedisTransactionOrigin,\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\tisTransactionOrigin,\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// 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\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmFA,IAAa,QAAb,MAAwC;CAsCvC,AAAO,YAAY,eAAuC;kBAhC/C;uBAEoB;GAC9B,MAAM;GACN,MAAM;GACN,QAAQ;GACR,YAAY,QAAQA,oBAAO,YAAY;GACvC,aAAa;GACb,iBAAiB;GACjB;gCAEgD,EAChD,QAAQ,SACR;+BAUO,IAAI,KAAiE;2DAIjC,IAAI,KAG7C;+BAsP6B,OAAO,SAAiB,SAAiB;GACxE,MAAM,CAAC,YAAY,iBAAiB,KAAK,cAAc,KAAK;AAE5D,OAAI,eAAe,KAAK,cAAc,WACrC;GAGD,MAAM,UAAU,IAAIC,mCAAgB,cAAc;GAClD,MAAM,eAAe,QAAQ,eAAe;AAC5C,WAAQ,eAAe,aAAa;GAEpC,MAAM,WAAW,KAAK,SAAS,UAAU,IAAI,aAAa;AAE1D,OAAI,CAAC,SACJ;AAID,SADiB,IAAIC,mCAAgB,SAAS,KAAK,uBAAuB,CAC3D,MAAM,UAAU,SAAY,UAAU;AACpD,WAAO,KAAK,IAAI,QACf,KAAK,OAAO,SAAS,KAAK,EAC1B,KAAK,cAAc,MAAM,CACzB;KACA;;AA1QF,OAAK,gBAAgB;GACpB,GAAG,KAAK;GACR,GAAG;GACH;EAGD,MAAM,EAAE,MAAM,MAAM,SAAS,OAAO,OAAO,iBAC1C,KAAK;AAEN,MAAI,OAAO,iBAAiB,YAAY;AACvC,QAAK,MAAM,cAAc;AACzB,QAAK,MAAM,cAAc;aACf,OAAO;AACjB,QAAK,MAAM,MAAM,WAAW;AAC5B,QAAK,MAAM,MAAM,WAAW;aAClB,SAAS,MAAM,SAAS,GAAG;AACrC,QAAK,MAAM,IAAIC,gBAAY,QAAQ,OAAO,QAAQ;AAClD,QAAK,MAAM,IAAIA,gBAAY,QAAQ,OAAO,QAAQ;SAC5C;AACN,QAAK,MAAM,IAAIA,gBAAY,MAAM,MAAM,WAAW,EAAE,CAAC;AACrD,QAAK,MAAM,IAAIA,gBAAY,MAAM,MAAM,WAAW,EAAE,CAAC;;AAGtD,OAAK,IAAI,GAAG,iBAAiB,KAAK,sBAAsB;AAExD,OAAK,UAAU,IAAIC,gCAAQ,CAAC,KAAK,IAAI,EAAE,EACtC,YAAY,GACZ,CAAC;EAEF,MAAM,mBAAmB,OAAO,KAC/B,KAAK,cAAc,YACnB,QACA;AACD,OAAK,gBAAgB,OAAO,OAAO,CAClC,OAAO,KAAK,CAAC,iBAAiB,OAAO,CAAC,EACtC,iBACA,CAAC;;CAGH,MAAM,YAAY,EAAE,YAAgC;AACnD,OAAK,WAAW;;CAGjB,AAAQ,OAAO,cAAsB;AACpC,SAAO,GAAG,KAAK,cAAc,OAAO,GAAG;;CAGxC,AAAQ,OAAO,cAAsB;AACpC,SAAO,KAAK,OAAO,aAAa;;CAGjC,AAAQ,OAAO,cAAsB;AACpC,SAAO,KAAK,OAAO,aAAa;;CAGjC,AAAQ,QAAQ,cAAsB;AACrC,SAAO,GAAG,KAAK,OAAO,aAAa,CAAC;;CAGrC,AAAQ,cAAc,SAAqB;AAC1C,SAAO,OAAO,OAAO,CAAC,KAAK,eAAe,OAAO,KAAK,QAAQ,CAAC,CAAC;;CAGjE,AAAQ,cAAc,QAAgB;EACrC,MAAM,mBAAmB,OAAO;AAGhC,SAAO,CAFY,OAAO,SAAS,SAAS,GAAG,mBAAmB,EAAE,EAEhD,OAAO,MAAM,mBAAmB,EAAE,CAAC;;;;;CAMxD,MAAa,kBAAkB,EAC9B,cACA,YAC4B;AAC5B,SAAO,IAAI,SAAS,SAAS,WAAW;AAGvC,QAAK,IAAI,UAAU,KAAK,OAAO,aAAa,EAAE,OAAO,UAAe;AACnE,QAAI,OAAO;AACV,YAAO,MAAM;AACb;;AAGD,SAAK,qBAAqB,cAAc,SAAS;AACjD,SAAK,mCAAmC,aAAa;AAErD,YAAQ,OAAU;KACjB;IACD;;;;;CAMH,MAAc,qBAAqB,cAAsB,UAAoB;EAC5E,MAAM,cAAc,IAAIC,mCAAgB,aAAa,CACnD,mBAAmB,CACnB,sBAAsB,SAAS;AAEjC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,YAAY,cAAc,CAAC,CAC9C;;;;;CAMF,MAAc,mCAAmC,cAAsB;EACtE,MAAM,mBAAmB,IAAIA,mCAC5B,aACA,CAAC,qBAAqB;AAEvB,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,iBAAiB,cAAc,CAAC,CACnD;;;;;;CAOF,MAAM,gBAAgB,EAAE,gBAAwC;EAG/D,MAAM,WAAW,KAAK,QAAQ,aAAa;EAC3C,MAAM,MAAM,KAAK,cAAc;AAC/B,MAAI;GACH,MAAM,OAAO,MAAM,KAAK,QAAQ,QAAQ,CAAC,SAAS,EAAE,IAAI;GACxD,MAAM,UAAU,KAAK,MAAM,IAAI,SAAS;AACxC,OAAI,SAAS,QACZ,OAAM,QAAQ;AAEf,QAAK,MAAM,IAAI,UAAU,EAAE,MAAM,CAAC;WAC1B,OAAY;AAEpB,OACC,iBAAiBC,0CACjB,MAAM,YACL,wEAID,OAAM,IAAI,MAAM,IAAI,EACnB,OAAO,+DACP,CAAC;AAGH,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,IAAID,mCACnB,aACA,CAAC,6BAA6B,WAAW,eAAe;AAEzD,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAqCF,MAAa,SAAS,MAAqC;AAC1D,kDACqB,KAAK,kBAAkB,IAC3C,KAAK,kBAAkB,WAAW,QAElC;AAGD,SAAO,KAAK,qBAAqB,KAAK,cAAc,KAAK,SAAS;;;;;CAMnE,MAAM,qBAAqB,MAAmC;AAC7D,SAAO,IAAI,SAAe,YAAY;AACrC,oBAAiB;AAChB,aAAS;MACP,KAAK,cAAc,gBAAgB;IACrC;;CAGH,MAAM,oBAAoB,MAAkC;AAC3D,MAAI,KAAK,SAAS,UAAU,IAAI,KAAK,aAAa,CAAE;AAEpD,OAAK,IAAI,YAAY,KAAK,OAAO,KAAK,aAAa,GAAG,UAAe;AACpE,OAAI,MACH,SAAQ,MAAM,MAAM;IAEpB;;CAGH,MAAM,yBAAyB,MAAuC;EACrE,MAAM,UAAU,IAAIA,mCACnB,KAAK,aACL,CAAC,wBAAwB,KAAK,QAAQ;AAEvC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,KAAK,aAAa,EAC9B,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAMF,MAAM,YAAY;AACjB,QAAM,KAAK,QAAQ,MAAM;AACzB,OAAK,IAAI,WAAW,MAAM;AAC1B,OAAK,IAAI,WAAW,MAAM"}
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
- import { IncomingMessage, MessageReceiver, OutgoingMessage } from "@hocuspocus/server";
3
- import { Redlock } from "@sesamecare-oss/redlock";
2
+ import { IncomingMessage, MessageReceiver, OutgoingMessage, isTransactionOrigin } from "@hocuspocus/server";
3
+ import { ExecutionError, Redlock } from "@sesamecare-oss/redlock";
4
4
  import RedisClient from "ioredis";
5
5
 
6
6
  //#region packages/extension-redis/src/Redis.ts
@@ -15,7 +15,7 @@ var Redis = class {
15
15
  lockTimeout: 1e3,
16
16
  disconnectDelay: 1e3
17
17
  };
18
- this.redisTransactionOrigin = "__hocuspocus__redis__origin__";
18
+ this.redisTransactionOrigin = { source: "redis" };
19
19
  this.locks = /* @__PURE__ */ new Map();
20
20
  this.pendingAfterStoreDocumentResolves = /* @__PURE__ */ new Map();
21
21
  this.handleIncomingMessage = async (channel, data) => {
@@ -26,7 +26,7 @@ var Redis = class {
26
26
  message.writeVarString(documentName);
27
27
  const document = this.instance.documents.get(documentName);
28
28
  if (!document) return;
29
- new MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
29
+ await new MessageReceiver(message, this.redisTransactionOrigin).apply(document, void 0, (reply) => {
30
30
  return this.pub.publish(this.pubKey(document.name), this.encodeMessage(reply));
31
31
  });
32
32
  };
@@ -118,7 +118,7 @@ var Redis = class {
118
118
  if (oldLock?.release) await oldLock.release;
119
119
  this.locks.set(resource, { lock });
120
120
  } 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." });
121
+ if (error instanceof ExecutionError && error.message === "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
122
  console.error("unexpected error:", error);
123
123
  throw error;
124
124
  }
@@ -126,7 +126,7 @@ var Redis = class {
126
126
  /**
127
127
  * Release the Redis lock, so other instances can store documents.
128
128
  */
129
- async afterStoreDocument({ documentName, socketId }) {
129
+ async afterStoreDocument({ documentName, lastTransactionOrigin }) {
130
130
  const lockKey = this.lockKey(documentName);
131
131
  const lock = this.locks.get(lockKey);
132
132
  if (lock) try {
@@ -135,7 +135,7 @@ var Redis = class {
135
135
  } catch {} finally {
136
136
  this.locks.delete(lockKey);
137
137
  }
138
- if (socketId === "server") {
138
+ if (isTransactionOrigin(lastTransactionOrigin) && lastTransactionOrigin.source === "local") {
139
139
  const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
140
140
  if (pending) {
141
141
  clearTimeout(pending.timeout);
@@ -170,7 +170,8 @@ var Redis = class {
170
170
  * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
171
171
  */
172
172
  async onChange(data) {
173
- if (data.transactionOrigin !== this.redisTransactionOrigin) return this.publishFirstSyncStep(data.documentName, data.document);
173
+ if (isTransactionOrigin(data.transactionOrigin) && data.transactionOrigin.source === "redis") return;
174
+ return this.publishFirstSyncStep(data.documentName, data.document);
174
175
  }
175
176
  /**
176
177
  * 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\tDocument,\n\tExtension,\n\tHocuspocus,\n\tRedisTransactionOrigin,\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\tisTransactionOrigin,\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// 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\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.1",
3
+ "version": "4.0.0-rc.0",
4
4
  "description": "Scale Hocuspocus horizontally with Redis",
5
5
  "homepage": "https://hocuspocus.dev",
6
6
  "keywords": [
@@ -31,9 +31,9 @@
31
31
  "@types/lodash.debounce": "^4.0.6"
32
32
  },
33
33
  "dependencies": {
34
- "@hocuspocus/server": "^3.4.6-rc.1",
34
+ "@hocuspocus/server": "^4.0.0-rc.0",
35
35
  "@sesamecare-oss/redlock": "^1.4.0",
36
- "ioredis": "^5.6.1",
36
+ "ioredis": "~5.6.1",
37
37
  "kleur": "^4.1.4",
38
38
  "lodash.debounce": "^4.0.8"
39
39
  },
@@ -41,8 +41,11 @@
41
41
  "y-protocols": "^1.0.6",
42
42
  "yjs": "^13.6.8"
43
43
  },
44
- "gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
44
+ "gitHead": "3fe6c023877badd74d3ea3e50019b73660e028a4",
45
45
  "repository": {
46
46
  "url": "https://github.com/ueberdosis/hocuspocus"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
47
50
  }
48
51
  }
package/src/Redis.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  Document,
4
4
  Extension,
5
5
  Hocuspocus,
6
+ RedisTransactionOrigin,
6
7
  afterLoadDocumentPayload,
7
8
  afterStoreDocumentPayload,
8
9
  afterUnloadDocumentPayload,
@@ -17,8 +18,10 @@ import {
17
18
  IncomingMessage,
18
19
  MessageReceiver,
19
20
  OutgoingMessage,
21
+ isTransactionOrigin,
20
22
  } from "@hocuspocus/server";
21
23
  import {
24
+ ExecutionError,
22
25
  type ExecutionResult,
23
26
  type Lock,
24
27
  Redlock,
@@ -95,7 +98,9 @@ export class Redis implements Extension {
95
98
  disconnectDelay: 1000,
96
99
  };
97
100
 
98
- redisTransactionOrigin = "__hocuspocus__redis__origin__";
101
+ redisTransactionOrigin: RedisTransactionOrigin = {
102
+ source: "redis",
103
+ };
99
104
 
100
105
  pub: RedisInstance;
101
106
 
@@ -137,6 +142,7 @@ export class Redis implements Extension {
137
142
  this.pub = new RedisClient(port, host, options ?? {});
138
143
  this.sub = new RedisClient(port, host, options ?? {});
139
144
  }
145
+
140
146
  this.sub.on("messageBuffer", this.handleIncomingMessage);
141
147
 
142
148
  this.redlock = new Redlock([this.pub], {
@@ -252,11 +258,12 @@ export class Redis implements Extension {
252
258
  await oldLock.release;
253
259
  }
254
260
  this.locks.set(resource, { lock });
255
- } catch (error) {
261
+ } catch (error: any) {
256
262
  //based on: https://github.com/sesamecare/redlock/blob/508e00dcd1e4d2bc6373ce455f4fe847e98a9aab/src/index.ts#L347-L349
257
263
  if (
258
- error ==
259
- "ExecutionError: The operation was unable to achieve a quorum during its retry window."
264
+ error instanceof ExecutionError &&
265
+ error.message ===
266
+ "The operation was unable to achieve a quorum during its retry window."
260
267
  ) {
261
268
  // Expected behavior: Could not acquire lock, another instance locked it already.
262
269
  // No further `onStoreDocument` hooks will be executed; should throw a silent error with no message.
@@ -275,7 +282,7 @@ export class Redis implements Extension {
275
282
  */
276
283
  async afterStoreDocument({
277
284
  documentName,
278
- socketId,
285
+ lastTransactionOrigin,
279
286
  }: afterStoreDocumentPayload) {
280
287
  const lockKey = this.lockKey(documentName);
281
288
  const lock = this.locks.get(lockKey);
@@ -290,9 +297,13 @@ export class Redis implements Extension {
290
297
  this.locks.delete(lockKey);
291
298
  }
292
299
  }
293
- // if the change was initiated by a directConnection, we need to delay this hook to make sure sync can finish first.
300
+
301
+ // 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
302
  // for provider connections, this usually happens in the onDisconnect hook
295
- if (socketId === "server") {
303
+ if (
304
+ isTransactionOrigin(lastTransactionOrigin) &&
305
+ lastTransactionOrigin.source === "local"
306
+ ) {
296
307
  const pending = this.pendingAfterStoreDocumentResolves.get(documentName);
297
308
 
298
309
  if (pending) {
@@ -369,25 +380,27 @@ export class Redis implements Extension {
369
380
  return;
370
381
  }
371
382
 
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
- );
383
+ const receiver = new MessageReceiver(message, this.redisTransactionOrigin);
384
+ await receiver.apply(document, undefined, (reply) => {
385
+ return this.pub.publish(
386
+ this.pubKey(document.name),
387
+ this.encodeMessage(reply),
388
+ );
389
+ });
382
390
  };
383
391
 
384
392
  /**
385
393
  * if the ydoc changed, we'll need to inform other Hocuspocus servers about it.
386
394
  */
387
395
  public async onChange(data: onChangePayload): Promise<any> {
388
- if (data.transactionOrigin !== this.redisTransactionOrigin) {
389
- return this.publishFirstSyncStep(data.documentName, data.document);
396
+ if (
397
+ isTransactionOrigin(data.transactionOrigin) &&
398
+ data.transactionOrigin.source === "redis"
399
+ ) {
400
+ return;
390
401
  }
402
+
403
+ return this.publishFirstSyncStep(data.documentName, data.document);
391
404
  }
392
405
 
393
406
  /**