@tldraw/sync-core 4.1.0-next.b6dfe9bccde9 → 4.1.0-next.b9999db71010

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist-cjs/index.d.ts +605 -75
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
  4. package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
  5. package/dist-cjs/lib/RoomSession.js +3 -0
  6. package/dist-cjs/lib/RoomSession.js.map +2 -2
  7. package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
  8. package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
  9. package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
  10. package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
  11. package/dist-cjs/lib/TLSocketRoom.js +280 -56
  12. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  13. package/dist-cjs/lib/TLSyncClient.js +45 -2
  14. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  15. package/dist-cjs/lib/TLSyncRoom.js +161 -16
  16. package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
  17. package/dist-cjs/lib/chunk.js +30 -0
  18. package/dist-cjs/lib/chunk.js.map +2 -2
  19. package/dist-cjs/lib/diff.js.map +2 -2
  20. package/dist-cjs/lib/findMin.js.map +2 -2
  21. package/dist-cjs/lib/interval.js.map +2 -2
  22. package/dist-cjs/lib/protocol.js.map +2 -2
  23. package/dist-cjs/lib/server-types.js.map +1 -1
  24. package/dist-esm/index.d.mts +605 -75
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
  27. package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
  28. package/dist-esm/lib/RoomSession.mjs +3 -0
  29. package/dist-esm/lib/RoomSession.mjs.map +2 -2
  30. package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
  31. package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
  32. package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
  33. package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
  34. package/dist-esm/lib/TLSocketRoom.mjs +280 -56
  35. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  36. package/dist-esm/lib/TLSyncClient.mjs +45 -2
  37. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  38. package/dist-esm/lib/TLSyncRoom.mjs +161 -16
  39. package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
  40. package/dist-esm/lib/chunk.mjs +30 -0
  41. package/dist-esm/lib/chunk.mjs.map +2 -2
  42. package/dist-esm/lib/diff.mjs.map +2 -2
  43. package/dist-esm/lib/findMin.mjs.map +2 -2
  44. package/dist-esm/lib/interval.mjs.map +2 -2
  45. package/dist-esm/lib/protocol.mjs.map +2 -2
  46. package/package.json +6 -6
  47. package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
  48. package/src/lib/ClientWebSocketAdapter.ts +240 -9
  49. package/src/lib/RoomSession.test.ts +97 -0
  50. package/src/lib/RoomSession.ts +105 -3
  51. package/src/lib/ServerSocketAdapter.test.ts +228 -0
  52. package/src/lib/ServerSocketAdapter.ts +124 -5
  53. package/src/lib/TLRemoteSyncError.ts +50 -1
  54. package/src/lib/TLSocketRoom.ts +377 -60
  55. package/src/lib/TLSyncClient.test.ts +828 -0
  56. package/src/lib/TLSyncClient.ts +251 -26
  57. package/src/lib/TLSyncRoom.ts +284 -24
  58. package/src/lib/chunk.ts +72 -1
  59. package/src/lib/diff.ts +128 -14
  60. package/src/lib/findMin.ts +6 -0
  61. package/src/lib/interval.ts +40 -0
  62. package/src/lib/protocol.ts +185 -7
  63. package/src/lib/server-types.test.ts +44 -0
  64. package/src/lib/server-types.ts +45 -1
  65. package/src/test/TLSocketRoom.test.ts +438 -29
  66. package/src/test/chunk.test.ts +200 -3
  67. package/src/test/diff.test.ts +396 -1
@@ -51,24 +51,71 @@ import {
51
51
  getTlsyncProtocolVersion,
52
52
  } from './protocol'
53
53
 
54
- /** @internal */
54
+ /**
55
+ * WebSocket interface for server-side room connections. This defines the contract
56
+ * that socket implementations must follow to work with TLSyncRoom.
57
+ *
58
+ * @internal
59
+ */
55
60
  export interface TLRoomSocket<R extends UnknownRecord> {
61
+ /**
62
+ * Whether the socket connection is currently open and ready to send messages.
63
+ */
56
64
  isOpen: boolean
65
+ /**
66
+ * Send a message to the connected client through this socket.
67
+ *
68
+ * @param msg - The server-sent event message to transmit
69
+ */
57
70
  sendMessage(msg: TLSocketServerSentEvent<R>): void
71
+ /**
72
+ * Close the socket connection with optional status code and reason.
73
+ *
74
+ * @param code - WebSocket close code (optional)
75
+ * @param reason - Human-readable close reason (optional)
76
+ */
58
77
  close(code?: number, reason?: string): void
59
78
  }
