@tldraw/sync-core 4.2.0-next.f100cedfc45b → 4.3.0-canary.d8da2a99f394
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +66 -0
- package/dist-cjs/index.js +2 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSyncRoom.js +35 -9
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +4 -4
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-cjs/lib/diff.js +29 -29
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-cjs/lib/protocol.js +1 -1
- package/dist-cjs/lib/protocol.js.map +1 -1
- package/dist-esm/index.d.mts +66 -0
- package/dist-esm/index.mjs +3 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSyncRoom.mjs +35 -9
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +4 -4
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/dist-esm/lib/diff.mjs +29 -29
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs +1 -1
- package/dist-esm/lib/protocol.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +2 -2
- package/src/lib/RoomSession.test.ts +3 -0
- package/src/lib/RoomSession.ts +28 -42
- package/src/lib/TLSyncRoom.ts +42 -7
- package/src/lib/chunk.ts +4 -4
- package/src/lib/diff.ts +55 -32
- package/src/lib/protocol.ts +1 -1
- package/src/test/TLSocketRoom.test.ts +2 -2
- package/src/test/TLSyncRoom.test.ts +22 -21
- package/src/test/diff.test.ts +200 -0
|
@@ -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.\n *\n * @internal\n */\nexport type AppendOp = [type: typeof ValueOpType.Append, values: unknown[], 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 * @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(prev: object, next: object): ObjectDiff | null {\n\treturn diffObject(prev, next, new Set(['props']))\n}\n\nfunction diffObject(prev: object, next: object, nestedKeys?: Set<string>): 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\t// if key is in both places, then compare values\n\t\tconst prevVal = (prev as any)[key]\n\t\tconst nextVal = (next as any)[key]\n\t\tif (!isEqual(prevVal, nextVal)) {\n\t\t\tif (nestedKeys?.has(key) && prevVal && nextVal) {\n\t\t\t\tconst diff = diffObject(prevVal, nextVal)\n\t\t\t\tif (diff) {\n\t\t\t\t\tif (!result) result = {}\n\t\t\t\t\tresult[key] = [ValueOpType.Patch, diff]\n\t\t\t\t}\n\t\t\t} else if (Array.isArray(nextVal) && Array.isArray(prevVal)) {\n\t\t\t\tconst op = diffArray(prevVal, nextVal)\n\t\t\t\tif (op) {\n\t\t\t\t\tif (!result) result = {}\n\t\t\t\t\tresult[key] = op\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif (!result) result = {}\n\t\t\t\tresult[key] = [ValueOpType.Put, nextVal]\n\t\t\t}\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): ValueOp | null {\n\tif (Object.is(valueA, valueB)) return null\n\tif (Array.isArray(valueA) && Array.isArray(valueB)) {\n\t\treturn diffArray(valueA, 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)\n\t\treturn diff ? [ValueOpType.Patch, diff] : null\n\t}\n}\n\nfunction diffArray(prevArray: unknown[], nextArray: unknown[]): 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)\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 values = op[1]\n\t\t\t\tconst offset = op[2]\n\t\t\t\tconst arr = object[key as keyof T]\n\t\t\t\tif (Array.isArray(arr) && arr.length === offset) {\n\t\t\t\t\tset(key, [...arr, ...values])\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Patch: {\n\t\t\t\tif (object[key as keyof T] && typeof object[key as keyof T] === 'object') {\n\t\t\t\t\tconst diff = op[1]\n\t\t\t\t\tconst patched = applyObjectDiff(object[key as keyof T] as object, diff)\n\t\t\t\t\tif (patched !== object[key as keyof T]) {\n\t\t\t\t\t\tset(key, patched)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tcase ValueOpType.Delete: {\n\t\t\t\tif (key in object) {\n\t\t\t\t\tif (!newObject) {\n\t\t\t\t\t\tif (isArray) {\n\t\t\t\t\t\t\tconsole.error(\"Can't delete array item yet (this should never happen)\")\n\t\t\t\t\t\t\tnewObject = [...object]\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tnewObject = { ...object }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdelete newObject[key]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn newObject ?? object\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,SAAS,kBAAkB,uBAAuB;AAQpD,MAAM,eAAe;AAAA,EAC3B,KAAK;AAAA,EACL,OAAO;AAAA,EACP,QAAQ;AACT;AA4DO,SAAS,eACf,MACwB;AACxB,MAAI,MAA6B;AAEjC,aAAW,CAAC,GAAG,CAAC,KAAK,iBAAiB,KAAK,KAAK,GAAG;AAClD,QAAI,CAAC,IAAK,OAAM,CAAC;AACjB,QAAI,CAAC,IAAI,CAAC,aAAa,KAAK,CAAC;AAAA,EAC9B;AAEA,aAAW,CAAC,MAAM,EAAE,KAAK,gBAAgB,KAAK,OAAO,GAAG;AACvD,UAAMA,QAAO,WAAW,MAAM,EAAE;AAChC,QAAIA,OAAM;AACT,UAAI,CAAC,IAAK,OAAM,CAAC;AACjB,UAAI,GAAG,EAAE,IAAI,CAAC,aAAa,OAAOA,KAAI;AAAA,IACvC;AAAA,EACD;AAEA,aAAW,WAAW,OAAO,KAAK,KAAK,OAAO,GAAG;AAChD,QAAI,CAAC,IAAK,OAAM,CAAC;AACjB,QAAI,OAAO,IAAI,CAAC,aAAa,MAAM;AAAA,EACpC;AAEA,SAAO;AACR;AAQO,MAAM,cAAc;AAAA,EAC1B,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,OAAO;AACR;
|
|
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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",
|
|
6
6
|
"names": ["diff"]
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/protocol.ts"],
|
|
4
|
-
"sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { NetworkDiff, ObjectDiff, RecordOpType } from './diff'\n\nconst TLSYNC_PROTOCOL_VERSION =
|
|
4
|
+
"sourcesContent": ["import { SerializedSchema, UnknownRecord } from '@tldraw/store'\nimport { NetworkDiff, ObjectDiff, RecordOpType } from './diff'\n\nconst TLSYNC_PROTOCOL_VERSION = 8\n\n/**\n * Gets the current tldraw sync protocol version number.\n *\n * This version number is used during WebSocket connection handshake to ensure\n * client and server compatibility. When versions don't match, the connection\n * will be rejected with an incompatibility error.\n *\n * @returns The current protocol version number\n *\n * @example\n * ```ts\n * const version = getTlsyncProtocolVersion()\n * console.log(`Using protocol version: ${version}`)\n * ```\n *\n * @internal\n */\nexport function getTlsyncProtocolVersion() {\n\treturn TLSYNC_PROTOCOL_VERSION\n}\n\n/**\n * Constants defining the different types of protocol incompatibility reasons.\n *\n * These values indicate why a client-server connection was rejected due to\n * version or compatibility issues. Each reason helps diagnose specific problems\n * during the connection handshake.\n *\n * @example\n * ```ts\n * if (error.reason === TLIncompatibilityReason.ClientTooOld) {\n * showUpgradeMessage('Please update your client')\n * }\n * ```\n *\n * @internal\n * @deprecated Replaced by websocket .close status/reason\n */\nexport const TLIncompatibilityReason = {\n\tClientTooOld: 'clientTooOld',\n\tServerTooOld: 'serverTooOld',\n\tInvalidRecord: 'invalidRecord',\n\tInvalidOperation: 'invalidOperation',\n} as const\n\n/**\n * Union type representing all possible incompatibility reason values.\n *\n * This type represents the different reasons why a client-server connection\n * might fail due to protocol or version mismatches.\n *\n * @example\n * ```ts\n * function handleIncompatibility(reason: TLIncompatibilityReason) {\n * switch (reason) {\n * case 'clientTooOld':\n * return 'Client needs to be updated'\n * case 'serverTooOld':\n * return 'Server needs to be updated'\n * }\n * }\n * ```\n *\n * @internal\n * @deprecated replaced by websocket .close status/reason\n */\nexport type TLIncompatibilityReason =\n\t(typeof TLIncompatibilityReason)[keyof typeof TLIncompatibilityReason]\n\n/**\n * Union type representing all possible message types that can be sent from server to client.\n *\n * This encompasses the complete set of server-originated WebSocket messages in the tldraw\n * sync protocol, including connection establishment, data synchronization, and error handling.\n *\n * @param R - The record type being synchronized (extends UnknownRecord)\n *\n * @example\n * ```ts\n * syncClient.onReceiveMessage((message: TLSocketServerSentEvent<MyRecord>) => {\n * switch (message.type) {\n * case 'connect':\n * console.log('Connected to room with clock:', message.serverClock)\n * break\n * case 'data':\n * console.log('Received data updates:', message.data)\n * break\n * }\n * })\n * ```\n *\n * @internal\n */\nexport type TLSocketServerSentEvent<R extends UnknownRecord> =\n\t| {\n\t\t\ttype: 'connect'\n\t\t\thydrationType: 'wipe_all' | 'wipe_presence'\n\t\t\tconnectRequestId: string\n\t\t\tprotocolVersion: number\n\t\t\tschema: SerializedSchema\n\t\t\tdiff: NetworkDiff<R>\n\t\t\tserverClock: number\n\t\t\tisReadonly: boolean\n\t }\n\t| {\n\t\t\ttype: 'incompatibility_error'\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-deprecated\n\t\t\treason: TLIncompatibilityReason\n\t }\n\t| {\n\t\t\ttype: 'pong'\n\t }\n\t| { type: 'data'; data: TLSocketServerSentDataEvent<R>[] }\n\t| { type: 'custom'; data: any }\n\t| TLSocketServerSentDataEvent<R>\n\n/**\n * Union type representing data-related messages sent from server to client.\n *\n * These messages handle the core synchronization operations: applying patches from\n * other clients and confirming the results of client push operations.\n *\n * @param R - The record type being synchronized (extends UnknownRecord)\n *\n * @example\n * ```ts\n * function handleDataEvent(event: TLSocketServerSentDataEvent<MyRecord>) {\n * if (event.type === 'patch') {\n * // Apply changes from other clients\n * applyNetworkDiff(event.diff)\n * } else if (event.type === 'push_result') {\n * // Handle result of our push request\n * if (event.action === 'commit') {\n * console.log('Changes accepted by server')\n * }\n * }\n * }\n * ```\n *\n * @internal\n */\nexport type TLSocketServerSentDataEvent<R extends UnknownRecord> =\n\t| {\n\t\t\ttype: 'patch'\n\t\t\tdiff: NetworkDiff<R>\n\t\t\tserverClock: number\n\t }\n\t| {\n\t\t\ttype: 'push_result'\n\t\t\tclientClock: number\n\t\t\tserverClock: number\n\t\t\taction: 'discard' | 'commit' | { rebaseWithDiff: NetworkDiff<R> }\n\t }\n\n/**\n * Interface defining a client-to-server push request message.\n *\n * Push requests are sent when the client wants to synchronize local changes\n * with the server. They contain document changes and optionally presence updates\n * (like cursor position or user selection).\n *\n * @param R - The record type being synchronized (extends UnknownRecord)\n *\n * @example\n * ```ts\n * const pushRequest: TLPushRequest<MyRecord> = {\n * type: 'push',\n * clientClock: 15,\n * diff: {\n * 'shape:abc123': [RecordOpType.Patch, { x: [ValueOpType.Put, 100] }]\n * },\n * presence: [RecordOpType.Put, { cursor: { x: 150, y: 200 } }]\n * }\n * socket.sendMessage(pushRequest)\n * ```\n *\n * @internal\n */\nexport interface TLPushRequest<R extends UnknownRecord> {\n\ttype: 'push'\n\tclientClock: number\n\tdiff?: NetworkDiff<R>\n\tpresence?: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R]\n}\n\n/**\n * Interface defining a client-to-server connection request message.\n *\n * This message initiates a WebSocket connection to a sync room. It includes\n * the client's schema, protocol version, and last known server clock for\n * proper synchronization state management.\n *\n * @example\n * ```ts\n * const connectRequest: TLConnectRequest = {\n * type: 'connect',\n * connectRequestId: 'conn-123',\n * lastServerClock: 42,\n * protocolVersion: getTlsyncProtocolVersion(),\n * schema: mySchema.serialize()\n * }\n * socket.sendMessage(connectRequest)\n * ```\n *\n * @internal\n */\nexport interface TLConnectRequest {\n\ttype: 'connect'\n\tconnectRequestId: string\n\tlastServerClock: number\n\tprotocolVersion: number\n\tschema: SerializedSchema\n}\n\n/**\n * Interface defining a client-to-server ping request message.\n *\n * Ping requests are used to measure network latency and ensure the connection\n * is still active. The server responds with a 'pong' message.\n *\n * @example\n * ```ts\n * const pingRequest: TLPingRequest = { type: 'ping' }\n * socket.sendMessage(pingRequest)\n *\n * // Server will respond with { type: 'pong' }\n * ```\n *\n * @internal\n */\nexport interface TLPingRequest {\n\ttype: 'ping'\n}\n\n/**\n * Union type representing all possible message types that can be sent from client to server.\n *\n * This encompasses the complete set of client-originated WebSocket messages in the tldraw\n * sync protocol, covering connection establishment, data synchronization, and connectivity checks.\n *\n * @param R - The record type being synchronized (extends UnknownRecord)\n *\n * @example\n * ```ts\n * function sendMessage(message: TLSocketClientSentEvent<MyRecord>) {\n * switch (message.type) {\n * case 'connect':\n * console.log('Establishing connection...')\n * break\n * case 'push':\n * console.log('Pushing changes:', message.diff)\n * break\n * case 'ping':\n * console.log('Checking connection latency')\n * break\n * }\n * socket.send(JSON.stringify(message))\n * }\n * ```\n *\n * @internal\n */\nexport type TLSocketClientSentEvent<R extends UnknownRecord> =\n\t| TLPushRequest<R>\n\t| TLConnectRequest\n\t| TLPingRequest\n"],
|
|
5
5
|
"mappings": "AAGA,MAAM,0BAA0B;AAmBzB,SAAS,2BAA2B;AAC1C,SAAO;AACR;AAmBO,MAAM,0BAA0B;AAAA,EACtC,cAAc;AAAA,EACd,cAAc;AAAA,EACd,eAAe;AAAA,EACf,kBAAkB;AACnB;",
|
|
6
6
|
"names": []
|
|
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
|
+
"version": "4.3.0-canary.d8da2a99f394",
|
|
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": "^18.3.1",
|
|
50
50
|
"react-dom": "^18.3.1",
|
|
51
|
-
"tldraw": "4.
|
|
51
|
+
"tldraw": "4.3.0-canary.d8da2a99f394",
|
|
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.
|
|
59
|
-
"@tldraw/store": "4.
|
|
60
|
-
"@tldraw/tlschema": "4.
|
|
61
|
-
"@tldraw/utils": "4.
|
|
58
|
+
"@tldraw/state": "4.3.0-canary.d8da2a99f394",
|
|
59
|
+
"@tldraw/store": "4.3.0-canary.d8da2a99f394",
|
|
60
|
+
"@tldraw/tlschema": "4.3.0-canary.d8da2a99f394",
|
|
61
|
+
"@tldraw/utils": "4.3.0-canary.d8da2a99f394",
|
|
62
62
|
"nanoevents": "^7.0.1",
|
|
63
63
|
"ws": "^8.18.0"
|
|
64
64
|
},
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
|
2
|
-
export { chunk } from './lib/chunk'
|
|
2
|
+
export { chunk, JsonChunkAssembler } from './lib/chunk'
|
|
3
3
|
export { ClientWebSocketAdapter, ReconnectManager } from './lib/ClientWebSocketAdapter'
|
|
4
4
|
export {
|
|
5
5
|
applyObjectDiff,
|
|
@@ -26,7 +26,7 @@ export {
|
|
|
26
26
|
type TLSocketServerSentDataEvent,
|
|
27
27
|
type TLSocketServerSentEvent,
|
|
28
28
|
} from './lib/protocol'
|
|
29
|
-
export { RoomSessionState, type RoomSession } from './lib/RoomSession'
|
|
29
|
+
export { RoomSessionState, type RoomSession, type RoomSessionBase } from './lib/RoomSession'
|
|
30
30
|
export type { PersistedRoomSnapshotForSupabase } from './lib/server-types'
|
|
31
31
|
export type { WebSocketMinimal } from './lib/ServerSocketAdapter'
|
|
32
32
|
export { TLRemoteSyncError } from './lib/TLRemoteSyncError'
|
|
@@ -54,6 +54,7 @@ describe('RoomSession state transitions', () => {
|
|
|
54
54
|
const initialSession: RoomSession<TLRecord, { userId: string }> = {
|
|
55
55
|
state: RoomSessionState.AwaitingConnectMessage,
|
|
56
56
|
sessionStartTime: Date.now(),
|
|
57
|
+
supportsStringAppend: true,
|
|
57
58
|
...baseSessionData,
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -67,6 +68,7 @@ describe('RoomSession state transitions', () => {
|
|
|
67
68
|
isReadonly: initialSession.isReadonly,
|
|
68
69
|
requiresLegacyRejection: initialSession.requiresLegacyRejection,
|
|
69
70
|
serializedSchema: mockSerializedSchema,
|
|
71
|
+
supportsStringAppend: true,
|
|
70
72
|
lastInteractionTime: Date.now(),
|
|
71
73
|
debounceTimer: null,
|
|
72
74
|
outstandingDataMessages: [],
|
|
@@ -81,6 +83,7 @@ describe('RoomSession state transitions', () => {
|
|
|
81
83
|
meta: connectedSession.meta,
|
|
82
84
|
isReadonly: connectedSession.isReadonly,
|
|
83
85
|
requiresLegacyRejection: connectedSession.requiresLegacyRejection,
|
|
86
|
+
supportsStringAppend: connectedSession.supportsStringAppend,
|
|
84
87
|
cancellationTime: Date.now(),
|
|
85
88
|
}
|
|
86
89
|
|
package/src/lib/RoomSession.ts
CHANGED
|
@@ -64,6 +64,28 @@ export const SESSION_REMOVAL_WAIT_TIME = 5000
|
|
|
64
64
|
*/
|
|
65
65
|
export const SESSION_IDLE_TIMEOUT = 20000
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Base properties shared by all room session states.
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
export interface RoomSessionBase<R extends UnknownRecord, Meta> {
|
|
73
|
+
/** Unique identifier for this session */
|
|
74
|
+
sessionId: string
|
|
75
|
+
/** Presence identifier for live cursor/selection tracking, if available */
|
|
76
|
+
presenceId: string | null
|
|
77
|
+
/** WebSocket connection wrapper for this session */
|
|
78
|
+
socket: TLRoomSocket<R>
|
|
79
|
+
/** Custom metadata associated with this session */
|
|
80
|
+
meta: Meta
|
|
81
|
+
/** Whether this session has read-only permissions */
|
|
82
|
+
isReadonly: boolean
|
|
83
|
+
/** Whether this session requires legacy protocol rejection handling */
|
|
84
|
+
requiresLegacyRejection: boolean
|
|
85
|
+
/** Whether this session supports string append operations */
|
|
86
|
+
supportsStringAppend: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/**
|
|
68
90
|
* Represents a client session within a collaborative room, tracking the connection
|
|
69
91
|
* state, permissions, and synchronization details for a single user.
|
|
@@ -93,51 +115,21 @@ export const SESSION_IDLE_TIMEOUT = 20000
|
|
|
93
115
|
* @internal
|
|
94
116
|
*/
|
|
95
117
|
export type RoomSession<R extends UnknownRecord, Meta> =
|
|
96
|
-
| {
|
|
118
|
+
| (RoomSessionBase<R, Meta> & {
|
|
97
119
|
/** Current state of the session */
|
|
98
120
|
state: typeof RoomSessionState.AwaitingConnectMessage
|
|
99
|
-
/** Unique identifier for this session */
|
|
100
|
-
sessionId: string
|
|
101
|
-
/** Presence identifier for live cursor/selection tracking, if available */
|
|
102
|
-
presenceId: string | null
|
|
103
|
-
/** WebSocket connection wrapper for this session */
|
|
104
|
-
socket: TLRoomSocket<R>
|
|
105
121
|
/** Timestamp when the session was created */
|
|
106
122
|
sessionStartTime: number
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
/** Whether this session has read-only permissions */
|
|
110
|
-
isReadonly: boolean
|
|
111
|
-
/** Whether this session requires legacy protocol rejection handling */
|
|
112
|
-
requiresLegacyRejection: boolean
|
|
113
|
-
}
|
|
114
|
-
| {
|
|
123
|
+
})
|
|
124
|
+
| (RoomSessionBase<R, Meta> & {
|
|
115
125
|
/** Current state of the session */
|
|
116
126
|
state: typeof RoomSessionState.AwaitingRemoval
|
|
117
|
-
/** Unique identifier for this session */
|
|
118
|
-
sessionId: string
|
|
119
|
-
/** Presence identifier for live cursor/selection tracking, if available */
|
|
120
|
-
presenceId: string | null
|
|
121
|
-
/** WebSocket connection wrapper for this session */
|
|
122
|
-
socket: TLRoomSocket<R>
|
|
123
127
|
/** Timestamp when the session was marked for removal */
|
|
124
128
|
cancellationTime: number
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/** Whether this session has read-only permissions */
|
|
128
|
-
isReadonly: boolean
|
|
129
|
-
/** Whether this session requires legacy protocol rejection handling */
|
|
130
|
-
requiresLegacyRejection: boolean
|
|
131
|
-
}
|
|
132
|
-
| {
|
|
129
|
+
})
|
|
130
|
+
| (RoomSessionBase<R, Meta> & {
|
|
133
131
|
/** Current state of the session */
|
|
134
132
|
state: typeof RoomSessionState.Connected
|
|
135
|
-
/** Unique identifier for this session */
|
|
136
|
-
sessionId: string
|
|
137
|
-
/** Presence identifier for live cursor/selection tracking, if available */
|
|
138
|
-
presenceId: string | null
|
|
139
|
-
/** WebSocket connection wrapper for this session */
|
|
140
|
-
socket: TLRoomSocket<R>
|
|
141
133
|
/** Serialized schema information for this connected session */
|
|
142
134
|
serializedSchema: SerializedSchema
|
|
143
135
|
/** Timestamp of the last interaction or message from this session */
|
|
@@ -146,10 +138,4 @@ export type RoomSession<R extends UnknownRecord, Meta> =
|
|
|
146
138
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
147
139
|
/** Queue of data messages waiting to be sent to this session */
|
|
148
140
|
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
|
|
149
|
-
|
|
150
|
-
meta: Meta
|
|
151
|
-
/** Whether this session has read-only permissions */
|
|
152
|
-
isReadonly: boolean
|
|
153
|
-
/** Whether this session requires legacy protocol rejection handling */
|
|
154
|
-
requiresLegacyRejection: boolean
|
|
155
|
-
}
|
|
141
|
+
})
|
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -156,10 +156,15 @@ export class DocumentState<R extends UnknownRecord> {
|
|
|
156
156
|
*
|
|
157
157
|
* @param state - The new record state
|
|
158
158
|
* @param clock - The new clock value
|
|
159
|
+
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
159
160
|
* @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
|
|
160
161
|
*/
|
|
161
|
-
replaceState(
|
|
162
|
-
|
|
162
|
+
replaceState(
|
|
163
|
+
state: R,
|
|
164
|
+
clock: number,
|
|
165
|
+
legacyAppendMode = false
|
|
166
|
+
): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
|
|
167
|
+
const diff = diffRecord(this.state, state, legacyAppendMode)
|
|
163
168
|
if (!diff) return Result.ok(null)
|
|
164
169
|
try {
|
|
165
170
|
this.recordType.validate(state)
|
|
@@ -173,11 +178,16 @@ export class DocumentState<R extends UnknownRecord> {
|
|
|
173
178
|
*
|
|
174
179
|
* @param diff - The object diff to apply
|
|
175
180
|
* @param clock - The new clock value
|
|
181
|
+
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
176
182
|
* @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
|
|
177
183
|
*/
|
|
178
|
-
mergeDiff(
|
|
184
|
+
mergeDiff(
|
|
185
|
+
diff: ObjectDiff,
|
|
186
|
+
clock: number,
|
|
187
|
+
legacyAppendMode = false
|
|
188
|
+
): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
|
|
179
189
|
const newState = applyObjectDiff(this.state, diff)
|
|
180
|
-
return this.replaceState(newState, clock)
|
|
190
|
+
return this.replaceState(newState, clock, legacyAppendMode)
|
|
181
191
|
}
|
|
182
192
|
}
|
|
183
193
|
|
|
@@ -720,6 +730,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
720
730
|
meta: session.meta,
|
|
721
731
|
isReadonly: session.isReadonly,
|
|
722
732
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
733
|
+
supportsStringAppend: session.supportsStringAppend,
|
|
723
734
|
})
|
|
724
735
|
|
|
725
736
|
try {
|
|
@@ -843,10 +854,28 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
843
854
|
isReadonly: isReadonly ?? false,
|
|
844
855
|
// this gets set later during handleConnectMessage
|
|
845
856
|
requiresLegacyRejection: false,
|
|
857
|
+
supportsStringAppend: true,
|
|
846
858
|
})
|
|
847
859
|
return this
|
|
848
860
|
}
|
|
849
861
|
|
|
862
|
+
/**
|
|
863
|
+
* Checks if all connected sessions support string append operations (protocol version 8+).
|
|
864
|
+
* If any client is on an older version, returns false to enable legacy append mode.
|
|
865
|
+
*
|
|
866
|
+
* @returns True if all connected sessions are on protocol version 8 or higher
|
|
867
|
+
*/
|
|
868
|
+
getCanEmitStringAppend(): boolean {
|
|
869
|
+
for (const session of this.sessions.values()) {
|
|
870
|
+
if (session.state === RoomSessionState.Connected) {
|
|
871
|
+
if (!session.supportsStringAppend) {
|
|
872
|
+
return false
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return true
|
|
877
|
+
}
|
|
878
|
+
|
|
850
879
|
/**
|
|
851
880
|
* When we send a diff to a client, if that client is on a lower version than us, we need to make
|
|
852
881
|
* the diff compatible with their version. At the moment this means migrating each affected record
|
|
@@ -1010,6 +1039,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1010
1039
|
if (theirProtocolVersion === 6) {
|
|
1011
1040
|
theirProtocolVersion++
|
|
1012
1041
|
}
|
|
1042
|
+
if (theirProtocolVersion === 7) {
|
|
1043
|
+
theirProtocolVersion++
|
|
1044
|
+
session.supportsStringAppend = false
|
|
1045
|
+
}
|
|
1013
1046
|
|
|
1014
1047
|
if (theirProtocolVersion == null || theirProtocolVersion < getTlsyncProtocolVersion()) {
|
|
1015
1048
|
this.rejectSession(session.sessionId, TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
@@ -1045,6 +1078,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1045
1078
|
lastInteractionTime: Date.now(),
|
|
1046
1079
|
debounceTimer: null,
|
|
1047
1080
|
outstandingDataMessages: [],
|
|
1081
|
+
supportsStringAppend: session.supportsStringAppend,
|
|
1048
1082
|
meta: session.meta,
|
|
1049
1083
|
isReadonly: session.isReadonly,
|
|
1050
1084
|
requiresLegacyRejection: session.requiresLegacyRejection,
|
|
@@ -1150,6 +1184,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1150
1184
|
const initialDocumentClock = this.documentClock
|
|
1151
1185
|
let didPresenceChange = false
|
|
1152
1186
|
transaction((rollback) => {
|
|
1187
|
+
const legacyAppendMode = !this.getCanEmitStringAppend()
|
|
1153
1188
|
// collect actual ops that resulted from the push
|
|
1154
1189
|
// these will be broadcast to other users
|
|
1155
1190
|
interface ActualChanges {
|
|
@@ -1198,7 +1233,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1198
1233
|
if (doc) {
|
|
1199
1234
|
// If there's an existing document, replace it with the new state
|
|
1200
1235
|
// but propagate a diff rather than the entire value
|
|
1201
|
-
const diff = doc.replaceState(state, this.clock)
|
|
1236
|
+
const diff = doc.replaceState(state, this.clock, legacyAppendMode)
|
|
1202
1237
|
if (!diff.ok) {
|
|
1203
1238
|
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1204
1239
|
}
|
|
@@ -1238,7 +1273,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1238
1273
|
|
|
1239
1274
|
if (downgraded.value === doc.state) {
|
|
1240
1275
|
// If the versions are compatible, apply the patch and propagate the patch op
|
|
1241
|
-
const diff = doc.mergeDiff(patch, this.clock)
|
|
1276
|
+
const diff = doc.mergeDiff(patch, this.clock, legacyAppendMode)
|
|
1242
1277
|
if (!diff.ok) {
|
|
1243
1278
|
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1244
1279
|
}
|
|
@@ -1260,7 +1295,7 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
|
|
|
1260
1295
|
return fail(TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
|
|
1261
1296
|
}
|
|
1262
1297
|
// replace the state with the upgraded version and propagate the patch op
|
|
1263
|
-
const diff = doc.replaceState(upgraded.value, this.clock)
|
|
1298
|
+
const diff = doc.replaceState(upgraded.value, this.clock, legacyAppendMode)
|
|
1264
1299
|
if (!diff.ok) {
|
|
1265
1300
|
return fail(TLSyncErrorCloseEventReason.INVALID_RECORD)
|
|
1266
1301
|
}
|
package/src/lib/chunk.ts
CHANGED
|
@@ -83,13 +83,13 @@ export class JsonChunkAssembler {
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Processes a single message, which can be either a complete JSON object or a chunk.
|
|
86
|
-
* For complete JSON objects (starting with '{'), parses immediately.
|
|
87
|
-
* For chunks (prefixed with "{number}_"), accumulates until all chunks received.
|
|
86
|
+
* For complete JSON objects (starting with '\{'), parses immediately.
|
|
87
|
+
* For chunks (prefixed with "\{number\}_"), accumulates until all chunks received.
|
|
88
88
|
*
|
|
89
89
|
* @param msg - The message to process, either JSON or chunk format
|
|
90
90
|
* @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
|
|
91
|
-
* -
|
|
92
|
-
* -
|
|
91
|
+
* - `\{ data: object, stringified: string \}` - Successfully parsed complete message
|
|
92
|
+
* - `\{ error: Error \}` - Parse error or invalid chunk sequence
|
|
93
93
|
* - `null` - Chunk received but more chunks expected
|
|
94
94
|
*
|
|
95
95
|
* @example
|
package/src/lib/diff.ts
CHANGED
|
@@ -123,11 +123,11 @@ export type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]
|
|
|
123
123
|
*/
|
|
124
124
|
export type PutOp = [type: typeof ValueOpType.Put, value: unknown]
|
|
125
125
|
/**
|
|
126
|
-
* Operation that appends new values to the end of an array.
|
|
126
|
+
* Operation that appends new values to the end of an array or string.
|
|
127
127
|
*
|
|
128
128
|
* @internal
|
|
129
129
|
*/
|
|
130
|
-
export type AppendOp = [type: typeof ValueOpType.Append,
|
|
130
|
+
export type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]
|
|
131
131
|
/**
|
|
132
132
|
* Operation that applies a nested diff to an object or array.
|
|
133
133
|
*
|
|
@@ -165,6 +165,7 @@ export interface ObjectDiff {
|
|
|
165
165
|
*
|
|
166
166
|
* @param prev - The previous version of the record
|
|
167
167
|
* @param next - The next version of the record
|
|
168
|
+
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
|
|
168
169
|
* @returns An ObjectDiff describing the changes, or null if no changes exist
|
|
169
170
|
*
|
|
170
171
|
* @example
|
|
@@ -181,11 +182,20 @@ export interface ObjectDiff {
|
|
|
181
182
|
*
|
|
182
183
|
* @internal
|
|
183
184
|
*/
|
|
184
|
-
export function diffRecord(
|
|
185
|
-
|
|
185
|
+
export function diffRecord(
|
|
186
|
+
prev: object,
|
|
187
|
+
next: object,
|
|
188
|
+
legacyAppendMode = false
|
|
189
|
+
): ObjectDiff | null {
|
|
190
|
+
return diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)
|
|
186
191
|
}
|
|
187
192
|
|
|
188
|
-
function diffObject(
|
|
193
|
+
function diffObject(
|
|
194
|
+
prev: object,
|
|
195
|
+
next: object,
|
|
196
|
+
nestedKeys: Set<string> | undefined,
|
|
197
|
+
legacyAppendMode: boolean
|
|
198
|
+
): ObjectDiff | null {
|
|
189
199
|
if (prev === next) {
|
|
190
200
|
return null
|
|
191
201
|
}
|
|
@@ -197,26 +207,22 @@ function diffObject(prev: object, next: object, nestedKeys?: Set<string>): Objec
|
|
|
197
207
|
result[key] = [ValueOpType.Delete]
|
|
198
208
|
continue
|
|
199
209
|
}
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
} else if (Array.isArray(nextVal) && Array.isArray(prevVal)) {
|
|
211
|
-
const op = diffArray(prevVal, nextVal)
|
|
212
|
-
if (op) {
|
|
213
|
-
if (!result) result = {}
|
|
214
|
-
result[key] = op
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
210
|
+
const prevValue = (prev as any)[key]
|
|
211
|
+
const nextValue = (next as any)[key]
|
|
212
|
+
if (
|
|
213
|
+
nestedKeys?.has(key) ||
|
|
214
|
+
(Array.isArray(prevValue) && Array.isArray(nextValue)) ||
|
|
215
|
+
(typeof prevValue === 'string' && typeof nextValue === 'string')
|
|
216
|
+
) {
|
|
217
|
+
// if key is in both places, then compare values
|
|
218
|
+
const diff = diffValue(prevValue, nextValue, legacyAppendMode)
|
|
219
|
+
if (diff) {
|
|
217
220
|
if (!result) result = {}
|
|
218
|
-
result[key] =
|
|
221
|
+
result[key] = diff
|
|
219
222
|
}
|
|
223
|
+
} else if (!isEqual(prevValue, nextValue)) {
|
|
224
|
+
if (!result) result = {}
|
|
225
|
+
result[key] = [ValueOpType.Put, nextValue]
|
|
220
226
|
}
|
|
221
227
|
}
|
|
222
228
|
for (const key of Object.keys(next)) {
|
|
@@ -229,19 +235,29 @@ function diffObject(prev: object, next: object, nestedKeys?: Set<string>): Objec
|
|
|
229
235
|
return result
|
|
230
236
|
}
|
|
231
237
|
|
|
232
|
-
function diffValue(valueA: unknown, valueB: unknown): ValueOp | null {
|
|
238
|
+
function diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {
|
|
233
239
|
if (Object.is(valueA, valueB)) return null
|
|
234
240
|
if (Array.isArray(valueA) && Array.isArray(valueB)) {
|
|
235
|
-
return diffArray(valueA, valueB)
|
|
241
|
+
return diffArray(valueA, valueB, legacyAppendMode)
|
|
242
|
+
} else if (typeof valueA === 'string' && typeof valueB === 'string') {
|
|
243
|
+
if (!legacyAppendMode && valueB.startsWith(valueA)) {
|
|
244
|
+
const appendedText = valueB.slice(valueA.length)
|
|
245
|
+
return [ValueOpType.Append, appendedText, valueA.length]
|
|
246
|
+
}
|
|
247
|
+
return [ValueOpType.Put, valueB]
|
|
236
248
|
} else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {
|
|
237
249
|
return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
|
|
238
250
|
} else {
|
|
239
|
-
const diff = diffObject(valueA, valueB)
|
|
251
|
+
const diff = diffObject(valueA, valueB, undefined, legacyAppendMode)
|
|
240
252
|
return diff ? [ValueOpType.Patch, diff] : null
|
|
241
253
|
}
|
|
242
254
|
}
|
|
243
255
|
|
|
244
|
-
function diffArray(
|
|
256
|
+
function diffArray(
|
|
257
|
+
prevArray: unknown[],
|
|
258
|
+
nextArray: unknown[],
|
|
259
|
+
legacyAppendMode: boolean
|
|
260
|
+
): PutOp | AppendOp | PatchOp | null {
|
|
245
261
|
if (Object.is(prevArray, nextArray)) return null
|
|
246
262
|
// if lengths are equal, check for patch operation
|
|
247
263
|
if (prevArray.length === nextArray.length) {
|
|
@@ -267,7 +283,7 @@ function diffArray(prevArray: unknown[], nextArray: unknown[]): PutOp | AppendOp
|
|
|
267
283
|
if (!prevItem || !nextItem) {
|
|
268
284
|
diff[i] = [ValueOpType.Put, nextItem]
|
|
269
285
|
} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {
|
|
270
|
-
const op = diffValue(prevItem, nextItem)
|
|
286
|
+
const op = diffValue(prevItem, nextItem, legacyAppendMode)
|
|
271
287
|
if (op) {
|
|
272
288
|
diff[i] = op
|
|
273
289
|
}
|
|
@@ -341,12 +357,19 @@ export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectD
|
|
|
341
357
|
break
|
|
342
358
|
}
|
|
343
359
|
case ValueOpType.Append: {
|
|
344
|
-
const
|
|
360
|
+
const value = op[1]
|
|
345
361
|
const offset = op[2]
|
|
346
|
-
const
|
|
347
|
-
if (Array.isArray(
|
|
348
|
-
set(key, [...
|
|
362
|
+
const currentValue = object[key as keyof T]
|
|
363
|
+
if (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {
|
|
364
|
+
set(key, [...currentValue, ...value])
|
|
365
|
+
} else if (
|
|
366
|
+
typeof currentValue === 'string' &&
|
|
367
|
+
typeof value === 'string' &&
|
|
368
|
+
currentValue.length === offset
|
|
369
|
+
) {
|
|
370
|
+
set(key, currentValue + value)
|
|
349
371
|
}
|
|
372
|
+
// If validation fails (type mismatch or length mismatch), silently ignore
|
|
350
373
|
break
|
|
351
374
|
}
|
|
352
375
|
case ValueOpType.Patch: {
|
package/src/lib/protocol.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SerializedSchema, UnknownRecord } from '@tldraw/store'
|
|
2
2
|
import { NetworkDiff, ObjectDiff, RecordOpType } from './diff'
|
|
3
3
|
|
|
4
|
-
const TLSYNC_PROTOCOL_VERSION =
|
|
4
|
+
const TLSYNC_PROTOCOL_VERSION = 8
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Gets the current tldraw sync protocol version number.
|
|
@@ -159,7 +159,7 @@ describe(TLSocketRoom, () => {
|
|
|
159
159
|
type: 'connect' as const,
|
|
160
160
|
connectRequestId: 'connect-1',
|
|
161
161
|
lastServerClock: 0,
|
|
162
|
-
protocolVersion:
|
|
162
|
+
protocolVersion: 8,
|
|
163
163
|
schema: store.schema.serialize(),
|
|
164
164
|
}
|
|
165
165
|
room.handleSocketMessage(sessionId1, JSON.stringify(connectRequest1))
|
|
@@ -168,7 +168,7 @@ describe(TLSocketRoom, () => {
|
|
|
168
168
|
type: 'connect' as const,
|
|
169
169
|
connectRequestId: 'connect-2',
|
|
170
170
|
lastServerClock: 0,
|
|
171
|
-
protocolVersion:
|
|
171
|
+
protocolVersion: 8,
|
|
172
172
|
schema: store.schema.serialize(),
|
|
173
173
|
}
|
|
174
174
|
room.handleSocketMessage(sessionId2, JSON.stringify(connectRequest2))
|