@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
@@ -0,0 +1,228 @@
1
+ import { UnknownRecord } from '@tldraw/store'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
4
+ import { TLSocketServerSentEvent } from './protocol'
5
+
6
+ // Mock WebSocket implementations for testing different scenarios
7
+ class MockWebSocket implements WebSocketMinimal {
8
+ readyState: number = 1 // OPEN by default
9
+ send = vi.fn()
10
+ close = vi.fn()
11
+ addEventListener = vi.fn()
12
+ removeEventListener = vi.fn()
13
+ }
14
+
15
+ class MinimalMockWebSocket implements WebSocketMinimal {
16
+ readyState: number = 1
17
+ send = vi.fn()
18
+ close = vi.fn()
19
+ // No addEventListener/removeEventListener
20
+ }
21
+
22
+ describe('ServerSocketAdapter', () => {
23
+ describe('sendMessage', () => {
24
+ it('should JSON stringify and send the message', () => {
25
+ const mockWs = new MockWebSocket()
26
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
27
+
28
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
29
+ type: 'connect',
30
+ hydrationType: 'wipe_all',
31
+ connectRequestId: 'test-request-123',
32
+ protocolVersion: 1,
33
+ schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
34
+ diff: {},
35
+ serverClock: 0,
36
+ isReadonly: false,
37
+ }
38
+
39
+ adapter.sendMessage(message)
40
+
41
+ expect(mockWs.send).toHaveBeenCalledTimes(1)
42
+ expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(message))
43
+ })
44
+
45
+ it('should call onBeforeSendMessage callback when provided', () => {
46
+ const mockWs = new MockWebSocket()
47
+ const onBeforeSendMessage = vi.fn()
48
+ const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage })
49
+
50
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
51
+ type: 'data',
52
+ data: [{ type: 'patch', diff: {}, serverClock: 1 }],
53
+ }
54
+
55
+ adapter.sendMessage(message)
56
+
57
+ expect(onBeforeSendMessage).toHaveBeenCalledTimes(1)
58
+ expect(onBeforeSendMessage).toHaveBeenCalledWith(message, JSON.stringify(message))
59
+ expect(mockWs.send).toHaveBeenCalledTimes(1)
60
+ })
61
+
62
+ it('should call onBeforeSendMessage before sending to WebSocket', () => {
63
+ const mockWs = new MockWebSocket()
64
+ const callOrder: string[] = []
65
+
66
+ const onBeforeSendMessage = vi.fn(() => {
67
+ callOrder.push('callback')
68
+ })
69
+
70
+ mockWs.send.mockImplementation(() => {
71
+ callOrder.push('websocket')
72
+ })
73
+
74
+ const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage })
75
+
76
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
77
+ type: 'pong',
78
+ }
79
+
80
+ adapter.sendMessage(message)
81
+
82
+ expect(callOrder).toEqual(['callback', 'websocket'])
83
+ })
84
+ })
85
+
86
+ describe('close method', () => {
87
+ it('should call close on the underlying WebSocket with no parameters', () => {
88
+ const mockWs = new MockWebSocket()
89
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
90
+
91
+ adapter.close()
92
+
93
+ expect(mockWs.close).toHaveBeenCalledTimes(1)
94
+ expect(mockWs.close).toHaveBeenCalledWith(undefined, undefined)
95
+ })
96
+
97
+ it('should call close on the underlying WebSocket with code only', () => {
98
+ const mockWs = new MockWebSocket()
99
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
100
+
101
+ const closeCode = 1000
102
+ adapter.close(closeCode)
103
+
104
+ expect(mockWs.close).toHaveBeenCalledTimes(1)
105
+ expect(mockWs.close).toHaveBeenCalledWith(closeCode, undefined)
106
+ })
107
+
108
+ it('should call close on the underlying WebSocket with code and reason', () => {
109
+ const mockWs = new MockWebSocket()
110
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
111
+
112
+ const closeCode = 1001
113
+ const closeReason = 'Going away'
114
+ adapter.close(closeCode, closeReason)
115
+
116
+ expect(mockWs.close).toHaveBeenCalledTimes(1)
117
+ expect(mockWs.close).toHaveBeenCalledWith(closeCode, closeReason)
118
+ })
119
+ })
120
+
121
+ describe('Error handling', () => {
122
+ it('should handle WebSocket send errors gracefully', () => {
123
+ const mockWs = new MockWebSocket()
124
+ mockWs.send.mockImplementation(() => {
125
+ throw new Error('WebSocket send failed')
126
+ })
127
+
128
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
129
+
130
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
131
+ type: 'pong',
132
+ }
133
+
134
+ expect(() => adapter.sendMessage(message)).toThrow('WebSocket send failed')
135
+ })
136
+
137
+ it('should handle WebSocket close errors gracefully', () => {
138
+ const mockWs = new MockWebSocket()
139
+ mockWs.close.mockImplementation(() => {
140
+ throw new Error('WebSocket close failed')
141
+ })
142
+
143
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
144
+
145
+ expect(() => adapter.close()).toThrow('WebSocket close failed')
146
+ })
147
+
148
+ it('should handle onBeforeSendMessage callback errors', () => {
149
+ const mockWs = new MockWebSocket()
150
+ const faultyCallback = vi.fn(() => {
151
+ throw new Error('Callback error')
152
+ })
153
+
154
+ const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage: faultyCallback })
155
+
156
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
157
+ type: 'pong',
158
+ }
159
+
160
+ expect(() => adapter.sendMessage(message)).toThrow('Callback error')
161
+ expect(faultyCallback).toHaveBeenCalled()
162
+ // WebSocket send should not be called if callback throws
163
+ expect(mockWs.send).not.toHaveBeenCalled()
164
+ })
165
+ })
166
+
167
+ describe('Integration scenarios', () => {
168
+ it('should work with different WebSocket implementations', () => {
169
+ const scenarios = [
170
+ { name: 'Standard WebSocket', ws: new MockWebSocket() },
171
+ { name: 'Minimal WebSocket', ws: new MinimalMockWebSocket() },
172
+ ]
173
+
174
+ scenarios.forEach(({ name, ws }) => {
175
+ const adapter = new ServerSocketAdapter({ ws })
176
+
177
+ expect(adapter, `${name} should create adapter`).toBeInstanceOf(ServerSocketAdapter)
178
+
179
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
180
+ type: 'pong',
181
+ }
182
+
183
+ expect(
184
+ () => adapter.sendMessage(message),
185
+ `${name} should handle sendMessage`
186
+ ).not.toThrow()
187
+ expect(() => adapter.close(), `${name} should handle close`).not.toThrow()
188
+ })
189
+ })
190
+
191
+ it('should handle rapid message sending', () => {
192
+ const mockWs = new MockWebSocket()
193
+ const onBeforeSendMessage = vi.fn()
194
+ const adapter = new ServerSocketAdapter({ ws: mockWs, onBeforeSendMessage })
195
+
196
+ // Send multiple messages rapidly
197
+ for (let i = 0; i < 100; i++) {
198
+ const message: TLSocketServerSentEvent<UnknownRecord> = {
199
+ type: 'pong',
200
+ }
201
+ adapter.sendMessage(message)
202
+ }
203
+
204
+ expect(mockWs.send).toHaveBeenCalledTimes(100)
205
+ expect(onBeforeSendMessage).toHaveBeenCalledTimes(100)
206
+ })
207
+
208
+ it('should maintain consistent behavior across state changes', () => {
209
+ const mockWs = new MockWebSocket()
210
+ const adapter = new ServerSocketAdapter({ ws: mockWs })
211
+
212
+ // Test behavior when WebSocket changes state
213
+ expect(adapter.isOpen).toBe(true)
214
+
215
+ mockWs.readyState = 0 // CONNECTING
216
+ expect(adapter.isOpen).toBe(false)
217
+
218
+ mockWs.readyState = 2 // CLOSING
219
+ expect(adapter.isOpen).toBe(false)
220
+
221
+ mockWs.readyState = 3 // CLOSED
222
+ expect(adapter.isOpen).toBe(false)
223
+
224
+ mockWs.readyState = 1 // OPEN
225
+ expect(adapter.isOpen).toBe(true)
226
+ })
227
+ })
228
+ })
@@ -3,49 +3,168 @@ import { TLRoomSocket } from './TLSyncRoom'
3
3
  import { TLSocketServerSentEvent } from './protocol'
