@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
@@ -24,90 +24,229 @@ import {
24
24
  getTlsyncProtocolVersion,
25
25
  } from './protocol'
26
26
 
27
- /** @internal */
27
+ /**
28
+ * Function type for subscribing to events with a callback.
29
+ * Returns an unsubscribe function to clean up the listener.
30
+ *
31
+ * @param cb - Callback function that receives the event value
32
+ * @returns Function to call when you want to unsubscribe from the events
33
+ *
34
+ * @internal
35
+ */
28
36
  export type SubscribingFn<T> = (cb: (val: T) => void) => () => void
29
37
 
30
38
  /**
31
- * This the close code that we use on the server to signal to a socket that
32
- * the connection is being closed because of a non-recoverable error.
33
- *
34
- * You should use this if you need to close a connection.
39
+ * WebSocket close code used by the server to signal a non-recoverable sync error.
40
+ * This close code indicates that the connection is being terminated due to an error
41
+ * that cannot be automatically recovered from, such as authentication failures,
42
+ * incompatible client versions, or invalid data.
35
43
  *
36
44
  * @example
37
45
  * ```ts
46
+ * // Server-side: Close connection with specific error reason
38
47
  * socket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.NOT_FOUND)
39
- * ```
40
48
  *
41
- * The `reason` parameter that you pass to `socket.close()` will be made available at `useSync().error.reason`
49
+ * // Client-side: Handle the error in your sync error handler
50
+ * const syncClient = new TLSyncClient({
51
+ * // ... other config
52
+ * onSyncError: (reason) => {
53
+ * console.error('Sync failed:', reason) // Will receive 'NOT_FOUND'
54
+ * }
55
+ * })
56
+ * ```
42
57
  *
43
58
  * @public
44
59
  */
45
60
  export const TLSyncErrorCloseEventCode = 4099 as const
46
61
 
47
62
  /**
48
- * The set of reasons that a connection can be closed by the server
63
+ * Predefined reasons for server-initiated connection closures.
64
+ * These constants represent different error conditions that can cause
65
+ * the sync server to terminate a WebSocket connection.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * // Server usage
70
+ * if (!user.hasPermission(roomId)) {
71
+ * socket.close(TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason.FORBIDDEN)
72
+ * }
73
+ *
74
+ * // Client error handling
75
+ * syncClient.onSyncError((reason) => {
76
+ * switch (reason) {
77
+ * case TLSyncErrorCloseEventReason.NOT_FOUND:
78
+ * showError('Room does not exist')
79
+ * break
80
+ * case TLSyncErrorCloseEventReason.FORBIDDEN:
81
+ * showError('Access denied')
82
+ * break
83
+ * case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:
84
+ * showError('Please update your app')
85
+ * break
86
+ * }
87
+ * })
88
+ * ```
89
+ *
49
90
  * @public
50
91
  */
51
92
  export const TLSyncErrorCloseEventReason = {
93
+ /** Room or resource not found */
52
94
  NOT_FOUND: 'NOT_FOUND',
95
+ /** User lacks permission to access the room */
53
96
  FORBIDDEN: 'FORBIDDEN',
97
+ /** User authentication required or invalid */
54
98
  NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
99
+ /** Unexpected server error occurred */
55
100
  UNKNOWN_ERROR: 'UNKNOWN_ERROR',
101
+ /** Client protocol version too old */
56
102
  CLIENT_TOO_OLD: 'CLIENT_TOO_OLD',
103
+ /** Server protocol version too old */
57
104
  SERVER_TOO_OLD: 'SERVER_TOO_OLD',
105
+ /** Client sent invalid or corrupted record data */
58
106
  INVALID_RECORD: 'INVALID_RECORD',
107
+ /** Client exceeded rate limits */
59
108
  RATE_LIMITED: 'RATE_LIMITED',
109
+ /** Room has reached maximum capacity */
60
110
  ROOM_FULL: 'ROOM_FULL',
61
111
  } as const
62
112
  /**
63
- * The set of reasons that a connection can be closed by the server
113
+ * Union type of all possible server connection close reasons.
114
+ * Represents the string values that can be passed when a server closes
115
+ * a sync connection due to an error condition.
116
+ *
64
117
  * @public
65
118
  */
66
119
  export type TLSyncErrorCloseEventReason =
67
120
  (typeof TLSyncErrorCloseEventReason)[keyof typeof TLSyncErrorCloseEventReason]
68
121
 
69
122
  /**
70
- * Event handler for userland socket messages
123
+ * Handler function for custom application messages sent through the sync protocol.
124
+ * These are user-defined messages that can be sent between clients via the sync server,
125
+ * separate from the standard document synchronization messages.
126
+ *
127
+ * @param data - Custom message payload (application-defined structure)
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * const customMessageHandler: TLCustomMessageHandler = (data) => {
132
+ * if (data.type === 'user_joined') {
133
+ * console.log(`${data.username} joined the session`)
134
+ * showToast(`${data.username} is now collaborating`)
135
+ * }
136
+ * }
137
+ *
138
+ * const syncClient = new TLSyncClient({
139
+ * // ... other config
140
+ * onCustomMessageReceived: customMessageHandler
141
+ * })
142
+ * ```
143
+ *
71
144
  * @public
72
145
  */
