sonamu 0.11.16 → 0.12.0

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 (65) hide show
  1. package/dist/api/sonamu.d.ts.map +1 -1
  2. package/dist/api/sonamu.js +12 -7
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -2
  6. package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
  7. package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
  8. package/dist/stream/__tests__/ws-test-helpers.js +260 -0
  9. package/dist/stream/index.d.ts +1 -0
  10. package/dist/stream/index.d.ts.map +1 -1
  11. package/dist/stream/index.js +4 -2
  12. package/dist/stream/ws-audience-resolver.d.ts +8 -5
  13. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  14. package/dist/stream/ws-audience-resolver.js +16 -13
  15. package/dist/stream/ws-cluster-bus.d.ts +9 -4
  16. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  17. package/dist/stream/ws-cluster-bus.js +1 -1
  18. package/dist/stream/ws-core.d.ts +10 -0
  19. package/dist/stream/ws-core.d.ts.map +1 -1
  20. package/dist/stream/ws-delivery.d.ts +30 -4
  21. package/dist/stream/ws-delivery.d.ts.map +1 -1
  22. package/dist/stream/ws-delivery.js +199 -52
  23. package/dist/stream/ws-frame.d.ts +15 -0
  24. package/dist/stream/ws-frame.d.ts.map +1 -0
  25. package/dist/stream/ws-frame.js +29 -0
  26. package/dist/stream/ws-local-connection-store.d.ts +4 -4
  27. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  28. package/dist/stream/ws-local-connection-store.js +2 -2
  29. package/dist/stream/ws-presence-index.d.ts +36 -0
  30. package/dist/stream/ws-presence-index.d.ts.map +1 -0
  31. package/dist/stream/ws-presence-index.js +177 -0
  32. package/dist/stream/ws-presence-store.d.ts +39 -39
  33. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  34. package/dist/stream/ws-presence-store.js +22 -152
  35. package/dist/stream/ws-registry.d.ts +45 -17
  36. package/dist/stream/ws-registry.d.ts.map +1 -1
  37. package/dist/stream/ws-registry.js +169 -60
  38. package/dist/stream/ws-session-id.d.ts +14 -0
  39. package/dist/stream/ws-session-id.d.ts.map +1 -0
  40. package/dist/stream/ws-session-id.js +37 -0
  41. package/dist/stream/ws.d.ts +20 -10
  42. package/dist/stream/ws.d.ts.map +1 -1
  43. package/dist/stream/ws.js +59 -36
  44. package/package.json +1 -1
  45. package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
  46. package/src/api/__tests__/sonamu.websocket.test.ts +241 -20
  47. package/src/api/sonamu.ts +20 -7
  48. package/src/index.ts +1 -0
  49. package/src/stream/__tests__/ws-contracts.test.ts +483 -265
  50. package/src/stream/__tests__/ws-multinode.test.ts +326 -0
  51. package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
  52. package/src/stream/__tests__/ws-test-helpers.ts +360 -0
  53. package/src/stream/__tests__/ws.test.ts +342 -145
  54. package/src/stream/index.ts +1 -0
  55. package/src/stream/ws-audience-resolver.ts +21 -19
  56. package/src/stream/ws-cluster-bus.ts +12 -4
  57. package/src/stream/ws-core.ts +14 -0
  58. package/src/stream/ws-delivery.ts +321 -97
  59. package/src/stream/ws-frame.ts +39 -0
  60. package/src/stream/ws-local-connection-store.ts +10 -9
  61. package/src/stream/ws-presence-index.ts +273 -0
  62. package/src/stream/ws-presence-store.ts +64 -284
  63. package/src/stream/ws-registry.ts +272 -85
  64. package/src/stream/ws-session-id.ts +39 -0
  65. package/src/stream/ws.ts +91 -50
@@ -1,97 +1,20 @@
1
- import { EventEmitter } from "node:events";
2
-
3
1
  import { describe, expect, it, vi } from "vitest";
4
2
  import { z } from "zod";
5
3
 
6
- import { createWebSocketRuntime, type WebSocketTransport } from "../ws";
4
+ import { createWebSocketRuntime } from "../ws";
7
5
  import { WebSocketAudience } from "../ws-audience";
8
- import {
9
- type WebSocketClusterBus,
10
- type WebSocketClusterEnvelope,
11
- type WebSocketClusterEnvelopeHandler,
12
- } from "../ws-cluster-bus";
13
6
  import { InMemoryWebSocketPresenceStore } from "../ws-presence-store";
7
+ import {
8
+ FakeWebSocket,
9
+ RecordingClusterBus,
10
+ asWebSocket,
11
+ collectUnhandledRejections,
12
+ flushAsync,
13
+ parseFrames,
14
+ } from "./ws-test-helpers";
14
15
 