4
4
 
5
5
  /**
6
- * Minimal server-side WebSocket interface that is compatible with
6
+ * Minimal server-side WebSocket interface that is compatible with various WebSocket implementations.
7
+ * This interface abstracts over different WebSocket libraries and platforms to provide a consistent
8
+ * API for the ServerSocketAdapter.
7
9
  *
8
- * - The standard WebSocket interface (cloudflare, deno, some node setups)
9
- * - The 'ws' WebSocket interface (some node setups)
10
+ * Supports:
11
+ * - The standard WebSocket interface (Cloudflare, Deno, some Node.js setups)
12
+ * - The 'ws' WebSocket interface (Node.js ws library)
10
13
  * - The Bun.serve socket implementation
11
14
  *
12
15
  * @public
16
+ * @example
17
+ * ```ts
18
+ * // Standard WebSocket
19
+ * const standardWs: WebSocketMinimal = new WebSocket('ws://localhost:8080')
20
+ *
21
+ * // Node.js 'ws' library WebSocket
22
+ * import WebSocket from 'ws'
23
+ * const nodeWs: WebSocketMinimal = new WebSocket('ws://localhost:8080')
24
+ *
25
+ * // Bun WebSocket (in server context)
26
+ * // const bunWs: WebSocketMinimal = server.upgrade(request)
27
+ * ```
13
28
  */