60
79
 
61
- // the max number of tombstones to keep in the store
80
+ /**
81
+ * The maximum number of tombstone records to keep in memory. Tombstones track
82
+ * deleted records to prevent resurrection during sync operations.
83
+ * @public
84
+ */
62
85
  export const MAX_TOMBSTONES = 3000
63
- // the number of tombstones to delete when the max is reached
86
+
87
+ /**
88
+ * The number of tombstones to delete when pruning occurs after reaching MAX_TOMBSTONES.
89
+ * This buffer prevents frequent pruning operations.
90
+ * @public
91
+ */
64
92
  export const TOMBSTONE_PRUNE_BUFFER_SIZE = 300
65
- // the minimum time between data-related messages to the clients
93
+
94
+ /**
95
+ * The minimum time interval (in milliseconds) between sending batched data messages
96
+ * to clients. This debouncing prevents overwhelming clients with rapid updates.
97
+ * @public
98
+ */
66
99
  export const DATA_MESSAGE_DEBOUNCE_INTERVAL = 1000 / 60
67
100
 
68
101
  const timeSince = (time: number) => Date.now() - time
69
102
 
70
- /** @internal */
103
+ /**
104
+ * Represents the state of a document record within a sync room, including
105
+ * its current data and the clock value when it was last modified.
106
+ *
107
+ * @internal
108
+ */
71
109
  export class DocumentState<R extends UnknownRecord> {
110
+ /**
111
+ * Create a DocumentState instance without validating the record data.
112
+ * Used for performance when validation has already been performed.
113
+ *
114
+ * @param state - The record data
115
+ * @param lastChangedClock - Clock value when this record was last modified
116
+ * @param recordType - The record type definition for validation
117
+ * @returns A new DocumentState instance
118
+ */
72
119
  static createWithoutValidating<R extends UnknownRecord>(
73
120
  state: R,
74
121
  lastChangedClock: number,
@@ -77,6 +124,14 @@ export class DocumentState<R extends UnknownRecord> {
77
124
  return new DocumentState(state, lastChangedClock, recordType)
78
125
  }
79
126
 
127
+ /**
128
+ * Create a DocumentState instance with validation of the record data.
129
+ *
130
+ * @param state - The record data to validate
131
+ * @param lastChangedClock - Clock value when this record was last modified
132
+ * @param recordType - The record type definition for validation
133
+ * @returns Result containing the DocumentState or validation error
134
+ */
80
135
  static createAndValidate<R extends UnknownRecord>(
81
136
  state: R,
82
137
  lastChangedClock: number,
@@ -96,6 +151,13 @@ export class DocumentState<R extends UnknownRecord> {
96
151
  private readonly recordType: RecordType<R, any>
97
152
  ) {}
98
153
 
154
+ /**
155
+ * Replace the current state with new state and calculate the diff.
156
+ *
157
+ * @param state - The new record state
158
+ * @param clock - The new clock value
159
+ * @returns Result containing the diff and new DocumentState, or null if no changes, or validation error
160
+ */
99
161
  replaceState(state: R, clock: number): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
100
162
  const diff = diffRecord(this.state, state)
101
163
  if (!diff) return Result.ok(null)
@@ -106,19 +168,49 @@ export class DocumentState<R extends UnknownRecord> {
106
168
  }
107
169
  return Result.ok([diff, new DocumentState(state, clock, this.recordType)])
108
170
  }
171
+ /**
172
+ * Apply a diff to the current state and return the resulting changes.
173
+ *
174
+ * @param diff - The object diff to apply
175
+ * @param clock - The new clock value
176
+ * @returns Result containing the final diff and new DocumentState, or null if no changes, or validation error
177
+ */
109
178
  mergeDiff(diff: ObjectDiff, clock: number): Result<[ObjectDiff, DocumentState<R>] | null, Error> {
110
179
  const newState = applyObjectDiff(this.state, diff)
111
180
  return this.replaceState(newState, clock)
112
181
  }
113
182
  }
114
183
 
115
- /** @public */
184
+ /**
185
+ * Snapshot of a room's complete state that can be persisted and restored.
186
+ * Contains all documents, tombstones, and metadata needed to reconstruct the room.
187
+ *
188
+ * @public
189
+ */
116
190
  export interface RoomSnapshot {
191
+ /**
192
+ * The current logical clock value for the room
193
+ */
117
194
  clock: number
195
+ /**
196
+ * Clock value when document data was last changed (optional for backwards compatibility)
197
+ */
118
198
  documentClock?: number
199
+ /**
200
+ * Array of all document records with their last modification clocks
201
+ */
119
202
  documents: Array<{ state: UnknownRecord; lastChangedClock: number }>
203
+ /**
204
+ * Map of deleted record IDs to their deletion clock values (optional)
205
+ */
120
206
  tombstones?: Record<string, number>
207
+ /**
208
+ * Clock value where tombstone history begins - older deletions are not tracked (optional)
209
+ */
121
210
  tombstoneHistoryStartsAtClock?: number
211
+ /**
212
+ * Serialized schema used when creating this snapshot (optional)
213
+ */
122
214
  schema?: SerializedSchema
123
215
  }
124
216
 
@@ -137,8 +229,27 @@ function getDocumentClock(snapshot: RoomSnapshot) {
137
229
  }
138
230
 
139
231
  /**
140
- * A room is a workspace for a group of clients. It allows clients to collaborate on documents
141
- * within that workspace.
232
+ * A collaborative workspace that manages multiple client sessions and synchronizes
233
+ * document changes between them. The room serves as the authoritative source for
234
+ * all document state and handles conflict resolution, schema migrations, and
235
+ * real-time data distribution.
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * const room = new TLSyncRoom({
240
+ * schema: mySchema,
241
+ * onDataChange: () => saveToDatabase(room.getSnapshot()),
242
+ * onPresenceChange: () => updateLiveCursors()
243
+ * })
244
+ *
245
+ * // Handle new client connections
246
+ * room.handleNewSession({
247
+ * sessionId: 'user-123',
248
+ * socket: webSocketAdapter,
249
+ * meta: { userId: '123', name: 'Alice' },
250
+ * isReadonly: false
251
+ * })
252
+ * ```
142
253
  *
143
254
  * @internal
144
255
  */
@@ -183,6 +294,10 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
183
294
 
184
295
  private _isClosed = false
185
296
 
297
+ /**
298
+ * Close the room and clean up all resources. Disconnects all sessions
299
+ * and stops background processes.
300
+ */
186
301
  close() {
187
302
  this.disposables.forEach((d) => d())
188
303
  this.sessions.forEach((session) => {
@@ -191,6 +306,11 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
191
306
  this._isClosed = true
192
307
  }
193
308
 
309
+ /**
310
+ * Check if the room has been closed and is no longer accepting connections.
311
+ *
312
+ * @returns True if the room is closed
313
+ */
194
314
  isClosed() {
195
315
  return this._isClosed
196
316
  }
@@ -442,6 +562,23 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
442
562
  }
443
563
  }
444
564
 
565
+ /**
566
+ * Get a complete snapshot of the current room state that can be persisted
567
+ * and later used to restore the room.
568
+ *
569
+ * @returns Room snapshot containing all documents, tombstones, and metadata
570
+ * @example
571
+ * ```ts
572
+ * const snapshot = room.getSnapshot()
573
+ * await database.saveRoomSnapshot(roomId, snapshot)
574
+ *
575
+ * // Later, restore from snapshot
576
+ * const restoredRoom = new TLSyncRoom({
577
+ * schema: mySchema,
578
+ * snapshot: snapshot
579
+ * })
580
+ * ```
581
+ */
445
582
  getSnapshot(): RoomSnapshot {
446
583
  const tombstones = Object.fromEntries(this.tombstones.entries())
447
584
  const documents = []
@@ -594,8 +731,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
594
731
 
595
732
  /**
596
733
  * Broadcast a patch to all connected clients except the one with the sessionId provided.
734
+ * Automatically handles schema migration for clients on different versions.
597
735
  *
598
- * @param message - The message to broadcast.
736
+ * @param message - The broadcast message
737
+ * - diff - The network diff to broadcast to all clients
738
+ * - sourceSessionId - Optional ID of the session that originated this change (excluded from broadcast)
739
+ * @returns This room instance for method chaining
740
+ * @example
741
+ * ```ts
742
+ * room.broadcastPatch({
743
+ * diff: { 'shape:123': [RecordOpType.Put, newShapeData] },
744
+ * sourceSessionId: 'user-456' // This user won't receive the broadcast
745
+ * })
746
+ * ```
599
747
  */
600
748
  broadcastPatch(message: { diff: NetworkDiff<R>; sourceSessionId?: string }) {
601
749
  const { diff, sourceSessionId } = message
@@ -630,18 +778,50 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
630
778
  }
631
779
 
632
780
  /**
633
- * Send a custom message to a connected client.
781
+ * Send a custom message to a connected client. Useful for application-specific
782
+ * communication that doesn't involve document synchronization.
634
783
  *
635
- * @param sessionId - The id of the session to send the message to.
636
- * @param data - The payload to send.
784
+ * @param sessionId - The ID of the session to send the message to
785
+ * @param data - The custom payload to send (will be JSON serialized)
786
+ * @example
787
+ * ```ts
788
+ * // Send a custom notification
789
+ * room.sendCustomMessage('user-123', {
790
+ * type: 'notification',
791
+ * message: 'Document saved successfully'
792
+ * })
793
+ *
794
+ * // Send user-specific data
795
+ * room.sendCustomMessage('user-456', {
796
+ * type: 'user_permissions',
797
+ * canEdit: true,
798
+ * canDelete: false
799
+ * })
800
+ * ```
637
801
  */
638
802
  sendCustomMessage(sessionId: string, data: any): void {
639
803
  this.sendMessage(sessionId, { type: 'custom', data })
640
804
  }
641
805
 
642
806
  /**
643
- * When a client connects to the room, add them to the list of clients and then merge the history
644
- * down into the snapshots.
807
+ * Register a new client session with the room. The session will be in an awaiting
808
+ * state until it sends a connect message with protocol handshake.
809
+ *
810
+ * @param opts - Session configuration
811
+ * - sessionId - Unique identifier for this session
812
+ * - socket - WebSocket adapter for communication
813
+ * - meta - Application-specific metadata for this session
814
+ * - isReadonly - Whether this session can modify documents
815
+ * @returns This room instance for method chaining
816
+ * @example
817
+ * ```ts
818
+ * room.handleNewSession({
819
+ * sessionId: crypto.randomUUID(),
820
+ * socket: new WebSocketAdapter(ws),
821
+ * meta: { userId: '123', name: 'Alice', avatar: 'url' },
822
+ * isReadonly: !hasEditPermission
823
+ * })
824
+ * ```
645
825
  *
646
826
  * @internal
647
827
  */
@@ -714,11 +894,19 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
714
894
  }
715
895
 
716
896
  /**
717
- * When the server receives a message from the clients Currently, supports connect and patches.
718
- * Invalid messages types throws an error. Currently, doesn't validate data.
897
+ * Process an incoming message from a client session. Handles connection requests,
898
+ * data synchronization pushes, and ping/pong for connection health.
719
899
  *
720
- * @param sessionId - The session that sent the message
721
- * @param message - The message that was sent
900
+ * @param sessionId - The ID of the session that sent the message
901
+ * @param message - The client message to process
902
+ * @example
903
+ * ```ts
904
+ * // Typically called by WebSocket message handlers
905
+ * websocket.onMessage((data) => {
906
+ * const message = JSON.parse(data)
907
+ * room.handleMessage(sessionId, message)
908
+ * })
909
+ * ```
722
910
  */
723
911
  async handleMessage(sessionId: string, message: TLSocketClientSentEvent<R>) {
724
912
  const session = this.sessions.get(sessionId)
@@ -745,7 +933,21 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
745
933
  }
746
934
  }