15
16
  async function waitForAsyncQueue(rounds: number = 3): Promise<void> {
16
- for (let index = 0; index < rounds; index += 1) {
17
- await new Promise<void>((resolve) => {
18
- setImmediate(resolve);
19
- });
20
- }
21
- }
22
-
23
- class FakeWebSocket extends EventEmitter {
24
- readyState: 0 | 1 | 2 | 3 = 1;
25
- bufferedAmount = 0;
26
- sentMessages: string[] = [];
27
- pingCount = 0;
28
- closedWith: { code?: number; reason?: string } | null = null;
29
-
30
- send(data: string): void {
31
- this.sentMessages.push(data);
32
- }
33
-
34
- close(code?: number, reason?: string): void {
35
- this.closedWith = { code, reason };
36
- this.readyState = 3;
37
- this.emit("close", code ?? 1000, Buffer.from(reason ?? "", "utf-8"));
38
- }
39
-
40
- ping(): void {
41
- this.pingCount += 1;
42
- }
43
-
44
- terminate(): void {
45
- this.readyState = 3;
46
- this.emit("close", 1006, Buffer.alloc(0));
47
- }
48
- }
49
-
50
- function asWebSocket(socket: FakeWebSocket): WebSocketTransport {
51
- return {
52
- get bufferedAmount() {
53
- return socket.bufferedAmount;
54
- },
55
- get readyState() {
56
- return socket.readyState;
57
- },
58
- close(code, reason) {
59
- socket.close(code, reason?.toString());
60
- },
61
- off(...args) {
62
- socket.off(...args);
63
- },
64
- on(...args) {
65
- socket.on(...args);
66
- },
67
- ping() {
68
- socket.ping();
69
- },
70
- send(data) {
71
- socket.send(data.toString());
72
- },
73
- terminate() {
74
- socket.terminate();
75
- },
76
- };
77
- }
78
-
79
- class FakeClusterBus implements WebSocketClusterBus {
80
- readonly published: WebSocketClusterEnvelope[] = [];
81
- private readonly handlers = new Set<WebSocketClusterEnvelopeHandler>();
82
-
83
- publish(envelope: WebSocketClusterEnvelope): void {
84
- this.published.push(envelope);
85
- }
86
-
87
- subscribe(handler: WebSocketClusterEnvelopeHandler): () => void {
88
- this.handlers.add(handler);
89
- return () => {
90
- this.handlers.delete(handler);
91
- };
92
- }
93
-
94
- shutdown(): void {}
17
+ await flushAsync(rounds);
95
18
  }
96
19
 