14
29
  export interface WebSocketMinimal {
30
+ /**
31
+ * Optional method to add event listeners for WebSocket events.
32
+ * Not all WebSocket implementations provide this method.
33
+ *
34
+ * @param type - The event type to listen for
35
+ * @param listener - The event handler function
36
+ */
15
37
  // eslint-disable-next-line @typescript-eslint/method-signature-style
16
38
  addEventListener?: (type: 'message' | 'close' | 'error', listener: (event: any) => void) => void
39
+
40
+ /**
41
+ * Optional method to remove event listeners for WebSocket events.
42
+ * Not all WebSocket implementations provide this method.
43
+ *
44
+ * @param type - The event type to stop listening for
45
+ * @param listener - The event handler function to remove
46
+ */
17
47
  // eslint-disable-next-line @typescript-eslint/method-signature-style
18
48
  removeEventListener?: (
19
49
  type: 'message' | 'close' | 'error',
20
50
  listener: (event: any) => void
21
51
  ) => void
52
+
53
+ /**
54
+ * Sends a string message through the WebSocket connection.
55
+ *
56
+ * @param data - The string data to send
57
+ */
22
58
  // eslint-disable-next-line @typescript-eslint/method-signature-style
23
59
  send: (data: string) => void
60
+
61
+ /**
62
+ * Closes the WebSocket connection.
63
+ *
64
+ * @param code - Optional close code (default: 1000 for normal closure)
65
+ * @param reason - Optional human-readable close reason
66
+ */
24
67
  // eslint-disable-next-line @typescript-eslint/method-signature-style
25
68
  close: (code?: number, reason?: string) => void
69
+
70
+ /**
71
+ * The current state of the WebSocket connection.
72
+ * - 0: CONNECTING
73
+ * - 1: OPEN
74
+ * - 2: CLOSING
75
+ * - 3: CLOSED
76
+ */
26
77
  readyState: number
27
78
  }
28
79
 
29
- /** @internal */
80
+ /**
81
+ * Configuration options for creating a ServerSocketAdapter instance.
82
+ *
83
+ * @internal
84
+ */
30
85
  export interface ServerSocketAdapterOptions<R extends UnknownRecord> {
86
+ /** The underlying WebSocket connection to wrap */
31
87
  readonly ws: WebSocketMinimal
88
+
89
+ /**
90
+ * Optional callback invoked before each message is sent to the client.
91
+ * Useful for logging, metrics, or message transformation.
92
+ *
93
+ * @param msg - The message object being sent
94
+ * @param stringified - The JSON stringified version of the message
95
+ */
32
96
  // eslint-disable-next-line @typescript-eslint/method-signature-style
33
97
  readonly onBeforeSendMessage?: (msg: TLSocketServerSentEvent<R>, stringified: string) => void
34
98
  }
35
99
 