747
935
 
748
- /** If the client is out of date, or we are out of date, we need to let them know */
936
+ /**
937
+ * Reject and disconnect a session due to incompatibility or other fatal errors.
938
+ * Sends appropriate error messages before closing the connection.
939
+ *
940
+ * @param sessionId - The session to reject
941
+ * @param fatalReason - The reason for rejection (optional)
942
+ * @example
943
+ * ```ts
944
+ * // Reject due to version mismatch
945
+ * room.rejectSession('user-123', TLSyncErrorCloseEventReason.CLIENT_TOO_OLD)
946
+ *
947
+ * // Reject due to permission issue
948
+ * room.rejectSession('user-456', 'Insufficient permissions')
949
+ * ```
950
+ */
749
951
  rejectSession(sessionId: string, fatalReason?: TLSyncErrorCloseEventReason | string) {
750
952
  const session = this.sessions.get(sessionId)
751
953
  if (!session) return
@@ -1227,18 +1429,41 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1227
1429
  }
1228
1430
 
1229
1431
  /**
1230
- * Handle the event when a client disconnects.
1432
+ * Handle the event when a client disconnects. Cleans up the session and
1433
+ * removes any presence information.
1231
1434
  *
1232
- * @param sessionId - The session that disconnected.
1435
+ * @param sessionId - The session that disconnected
1436
+ * @example
1437
+ * ```ts
1438
+ * websocket.onClose(() => {
1439
+ * room.handleClose(sessionId)
1440
+ * })
1441
+ * ```
1233
1442
  */