73
146
  export type TLCustomMessageHandler = (this: null, data: any) => void
74
147
 
75
148
  /**
149
+ * Event object describing changes in socket connection status.
150
+ * Contains either a basic status change or an error with details.
151
+ *
76
152
  * @internal
77
153
  */
78
154
  export type TlSocketStatusChangeEvent =
79
155
  | {
156
+ /** Connection came online or went offline */
80
157
  status: 'online' | 'offline'
81
158
  }
82
159
  | {
160
+ /** Connection encountered an error */
83
161
  status: 'error'
162
+ /** Description of the error that occurred */
84
163
  reason: string
85
164
  }
86
- /** @internal */
165
+ /**
166
+ * Callback function type for listening to socket status changes.
167
+ *
168
+ * @param params - Event object containing the new status and optional error details
169
+ *
170
+ * @internal
171
+ */
87
172
  export type TLSocketStatusListener = (params: TlSocketStatusChangeEvent) => void
88
173
 
89
- /** @internal */
174
+ /**
175
+ * Possible connection states for a persistent client socket.
176
+ * Represents the current connectivity status between client and server.
177
+ *
178
+ * @internal
179
+ */
90
180
  export type TLPersistentClientSocketStatus = 'online' | 'offline' | 'error'
91
181
 
92
- /** @internal */
93
- export type TLPresenceMode = 'solo' | 'full'
94
182
  /**
95
- * A socket that can be used to send and receive messages to the server. It should handle staying
96
- * open and reconnecting when the connection is lost. In actual client code this will be a wrapper
97
- * around a websocket or socket.io or something similar.
183
+ * Mode for handling presence information in sync sessions.
184
+ * Controls whether presence data (cursors, selections) is shared with other clients.
185
+ *
186
+ * @internal
187
+ */
188
+ export type TLPresenceMode =
189
+ /** No presence sharing - client operates independently */
190
+ | 'solo'
191
+ /** Full presence sharing - cursors and selections visible to others */
192
+ | 'full'
193
+ /**
194
+ * Interface for persistent WebSocket-like connections used by TLSyncClient.
195
+ * Handles automatic reconnection and provides event-based communication with the sync server.
196
+ * Implementations should maintain connection resilience and handle network interruptions gracefully.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * class MySocketAdapter implements TLPersistentClientSocket {
201
+ * connectionStatus: 'offline' | 'online' | 'error' = 'offline'
202
+ *
203
+ * sendMessage(msg: TLSocketClientSentEvent) {
204
+ * if (this.ws && this.ws.readyState === WebSocket.OPEN) {
205
+ * this.ws.send(JSON.stringify(msg))
206
+ * }
207
+ * }
208
+ *
209
+ * onReceiveMessage = (callback) => {
210
+ * // Set up message listener and return cleanup function
211
+ * }
212
+ *
213
+ * restart() {
214
+ * this.disconnect()
215
+ * this.connect()
216
+ * }
217
+ * }
218
+ * ```
98
219
  *
99
220
  * @internal
100
221
  */
101
222
  export interface TLPersistentClientSocket<R extends UnknownRecord = UnknownRecord> {
102
- /** Whether there is currently an open connection to the server. */
223
+ /** Current connection state - online means actively connected and ready */
103
224
  connectionStatus: 'online' | 'offline' | 'error'
104
- /** Send a message to the server */
225
+
226
+ /**
227
+ * Send a protocol message to the sync server
228
+ * @param msg - Message to send (connect, push, ping, etc.)
229
+ */
105
230
  sendMessage(msg: TLSocketClientSentEvent<R>): void
106
- /** Attach a listener for messages sent by the server */
231
+
232
+ /**
233
+ * Subscribe to messages received from the server
234
+ * @param callback - Function called for each received message
235
+ * @returns Cleanup function to remove the listener
236
+ */
107
237
  onReceiveMessage: SubscribingFn<TLSocketServerSentEvent<R>>
108
- /** Attach a listener for connection status changes */
238
+
239
+ /**
240
+ * Subscribe to connection status changes
241
+ * @param callback - Function called when connection status changes
242
+ * @returns Cleanup function to remove the listener
243
+ */
109
244
  onStatusChange: SubscribingFn<TlSocketStatusChangeEvent>
110
- /** Restart the connection */
245
+
246
+ /**
247
+ * Force a connection restart (disconnect then reconnect)
248
+ * Used for error recovery or when connection health checks fail
249
+ */
111
250
  restart(): void
112
251
  }
113
252
 
@@ -117,9 +256,61 @@ const MAX_TIME_TO_WAIT_FOR_SERVER_INTERACTION_BEFORE_RESETTING_CONNECTION = PING
117
256
  // Should connect support chunking the response to allow for large payloads?
118
257
 
