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.
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +12 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
- package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
- package/dist/stream/__tests__/ws-test-helpers.js +260 -0
- package/dist/stream/index.d.ts +1 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +4 -2
- package/dist/stream/ws-audience-resolver.d.ts +8 -5
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
- package/dist/stream/ws-audience-resolver.js +16 -13
- package/dist/stream/ws-cluster-bus.d.ts +9 -4
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
- package/dist/stream/ws-cluster-bus.js +1 -1
- package/dist/stream/ws-core.d.ts +10 -0
- package/dist/stream/ws-core.d.ts.map +1 -1
- package/dist/stream/ws-delivery.d.ts +30 -4
- package/dist/stream/ws-delivery.d.ts.map +1 -1
- package/dist/stream/ws-delivery.js +199 -52
- package/dist/stream/ws-frame.d.ts +15 -0
- package/dist/stream/ws-frame.d.ts.map +1 -0
- package/dist/stream/ws-frame.js +29 -0
- package/dist/stream/ws-local-connection-store.d.ts +4 -4
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
- package/dist/stream/ws-local-connection-store.js +2 -2
- package/dist/stream/ws-presence-index.d.ts +36 -0
- package/dist/stream/ws-presence-index.d.ts.map +1 -0
- package/dist/stream/ws-presence-index.js +177 -0
- package/dist/stream/ws-presence-store.d.ts +39 -39
- package/dist/stream/ws-presence-store.d.ts.map +1 -1
- package/dist/stream/ws-presence-store.js +22 -152
- package/dist/stream/ws-registry.d.ts +45 -17
- package/dist/stream/ws-registry.d.ts.map +1 -1
- package/dist/stream/ws-registry.js +169 -60
- package/dist/stream/ws-session-id.d.ts +14 -0
- package/dist/stream/ws-session-id.d.ts.map +1 -0
- package/dist/stream/ws-session-id.js +37 -0
- package/dist/stream/ws.d.ts +20 -10
- package/dist/stream/ws.d.ts.map +1 -1
- package/dist/stream/ws.js +59 -36
- package/package.json +1 -1
- package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
- package/src/api/__tests__/sonamu.websocket.test.ts +241 -20
- package/src/api/sonamu.ts +20 -7
- package/src/index.ts +1 -0
- package/src/stream/__tests__/ws-contracts.test.ts +483 -265
- package/src/stream/__tests__/ws-multinode.test.ts +326 -0
- package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
- package/src/stream/__tests__/ws-test-helpers.ts +360 -0
- package/src/stream/__tests__/ws.test.ts +342 -145
- package/src/stream/index.ts +1 -0
- package/src/stream/ws-audience-resolver.ts +21 -19
- package/src/stream/ws-cluster-bus.ts +12 -4
- package/src/stream/ws-core.ts +14 -0
- package/src/stream/ws-delivery.ts +321 -97
- package/src/stream/ws-frame.ts +39 -0
- package/src/stream/ws-local-connection-store.ts +10 -9
- package/src/stream/ws-presence-index.ts +273 -0
- package/src/stream/ws-presence-store.ts +64 -284
- package/src/stream/ws-registry.ts +272 -85
- package/src/stream/ws-session-id.ts +39 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
|
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
|
});
|