@tldraw/sync-core 4.4.0 → 4.5.0-canary.b51a70ff0cf6

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.js CHANGED
@@ -61,7 +61,7 @@ var import_TLSyncRoom = require("./lib/TLSyncRoom");
61
61
  var import_TLSyncStorage = require("./lib/TLSyncStorage");
62
62
  (0, import_utils.registerTldrawLibraryVersion)(
63
63
  "@tldraw/sync-core",
64
- "4.4.0",
64
+ "4.5.0-canary.b51a70ff0cf6",
65
65
  "cjs"
66
66
  );
67
67
  //# sourceMappingURL=index.js.map
@@ -42,7 +42,7 @@ function chunk(msg, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
42
42
  return chunks;
43
43
  }
44
44
  }
45
- const chunkRe = /^(\d+)_(.*)$/;
45
+ const chunkRe = /^(\d+)_(.*)$/s;
46
46
  class JsonChunkAssembler {
47
47
  /**
48
48
  * Current assembly state - either 'idle' or tracking chunks being received
@@ -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\nconst chunkRe = /^(\\d+)_(.*)$/\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": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;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;AAEA,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/**\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": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;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;",
6
6
  "names": ["data"]
7
7
  }
@@ -36,7 +36,7 @@ import {
36
36
  } from "./lib/TLSyncStorage.mjs";
37
37
  registerTldrawLibraryVersion(
38
38
  "@tldraw/sync-core",
39
- "4.4.0",
39
+ "4.5.0-canary.b51a70ff0cf6",
40
40
  "esm"
41
41
  );
42
42
  export {
@@ -18,7 +18,7 @@ function chunk(msg, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
18
18
  return chunks;
19
19
  }
20
20
  }
21
- const chunkRe = /^(\d+)_(.*)$/;
21
+ const chunkRe = /^(\d+)_(.*)$/s;
22
22
  class JsonChunkAssembler {
23
23
  /**
24
24
  * Current assembly state - either 'idle' or tracking chunks being received
@@ -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\nconst chunkRe = /^(\\d+)_(.*)$/\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;AAEA,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/**\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;",
6
6
  "names": ["data"]
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": "4.4.0",
4
+ "version": "4.5.0-canary.b51a70ff0cf6",
5
5
  "author": {
6
6
  "name": "tldraw GB Ltd.",
7
7
  "email": "hello@tldraw.com"
@@ -48,17 +48,17 @@
48
48
  "@types/uuid-readable": "^0.0.3",
49
49
  "react": "^19.2.1",
50
50
  "react-dom": "^19.2.1",
51
- "tldraw": "4.4.0",
51
+ "tldraw": "4.5.0-canary.b51a70ff0cf6",
52
52
  "typescript": "^5.8.3",
53
53
  "uuid-by-string": "^4.0.0",
54
54
  "uuid-readable": "^0.0.2",
55
55
  "vitest": "^3.2.4"
56
56
  },
57
57
  "dependencies": {
58
- "@tldraw/state": "4.4.0",
59
- "@tldraw/store": "4.4.0",
60
- "@tldraw/tlschema": "4.4.0",
61
- "@tldraw/utils": "4.4.0",
58
+ "@tldraw/state": "4.5.0-canary.b51a70ff0cf6",
59
+ "@tldraw/store": "4.5.0-canary.b51a70ff0cf6",
60
+ "@tldraw/tlschema": "4.5.0-canary.b51a70ff0cf6",
61
+ "@tldraw/utils": "4.5.0-canary.b51a70ff0cf6",
62
62
  "nanoevents": "^7.0.1",
63
63
  "ws": "^8.18.0"
64
64
  },
package/src/lib/chunk.ts CHANGED
@@ -47,7 +47,9 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
47
47
  }
48
48
  }
49
49
 
50
- const chunkRe = /^(\d+)_(.*)$/
50
+ // The 's' flag (dotAll) makes '.' match any character including line terminators
51
+ // like U+2028 and U+2029, which are commonly introduced via copy/paste from Word
52
+ const chunkRe = /^(\d+)_(.*)$/s
51
53
 
52
54
  /**
53
55
  * Assembles chunked JSON messages back into complete objects.
@@ -260,6 +260,25 @@ describe('json unchunker', () => {
260
260
  expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
261
261
  })
262
262
 
263
+ it('handles unicode line separators U+2028 and U+2029 in chunks', () => {
264
+ // These characters are common when copy/pasting from Microsoft Word
265
+ // and are valid JSON string content but act as line terminators in JS
266
+ const unchunker = new JsonChunkAssembler()
267
+
268
+ // Test U+2028 (Line Separator) and U+2029 (Paragraph Separator)
269
+ const jsonWithLineSeparators = '{"text": "hello\u2028world\u2029end"}'
270
+ const chunks = chunk(jsonWithLineSeparators, 10)
271
+
272
+ for (const c of chunks.slice(0, -1)) {
273
+ expect(unchunker.handleMessage(c)).toBeNull()
274
+ }
275
+
276
+ const result = unchunker.handleMessage(chunks[chunks.length - 1])
277
+ expect(result).toMatchObject({
278
+ data: { text: 'hello\u2028world\u2029end' },
279
+ })
280
+ })
281
+
263
282
  it('handles duplicate or out-of-order chunk numbers', () => {
264
283
  const unchunker = new JsonChunkAssembler()
265
284