@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.
Files changed (60) hide show
  1. package/dist-cjs/index.d.ts +8 -4
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +46 -18
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/InMemorySyncStorage.js +18 -18
  6. package/dist-cjs/lib/InMemorySyncStorage.js.map +2 -2
  7. package/dist-cjs/lib/SQLiteSyncStorage.js +15 -7
  8. package/dist-cjs/lib/SQLiteSyncStorage.js.map +2 -2
  9. package/dist-cjs/lib/TLSocketRoom.js +9 -8
  10. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  11. package/dist-cjs/lib/TLSyncClient.js +8 -1
  12. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncRoom.js +82 -50
  14. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncStorage.js +2 -2
  16. package/dist-cjs/lib/TLSyncStorage.js.map +2 -2
  17. package/dist-cjs/lib/chunk.js +35 -3
  18. package/dist-cjs/lib/chunk.js.map +2 -2
  19. package/dist-cjs/lib/diff.js +15 -7
  20. package/dist-cjs/lib/diff.js.map +2 -2
  21. package/dist-esm/index.d.mts +8 -4
  22. package/dist-esm/index.mjs +1 -1
  23. package/dist-esm/lib/ClientWebSocketAdapter.mjs +47 -19
  24. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  25. package/dist-esm/lib/InMemorySyncStorage.mjs +18 -18
  26. package/dist-esm/lib/InMemorySyncStorage.mjs.map +2 -2
  27. package/dist-esm/lib/SQLiteSyncStorage.mjs +15 -7
  28. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +2 -2
  29. package/dist-esm/lib/TLSocketRoom.mjs +10 -9
  30. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  31. package/dist-esm/lib/TLSyncClient.mjs +8 -1
  32. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  33. package/dist-esm/lib/TLSyncRoom.mjs +82 -50
  34. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  35. package/dist-esm/lib/TLSyncStorage.mjs +2 -2
  36. package/dist-esm/lib/TLSyncStorage.mjs.map +2 -2
  37. package/dist-esm/lib/chunk.mjs +35 -3
  38. package/dist-esm/lib/chunk.mjs.map +2 -2
  39. package/dist-esm/lib/diff.mjs +16 -8
  40. package/dist-esm/lib/diff.mjs.map +2 -2
  41. package/package.json +22 -18
  42. package/src/lib/ClientWebSocketAdapter.test.ts +221 -16
  43. package/src/lib/ClientWebSocketAdapter.ts +62 -23
  44. package/src/lib/InMemorySyncStorage.test.ts +21 -0
  45. package/src/lib/InMemorySyncStorage.ts +22 -24
  46. package/src/lib/SQLiteSyncStorage.test.ts +60 -1
  47. package/src/lib/SQLiteSyncStorage.ts +24 -16
  48. package/src/lib/TLSocketRoom.ts +16 -9
  49. package/src/lib/TLSyncClient.test.ts +61 -0
  50. package/src/lib/TLSyncClient.ts +11 -0
  51. package/src/lib/TLSyncRoom.ts +122 -54
  52. package/src/lib/TLSyncStorage.ts +7 -2
  53. package/src/lib/chunk.test.ts +88 -1
  54. package/src/lib/chunk.ts +66 -3
  55. package/src/lib/diff.test.ts +91 -1
  56. package/src/lib/diff.ts +30 -9
  57. package/src/test/TLSocketRoom.test.ts +63 -0
  58. package/src/test/TLSyncRoom.test.ts +300 -0
  59. package/src/test/objectStore.test.ts +35 -0
  60. 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\tconst chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)\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\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 } = '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 or invalid chunk sequence\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\tthis.state = {\n\t\t\t\t\tchunksReceived: [data],\n\t\t\t\t\ttotalChunks: numChunksRemaining + 1,\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.state.chunksReceived.push(data)\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.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": "AAGA,MAAM,qCAAqC,OAAO;AAElD,MAAM,qBAAqB;AAG3B,MAAM,wBAAwB,qCAAqC;AAuB5D,SAAS,MAAM,KAAa,qBAAqB,uBAAuB;AAC9E,MAAI,IAAI,SAAS,oBAAoB;AACpC,WAAO,CAAC,GAAG;AAAA,EACZ,OAAO;AACN,UAAM,SAAS,CAAC;AAChB,QAAI,cAAc;AAClB,QAAI,SAAS,IAAI;AACjB,WAAO,SAAS,GAAG;AAClB,YAAM,SAAS,GAAG,WAAW;AAC7B,YAAM,YAAY,KAAK,IAAI,KAAK,IAAI,qBAAqB,OAAO,QAAQ,MAAM,GAAG,CAAC;AAClF,aAAO,QAAQ,SAAS,IAAI,MAAM,SAAS,WAAW,MAAM,CAAC;AAC7D,gBAAU;AACV;AAAA,IACD;AACA,WAAO;AAAA,EACR;AACD;AAIA,MAAM,UAAU;AAuBT,MAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA,EAI/B,QAKO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA6BP,cAAc,KAA8E;AAC3F,QAAI,IAAI,WAAW,GAAG,GAAG;AACxB,YAAM,QAAQ,KAAK,UAAU,SAAS,SAAY,IAAI,MAAM,8BAA8B;AAC1F,WAAK,QAAQ;AACb,aAAO,QAAQ,EAAE,MAAM,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,GAAG,aAAa,IAAI;AAAA,IACtE,OAAO;AACN,YAAM,QAAQ,QAAQ,KAAK,GAAG;AAC9B,UAAI,CAAC,OAAO;AACX,aAAK,QAAQ;AACb,eAAO,EAAE,OAAO,IAAI,MAAM,oBAAoB,KAAK,UAAU,IAAI,MAAM,GAAG,EAAE,IAAI,KAAK,CAAC,EAAE;AAAA,MACzF;AACA,YAAM,qBAAqB,OAAO,MAAM,CAAC,CAAC;AAC1C,YAAM,OAAO,MAAM,CAAC;AAEpB,UAAI,KAAK,UAAU,QAAQ;AAC1B,aAAK,QAAQ;AAAA,UACZ,gBAAgB,CAAC,IAAI;AAAA,UACrB,aAAa,qBAAqB;AAAA,QACnC;AAAA,MACD,OAAO;AACN,aAAK,MAAM,eAAe,KAAK,IAAI;AACnC,YAAI,uBAAuB,KAAK,MAAM,cAAc,KAAK,MAAM,eAAe,QAAQ;AACrF,eAAK,QAAQ;AACb,iBAAO,EAAE,OAAO,IAAI,MAAM,gCAAgC,EAAE;AAAA,QAC7D;AAAA,MACD;AACA,UAAI,KAAK,MAAM,eAAe,WAAW,KAAK,MAAM,aAAa;AAChE,YAAI;AACH,gBAAM,cAAc,KAAK,MAAM,eAAe,KAAK,EAAE;AACrD,gBAAMA,QAAO,KAAK,MAAM,WAAW;AACnC,iBAAO,EAAE,MAAAA,OAAM,YAAY;AAAA,QAC5B,SAAS,GAAG;AACX,iBAAO,EAAE,OAAO,EAAW;AAAA,QAC5B,UAAE;AACD,eAAK,QAAQ;AAAA,QACd;AAAA,MACD;AACA,aAAO;AAAA,IACR;AAAA,EACD;AACD;",
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
  }
@@ -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 (!(key in next)) {
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 (!(key in prev)) {
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, next[key]];
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 in object) {
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": "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",
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.5.0-next.f9308d324e1c",
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
- "tldraw": "5.5.0-next.f9308d324e1c",
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": "^4.1.7"
57
+ "vitest": "^5.0.0"
64
58
  },
65
59
  "dependencies": {
66
- "@tldraw/state": "5.5.0-next.f9308d324e1c",
67
- "@tldraw/store": "5.5.0-next.f9308d324e1c",
68
- "@tldraw/tlschema": "5.5.0-next.f9308d324e1c",
69
- "@tldraw/utils": "5.5.0-next.f9308d324e1c",
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
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2233')
80
- wsServer = new WebSocketServer({ port: 2233 })
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(() => 'ws://localhost:2233')
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:2233?attempt=${uriCallCount}`
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!('ws://localhost:2233')
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(() => 'http://localhost:2233')
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(() => 'https://localhost:2233')
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
- adapter = new ClientWebSocketAdapter(() => 'ws://localhost:2234')
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(() => 'ws://localhost:2234')
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(() => 'ws://localhost:2234')
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._ws?.readyState !== WebSocket.OPEN)
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(() => 'ws://localhost:2234')
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
  })