36
- /** @internal */
100
+ /**
101
+ * Server-side adapter that wraps various WebSocket implementations to provide a consistent
102
+ * TLRoomSocket interface for the TLSyncRoom. This adapter handles the differences between
103
+ * WebSocket libraries and platforms, allowing sync-core to work across different server
104
+ * environments.
105
+ *
106
+ * The adapter implements the TLRoomSocket interface, providing methods for sending messages,
107
+ * checking connection status, and closing connections.
108
+ *
109
+ * @internal
110
+ * @example
111
+ * ```ts
112
+ * import { ServerSocketAdapter } from '@tldraw/sync-core'
113
+ *
114
+ * // Wrap a standard WebSocket
115
+ * const adapter = new ServerSocketAdapter({
116
+ * ws: webSocketConnection,
117
+ * onBeforeSendMessage: (msg, json) => {
118
+ * console.log('Sending:', msg.type)
119
+ * }
120
+ * })
121
+ *
122
+ * // Use with TLSyncRoom
123
+ * room.handleNewSession({
124
+ * sessionId: 'session-123',
125
+ * socket: adapter,
126
+ * isReadonly: false
127
+ * })
128
+ * ```
129
+ */
37
130
  export class ServerSocketAdapter<R extends UnknownRecord> implements TLRoomSocket<R> {
131
+ /**
132
+ * Creates a new ServerSocketAdapter instance.
133
+ *
134
+ * opts - Configuration options for the adapter
135
+ */
38
136
  constructor(public readonly opts: ServerSocketAdapterOptions<R>) {}
137
+
138
+ /**
139
+ * Checks if the underlying WebSocket connection is currently open and ready to send messages.
140
+ *
141
+ * @returns True if the connection is open (readyState === 1), false otherwise
142
+ */
39
143
  // eslint-disable-next-line no-restricted-syntax
40
144
  get isOpen(): boolean {
41
145
  return this.opts.ws.readyState === 1 // ready state open
42
146
  }
147
+
148
+ /**
149
+ * Sends a sync protocol message to the connected client. The message is JSON stringified
150
+ * before being sent through the WebSocket. If configured, the onBeforeSendMessage callback
151
+ * is invoked before sending.
152
+ *
153
+ * @param msg - The sync protocol message to send
154
+ */
43
155
  // see TLRoomSocket for details on why this accepts a union and not just arrays
44
156
  sendMessage(msg: TLSocketServerSentEvent<R>) {
45
157
  const message = JSON.stringify(msg)
46
158
  this.opts.onBeforeSendMessage?.(msg, message)
47
159
  this.opts.ws.send(message)
48
160
  }
161
+
162
+ /**
163
+ * Closes the WebSocket connection with an optional close code and reason.
164
+ *
165
+ * @param code - Optional close code (default: 1000 for normal closure)
166
+ * @param reason - Optional human-readable reason for closing
167
+ */
49
168
  close(code?: number, reason?: string) {
50
169
  this.opts.ws.close(code, reason)
51
170
  }
@@ -1,8 +1,57 @@
1
1
  import { TLSyncErrorCloseEventReason } from './TLSyncClient'
2
2
 
3
- /** @public */
3
+ /**
4
+ * Specialized error class for synchronization-related failures in tldraw collaboration.
5
+ *
6
+ * This error is thrown when the sync client encounters fatal errors that prevent
7
+ * successful synchronization with the server. It captures both the error message
8
+ * and the specific reason code that triggered the failure.
9
+ *
10
+ * Common scenarios include schema version mismatches, authentication failures,
11
+ * network connectivity issues, and server-side validation errors.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { TLRemoteSyncError, TLSyncErrorCloseEventReason } from '@tldraw/sync-core'
16
+ *
17
+ * // Handle sync errors in your application
18
+ * syncClient.onSyncError((error) => {
19
+ * if (error instanceof TLRemoteSyncError) {
20
+ * switch (error.reason) {
21
+ * case TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:
22
+ * // Redirect user to login
23
+ * break
24
+ * case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:
25
+ * // Show update required message
26
+ * break
27
+ * default:
28
+ * console.error('Sync error:', error.message)
29
+ * }
30
+ * }
31
+ * })
32
+ * ```
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // Server-side: throwing a sync error
37
+ * if (!hasPermission(userId, roomId)) {
38
+ * throw new TLRemoteSyncError(TLSyncErrorCloseEventReason.FORBIDDEN)
39
+ * }
40
+ * ```
41
+ *
42
+ * @public
43
+ */
4
44
  export class TLRemoteSyncError extends Error {
5
45
  override name = 'RemoteSyncError'
46
+
47
+ /**
48
+ * Creates a new TLRemoteSyncError with the specified reason.
49
+ *
50
+ * reason - The specific reason code or custom string describing why the sync failed.
51
+ * When using predefined reasons from TLSyncErrorCloseEventReason, the client
52
+ * can handle specific error types appropriately. Custom strings allow for
53
+ * application-specific error details.
54
+ */
6
55
  constructor(public readonly reason: TLSyncErrorCloseEventReason | string) {
7
56
  super(`sync error: ${reason}`)
8
57
  }