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
@@ -0,0 +1,360 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ import { type WebSocketTransport } from "../ws";
4
+ import { type WebSocketAudience } from "../ws-audience";
5
+ import {
6
+ type WebSocketClusterBus,
7
+ type WebSocketClusterEnvelope,
8
+ type WebSocketClusterEnvelopeHandler,
9
+ } from "../ws-cluster-bus";
10
+ import { type WebSocketRoomId, type WebSocketUserId } from "../ws-core";
11
+ import { InMemoryWebSocketPresenceStore } from "../ws-presence-store";
12
+
13
+ // setImmediate 단위로 큐를 비워 fan-out/outbound flush/cluster 전달이 끝나기를 기다린다
14
+ export async function flushAsync(rounds: number = 5): Promise<void> {
15
+ for (let index = 0; index < rounds; index += 1) {
16
+ await new Promise<void>((resolve) => {
17
+ setImmediate(resolve);
18
+ });
19
+ }
20
+ }
21
+
22
+ // 조건이 만족될 때까지 setImmediate 단위로 기다린다. 끝내 만족하지 않으면 그대로 반환해 assertion에서 실패하게 한다
23
+ export async function flushUntil(predicate: () => boolean, maxRounds: number = 50): Promise<void> {
24
+ for (let index = 0; index < maxRounds && !predicate(); index += 1) {
25
+ await flushAsync(1);
26
+ }
27
+ }
28
+
29
+ export type Deferred<T> = {
30
+ promise: Promise<T>;
31
+ resolve: (value: T) => void;
32
+ reject: (error: Error) => void;
33
+ };
34
+
35
+ export function createDeferred<T = void>(): Deferred<T> {
36
+ let resolve!: (value: T) => void;
37
+ let reject!: (error: Error) => void;
38
+ const promise = new Promise<T>((innerResolve, innerReject) => {
39
+ resolve = innerResolve;
40
+ reject = innerReject;
41
+ });
42
+ return { promise, resolve, reject };
43
+ }
44
+
45
+ // promise가 아직 settle되지 않았는지 확인한다
46
+ export async function isSettled<T>(promise: Promise<T>): Promise<boolean> {
47
+ let settled = false;
48
+ promise.then(
49
+ () => {
50
+ settled = true;
51
+ },
52
+ () => {
53
+ settled = true;
54
+ },
55
+ );
56
+ await flushAsync(2);
57
+ return settled;
58
+ }
59
+
60
+ export class FakeWebSocket extends EventEmitter {
61
+ readyState: 0 | 1 | 2 | 3 = 1;
62
+ bufferedAmount = 0;
63
+ sentMessages: string[] = [];
64
+ pingCount = 0;
65
+ closedWith: { code?: number; reason?: string } | null = null;
66
+
67
+ send(data: string): void {
68
+ this.sentMessages.push(data);
69
+ }
70
+
71
+ close(code?: number, reason?: string): void {
72
+ this.closedWith = { code, reason };
73
+ this.readyState = 3;
74
+ this.emit("close", code ?? 1000, Buffer.from(reason ?? "", "utf-8"));
75
+ }
76
+
77
+ ping(): void {
78
+ this.pingCount += 1;
79
+ }
80
+
81
+ terminate(): void {
82
+ this.readyState = 3;
83
+ this.emit("close", 1006, Buffer.alloc(0));
84
+ }
85
+ }
86
+
87
+ export function asWebSocket(socket: FakeWebSocket): WebSocketTransport {
88
+ return {
89
+ get bufferedAmount() {
90
+ return socket.bufferedAmount;
91
+ },
92
+ get readyState() {
93
+ return socket.readyState;
94
+ },
95
+ close(code, reason) {
96
+ socket.close(code, reason?.toString());
97
+ },
98
+ off(...args) {
99
+ socket.off(...args);
100
+ },
101
+ on(...args) {
102
+ socket.on(...args);
103
+ },
104
+ ping() {
105
+ socket.ping();
106
+ },
107
+ send(data) {
108
+ socket.send(data.toString());
109
+ },
110
+ terminate() {
111
+ socket.terminate();
112
+ },
113
+ };
114
+ }
115
+
116
+ // 테스트 데이터 직렬화 결과를 되돌린다. 깨진 JSON이면 원문을 포함한 에러로 테스트를 실패시킨다
117
+ function parseTestJson<T>(raw: string): T {
118
+ try {
119
+ return /* SAFETY: 테스트가 직접 직렬화했거나 runtime이 직렬화한 값만 넘긴다. */ JSON.parse(
120
+ raw,
121
+ ) as T;
122
+ } catch (error) {
123
+ throw new Error(`테스트 JSON 파싱 실패: ${raw}`, { cause: error });
124
+ }
125
+ }
126
+
127
+ export function parseFrames(socket: FakeWebSocket): Array<{ event: string; data: object }> {
128
+ return socket.sentMessages.map((message) =>
129
+ parseTestJson<{ event: string; data: object }>(message),
130
+ );
131
+ }
132
+
133
+ type ScriptedMethod =
134
+ | "register"
135
+ | "activate"
136
+ | "unregister"
137
+ | "setUserId"
138
+ | "clearUserId"
139
+ | "join"
140
+ | "leave"
141
+ | "resolveRoute"
142
+ | "getRoomMemberCount"
143
+ | "shutdown";
144
+
145
+ // 다음 호출 직전에 끼워넣을 동작. 동기 throw, reject, 지연된 promise를 그대로 흉내낸다
146
+ type ScriptedInterceptor = () => void | Promise<void>;
147
+
148
+ /**
149
+ * InMemoryWebSocketPresenceStore를 감싸 호출 순서를 기록하고, 메서드별로 다음 호출의 동작을 스크립트할 수 있는 store.
150
+ * 호출 기록은 store가 호출을 "받은" 시점에 남긴다.
151
+ */
152
+ export class ScriptedPresenceStore {
153
+ readonly calls: string[] = [];
154
+ readonly inner: InMemoryWebSocketPresenceStore;
155
+ nodeId?: string;
156
+ private readonly interceptors = new Map<ScriptedMethod, ScriptedInterceptor[]>();
157
+
158
+ constructor(inner: InMemoryWebSocketPresenceStore = new InMemoryWebSocketPresenceStore()) {
159
+ this.inner = inner;
160
+ }
161
+
162
+ next(method: ScriptedMethod, interceptor: ScriptedInterceptor): this {
163
+ const queue = this.interceptors.get(method) ?? [];
164
+ queue.push(interceptor);
165
+ this.interceptors.set(method, queue);
166
+ return this;
167
+ }
168
+
169
+ // 이후 모든 호출에 같은 동작을 적용한다
170
+ always(method: ScriptedMethod, interceptor: ScriptedInterceptor): this {
171
+ for (let index = 0; index < 1_000; index += 1) {
172
+ this.next(method, interceptor);
173
+ }
174
+ return this;
175
+ }
176
+
177
+ register(input: {
178
+ sessionId: string;
179
+ nodeId: string;
180
+ namespace: string;
181
+ active: boolean;
182
+ connectedAt: Date;
183
+ }): void | Promise<void> {
184
+ this.calls.push(`register:${input.sessionId}`);
185
+ return this.run("register", () => this.inner.register(input));
186
+ }
187
+
188
+ activate(sessionId: string): void | Promise<void> {
189
+ this.calls.push(`activate:${sessionId}`);
190
+ return this.run("activate", () => this.inner.activate(sessionId));
191
+ }
192
+
193
+ unregister(sessionId: string): void | Promise<void> {
194
+ this.calls.push(`unregister:${sessionId}`);
195
+ return this.run("unregister", () => this.inner.unregister(sessionId));
196
+ }
197
+
198
+ setUserId(sessionId: string, userId: WebSocketUserId): void | Promise<void> {
199
+ this.calls.push(`setUserId:${sessionId}:${JSON.stringify(userId)}`);
200
+ return this.run("setUserId", () => this.inner.setUserId(sessionId, userId));
201
+ }
202
+
203
+ clearUserId(sessionId: string): void | Promise<void> {
204
+ this.calls.push(`clearUserId:${sessionId}`);
205
+ return this.run("clearUserId", () => this.inner.clearUserId(sessionId));
206
+ }
207
+
208
+ join(sessionId: string, roomId: WebSocketRoomId): void | Promise<void> {
209
+ this.calls.push(`join:${sessionId}:${roomId}`);
210
+ return this.run("join", () => this.inner.join(sessionId, roomId));
211
+ }
212
+
213
+ leave(sessionId: string, roomId: WebSocketRoomId): void | Promise<void> {
214
+ this.calls.push(`leave:${sessionId}:${roomId}`);
215
+ return this.run("leave", () => this.inner.leave(sessionId, roomId));
216
+ }
217
+
218
+ resolveRoute(
219
+ audience: WebSocketAudience,
220
+ nodeId: string,
221
+ ):
222
+ | { localSessionIds: string[]; remoteNodeIds: string[] }
223
+ | Promise<{ localSessionIds: string[]; remoteNodeIds: string[] }> {
224
+ this.calls.push(`resolveRoute:${nodeId}:${audience.type}`);
225
+ return this.run("resolveRoute", () => this.inner.resolveRoute(audience, nodeId));
226
+ }
227
+
228
+ getRoomMemberCount(roomId: WebSocketRoomId, namespace?: string): number | Promise<number> {
229
+ this.calls.push(`getRoomMemberCount:${roomId}`);
230
+ return this.run("getRoomMemberCount", () => this.inner.getRoomMemberCount(roomId, namespace));
231
+ }
232
+
233
+ shutdown(): void | Promise<void> {
234
+ this.calls.push("shutdown");
235
+ return this.run("shutdown", () => undefined);
236
+ }
237
+
238
+ callsOf(prefix: string): string[] {
239
+ return this.calls.filter((call) => call.startsWith(prefix));
240
+ }
241
+
242
+ private run<T>(method: ScriptedMethod, delegate: () => T | Promise<T>): T | Promise<T> {
243
+ const interceptor = this.interceptors.get(method)?.shift();
244
+ if (!interceptor) {
245
+ return delegate();
246
+ }
247
+
248
+ const result = interceptor();
249
+ if (result instanceof Promise) {
250
+ return result.then(() => delegate());
251
+ }
252
+ return delegate();
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 한 프로세스 안에서 targetNodeIds 기준으로 envelope을 라우팅하는 fake cluster 네트워크.
258
+ * 전송 구간을 흉내내기 위해 envelope을 JSON 직렬화/역직렬화한 뒤 다음 tick에 전달한다.
259
+ */
260
+ export class InProcessClusterNetwork {
261
+ readonly published: WebSocketClusterEnvelope[] = [];
262
+ private readonly handlers = new Map<string, Set<WebSocketClusterEnvelopeHandler>>();
263
+
264
+ bus(nodeId: string): InProcessClusterBus {
265
+ return new InProcessClusterBus(nodeId, this);
266
+ }
267
+
268
+ subscribe(nodeId: string, handler: WebSocketClusterEnvelopeHandler): () => void {
269
+ const handlers = this.handlers.get(nodeId) ?? new Set();
270
+ handlers.add(handler);
271
+ this.handlers.set(nodeId, handlers);
272
+ return () => {
273
+ handlers.delete(handler);
274
+ };
275
+ }
276
+
277
+ deliver(envelope: WebSocketClusterEnvelope): void {
278
+ const wire = JSON.stringify(envelope);
279
+ this.published.push(parseTestJson<WebSocketClusterEnvelope>(wire));
280
+ setImmediate(() => {
281
+ for (const targetNodeId of envelope.targetNodeIds ?? []) {
282
+ for (const handler of this.handlers.get(targetNodeId) ?? []) {
283
+ const received = parseTestJson<WebSocketClusterEnvelope>(wire);
284
+ void Promise.resolve(handler(received)).catch(() => {});
285
+ }
286
+ }
287
+ });
288
+ }
289
+ }
290
+
291
+ export class InProcessClusterBus implements WebSocketClusterBus {
292
+ shutdownCalls = 0;
293
+ unsubscribeCalls = 0;
294
+
295
+ constructor(
296
+ readonly nodeId: string,
297
+ private readonly network: InProcessClusterNetwork,
298
+ ) {}
299
+
300
+ publish(envelope: WebSocketClusterEnvelope): void {
301
+ this.network.deliver(envelope);
302
+ }
303
+
304
+ // 실제 adapter처럼 구독 완료를 promise로 알린다
305
+ async subscribe(handler: WebSocketClusterEnvelopeHandler): Promise<() => void> {
306
+ const unsubscribe = this.network.subscribe(this.nodeId, handler);
307
+ return () => {
308
+ this.unsubscribeCalls += 1;
309
+ unsubscribe();
310
+ };
311
+ }
312
+
313
+ shutdown(): void {
314
+ this.shutdownCalls += 1;
315
+ }
316
+ }
317
+
318
+ // 발행된 envelope을 기록하고 테스트가 직접 수신 envelope을 주입할 수 있는 bus
319
+ export class RecordingClusterBus implements WebSocketClusterBus {
320
+ readonly published: WebSocketClusterEnvelope[] = [];
321
+ readonly handlers = new Set<WebSocketClusterEnvelopeHandler>();
322
+ shutdownCalls = 0;
323
+ unsubscribeCalls = 0;
324
+ nodeId?: string;
325
+
326
+ publish(envelope: WebSocketClusterEnvelope): void | Promise<void> {
327
+ this.published.push(envelope);
328
+ }
329
+
330
+ subscribe(handler: WebSocketClusterEnvelopeHandler): (() => void) | Promise<() => void> {
331
+ this.handlers.add(handler);
332
+ return () => {
333
+ this.unsubscribeCalls += 1;
334
+ this.handlers.delete(handler);
335
+ };
336
+ }
337
+
338
+ async emit(envelope: WebSocketClusterEnvelope): Promise<void> {
339
+ for (const handler of this.handlers) {
340
+ await handler(envelope);
341
+ }
342
+ }
343
+
344
+ shutdown(): void | Promise<void> {
345
+ this.shutdownCalls += 1;
346
+ }
347
+ }
348
+
349
+ // 전역 unhandledRejection을 수집한다. 반환한 함수로 해제하고 수집 결과를 받는다
350
+ export function collectUnhandledRejections(): () => Error[] {
351
+ const collected: Error[] = [];
352
+ const listener = (reason: Error) => {
353
+ collected.push(reason);
354
+ };
355
+ process.on("unhandledRejection", listener);
356
+ return () => {
357
+ process.off("unhandledRejection", listener);
358
+ return collected;
359
+ };
360
+ }