cojson-transport-ws 0.20.0 → 0.20.2

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.
@@ -9,45 +9,91 @@ import type { AnyWebSocket } from "../types.js";
9
9
  import { BUFFER_LIMIT, BUFFER_LIMIT_POLLING_INTERVAL } from "../utils.js";
10
10
  import { createTestMetricReader, tearDownTestMetricReader } from "./utils.js";
11
11
 
12
- const { CO_VALUE_PRIORITY, WEBSOCKET_CONFIG, setOutgoingMessagesChunkDelay } =
13
- cojsonInternals;
12
+ const { CO_VALUE_PRIORITY, WEBSOCKET_CONFIG } = cojsonInternals;
14
13
 
15
14
  const { MAX_OUTGOING_MESSAGES_CHUNK_BYTES } = WEBSOCKET_CONFIG;
16
15
 
17
- function setup(opts: Partial<CreateWebSocketPeerOpts> = {}) {
18
- const listeners = new Map<string, (event: MessageEvent) => void>();
16
+ interface SetupOptions extends Partial<CreateWebSocketPeerOpts> {
17
+ initialReadyState?: number;
18
+ }
19
+
20
+ function setup(opts: SetupOptions = {}) {
21
+ const { initialReadyState = 1, ...peerOpts } = opts;
22
+ const listeners = new Map<
23
+ string,
24
+ Set<{ callback: (event: MessageEvent) => void; once?: boolean }>
25
+ >();
19
26
 
20
27
  const mockWebSocket = {
21
- readyState: 1,
22
- addEventListener: vi.fn().mockImplementation((type, listener) => {
23
- listeners.set(type, listener);
24
- }),
25
- removeEventListener: vi.fn().mockImplementation((type) => {
26
- listeners.delete(type);
27
- }),
28
+ readyState: initialReadyState,
29
+ bufferedAmount: 0,
30
+ addEventListener: vi
31
+ .fn()
32
+ .mockImplementation(
33
+ (
34
+ type: string,
35
+ callback: (event: MessageEvent) => void,
36
+ options?: { once?: boolean },
37
+ ) => {
38
+ if (!listeners.has(type)) {
39
+ listeners.set(type, new Set());
40
+ }
41
+ listeners.get(type)!.add({ callback, once: options?.once });
42
+ },
43
+ ),
44
+ removeEventListener: vi
45
+ .fn()
46
+ .mockImplementation(
47
+ (type: string, callback: (event: MessageEvent) => void) => {
48
+ const set = listeners.get(type);
49
+ if (set) {
50
+ for (const entry of set) {
51
+ if (entry.callback === callback) {
52
+ set.delete(entry);
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ },
58
+ ),
28
59
  close: vi.fn(),
29
60
  send: vi.fn(),
30
61
  } as unknown as Mocked<AnyWebSocket>;
31
62
 
63
+ const triggerEvent = (type: string, event?: MessageEvent) => {
64
+ const set = listeners.get(type);
65
+ if (set) {
66
+ const toRemove: {
67
+ callback: (event: MessageEvent) => void;
68
+ once?: boolean;
69
+ }[] = [];
70
+ for (const entry of set) {
71
+ entry.callback(event ?? new MessageEvent(type));
72
+ if (entry.once) {
73
+ toRemove.push(entry);
74
+ }
75
+ }
76
+ for (const entry of toRemove) {
77
+ set.delete(entry);
78
+ }
79
+ }
80
+ };
81
+
32
82
  const peer = createWebSocketPeer({
33
83
  id: "test-peer",
34
84
  websocket: mockWebSocket,
35
85
  role: "client",
36
86
  batchingByDefault: true,
37
- ...opts,
87
+ ...peerOpts,
38
88
  });
39
89
 
40
- return { mockWebSocket, peer, listeners };
90
+ return { mockWebSocket, peer, listeners, triggerEvent };
41
91
  }
42
92
 
43
93
  function serializeMessages(messages: SyncMessage[]) {
44
94
  return messages.map((msg) => JSON.stringify(msg)).join("\n");
45
95
  }
46
96
 
47
- afterEach(() => {
48
- setOutgoingMessagesChunkDelay(5);
49
- });
50
-
51
97
  describe("createWebSocketPeer", () => {
52
98
  test("should create a peer with correct properties", () => {
53
99
  const { peer } = setup();
@@ -59,29 +105,25 @@ describe("createWebSocketPeer", () => {
59
105
  });
60
106
 
61
107
  test("should handle disconnection", async () => {
62
- const { listeners, peer } = setup();
108
+ const { triggerEvent, peer } = setup();
63
109
 
64
110
  const onMessageSpy = vi.fn();
65
111
  peer.incoming.onMessage(onMessageSpy);
66
112
 
67
- const closeHandler = listeners.get("close");
68
-
69
- closeHandler?.(new MessageEvent("close"));
113
+ triggerEvent("close");
70
114
 
71
115
  expect(onMessageSpy).toHaveBeenCalledWith("Disconnected");
72
116
  });
73
117
 
74
118
  test("should handle ping timeout", async () => {
75
119
  vi.useFakeTimers();
76
- const { listeners, peer } = setup();
120
+ const { triggerEvent, peer } = setup();
77
121
 
78
122
  const onMessageSpy = vi.fn();
79
123
 
80
124
  peer.incoming.onMessage(onMessageSpy);
81
125
 
82
- const messageHandler = listeners.get("message");
83
-
84
- messageHandler?.(new MessageEvent("message", { data: "{}" }));
126
+ triggerEvent("message", new MessageEvent("message", { data: "{}" }));
85
127
 
86
128
  await vi.advanceTimersByTimeAsync(10_000);
87
129
 
@@ -156,9 +198,8 @@ describe("createWebSocketPeer", () => {
156
198
 
157
199
  test("should call onSuccess handler after receiving first message", () => {
158
200
  const onSuccess = vi.fn();
159
- const { listeners } = setup({ onSuccess });
201
+ const { triggerEvent } = setup({ onSuccess });
160
202
 
161
- const messageHandler = listeners.get("message");
162
203
  const message: SyncMessage = {
163
204
  action: "known",
164
205
  id: "co_ztest",
@@ -167,24 +208,24 @@ describe("createWebSocketPeer", () => {
167
208
  };
168
209
 
169
210
  // First message should trigger onSuccess
170
- messageHandler?.(
211
+ triggerEvent(
212
+ "message",
171
213
  new MessageEvent("message", { data: JSON.stringify(message) }),
172
214
  );
173
215
  expect(onSuccess).toHaveBeenCalledTimes(1);
174
216
 
175
217
  // Subsequent messages should not trigger onSuccess again
176
- messageHandler?.(
218
+ triggerEvent(
219
+ "message",
177
220
  new MessageEvent("message", { data: JSON.stringify(message) }),
178
221
  );
179
222
  expect(onSuccess).toHaveBeenCalledTimes(1);
180
223
  });
181
224
 
182
225
  describe("batchingByDefault = true", () => {
183
- test("should batch outgoing messages", async () => {
184
- const { peer, mockWebSocket } = setup();
185
-
186
- mockWebSocket.send.mockImplementation(() => {
187
- mockWebSocket.readyState = 0;
226
+ test("should batch outgoing messages when socket is not ready", async () => {
227
+ const { peer, mockWebSocket, triggerEvent } = setup({
228
+ initialReadyState: 0,
188
229
  });
189
230
 
190
231
  const message1: SyncMessage = {
@@ -204,6 +245,10 @@ describe("createWebSocketPeer", () => {
204
245
  void peer.outgoing.push(message1);
205
246
  void peer.outgoing.push(message2);
206
247
 
248
+ // Simulate socket becoming ready
249
+ mockWebSocket.readyState = 1;
250
+ triggerEvent("open");
251
+
207
252
  await waitFor(() => {
208
253
  expect(mockWebSocket.send).toHaveBeenCalled();
209
254
  });
@@ -213,44 +258,88 @@ describe("createWebSocketPeer", () => {
213
258
  );
214
259
  });
215
260
 
216
- test("should sort outgoing messages by priority", async () => {
261
+ test("should send messages immediately when socket is ready", async () => {
217
262
  const { peer, mockWebSocket } = setup();
218
263
 
219
- mockWebSocket.send.mockImplementation(() => {
220
- mockWebSocket.readyState = 0;
264
+ const message1: SyncMessage = {
265
+ action: "known",
266
+ id: "co_ztest",
267
+ header: false,
268
+ sessions: {},
269
+ };
270
+
271
+ const message2: SyncMessage = {
272
+ action: "content",
273
+ id: "co_zlow",
274
+ new: {},
275
+ priority: 6,
276
+ };
277
+
278
+ void peer.outgoing.push(message1);
279
+
280
+ await waitFor(() => {
281
+ expect(mockWebSocket.send).toHaveBeenCalledTimes(1);
221
282
  });
222
283
 
223
- const message1: SyncMessage = {
284
+ expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(message1));
285
+
286
+ void peer.outgoing.push(message2);
287
+
288
+ await waitFor(() => {
289
+ expect(mockWebSocket.send).toHaveBeenCalledTimes(2);
290
+ });
291
+
292
+ expect(mockWebSocket.send).toHaveBeenNthCalledWith(
293
+ 2,
294
+ JSON.stringify(message2),
295
+ );
296
+ });
297
+
298
+ test("should sort remaining queued messages by priority after first message", async () => {
299
+ const { peer, mockWebSocket, triggerEvent } = setup({
300
+ initialReadyState: 0,
301
+ });
302
+
303
+ const lowPriority: SyncMessage = {
224
304
  action: "content",
225
305
  id: "co_zlow",
226
306
  new: {},
227
307
  priority: CO_VALUE_PRIORITY.LOW,
228
308
  };
229
309
 
230
- const message2: SyncMessage = {
310
+ const highPriority: SyncMessage = {
231
311
  action: "content",
232
312
  id: "co_zhigh",
233
313
  new: {},
234
314
  priority: CO_VALUE_PRIORITY.HIGH,
235
315
  };
236
316
 
237
- void peer.outgoing.push(message1);
238
- void peer.outgoing.push(message2);
239
- void peer.outgoing.push(message2);
317
+ // First message is pulled immediately before socket check,
318
+ // so it will be first regardless of priority
319
+ void peer.outgoing.push(lowPriority);
320
+ // Subsequent messages are queued and sorted by priority
321
+ void peer.outgoing.push(lowPriority);
322
+ void peer.outgoing.push(highPriority);
323
+
324
+ // Simulate socket becoming ready
325
+ mockWebSocket.readyState = 1;
326
+ triggerEvent("open");
240
327
 
241
328
  await waitFor(() => {
242
329
  expect(mockWebSocket.send).toHaveBeenCalled();
243
330
  });
244
331
 
332
+ // First message (lowPriority) comes first as it was pulled before waiting,
333
+ // then remaining messages are sorted: highPriority before lowPriority
245
334
  expect(mockWebSocket.send).toHaveBeenCalledWith(
246
- [message2, message2, message1]
335
+ [lowPriority, highPriority, lowPriority]
247
336
  .map((msg) => JSON.stringify(msg))
248
337
  .join("\n"),
249
338
  );
250
339
  });
251
340
 
252
- test("should send all the pending messages when the websocket is closed", async () => {
253
- const { peer, mockWebSocket } = setup();
341
+ test("should send remaining queued messages when close is called", async () => {
342
+ const { peer, mockWebSocket } = setup({ initialReadyState: 0 });
254
343
 
255
344
  const message1: SyncMessage = {
256
345
  action: "known",
@@ -266,19 +355,36 @@ describe("createWebSocketPeer", () => {
266
355
  priority: 6,
267
356
  };
268
357
 
358
+ const message3: SyncMessage = {
359
+ action: "content",
360
+ id: "co_zmedium",
361
+ new: {},
362
+ priority: 3,
363
+ };
364
+
269
365
  void peer.outgoing.push(message1);
270
366
  void peer.outgoing.push(message2);
367
+ void peer.outgoing.push(message3);
271
368
 
369
+ // Set socket to open before close to allow sending
370
+ mockWebSocket.readyState = 1;
272
371
  peer.outgoing.close();
273
372
 
373
+ // First message was already pulled by processQueue (waiting for socket),
374
+ // close() processes and sends remaining messages from queue sorted by priority
274
375
  expect(mockWebSocket.send).toHaveBeenCalledWith(
275
- [message1, message2].map((msg) => JSON.stringify(msg)).join("\n"),
376
+ [message3, message2].map((msg) => JSON.stringify(msg)).join("\n"),
276
377
  );
277
378
  });
278
379
 
279
380
  test("should limit the chunk size to MAX_OUTGOING_MESSAGES_CHUNK_SIZE", async () => {
381
+ // This test verifies chunking works when socket is already ready
280
382
  const { peer, mockWebSocket } = setup();
281
383
 
384
+ mockWebSocket.send.mockImplementation((value: string) => {
385
+ mockWebSocket.bufferedAmount += value.length;
386
+ });
387
+
282
388
  const message1: SyncMessage = {
283
389
  action: "known",
284
390
  id: "co_ztest",
@@ -292,35 +398,30 @@ describe("createWebSocketPeer", () => {
292
398
  priority: 6,
293
399
  };
294
400
 
295
- const stream: SyncMessage[] = [];
296
-
297
- while (
298
- serializeMessages(stream.concat(message1)).length <
299
- MAX_OUTGOING_MESSAGES_CHUNK_BYTES
300
- ) {
301
- stream.push(message1);
302
- void peer.outgoing.push(message1);
401
+ // Fill up the buffer
402
+ while (mockWebSocket.bufferedAmount < BUFFER_LIMIT) {
403
+ peer.outgoing.push(message1);
303
404
  }
304
405
 
406
+ mockWebSocket.send.mockClear();
407
+
305
408
  void peer.outgoing.push(message2);
306
409
 
307
- await waitFor(() => {
308
- expect(mockWebSocket.send).toHaveBeenCalledTimes(2);
309
- });
410
+ expect(mockWebSocket.send).not.toHaveBeenCalled();
310
411
 
311
- expect(mockWebSocket.send).toHaveBeenCalledWith(
312
- serializeMessages(stream),
313
- );
412
+ // Reset the buffer, make it look like we have sent the messages
413
+ mockWebSocket.bufferedAmount = 0;
314
414
 
315
- expect(mockWebSocket.send).toHaveBeenNthCalledWith(
316
- 2,
317
- JSON.stringify(message2),
318
- );
415
+ await waitFor(() => {
416
+ expect(mockWebSocket.send).toHaveBeenCalled();
417
+ });
319
418
  });
320
419
 
321
420
  test("should send accumulated messages before a large message", async () => {
322
421
  const { peer, mockWebSocket } = setup();
323
422
 
423
+ mockWebSocket.bufferedAmount = BUFFER_LIMIT + 1;
424
+
324
425
  const smallMessage: SyncMessage = {
325
426
  action: "known",
326
427
  id: "co_z_small",
@@ -340,6 +441,8 @@ describe("createWebSocketPeer", () => {
340
441
  void peer.outgoing.push(smallMessage);
341
442
  void peer.outgoing.push(largeMessage);
342
443
 
444
+ mockWebSocket.bufferedAmount = 0;
445
+
343
446
  await waitFor(() => {
344
447
  expect(mockWebSocket.send).toHaveBeenCalledTimes(2);
345
448
  });
@@ -359,10 +462,6 @@ describe("createWebSocketPeer", () => {
359
462
  vi.useFakeTimers();
360
463
  const { peer, mockWebSocket } = setup();
361
464
 
362
- mockWebSocket.send.mockImplementation(() => {
363
- mockWebSocket.bufferedAmount = BUFFER_LIMIT + 1;
364
- });
365
-
366
465
  const message1: SyncMessage = {
367
466
  action: "known",
368
467
  id: "co_ztest",
@@ -376,31 +475,25 @@ describe("createWebSocketPeer", () => {
376
475
  priority: 6,
377
476
  };
378
477
 
379
- const stream: SyncMessage[] = [];
380
-
381
- while (
382
- serializeMessages(stream.concat(message1)).length <
383
- MAX_OUTGOING_MESSAGES_CHUNK_BYTES
384
- ) {
385
- stream.push(message1);
386
- void peer.outgoing.push(message1);
387
- }
478
+ // Start with buffer full so messages go through the queue
479
+ mockWebSocket.bufferedAmount = BUFFER_LIMIT + 1;
388
480
 
481
+ void peer.outgoing.push(message1);
389
482
  void peer.outgoing.push(message2);
390
483
 
391
- await vi.advanceTimersByTimeAsync(100);
484
+ await vi.advanceTimersByTimeAsync(0);
392
485
 
393
- expect(mockWebSocket.send).toHaveBeenCalledWith(
394
- serializeMessages(stream),
395
- );
486
+ // No messages sent yet because buffer is full
487
+ expect(mockWebSocket.send).not.toHaveBeenCalled();
396
488
 
489
+ // Clear the buffer
397
490
  mockWebSocket.bufferedAmount = 0;
398
491
 
399
492
  await vi.advanceTimersByTimeAsync(BUFFER_LIMIT_POLLING_INTERVAL + 1);
400
493
 
401
- expect(mockWebSocket.send).toHaveBeenNthCalledWith(
402
- 2,
403
- JSON.stringify(message2),
494
+ // Both messages are batched together
495
+ expect(mockWebSocket.send).toHaveBeenCalledWith(
496
+ [message1, message2].map((msg) => JSON.stringify(msg)).join("\n"),
404
497
  );
405
498
 
406
499
  vi.useRealTimers();
@@ -426,10 +519,15 @@ describe("createWebSocketPeer", () => {
426
519
  };
427
520
 
428
521
  void peer.outgoing.push(message1);
522
+
523
+ await waitFor(() => {
524
+ expect(mockWebSocket.send).toHaveBeenCalledTimes(1);
525
+ });
526
+
429
527
  void peer.outgoing.push(message2);
430
528
 
431
529
  await waitFor(() => {
432
- expect(mockWebSocket.send).toHaveBeenCalled();
530
+ expect(mockWebSocket.send).toHaveBeenCalledTimes(2);
433
531
  });
434
532
 
435
533
  expect(mockWebSocket.send).toHaveBeenNthCalledWith(
@@ -442,9 +540,10 @@ describe("createWebSocketPeer", () => {
442
540
  );
443
541
  });
444
542
 
445
- test("should start batching outgoing messages when reiceving a batched message", async () => {
446
- const { peer, mockWebSocket, listeners } = setup({
543
+ test("should start batching outgoing messages when receiving a batched message", async () => {
544
+ const { peer, mockWebSocket, triggerEvent } = setup({
447
545
  batchingByDefault: false,
546
+ initialReadyState: 0,
448
547
  });
449
548
 
450
549
  const message1: SyncMessage = {
@@ -454,9 +553,8 @@ describe("createWebSocketPeer", () => {
454
553
  sessions: {},
455
554
  };
456
555
 
457
- const messageHandler = listeners.get("message");
458
-
459
- messageHandler?.(
556
+ triggerEvent(
557
+ "message",
460
558
  new MessageEvent("message", {
461
559
  data: Array.from({ length: 5 }, () => message1)
462
560
  .map((msg) => JSON.stringify(msg))
@@ -474,6 +572,10 @@ describe("createWebSocketPeer", () => {
474
572
  void peer.outgoing.push(message1);
475
573
  void peer.outgoing.push(message2);
476
574
 
575
+ // Simulate socket becoming ready
576
+ mockWebSocket.readyState = 1;
577
+ triggerEvent("open");
578
+
477
579
  await waitFor(() => {
478
580
  expect(mockWebSocket.send).toHaveBeenCalled();
479
581
  });
@@ -483,8 +585,8 @@ describe("createWebSocketPeer", () => {
483
585
  );
484
586
  });
485
587
 
486
- test("should not start batching outgoing messages when reiceving non-batched message", async () => {
487
- const { peer, mockWebSocket, listeners } = setup({
588
+ test("should not start batching outgoing messages when receiving non-batched message", async () => {
589
+ const { peer, mockWebSocket, triggerEvent } = setup({
488
590
  batchingByDefault: false,
489
591
  });
490
592
 
@@ -495,9 +597,8 @@ describe("createWebSocketPeer", () => {
495
597
  sessions: {},
496
598
  };
497
599
 
498
- const messageHandler = listeners.get("message");
499
-
500
- messageHandler?.(
600
+ triggerEvent(
601
+ "message",
501
602
  new MessageEvent("message", {
502
603
  data: JSON.stringify(message1),
503
604
  }),
@@ -511,10 +612,15 @@ describe("createWebSocketPeer", () => {
511
612
  };
512
613
 
513
614
  void peer.outgoing.push(message1);
615
+
616
+ await waitFor(() => {
617
+ expect(mockWebSocket.send).toHaveBeenCalledTimes(1);
618
+ });
619
+
514
620
  void peer.outgoing.push(message2);
515
621
 
516
622
  await waitFor(() => {
517
- expect(mockWebSocket.send).toHaveBeenCalled();
623
+ expect(mockWebSocket.send).toHaveBeenCalledTimes(2);
518
624
  });
519
625
 
520
626
  expect(mockWebSocket.send).toHaveBeenNthCalledWith(
@@ -550,14 +656,13 @@ describe("createWebSocketPeer", () => {
550
656
 
551
657
  test("should correctly measure incoming ingress", async () => {
552
658
  const metricReader = createTestMetricReader();
553
- const { listeners } = setup({
659
+ const { triggerEvent } = setup({
554
660
  meta: { label: "value" },
555
661
  });
556
662
 
557
- const messageHandler = listeners.get("message");
558
-
559
663
  const encryptedChanges = "Hello, world!";
560
- messageHandler?.(
664
+ triggerEvent(
665
+ "message",
561
666
  new MessageEvent("message", {
562
667
  data: JSON.stringify({
563
668
  action: "content",
@@ -585,7 +690,8 @@ describe("createWebSocketPeer", () => {
585
690
  ).toBe(encryptedChanges.length);
586
691
 
587
692
  const trustingChanges = "Jazz is great!";
588
- messageHandler?.(
693
+ triggerEvent(
694
+ "message",
589
695
  new MessageEvent("message", {
590
696
  data: JSON.stringify({
591
697
  action: "content",
@@ -611,9 +717,8 @@ describe("createWebSocketPeer", () => {
611
717
  });
612
718
 
613
719
  test("should drain the outgoing queue on websocket close so pulled equals pushed", async () => {
614
- setOutgoingMessagesChunkDelay(500);
615
720
  const metricReader = createTestMetricReader();
616
- const { peer, listeners } = setup();
721
+ const { peer, triggerEvent } = setup({ initialReadyState: 0 });
617
722
 
618
723
  const high: SyncMessage = {
619
724
  action: "content",
@@ -657,12 +762,14 @@ describe("createWebSocketPeer", () => {
657
762
  }),
658
763
  ).toBe(1);
659
764
 
765
+ // First message is already pulled by processQueue (waiting for socket open),
766
+ // so pulled count for that priority is already 1
660
767
  expect(
661
768
  await metricReader.getMetricValue("jazz.messagequeue.outgoing.pulled", {
662
769
  priority: CO_VALUE_PRIORITY.HIGH,
663
770
  peerRole: "client",
664
771
  }),
665
- ).toBe(0);
772
+ ).toBe(1);
666
773
  expect(
667
774
  await metricReader.getMetricValue("jazz.messagequeue.outgoing.pulled", {
668
775
  priority: CO_VALUE_PRIORITY.MEDIUM,
@@ -676,9 +783,9 @@ describe("createWebSocketPeer", () => {
676
783
  }),
677
784
  ).toBe(0);
678
785
 
679
- const closeHandler = listeners.get("close");
680
- closeHandler?.(new MessageEvent("close"));
786
+ triggerEvent("close");
681
787
 
788
+ // After close, drain() is called which pulls all remaining messages
682
789
  expect(
683
790
  await metricReader.getMetricValue("jazz.messagequeue.outgoing.pulled", {
684
791
  priority: CO_VALUE_PRIORITY.HIGH,
@@ -697,8 +804,6 @@ describe("createWebSocketPeer", () => {
697
804
  peerRole: "client",
698
805
  }),
699
806
  ).toBe(1);
700
-
701
- vi.useRealTimers();
702
807
  });
703
808
  });
704
809
  });