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,234 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import Fastify, { type FastifyInstance } from "fastify";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { z } from "zod";
8
+
9
+ import { RecordingClusterBus, flushAsync } from "../../stream/__tests__/ws-test-helpers";
10
+ import { createWebSocketRuntime, type WebSocketRuntime } from "../../stream/ws";
11
+ import { InMemoryWebSocketPresenceStore } from "../../stream/ws-presence-store";
12
+ import { type SonamuFastifyConfig } from "../../types/types";
13
+ import { type ExtendedApi } from "../decorators";
14
+ import { Sonamu } from "../sonamu";
15
+
16
+ const ChatEventsV1 = z.object({
17
+ onMessage: z.object({ text: z.string() }),
18
+ });
19
+ // HMR로 바뀐 스키마: 기본값 필드가 추가돼 인코딩 결과가 달라진다
20
+ const ChatEventsV2 = z.object({
21
+ onMessage: z.object({ text: z.string(), edited: z.boolean().default(true) }),
22
+ });
23
+ const NoticeEvents = z.object({
24
+ onNotice: z.object({ level: z.number() }),
25
+ });
26
+ const AlertEvents = z.object({
27
+ onAlert: z.object({ message: z.string() }),
28
+ });
29
+
30
+ function createWebSocketApi(input: {
31
+ modelName: string;
32
+ methodName: string;
33
+ path: string;
34
+ outEvents: z.ZodObject;
35
+ }): ExtendedApi {
36
+ return {
37
+ modelName: input.modelName,
38
+ methodName: input.methodName,
39
+ path: input.path,
40
+ options: {},
41
+ websocketOptions: {
42
+ outEvents: input.outEvents,
43
+ inEvents: z.object({}),
44
+ namespace: "chat",
45
+ },
46
+ typeParameters: [],
47
+ parameters: [],
48
+ returnType: "void",
49
+ };
50
+ }
51
+
52
+ const chatApi = (outEvents: z.ZodObject) =>
53
+ createWebSocketApi({
54
+ modelName: "ChatFrame",
55
+ methodName: "subscribe",
56
+ path: "/chat/subscribe",
57
+ outEvents,
58
+ });
59
+ const noticeApi = createWebSocketApi({
60
+ modelName: "NoticeFrame",
61
+ methodName: "subscribe",
62
+ path: "/notice/subscribe",
63
+ outEvents: NoticeEvents,
64
+ });
65
+ const alertApi = createWebSocketApi({
66
+ modelName: "AlertFrame",
67
+ methodName: "subscribe",
68
+ path: "/alert/subscribe",
69
+ outEvents: AlertEvents,
70
+ });
71
+
72
+ // 테스트가 서버 측에서 publish하는 payload 모양
73
+ type ServerPublishData = { text: string; extra?: number } | { level: number } | { message: string };
74
+
75
+ const config = /* SAFETY: route 등록 단계는 SonamuFastifyConfig의 함수를 호출하지 않는다. */ {
76
+ contextProvider(defaultContext) {
77
+ return defaultContext;
78
+ },
79
+ guardHandler() {},
80
+ } as SonamuFastifyConfig;
81
+
82
+ describe("Sonamu websocket route table 등록", () => {
83
+ const originalState = Sonamu.captureTestingSnapshot();
84
+ const originalInitialized = Sonamu.isInitialized;
85
+ const originalNodeEnv = process.env.NODE_ENV;
86
+ const servers: FastifyInstance[] = [];
87
+ let tempRoot: string;
88
+ let runtime: WebSocketRuntime;
89
+ let clusterBus: RecordingClusterBus;
90
+
91
+ beforeEach(async () => {
92
+ tempRoot = await mkdtemp(path.join(os.tmpdir(), "sonamu-ws-routes-"));
93
+ // web, web-dist가 없는 api 루트를 만들어 dev/prod 모두 정적 서빙 없이 route만 등록하게 한다
94
+ Reflect.set(Sonamu, "apiRootPathValue", path.join(tempRoot, "api"));
95
+ Reflect.set(Sonamu, "configValue", {
96
+ sync: { targets: [] },
97
+ api: { route: { prefix: "/api" } },
98
+ server: {},
99
+ validation: { zodCompiler: false },
100
+ });
101
+ Sonamu.isInitialized = true;
102
+
103
+ // 원격 노드 node-b의 세션이 room-1에 있고, 이 노드(node-a)에는 로컬 연결이 하나도 없다
104
+ const presenceStore = new InMemoryWebSocketPresenceStore();
105
+ await presenceStore.register({
106
+ sessionId: "remote-1",
107
+ nodeId: "node-b",
108
+ namespace: "chat",
109
+ active: true,
110
+ connectedAt: new Date(),
111
+ });
112
+ await presenceStore.join("remote-1", "room-1");
113
+ clusterBus = new RecordingClusterBus();
114
+ runtime = createWebSocketRuntime({ nodeId: "node-a", presenceStore, clusterBus });
115
+ Sonamu.websocketRuntime = runtime;
116
+ vi.spyOn(console, "warn").mockImplementation(() => {});
117
+ });
118
+
119
+ afterEach(async () => {
120
+ await Promise.all(servers.splice(0).map((server) => server.close()));
121
+ Sonamu.restoreTestingSnapshot(originalState);
122
+ Sonamu.isInitialized = originalInitialized;
123
+ if (originalNodeEnv === undefined) {
124
+ delete process.env.NODE_ENV;
125
+ } else {
126
+ process.env.NODE_ENV = originalNodeEnv;
127
+ }
128
+ vi.restoreAllMocks();
129
+ await rm(tempRoot, { recursive: true, force: true });
130
+ });
131
+
132
+ function setApis(apis: ExtendedApi[]): void {
133
+ Reflect.set(Sonamu, "syncerValue", {
134
+ apis,
135
+ models: { ChatFrame: {}, NoticeFrame: {}, AlertFrame: {} },
136
+ types: {},
137
+ });
138
+ }
139
+
140
+ async function startServer(environment: "production" | "test"): Promise<FastifyInstance> {
141
+ process.env.NODE_ENV = environment;
142
+ const server = Fastify();
143
+ servers.push(server);
144
+ await Sonamu.withFastify(server, config, { doSilent: true, enableSync: false });
145
+ return server;
146
+ }
147
+
148
+ // 서버 측 publish 결과로 이 노드의 route table을 관찰한다
149
+ async function publishAndCollectFrames(
150
+ event: string,
151
+ data: ServerPublishData,
152
+ ): Promise<Record<string, string> | undefined> {
153
+ const before = clusterBus.published.length;
154
+ await runtime.publishToRoom("room-1", event, data, "chat");
155
+ await flushAsync();
156
+ const published = clusterBus.published.slice(before);
157
+ expect(published.length).toBeLessThanOrEqual(1);
158
+ return published[0]?.frames;
159
+ }
160
+
161
+ for (const environment of ["production", "test"] as const) {
162
+ const label =
163
+ environment === "production" ? "프로덕션 route 등록 경로" : "dev/HMR catch-all 경로";
164
+
165
+ it(`${label}: 시작 시 모든 @websocket route의 outEvents가 api.path 키로 등록돼, 로컬 연결이 없어도 원격 수신자용 frame을 만든다`, async () => {
166
+ setApis([chatApi(ChatEventsV1), noticeApi]);
167
+
168
+ const server = await startServer(environment);
169
+
170
+ // 의도한 등록 경로를 실제로 탔는지 확인한다 (프로덕션은 route별 등록, dev는 catch-all)
171
+ if (environment === "production") {
172
+ expect(server.hasRoute({ method: "GET", url: "/api/chat/subscribe" })).toBe(true);
173
+ expect(server.hasRoute({ method: "GET", url: "/api/notice/subscribe" })).toBe(true);
174
+ } else {
175
+ expect(server.hasRoute({ method: "GET", url: "/api/*" })).toBe(true);
176
+ }
177
+
178
+ const chatFrames = await publishAndCollectFrames("onMessage", { text: "hi", extra: 1 });
179
+ expect(chatFrames).toBeDefined();
180
+ expect(Object.keys(chatFrames ?? {})).toEqual(["/chat/subscribe"]);
181
+ expect(JSON.parse(chatFrames?.["/chat/subscribe"] ?? "null")).toEqual({
182
+ event: "onMessage",
183
+ data: { text: "hi" },
184
+ });
185
+
186
+ const noticeFrames = await publishAndCollectFrames("onNotice", { level: 2 });
187
+ expect(Object.keys(noticeFrames ?? {})).toEqual(["/notice/subscribe"]);
188
+ expect(JSON.parse(noticeFrames?.["/notice/subscribe"] ?? "null")).toEqual({
189
+ event: "onNotice",
190
+ data: { level: 2 },
191
+ });
192
+ });
193
+ }
194
+
195
+ it("HMR refresh 뒤에는 바뀐 스키마로 인코딩하고, 제거된 route는 빠지고, 추가된 route는 포함된다", async () => {
196
+ setApis([chatApi(ChatEventsV1), noticeApi]);
197
+ await startServer("test");
198
+
199
+ // syncer HMR 사이클과 같은 순서: API를 다시 읽은 뒤 refreshHttpValidators를 호출한다
200
+ setApis([chatApi(ChatEventsV2), alertApi]);
201
+ await Sonamu.refreshHttpValidators();
202
+
203
+ const chatFrames = await publishAndCollectFrames("onMessage", { text: "hi" });
204
+ expect(JSON.parse(chatFrames?.["/chat/subscribe"] ?? "null")).toEqual({
205
+ event: "onMessage",
206
+ data: { text: "hi", edited: true },
207
+ });
208
+
209
+ const noticeFrames = await publishAndCollectFrames("onNotice", { level: 2 });
210
+ expect(noticeFrames?.["/notice/subscribe"]).toBeUndefined();
211
+
212
+ const alertFrames = await publishAndCollectFrames("onAlert", { message: "fire" });
213
+ expect(Object.keys(alertFrames ?? {})).toEqual(["/alert/subscribe"]);
214
+ expect(JSON.parse(alertFrames?.["/alert/subscribe"] ?? "null")).toEqual({
215
+ event: "onAlert",
216
+ data: { message: "fire" },
217
+ });
218
+ });
219
+
220
+ it("HMR refresh로 스키마가 바뀌면 이전 스키마로만 유효한 payload는 더 이상 인코딩되지 않는다", async () => {
221
+ const StrictV2 = z.object({ onMessage: z.object({ text: z.string().max(3) }) });
222
+ setApis([chatApi(ChatEventsV1)]);
223
+ await startServer("test");
224
+
225
+ const before = await publishAndCollectFrames("onMessage", { text: "long text" });
226
+ expect(before?.["/chat/subscribe"]).toBeDefined();
227
+
228
+ setApis([chatApi(StrictV2)]);
229
+ await Sonamu.refreshHttpValidators();
230
+
231
+ const after = await publishAndCollectFrames("onMessage", { text: "long text" });
232
+ expect(after?.["/chat/subscribe"]).toBeUndefined();
233
+ });
234
+ });
@@ -3,17 +3,16 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import { type WebSocket } from "ws";
4
4
  import { z } from "zod";