97
20
  describe("WebSocketRuntime", () => {
@@ -117,7 +40,7 @@ describe("WebSocketRuntime", () => {
117
40
  it("buffers early messages until handlers are attached", async () => {
118
41
  const runtime = createWebSocketRuntime();
119
42
  const socket = new FakeWebSocket();
120
- const connection = runtime.registerConnection(asWebSocket(socket), {
43
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
121
44
  outEvents: OutEvents,
122
45
  inEvents: InEvents,
123
46
  namespace: "chat",
@@ -143,7 +66,7 @@ describe("WebSocketRuntime", () => {
143
66
  it("passes message trace context from meta.traceparent to handlers", async () => {
144
67
  const runtime = createWebSocketRuntime({ telemetry: true });
145
68
  const socket = new FakeWebSocket();
146
- const connection = runtime.registerConnection(asWebSocket(socket), {
69
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
147
70
  outEvents: OutEvents,
148
71
  inEvents: InEvents,
149
72
  namespace: "chat",
@@ -185,7 +108,7 @@ describe("WebSocketRuntime", () => {
185
108
  telemetry: { trace: { propagateMessageTrace: false } },
186
109
  });
187
110
  const socket = new FakeWebSocket();
188
- const connection = runtime.registerConnection(asWebSocket(socket), {
111
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
189
112
  outEvents: OutEvents,
190
113
  inEvents: InEvents,
191
114
  namespace: "chat",
@@ -225,12 +148,12 @@ describe("WebSocketRuntime", () => {
225
148
  });
226
149
  });
227
150
 
228
- it("records ok status for normal close codes", () => {
151
+ it("records ok status for normal close codes", async () => {
229
152
  const runtime = createWebSocketRuntime({
230
153
  telemetry: { trace: { recordConnectionLifetimeSpan: true } },
231
154
  });
232
155
  const socket = new FakeWebSocket();
233
- const connection = runtime.registerConnection(asWebSocket(socket), {
156
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
234
157
  outEvents: OutEvents,
235
158
  inEvents: InEvents,
236
159
  namespace: "chat",
@@ -260,12 +183,12 @@ describe("WebSocketRuntime", () => {
260
183
  expect(lifetimeSpans[0].durationMs).toBeGreaterThanOrEqual(0);
261
184
  });
262
185
 
263
- it("records error status for non-normal close codes", () => {
186
+ it("records error status for non-normal close codes", async () => {
264
187
  const runtime = createWebSocketRuntime({
265
188
  telemetry: { trace: { recordConnectionLifetimeSpan: true } },
266
189
  });
267
190
  const socket = new FakeWebSocket();
268
- const connection = runtime.registerConnection(asWebSocket(socket), {
191
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
269
192
  outEvents: OutEvents,
270
193
  inEvents: InEvents,
271
194
  namespace: "chat",
@@ -287,12 +210,12 @@ describe("WebSocketRuntime", () => {
287
210
  expect(lifetimeSpans[0].durationMs).toBeGreaterThanOrEqual(0);
288
211
  });
289
212
 
290
- it("records unset status when close code is unknown", () => {
213
+ it("records unset status when close code is unknown", async () => {
291
214
  const runtime = createWebSocketRuntime({
292
215
  telemetry: { trace: { recordConnectionLifetimeSpan: true } },
293
216
  });
294
217
  const socket = new FakeWebSocket();
295
- const connection = runtime.registerConnection(asWebSocket(socket), {
218
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
296
219
  outEvents: OutEvents,
297
220
  inEvents: InEvents,
298
221
  namespace: "chat",
@@ -330,7 +253,7 @@ describe("WebSocketRuntime", () => {
330
253
  traceparent: "00-4bf92f3577b16d8a2e3e24ff02e6c998-00f067aa0ba902b7-01",
331
254
  },
332
255
  });
333
- const connection = runtime.registerConnection(asWebSocket(socket), {
256
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
334
257
  outEvents: OutEvents,
335
258
  inEvents: InEvents,
336
259
  namespace: "chat",
@@ -373,14 +296,16 @@ describe("WebSocketRuntime", () => {
373
296
  expect(capturedTrace?.spanId).toMatch(/^[0-9a-f]{16}$/);
374
297
  });
375
298
 
376
- it("emits skipped registry mutation telemetry for missing connections", () => {
299
+ it("emits skipped registry mutation telemetry for missing connections", async () => {
377
300
  const runtime = createWebSocketRuntime({ telemetry: true });
378
301
 
379
- runtime.activateConnection("missing-connection");
380
- runtime.registry.join("missing-connection", "room-1");
381
- runtime.registry.leave("missing-connection", "room-1");
382
- runtime.registry.setUserId("missing-connection", "user-1");
383
- runtime.registry.clearUserId("missing-connection");
302
+ await expect(runtime.activateConnection("missing-connection")).resolves.toBeUndefined();
303
+ await expect(runtime.registry.join("missing-connection", "room-1")).resolves.toBeUndefined();
304
+ await expect(runtime.registry.leave("missing-connection", "room-1")).resolves.toBeUndefined();
305
+ await expect(
306
+ runtime.registry.setUserId("missing-connection", "user-1"),
307
+ ).resolves.toBeUndefined();
308
+ await expect(runtime.registry.clearUserId("missing-connection")).resolves.toBeUndefined();
384
309
 
385
310
  const store = runtime.telemetryController.getEventStore();
386
311
  const skippedEvents = store?.query({ name: "ws.registry.mutation.skipped" }) ?? [];
@@ -428,14 +353,14 @@ describe("WebSocketRuntime", () => {
428
353
  it("keeps inactive connections out of registry fan-out until activated", async () => {
429
354
  const runtime = createWebSocketRuntime();
430
355
  const socket = new FakeWebSocket();
431
- const connection = runtime.registerConnection(asWebSocket(socket), {
356
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
432
357
  outEvents: OutEvents,
433
358
  inEvents: InEvents,
434
359
  namespace: "chat",
435
360
  active: false,
436
361
  });
437
362
 
438
- runtime.broadcast("onReady", {
363
+ await runtime.broadcast("onReady", {
439
364
  ok: true,
440
365
  });
441
366
  await waitForAsyncQueue();
@@ -443,8 +368,8 @@ describe("WebSocketRuntime", () => {
443
368
  expect(socket.sentMessages).toHaveLength(0);
444
369
  expect(runtime.registry.getConnectionCount("chat")).toBe(0);
445
370
 
446
- runtime.activateConnection(connection.id);
447
- runtime.broadcast("onReady", {
371
+ await runtime.activateConnection(connection.id);
372
+ await runtime.broadcast("onReady", {
448
373
  ok: true,
449
374
  });
450
375
  await waitForAsyncQueue();
@@ -457,26 +382,26 @@ describe("WebSocketRuntime", () => {
457
382
  const runtime = createWebSocketRuntime();
458
383
  const socketA = new FakeWebSocket();
459
384
  const socketB = new FakeWebSocket();
460
- const connectionA = runtime.registerConnection(asWebSocket(socketA), {
385
+ const connectionA = await runtime.registerConnection(asWebSocket(socketA), {
461
386
  outEvents: OutEvents,
462
387
  inEvents: InEvents,
463
388
  namespace: "chat",
464
389
  });
465
- const connectionB = runtime.registerConnection(asWebSocket(socketB), {
390
+ const connectionB = await runtime.registerConnection(asWebSocket(socketB), {
466
391
  outEvents: OutEvents,
467
392
  inEvents: InEvents,
468
393
  namespace: "chat",
469
394
  });
470
395
 
471
- connectionA.join("room-1");
472
- connectionB.join("room-1");
473
- connectionA.setUserId("user-1");
396
+ await connectionA.join("room-1");
397
+ await connectionB.join("room-1");
398
+ await connectionA.setUserId("user-1");
474
399
 
475
- runtime.registry.publishToRoom("room-1", "onRoomMessage", {
400
+ await runtime.registry.publishToRoom("room-1", "onRoomMessage", {
476
401
  roomId: "room-1",
477
402
  text: "hello",
478
403
  });
479
- runtime.registry.publishToUser("user-1", "onReady", {
404
+ await runtime.registry.publishToUser("user-1", "onReady", {
480
405
  ok: true,
481
406
  });
482
407
 
@@ -496,7 +421,7 @@ describe("WebSocketRuntime", () => {
496
421
  it("emits runtime telemetry for room/user bindings and outbound queue flow", async () => {
497
422
  const runtime = createWebSocketRuntime({ telemetry: true });
498
423
  const socket = new FakeWebSocket();
499
- const connection = runtime.registerConnection(asWebSocket(socket), {
424
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
500
425
  outEvents: OutEvents,
501
426
  inEvents: InEvents,
502
427
  namespace: "chat",
@@ -504,10 +429,10 @@ describe("WebSocketRuntime", () => {
504
429
  spanId: "2222222222222222",
505
430
  });
506
431
 
507
- connection.join("room-1");
508
- connection.leave("room-1");
509
- connection.setUserId("user-1");
510
- connection.clearUserId();
432
+ await connection.join("room-1");
433
+ await connection.leave("room-1");
434
+ await connection.setUserId("user-1");
435
+ await connection.clearUserId();
511
436
 
512
437
  expect(() => connection.publishUntyped("missing", {})).toThrow("Unknown websocket event");
513
438
  expect(() => connection.publishUntyped("onReady", { ok: "nope" })).toThrow(
@@ -541,21 +466,21 @@ describe("WebSocketRuntime", () => {
541
466
  const runtime = createWebSocketRuntime();
542
467
  const chatSocket = new FakeWebSocket();
543
468
  const otherSocket = new FakeWebSocket();
544
- const chatConnection = runtime.registerConnection(asWebSocket(chatSocket), {
469
+ const chatConnection = await runtime.registerConnection(asWebSocket(chatSocket), {
545
470
  outEvents: OutEvents,
546
471
  inEvents: InEvents,
547
472
  namespace: "chat",
548
473
  });
549
- const otherConnection = runtime.registerConnection(asWebSocket(otherSocket), {
474
+ const otherConnection = await runtime.registerConnection(asWebSocket(otherSocket), {
550
475
  outEvents: OutEvents,
551
476
  inEvents: InEvents,
552
477
  namespace: "other",
553
478
  });
554
479
 
555
- chatConnection.join("room-1");
556
- otherConnection.join("room-1");
480
+ await chatConnection.join("room-1");
481
+ await otherConnection.join("room-1");
557
482
 
558
- runtime.registry.publishToRoom(
483
+ await runtime.registry.publishToRoom(
559
484
  "room-1",
560
485
  "onRoomMessage",
561
486
  {
@@ -575,16 +500,16 @@ describe("WebSocketRuntime", () => {
575
500
  it("dedupes union audiences before delivery", async () => {
576
501
  const runtime = createWebSocketRuntime();
577
502
  const socket = new FakeWebSocket();
578
- const connection = runtime.registerConnection(asWebSocket(socket), {
503
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
579
504
  outEvents: OutEvents,
580
505
  inEvents: InEvents,
581
506
  namespace: "chat",
582
507
  });
583
508
 
584
- connection.join("room-1");
585
- connection.setUserId("user-1");
509
+ await connection.join("room-1");
510
+ await connection.setUserId("user-1");
586
511
 
587
- runtime.publishToAudience(
512
+ await runtime.publishToAudience(
588
513
  WebSocketAudience.union(
589
514
  WebSocketAudience.room("room-1", "chat"),
590
515
  WebSocketAudience.user("user-1", "chat"),
@@ -600,24 +525,26 @@ describe("WebSocketRuntime", () => {
600
525
  expect(socket.sentMessages).toHaveLength(1);
601
526
  });
602
527
 
603
- it("publishes a cluster envelope for remote-node audiences", () => {
528
+ it("publishes a cluster envelope for remote-node audiences", async () => {
604
529
  const presenceStore = new InMemoryWebSocketPresenceStore();
605
- const clusterBus = new FakeClusterBus();
530
+ const clusterBus = new RecordingClusterBus();
606
531
  const runtime = createWebSocketRuntime({
607
532
  nodeId: "local-node",
608
533
  presenceStore,
609
534
  clusterBus,
610
535
  });
536
+ runtime.registerRoute("/chat", { outEvents: OutEvents });
611
537
 
612
- presenceStore.register({
538
+ await presenceStore.register({
613
539
  sessionId: "remote-1",
614
540
  nodeId: "remote-node",
615
541
  namespace: "chat",
616
542
  active: true,
543
+ connectedAt: new Date(),
617
544
  });
618
- presenceStore.join("remote-1", "room-remote");
545
+ await presenceStore.join("remote-1", "room-remote");
619
546
 
620
- runtime.publishToRoom(
547
+ await runtime.publishToRoom(
621
548
  "room-remote",
622
549
  "onRoomMessage",
623
550
  {
@@ -633,12 +560,16 @@ describe("WebSocketRuntime", () => {
633
560
  targetNodeIds: ["remote-node"],
634
561
  event: "onRoomMessage",
635
562
  });
563
+ expect(JSON.parse(clusterBus.published[0].frames["/chat"])).toEqual({
564
+ event: "onRoomMessage",
565
+ data: { roomId: "room-remote", text: "hello remote" },
566
+ });
636
567
  });
637
568
 
638
569
  it("closes invalid inbound payloads", async () => {
639
570
  const runtime = createWebSocketRuntime();
640
571
  const socket = new FakeWebSocket();
641
- runtime.registerConnection(asWebSocket(socket), {
572
+ await runtime.registerConnection(asWebSocket(socket), {
642
573
  outEvents: OutEvents,
643
574
  inEvents: InEvents,
644
575
  namespace: "chat",
@@ -658,10 +589,10 @@ describe("WebSocketRuntime", () => {
658
589
  expect(socket.closedWith?.code).toBe(1007);
659
590
  });
660
591
 
661
- it("still closes and unregisters when onClose callbacks throw", () => {
592
+ it("still closes and unregisters when onClose callbacks throw", async () => {
662
593
  const runtime = createWebSocketRuntime();
663
594
  const socket = new FakeWebSocket();
664
- const connection = runtime.registerConnection(asWebSocket(socket), {
595
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
665
596
  outEvents: OutEvents,
666
597
  inEvents: InEvents,
667
598
  namespace: "chat",
@@ -683,7 +614,7 @@ describe("WebSocketRuntime", () => {
683
614
  it("swallows rejected async onClose callbacks without leaking unhandled rejections", async () => {
684
615
  const runtime = createWebSocketRuntime();
685
616
  const socket = new FakeWebSocket();
686
- const connection = runtime.registerConnection(asWebSocket(socket), {
617
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
687
618
  outEvents: OutEvents,
688
619
  inEvents: InEvents,
689
620
  namespace: "chat",
@@ -707,10 +638,10 @@ describe("WebSocketRuntime", () => {
707
638
  expect(runtime.registry.getConnectionCount("chat")).toBe(0);
708
639
  });
709
640
 
710
- it("closes the transport when the socket emits an error", () => {
641
+ it("closes the transport when the socket emits an error", async () => {
711
642
  const runtime = createWebSocketRuntime();
712
643
  const socket = new FakeWebSocket();
713
- runtime.registerConnection(asWebSocket(socket), {
644
+ await runtime.registerConnection(asWebSocket(socket), {
714
645
  outEvents: OutEvents,
715
646
  inEvents: InEvents,
716
647
  namespace: "chat",
@@ -728,7 +659,7 @@ describe("WebSocketRuntime", () => {
728
659
  it("closes the connection when a buffered handler rejects after registration", async () => {
729
660
  const runtime = createWebSocketRuntime();
730
661
  const socket = new FakeWebSocket();
731
- const connection = runtime.registerConnection(asWebSocket(socket), {
662
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
732
663
  outEvents: OutEvents,
733
664
  inEvents: InEvents,
734
665
  namespace: "chat",
@@ -751,11 +682,11 @@ describe("WebSocketRuntime", () => {
751
682
  expect(socket.closedWith?.code).toBe(1011);
752
683
  });
753
684
 
754
- it("closes slow consumers when outbound backpressure keeps growing", () => {
685
+ it("closes slow consumers when outbound backpressure keeps growing", async () => {
755
686
  const runtime = createWebSocketRuntime();
756
687
  const socket = new FakeWebSocket();
757
688
  socket.bufferedAmount = 2_000_000;
758
- const connection = runtime.registerConnection(asWebSocket(socket), {
689
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
759
690
  outEvents: OutEvents,
760
691
  inEvents: InEvents,
761
692
  namespace: "chat",
@@ -774,7 +705,7 @@ describe("WebSocketRuntime", () => {
774
705
  const runtime = createWebSocketRuntime({ telemetry: true });
775
706
  const socket = new FakeWebSocket();
776
707
  socket.bufferedAmount = 2_000_000;
777
- const connection = runtime.registerConnection(asWebSocket(socket), {
708
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
778
709
  outEvents: OutEvents,
779
710
  inEvents: InEvents,
780
711
  namespace: "chat",
@@ -791,7 +722,7 @@ describe("WebSocketRuntime", () => {
791
722
  await Promise.resolve();
792
723
 
793
724
  connection.publish("onReady", { ok: true });
794
- runtime.broadcast("onReady", { ok: true }, "chat");
725
+ await runtime.broadcast("onReady", { ok: true }, "chat");
795
726
 
796
727
  const snapshot = runtime.telemetryController.getMetricsSnapshot();
797
728
 
@@ -807,7 +738,7 @@ describe("WebSocketRuntime", () => {
807
738
  const sockets = Array.from({ length: 300 }, () => new FakeWebSocket());
808
739
 
809
740
  for (const socket of sockets) {
810
- runtime.registerConnection(asWebSocket(socket), {
741
+ await runtime.registerConnection(asWebSocket(socket), {
811
742
  outEvents: OutEvents,
812
743
  inEvents: InEvents,
813
744
  namespace: "chat",
@@ -830,4 +761,270 @@ describe("WebSocketRuntime", () => {
830
761
  expect(JSON.parse(sockets.at(-1)!.sentMessages[0]).data).toEqual({ ok: true });
831
762
  expect(JSON.parse(sockets.at(-1)!.sentMessages[1]).data).toEqual({ ok: false });
832
763
  });
764
+
765
+ it("await 없이 join한 직후의 publish도 해당 연결에 전달된다", async () => {
766
+ const runtime = createWebSocketRuntime();
767
+ const socket = new FakeWebSocket();
768
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
769
+ outEvents: OutEvents,
770
+ inEvents: InEvents,
771
+ namespace: "chat",
772
+ heartbeat: 0,
773
+ });
774
+
775
+ // 메모리 갱신은 호출 시점에 동기로 일어나야 한다
776
+ void connection.join("room-1");
777
+ expect(runtime.registry.getRoomMembers("room-1", "chat").map((meta) => meta.sessionId)).toEqual(
778
+ [connection.id],
779
+ );
780
+ await runtime.publishToRoom(
781
+ "room-1",
782
+ "onRoomMessage",
783
+ { roomId: "room-1", text: "hi" },
784
+ "chat",
785
+ );
786
+ await waitForAsyncQueue();
787
+
788
+ expect(parseFrames(socket)).toEqual([
789
+ { event: "onRoomMessage", data: { roomId: "room-1", text: "hi" } },
790
+ ]);
791
+ });
792
+
793
+ it("mutation과 publish 메서드는 Promise<void>를 반환한다", async () => {
794
+ const runtime = createWebSocketRuntime();
795
+ const socket = new FakeWebSocket();
796
+ const registering = runtime.registerConnection(asWebSocket(socket), {
797
+ outEvents: OutEvents,
798
+ inEvents: InEvents,
799
+ namespace: "chat",
800
+ active: false,
801
+ heartbeat: 0,
802
+ });
803
+ expect(registering).toBeInstanceOf(Promise);
804
+ const connection = await registering;
805
+
806
+ const results = [
807
+ runtime.activateConnection(connection.id),
808
+ connection.join("room-1"),
809
+ connection.leave("room-1"),
810
+ connection.setUserId(1),
811
+ connection.clearUserId(),
812
+ runtime.broadcast("onReady", { ok: true }),
813
+ runtime.publishToRoom("room-1", "onReady", { ok: true }),
814
+ runtime.publishToUser(1, "onReady", { ok: true }),
815
+ runtime.publishToAudience(WebSocketAudience.all(), "onReady", { ok: true }),
816
+ runtime.registry.broadcast("onReady", { ok: true }),
817
+ runtime.registry.publishToRoom("room-1", "onReady", { ok: true }),
818
+ runtime.registry.publishToUser(1, "onReady", { ok: true }),
819
+ runtime.registry.publishToAudience(WebSocketAudience.all(), "onReady", { ok: true }),
820
+ ];
821
+
822
+ for (const result of results) {
823
+ expect(result).toBeInstanceOf(Promise);
824
+ }
825
+ await expect(Promise.all(results)).resolves.toEqual(results.map(() => undefined));
826
+ });
827
+
828
+ it("userId는 타입까지 일치해야 publishToUser 대상이 된다", async () => {
829
+ const runtime = createWebSocketRuntime();
830
+ const socket = new FakeWebSocket();
831
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
832
+ outEvents: OutEvents,
833
+ inEvents: InEvents,
834
+ namespace: "chat",
835
+ heartbeat: 0,
836
+ });
837
+ await connection.setUserId(1);
838
+
839
+ await runtime.publishToUser("1", "onReady", { ok: false });
840
+ await waitForAsyncQueue();
841
+ expect(socket.sentMessages).toHaveLength(0);
842
+
843
+ await runtime.publishToUser(1, "onReady", { ok: true });
844
+ await waitForAsyncQueue();
845
+ expect(parseFrames(socket)).toEqual([{ event: "onReady", data: { ok: true } }]);
846
+ expect(runtime.registry.getConnection(connection.id)?.userId).toBe(1);
847
+ });
848
+
849
+ it("registry/runtime publish의 잘못된 payload와 모르는 이벤트는 resolve하고 아무도 닫지 않는다", async () => {
850
+ const runtime = createWebSocketRuntime({ telemetry: true });
851
+ const socket = new FakeWebSocket();
852
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
853
+ outEvents: OutEvents,
854
+ inEvents: InEvents,
855
+ namespace: "chat",
856
+ heartbeat: 0,
857
+ });
858
+ await connection.join("room-1");
859
+ await connection.setUserId("user-1");
860
+ const collect = collectUnhandledRejections();
861
+
862
+ await expect(
863
+ runtime.publishToRoom("room-1", "onReady", { ok: "not-a-boolean" }, "chat"),
864
+ ).resolves.toBeUndefined();
865
+ await expect(runtime.broadcast("onUnknown", { ok: true }, "chat")).resolves.toBeUndefined();
866
+ await expect(
867
+ runtime.registry.publishToUser("user-1", "onReady", { ok: "still-wrong" }),
868
+ ).resolves.toBeUndefined();
869
+ await waitForAsyncQueue(6);
870
+ const unhandled = collect();
871
+
872
+ expect(unhandled).toEqual([]);
873
+ expect(socket.sentMessages).toHaveLength(0);
874
+ expect(socket.closedWith).toBeNull();
875
+ expect(connection.closed).toBe(false);
876
+ const events = runtime.telemetryController.getEventStore()?.query({}) ?? [];
877
+ const rejected = events.filter((record) => record.name === "ws.publish.rejected");
878
+ expect(rejected.map((record) => record.detail)).toEqual(
879
+ expect.arrayContaining([
880
+ expect.objectContaining({ event: "onReady", reason: "invalidPayload" }),
881
+ expect.objectContaining({ event: "onUnknown", reason: "unknownEvent" }),
882
+ ]),
883
+ );
884
+ expect(events.some((record) => record.name === "ws.fanout.publish.failed")).toBe(false);
885
+
886
+ // 잘못된 publish 뒤에도 정상 publish는 그대로 전달된다
887
+ await runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat");
888
+ await waitForAsyncQueue();
889
+ expect(parseFrames(socket)).toEqual([{ event: "onReady", data: { ok: true } }]);
890
+ });
891
+
892
+ it("직접 connection.publish는 잘못된 payload와 모르는 이벤트에 동기로 throw한다", async () => {
893
+ const runtime = createWebSocketRuntime();
894
+ const socket = new FakeWebSocket();
895
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
896
+ outEvents: OutEvents,
897
+ inEvents: InEvents,
898
+ namespace: "chat",
899
+ heartbeat: 0,
900
+ });
901
+
902
+ expect(() => connection.publishUntyped("onReady", { ok: "nope" })).toThrow(
903
+ "Invalid websocket event payload",
904
+ );
905
+ expect(() => connection.publishUntyped("missing", {})).toThrow("Unknown websocket event");
906
+ });
907
+
908
+ it("같은 namespace의 두 route가 같은 이벤트를 다른 스키마로 선언하면 각 연결은 자기 route의 frame을 받고, 선언하지 않은 route는 건너뛴다", async () => {
909
+ const runtime = createWebSocketRuntime({ telemetry: true });
910
+ const RouteAEvents = z.object({ onNotice: z.object({ value: z.number() }) });
911
+ const RouteBEvents = z.object({
912
+ onNotice: z.object({ value: z.number().max(10), label: z.string().default("route-b") }),
913
+ });
914
+ const RouteCEvents = z.object({ onOther: z.object({ value: z.number() }) });
915
+ runtime.registerRoute("/a", { outEvents: RouteAEvents });
916
+ runtime.registerRoute("/b", { outEvents: RouteBEvents });
917
+ runtime.registerRoute("/c", { outEvents: RouteCEvents });
918
+ const sockets = { a: new FakeWebSocket(), b: new FakeWebSocket(), c: new FakeWebSocket() };
919
+ const connectionA = await runtime.registerConnection(asWebSocket(sockets.a), {
920
+ routeKey: "/a",
921
+ outEvents: RouteAEvents,
922
+ inEvents: InEvents,
923
+ namespace: "chat",
924
+ heartbeat: 0,
925
+ });
926
+ const connectionB = await runtime.registerConnection(asWebSocket(sockets.b), {
927
+ routeKey: "/b",
928
+ outEvents: RouteBEvents,
929
+ inEvents: InEvents,
930
+ namespace: "chat",
931
+ heartbeat: 0,
932
+ });
933
+ const connectionC = await runtime.registerConnection(asWebSocket(sockets.c), {
934
+ routeKey: "/c",
935
+ outEvents: RouteCEvents,
936
+ inEvents: InEvents,
937
+ namespace: "chat",
938
+ heartbeat: 0,
939
+ });
940
+
941
+ await runtime.broadcast("onNotice", { value: 1, extra: "stripped" }, "chat");
942
+ await waitForAsyncQueue();
943
+
944
+ expect(parseFrames(sockets.a)).toEqual([{ event: "onNotice", data: { value: 1 } }]);
945
+ expect(parseFrames(sockets.b)).toEqual([
946
+ { event: "onNotice", data: { value: 1, label: "route-b" } },
947
+ ]);
948
+ expect(sockets.c.sentMessages).toEqual([]);
949
+ for (const connection of [connectionA, connectionB, connectionC]) {
950
+ expect(connection.closed).toBe(false);
951
+ }
952
+
953
+ // route B의 스키마만 거부하는 payload: route A에는 전달되고 route B는 건너뛰며 아무도 닫히지 않는다
954
+ await runtime.broadcast("onNotice", { value: 100 }, "chat");
955
+ await waitForAsyncQueue();
956
+
957
+ expect(parseFrames(sockets.a)).toEqual([
958
+ { event: "onNotice", data: { value: 1 } },
959
+ { event: "onNotice", data: { value: 100 } },
960
+ ]);
961
+ expect(sockets.b.sentMessages).toHaveLength(1);
962
+ expect(sockets.c.sentMessages).toHaveLength(0);
963
+ for (const socket of Object.values(sockets)) {
964
+ expect(socket.closedWith).toBeNull();
965
+ }
966
+ const rejected =
967
+ runtime.telemetryController.getEventStore()?.query({ name: "ws.publish.rejected" }) ?? [];
968
+ expect(rejected.map((record) => record.detail)).toEqual([
969
+ expect.objectContaining({ event: "onNotice", reason: "invalidPayload" }),
970
+ ]);
971
+ });
972
+
973
+ it("같은 route의 연결들은 바이트가 동일한 frame을 받는다", async () => {
974
+ const runtime = createWebSocketRuntime();
975
+ const StampEvents = z.object({ onStamp: z.object({ at: z.date() }) });
976
+ runtime.registerRoute("/stamp", { outEvents: StampEvents });
977
+ const sockets = [new FakeWebSocket(), new FakeWebSocket()];
978
+ for (const socket of sockets) {
979
+ await runtime.registerConnection(asWebSocket(socket), {
980
+ routeKey: "/stamp",
981
+ outEvents: StampEvents,
982
+ inEvents: InEvents,
983
+ namespace: "chat",
984
+ heartbeat: 0,
985
+ });
986
+ }
987
+
988
+ await runtime.broadcast("onStamp", { at: new Date("2026-01-02T03:04:05.000Z") }, "chat");
989
+ await waitForAsyncQueue();
990
+
991
+ expect(sockets[0].sentMessages).toHaveLength(1);
992
+ expect(sockets[1].sentMessages).toEqual(sockets[0].sentMessages);
993
+ expect(parseFrames(sockets[0])).toEqual([
994
+ { event: "onStamp", data: { at: "2026-01-02T03:04:05.000Z" } },
995
+ ]);
996
+ });
997
+
998
+ it("registry 읽기 API는 내부 상태와 분리된 snapshot을 반환한다", async () => {
999
+ const runtime = createWebSocketRuntime();
1000
+ const socket = new FakeWebSocket();
1001
+ const connection = await runtime.registerConnection(asWebSocket(socket), {
1002
+ outEvents: OutEvents,
1003
+ inEvents: InEvents,
1004
+ namespace: "chat",
1005
+ heartbeat: 0,
1006
+ });
1007
+ await connection.join("room-1");
1008
+
1009
+ const meta = runtime.registry.getConnection(connection.id);
1010
+ meta!.rooms.add("room-hacked");
1011
+ meta!.rooms.delete("room-1");
1012
+ meta!.active = false;
1013
+ const [member] = runtime.registry.getRoomMembers("room-1", "chat");
1014
+ member.rooms.clear();
1015
+ const stats = runtime.registry.getStats();
1016
+ stats.byNamespace.chat = 999;
1017
+
1018
+ const fresh = runtime.registry.getConnection(connection.id);
1019
+ expect([...fresh!.rooms]).toEqual(["room-1"]);
1020
+ expect(fresh!.active).toBe(true);
1021
+ expect(runtime.registry.getRoomMembers("room-hacked")).toHaveLength(0);
1022
+ expect(runtime.registry.getRoomMembers("room-1", "chat")).toHaveLength(1);
1023
+ expect(runtime.registry.getConnectionCount("chat")).toBe(1);
1024
+ expect(runtime.registry.getStats().byNamespace).toEqual({ chat: 1 });
1025
+
1026
+ await runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat");
1027
+ await waitForAsyncQueue();
1028
+ expect(socket.sentMessages).toHaveLength(1);
1029
+ });
833
1030
  });