@voidhash/mimic 1.0.0-beta.16 → 1.0.0-beta.17

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 (95) hide show
  1. package/dist/EffectSchema.cjs +3 -3
  2. package/dist/EffectSchema.d.cts +5 -5
  3. package/dist/EffectSchema.d.cts.map +1 -1
  4. package/dist/EffectSchema.d.mts +5 -5
  5. package/dist/EffectSchema.d.mts.map +1 -1
  6. package/dist/EffectSchema.mjs +3 -3
  7. package/dist/EffectSchema.mjs.map +1 -1
  8. package/dist/FractionalIndex.mjs.map +1 -1
  9. package/dist/Operation.d.cts +4 -4
  10. package/dist/Operation.d.cts.map +1 -1
  11. package/dist/Operation.d.mts +4 -4
  12. package/dist/Operation.d.mts.map +1 -1
  13. package/dist/Operation.mjs.map +1 -1
  14. package/dist/OperationDefinition.d.cts +2 -2
  15. package/dist/OperationDefinition.d.cts.map +1 -1
  16. package/dist/OperationDefinition.d.mts +2 -2
  17. package/dist/OperationDefinition.d.mts.map +1 -1
  18. package/dist/OperationDefinition.mjs.map +1 -1
  19. package/dist/Presence.mjs.map +1 -1
  20. package/dist/SchemaJSON.cjs +305 -0
  21. package/dist/SchemaJSON.d.cts +11 -0
  22. package/dist/SchemaJSON.d.cts.map +1 -0
  23. package/dist/SchemaJSON.d.mts +11 -0
  24. package/dist/SchemaJSON.d.mts.map +1 -0
  25. package/dist/SchemaJSON.mjs +301 -0
  26. package/dist/SchemaJSON.mjs.map +1 -0
  27. package/dist/index.cjs +7 -0
  28. package/dist/index.d.cts +2 -1
  29. package/dist/index.d.mts +2 -1
  30. package/dist/index.mjs +2 -1
  31. package/dist/primitives/Array.cjs +12 -2
  32. package/dist/primitives/Array.d.cts.map +1 -1
  33. package/dist/primitives/Array.d.mts.map +1 -1
  34. package/dist/primitives/Array.mjs +12 -2
  35. package/dist/primitives/Array.mjs.map +1 -1
  36. package/dist/primitives/Boolean.mjs.map +1 -1
  37. package/dist/primitives/Either.mjs.map +1 -1
  38. package/dist/primitives/Literal.mjs.map +1 -1
  39. package/dist/primitives/Number.cjs +27 -5
  40. package/dist/primitives/Number.d.cts.map +1 -1
  41. package/dist/primitives/Number.d.mts.map +1 -1
  42. package/dist/primitives/Number.mjs +27 -5
  43. package/dist/primitives/Number.mjs.map +1 -1
  44. package/dist/primitives/String.cjs +44 -13
  45. package/dist/primitives/String.d.cts.map +1 -1
  46. package/dist/primitives/String.d.mts.map +1 -1
  47. package/dist/primitives/String.mjs +44 -13
  48. package/dist/primitives/String.mjs.map +1 -1
  49. package/dist/primitives/Union.mjs.map +1 -1
  50. package/dist/primitives/shared.d.cts +2 -0
  51. package/dist/primitives/shared.d.cts.map +1 -1
  52. package/dist/primitives/shared.d.mts +2 -0
  53. package/dist/primitives/shared.d.mts.map +1 -1
  54. package/dist/primitives/shared.mjs.map +1 -1
  55. package/package.json +15 -8
  56. package/src/EffectSchema.ts +3 -3
  57. package/src/FractionalIndex.ts +18 -18
  58. package/src/Operation.ts +5 -5
  59. package/src/OperationDefinition.ts +2 -2
  60. package/src/Presence.ts +3 -3
  61. package/src/SchemaJSON.ts +396 -0
  62. package/src/index.ts +1 -0
  63. package/src/primitives/Array.ts +18 -8
  64. package/src/primitives/Boolean.ts +2 -2
  65. package/src/primitives/Either.ts +2 -2
  66. package/src/primitives/Literal.ts +2 -2
  67. package/src/primitives/Number.ts +44 -22
  68. package/src/primitives/String.ts +61 -34
  69. package/src/primitives/Union.ts +1 -1
  70. package/src/primitives/shared.ts +2 -0
  71. package/.turbo/turbo-build.log +0 -270
  72. package/tests/Document.test.ts +0 -557
  73. package/tests/EffectSchema.test.ts +0 -546
  74. package/tests/FractionalIndex.test.ts +0 -377
  75. package/tests/OperationPath.test.ts +0 -151
  76. package/tests/Presence.test.ts +0 -321
  77. package/tests/Primitive.test.ts +0 -381
  78. package/tests/client/ClientDocument.test.ts +0 -1981
  79. package/tests/client/WebSocketTransport.test.ts +0 -1217
  80. package/tests/primitives/Array.test.ts +0 -526
  81. package/tests/primitives/Boolean.test.ts +0 -126
  82. package/tests/primitives/Either.test.ts +0 -707
  83. package/tests/primitives/Lazy.test.ts +0 -143
  84. package/tests/primitives/Literal.test.ts +0 -122
  85. package/tests/primitives/Number.test.ts +0 -133
  86. package/tests/primitives/String.test.ts +0 -128
  87. package/tests/primitives/Struct.test.ts +0 -1154
  88. package/tests/primitives/Tree.test.ts +0 -1139
  89. package/tests/primitives/TreeNode.test.ts +0 -50
  90. package/tests/primitives/Union.test.ts +0 -554
  91. package/tests/server/ServerDocument.test.ts +0 -903
  92. package/tsconfig.build.json +0 -24
  93. package/tsconfig.json +0 -8
  94. package/tsdown.config.ts +0 -18
  95. package/vitest.mts +0 -11
