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
package/src/stream/ws.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { type EventEmitter } from "node:events";
3
- import { hostname } from "node:os";
4
3
 
5
4
  import { type RawData, type WebSocket } from "ws";
6
5
  import { z } from "zod";
@@ -9,6 +8,9 @@ import { runtimeTypeOf } from "../runtime-type";
9
8
  import { isStringValue } from "../utils/runtime-value";
10
9
  import { type WebSocketAudience } from "./ws-audience";
11
10
  import { type WebSocketClusterBus } from "./ws-cluster-bus";
11
+ import { type WebSocketFrameConnection } from "./ws-core";
12
+ import { type WebSocketRouteDefinition } from "./ws-delivery";
13
+ import { encodeWebSocketFrame } from "./ws-frame";
12
14
  import { type WebSocketPresenceStore } from "./ws-presence-store";
13
15
  import {
14
16
  type ManagedWebSocketConnection,
@@ -17,6 +19,7 @@ import {
17
19
  type WebSocketRoomId,
18
20
  type WebSocketUserId,
19
21
  } from "./ws-registry";
22
+ import { createWebSocketNodeId, createWebSocketSessionId } from "./ws-session-id";
20
23
  import {
21
24
  type TelemetryContextProvider,
22
25
  type WebSocketTelemetryConnectionSnapshot,
@@ -95,10 +98,11 @@ export interface WebSocketConnection<
95
98
  data: WebSocketOutEvents<TOut>[K],
96
99
  ): void;
97
100
  waitForClose(): Promise<void>;
98
- join(roomId: WebSocketRoomId): void;
99
- leave(roomId: WebSocketRoomId): void;
100
- setUserId(userId: WebSocketUserId): void;
101
- clearUserId(): void;
101
+ // 아래 mutation은 메모리를 즉시 갱신하고, store 반영이 끝나면 resolve한다. reject하지 않는다
102
+ join(roomId: WebSocketRoomId): Promise<void>;
103
+ leave(roomId: WebSocketRoomId): Promise<void>;
104
+ setUserId(userId: WebSocketUserId): Promise<void>;
105
+ clearUserId(): Promise<void>;
102
106
  }
103
107
 
104
108
  export type AnyWebSocketConnection = WebSocketConnection<object, object>;
@@ -109,6 +113,8 @@ type WebSocketConnectionOptions<
109
113
  TOut extends WebSocketEventFields,
110
114
  TIn extends WebSocketEventFields,