119
258
  /**
120
- * TLSyncClient manages syncing data in a local Store with a remote server.
259
+ * Main client-side synchronization engine for collaborative tldraw applications.
121
260
  *
122
- * It uses a git-style push/pull/rebase model.
261
+ * TLSyncClient manages bidirectional synchronization between a local tldraw Store
262
+ * and a remote sync server. It uses an optimistic update model where local changes
263
+ * are immediately applied for responsive UI, then sent to the server for validation
264
+ * and distribution to other clients.
265
+ *
266
+ * The synchronization follows a git-like push/pull/rebase model:
267
+ * - **Push**: Local changes are sent to server as diff operations
268
+ * - **Pull**: Server changes are received and applied locally
269
+ * - **Rebase**: Conflicting changes are resolved by undoing local changes,
270
+ * applying server changes, then re-applying local changes on top
271
+ *
272
+ * @example
273
+ * ```ts
274
+ * import { TLSyncClient, ClientWebSocketAdapter } from '@tldraw/sync-core'
275
+ * import { createTLStore } from '@tldraw/store'
276
+ *
277
+ * // Create store and socket
278
+ * const store = createTLStore({ schema: mySchema })
279
+ * const socket = new ClientWebSocketAdapter('ws://localhost:3000/sync')
280
+ *
281
+ * // Create sync client
282
+ * const syncClient = new TLSyncClient({
283
+ * store,
284
+ * socket,
285
+ * presence: atom(null),
286
+ * onLoad: () => console.log('Connected and loaded'),
287
+ * onSyncError: (reason) => console.error('Sync failed:', reason)
288
+ * })
289
+ *
290
+ * // Changes to store are now automatically synchronized
291
+ * store.put([{ id: 'shape1', type: 'geo', x: 100, y: 100 }])
292
+ * ```
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * // Advanced usage with presence and custom messages
297
+ * const syncClient = new TLSyncClient({
298
+ * store,
299
+ * socket,
300
+ * presence: atom({ cursor: { x: 0, y: 0 }, userName: 'Alice' }),
301
+ * presenceMode: atom('full'),
302
+ * onCustomMessageReceived: (data) => {
303
+ * if (data.type === 'chat') {
304
+ * showChatMessage(data.message, data.from)
305
+ * }
306
+ * },
307
+ * onAfterConnect: (client, { isReadonly }) => {
308
+ * if (isReadonly) {
309
+ * showNotification('Connected in read-only mode')
310
+ * }
311
+ * }
312
+ * })
313
+ * ```
123
314
  *
124
315
  * @internal
125
316
  */
@@ -167,8 +358,13 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
167
358
  private clientClock = 0
168
359
 
169
360
  /**
170
- * Called immediately after a connect acceptance has been received and processed Use this to make
171
- * any changes to the store that are required to keep it operational
361
+ * Callback executed immediately after successful connection to sync room.
362
+ * Use this to perform any post-connection setup required for your application,
363
+ * such as initializing default content or updating UI state.
364
+ *
365
+ * @param self - The TLSyncClient instance that connected
366
+ * @param details - Connection details
367
+ * - isReadonly - Whether the connection is in read-only mode
172
368
  */
173
369
  public readonly onAfterConnect?: (self: this, details: { isReadonly: boolean }) => void
174
370
 
@@ -186,6 +382,22 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
186
382
 
187
383
  didCancel?: () => boolean
188
384
 
385
+ /**
386
+ * Creates a new TLSyncClient instance to manage synchronization with a remote server.
387
+ *
388
+ * @param config - Configuration object for the sync client
389
+ * - store - The local tldraw store to synchronize
390
+ * - socket - WebSocket adapter for server communication
391
+ * - presence - Reactive signal containing current user's presence data
392
+ * - presenceMode - Optional signal controlling presence sharing (defaults to 'full')
393
+ * - onLoad - Callback fired when initial sync completes successfully
394
+ * - onSyncError - Callback fired when sync fails with error reason
395
+ * - onCustomMessageReceived - Optional handler for custom messages
396
+ * - onAfterConnect - Optional callback fired after successful connection
397
+ * - self - The TLSyncClient instance
398
+ * - details - Connection details including readonly status
399
+ * - didCancel - Optional function to check if sync should be cancelled
400
+ */
189
401
  constructor(config: {
190
402
  store: S
191
403
  socket: TLPersistentClientSocket<R>
@@ -469,6 +681,19 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
469
681
  }
470
682
  }
471
683
 
684
+ /**
685
+ * Closes the sync client and cleans up all resources.
686
+ *
687
+ * Call this method when you no longer need the sync client to prevent
688
+ * memory leaks and close the WebSocket connection. After calling close(),
689
+ * the client cannot be reused.
690
+ *
691
+ * @example
692
+ * ```ts
693
+ * // Clean shutdown
694
+ * syncClient.close()
695
+ * ```
696
+ */
472
697
  close() {
473
698
  this.debug('closing')
474
699
  this.disposables.forEach((dispose) => dispose())