@@ -1,1217 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import * as Transaction from "../../src/Transaction";
3
- import * as OperationPath from "../../src/OperationPath";
4
- import * as WebSocketTransport from "../../src/client/WebSocketTransport";
5
- import type * as Transport from "../../src/client/Transport";
6
-
7
- // =============================================================================
8
- // Mock CloseEvent (not available in Node.js)
9
- // =============================================================================
10
-
11
- class MockCloseEvent extends Event {
12
- readonly code: number;
13
- readonly reason: string;
14
- readonly wasClean: boolean;
15
-
16
- constructor(type: string, init?: { code?: number; reason?: string; wasClean?: boolean }) {
17
- super(type);
18
- this.code = init?.code ?? 1000;
19
- this.reason = init?.reason ?? "";
20
- this.wasClean = init?.wasClean ?? true;
21
- }
22
- }
23
-
24
- // =============================================================================
25
- // Mock WebSocket
26
- // =============================================================================
27
-
28
- type MockWebSocketEventHandler = ((event: Event) => void) | null;
29
- type MockMessageHandler = ((event: MessageEvent) => void) | null;
30
- type MockCloseHandler = ((event: MockCloseEvent) => void) | null;
31
-
32
- class MockWebSocket {
33
- static readonly CONNECTING = 0;
34
- static readonly OPEN = 1;
35
- static readonly CLOSING = 2;
36
- static readonly CLOSED = 3;
37
-
38
- readonly CONNECTING = 0;
39
- readonly OPEN = 1;
40
- readonly CLOSING = 2;
41
- readonly CLOSED = 3;
42
-
43
- url: string;
44
- protocols: string | string[] | undefined;
45
- readyState: number = MockWebSocket.CONNECTING;
46
-
47
- onopen: MockWebSocketEventHandler = null;
48
- onclose: MockCloseHandler = null;
49
- onerror: MockWebSocketEventHandler = null;
50
- onmessage: MockMessageHandler = null;
51
-
52
- sentMessages: string[] = [];
53
- static instances: MockWebSocket[] = [];
54
-
55
- constructor(url: string, protocols?: string | string[]) {
56
- this.url = url;
57
- this.protocols = protocols;
58
- MockWebSocket.instances.push(this);
59
- }
60
-
61
- send(data: string): void {
62
- this.sentMessages.push(data);
63
- }
64
-
65
- close(code?: number, reason?: string): void {
66
- this.readyState = MockWebSocket.CLOSED;
67
- if (this.onclose) {
68
- const event = new MockCloseEvent("close", { code: code ?? 1000, reason: reason ?? "" });
69
- this.onclose(event);
70
- }
71
- }
72
-
73
- // Test helpers
74
- simulateOpen(): void {
75
- this.readyState = MockWebSocket.OPEN;
76
- if (this.onopen) {
77
- this.onopen(new Event("open"));
78
- }
79
- }
80
-
81
- /**
82
- * Simulates a complete connection: opens the socket and sends auth_result
83
- * after the transport sends the auth message.
84
- * This is needed because the transport always sends an auth message on open
85
- * and waits for auth_result before completing the connection.
86
- *
87
- * Returns a Promise that resolves after auth is simulated.
88
- */
89
- simulateOpenWithAuth(): Promise<void> {
90
- this.simulateOpen();
91
- // The onopen handler is async and sends auth message
92
- // We need to wait for it, then send auth_result
93
- // Use Promise.resolve().then() to ensure we run after the microtask queue
94
- return Promise.resolve().then(() => Promise.resolve()).then(() => {
95
- this.simulateMessage({ type: "auth_result", success: true });
96
- });
97
- }
98
-
99
- simulateMessage(data: unknown): void {
100
- if (this.onmessage) {
101
- this.onmessage(new MessageEvent("message", { data: JSON.stringify(data) }));
102
- }
103
- }
104
-
105
- simulateClose(code = 1000, reason = ""): void {
106
- this.readyState = MockWebSocket.CLOSED;
107
- if (this.onclose) {
108
- this.onclose(new MockCloseEvent("close", { code, reason }));
109
- }
110
- }
111
-
112
- simulateError(): void {
113
- if (this.onerror) {
114
- this.onerror(new Event("error"));
115
- }
116
- }
117
-
118
- static reset(): void {
119
- MockWebSocket.instances = [];
120
- }
121
-
122
- static getLatest(): MockWebSocket | undefined {
123
- return MockWebSocket.instances[MockWebSocket.instances.length - 1];
124
- }
125
- }
126
-
127
- // Set up global mock
128
- const originalWebSocket = globalThis.WebSocket;
129
-
130
- beforeEach(() => {
131
- MockWebSocket.reset();
132
- // @ts-expect-error - Mocking global WebSocket
133
- globalThis.WebSocket = MockWebSocket;
134
- vi.useFakeTimers();
135
- });
136
-
137
- afterEach(() => {
138
- globalThis.WebSocket = originalWebSocket;
139
- vi.useRealTimers();
140
- });
141
-
142
- // =============================================================================
143
- // WebSocketTransport Tests
144
- // =============================================================================
145
-
146
- describe("WebSocketTransport", () => {
147
- describe("make", () => {
148
- it("should create a transport with default options", () => {
149
- const transport = WebSocketTransport.make({
150
- url: "ws://localhost:8080",
151
- });
152
-
153
- expect(transport.isConnected()).toBe(false);
154
- });
155
- });
156
-
157
- describe("connect", () => {
158
- it("should establish connection to WebSocket server", async () => {
159
- const transport = WebSocketTransport.make({
160
- url: "ws://localhost:8080",
161
- });
162
-
163
- const connectPromise = transport.connect();
164
-
165
- // Simulate WebSocket open
166
- const ws = MockWebSocket.getLatest()!;
167
- await ws.simulateOpenWithAuth();
168
-
169
- await connectPromise;
170
-
171
- expect(transport.isConnected()).toBe(true);
172
- expect(ws.url).toBe("ws://localhost:8080");
173
- });
174
-
175
- it("should pass protocols to WebSocket", async () => {
176
- const transport = WebSocketTransport.make({
177
- url: "ws://localhost:8080",
178
- protocols: ["mimic-v1"],
179
- });
180
-
181
- transport.connect();
182
-
183
- const ws = MockWebSocket.getLatest()!;
184
- expect(ws.protocols).toEqual(["mimic-v1"]);
185
- });
186
-
187
- it("should emit connected event", async () => {
188
- let connectedEmitted = false;
189
-
190
- const transport = WebSocketTransport.make({
191
- url: "ws://localhost:8080",
192
- onEvent: (event) => {
193
- if (event.type === "connected") {
194
- connectedEmitted = true;
195
- }
196
- },
197
- });
198
-
199
- const connectPromise = transport.connect();
200
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
201
- await connectPromise;
202
-
203
- expect(connectedEmitted).toBe(true);
204
- });
205
-
206
- it("should reject on connection timeout", async () => {
207
- const transport = WebSocketTransport.make({
208
- url: "ws://localhost:8080",
209
- connectionTimeout: 1000,
210
- });
211
-
212
- const connectPromise = transport.connect();
213
-
214
- // Advance time past timeout
215
- vi.advanceTimersByTime(1001);
216
-
217
- await expect(connectPromise).rejects.toThrow("Connection failed");
218
- });
219
-
220
- it("should return immediately if already connected", async () => {
221
- const transport = WebSocketTransport.make({
222
- url: "ws://localhost:8080",
223
- });
224
-
225
- const connectPromise = transport.connect();
226
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
227
- await connectPromise;
228
-
229
- // Should return immediately
230
- await transport.connect();
231
- expect(MockWebSocket.instances.length).toBe(1);
232
- });
233
- });
234
-
235
- describe("disconnect", () => {
236
- it("should close WebSocket connection", async () => {
237
- const transport = WebSocketTransport.make({
238
- url: "ws://localhost:8080",
239
- });
240
-
241
- const connectPromise = transport.connect();
242
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
243
- await connectPromise;
244
-
245
- transport.disconnect();
246
-
247
- expect(transport.isConnected()).toBe(false);
248
- });
249
-
250
- it("should emit disconnected event", async () => {
251
- let disconnectedReason: string | undefined;
252
-
253
- const transport = WebSocketTransport.make({
254
- url: "ws://localhost:8080",
255
- onEvent: (event) => {
256
- if (event.type === "disconnected") {
257
- disconnectedReason = event.reason;
258
- }
259
- },
260
- });
261
-
262
- const connectPromise = transport.connect();
263
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
264
- await connectPromise;
265
-
266
- transport.disconnect();
267
-
268
- expect(disconnectedReason).toBe("User disconnected");
269
- });
270
-
271
- it("should reject pending connect promise", async () => {
272
- const transport = WebSocketTransport.make({
273
- url: "ws://localhost:8080",
274
- });
275
-
276
- const connectPromise = transport.connect();
277
-
278
- // Disconnect while connecting
279
- transport.disconnect();
280
-
281
- await expect(connectPromise).rejects.toThrow("Disconnected by user");
282
- });
283
- });
284
-
285
- describe("send", () => {
286
- it("should send transaction as JSON", async () => {
287
- const transport = WebSocketTransport.make({
288
- url: "ws://localhost:8080",
289
- });
290
-
291
- const connectPromise = transport.connect();
292
- const ws = MockWebSocket.getLatest()!;
293
- await ws.simulateOpenWithAuth();
294
- await connectPromise;
295
-
296
- const tx = Transaction.make([
297
- {
298
- kind: "string.set" as const,
299
- path: OperationPath.make("title"),
300
- payload: "test",
301
- },
302
- ]);
303
-
304
- transport.send(tx);
305
-
306
- // 2 messages: auth + submit
307
- expect(ws.sentMessages.length).toBe(2);
308
- const authMsg = JSON.parse(ws.sentMessages[0]!);
309
- expect(authMsg.type).toBe("auth");
310
- const sent = JSON.parse(ws.sentMessages[1]!);
311
- expect(sent.type).toBe("submit");
312
- expect(sent.transaction.id).toBe(tx.id);
313
- });
314
-
315
- it("should queue messages during reconnection", async () => {
316
- const transport = WebSocketTransport.make({
317
- url: "ws://localhost:8080",
318
- autoReconnect: true,
319
- });
320
-
321
- const connectPromise = transport.connect();
322
- const ws = MockWebSocket.getLatest()!;
323
- await ws.simulateOpenWithAuth();
324
- await connectPromise;
325
-
326
- // Simulate connection lost
327
- ws.simulateClose(1006, "Connection lost");
328
-
329
- // Queue message during reconnection
330
- const tx = Transaction.make([
331
- {
332
- kind: "string.set" as const,
333
- path: OperationPath.make("title"),
334
- payload: "queued",
335
- },
336
- ]);
337
- transport.send(tx);
338
-
339
- // Reconnect
340
- vi.advanceTimersByTime(1000);
341
- const newWs = MockWebSocket.getLatest()!;
342
- await newWs.simulateOpenWithAuth();
343
-
344
- // Queued message should be sent after auth
345
- // 2 messages: auth + submit (queued message)
346
- expect(newWs.sentMessages.length).toBe(2);
347
- const authMsg = JSON.parse(newWs.sentMessages[0]!);
348
- expect(authMsg.type).toBe("auth");
349
- const sent = JSON.parse(newWs.sentMessages[1]!);
350
- expect(sent.type).toBe("submit");
351
- });
352
-
353
- it("should queue messages when disconnected (never connected)", () => {
354
- const transport = WebSocketTransport.make({
355
- url: "ws://localhost:8080",
356
- });
357
-
358
- // Queue message before connecting
359
- const tx = Transaction.make([
360
- {
361
- kind: "string.set" as const,
362
- path: OperationPath.make("title"),
363
- payload: "offline",
364
- },
365
- ]);
366
- transport.send(tx);
367
-
368
- // No WebSocket created yet
369
- expect(MockWebSocket.instances.length).toBe(0);
370
-
371
- // Message should be queued internally
372
- // (We can't directly verify the queue, but we can verify it's sent after connect)
373
- });
374
-
375
- it("should send queued messages after initial connection", async () => {
376
- const transport = WebSocketTransport.make({
377
- url: "ws://localhost:8080",
378
- });
379
-
380
- // Queue messages before connecting
381
- const tx1 = Transaction.make([
382
- {
383
- kind: "string.set" as const,
384
- path: OperationPath.make("title"),
385
- payload: "first",
386
- },
387
- ]);
388
- const tx2 = Transaction.make([
389
- {
390
- kind: "number.set" as const,
391
- path: OperationPath.make("count"),
392
- payload: 42,
393
- },
394
- ]);
395
- transport.send(tx1);
396
- transport.send(tx2);
397
-
398
- // Now connect
399
- const connectPromise = transport.connect();
400
- const ws = MockWebSocket.getLatest()!;
401
- await ws.simulateOpenWithAuth();
402
- await connectPromise;
403
-
404
- // Should have sent: auth + 2 queued transactions
405
- expect(ws.sentMessages.length).toBe(3);
406
-
407
- const authMsg = JSON.parse(ws.sentMessages[0]!);
408
- expect(authMsg.type).toBe("auth");
409
-
410
- const sent1 = JSON.parse(ws.sentMessages[1]!);
411
- expect(sent1.type).toBe("submit");
412
- expect(sent1.transaction.id).toBe(tx1.id);
413
-
414
- const sent2 = JSON.parse(ws.sentMessages[2]!);
415
- expect(sent2.type).toBe("submit");
416
- expect(sent2.transaction.id).toBe(tx2.id);
417
- });
418
-
419
- it("should queue multiple messages during disconnection", async () => {
420
- const transport = WebSocketTransport.make({
421
- url: "ws://localhost:8080",
422
- autoReconnect: true,
423
- });
424
-
425
- const connectPromise = transport.connect();
426
- const ws = MockWebSocket.getLatest()!;
427
- await ws.simulateOpenWithAuth();
428
- await connectPromise;
429
-
430
- // Simulate connection lost
431
- ws.simulateClose(1006, "Connection lost");
432
-
433
- // Queue multiple messages during disconnection
434
- const tx1 = Transaction.make([
435
- {
436
- kind: "string.set" as const,
437
- path: OperationPath.make("title"),
438
- payload: "change1",
439
- },
440
- ]);
441
- const tx2 = Transaction.make([
442
- {
443
- kind: "string.set" as const,
444
- path: OperationPath.make("title"),
445
- payload: "change2",
446
- },
447
- ]);
448
- const tx3 = Transaction.make([
449
- {
450
- kind: "number.set" as const,
451
- path: OperationPath.make("count"),
452
- payload: 100,
453
- },
454
- ]);
455
-
456
- transport.send(tx1);
457
- transport.send(tx2);
458
- transport.send(tx3);
459
-
460
- // Reconnect
461
- vi.advanceTimersByTime(1000);
462
- const newWs = MockWebSocket.getLatest()!;
463
- await newWs.simulateOpenWithAuth();
464
-
465
- // Should have sent: auth + 3 queued transactions
466
- expect(newWs.sentMessages.length).toBe(4);
467
-
468
- const authMsg = JSON.parse(newWs.sentMessages[0]!);
469
- expect(authMsg.type).toBe("auth");
470
-
471
- // Verify all transactions were sent in order
472
- const sent1 = JSON.parse(newWs.sentMessages[1]!);
473
- expect(sent1.transaction.id).toBe(tx1.id);
474
-
475
- const sent2 = JSON.parse(newWs.sentMessages[2]!);
476
- expect(sent2.transaction.id).toBe(tx2.id);
477
-
478
- const sent3 = JSON.parse(newWs.sentMessages[3]!);
479
- expect(sent3.transaction.id).toBe(tx3.id);
480
- });
481
-
482
- it("should preserve queue across multiple reconnection attempts", async () => {
483
- const transport = WebSocketTransport.make({
484
- url: "ws://localhost:8080",
485
- autoReconnect: true,
486
- reconnectDelay: 100,
487
- });
488
-
489
- const connectPromise = transport.connect();
490
- const ws = MockWebSocket.getLatest()!;
491
- await ws.simulateOpenWithAuth();
492
- await connectPromise;
493
-
494
- // Simulate connection lost
495
- ws.simulateClose(1006, "Connection lost");
496
-
497
- // Queue message
498
- const tx = Transaction.make([
499
- {
500
- kind: "string.set" as const,
501
- path: OperationPath.make("title"),
502
- payload: "important",
503
- },
504
- ]);
505
- transport.send(tx);
506
-
507
- // First reconnection attempt fails
508
- vi.advanceTimersByTime(100);
509
- MockWebSocket.getLatest()!.simulateClose(1006, "Failed again");
510
-
511
- // Second reconnection attempt succeeds
512
- vi.advanceTimersByTime(200);
513
- const finalWs = MockWebSocket.getLatest()!;
514
- await finalWs.simulateOpenWithAuth();
515
-
516
- // Queued message should still be sent
517
- expect(finalWs.sentMessages.length).toBe(2);
518
- const sent = JSON.parse(finalWs.sentMessages[1]!);
519
- expect(sent.type).toBe("submit");
520
- expect(sent.transaction.id).toBe(tx.id);
521
- });
522
-
523
- it("should queue snapshot requests when disconnected", async () => {
524
- const transport = WebSocketTransport.make({
525
- url: "ws://localhost:8080",
526
- });
527
-
528
- // Queue snapshot request before connecting
529
- transport.requestSnapshot();
530
-
531
- // Connect
532
- const connectPromise = transport.connect();
533
- const ws = MockWebSocket.getLatest()!;
534
- await ws.simulateOpenWithAuth();
535
- await connectPromise;
536
-
537
- // Should have sent: auth + request_snapshot
538
- expect(ws.sentMessages.length).toBe(2);
539
- const sent = JSON.parse(ws.sentMessages[1]!);
540
- expect(sent.type).toBe("request_snapshot");
541
- });
542
- });
543
-
544
- describe("requestSnapshot", () => {
545
- it("should send snapshot request as JSON", async () => {
546
- const transport = WebSocketTransport.make({
547
- url: "ws://localhost:8080",
548
- });
549
-
550
- const connectPromise = transport.connect();
551
- const ws = MockWebSocket.getLatest()!;
552
- await ws.simulateOpenWithAuth();
553
- await connectPromise;
554
-
555
- transport.requestSnapshot();
556
-
557
- // 2 messages: auth + request_snapshot
558
- expect(ws.sentMessages.length).toBe(2);
559
- const authMsg = JSON.parse(ws.sentMessages[0]!);
560
- expect(authMsg.type).toBe("auth");
561
- const sent = JSON.parse(ws.sentMessages[1]!);
562
- expect(sent.type).toBe("request_snapshot");
563
- });
564
- });
565
-
566
- describe("subscribe", () => {
567
- it("should forward server messages to handlers", async () => {
568
- const transport = WebSocketTransport.make({
569
- url: "ws://localhost:8080",
570
- });
571
-
572
- const messages: Transport.ServerMessage[] = [];
573
- transport.subscribe((msg) => messages.push(msg));
574
-
575
- const connectPromise = transport.connect();
576
- const ws = MockWebSocket.getLatest()!;
577
- await ws.simulateOpenWithAuth();
578
- await connectPromise;
579
-
580
- ws.simulateMessage({
581
- type: "transaction",
582
- transaction: { id: "tx-1", ops: [], timestamp: Date.now() },
583
- version: 1,
584
- });
585
-
586
- expect(messages.length).toBe(1);
587
- expect(messages[0]!.type).toBe("transaction");
588
- });
589
-
590
- it("should allow unsubscribing", async () => {
591
- const transport = WebSocketTransport.make({
592
- url: "ws://localhost:8080",
593
- });
594
-
595
- const messages: Transport.ServerMessage[] = [];
596
- const unsubscribe = transport.subscribe((msg) => messages.push(msg));
597
-
598
- const connectPromise = transport.connect();
599
- const ws = MockWebSocket.getLatest()!;
600
- await ws.simulateOpenWithAuth();
601
- await connectPromise;
602
-
603
- unsubscribe();
604
-
605
- ws.simulateMessage({
606
- type: "snapshot",
607
- state: {},
608
- version: 1,
609
- });
610
-
611
- expect(messages.length).toBe(0);
612
- });
613
- });
614
-
615
- describe("reconnection", () => {
616
- it("should automatically reconnect on connection lost", async () => {
617
- let reconnectingAttempt = 0;
618
-
619
- const transport = WebSocketTransport.make({
620
- url: "ws://localhost:8080",
621
- autoReconnect: true,
622
- reconnectDelay: 1000,
623
- onEvent: (event) => {
624
- if (event.type === "reconnecting") {
625
- reconnectingAttempt = event.attempt;
626
- }
627
- },
628
- });
629
-
630
- const connectPromise = transport.connect();
631
- const ws = MockWebSocket.getLatest()!;
632
- await ws.simulateOpenWithAuth();
633
- await connectPromise;
634
-
635
- // Simulate connection lost
636
- ws.simulateClose(1006, "Connection lost");
637
-
638
- expect(reconnectingAttempt).toBe(1);
639
-
640
- // Advance time to trigger reconnect
641
- vi.advanceTimersByTime(1000);
642
-
643
- // Should have created new WebSocket
644
- expect(MockWebSocket.instances.length).toBe(2);
645
-
646
- // Complete reconnection (needs auth too)
647
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
648
-
649
- expect(transport.isConnected()).toBe(true);
650
- });
651
-
652
- it("should use exponential backoff for reconnection", async () => {
653
- const transport = WebSocketTransport.make({
654
- url: "ws://localhost:8080",
655
- autoReconnect: true,
656
- reconnectDelay: 1000,
657
- maxReconnectDelay: 30000,
658
- });
659
-
660
- const connectPromise = transport.connect();
661
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
662
- await connectPromise;
663
-
664
- // First disconnection
665
- MockWebSocket.getLatest()!.simulateClose();
666
-
667
- // First retry after 1s (1000 * 2^0)
668
- vi.advanceTimersByTime(999);
669
- expect(MockWebSocket.instances.length).toBe(1);
670
- vi.advanceTimersByTime(1);
671
- expect(MockWebSocket.instances.length).toBe(2);
672
-
673
- // Fail again
674
- MockWebSocket.getLatest()!.simulateClose();
675
-
676
- // Second retry after 2s (1000 * 2^1)
677
- vi.advanceTimersByTime(1999);
678
- expect(MockWebSocket.instances.length).toBe(2);
679
- vi.advanceTimersByTime(1);
680
- expect(MockWebSocket.instances.length).toBe(3);
681
- });
682
-
683
- it("should stop reconnecting after max attempts", async () => {
684
- let finalDisconnectReason: string | undefined;
685
-
686
- const transport = WebSocketTransport.make({
687
- url: "ws://localhost:8080",
688
- autoReconnect: true,
689
- maxReconnectAttempts: 2,
690
- reconnectDelay: 100,
691
- onEvent: (event) => {
692
- if (event.type === "disconnected") {
693
- finalDisconnectReason = event.reason;
694
- }
695
- },
696
- });
697
-
698
- const connectPromise = transport.connect();
699
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
700
- await connectPromise;
701
-
702
- // First disconnection
703
- MockWebSocket.getLatest()!.simulateClose();
704
- vi.advanceTimersByTime(100);
705
- MockWebSocket.getLatest()!.simulateClose();
706
- vi.advanceTimersByTime(200);
707
- MockWebSocket.getLatest()!.simulateClose();
708
-
709
- // Should have given up after 2 attempts
710
- expect(finalDisconnectReason).toBe("Max reconnection attempts reached");
711
- });
712
-
713
- it("should not reconnect when autoReconnect is false", async () => {
714
- let disconnected = false;
715
-
716
- const transport = WebSocketTransport.make({
717
- url: "ws://localhost:8080",
718
- autoReconnect: false,
719
- onEvent: (event) => {
720
- if (event.type === "disconnected") {
721
- disconnected = true;
722
- }
723
- },
724
- });
725
-
726
- const connectPromise = transport.connect();
727
- await MockWebSocket.getLatest()!.simulateOpenWithAuth();
728
- await connectPromise;
729
-
730
- MockWebSocket.getLatest()!.simulateClose();
731
-
732
- // Advance time - should not reconnect
733
- vi.advanceTimersByTime(10000);
734
-
735
- expect(MockWebSocket.instances.length).toBe(1);
736
- expect(disconnected).toBe(true);
737
- });
738
- });
739
-
740
- describe("heartbeat", () => {
741
- it("should send ping at configured interval", async () => {
742
- const transport = WebSocketTransport.make({
743
- url: "ws://localhost:8080",
744
- heartbeatInterval: 5000,
745
- });
746
-
747
- const connectPromise = transport.connect();
748
- const ws = MockWebSocket.getLatest()!;
749
- await ws.simulateOpenWithAuth();
750
- await connectPromise;
751
-
752
- // Advance time to trigger heartbeat
753
- vi.advanceTimersByTime(5000);
754
-
755
- // 2 messages: auth + ping
756
- expect(ws.sentMessages.length).toBe(2);
757
- const authMsg = JSON.parse(ws.sentMessages[0]!);
758
- expect(authMsg.type).toBe("auth");
759
- const sent = JSON.parse(ws.sentMessages[1]!);
760
- expect(sent.type).toBe("ping");
761
- });
762
-
763
- it("should handle pong response", async () => {
764
- const transport = WebSocketTransport.make({
765
- url: "ws://localhost:8080",
766
- heartbeatInterval: 5000,
767
- heartbeatTimeout: 2000,
768
- });
769
-
770
- const connectPromise = transport.connect();
771
- const ws = MockWebSocket.getLatest()!;
772
- await ws.simulateOpenWithAuth();
773
- await connectPromise;
774
-
775
- // Trigger heartbeat
776
- vi.advanceTimersByTime(5000);
777
-
778
- // Respond with pong
779
- ws.simulateMessage({ type: "pong" });
780
-
781
- // Advance past timeout - should not disconnect
782
- vi.advanceTimersByTime(3000);
783
-
784
- expect(transport.isConnected()).toBe(true);
785
- });
786
-
787
- it("should trigger reconnection on heartbeat timeout", async () => {
788
- let reconnecting = false;
789
-
790
- const transport = WebSocketTransport.make({
791
- url: "ws://localhost:8080",
792
- autoReconnect: true,
793
- heartbeatInterval: 5000,
794
- heartbeatTimeout: 2000,
795
- onEvent: (event) => {
796
- if (event.type === "reconnecting") {
797
- reconnecting = true;
798
- }
799
- },
800
- });
801
-
802
- const connectPromise = transport.connect();
803
- const ws = MockWebSocket.getLatest()!;
804
- await ws.simulateOpenWithAuth();
805
- await connectPromise;
806
-
807
- // Trigger heartbeat
808
- vi.advanceTimersByTime(5000);
809
-
810
- // No pong response - wait for timeout
811
- vi.advanceTimersByTime(2000);
812
-
813
- expect(reconnecting).toBe(true);
814
- });
815
- });
816
-
817
- describe("authentication", () => {
818
- it("should send auth message after connection with string token", async () => {
819
- const transport = WebSocketTransport.make({
820
- url: "ws://localhost:8080",
821
- authToken: "test-token-123",
822
- });
823
-
824
- const connectPromise = transport.connect();
825
- const ws = MockWebSocket.getLatest()!;
826
- // Use simulateOpen() only - we want to test auth manually
827
- ws.simulateOpen();
828
-
829
- // Should send auth message
830
- await vi.waitFor(() => {
831
- expect(ws.sentMessages.length).toBe(1);
832
- });
833
-
834
- const authMessage = JSON.parse(ws.sentMessages[0]!);
835
- expect(authMessage.type).toBe("auth");
836
- expect(authMessage.token).toBe("test-token-123");
837
-
838
- // Simulate auth success
839
- ws.simulateMessage({ type: "auth_result", success: true });
840
-
841
- await connectPromise;
842
- expect(transport.isConnected()).toBe(true);
843
- });
844
-
845
- it("should send auth message after connection with function token", async () => {
846
- const transport = WebSocketTransport.make({
847
- url: "ws://localhost:8080",
848
- authToken: () => "dynamic-token",
849
- });
850
-
851
- const connectPromise = transport.connect();
852
- const ws = MockWebSocket.getLatest()!;
853
- ws.simulateOpen();
854
-
855
- await vi.waitFor(() => {
856
- expect(ws.sentMessages.length).toBe(1);
857
- });
858
-
859
- const authMessage = JSON.parse(ws.sentMessages[0]!);
860
- expect(authMessage.token).toBe("dynamic-token");
861
-
862
- ws.simulateMessage({ type: "auth_result", success: true });
863
- await connectPromise;
864
- });
865
-
866
- it("should send auth message with async function token", async () => {
867
- const transport = WebSocketTransport.make({
868
- url: "ws://localhost:8080",
869
- authToken: async () => {
870
- return "async-token";
871
- },
872
- });
873
-
874
- const connectPromise = transport.connect();
875
- const ws = MockWebSocket.getLatest()!;
876
- ws.simulateOpen();
877
-
878
- await vi.waitFor(() => {
879
- expect(ws.sentMessages.length).toBe(1);
880
- });
881
-
882
- const authMessage = JSON.parse(ws.sentMessages[0]!);
883
- expect(authMessage.token).toBe("async-token");
884
-
885
- ws.simulateMessage({ type: "auth_result", success: true });
886
- await connectPromise;
887
- });
888
-
889
- it("should send empty token auth message when no authToken provided", async () => {
890
- const transport = WebSocketTransport.make({
891
- url: "ws://localhost:8080",
892
- // No authToken provided
893
- });
894
-
895
- const connectPromise = transport.connect();
896
- const ws = MockWebSocket.getLatest()!;
897
- ws.simulateOpen();
898
-
899
- // Should send auth message with empty token
900
- await vi.waitFor(() => {
901
- expect(ws.sentMessages.length).toBe(1);
902
- });
903
-
904
- const authMessage = JSON.parse(ws.sentMessages[0]!);
905
- expect(authMessage.type).toBe("auth");
906
- expect(authMessage.token).toBe("");
907
-
908
- // Simulate auth success
909
- ws.simulateMessage({ type: "auth_result", success: true });
910
-
911
- await connectPromise;
912
- expect(transport.isConnected()).toBe(true);
913
- });
914
-
915
- it("should reject connection on auth failure", async () => {
916
- let errorEmitted = false;
917
-
918
- const transport = WebSocketTransport.make({
919
- url: "ws://localhost:8080",
920
- authToken: "bad-token",
921
- onEvent: (event) => {
922
- if (event.type === "error") {
923
- errorEmitted = true;
924
- }
925
- },
926
- });
927
-
928
- const connectPromise = transport.connect();
929
- const ws = MockWebSocket.getLatest()!;
930
- ws.simulateOpen();
931
-
932
- await vi.waitFor(() => {
933
- expect(ws.sentMessages.length).toBe(1);
934
- });
935
-
936
- // Simulate auth failure
937
- ws.simulateMessage({
938
- type: "auth_result",
939
- success: false,
940
- error: "Invalid token",
941
- });
942
-
943
- await expect(connectPromise).rejects.toThrow("Invalid token");
944
- expect(errorEmitted).toBe(true);
945
- expect(transport.isConnected()).toBe(false);
946
- });
947
- });
948
-
949
- describe("presence", () => {
950
- describe("sendPresenceSet", () => {
951
- it("should send presence_set message when connected", async () => {
952
- const transport = WebSocketTransport.make({
953
- url: "ws://localhost:8080",
954
- });
955
-
956
- const connectPromise = transport.connect();
957
- const ws = MockWebSocket.getLatest()!;
958
- await ws.simulateOpenWithAuth();
959
- await connectPromise;
960
-
961
- transport.sendPresenceSet({ x: 100, y: 200, name: "Alice" });
962
-
963
- // 2 messages: auth + presence_set
964
- expect(ws.sentMessages.length).toBe(2);
965
- const authMsg = JSON.parse(ws.sentMessages[0]!);
966
- expect(authMsg.type).toBe("auth");
967
- const sent = JSON.parse(ws.sentMessages[1]!);
968
- expect(sent.type).toBe("presence_set");
969
- expect(sent.data).toEqual({ x: 100, y: 200, name: "Alice" });
970
- });
971
-
972
- it("should queue presence_set during reconnection", async () => {
973
- const transport = WebSocketTransport.make({
974
- url: "ws://localhost:8080",
975
- autoReconnect: true,
976
- });
977
-
978
- const connectPromise = transport.connect();
979
- const ws = MockWebSocket.getLatest()!;
980
- await ws.simulateOpenWithAuth();
981
- await connectPromise;
982
-
983
- // Simulate connection lost
984
- ws.simulateClose(1006, "Connection lost");
985
-
986
- // Queue presence message during reconnection
987
- transport.sendPresenceSet({ cursor: { x: 50, y: 75 } });
988
-
989
- // Reconnect
990
- vi.advanceTimersByTime(1000);
991
- const newWs = MockWebSocket.getLatest()!;
992
- await newWs.simulateOpenWithAuth();
993
-
994
- // Queued message should be sent after auth
995
- // 2 messages: auth + presence_set (queued message)
996
- expect(newWs.sentMessages.length).toBe(2);
997
- const authMsg = JSON.parse(newWs.sentMessages[0]!);
998
- expect(authMsg.type).toBe("auth");
999
- const sent = JSON.parse(newWs.sentMessages[1]!);
1000
- expect(sent.type).toBe("presence_set");
1001
- expect(sent.data).toEqual({ cursor: { x: 50, y: 75 } });
1002
- });
1003
-
1004
- it("should queue presence_set when disconnected and send after connect", async () => {
1005
- const transport = WebSocketTransport.make({
1006
- url: "ws://localhost:8080",
1007
- });
1008
-
1009
- // Queue presence before connecting
1010
- transport.sendPresenceSet({ x: 100, y: 200 });
1011
-
1012
- // No WebSocket created yet
1013
- expect(MockWebSocket.instances.length).toBe(0);
1014
-
1015
- // Now connect
1016
- const connectPromise = transport.connect();
1017
- const ws = MockWebSocket.getLatest()!;
1018
- await ws.simulateOpenWithAuth();
1019
- await connectPromise;
1020
-
1021
- // Should have sent: auth + presence_set
1022
- expect(ws.sentMessages.length).toBe(2);
1023
- const sent = JSON.parse(ws.sentMessages[1]!);
1024
- expect(sent.type).toBe("presence_set");
1025
- expect(sent.data).toEqual({ x: 100, y: 200 });
1026
- });
1027
-
1028
- it("should only keep latest presence_set when multiple are queued", async () => {
1029
- const transport = WebSocketTransport.make({
1030
- url: "ws://localhost:8080",
1031
- });
1032
-
1033
- // Queue multiple presence updates before connecting
1034
- transport.sendPresenceSet({ x: 100, y: 200 });
1035
- transport.sendPresenceSet({ x: 150, y: 250 });
1036
- transport.sendPresenceSet({ x: 200, y: 300 });
1037
-
1038
- // Now connect
1039
- const connectPromise = transport.connect();
1040
- const ws = MockWebSocket.getLatest()!;
1041
- await ws.simulateOpenWithAuth();
1042
- await connectPromise;
1043
-
1044
- // Should have sent: auth + only the latest presence_set
1045
- expect(ws.sentMessages.length).toBe(2);
1046
- const sent = JSON.parse(ws.sentMessages[1]!);
1047
- expect(sent.type).toBe("presence_set");
1048
- expect(sent.data).toEqual({ x: 200, y: 300 });
1049
- });
1050
- });
1051
-
1052
- describe("sendPresenceClear", () => {
1053
- it("should send presence_clear message when connected", async () => {
1054
- const transport = WebSocketTransport.make({
1055
- url: "ws://localhost:8080",
1056
- });
1057
-
1058
- const connectPromise = transport.connect();
1059
- const ws = MockWebSocket.getLatest()!;
1060
- await ws.simulateOpenWithAuth();
1061
- await connectPromise;
1062
-
1063
- transport.sendPresenceClear();
1064
-
1065
- // 2 messages: auth + presence_clear
1066
- expect(ws.sentMessages.length).toBe(2);
1067
- const authMsg = JSON.parse(ws.sentMessages[0]!);
1068
- expect(authMsg.type).toBe("auth");
1069
- const sent = JSON.parse(ws.sentMessages[1]!);
1070
- expect(sent.type).toBe("presence_clear");
1071
- });
1072
-
1073
- it("should queue presence_clear during reconnection", async () => {
1074
- const transport = WebSocketTransport.make({
1075
- url: "ws://localhost:8080",
1076
- autoReconnect: true,
1077
- });
1078
-
1079
- const connectPromise = transport.connect();
1080
- const ws = MockWebSocket.getLatest()!;
1081
- await ws.simulateOpenWithAuth();
1082
- await connectPromise;
1083
-
1084
- // Simulate connection lost
1085
- ws.simulateClose(1006, "Connection lost");
1086
-
1087
- // Queue presence_clear during reconnection
1088
- transport.sendPresenceClear();
1089
-
1090
- // Reconnect
1091
- vi.advanceTimersByTime(1000);
1092
- const newWs = MockWebSocket.getLatest()!;
1093
- await newWs.simulateOpenWithAuth();
1094
-
1095
- // Queued message should be sent after auth
1096
- expect(newWs.sentMessages.length).toBe(2);
1097
- const sent = JSON.parse(newWs.sentMessages[1]!);
1098
- expect(sent.type).toBe("presence_clear");
1099
- });
1100
- });
1101
-
1102
- describe("presence message forwarding", () => {
1103
- it("should forward presence_snapshot to subscribers", async () => {
1104
- const transport = WebSocketTransport.make({
1105
- url: "ws://localhost:8080",
1106
- });
1107
-
1108
- const messages: Transport.ServerMessage[] = [];
1109
- transport.subscribe((msg) => messages.push(msg));
1110
-
1111
- const connectPromise = transport.connect();
1112
- const ws = MockWebSocket.getLatest()!;
1113
- await ws.simulateOpenWithAuth();
1114
- await connectPromise;
1115
-
1116
- ws.simulateMessage({
1117
- type: "presence_snapshot",
1118
- selfId: "conn-123",
1119
- presences: {
1120
- "conn-456": { data: { x: 10, y: 20 }, userId: "user-456" },
1121
- },
1122
- });
1123
-
1124
- expect(messages.length).toBe(1);
1125
- expect(messages[0]!.type).toBe("presence_snapshot");
1126
- if (messages[0]!.type === "presence_snapshot") {
1127
- expect(messages[0]!.selfId).toBe("conn-123");
1128
- expect(messages[0]!.presences["conn-456"]).toEqual({
1129
- data: { x: 10, y: 20 },
1130
- userId: "user-456",
1131
- });
1132
- }
1133
- });
1134
-
1135
- it("should forward presence_update to subscribers", async () => {
1136
- const transport = WebSocketTransport.make({
1137
- url: "ws://localhost:8080",
1138
- });
1139
-
1140
- const messages: Transport.ServerMessage[] = [];
1141
- transport.subscribe((msg) => messages.push(msg));
1142
-
1143
- const connectPromise = transport.connect();
1144
- const ws = MockWebSocket.getLatest()!;
1145
- await ws.simulateOpenWithAuth();
1146
- await connectPromise;
1147
-
1148
- ws.simulateMessage({
1149
- type: "presence_update",
1150
- id: "conn-789",
1151
- data: { cursor: { x: 50, y: 100 } },
1152
- userId: "user-789",
1153
- });
1154
-
1155
- expect(messages.length).toBe(1);
1156
- expect(messages[0]!.type).toBe("presence_update");
1157
- if (messages[0]!.type === "presence_update") {
1158
- expect(messages[0]!.id).toBe("conn-789");
1159
- expect(messages[0]!.data).toEqual({ cursor: { x: 50, y: 100 } });
1160
- expect(messages[0]!.userId).toBe("user-789");
1161
- }
1162
- });
1163
-
1164
- it("should forward presence_remove to subscribers", async () => {
1165
- const transport = WebSocketTransport.make({
1166
- url: "ws://localhost:8080",
1167
- });
1168
-
1169
- const messages: Transport.ServerMessage[] = [];
1170
- transport.subscribe((msg) => messages.push(msg));
1171
-
1172
- const connectPromise = transport.connect();
1173
- const ws = MockWebSocket.getLatest()!;
1174
- await ws.simulateOpenWithAuth();
1175
- await connectPromise;
1176
-
1177
- ws.simulateMessage({
1178
- type: "presence_remove",
1179
- id: "conn-disconnected",
1180
- });
1181
-
1182
- expect(messages.length).toBe(1);
1183
- expect(messages[0]!.type).toBe("presence_remove");
1184
- if (messages[0]!.type === "presence_remove") {
1185
- expect(messages[0]!.id).toBe("conn-disconnected");
1186
- }
1187
- });
1188
-
1189
- it("should forward presence_update without userId", async () => {
1190
- const transport = WebSocketTransport.make({
1191
- url: "ws://localhost:8080",
1192
- });
1193
-
1194
- const messages: Transport.ServerMessage[] = [];
1195
- transport.subscribe((msg) => messages.push(msg));
1196
-
1197
- const connectPromise = transport.connect();
1198
- const ws = MockWebSocket.getLatest()!;
1199
- await ws.simulateOpenWithAuth();
1200
- await connectPromise;
1201
-
1202
- ws.simulateMessage({
1203
- type: "presence_update",
1204
- id: "conn-anon",
1205
- data: { status: "online" },
1206
- });
1207
-
1208
- expect(messages.length).toBe(1);
1209
- if (messages[0]!.type === "presence_update") {
1210
- expect(messages[0]!.id).toBe("conn-anon");
1211
- expect(messages[0]!.data).toEqual({ status: "online" });
1212
- expect(messages[0]!.userId).toBeUndefined();
1213
- }
1214
- });
1215
- });
1216
- });
1217
- });