111
115
  > = {
116
+ // route table 키(보통 API path). 원격 노드가 보낸 envelope에서 이 연결이 받을 frame을 고를 때 쓴다
117
+ routeKey?: string;
112
118
  namespace?: string;
113
119
  heartbeat?: number;
114
120
  active?: boolean;
@@ -127,6 +133,8 @@ export type WebSocketRuntimeOptions = {
127
133
  presenceStore?: WebSocketPresenceStore;
128
134
  clusterBus?: WebSocketClusterBus;
129
135
  telemetry?: boolean | WebSocketTelemetryOptions;
136
+ // shutdown 시 진행 중인 store mutation과 publish를 기다리는 상한. 기본값 5000ms
137
+ drainTimeoutMs?: number;
130
138
  };
131
139
 
132
140
  // registry를 소유하고 connection 생성/shutdown을 담당함. Sonamu 애플리케이션 수명주기와 같이 움직이도록 설계함
@@ -135,9 +143,13 @@ export class WebSocketRuntime {
135
143
  readonly telemetryController: WebSocketTelemetryController;
136
144
 
137
145
  constructor(options: WebSocketRuntimeOptions = {}) {
138
- // 분산 환경에서 노드 간 충돌을 막기 위해 hostname + pid로 디폴트 식별자를 만든다.
139
- // 같은 호스트에 여러 프로세스가 떠 있어도 pid로 구분되며, 로그/메트릭에서도 식별이 쉬움
140
- const resolvedNodeId = options.nodeId ?? `${hostname()}-${process.pid}`;
146
+ // nodeId를 주지 않으면 store/bus가 가진 nodeId를 따르고, 둘 다 없으면 hostname-pid-랜덤으로 만든다.
147
+ // store나 bus의 nodeId가 이 값과 다르면 registry 생성 시 즉시 실패한다
148
+ const resolvedNodeId =
149
+ options.nodeId ??
150
+ options.presenceStore?.nodeId ??
151
+ options.clusterBus?.nodeId ??
152
+ createWebSocketNodeId();
141
153
  this.telemetryController = createWebSocketTelemetryController(options.telemetry, {
142
154
  runtimeId: randomUUID(),
143
155
  nodeId: resolvedNodeId,
@@ -147,11 +159,25 @@ export class WebSocketRuntime {
147
159
  presenceStore: options.presenceStore,
148
160
  clusterBus: options.clusterBus,
149
161
  telemetryController: this.telemetryController,
162
+ drainTimeoutMs: options.drainTimeoutMs,
150
163
  };
151
164
  this.registry = new WebSocketRegistry(registryOptions);
152
165
  }
153
166
 
154
- registerConnection<
167
+ // routeKey별 outEvents를 등록한다. 같은 키로 다시 호출하면 스키마를 교체한다
168
+ registerRoute(routeKey: string, route: { outEvents: z.ZodObject }): void {
169
+ this.registry.registerRoute(routeKey, route.outEvents);
170
+ }
171
+
172
+ // route table 전체를 교체한다. 목록에 없는 routeKey는 제거된다
173
+ replaceRoutes(routes: WebSocketRouteDefinition[]): void {
174
+ this.registry.replaceRoutes(routes);
175
+ }
176
+
177
+ /**
178
+ * 소켓 리스너를 동기로 붙이고 registry에 등록한다. store.register가 끝나면(실패해도) resolve한다.
179
+ */
180
+ async registerConnection<
155
181
  TOutSchema extends WebSocketEventFields,
156
182
  TInSchema extends WebSocketEventFields,
157
183
  >(
@@ -160,20 +186,24 @@ export class WebSocketRuntime {
160
186
  WebSocketConnectionOptions<TOutSchema, TInSchema>,
161
187
  "registry" | "telemetryController"
162
188
  >,
163
- ): WebSocketConnection<InferWebSocketEventMap<TOutSchema>, InferWebSocketEventMap<TInSchema>> {
164
- return new WebSocketConnectionImpl(socket, {
189
+ ): Promise<
190
+ WebSocketConnection<InferWebSocketEventMap<TOutSchema>, InferWebSocketEventMap<TInSchema>>
191
+ > {
192
+ const connection = new WebSocketConnectionImpl(socket, {
165
193
  ...options,
166
194
  registry: this.registry,
167
195
  telemetryController: this.telemetryController,
168
196
  });
197
+ await this.registry.register(connection, options.active ?? true);
198
+ return connection;
169
199
  }
170
200
 
171
- activateConnection(connectionId: string): void {
172
- this.registry.activate(connectionId);
201
+ activateConnection(connectionId: string): Promise<void> {
202
+ return this.registry.activate(connectionId);
173
203
  }
174
204
 
175
- broadcast<Data>(event: string, data: Data, namespace?: string): void {
176
- this.registry.broadcast(event, data, namespace);
205
+ broadcast<Data>(event: string, data: Data, namespace?: string): Promise<void> {
206
+ return this.registry.broadcast(event, data, namespace);
177
207
  }
178
208
 
179
209
  publishToRoom<Data>(
@@ -181,8 +211,8 @@ export class WebSocketRuntime {
181
211
  event: string,
182
212
  data: Data,
183
213
  namespace?: string,
184
- ): void {
185
- this.registry.publishToRoom(roomId, event, data, namespace);
214
+ ): Promise<void> {
215
+ return this.registry.publishToRoom(roomId, event, data, namespace);
186
216
  }
187
217
 
188
218
  publishToUser<Data>(
@@ -190,12 +220,12 @@ export class WebSocketRuntime {
190
220
  event: string,
191
221
  data: Data,
192
222
  namespace?: string,
193
- ): void {
194
- this.registry.publishToUser(userId, event, data, namespace);
223
+ ): Promise<void> {
224
+ return this.registry.publishToUser(userId, event, data, namespace);
195
225
  }
196
226
 
197
- publishToAudience<Data>(audience: WebSocketAudience, event: string, data: Data): void {
198
- this.registry.publishToAudience(audience, event, data);
227
+ publishToAudience<Data>(audience: WebSocketAudience, event: string, data: Data): Promise<void> {
228
+ return this.registry.publishToAudience(audience, event, data);
199
229
  }
200
230
 
201
231
  // 프로세스 종료 시 살아있는 연결이 남지 않도록 registry를 순회해 일괄 종료함
@@ -218,12 +248,15 @@ class WebSocketConnectionImpl<
218
248
  >
219
249
  implements
220
250
  WebSocketConnection<InferWebSocketEventMap<TOutSchema>, InferWebSocketEventMap<TInSchema>>,
251
+ WebSocketFrameConnection,
221
252
  TelemetryInspectableConnection,
222
253
  TelemetryContextProvider
223
254
  {
224
- readonly id = randomUUID();
255
+ readonly id: string;
225
256
  readonly transport = "ws";
226
257
  readonly namespace: string;
258
+ readonly routeKey?: string;
259
+ readonly outEvents: z.ZodObject;
227
260
 
228
261
  private readonly closeCallbacks: CloseHandler[] = [];
229
262
  private readonly messageHandlers = new Map<string, Array<MessageHandler<unknown>>>();
@@ -233,7 +266,6 @@ class WebSocketConnectionImpl<
233
266
  private readonly resolveClosePromise: () => void;
234
267
  private readonly heartbeatMs: number;
235
268
  private readonly eventSchemasIn: Record<string, z.ZodTypeAny>;
236
- private readonly eventSchemasOut: Record<string, z.ZodTypeAny>;
237
269
 
238
270
  private readonly connectionTraceId?: string;
239
271
  private readonly connectionSpanId?: string;
@@ -259,6 +291,9 @@ class WebSocketConnectionImpl<
259
291
  private readonly socket: WebSocketTransport,
260
292
  private readonly options: WebSocketConnectionOptions<TOutSchema, TInSchema>,
261
293
  ) {
294
+ this.id = createWebSocketSessionId(options.registry.nodeId);
295
+ this.routeKey = options.routeKey;
296
+ this.outEvents = options.outEvents;
262
297
  this.namespace = options.namespace ?? "default";
263
298
  this.heartbeatMs = options.heartbeat ?? 30000;
264
299
  this.connectionTraceId = options.traceId;
@@ -266,7 +301,6 @@ class WebSocketConnectionImpl<
266
301
  this.connectionParentSpanId = options.parentSpanId;
267
302
  this.connectionSampled = options.sampled;
268
303
  this.eventSchemasIn = options.inEvents["shape"];
269
- this.eventSchemasOut = options.outEvents["shape"];
270
304
 
271
305
  let resolveClosePromise!: () => void;
272
306
  this.closePromise = new Promise<void>((resolve) => {
@@ -274,7 +308,7 @@ class WebSocketConnectionImpl<
274
308
  });
275
309
  this.resolveClosePromise = resolveClosePromise;
276
310
 
277
- this.options.registry.register(this, options.active ?? true);
311
+ // 리스너를 동기로 붙여 registry 등록이 끝나기 전에 닫혀도 close를 놓치지 않는다. 등록은 runtime이 이어서 수행한다
278
312
  this.socket.on("message", this.handleMessage);
279
313
  this.socket.on("close", this.handleClose);
280
314
  this.socket.on("error", this.handleError);
@@ -318,22 +352,32 @@ class WebSocketConnectionImpl<
318
352
  return this.closePromise;
319
353
  }
320
354
 
321
- join(roomId: WebSocketRoomId): void {
322
- this.options.registry.join(this.id, roomId);
355
+ join(roomId: WebSocketRoomId): Promise<void> {
356
+ return this.options.registry.join(this.id, roomId);
323
357
  }
324
358
 
325
- leave(roomId: WebSocketRoomId): void {
326
- this.options.registry.leave(this.id, roomId);
359
+ leave(roomId: WebSocketRoomId): Promise<void> {
360
+ return this.options.registry.leave(this.id, roomId);
327
361
  }
328
362
 
329
- setUserId(userId: WebSocketUserId): void {
363
+ setUserId(userId: WebSocketUserId): Promise<void> {
330
364
  this.currentUserId = String(userId);
331
- this.options.registry.setUserId(this.id, userId);
365
+ return this.options.registry.setUserId(this.id, userId);
332
366
  }
333
367
 
334
- clearUserId(): void {
368
+ clearUserId(): Promise<void> {
335
369
  this.currentUserId = undefined;
336
- this.options.registry.clearUserId(this.id);
370
+ return this.options.registry.clearUserId(this.id);
371
+ }
372
+
373
+ // registry fan-out이 이미 검증·인코딩한 frame을 그대로 보낸다
374
+ sendFrame(event: string, frame: string): void {
375
+ if (this.closedInternal || this.socket.readyState !== WS_OPEN) {
376
+ this.emitPublishDropped(event);
377
+ return;
378
+ }
379
+
380
+ this.enqueueOutboundMessage(frame, event);
337
381
  }
338
382
 
339
383
  getTelemetrySnapshot(): WebSocketTelemetryConnectionSnapshot {
@@ -578,8 +622,8 @@ class WebSocketConnectionImpl<
578
622
  }
579
623
 
580
624
  private publishValidated<Data>(event: string, data: Data): void {
581
- const schema = this.eventSchemasOut[event];
582
- if (!schema) {
625
+ const encoding = encodeWebSocketFrame(this.outEvents, event, data);
626
+ if (encoding.status === "unknownEvent") {
583
627
  this.options.telemetryController.emit({
584
628
  name: "ws.publish.rejected",
585
629
  level: "error",
@@ -589,8 +633,7 @@ class WebSocketConnectionImpl<
589
633
  throw new Error(`Unknown websocket event: ${event}`);
590
634
  }
591
635
 
592
- const parsed = schema.safeParse(data);
593
- if (!parsed.success) {
636
+ if (encoding.status === "invalidPayload") {
594
637
  this.options.telemetryController.emit({
595
638
  name: "ws.publish.rejected",
596
639
  level: "error",
@@ -601,22 +644,20 @@ class WebSocketConnectionImpl<
601
644
  }
602
645
 
603
646
  if (this.closedInternal || this.socket.readyState !== WS_OPEN) {
604
- this.options.telemetryController.emit({
605
- name: "ws.publish.dropped",
606
- level: "debug",
607
- ...this.telemetryFields(),
608
- detail: { event, reason: "connectionClosed" },
609
- });
647
+ this.emitPublishDropped(event);
610
648
  return;
611
649
  }
612
650
 
613
- this.enqueueOutboundMessage(
614
- JSON.stringify({
615
- event,
616
- data: parsed.data,
617
- }),
618
- event,
619
- );
651
+ this.enqueueOutboundMessage(encoding.frame, event);
652
+ }
653
+
654
+ private emitPublishDropped(event: string): void {
655
+ this.options.telemetryController.emit({
656
+ name: "ws.publish.dropped",
657
+ level: "debug",
658
+ ...this.telemetryFields(),
659
+ detail: { event, reason: "connectionClosed" },
660
+ });
620
661
  }
621
662
 
622
663
  // listener 해제 / heartbeat 중단 / pending queue 비움 / registry unregister / onClose 실행 / waitForClose resolve 을 한 곳에 모아 원자적으로 처리함