5
5
 
6
- import {
7
- type AnyWebSocketConnection,
8
- type WebSocketConnection,
9
- WebSocketRuntime,
10
- } from "../../stream/ws";
6
+ import { type WebSocketConnection, WebSocketRuntime } from "../../stream/ws";
11
7
  import { type WebSocketTelemetryConnectionContext } from "../../stream/ws-telemetry";
12
8
  import { type SonamuFastifyConfig } from "../../types/types";
13
9
  import { type WebSocketContext } from "../context";
14
10
  import { type ExtendedApi } from "../decorators";
15
11
  import { Sonamu } from "../sonamu";
16
12
 
13
+ // runtime.registerConnection이 돌려주는 연결 타입. spy 대역도 이 타입으로 만들어야 한다
14
+ type RuntimeWebSocketConnection = Awaited<ReturnType<WebSocketRuntime["registerConnection"]>>;
15
+
17
16
  interface TestWebSocketMessage {
18
17
  roomId?: string;
19
18
  }
@@ -25,7 +24,7 @@ interface TestWebSocketEvents {
25
24
  describe("Sonamu websocket context scoping", () => {
26
25
  it("웹소켓 컨텍스트를 생성한 뒤 해당 컨텍스트 안에서 guard를 실행한다", async () => {
27
26
  const executionOrder: string[] = [];
28
- const rawWs: AnyWebSocketConnection = {
27
+ const rawWs: RuntimeWebSocketConnection = {
29
28
  id: "ws-guard",
30
29
  namespace: "chat",
31
30
  transport: "ws" as const,
@@ -38,10 +37,18 @@ describe("Sonamu websocket context scoping", () => {
38
37
  waitForClose() {
39
38
  return Promise.resolve();
40
39
  },
41
- join() {},
42
- leave() {},
43
- setUserId() {},
44
- clearUserId() {},
40
+ join() {
41
+ return Promise.resolve();
42
+ },
43
+ leave() {
44
+ return Promise.resolve();
45
+ },
46
+ setUserId() {
47
+ return Promise.resolve();
48
+ },
49
+ clearUserId() {
50
+ return Promise.resolve();
51
+ },
45
52
  };
46
53
  const runtime = new WebSocketRuntime({ nodeId: "websocket-guard-test" });
47
54
  const originalState = Sonamu.captureTestingSnapshot();
@@ -49,8 +56,8 @@ describe("Sonamu websocket context scoping", () => {
49
56
  let guardContext: WebSocketContext | null = null;
50
57
  let guardTransport: WebSocketContext["transport"] | null = null;
51
58
 
52
- vi.spyOn(runtime, "registerConnection").mockReturnValue(rawWs);
53
- vi.spyOn(runtime, "activateConnection").mockImplementation(() => {
59
+ vi.spyOn(runtime, "registerConnection").mockResolvedValue(rawWs);
60
+ vi.spyOn(runtime, "activateConnection").mockImplementation(async () => {
54
61
  executionOrder.push("activate");
55
62
  });
56
63
  vi.spyOn(Sonamu, "createWebSocketContext").mockImplementation(async (_config, request, ws) => {
@@ -144,10 +151,18 @@ describe("Sonamu websocket context scoping", () => {
144
151
  waitForClose() {
145
152
  return Promise.resolve();
146
153
  },
147
- join() {},
148
- leave() {},
149
- setUserId() {},
150
- clearUserId() {},
154
+ join() {
155
+ return Promise.resolve();
156
+ },
157
+ leave() {
158
+ return Promise.resolve();
159
+ },
160
+ setUserId() {
161
+ return Promise.resolve();
162
+ },
163
+ clearUserId() {
164
+ return Promise.resolve();
165
+ },
151
166
  };
152
167
 
153
168
  let context: WebSocketContext<TestWebSocketEvents, TestWebSocketEvents> | null = null;
@@ -201,10 +216,18 @@ describe("Sonamu websocket context scoping", () => {
201
216
  waitForClose() {
202
217
  return Promise.resolve();
203
218
  },
204
- join() {},
205
- leave() {},
206
- setUserId() {},
207
- clearUserId() {},
219
+ join() {
220
+ return Promise.resolve();
221
+ },
222
+ leave() {
223
+ return Promise.resolve();
224
+ },
225
+ setUserId() {
226
+ return Promise.resolve();
227
+ },
228
+ clearUserId() {
229
+ return Promise.resolve();
230
+ },
208
231
  };
209
232
 
210
233
  let context: WebSocketContext<TestWebSocketEvents, TestWebSocketEvents> | null = null;
@@ -243,4 +266,202 @@ describe("Sonamu websocket context scoping", () => {
243
266
 
244
267
  expect(seenTraceContext).toEqual(messageTraceContext);
245
268
  });
269
+
270
+ it("핸들러는 registerConnection과 activateConnection이 끝날 때까지 기다리며 context → guard → activate → handler 순서를 지킨다", async () => {
271
+ const executionOrder: string[] = [];
272
+ const rawWs: RuntimeWebSocketConnection = {
273
+ id: "ws-async",
274
+ namespace: "chat",
275
+ transport: "ws" as const,
276
+ closed: false,
277
+ publishUntyped() {},
278
+ close() {},
279
+ onClose() {},
280
+ onMessage() {},
281
+ publish() {},
282
+ waitForClose() {
283
+ return Promise.resolve();
284
+ },
285
+ join() {
286
+ return Promise.resolve();
287
+ },
288
+ leave() {
289
+ return Promise.resolve();
290
+ },
291
+ setUserId() {
292
+ return Promise.resolve();
293
+ },
294
+ clearUserId() {
295
+ return Promise.resolve();
296
+ },
297
+ };
298
+ const runtime = new WebSocketRuntime({ nodeId: "websocket-async-test" });
299
+ const originalState = Sonamu.captureTestingSnapshot();
300
+ let releaseRegister!: () => void;
301
+ let releaseActivate!: () => void;
302
+ const registerGate = new Promise<void>((resolve) => {
303
+ releaseRegister = resolve;
304
+ });
305
+ const activateGate = new Promise<void>((resolve) => {
306
+ releaseActivate = resolve;
307
+ });
308
+
309
+ const registerSpy = vi.spyOn(runtime, "registerConnection").mockImplementation(async () => {
310
+ executionOrder.push("register:start");
311
+ await registerGate;
312
+ executionOrder.push("register:end");
313
+ return rawWs;
314
+ });
315
+ const activateSpy = vi
316
+ .spyOn(runtime, "activateConnection")
317
+ .mockImplementation(async (connectionId) => {
318
+ executionOrder.push(`activate:start:${connectionId}`);
319
+ await activateGate;
320
+ executionOrder.push("activate:end");
321
+ });
322
+ vi.spyOn(Sonamu, "createWebSocketContext").mockImplementation(async (_config, request, ws) => {
323
+ executionOrder.push("context");
324
+ return {
325
+ transport: "ws",
326
+ request,
327
+ headers: request.headers,
328
+ ws,
329
+ naiteStore: new Map(),
330
+ locale: "ko",
331
+ user: null,
332
+ session: null,
333
+ };
334
+ });
335
+ vi.spyOn(Sonamu, "invokeModelMethod").mockImplementation(async () => {
336
+ executionOrder.push("handler");
337
+ });
338
+
339
+ const config = /* SAFETY: 테스트는 guardHandler와 contextProvider만 사용한다. */ {
340
+ guardHandler() {
341
+ executionOrder.push("guard");
342
+ },
343
+ contextProvider(defaultContext) {
344
+ return defaultContext;
345
+ },
346
+ } as SonamuFastifyConfig;
347
+ const api: ExtendedApi = {
348
+ modelName: "ChatFrame",
349
+ methodName: "subscribe",
350
+ path: "/chat/subscribe",
351
+ options: { guards: ["user"] },
352
+ websocketOptions: {
353
+ outEvents: z.object({}),
354
+ inEvents: z.object({}),
355
+ namespace: "chat",
356
+ },
357
+ typeParameters: [],
358
+ parameters: [],
359
+ returnType: "void",
360
+ };
361
+ const request = /* SAFETY: 테스트는 headers와 query만 읽는다. */ {
362
+ headers: {},
363
+ query: {},
364
+ } as FastifyRequest;
365
+ const socket =
366
+ /* SAFETY: registerConnection이 mock이라 소켓 메서드는 호출되지 않는다. */ {} as WebSocket;
367
+ try {
368
+ Sonamu.websocketRuntime = runtime;
369
+ Reflect.set(Sonamu, "syncerValue", { types: {} });
370
+
371
+ let handlerDone = false;
372
+ const handling = Sonamu.createWebSocketHandlerForTesting(api, config)(
373
+ { socket },
374
+ request,
375
+ ).then(() => {
376
+ handlerDone = true;
377
+ });
378
+ await new Promise<void>((resolve) => setImmediate(resolve));
379
+
380
+ // 등록이 끝나기 전에는 context를 만들지 않는다
381
+ expect(executionOrder).toEqual(["register:start"]);
382
+
383
+ releaseRegister();
384
+ await new Promise<void>((resolve) => setImmediate(resolve));
385
+
386
+ // 활성화가 끝나기 전에는 handler를 실행하지 않는다
387
+ expect(executionOrder).toEqual([
388
+ "register:start",
389
+ "register:end",
390
+ "context",
391
+ "guard",
392
+ "activate:start:ws-async",
393
+ ]);
394
+ expect(handlerDone).toBe(false);
395
+
396
+ releaseActivate();
397
+ await handling;
398
+
399
+ expect(executionOrder).toEqual([
400
+ "register:start",
401
+ "register:end",
402
+ "context",
403
+ "guard",
404
+ "activate:start:ws-async",
405
+ "activate:end",
406
+ "handler",
407
+ ]);
408
+ expect(registerSpy).toHaveBeenCalledWith(
409
+ socket,
410
+ expect.objectContaining({
411
+ routeKey: "/chat/subscribe",
412
+ namespace: "chat",
413
+ active: false,
414
+ }),
415
+ );
416
+ expect(activateSpy).toHaveBeenCalledTimes(1);
417
+ } finally {
418
+ Sonamu.restoreTestingSnapshot(originalState);
419
+ vi.restoreAllMocks();
420
+ }
421
+ });
422
+
423
+ it("scoped 연결의 join/leave/setUserId/clearUserId는 원본 연결의 promise를 그대로 반환한다", async () => {
424
+ const joinPromise = Promise.resolve();
425
+ const leavePromise = Promise.resolve();
426
+ const setUserIdPromise = Promise.resolve();
427
+ const clearUserIdPromise = Promise.resolve();
428
+ const calls: string[] = [];
429
+ const rawWs: WebSocketConnection<TestWebSocketEvents, TestWebSocketEvents> = {
430
+ id: "ws-scoped",
431
+ namespace: "chat",
432
+ transport: "ws" as const,
433
+ closed: false,
434
+ publishUntyped() {},
435
+ close() {},
436
+ onClose() {},
437
+ onMessage() {},
438
+ publish() {},
439
+ waitForClose() {
440
+ return Promise.resolve();
441
+ },
442
+ join(roomId) {
443
+ calls.push(`join:${roomId}`);
444
+ return joinPromise;
445
+ },
446
+ leave(roomId) {
447
+ calls.push(`leave:${roomId}`);
448
+ return leavePromise;
449
+ },
450
+ setUserId(userId) {
451
+ calls.push(`setUserId:${JSON.stringify(userId)}`);
452
+ return setUserIdPromise;
453
+ },
454
+ clearUserId() {
455
+ calls.push("clearUserId");
456
+ return clearUserIdPromise;
457
+ },
458
+ };
459
+ const scopedWs = Sonamu.createScopedWebSocketConnectionForTesting(rawWs, () => null);
460
+
461
+ expect(scopedWs.join("room-1")).toBe(joinPromise);
462
+ expect(scopedWs.leave("room-1")).toBe(leavePromise);
463
+ expect(scopedWs.setUserId(1)).toBe(setUserIdPromise);
464
+ expect(scopedWs.clearUserId()).toBe(clearUserIdPromise);
465
+ expect(calls).toEqual(["join:room-1", "leave:room-1", "setUserId:1", "clearUserId"]);
466
+ });
246
467
  });
package/src/api/sonamu.ts CHANGED
@@ -1331,6 +1331,16 @@ class SonamuClass {
1331
1331
  this.aotHttpValidatorRegistry = nextAotRegistry;
1332
1332
  this.preparedApis = nextApis;
1333
1333
  this.preparedModels = nextModels;
1334
+
1335
+ // @websocket route의 outEvents를 api.path 키로 route table에 반영한다.
1336
+ // 발행 노드가 route별로 한 번 인코딩한 frame을 원격 노드가 그대로 보내므로, HMR 뒤에도 최신 스키마를 써야 한다
1337
+ this.websocketRuntimeValue?.replaceRoutes(
1338
+ nextApis.flatMap((api) =>
1339
+ api.websocketOptions
1340
+ ? [{ routeKey: api.path, outEvents: api.websocketOptions.outEvents }]
1341
+ : [],
1342
+ ),
1343
+ );
1334
1344
  }
1335
1345
 
1336
1346
  // WS path를 일반 HTTP GET으로 호출한 경우 426 + Upgrade 헤더로 명시적으로 websocket 접속을 유도함
@@ -1391,7 +1401,7 @@ class SonamuClass {
1391
1401
  ): Promise<void> => {
1392
1402
  const socket = connection.socket;
1393
1403
  let wsContext: WebSocketContext<object, object> | null = null;
1394
- let rawWs: ReturnType<WebSocketRuntime["registerConnection"]> | null = null;
1404
+ let rawWs: Awaited<ReturnType<WebSocketRuntime["registerConnection"]>> | null = null;
1395
1405
  let preRegisterPhase: "guard" | "query" | "register" | "handler" = "guard";
1396
1406
  let traceContext: WebSocketTelemetryConnectionContext = {};
1397
1407
 
@@ -1403,7 +1413,9 @@ class SonamuClass {
1403
1413
  preRegisterPhase = "query";
1404
1414
  const reqBody = await this.parseWebSocketRequestParams(api, request);
1405
1415
  preRegisterPhase = "register";
1406
- rawWs = this.websocketRuntime.registerConnection(socket, {
1416
+ // 등록(store 반영 포함)이 끝난 뒤에 context/guard를 진행한다. 리스너는 등록 중에도 붙어 있어 close를 놓치지 않는다
1417
+ rawWs = await this.websocketRuntime.registerConnection(socket, {
1418
+ routeKey: api.path,
1407
1419
  outEvents: api.websocketOptions!.outEvents,
1408
1420
  inEvents: api.websocketOptions!.inEvents,
1409
1421
  namespace: api.websocketOptions!.namespace,
@@ -1427,7 +1439,8 @@ class SonamuClass {
1427
1439
  });
1428
1440
  });
1429
1441
 
1430
- this.websocketRuntime.activateConnection(rawWs.id);
1442
+ // 활성화가 store에 반영된 뒤 handler를 실행해 handler 안의 publish가 이 연결을 대상으로 잡게 한다
1443
+ await this.websocketRuntime.activateConnection(rawWs.id);
1431
1444
  preRegisterPhase = "handler";
1432
1445
 
1433
1446
  const { ApiParamType } = await import("../types/types");
@@ -1604,16 +1617,16 @@ class SonamuClass {
1604
1617
  return ws.waitForClose();
1605
1618
  },
1606
1619
  join(roomId) {
1607
- ws.join(roomId);
1620
+ return ws.join(roomId);
1608
1621
  },
1609
1622
  leave(roomId) {
1610
- ws.leave(roomId);
1623
+ return ws.leave(roomId);
1611
1624
  },
1612
1625
  setUserId(userId) {
1613
- ws.setUserId(userId);
1626
+ return ws.setUserId(userId);
1614
1627
  },
1615
1628
  clearUserId() {
1616
- ws.clearUserId();
1629
+ return ws.clearUserId();
1617
1630
  },
1618
1631
  };
1619
1632
  }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export * from "./stream/ws-audience";
39
39
  export * from "./stream/ws-cluster-bus";
40
40
  export * from "./stream/ws-core";
41
41
  export * from "./stream/ws-presence-store";
42
+ export * from "./stream/ws-session-id";
42
43
  export * from "./stream/ws";
43
44
  export * from "./stream/ws-registry";
44
45
  export * from "./tasks/decorator";