1234
1443
  handleClose(sessionId: string) {
1235
1444
  this.cancelSession(sessionId)
1236
1445
  }
1237
1446
 
1238
1447
  /**
1239
- * Allow applying changes to the store in a transactional way.
1240
- * @param updater - A function that will be called with a store object that can be used to make changes.
1241
- * @returns A promise that resolves when the transaction is complete.
1448
+ * Apply changes to the room's store in a transactional way. Changes are
1449
+ * automatically synchronized to all connected clients.
1450
+ *
1451
+ * @param updater - Function that receives store methods to make changes
1452
+ * @returns Promise that resolves when the transaction is complete
1453
+ * @example
1454
+ * ```ts
1455
+ * // Add multiple shapes atomically
1456
+ * await room.updateStore((store) => {
1457
+ * store.put(createShape({ type: 'geo', x: 100, y: 100 }))
1458
+ * store.put(createShape({ type: 'text', x: 200, y: 200 }))
1459
+ * })
1460
+ *
1461
+ * // Async operations are supported
1462
+ * await room.updateStore(async (store) => {
1463
+ * const template = await loadTemplate()
1464
+ * template.shapes.forEach(shape => store.put(shape))
1465
+ * })
1466
+ * ```
1242
1467
  */
1243
1468
  async updateStore(updater: (store: RoomStoreMethods<R>) => void | Promise<void>) {
1244
1469
  if (this._isClosed) {
@@ -1263,12 +1488,47 @@ export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
1263
1488
  }
1264
1489
 
1265
1490
  /**
1491
+ * Interface for making transactional changes to room store data. Used within
1492
+ * updateStore transactions to modify documents atomically.
1493
+ *
1494
+ * @example
1495
+ * ```ts
1496
+ * await room.updateStore((store) => {
1497
+ * const shape = store.get('shape:123')
1498
+ * if (shape) {
1499
+ * store.put({ ...shape, x: shape.x + 10 })
1500
+ * }
1501
+ * store.delete('shape:456')
1502
+ * })
1503
+ * ```
1504
+ *
1266
1505
  * @public
1267
1506
  */
1268
1507
  export interface RoomStoreMethods<R extends UnknownRecord = UnknownRecord> {
1508
+ /**
1509
+ * Add or update a record in the store.
1510
+ *
1511
+ * @param record - The record to store
1512
+ */
1269
1513
  put(record: R): void
1514
+ /**
1515
+ * Delete a record from the store.
1516
+ *
1517
+ * @param recordOrId - The record or record ID to delete
1518
+ */
1270
1519
  delete(recordOrId: R | string): void
1520
+ /**
1521
+ * Get a record by its ID.
1522
+ *
1523
+ * @param id - The record ID
1524
+ * @returns The record or null if not found
1525
+ */
1271
1526
  get(id: string): R | null
1527
+ /**
1528
+ * Get all records in the store.
1529
+ *
1530
+ * @returns Array of all records
1531
+ */
1272
1532
  getAll(): R[]
1273
1533
  }
1274
1534
 
package/src/lib/chunk.ts CHANGED
@@ -8,7 +8,27 @@ const MAX_BYTES_PER_CHAR = 4
8
8
  // in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte
9
9
  const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR
10
10
 
11
- /** @internal */
11
+ /**
12
+ * Splits a string into smaller chunks suitable for transmission over WebSockets.
13
+ * This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).
14
+ * Each chunk is prefixed with a number indicating how many more chunks follow.
15
+ *
16
+ * @param msg - The string to split into chunks
17
+ * @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding
18
+ * @returns Array of chunked strings, each prefixed with "\{number\}_" where number indicates remaining chunks
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * // Small message - returns as single chunk
23
+ * chunk('hello world') // ['hello world']
24
+ *
25
+ * // Large message - splits into multiple chunks
26
+ * chunk('very long message...', 10)
27
+ * // ['2_very long', '1_ message', '0_...']
28
+ * ```
29
+ *
30
+ * @internal
31
+ */
12
32
  export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
13
33
  if (msg.length < maxSafeMessageSize) {
14
34
  return [msg]
@@ -29,7 +49,31 @@ export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
29
49
 
30
50
  const chunkRe = /^(\d+)_(.*)$/
31
51
 
52
+ /**
53
+ * Assembles chunked JSON messages back into complete objects.
54
+ * Handles both regular JSON messages and chunked messages created by the chunk() function.
55
+ * Maintains internal state to track partially received chunked messages.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const assembler = new JsonChunkAssembler()
60
+ *
61
+ * // Handle regular JSON message
62
+ * const result1 = assembler.handleMessage('{"hello": "world"}')
63
+ * // Returns: { data: { hello: "world" }, stringified: '{"hello": "world"}' }
64
+ *
65
+ * // Handle chunked message
66
+ * assembler.handleMessage('1_hello') // Returns: null (partial)
67
+ * const result2 = assembler.handleMessage('0_ world')
68
+ * // Returns: { data: "hello world", stringified: "hello world" }
69
+ * ```
70
+ *
71
+ * @public
72
+ */
32
73
  export class JsonChunkAssembler {
74
+ /**
75
+ * Current assembly state - either 'idle' or tracking chunks being received
76
+ */
33
77
  state:
34
78
  | 'idle'
35
79
  | {
@@ -37,6 +81,33 @@ export class JsonChunkAssembler {
37
81
  totalChunks: number
38
82
  } = 'idle'
39
83
 
84
+ /**
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.
88
+ *
89
+ * @param msg - The message to process, either JSON or chunk format
90
+ * @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
91
+ * - `{ data: object, stringified: string }` - Successfully parsed complete message
92
+ * - `{ error: Error }` - Parse error or invalid chunk sequence
93
+ * - `null` - Chunk received but more chunks expected
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * const assembler = new JsonChunkAssembler()
98
+ *
99
+ * // Complete JSON message
100
+ * const result = assembler.handleMessage('{"key": "value"}')
101
+ * if (result && 'data' in result) {
102
+ * console.log(result.data) // { key: "value" }
103
+ * }
104
+ *
105
+ * // Chunked message sequence
106
+ * assembler.handleMessage('2_hel') // null - more chunks expected
107
+ * assembler.handleMessage('1_lo ') // null - more chunks expected
108
+ * assembler.handleMessage('0_wor') // { data: "hello wor", stringified: "hello wor" }
109
+ * ```
110
+ */
40
111
  handleMessage(msg: string): { error: Error } | { stringified: string; data: object } | null {
41
112
  if (msg.startsWith('{')) {
42
113
  const error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message')