@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 +21 -0
- package/dist/hocuspocus-redis.cjs +8 -6
- package/dist/hocuspocus-redis.cjs.map +1 -1
- package/dist/hocuspocus-redis.esm.js +10 -8
- package/dist/hocuspocus-redis.esm.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/package.json +8 -4
- package/src/Redis.ts +38 -26
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 = "
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgFA,IAAa,QAAb,MAAwC;CAoCvC,AAAO,YAAY,eAAuC;kBA9B/C;uBAEoB;GAC9B,MAAM;GACN,MAAM;GACN,QAAQ;GACR,YAAY,QAAQA,oBAAO,YAAY;GACvC,aAAa;GACb,iBAAiB;GACjB;gCAEwB;+BAUjB,IAAI,KAAiE;2DAIjC,IAAI,KAG7C;+BAgP6B,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;AAGD,OAAIC,mCAAgB,SAAS,KAAK,uBAAuB,CAAC,MACzD,UACA,SACC,UAAU;AACV,WAAO,KAAK,IAAI,QACf,KAAK,OAAO,SAAS,KAAK,EAC1B,KAAK,cAAc,MAAM,CACzB;KAEF;;AAvQD,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;;AAEtD,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,OAAO;AAEf,OACC,SACA,wFAIA,OAAM,IAAI,MAAM,IAAI,EACnB,OAAO,+DACP,CAAC;AAGH,WAAQ,MAAM,qBAAqB,MAAM;AACzC,SAAM;;;;;;CAOR,MAAM,mBAAmB,EACxB,cACA,YAC6B;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;;AAK5B,MAAI,aAAa,UAAU;GAC1B,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,IAAIA,mCACnB,aACA,CAAC,6BAA6B,WAAW,eAAe;AAEzD,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,aAAa,EACzB,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAwCF,MAAa,SAAS,MAAqC;AAC1D,MAAI,KAAK,sBAAsB,KAAK,uBACnC,QAAO,KAAK,qBAAqB,KAAK,cAAc,KAAK,SAAS;;;;;CAOpE,MAAM,qBAAqB,MAAmC;AAC7D,SAAO,IAAI,SAAe,YAAY;AACrC,oBAAiB;AAChB,aAAS;MACP,KAAK,cAAc,gBAAgB;IACrC;;CAGH,MAAM,oBAAoB,MAAkC;AAC3D,MAAI,KAAK,SAAS,UAAU,IAAI,KAAK,aAAa,CAAE;AAEpD,OAAK,IAAI,YAAY,KAAK,OAAO,KAAK,aAAa,GAAG,UAAe;AACpE,OAAI,MACH,SAAQ,MAAM,MAAM;IAEpB;;CAGH,MAAM,yBAAyB,MAAuC;EACrE,MAAM,UAAU,IAAIA,mCACnB,KAAK,aACL,CAAC,wBAAwB,KAAK,QAAQ;AAEvC,SAAO,KAAK,IAAI,QACf,KAAK,OAAO,KAAK,aAAa,EAC9B,KAAK,cAAc,QAAQ,cAAc,CAAC,CAC1C;;;;;CAMF,MAAM,YAAY;AACjB,QAAM,KAAK,QAAQ,MAAM;AACzB,OAAK,IAAI,WAAW,MAAM;AAC1B,OAAK,IAAI,WAAW,MAAM"}
|
|
1
|
+
{"version":3,"file":"hocuspocus-redis.cjs","names":["crypto","IncomingMessage","MessageReceiver","RedisClient","Redlock","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 {
|
|
3
|
-
import {
|
|
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 = "
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
+
"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/
|
|
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": "
|
|
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": "
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
263
|
-
throw new
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 (
|
|
389
|
-
|
|
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
|
/**
|