@tldraw/sync-core 5.5.0-next.f9308d324e1c → 5.6.0-canary.5bfc37740468
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/dist-cjs/index.d.ts +8 -4
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +46 -18
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
- package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
- package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +9 -8
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +8 -1
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +82 -50
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncStorage.js +2 -2
- package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
- package/dist-cjs/lib/chunk.js +35 -3
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js +15 -7
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-esm/index.d.mts +8 -4
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
- package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +10 -9
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +8 -1
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +82 -50
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs +2 -2
- package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +35 -3
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs +16 -8
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/package.json +22 -18
- package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
- package/src/lib/ClientWebSocketAdapter.ts +62 -23
- package/src/lib/InMemorySyncStorage.test.ts +21 -0
- package/src/lib/InMemorySyncStorage.ts +22 -24
- package/src/lib/SQLiteSyncStorage.test.ts +60 -1
- package/src/lib/SQLiteSyncStorage.ts +24 -16
- package/src/lib/TLSocketRoom.ts +16 -9
- package/src/lib/TLSyncClient.test.ts +61 -0
- package/src/lib/TLSyncClient.ts +11 -0
- package/src/lib/TLSyncRoom.ts +122 -54
- package/src/lib/TLSyncStorage.ts +7 -2
- package/src/lib/chunk.test.ts +88 -1
- package/src/lib/chunk.ts +66 -3
- package/src/lib/diff.test.ts +91 -1
- package/src/lib/diff.ts +30 -9
- package/src/test/TLSocketRoom.test.ts +63 -0
- package/src/test/TLSyncRoom.test.ts +300 -0
- package/src/test/objectStore.test.ts +35 -0
- package/src/test/upgradeDowngrade.test.ts +21 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/chunk.ts"],
|
|
4
|
-
"sourcesContent": ["// quarter of a megabyte, max possible utf-8 string size\n\n// cloudflare workers only accept messages of max 1mb\nconst MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024\n// utf-8 is max 4 bytes per char\nconst MAX_BYTES_PER_CHAR = 4\n\n// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte\nconst MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR\n\n/**\n * Splits a string into smaller chunks suitable for transmission over WebSockets.\n * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).\n * Each chunk is prefixed with a number indicating how many more chunks follow.\n *\n * @param msg - The string to split into chunks\n * @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding\n * @returns Array of chunked strings, each prefixed with \"\\{number\\}_\" where number indicates remaining chunks\n *\n * @example\n * ```ts\n * // Small message - returns as single chunk\n * chunk('hello world') // ['hello world']\n *\n * // Large message - splits into multiple chunks\n * chunk('very long message...', 10)\n * // ['2_very long', '1_ message', '0_...']\n * ```\n *\n * @internal\n */\nexport function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {\n\tif (msg.length < maxSafeMessageSize) {\n\t\treturn [msg]\n\t} else {\n\t\tconst chunks = []\n\t\tlet chunkNumber = 0\n\t\tlet offset = msg.length\n\t\twhile (offset > 0) {\n\t\t\tconst prefix = `${chunkNumber}_`\n\t\t\
|
|
5
|
-
"mappings": "AAGA,MAAM,qCAAqC,OAAO;AAElD,MAAM,qBAAqB;AAG3B,MAAM,wBAAwB,qCAAqC;
|
|
4
|
+
"sourcesContent": ["// quarter of a megabyte, max possible utf-8 string size\n\n// cloudflare workers only accept messages of max 1mb\nconst MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024\n// utf-8 is max 4 bytes per char\nconst MAX_BYTES_PER_CHAR = 4\n\n// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte\nconst MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR\n\n// JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender\n// decides how many that is \u2014 the count is just a prefix on the first chunk. Nothing in the\n// protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection\n// can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.\n//\n// The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the\n// 32MB of retained string data a room can afford to hold per session and stay inside a Durable\n// Object's 128MB instance limit with room for the document itself. It is far above anything\n// chunk() produces for a real document push. The character cap is the real bound; the chunk-count\n// cap only rejects an absurd declared count on arrival instead of waiting for the characters to\n// accumulate, and is loose enough not to constrain a sender that chunks more finely than the\n// default.\nexport const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024\nexport const MAX_CHUNK_COUNT = 100_000\n\n/**\n * Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.\n * Distinct from the other assembly errors because it is fatal: retrying sends the same\n * oversized message, so the session is rejected rather than reset.\n *\n * @internal\n */\nexport class MessageTooLargeError extends Error {\n\tconstructor(chars: number) {\n\t\tsuper(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)\n\t\tthis.name = 'MessageTooLargeError'\n\t}\n}\n\n/**\n * Splits a string into smaller chunks suitable for transmission over WebSockets.\n * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).\n * Each chunk is prefixed with a number indicating how many more chunks follow.\n *\n * @param msg - The string to split into chunks\n * @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding\n * @returns Array of chunked strings, each prefixed with \"\\{number\\}_\" where number indicates remaining chunks\n *\n * @example\n * ```ts\n * // Small message - returns as single chunk\n * chunk('hello world') // ['hello world']\n *\n * // Large message - splits into multiple chunks\n * chunk('very long message...', 10)\n * // ['2_very long', '1_ message', '0_...']\n * ```\n *\n * @internal\n */\nexport function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {\n\tif (msg.length < maxSafeMessageSize) {\n\t\treturn [msg]\n\t} else {\n\t\tconst chunks = []\n\t\tlet chunkNumber = 0\n\t\tlet offset = msg.length\n\t\twhile (offset > 0) {\n\t\t\tconst prefix = `${chunkNumber}_`\n\t\t\tlet chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)\n\t\t\t// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a\n\t\t\t// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently\n\t\t\t// carries corrupted text. Shrink by one so the pair moves whole into the preceding\n\t\t\t// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).\n\t\t\tif (\n\t\t\t\tchunkSize < offset &&\n\t\t\t\tisLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&\n\t\t\t\tisHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))\n\t\t\t) {\n\t\t\t\tchunkSize += chunkSize > 1 ? -1 : 1\n\t\t\t}\n\t\t\tchunks.unshift(prefix + msg.slice(offset - chunkSize, offset))\n\t\t\toffset -= chunkSize\n\t\t\tchunkNumber++\n\t\t}\n\t\treturn chunks\n\t}\n}\n\nfunction isHighSurrogate(code: number) {\n\treturn code >= 0xd800 && code <= 0xdbff\n}\n\nfunction isLowSurrogate(code: number) {\n\treturn code >= 0xdc00 && code <= 0xdfff\n}\n\n// The 's' flag (dotAll) makes '.' match any character including line terminators\n// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word\nconst chunkRe = /^(\\d+)_(.*)$/s\n\n/**\n * Assembles chunked JSON messages back into complete objects.\n * Handles both regular JSON messages and chunked messages created by the chunk() function.\n * Maintains internal state to track partially received chunked messages.\n *\n * @example\n * ```ts\n * const assembler = new JsonChunkAssembler()\n *\n * // Handle regular JSON message\n * const result1 = assembler.handleMessage('{\"hello\": \"world\"}')\n * // Returns: { data: { hello: \"world\" }, stringified: '{\"hello\": \"world\"}' }\n *\n * // Handle chunked message\n * assembler.handleMessage('1_hello') // Returns: null (partial)\n * const result2 = assembler.handleMessage('0_ world')\n * // Returns: { data: \"hello world\", stringified: \"hello world\" }\n * ```\n *\n * @public\n */\nexport class JsonChunkAssembler {\n\t/**\n\t * Current assembly state - either 'idle' or tracking chunks being received\n\t */\n\tstate:\n\t\t| 'idle'\n\t\t| {\n\t\t\t\tchunksReceived: string[]\n\t\t\t\ttotalChunks: number\n\t\t\t\tcharsReceived: number\n\t\t } = 'idle'\n\n\t/**\n\t * Processes a single message, which can be either a complete JSON object or a chunk.\n\t * For complete JSON objects (starting with '\\{'), parses immediately.\n\t * For chunks (prefixed with \"\\{number\\}_\"), accumulates until all chunks received.\n\t *\n\t * @param msg - The message to process, either JSON or chunk format\n\t * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks\n\t * \t- `\\{ data: object, stringified: string \\}` - Successfully parsed complete message\n\t * \t- `\\{ error: Error \\}` - Parse error, invalid chunk sequence, or a message exceeding\n\t * \t MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT\n\t * \t- `null` - Chunk received but more chunks expected\n\t *\n\t * @example\n\t * ```ts\n\t * const assembler = new JsonChunkAssembler()\n\t *\n\t * // Complete JSON message\n\t * const result = assembler.handleMessage('{\"key\": \"value\"}')\n\t * if (result && 'data' in result) {\n\t * console.log(result.data) // { key: \"value\" }\n\t * }\n\t *\n\t * // Chunked message sequence\n\t * assembler.handleMessage('2_hel') // null - more chunks expected\n\t * assembler.handleMessage('1_lo ') // null - more chunks expected\n\t * assembler.handleMessage('0_wor') // { data: \"hello wor\", stringified: \"hello wor\" }\n\t * ```\n\t */\n\thandleMessage(msg: string): { error: Error } | { stringified: string; data: object } | null {\n\t\tif (msg.startsWith('{')) {\n\t\t\tconst error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message')\n\t\t\tthis.state = 'idle'\n\t\t\treturn error ? { error } : { data: JSON.parse(msg), stringified: msg }\n\t\t} else {\n\t\t\tconst match = chunkRe.exec(msg)!\n\t\t\tif (!match) {\n\t\t\t\tthis.state = 'idle'\n\t\t\t\treturn { error: new Error('Invalid chunk: ' + JSON.stringify(msg.slice(0, 20) + '...')) }\n\t\t\t}\n\t\t\tconst numChunksRemaining = Number(match[1])\n\t\t\tconst data = match[2]\n\n\t\t\tif (this.state === 'idle') {\n\t\t\t\tconst totalChunks = numChunksRemaining + 1\n\t\t\t\tif (totalChunks > MAX_CHUNK_COUNT) {\n\t\t\t\t\t// Nothing has been buffered yet, so the state is already idle.\n\t\t\t\t\treturn { error: new Error(`Too many chunks: ${totalChunks}`) }\n\t\t\t\t}\n\t\t\t\tthis.state = {\n\t\t\t\t\tchunksReceived: [data],\n\t\t\t\t\ttotalChunks,\n\t\t\t\t\tcharsReceived: data.length,\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.state.chunksReceived.push(data)\n\t\t\t\tthis.state.charsReceived += data.length\n\t\t\t\tif (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {\n\t\t\t\t\tthis.state = 'idle'\n\t\t\t\t\treturn { error: new Error(`Chunks received in wrong order`) }\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {\n\t\t\t\t// Drop what was buffered rather than keeping it until the socket closes.\n\t\t\t\tconst charsReceived = this.state.charsReceived\n\t\t\t\tthis.state = 'idle'\n\t\t\t\treturn { error: new MessageTooLargeError(charsReceived) }\n\t\t\t}\n\t\t\tif (this.state.chunksReceived.length === this.state.totalChunks) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stringified = this.state.chunksReceived.join('')\n\t\t\t\t\tconst data = JSON.parse(stringified)\n\t\t\t\t\treturn { data, stringified }\n\t\t\t\t} catch (e) {\n\t\t\t\t\treturn { error: e as Error }\n\t\t\t\t} finally {\n\t\t\t\t\tthis.state = 'idle'\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null\n\t\t}\n\t}\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["data"]
|
|
7
7
|
}
|
package/dist-esm/lib/diff.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isEqual, objectMapEntries, objectMapValues } from "@tldraw/utils";
|
|
1
|
+
import { hasOwnProperty, isEqual, objectMapEntries, objectMapValues } from "@tldraw/utils";
|
|
2
2
|
const RecordOpType = {
|
|
3
3
|
Put: "put",
|
|
4
4
|
Patch: "patch",
|
|
@@ -38,13 +38,15 @@ function diffObject(prev, next, nestedKeys, legacyAppendMode) {
|
|
|
38
38
|
}
|
|
39
39
|
let result = null;
|
|
40
40
|
for (const key of Object.keys(prev)) {
|
|
41
|
-
if (
|
|
41
|
+
if (key === "__proto__") continue;
|
|
42
|
+
const prevValue = prev[key];
|
|
43
|
+
if (prevValue === void 0) continue;
|
|
44
|
+
const nextValue = hasOwnProperty(next, key) ? next[key] : void 0;
|
|
45
|
+
if (nextValue === void 0) {
|
|
42
46
|
if (!result) result = {};
|
|
43
47
|
result[key] = [ValueOpType.Delete];
|
|
44
48
|
continue;
|
|
45
49
|
}
|
|
46
|
-
const prevValue = prev[key];
|
|
47
|
-
const nextValue = next[key];
|
|
48
50
|
if (nestedKeys?.has(key) || Array.isArray(prevValue) && Array.isArray(nextValue) || typeof prevValue === "string" && typeof nextValue === "string") {
|
|
49
51
|
const diff = diffValue(prevValue, nextValue, legacyAppendMode);
|
|
50
52
|
if (diff) {
|
|
@@ -57,9 +59,12 @@ function diffObject(prev, next, nestedKeys, legacyAppendMode) {
|
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
for (const key of Object.keys(next)) {
|
|
60
|
-
if (
|
|
62
|
+
if (key === "__proto__") continue;
|
|
63
|
+
const nextValue = next[key];
|
|
64
|
+
if (nextValue === void 0) continue;
|
|
65
|
+
if (!hasOwnProperty(prev, key) || prev[key] === void 0) {
|
|
61
66
|
if (!result) result = {};
|
|
62
|
-
result[key] = [ValueOpType.Put,
|
|
67
|
+
result[key] = [ValueOpType.Put, nextValue];
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
return result;
|
|
@@ -74,7 +79,9 @@ function diffValue(valueA, valueB, legacyAppendMode) {
|
|
|
74
79
|
return [ValueOpType.Append, appendedText, valueA.length];
|
|
75
80
|
}
|
|
76
81
|
return [ValueOpType.Put, valueB];
|
|
77
|
-
} else if (!valueA || !valueB || typeof valueA !== "object" || typeof valueB !== "object"
|
|
82
|
+
} else if (!valueA || !valueB || typeof valueA !== "object" || typeof valueB !== "object" || // an array on one side and a plain object on the other can't be expressed as a patch:
|
|
83
|
+
// index deletes and named puts don't apply cleanly to either shape
|
|
84
|
+
Array.isArray(valueA) !== Array.isArray(valueB)) {
|
|
78
85
|
return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB];
|
|
79
86
|
} else {
|
|
80
87
|
const diff = diffObject(valueA, valueB, void 0, legacyAppendMode);
|
|
@@ -140,6 +147,7 @@ function applyObjectDiff(object, objectDiff) {
|
|
|
140
147
|
}
|
|
141
148
|
};
|
|
142
149
|
for (const [key, op] of Object.entries(objectDiff)) {
|
|
150
|
+
if (key === "__proto__") continue;
|
|
143
151
|
switch (op[0]) {
|
|
144
152
|
case ValueOpType.Put: {
|
|
145
153
|
const value = op[1];
|
|
@@ -170,7 +178,7 @@ function applyObjectDiff(object, objectDiff) {
|
|
|
170
178
|
break;
|
|
171
179
|
}
|
|
172
180
|
case ValueOpType.Delete: {
|
|
173
|
-
if (key
|
|
181
|
+
if (hasOwnProperty(object, key)) {
|
|
174
182
|
if (!newObject) {
|
|
175
183
|
if (isArray) {
|
|
176
184
|
console.error("Can't delete array item yet (this should never happen)");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/diff.ts"],
|
|
4
|
-
"sourcesContent": ["import { RecordsDiff, UnknownRecord } from '@tldraw/store'\nimport { isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'\n\n/**\n * Constants representing the types of operations that can be applied to records in network diffs.\n * These operations describe how a record has been modified during synchronization.\n *\n * @internal\n */\nexport const RecordOpType = {\n\tPut: 'put',\n\tPatch: 'patch',\n\tRemove: 'remove',\n} as const\n\n/**\n * Union type of all possible record operation types.\n *\n * @internal\n */\nexport type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType]\n\n/**\n * Represents a single operation to be applied to a record during synchronization.\n *\n * @param R - The record type being operated on\n *\n * @internal\n */\nexport type RecordOp<R extends UnknownRecord> =\n\t| [typeof RecordOpType.Put, R]\n\t| [typeof RecordOpType.Patch, ObjectDiff]\n\t| [typeof RecordOpType.Remove]\n\n/**\n * A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to\n * be sent over the wire. Either as push requests from the client to the server, or as patch\n * operations in the opposite direction.\n *\n * Each key in this object is the id of a record that has been added, updated, or removed.\n *\n * @internal\n */\nexport interface NetworkDiff<R extends UnknownRecord> {\n\t[id: string]: RecordOp<R>\n}\n\n/**\n * Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff\n * suitable for transmission over the network. This function optimizes the diff representation\n * for minimal bandwidth usage while maintaining all necessary change information.\n *\n * @param diff - The RecordsDiff containing added, updated, and removed records\n * @returns A compact NetworkDiff for network transmission, or null if no changes exist\n *\n * @example\n * ```ts\n * const recordsDiff = {\n * added: { 'shape:1': newShape },\n * updated: { 'shape:2': [oldShape, updatedShape] },\n * removed: { 'shape:3': removedShape }\n * }\n *\n * const networkDiff = getNetworkDiff(recordsDiff)\n * // Returns: {\n * // 'shape:1': ['put', newShape],\n * // 'shape:2': ['patch', { x: ['put', 100] }],\n * // 'shape:3': ['remove']\n * // }\n * ```\n *\n * @internal\n */\nexport function getNetworkDiff<R extends UnknownRecord>(\n\tdiff: RecordsDiff<R>\n): NetworkDiff<R> | null {\n\tlet res: NetworkDiff<R> | null = null\n\n\tfor (const [k, v] of objectMapEntries(diff.added)) {\n\t\tif (!res) res = {}\n\t\tres[k] = [RecordOpType.Put, v]\n\t}\n\n\tfor (const [from, to] of objectMapValues(diff.updated)) {\n\t\tconst diff = diffRecord(from, to)\n\t\tif (diff) {\n\t\t\tif (!res) res = {}\n\t\t\tres[to.id] = [RecordOpType.Patch, diff]\n\t\t}\n\t}\n\n\tfor (const removed of Object.keys(diff.removed)) {\n\t\tif (!res) res = {}\n\t\tres[removed] = [RecordOpType.Remove]\n\t}\n\n\treturn res\n}\n\n/**\n * Constants representing the types of operations that can be applied to individual values\n * within object diffs. These operations describe how object properties have changed.\n *\n * @internal\n */\nexport const ValueOpType = {\n\tPut: 'put',\n\tDelete: 'delete',\n\tAppend: 'append',\n\tPatch: 'patch',\n} as const\n/**\n * Union type of all possible value operation types.\n *\n * @internal\n */\nexport type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]\n\n/**\n * Operation that replaces a value entirely with a new value.\n *\n * @internal\n */\nexport type PutOp = [type: typeof ValueOpType.Put, value: unknown]\n/**\n * Operation that appends new values to the end of an array or string.\n *\n * @internal\n */\nexport type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]\n/**\n * Operation that applies a nested diff to an object or array.\n *\n * @internal\n */\nexport type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff]\n/**\n * Operation that removes a property from an object.\n *\n * @internal\n */\nexport type DeleteOp = [type: typeof ValueOpType.Delete]\n\n/**\n * Union type representing any value operation that can be applied during diffing.\n *\n * @internal\n */\nexport type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp\n\n/**\n * Represents the differences between two objects as a mapping of property names\n * to the operations needed to transform one object into another.\n *\n * @internal\n */\nexport interface ObjectDiff {\n\t[k: string]: ValueOp\n}\n\n/**\n * Computes the difference between two record objects, generating an ObjectDiff\n * that describes how to transform the previous record into the next record.\n * This function is optimized for tldraw records and treats 'props' as a nested object.\n *\n * @param prev - The previous version of the record\n * @param next - The next version of the record\n * @param legacyAppendMode - If true, string append operations will be converted to Put operations\n * @returns An ObjectDiff describing the changes, or null if no changes exist\n *\n * @example\n * ```ts\n * const oldShape = { id: 'shape:1', x: 100, y: 200, props: { color: 'red' } }\n * const newShape = { id: 'shape:1', x: 150, y: 200, props: { color: 'blue' } }\n *\n * const diff = diffRecord(oldShape, newShape)\n * // Returns: {\n * // x: ['put', 150],\n * // props: ['patch', { color: ['put', 'blue'] }]\n * // }\n * ```\n *\n * @internal\n */\nexport function diffRecord(\n\tprev: object,\n\tnext: object,\n\tlegacyAppendMode = false\n): ObjectDiff | null {\n\treturn diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)\n}\n\nfunction diffObject(\n\tprev: object,\n\tnext: object,\n\tnestedKeys: Set<string> | undefined,\n\tlegacyAppendMode: boolean\n): ObjectDiff | null {\n\tif (prev === next) {\n\t\treturn null\n\t}\n\tlet result: ObjectDiff | null = null\n\tfor (const key of Object.keys(prev)) {\n\t\t// if key is not in next then it was deleted\n\t\tif (!(key in next)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Delete]\n\t\t\tcontinue\n\t\t}\n\t\tconst prevValue = (prev as any)[key]\n\t\tconst nextValue = (next as any)[key]\n\t\tif (\n\t\t\tnestedKeys?.has(key) ||\n\t\t\t(Array.isArray(prevValue) && Array.isArray(nextValue)) ||\n\t\t\t(typeof prevValue === 'string' && typeof nextValue === 'string')\n\t\t) {\n\t\t\t// if key is in both places, then compare values\n\t\t\tconst diff = diffValue(prevValue, nextValue, legacyAppendMode)\n\t\t\tif (diff) {\n\t\t\t\tif (!result) result = {}\n\t\t\t\tresult[key] = diff\n\t\t\t}\n\t\t} else if (!isEqual(prevValue, nextValue)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, nextValue]\n\t\t}\n\t}\n\tfor (const key of Object.keys(next)) {\n\t\t// if key is in next but not in prev then it was added\n\t\tif (!(key in prev)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, (next as any)[key]]\n\t\t}\n\t}\n\treturn result\n}\n\nfunction diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {\n\tif (Object.is(valueA, valueB)) return null\n\tif (Array.isArray(valueA) && Array.isArray(valueB)) {\n\t\treturn diffArray(valueA, valueB, legacyAppendMode)\n\t} else if (typeof valueA === 'string' && typeof valueB === 'string') {\n\t\tif (!legacyAppendMode && valueB.startsWith(valueA)) {\n\t\t\tconst appendedText = valueB.slice(valueA.length)\n\t\t\treturn [ValueOpType.Append, appendedText, valueA.length]\n\t\t}\n\t\treturn [ValueOpType.Put, valueB]\n\t} else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {\n\t\treturn isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]\n\t} else {\n\t\tconst diff = diffObject(valueA, valueB, undefined, legacyAppendMode)\n\t\treturn diff ? [ValueOpType.Patch, diff] : null\n\t}\n}\n\nfunction diffArray(\n\tprevArray: unknown[],\n\tnextArray: unknown[],\n\tlegacyAppendMode: boolean\n): PutOp | AppendOp | PatchOp | null {\n\tif (Object.is(prevArray, nextArray)) return null\n\t// if lengths are equal, check for patch operation\n\tif (prevArray.length === nextArray.length) {\n\t\t// bail out if more than len/5 items need patching\n\t\tconst maxPatchIndexes = Math.max(prevArray.length / 5, 1)\n\t\tconst toPatchIndexes = []\n\t\tfor (let i = 0; i < prevArray.length; i++) {\n\t\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\t\ttoPatchIndexes.push(i)\n\t\t\t\tif (toPatchIndexes.length > maxPatchIndexes) {\n\t\t\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (toPatchIndexes.length === 0) {\n\t\t\t// same length and no items changed, so no diff\n\t\t\treturn null\n\t\t}\n\t\tconst diff: ObjectDiff = {}\n\t\tfor (const i of toPatchIndexes) {\n\t\t\tconst prevItem = prevArray[i]\n\t\t\tconst nextItem = nextArray[i]\n\t\t\tif (!prevItem || !nextItem) {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {\n\t\t\t\tconst op = diffValue(prevItem, nextItem, legacyAppendMode)\n\t\t\t\tif (op) {\n\t\t\t\t\tdiff[i] = op\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t}\n\t\t}\n\t\treturn [ValueOpType.Patch, diff]\n\t}\n\n\t// if lengths are not equal, check for append operation, and bail out\n\t// to replace whole array if any shared elems changed\n\tfor (let i = 0; i < prevArray.length; i++) {\n\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t}\n\t}\n\n\treturn [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length]\n}\n\n/**\n * Applies an ObjectDiff to an object, returning a new object with the changes applied.\n * This function handles all value operation types and creates a shallow copy when modifications\n * are needed. If no changes are required, the original object is returned.\n *\n * @param object - The object to apply the diff to\n * @param objectDiff - The ObjectDiff containing the operations to apply\n * @returns A new object with the diff applied, or the original object if no changes were needed\n *\n * @example\n * ```ts\n * const original = { x: 100, y: 200, props: { color: 'red' } }\n * const diff = {\n * x: ['put', 150],\n * props: ['patch', { color: ['put', 'blue'] }]\n * }\n *\n * const updated = applyObjectDiff(original, diff)\n * // Returns: { x: 150, y: 200, props: { color: 'blue' } }\n * ```\n *\n * @internal\n */\nexport function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T {\n\t// don't patch nulls\n\tif (!object || typeof object !== 'object') return object\n\tconst isArray = Array.isArray(object)\n\tlet newObject: any | undefined = undefined\n\tconst set = (k: any, v: any) => {\n\t\tif (!newObject) {\n\t\t\tif (isArray) {\n\t\t\t\tnewObject = [...object]\n\t\t\t} else {\n\t\t\t\tnewObject = { ...object }\n\t\t\t}\n\t\t}\n\t\tif (isArray) {\n\t\t\tnewObject[Number(k)] = v\n\t\t} else {\n\t\t\tnewObject[k] = v\n\t\t}\n\t}\n\tfor (const [key, op] of Object.entries(objectDiff)) {\n\t\tswitch (op[0]) {\n\t\t\tcase ValueOpType.Put: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tif (!isEqual(object[key as keyof T], value)) {\n\t\t\t\t\tset(key, value)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Append: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tconst offset = op[2]\n\t\t\t\tconst currentValue = object[key as keyof T]\n\t\t\t\tif (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {\n\t\t\t\t\tset(key, [...currentValue, ...value])\n\t\t\t\t} else if (\n\t\t\t\t\ttypeof currentValue === 'string' &&\n\t\t\t\t\ttypeof value === 'string' &&\n\t\t\t\t\tcurrentValue.length === offset\n\t\t\t\t) {\n\t\t\t\t\tset(key, currentValue + value)\n\t\t\t\t}\n\t\t\t\t// If validation fails (type mismatch or length mismatch), silently ignore\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Patch: {\n\t\t\t\tif (object[key as keyof T] && typeof object[key as keyof T] === 'object') {\n\t\t\t\t\tconst diff = op[1]\n\t\t\t\t\tconst patched = applyObjectDiff(object[key as keyof T] as object, diff)\n\t\t\t\t\tif (patched !== object[key as keyof T]) {\n\t\t\t\t\t\tset(key, patched)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Delete: {\n\t\t\t\tif (key in object) {\n\t\t\t\t\tif (!newObject) {\n\t\t\t\t\t\tif (isArray) {\n\t\t\t\t\t\t\tconsole.error(\"Can't delete array item yet (this should never happen)\")\n\t\t\t\t\t\t\tnewObject = [...object]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewObject = { ...object }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdelete newObject[key]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn newObject ?? object\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,SAAS,kBAAkB,uBAAuB;
|
|
4
|
+
"sourcesContent": ["import { RecordsDiff, UnknownRecord } from '@tldraw/store'\nimport { hasOwnProperty, isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'\n\n/**\n * Constants representing the types of operations that can be applied to records in network diffs.\n * These operations describe how a record has been modified during synchronization.\n *\n * @internal\n */\nexport const RecordOpType = {\n\tPut: 'put',\n\tPatch: 'patch',\n\tRemove: 'remove',\n} as const\n\n/**\n * Union type of all possible record operation types.\n *\n * @internal\n */\nexport type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType]\n\n/**\n * Represents a single operation to be applied to a record during synchronization.\n *\n * @param R - The record type being operated on\n *\n * @internal\n */\nexport type RecordOp<R extends UnknownRecord> =\n\t| [typeof RecordOpType.Put, R]\n\t| [typeof RecordOpType.Patch, ObjectDiff]\n\t| [typeof RecordOpType.Remove]\n\n/**\n * A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to\n * be sent over the wire. Either as push requests from the client to the server, or as patch\n * operations in the opposite direction.\n *\n * Each key in this object is the id of a record that has been added, updated, or removed.\n *\n * @internal\n */\nexport interface NetworkDiff<R extends UnknownRecord> {\n\t[id: string]: RecordOp<R>\n}\n\n/**\n * Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff\n * suitable for transmission over the network. This function optimizes the diff representation\n * for minimal bandwidth usage while maintaining all necessary change information.\n *\n * @param diff - The RecordsDiff containing added, updated, and removed records\n * @returns A compact NetworkDiff for network transmission, or null if no changes exist\n *\n * @example\n * ```ts\n * const recordsDiff = {\n * added: { 'shape:1': newShape },\n * updated: { 'shape:2': [oldShape, updatedShape] },\n * removed: { 'shape:3': removedShape }\n * }\n *\n * const networkDiff = getNetworkDiff(recordsDiff)\n * // Returns: {\n * // 'shape:1': ['put', newShape],\n * // 'shape:2': ['patch', { x: ['put', 100] }],\n * // 'shape:3': ['remove']\n * // }\n * ```\n *\n * @internal\n */\nexport function getNetworkDiff<R extends UnknownRecord>(\n\tdiff: RecordsDiff<R>\n): NetworkDiff<R> | null {\n\tlet res: NetworkDiff<R> | null = null\n\n\tfor (const [k, v] of objectMapEntries(diff.added)) {\n\t\tif (!res) res = {}\n\t\tres[k] = [RecordOpType.Put, v]\n\t}\n\n\tfor (const [from, to] of objectMapValues(diff.updated)) {\n\t\tconst diff = diffRecord(from, to)\n\t\tif (diff) {\n\t\t\tif (!res) res = {}\n\t\t\tres[to.id] = [RecordOpType.Patch, diff]\n\t\t}\n\t}\n\n\tfor (const removed of Object.keys(diff.removed)) {\n\t\tif (!res) res = {}\n\t\tres[removed] = [RecordOpType.Remove]\n\t}\n\n\treturn res\n}\n\n/**\n * Constants representing the types of operations that can be applied to individual values\n * within object diffs. These operations describe how object properties have changed.\n *\n * @internal\n */\nexport const ValueOpType = {\n\tPut: 'put',\n\tDelete: 'delete',\n\tAppend: 'append',\n\tPatch: 'patch',\n} as const\n/**\n * Union type of all possible value operation types.\n *\n * @internal\n */\nexport type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]\n\n/**\n * Operation that replaces a value entirely with a new value.\n *\n * @internal\n */\nexport type PutOp = [type: typeof ValueOpType.Put, value: unknown]\n/**\n * Operation that appends new values to the end of an array or string.\n *\n * @internal\n */\nexport type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]\n/**\n * Operation that applies a nested diff to an object or array.\n *\n * @internal\n */\nexport type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff]\n/**\n * Operation that removes a property from an object.\n *\n * @internal\n */\nexport type DeleteOp = [type: typeof ValueOpType.Delete]\n\n/**\n * Union type representing any value operation that can be applied during diffing.\n *\n * @internal\n */\nexport type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp\n\n/**\n * Represents the differences between two objects as a mapping of property names\n * to the operations needed to transform one object into another.\n *\n * @internal\n */\nexport interface ObjectDiff {\n\t[k: string]: ValueOp\n}\n\n/**\n * Computes the difference between two record objects, generating an ObjectDiff\n * that describes how to transform the previous record into the next record.\n * This function is optimized for tldraw records and treats 'props' as a nested object.\n *\n * @param prev - The previous version of the record\n * @param next - The next version of the record\n * @param legacyAppendMode - If true, string append operations will be converted to Put operations\n * @returns An ObjectDiff describing the changes, or null if no changes exist\n *\n * @example\n * ```ts\n * const oldShape = { id: 'shape:1', x: 100, y: 200, props: { color: 'red' } }\n * const newShape = { id: 'shape:1', x: 150, y: 200, props: { color: 'blue' } }\n *\n * const diff = diffRecord(oldShape, newShape)\n * // Returns: {\n * // x: ['put', 150],\n * // props: ['patch', { color: ['put', 'blue'] }]\n * // }\n * ```\n *\n * @internal\n */\nexport function diffRecord(\n\tprev: object,\n\tnext: object,\n\tlegacyAppendMode = false\n): ObjectDiff | null {\n\treturn diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)\n}\n\nfunction diffObject(\n\tprev: object,\n\tnext: object,\n\tnestedKeys: Set<string> | undefined,\n\tlegacyAppendMode: boolean\n): ObjectDiff | null {\n\tif (prev === next) {\n\t\treturn null\n\t}\n\tlet result: ObjectDiff | null = null\n\t// Own-property checks throughout: `in` would see Object.prototype members, so a user key named\n\t// e.g. 'constructor' would never diff as an add or delete. And a key whose value is `undefined`\n\t// is treated as absent: `undefined` doesn't survive JSON, so `['put', undefined]` would arrive\n\t// as `['put', null]` and fail validators that accept only the missing key.\n\tfor (const key of Object.keys(prev)) {\n\t\t// `result['__proto__'] = op` would set the diff's prototype, and applyObjectDiff drops the key (AD8)\n\t\tif (key === '__proto__') continue\n\t\tconst prevValue = (prev as any)[key]\n\t\tif (prevValue === undefined) continue\n\t\tconst nextValue = hasOwnProperty(next, key) ? (next as any)[key] : undefined\n\t\tif (nextValue === undefined) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Delete]\n\t\t\tcontinue\n\t\t}\n\t\tif (\n\t\t\tnestedKeys?.has(key) ||\n\t\t\t(Array.isArray(prevValue) && Array.isArray(nextValue)) ||\n\t\t\t(typeof prevValue === 'string' && typeof nextValue === 'string')\n\t\t) {\n\t\t\t// if key is in both places, then compare values\n\t\t\tconst diff = diffValue(prevValue, nextValue, legacyAppendMode)\n\t\t\tif (diff) {\n\t\t\t\tif (!result) result = {}\n\t\t\t\tresult[key] = diff\n\t\t\t}\n\t\t} else if (!isEqual(prevValue, nextValue)) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, nextValue]\n\t\t}\n\t}\n\tfor (const key of Object.keys(next)) {\n\t\tif (key === '__proto__') continue\n\t\tconst nextValue = (next as any)[key]\n\t\tif (nextValue === undefined) continue\n\t\t// if key is in next but not in prev then it was added\n\t\tif (!hasOwnProperty(prev, key) || (prev as any)[key] === undefined) {\n\t\t\tif (!result) result = {}\n\t\t\tresult[key] = [ValueOpType.Put, nextValue]\n\t\t}\n\t}\n\treturn result\n}\n\nfunction diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {\n\tif (Object.is(valueA, valueB)) return null\n\tif (Array.isArray(valueA) && Array.isArray(valueB)) {\n\t\treturn diffArray(valueA, valueB, legacyAppendMode)\n\t} else if (typeof valueA === 'string' && typeof valueB === 'string') {\n\t\tif (!legacyAppendMode && valueB.startsWith(valueA)) {\n\t\t\tconst appendedText = valueB.slice(valueA.length)\n\t\t\treturn [ValueOpType.Append, appendedText, valueA.length]\n\t\t}\n\t\treturn [ValueOpType.Put, valueB]\n\t} else if (\n\t\t!valueA ||\n\t\t!valueB ||\n\t\ttypeof valueA !== 'object' ||\n\t\ttypeof valueB !== 'object' ||\n\t\t// an array on one side and a plain object on the other can't be expressed as a patch:\n\t\t// index deletes and named puts don't apply cleanly to either shape\n\t\tArray.isArray(valueA) !== Array.isArray(valueB)\n\t) {\n\t\treturn isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]\n\t} else {\n\t\tconst diff = diffObject(valueA, valueB, undefined, legacyAppendMode)\n\t\treturn diff ? [ValueOpType.Patch, diff] : null\n\t}\n}\n\nfunction diffArray(\n\tprevArray: unknown[],\n\tnextArray: unknown[],\n\tlegacyAppendMode: boolean\n): PutOp | AppendOp | PatchOp | null {\n\tif (Object.is(prevArray, nextArray)) return null\n\t// if lengths are equal, check for patch operation\n\tif (prevArray.length === nextArray.length) {\n\t\t// bail out if more than len/5 items need patching\n\t\tconst maxPatchIndexes = Math.max(prevArray.length / 5, 1)\n\t\tconst toPatchIndexes = []\n\t\tfor (let i = 0; i < prevArray.length; i++) {\n\t\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\t\ttoPatchIndexes.push(i)\n\t\t\t\tif (toPatchIndexes.length > maxPatchIndexes) {\n\t\t\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (toPatchIndexes.length === 0) {\n\t\t\t// same length and no items changed, so no diff\n\t\t\treturn null\n\t\t}\n\t\tconst diff: ObjectDiff = {}\n\t\tfor (const i of toPatchIndexes) {\n\t\t\tconst prevItem = prevArray[i]\n\t\t\tconst nextItem = nextArray[i]\n\t\t\tif (!prevItem || !nextItem) {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {\n\t\t\t\tconst op = diffValue(prevItem, nextItem, legacyAppendMode)\n\t\t\t\tif (op) {\n\t\t\t\t\tdiff[i] = op\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tdiff[i] = [ValueOpType.Put, nextItem]\n\t\t\t}\n\t\t}\n\t\treturn [ValueOpType.Patch, diff]\n\t}\n\n\t// if lengths are not equal, check for append operation, and bail out\n\t// to replace whole array if any shared elems changed\n\tfor (let i = 0; i < prevArray.length; i++) {\n\t\tif (!isEqual(prevArray[i], nextArray[i])) {\n\t\t\treturn [ValueOpType.Put, nextArray]\n\t\t}\n\t}\n\n\treturn [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length]\n}\n\n/**\n * Applies an ObjectDiff to an object, returning a new object with the changes applied.\n * This function handles all value operation types and creates a shallow copy when modifications\n * are needed. If no changes are required, the original object is returned.\n *\n * @param object - The object to apply the diff to\n * @param objectDiff - The ObjectDiff containing the operations to apply\n * @returns A new object with the diff applied, or the original object if no changes were needed\n *\n * @example\n * ```ts\n * const original = { x: 100, y: 200, props: { color: 'red' } }\n * const diff = {\n * x: ['put', 150],\n * props: ['patch', { color: ['put', 'blue'] }]\n * }\n *\n * const updated = applyObjectDiff(original, diff)\n * // Returns: { x: 150, y: 200, props: { color: 'blue' } }\n * ```\n *\n * @internal\n */\nexport function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T {\n\t// don't patch nulls\n\tif (!object || typeof object !== 'object') return object\n\tconst isArray = Array.isArray(object)\n\tlet newObject: any | undefined = undefined\n\tconst set = (k: any, v: any) => {\n\t\tif (!newObject) {\n\t\t\tif (isArray) {\n\t\t\t\tnewObject = [...object]\n\t\t\t} else {\n\t\t\t\tnewObject = { ...object }\n\t\t\t}\n\t\t}\n\t\tif (isArray) {\n\t\t\tnewObject[Number(k)] = v\n\t\t} else {\n\t\t\tnewObject[k] = v\n\t\t}\n\t}\n\tfor (const [key, op] of Object.entries(objectDiff)) {\n\t\t// Diffs come off the wire from untrusted peers. `newObject['__proto__'] = v` would set the\n\t\t// record's prototype (JSON.parse creates '__proto__' as an own key), so a client could give\n\t\t// server-held records attacker-controlled inherited fields; skip such keys outright.\n\t\tif (key === '__proto__') continue\n\t\tswitch (op[0]) {\n\t\t\tcase ValueOpType.Put: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tif (!isEqual(object[key as keyof T], value)) {\n\t\t\t\t\tset(key, value)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Append: {\n\t\t\t\tconst value = op[1]\n\t\t\t\tconst offset = op[2]\n\t\t\t\tconst currentValue = object[key as keyof T]\n\t\t\t\tif (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {\n\t\t\t\t\tset(key, [...currentValue, ...value])\n\t\t\t\t} else if (\n\t\t\t\t\ttypeof currentValue === 'string' &&\n\t\t\t\t\ttypeof value === 'string' &&\n\t\t\t\t\tcurrentValue.length === offset\n\t\t\t\t) {\n\t\t\t\t\tset(key, currentValue + value)\n\t\t\t\t}\n\t\t\t\t// If validation fails (type mismatch or length mismatch), silently ignore\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Patch: {\n\t\t\t\tif (object[key as keyof T] && typeof object[key as keyof T] === 'object') {\n\t\t\t\t\tconst diff = op[1]\n\t\t\t\t\tconst patched = applyObjectDiff(object[key as keyof T] as object, diff)\n\t\t\t\t\tif (patched !== object[key as keyof T]) {\n\t\t\t\t\t\tset(key, patched)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Delete: {\n\t\t\t\tif (hasOwnProperty(object, key)) {\n\t\t\t\t\tif (!newObject) {\n\t\t\t\t\t\tif (isArray) {\n\t\t\t\t\t\t\tconsole.error(\"Can't delete array item yet (this should never happen)\")\n\t\t\t\t\t\t\tnewObject = [...object]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewObject = { ...object }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdelete newObject[key]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn newObject ?? object\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["diff"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/sync-core",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (multiplayer sync).",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.6.0-canary.5bfc37740468",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw GB Ltd.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -39,34 +39,28 @@
|
|
|
39
39
|
"src",
|
|
40
40
|
"DOCS.md"
|
|
41
41
|
],
|
|
42
|
-
"scripts": {
|
|
43
|
-
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
|
44
|
-
"test": "yarn run -T vitest --passWithNoTests",
|
|
45
|
-
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
|
46
|
-
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
|
|
47
|
-
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
|
48
|
-
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
|
49
|
-
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
|
50
|
-
"postpack": "../../internal/scripts/postpack.sh",
|
|
51
|
-
"pack-tarball": "yarn pack"
|
|
52
|
-
},
|
|
53
42
|
"devDependencies": {
|
|
43
|
+
"@peculiar/webcrypto": "^1.5.0",
|
|
44
|
+
"@tldraw/editor": "5.6.0-canary.5bfc37740468",
|
|
54
45
|
"@types/react": "^19.2.7",
|
|
55
46
|
"@types/react-dom": "^19.2.3",
|
|
56
47
|
"@types/uuid-readable": "^0.0.3",
|
|
48
|
+
"@types/ws": "^8.18.1",
|
|
49
|
+
"fake-indexeddb": "^4.0.2",
|
|
57
50
|
"react": "^19.2.1",
|
|
58
51
|
"react-dom": "^19.2.1",
|
|
59
|
-
"
|
|
52
|
+
"resize-observer-polyfill": "^1.5.1",
|
|
53
|
+
"tldraw": "5.6.0-canary.5bfc37740468",
|
|
60
54
|
"typescript": "^7.0.2",
|
|
61
55
|
"uuid-by-string": "^4.0.0",
|
|
62
56
|
"uuid-readable": "^0.0.2",
|
|
63
|
-
"vitest": "^
|
|
57
|
+
"vitest": "^5.0.0"
|
|
64
58
|
},
|
|
65
59
|
"dependencies": {
|
|
66
|
-
"@tldraw/state": "5.
|
|
67
|
-
"@tldraw/store": "5.
|
|
68
|
-
"@tldraw/tlschema": "5.
|
|
69
|
-
"@tldraw/utils": "5.
|
|
60
|
+
"@tldraw/state": "5.6.0-canary.5bfc37740468",
|
|
61
|
+
"@tldraw/store": "5.6.0-canary.5bfc37740468",
|
|
62
|
+
"@tldraw/tlschema": "5.6.0-canary.5bfc37740468",
|
|
63
|
+
"@tldraw/utils": "5.6.0-canary.5bfc37740468",
|
|
70
64
|
"nanoevents": "^7.0.1",
|
|
71
65
|
"ws": "^8.18.0"
|
|
72
66
|
},
|
|
@@ -84,5 +78,15 @@
|
|
|
84
78
|
"import": "./dist-esm/index.mjs",
|
|
85
79
|
"require": "./dist-cjs/index.js"
|
|
86
80
|
}
|
|
81
|
+
},
|
|
82
|
+
"scripts": {
|
|
83
|
+
"test-ci": "pnpm exec vitest run --passWithNoTests",
|
|
84
|
+
"test": "pnpm exec vitest --passWithNoTests",
|
|
85
|
+
"test-coverage": "pnpm exec vitest run --coverage --passWithNoTests",
|
|
86
|
+
"lint": "pnpm exec tsx ../../internal/scripts/lint.ts",
|
|
87
|
+
"build": "pnpm exec tsx ../../internal/scripts/build-package.ts",
|
|
88
|
+
"build-api": "pnpm exec tsx ../../internal/scripts/build-api.ts",
|
|
89
|
+
"pack-tarball": "pnpm pack",
|
|
90
|
+
"build-types": "tsc --build"
|
|
87
91
|
}
|
|
88
92
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { warnOnce } from '@tldraw/utils'
|
|
1
|
+
import { promiseWithResolve, warnOnce } from '@tldraw/utils'
|
|
2
2
|
import { TLRecord, sleep } from 'tldraw'
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
@@ -12,6 +12,7 @@ vi.mock('@tldraw/utils', async (importOriginal) => {
|
|
|
12
12
|
// NOTE: setupVitest.js replaces the global WebSocket with the 'ws' package's WebSocket,
|
|
13
13
|
// matching the WebSocketServer the tests connect to.
|
|
14
14
|
import { WebSocketServer, WebSocket as WsWebSocket } from 'ws'
|
|
15
|
+
import { MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
|
|
15
16
|
import {
|
|
16
17
|
ACTIVE_MAX_DELAY,
|
|
17
18
|
ACTIVE_MIN_DELAY,
|
|
@@ -73,24 +74,38 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
73
74
|
connectedServerSocket = socket
|
|
74
75
|
})
|
|
75
76
|
|
|
77
|
+
// Bound per test on an OS-assigned port. A fixed port raced against the previous test's
|
|
78
|
+
// server, which closes asynchronously, and intermittently failed with EADDRINUSE.
|
|
79
|
+
let port: number
|
|
80
|
+
|
|
76
81
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
77
|
-
beforeEach(() => {
|
|
82
|
+
beforeEach(async () => {
|
|
78
83
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
85
|
+
// Attach before the adapter exists: the server is already listening by the time the
|
|
86
|
+
// adapter is constructed, so a handler added afterwards can miss the first connection.
|
|
81
87
|
wsServer.on('connection', connectMock as any)
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
wsServer.once('listening', resolve)
|
|
90
|
+
wsServer.once('error', reject)
|
|
91
|
+
})
|
|
92
|
+
port = (wsServer.address() as { port: number }).port
|
|
93
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
82
94
|
})
|
|
83
95
|
|
|
84
96
|
afterEach(() => {
|
|
85
97
|
consoleWarnSpy.mockRestore()
|
|
86
98
|
adapter.close()
|
|
99
|
+
// Not awaited: the next test binds its own OS-assigned port, so a server still closing
|
|
100
|
+
// here can't collide with it. Awaiting would deliver in-flight messages to the socket
|
|
101
|
+
// adapter.close() just orphaned.
|
|
87
102
|
wsServer.close()
|
|
88
103
|
connectMock.mockClear()
|
|
89
104
|
})
|
|
90
105
|
|
|
91
106
|
describe('connection and initial state (CW1, CW2)', () => {
|
|
92
107
|
it('[CW2] starts with connectionStatus offline while the internal status is initial', () => {
|
|
93
|
-
const newAdapter = new ClientWebSocketAdapter(() =>
|
|
108
|
+
const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
94
109
|
try {
|
|
95
110
|
expect(newAdapter._connectionStatus.get()).toBe('initial')
|
|
96
111
|
expect(newAdapter.connectionStatus).toBe('offline')
|
|
@@ -113,7 +128,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
113
128
|
let uriCallCount = 0
|
|
114
129
|
const dynamicAdapter = new ClientWebSocketAdapter(() => {
|
|
115
130
|
uriCallCount++
|
|
116
|
-
return `ws://localhost
|
|
131
|
+
return `ws://localhost:${port}?attempt=${uriCallCount}`
|
|
117
132
|
})
|
|
118
133
|
try {
|
|
119
134
|
await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN)
|
|
@@ -143,7 +158,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
143
158
|
// no socket can be created until the promise resolves
|
|
144
159
|
expect(asyncAdapter._ws).toBeNull()
|
|
145
160
|
|
|
146
|
-
resolveUri!(
|
|
161
|
+
resolveUri!(`ws://localhost:${port}`)
|
|
147
162
|
|
|
148
163
|
await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN)
|
|
149
164
|
expect(asyncAdapter.connectionStatus).toBe('online')
|
|
@@ -301,7 +316,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
301
316
|
|
|
302
317
|
describe('URI conversion (CW1)', () => {
|
|
303
318
|
it('[CW1] converts http URIs to ws and connects', async () => {
|
|
304
|
-
const httpAdapter = new ClientWebSocketAdapter(() =>
|
|
319
|
+
const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`)
|
|
305
320
|
try {
|
|
306
321
|
await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN)
|
|
307
322
|
expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//)
|
|
@@ -312,7 +327,7 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
312
327
|
})
|
|
313
328
|
|
|
314
329
|
it('[CW1] converts https URIs to wss', async () => {
|
|
315
|
-
const httpsAdapter = new ClientWebSocketAdapter(() =>
|
|
330
|
+
const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`)
|
|
316
331
|
try {
|
|
317
332
|
await waitFor(() => httpsAdapter._ws !== null)
|
|
318
333
|
expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//)
|
|
@@ -386,6 +401,32 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
386
401
|
)
|
|
387
402
|
})
|
|
388
403
|
|
|
404
|
+
it('[CW6] refuses to send a message the server would reject as too large', async () => {
|
|
405
|
+
const onMessage = vi.fn()
|
|
406
|
+
connectMock.mockImplementationOnce((ws: any) => {
|
|
407
|
+
ws.on('message', onMessage)
|
|
408
|
+
})
|
|
409
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
410
|
+
|
|
411
|
+
const onStatusChange = vi.fn()
|
|
412
|
+
adapter.onStatusChange(onStatusChange)
|
|
413
|
+
|
|
414
|
+
const message = {
|
|
415
|
+
...connectMessage(),
|
|
416
|
+
largeData: 'x'.repeat(MAX_ASSEMBLED_MESSAGE_CHARS),
|
|
417
|
+
} as any
|
|
418
|
+
adapter.sendMessage(message)
|
|
419
|
+
|
|
420
|
+
// nothing goes on the wire: sending would only earn a close and a reconnect that
|
|
421
|
+
// re-sends the same message
|
|
422
|
+
expect(onMessage).not.toHaveBeenCalled()
|
|
423
|
+
expect(adapter.connectionStatus).toBe('error')
|
|
424
|
+
expect(onStatusChange).toHaveBeenCalledWith({
|
|
425
|
+
status: 'error',
|
|
426
|
+
reason: TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE,
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
389
430
|
it('[CW6] silently drops the message when there is no socket', async () => {
|
|
390
431
|
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
391
432
|
|
|
@@ -527,6 +568,46 @@ describe('ClientWebSocketAdapter', () => {
|
|
|
527
568
|
adapter.close()
|
|
528
569
|
expect(() => adapter.close()).not.toThrow()
|
|
529
570
|
})
|
|
571
|
+
|
|
572
|
+
it('[CW9][RM5] closing immediately does not start another getUri call', async () => {
|
|
573
|
+
const getUri = vi.fn(() => `ws://localhost:${port}`)
|
|
574
|
+
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
575
|
+
testAdapter.close()
|
|
576
|
+
const callsAtClose = getUri.mock.calls.length
|
|
577
|
+
|
|
578
|
+
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
579
|
+
|
|
580
|
+
expect(getUri).toHaveBeenCalledTimes(callsAtClose)
|
|
581
|
+
expect(testAdapter._ws).toBeNull()
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => {
|
|
585
|
+
let uriCallCount = 0
|
|
586
|
+
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
587
|
+
uriCallCount++
|
|
588
|
+
return `ws://localhost:${port}`
|
|
589
|
+
})
|
|
590
|
+
const onStatusChange = vi.fn()
|
|
591
|
+
testAdapter.onStatusChange(onStatusChange)
|
|
592
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
593
|
+
const callsBeforeClose = uriCallCount
|
|
594
|
+
onStatusChange.mockClear()
|
|
595
|
+
|
|
596
|
+
testAdapter.close()
|
|
597
|
+
expect(testAdapter.connectionStatus).toBe('offline')
|
|
598
|
+
// let the socket's own close event land, then run out any (leaked) reconnect timer
|
|
599
|
+
vi.useRealTimers()
|
|
600
|
+
await sleep(50)
|
|
601
|
+
vi.useFakeTimers()
|
|
602
|
+
vi.advanceTimersByTime(INACTIVE_MAX_DELAY)
|
|
603
|
+
vi.useRealTimers()
|
|
604
|
+
await sleep(20)
|
|
605
|
+
vi.useFakeTimers()
|
|
606
|
+
|
|
607
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
608
|
+
expect(uriCallCount).toBe(callsBeforeClose)
|
|
609
|
+
expect(testAdapter._ws).toBeNull()
|
|
610
|
+
})
|
|
530
611
|
})
|
|
531
612
|
|
|
532
613
|
describe('orphaned sockets (CW10)', () => {
|
|
@@ -566,17 +647,26 @@ describe('ReconnectManager', () => {
|
|
|
566
647
|
connectedServerSocket = socket
|
|
567
648
|
})
|
|
568
649
|
|
|
650
|
+
// See the note on the other suite's port: bound per test on an OS-assigned port.
|
|
651
|
+
let port: number
|
|
652
|
+
|
|
569
653
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
|
570
|
-
beforeEach(() => {
|
|
654
|
+
beforeEach(async () => {
|
|
571
655
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
572
|
-
|
|
573
|
-
wsServer = new WebSocketServer({ port: 2234 })
|
|
656
|
+
wsServer = new WebSocketServer({ port: 0 })
|
|
574
657
|
wsServer.on('connection', connectMock as any)
|
|
658
|
+
await new Promise<void>((resolve, reject) => {
|
|
659
|
+
wsServer.once('listening', resolve)
|
|
660
|
+
wsServer.once('error', reject)
|
|
661
|
+
})
|
|
662
|
+
port = (wsServer.address() as { port: number }).port
|
|
663
|
+
adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
575
664
|
})
|
|
576
665
|
|
|
577
666
|
afterEach(() => {
|
|
578
667
|
consoleWarnSpy.mockRestore()
|
|
579
668
|
adapter.close()
|
|
669
|
+
// Not awaited - see the note on the other suite's teardown.
|
|
580
670
|
wsServer.close()
|
|
581
671
|
connectMock.mockClear()
|
|
582
672
|
})
|
|
@@ -591,7 +681,7 @@ describe('ReconnectManager', () => {
|
|
|
591
681
|
})
|
|
592
682
|
|
|
593
683
|
it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => {
|
|
594
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
684
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
595
685
|
try {
|
|
596
686
|
const manager = testAdapter._reconnectManager
|
|
597
687
|
// make every disconnected() call take the "attempt now" branch
|
|
@@ -616,7 +706,7 @@ describe('ReconnectManager', () => {
|
|
|
616
706
|
|
|
617
707
|
it('[RM1] uses the inactive delay bounds when the tab is hidden', () => {
|
|
618
708
|
const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
|
619
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
709
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
620
710
|
try {
|
|
621
711
|
const manager = testAdapter._reconnectManager
|
|
622
712
|
;(manager as any).lastAttemptStart = Date.now() - 1_000_000
|
|
@@ -710,7 +800,7 @@ describe('ReconnectManager', () => {
|
|
|
710
800
|
// it's necessary to close the socket, as otherwise the websocket might stay half-open
|
|
711
801
|
connectedServerSocket.close()
|
|
712
802
|
wsServer.close()
|
|
713
|
-
await waitFor(() => adapter.
|
|
803
|
+
await waitFor(() => adapter.connectionStatus === 'offline')
|
|
714
804
|
expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY)
|
|
715
805
|
|
|
716
806
|
hiddenMock.mockReturnValue(false)
|
|
@@ -776,8 +866,34 @@ describe('ReconnectManager', () => {
|
|
|
776
866
|
expect(adapter._ws).toBeNull()
|
|
777
867
|
})
|
|
778
868
|
|
|
869
|
+
it.each(['throws', 'rejects'])(
|
|
870
|
+
'[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection',
|
|
871
|
+
async (failure) => {
|
|
872
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
873
|
+
let attempts = 0
|
|
874
|
+
const testAdapter = new ClientWebSocketAdapter(() => {
|
|
875
|
+
attempts++
|
|
876
|
+
if (attempts < 3) {
|
|
877
|
+
const error = new Error('token endpoint unavailable')
|
|
878
|
+
if (failure === 'throws') throw error
|
|
879
|
+
return Promise.reject(error)
|
|
880
|
+
}
|
|
881
|
+
return `ws://localhost:${port}`
|
|
882
|
+
})
|
|
883
|
+
try {
|
|
884
|
+
await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN)
|
|
885
|
+
expect(attempts).toBe(3)
|
|
886
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(2)
|
|
887
|
+
expect(testAdapter.connectionStatus).toBe('online')
|
|
888
|
+
} finally {
|
|
889
|
+
testAdapter.close()
|
|
890
|
+
consoleErrorSpy.mockRestore()
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
)
|
|
894
|
+
|
|
779
895
|
it('[RM5] close cancels timers and removes the reconnect event listeners', () => {
|
|
780
|
-
const testAdapter = new ClientWebSocketAdapter(() =>
|
|
896
|
+
const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`)
|
|
781
897
|
const manager = testAdapter._reconnectManager
|
|
782
898
|
|
|
783
899
|
testAdapter.close()
|
|
@@ -793,4 +909,93 @@ describe('ReconnectManager', () => {
|
|
|
793
909
|
// closing again is safe
|
|
794
910
|
expect(() => manager.close()).not.toThrow()
|
|
795
911
|
})
|
|
912
|
+
|
|
913
|
+
it('[RM4] reconnect hints and the disconnect handler survive an undefined WebSocket global', async () => {
|
|
914
|
+
// Regression test for the undefined-WebSocket crash guarded in ClientWebSocketAdapter.ts (#10106).
|
|
915
|
+
await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN)
|
|
916
|
+
const manager = adapter._reconnectManager
|
|
917
|
+
// jsdom reports a throwing event listener on window instead of from dispatchEvent
|
|
918
|
+
const listenerErrors: unknown[] = []
|
|
919
|
+
const onError = (event: ErrorEvent) => {
|
|
920
|
+
event.preventDefault()
|
|
921
|
+
listenerErrors.push(event.error)
|
|
922
|
+
}
|
|
923
|
+
window.addEventListener('error', onError)
|
|
924
|
+
const originalWebSocket = globalThis.WebSocket
|
|
925
|
+
try {
|
|
926
|
+
;(globalThis as any).WebSocket = undefined
|
|
927
|
+
|
|
928
|
+
window.dispatchEvent(new Event('online'))
|
|
929
|
+
expect(() => manager.maybeReconnected()).not.toThrow()
|
|
930
|
+
expect(() => manager.connected()).not.toThrow()
|
|
931
|
+
expect(adapter.connectionStatus).toBe('online')
|
|
932
|
+
|
|
933
|
+
window.dispatchEvent(new Event('offline'))
|
|
934
|
+
expect(() => manager.disconnected()).not.toThrow()
|
|
935
|
+
expect(listenerErrors).toEqual([])
|
|
936
|
+
expect(adapter.connectionStatus).toBe('offline')
|
|
937
|
+
expect(adapter._ws).toBeNull()
|
|
938
|
+
} finally {
|
|
939
|
+
globalThis.WebSocket = originalWebSocket
|
|
940
|
+
window.removeEventListener('error', onError)
|
|
941
|
+
}
|
|
942
|
+
})
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
describe('URI failure boundaries', () => {
|
|
946
|
+
// These tests never open a real socket - the adapter is closed first, or the socket is a
|
|
947
|
+
// mock - so the URI only has to parse. No server, hence no port to bind.
|
|
948
|
+
const UNUSED_URI = 'ws://localhost:1'
|
|
949
|
+
|
|
950
|
+
it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => {
|
|
951
|
+
const uri = promiseWithResolve<string>()
|
|
952
|
+
const getUri = vi.fn(() => uri)
|
|
953
|
+
const testAdapter = new ClientWebSocketAdapter(getUri)
|
|
954
|
+
const onStatusChange = vi.fn()
|
|
955
|
+
testAdapter.onStatusChange(onStatusChange)
|
|
956
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
957
|
+
try {
|
|
958
|
+
await Promise.resolve()
|
|
959
|
+
testAdapter.close()
|
|
960
|
+
if (outcome === 'resolves') uri.resolve(UNUSED_URI)
|
|
961
|
+
else uri.reject(new Error('token endpoint unavailable'))
|
|
962
|
+
await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY)
|
|
963
|
+
|
|
964
|
+
expect(getUri).toHaveBeenCalledTimes(1)
|
|
965
|
+
expect(testAdapter._ws).toBeNull()
|
|
966
|
+
expect(testAdapter.connectionStatus).toBe('offline')
|
|
967
|
+
expect(onStatusChange).not.toHaveBeenCalled()
|
|
968
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
969
|
+
} finally {
|
|
970
|
+
testAdapter.close()
|
|
971
|
+
consoleError.mockRestore()
|
|
972
|
+
}
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
it.each([
|
|
976
|
+
{ uri: 'not a URL', openSocket: false, error: 'Invalid URL' },
|
|
977
|
+
{
|
|
978
|
+
uri: UNUSED_URI,
|
|
979
|
+
openSocket: true,
|
|
980
|
+
error: 'There should be no connection attempts while already connected',
|
|
981
|
+
},
|
|
982
|
+
])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => {
|
|
983
|
+
let nextUri: string | Promise<string> = new Promise(() => {})
|
|
984
|
+
const testAdapter = new ClientWebSocketAdapter(() => nextUri)
|
|
985
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
986
|
+
try {
|
|
987
|
+
await Promise.resolve()
|
|
988
|
+
nextUri = uri
|
|
989
|
+
if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN)
|
|
990
|
+
|
|
991
|
+
const manager = testAdapter._reconnectManager as unknown as {
|
|
992
|
+
scheduleAttempt(): Promise<void>
|
|
993
|
+
}
|
|
994
|
+
await expect(manager.scheduleAttempt()).rejects.toThrow(error)
|
|
995
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
996
|
+
} finally {
|
|
997
|
+
testAdapter.close()
|
|
998
|
+
consoleError.mockRestore()
|
|
999
|
+
}
|
|
1000
|
+
})
|
|
796
1001
|
})
|