@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.
- package/dist-cjs/index.d.ts +605 -75
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +144 -0
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/RoomSession.js +3 -0
- package/dist-cjs/lib/RoomSession.js.map +2 -2
- package/dist-cjs/lib/ServerSocketAdapter.js +23 -0
- package/dist-cjs/lib/ServerSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLRemoteSyncError.js +8 -0
- package/dist-cjs/lib/TLRemoteSyncError.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +280 -56
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +45 -2
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +161 -16
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-cjs/lib/chunk.js +30 -0
- package/dist-cjs/lib/chunk.js.map +2 -2
- package/dist-cjs/lib/diff.js.map +2 -2
- package/dist-cjs/lib/findMin.js.map +2 -2
- package/dist-cjs/lib/interval.js.map +2 -2
- package/dist-cjs/lib/protocol.js.map +2 -2
- package/dist-cjs/lib/server-types.js.map +1 -1
- package/dist-esm/index.d.mts +605 -75
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +144 -0
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/RoomSession.mjs +3 -0
- package/dist-esm/lib/RoomSession.mjs.map +2 -2
- package/dist-esm/lib/ServerSocketAdapter.mjs +23 -0
- package/dist-esm/lib/ServerSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLRemoteSyncError.mjs +8 -0
- package/dist-esm/lib/TLRemoteSyncError.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +280 -56
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +45 -2
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +161 -16
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/dist-esm/lib/chunk.mjs +30 -0
- package/dist-esm/lib/chunk.mjs.map +2 -2
- package/dist-esm/lib/diff.mjs.map +2 -2
- package/dist-esm/lib/findMin.mjs.map +2 -2
- package/dist-esm/lib/interval.mjs.map +2 -2
- package/dist-esm/lib/protocol.mjs.map +2 -2
- package/package.json +6 -6
- package/src/lib/ClientWebSocketAdapter.test.ts +712 -129
- package/src/lib/ClientWebSocketAdapter.ts +240 -9
- package/src/lib/RoomSession.test.ts +97 -0
- package/src/lib/RoomSession.ts +105 -3
- package/src/lib/ServerSocketAdapter.test.ts +228 -0
- package/src/lib/ServerSocketAdapter.ts +124 -5
- package/src/lib/TLRemoteSyncError.ts +50 -1
- package/src/lib/TLSocketRoom.ts +377 -60
- package/src/lib/TLSyncClient.test.ts +828 -0
- package/src/lib/TLSyncClient.ts +251 -26
- package/src/lib/TLSyncRoom.ts +284 -24
- package/src/lib/chunk.ts +72 -1
- package/src/lib/diff.ts +128 -14
- package/src/lib/findMin.ts +6 -0
- package/src/lib/interval.ts +40 -0
- package/src/lib/protocol.ts +185 -7
- package/src/lib/server-types.test.ts +44 -0
- package/src/lib/server-types.ts +45 -1
- package/src/test/TLSocketRoom.test.ts +438 -29
- package/src/test/chunk.test.ts +200 -3
- package/src/test/diff.test.ts +396 -1
package/src/lib/TLSyncRoom.ts
CHANGED
|
@@ -51,24 +51,71 @@ import {
|
|
|
51
51
|
getTlsyncProtocolVersion,
|
|
52
52
|
} from './protocol'
|
|
53
53
|
|
|
54
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
141
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
644
|
-
*
|
|
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
|
-
*
|
|
718
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
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
|
-
/**
|
|
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')
|