sonamu 0.11.16 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/api/sonamu.d.ts.map +1 -1
  2. package/dist/api/sonamu.js +12 -7
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -2
  6. package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
  7. package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
  8. package/dist/stream/__tests__/ws-test-helpers.js +260 -0
  9. package/dist/stream/index.d.ts +1 -0
  10. package/dist/stream/index.d.ts.map +1 -1
  11. package/dist/stream/index.js +4 -2
  12. package/dist/stream/ws-audience-resolver.d.ts +8 -5
  13. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  14. package/dist/stream/ws-audience-resolver.js +16 -13
  15. package/dist/stream/ws-cluster-bus.d.ts +9 -4
  16. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  17. package/dist/stream/ws-cluster-bus.js +1 -1
  18. package/dist/stream/ws-core.d.ts +10 -0
  19. package/dist/stream/ws-core.d.ts.map +1 -1
  20. package/dist/stream/ws-delivery.d.ts +30 -4
  21. package/dist/stream/ws-delivery.d.ts.map +1 -1
  22. package/dist/stream/ws-delivery.js +199 -52
  23. package/dist/stream/ws-frame.d.ts +15 -0
  24. package/dist/stream/ws-frame.d.ts.map +1 -0
  25. package/dist/stream/ws-frame.js +29 -0
  26. package/dist/stream/ws-local-connection-store.d.ts +4 -4
  27. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  28. package/dist/stream/ws-local-connection-store.js +2 -2
  29. package/dist/stream/ws-presence-index.d.ts +36 -0
  30. package/dist/stream/ws-presence-index.d.ts.map +1 -0
  31. package/dist/stream/ws-presence-index.js +177 -0
  32. package/dist/stream/ws-presence-store.d.ts +39 -39
  33. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  34. package/dist/stream/ws-presence-store.js +22 -152
  35. package/dist/stream/ws-registry.d.ts +45 -17
  36. package/dist/stream/ws-registry.d.ts.map +1 -1
  37. package/dist/stream/ws-registry.js +169 -60
  38. package/dist/stream/ws-session-id.d.ts +14 -0
  39. package/dist/stream/ws-session-id.d.ts.map +1 -0
  40. package/dist/stream/ws-session-id.js +37 -0
  41. package/dist/stream/ws.d.ts +20 -10
  42. package/dist/stream/ws.d.ts.map +1 -1
  43. package/dist/stream/ws.js +59 -36
  44. package/package.json +1 -1
  45. package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
  46. package/src/api/__tests__/sonamu.websocket.test.ts +241 -20
  47. package/src/api/sonamu.ts +20 -7
  48. package/src/index.ts +1 -0
  49. package/src/stream/__tests__/ws-contracts.test.ts +483 -265
  50. package/src/stream/__tests__/ws-multinode.test.ts +326 -0
  51. package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
  52. package/src/stream/__tests__/ws-test-helpers.ts +360 -0
  53. package/src/stream/__tests__/ws.test.ts +342 -145
  54. package/src/stream/index.ts +1 -0
  55. package/src/stream/ws-audience-resolver.ts +21 -19
  56. package/src/stream/ws-cluster-bus.ts +12 -4
  57. package/src/stream/ws-core.ts +14 -0
  58. package/src/stream/ws-delivery.ts +321 -97
  59. package/src/stream/ws-frame.ts +39 -0
  60. package/src/stream/ws-local-connection-store.ts +10 -9
  61. package/src/stream/ws-presence-index.ts +273 -0
  62. package/src/stream/ws-presence-store.ts +64 -284
  63. package/src/stream/ws-registry.ts +272 -85
  64. package/src/stream/ws-session-id.ts +39 -0
  65. package/src/stream/ws.ts +91 -50
@@ -1,15 +1,21 @@
1
+ import { type z } from "zod";
2
+
3
+ import { runtimeTypeOf } from "../runtime-type";
1
4
  import { isFunctionValue } from "../utils/runtime-value";
2
5
  import { WebSocketAudience, type WebSocketAudience as WebSocketAudienceSpec } from "./ws-audience";
3
6
  import { WebSocketAudienceResolver } from "./ws-audience-resolver";
4
7
  import { NoopWebSocketClusterBus, type WebSocketClusterBus } from "./ws-cluster-bus";
5
8
  import {
6
9
  type ManagedWebSocketConnection,
10
+ type MaybePromise,
11
+ type WebSocketFrameConnection,
7
12
  type WebSocketRegistryStats,
8
13
  type WebSocketRoomId,
9
14
  type WebSocketUserId,
10
15
  } from "./ws-core";
11
- import { WebSocketDeliveryEngine } from "./ws-delivery";
16
+ import { WebSocketDeliveryEngine, type WebSocketRouteDefinition } from "./ws-delivery";
12
17
  import { WebSocketLocalConnectionStore } from "./ws-local-connection-store";
18
+ import { WebSocketPresenceIndex } from "./ws-presence-index";
13
19
  import {
14
20
  InMemoryWebSocketPresenceStore,
15
21
  type WebSocketPresenceStore,
@@ -29,6 +35,22 @@ export type {
29
35
  WebSocketUserId,
30
36
  };
31
37
  export type WebSocketConnectionMeta = WebSocketSessionPresence;
38
+ export type WebSocketRoomMemberCount = {
39
+ count: number;
40
+ // store 조회에 실패해 노드 로컬 수로 대체했으면 true
41
+ partial: boolean;
42
+ };
43
+
44
+ const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
45
+
46
+ type StoreMutationOperation =
47
+ | "register"
48
+ | "activate"
49
+ | "unregister"
50
+ | "setUserId"
51
+ | "clearUserId"
52
+ | "join"
53
+ | "leave";
32
54
 
33
55
  export type WebSocketRegistryOptions = {
34
56
  // 분산 환경에서 노드 간 라우팅 식별자로 쓰이므로 반드시 호출 측(WebSocketRuntime)에서 결정해 넘긴다
@@ -36,38 +58,52 @@ export type WebSocketRegistryOptions = {
36
58
  presenceStore?: WebSocketPresenceStore;
37
59
  clusterBus?: WebSocketClusterBus;
38
60
  telemetryController?: WebSocketTelemetryController;
61
+ // shutdown 시 진행 중인 store mutation과 publish를 기다리는 상한
62
+ drainTimeoutMs?: number;
39
63
  };
40
64
 
65
+ /**
66
+ * 노드 로컬 presence를 메모리에 소유하고, 설정된 store에는 세션별 순서대로 replica를 반영한다.
67
+ * mutation과 publish는 호출 시점에 메모리를 동기로 갱신하고, store 실패는 telemetry로만 기록하며 reject하지 않는다.
68
+ */
41
69
  export class WebSocketRegistry {
42
70
  readonly nodeId: string;
43
71
  readonly localConnections = new WebSocketLocalConnectionStore();
44
72
  readonly presenceStore: WebSocketPresenceStore;
45
73
  readonly clusterBus: WebSocketClusterBus;
46
- readonly audienceResolver: WebSocketAudienceResolver;
47
- readonly deliveryEngine: WebSocketDeliveryEngine;
48
74
  readonly telemetryController: WebSocketTelemetryController;
75
+ private readonly memory = new WebSocketPresenceIndex();
76
+ private readonly deliveryEngine: WebSocketDeliveryEngine;
77
+ private readonly drainTimeoutMs: number;
78
+ // 세션별 store mutation 큐의 꼬리. 같은 세션의 호출은 이 promise 뒤에 이어 붙어 호출 순서대로 store에 도달한다
79
+ private readonly mutationQueues = new Map<string, Promise<void>>();
49
80
 
50
81
  constructor(options: WebSocketRegistryOptions) {
51
82
  this.nodeId = options.nodeId;
52
83
  this.presenceStore = options.presenceStore ?? new InMemoryWebSocketPresenceStore();
53
84
  this.clusterBus = options.clusterBus ?? new NoopWebSocketClusterBus();
85
+ this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
86
+ assertMatchingNodeId("presenceStore", this.presenceStore.nodeId, this.nodeId);
87
+ assertMatchingNodeId("clusterBus", this.clusterBus.nodeId, this.nodeId);
54
88
  this.telemetryController =
55
89
  options.telemetryController ?? new NoopWebSocketTelemetryController();
56
- this.audienceResolver = new WebSocketAudienceResolver({
57
- nodeId: this.nodeId,
58
- presenceStore: this.presenceStore,
59
- });
60
90
  this.deliveryEngine = new WebSocketDeliveryEngine({
61
91
  nodeId: this.nodeId,
62
92
  localConnections: this.localConnections,
63
- audienceResolver: this.audienceResolver,
93
+ resolveLocalSessionIds: (audience) =>
94
+ this.memory.queryAudience(audience).map((session) => session.sessionId),
95
+ audienceResolver: new WebSocketAudienceResolver({
96
+ nodeId: this.nodeId,
97
+ presenceStore: this.presenceStore,
98
+ }),
64
99
  clusterBus: this.clusterBus,
65
100
  telemetryController: this.telemetryController,
66
101
  });
67
102
 
103
+ // telemetry는 노드 로컬 메모리만 집계한다. 클러스터 합계를 모든 노드가 보고하면 집계 시 중복되기 때문이다
68
104
  this.telemetryController.registerMetricSource({
69
105
  collect: (_now) => {
70
- const stats = this.presenceStore.getStats();
106
+ const stats = this.memory.getStats();
71
107
  const localSnapshot = this.localConnections.getTelemetrySnapshot();
72
108
  return {
73
109
  activeConnections: stats.totalConnections,
@@ -81,17 +117,31 @@ export class WebSocketRegistry {
81
117
  });
82
118
  }
83
119
 
84
- register(
85
- connection: ManagedWebSocketConnection,
120
+ registerRoute(routeKey: string, outEvents: z.ZodObject): void {
121
+ this.deliveryEngine.registerRoute(routeKey, outEvents);
122
+ }
123
+
124
+ replaceRoutes(routes: WebSocketRouteDefinition[]): void {
125
+ this.deliveryEngine.replaceRoutes(routes);
126
+ }
127
+
128
+ /**
129
+ * 연결을 메모리에 즉시 등록하고 store.register가 끝나면 resolve한다.
130
+ * store가 실패해도 메모리 전용 등록으로 resolve한다.
131
+ */
132
+ async register(
133
+ connection: WebSocketFrameConnection,
86
134
  active: boolean = true,
87
- ): WebSocketConnectionMeta {
88
- this.localConnections.register(connection);
89
- const meta = this.presenceStore.register({
135
+ ): Promise<WebSocketConnectionMeta> {
136
+ const registration = {
90
137
  sessionId: connection.id,
91
138
  nodeId: this.nodeId,
92
139
  namespace: connection.namespace,
93
140
  active,
94
- });
141
+ connectedAt: new Date(),
142
+ };
143
+ this.localConnections.register(connection);
144
+ this.memory.register(registration);
95
145
  this.telemetryController.emit({
96
146
  name: "ws.connection.registered",
97
147
  level: "info",
@@ -108,16 +158,27 @@ export class WebSocketRegistry {
108
158
  tags: { outcome: "registered", namespace: connection.namespace },
109
159
  userId: connection.userId,
110
160
  });
111
- return meta;
161
+
162
+ await this.enqueueStoreMutation(connection.id, "register", () =>
163
+ this.presenceStore.register(registration),
164
+ );
165
+ // 등록 도중 닫혔으면 메모리에서 이미 빠졌으므로 등록 시점 값을 돌려준다
166
+ return (
167
+ this.memory.getConnection(connection.id) ?? {
168
+ ...registration,
169
+ rooms: new Set(),
170
+ lastSeenAt: registration.connectedAt,
171
+ }
172
+ );
112
173
  }
113
174
 
114
- activate(connectionId: string): void {
115
- if (!this.presenceStore.getConnection(connectionId)) {
175
+ activate(connectionId: string): Promise<void> {
176
+ if (!this.memory.has(connectionId)) {
116
177
  this.emitMutationSkipped(connectionId, { operation: "activate" });
117
- return;
178
+ return Promise.resolve();
118
179
  }
119
180
 
120
- this.presenceStore.activate(connectionId);
181
+ this.memory.activate(connectionId);
121
182
  const connection = this.localConnections.getConnection(connectionId);
122
183
  this.telemetryController.emit({
123
184
  name: "ws.connection.activated",
@@ -126,14 +187,16 @@ export class WebSocketRegistry {
126
187
  userId: connection?.userId,
127
188
  ...getConnectionTelemetryContext(connection),
128
189
  });
190
+ return this.enqueueStoreMutation(connectionId, "activate", () =>
191
+ this.presenceStore.activate(connectionId),
192
+ );
129
193
  }
130
194
 
131
- unregister(connectionId: string): void {
195
+ unregister(connectionId: string): Promise<void> {
132
196
  const connection = this.localConnections.getConnection(connectionId);
133
197
  const telemetryContext = getConnectionTelemetryContext(connection);
134
- const userId =
135
- connection?.userId ?? formatUserId(this.presenceStore.getConnection(connectionId)?.userId);
136
- const meta = this.presenceStore.unregister(connectionId);
198
+ const meta = this.memory.unregister(connectionId);
199
+ const userId = connection?.userId ?? formatUserId(meta?.userId);
137
200
  this.localConnections.unregister(connectionId);
138
201
  this.telemetryController.emit({
139
202
  name: "ws.connection.unregistered",
@@ -143,50 +206,60 @@ export class WebSocketRegistry {
143
206
  userId,
144
207
  ...telemetryContext,
145
208
  });
146
- if (meta) {
147
- this.telemetryController.recordMetric({
148
- name: "sonamu.ws.connections",
149
- kind: "counter",
150
- value: 1,
151
- unit: "1",
152
- tags: { outcome: "unregistered", namespace: meta.namespace },
153
- userId,
154
- });
209
+ if (!meta) {
210
+ return Promise.resolve();
155
211
  }
212
+
213
+ this.telemetryController.recordMetric({
214
+ name: "sonamu.ws.connections",
215
+ kind: "counter",
216
+ value: 1,
217
+ unit: "1",
218
+ tags: { outcome: "unregistered", namespace: meta.namespace },
219
+ userId,
220
+ });
221
+ // 같은 세션 큐에 이어 붙으므로 진행 중인 register보다 먼저 store에 도달하지 않는다
222
+ return this.enqueueStoreMutation(connectionId, "unregister", () =>
223
+ this.presenceStore.unregister(connectionId),
224
+ );
156
225
  }
157
226
 
227
+ // lastSeenAt은 메모리만 갱신한다. store에는 마지막 mutation 시점 값이 남는다
158
228
  touch(connectionId: string): void {
159
- this.presenceStore.touch(connectionId);
229
+ this.memory.touch(connectionId);
160
230
  }
161
231
 
162
- setUserId(connectionId: string, userId: WebSocketUserId): void {
163
- if (!this.presenceStore.getConnection(connectionId)) {
232
+ setUserId(connectionId: string, userId: WebSocketUserId): Promise<void> {
233
+ const meta = this.memory.getConnection(connectionId);
234
+ if (!meta) {
164
235
  this.emitMutationSkipped(connectionId, { operation: "setUserId", userId });
165
- return;
236
+ return Promise.resolve();
166
237
  }
167
238
 
168
- this.presenceStore.setUserId(connectionId, userId);
169
- const meta = this.presenceStore.getConnection(connectionId);
239
+ this.memory.setUserId(connectionId, userId);
170
240
  this.telemetryController.emit({
171
241
  name: "ws.user.bound",
172
242
  level: "debug",
173
243
  connectionId,
174
- namespace: meta?.namespace,
244
+ namespace: meta.namespace,
175
245
  userId: String(userId),
176
246
  detail: { userId },
177
247
  ...getConnectionTelemetryContext(this.localConnections.getConnection(connectionId)),
178
248
  });
249
+ return this.enqueueStoreMutation(connectionId, "setUserId", () =>
250
+ this.presenceStore.setUserId(connectionId, userId),
251
+ );
179
252
  }
180
253
 
181
- clearUserId(connectionId: string): void {
182
- const meta = this.presenceStore.getConnection(connectionId);
254
+ clearUserId(connectionId: string): Promise<void> {
255
+ const meta = this.memory.getConnection(connectionId);
183
256
  if (!meta) {
184
257
  this.emitMutationSkipped(connectionId, { operation: "clearUserId" });
185
- return;
258
+ return Promise.resolve();
186
259
  }
187
260
 
188
261
  const userId = meta.userId;
189
- this.presenceStore.clearUserId(connectionId);
262
+ this.memory.clearUserId(connectionId);
190
263
  this.telemetryController.emit({
191
264
  name: "ws.user.cleared",
192
265
  level: "debug",
@@ -196,36 +269,42 @@ export class WebSocketRegistry {
196
269
  detail: userId !== undefined ? { userId } : undefined,
197
270
  ...getConnectionTelemetryContext(this.localConnections.getConnection(connectionId)),
198
271
  });
272
+ return this.enqueueStoreMutation(connectionId, "clearUserId", () =>
273
+ this.presenceStore.clearUserId(connectionId),
274
+ );
199
275
  }
200
276
 
201
- join(connectionId: string, roomId: WebSocketRoomId): void {
202
- if (!this.presenceStore.getConnection(connectionId)) {
277
+ join(connectionId: string, roomId: WebSocketRoomId): Promise<void> {
278
+ const meta = this.memory.getConnection(connectionId);
279
+ if (!meta) {
203
280
  this.emitMutationSkipped(connectionId, { operation: "join", roomId });
204
- return;
281
+ return Promise.resolve();
205
282
  }
206
283
 
207
- this.presenceStore.join(connectionId, roomId);
208
- const meta = this.presenceStore.getConnection(connectionId);
284
+ this.memory.join(connectionId, roomId);
209
285
  const connection = this.localConnections.getConnection(connectionId);
210
286
  this.telemetryController.emit({
211
287
  name: "ws.room.joined",
212
288
  level: "debug",
213
289
  connectionId,
214
- namespace: meta?.namespace,
215
- userId: connection?.userId ?? formatUserId(meta?.userId),
290
+ namespace: meta.namespace,
291
+ userId: connection?.userId ?? formatUserId(meta.userId),
216
292
  detail: { roomId },
217
293
  ...getConnectionTelemetryContext(connection),
218
294
  });
295
+ return this.enqueueStoreMutation(connectionId, "join", () =>
296
+ this.presenceStore.join(connectionId, roomId),
297
+ );
219
298
  }
220
299
 
221
- leave(connectionId: string, roomId: WebSocketRoomId): void {
222
- const meta = this.presenceStore.getConnection(connectionId);
300
+ leave(connectionId: string, roomId: WebSocketRoomId): Promise<void> {
301
+ const meta = this.memory.getConnection(connectionId);
223
302
  if (!meta) {
224
303
  this.emitMutationSkipped(connectionId, { operation: "leave", roomId });
225
- return;
304
+ return Promise.resolve();
226
305
  }
227
306
 
228
- this.presenceStore.leave(connectionId, roomId);
307
+ this.memory.leave(connectionId, roomId);
229
308
  const connection = this.localConnections.getConnection(connectionId);
230
309
  this.telemetryController.emit({
231
310
  name: "ws.room.left",
@@ -236,26 +315,13 @@ export class WebSocketRegistry {
236
315
  detail: { roomId },
237
316
  ...getConnectionTelemetryContext(connection),
238
317
  });
318
+ return this.enqueueStoreMutation(connectionId, "leave", () =>
319
+ this.presenceStore.leave(connectionId, roomId),
320
+ );
239
321
  }
240
322
 
241
- private emitMutationSkipped(
242
- connectionId: string,
243
- detail: {
244
- operation: "activate" | "setUserId" | "clearUserId" | "join" | "leave";
245
- roomId?: WebSocketRoomId;
246
- userId?: WebSocketUserId;
247
- },
248
- ): void {
249
- this.telemetryController.emit({
250
- name: "ws.registry.mutation.skipped",
251
- level: "debug",
252
- connectionId,
253
- detail: { ...detail, reason: "connectionMissing" },
254
- });
255
- }
256
-
257
- broadcast<Data>(event: string, data: Data, namespace?: string): void {
258
- this.publishToAudience(WebSocketAudience.all(namespace), event, data);
323
+ broadcast<Data>(event: string, data: Data, namespace?: string): Promise<void> {
324
+ return this.publishToAudience(WebSocketAudience.all(namespace), event, data);
259
325
  }
260
326
 
261
327
  publishToRoom<Data>(
@@ -263,8 +329,8 @@ export class WebSocketRegistry {
263
329
  event: string,
264
330
  data: Data,
265
331
  namespace?: string,
266
- ): void {
267
- this.publishToAudience(WebSocketAudience.room(roomId, namespace), event, data);
332
+ ): Promise<void> {
333
+ return this.publishToAudience(WebSocketAudience.room(roomId, namespace), event, data);
268
334
  }
269
335
 
270
336
  publishToUser<Data>(
@@ -272,40 +338,161 @@ export class WebSocketRegistry {
272
338
  event: string,
273
339
  data: Data,
274
340
  namespace?: string,
275
- ): void {
276
- this.publishToAudience(WebSocketAudience.user(userId, namespace), event, data);
341
+ ): Promise<void> {
342
+ return this.publishToAudience(WebSocketAudience.user(userId, namespace), event, data);
277
343
  }
278
344
 
279
- publishToAudience<Data>(audience: WebSocketAudienceSpec, event: string, data: Data): void {
280
- this.deliveryEngine.publishToAudience(audience, event, data);
345
+ publishToAudience<Data>(
346
+ audience: WebSocketAudienceSpec,
347
+ event: string,
348
+ data: Data,
349
+ ): Promise<void> {
350
+ return this.deliveryEngine.publishToAudience(audience, event, data);
281
351
  }
282
352
 
353
+ // 아래 읽기 API는 노드 로컬 snapshot(복사본)이다
283
354
  getConnection(connectionId: string): WebSocketConnectionMeta | undefined {
284
- return this.presenceStore.getConnection(connectionId);
355
+ return this.memory.getConnection(connectionId);
285
356
  }
286
357
 
287
358
  getConnectionCount(namespace?: string): number {
288
- return this.presenceStore.getConnectionCount(namespace);
359
+ return this.memory.getConnectionCount(namespace);
289
360
  }
290
361
 
291
362
  getRoomMembers(roomId: WebSocketRoomId, namespace?: string): WebSocketConnectionMeta[] {
292
- return this.presenceStore.getRoomMembers(roomId, namespace);
363
+ return this.memory.getRoomMembers(roomId, namespace);
293
364
  }
294
365
 
295
366
  getStats(): WebSocketRegistryStats {
296
- return this.presenceStore.getStats();
367
+ return this.memory.getStats();
368
+ }
369
+
370
+ /**
371
+ * 클러스터 전체의 활성 방 인원수. store가 실패하면 노드 로컬 수에 partial: true를 붙여 반환하며 reject하지 않는다.
372
+ */
373
+ async getRoomMemberCount(
374
+ roomId: WebSocketRoomId,
375
+ namespace?: string,
376
+ ): Promise<WebSocketRoomMemberCount> {
377
+ try {
378
+ const count = await callStore(() => this.presenceStore.getRoomMemberCount(roomId, namespace));
379
+ return { count, partial: false };
380
+ } catch (cause) {
381
+ this.telemetryController.emit({
382
+ name: "ws.registry.read.failed",
383
+ level: "warn",
384
+ detail: { operation: "getRoomMemberCount", roomId, errorType: describeError(cause) },
385
+ });
386
+ return { count: this.memory.getRoomMemberCount(roomId, namespace), partial: true };
387
+ }
297
388
  }
298
389
 
299
390
  closeAll(code?: number, reason?: string): void {
300
391
  this.localConnections.closeAll(code, reason);
301
392
  }
302
393
 
394
+ /**
395
+ * 로컬 연결을 닫고, 진행 중인 store mutation과 publish를 drainTimeoutMs까지 기다린 뒤 store와 bus를 종료한다.
396
+ */
303
397
  async shutdown(code?: number, reason?: string): Promise<void> {
304
398
  this.closeAll(code, reason);
305
- await this.deliveryEngine.shutdown();
399
+
400
+ let timer: ReturnType<typeof setTimeout> | undefined;
401
+ const deadline = new Promise<void>((resolve) => {
402
+ timer = setTimeout(resolve, this.drainTimeoutMs);
403
+ });
404
+ try {
405
+ await Promise.race([
406
+ Promise.all([...this.mutationQueues.values(), this.deliveryEngine.drainPublishes()]),
407
+ deadline,
408
+ ]);
409
+ } finally {
410
+ clearTimeout(timer);
411
+ }
412
+
413
+ try {
414
+ await callStore(() => this.presenceStore.shutdown?.());
415
+ } catch (cause) {
416
+ this.telemetryController.emit({
417
+ name: "ws.registry.shutdown.failed",
418
+ level: "error",
419
+ detail: { errorType: describeError(cause) },
420
+ });
421
+ }
422
+ // 구독 해제는 drain이 끝났으므로 즉시 포기할 수 있게 이미 끝난 deadline을 넘긴다
423
+ await this.deliveryEngine.shutdown(Promise.resolve());
424
+ }
425
+
426
+ /**
427
+ * 세션별 큐 뒤에 store 호출을 이어 붙인다. 실패는 telemetry로만 기록하고, 반환한 promise는 reject하지 않는다.
428
+ */
429
+ private enqueueStoreMutation(
430
+ sessionId: string,
431
+ operation: StoreMutationOperation,
432
+ call: () => MaybePromise<void>,
433
+ ): Promise<void> {
434
+ const previous = this.mutationQueues.get(sessionId) ?? Promise.resolve();
435
+ const next: Promise<void> = previous
436
+ .then(call)
437
+ .then(
438
+ () => undefined,
439
+ (cause: unknown) => {
440
+ this.telemetryController.emit({
441
+ name: "ws.registry.mutation.failed",
442
+ level: "warn",
443
+ connectionId: sessionId,
444
+ detail: { operation, errorType: describeError(cause) },
445
+ });
446
+ },
447
+ )
448
+ .then(() => {
449
+ // 마지막 mutation이 끝나면 큐를 비워 세션 수만큼 promise가 쌓이지 않게 한다
450
+ if (this.mutationQueues.get(sessionId) === next) {
451
+ this.mutationQueues.delete(sessionId);
452
+ }
453
+ });
454
+ this.mutationQueues.set(sessionId, next);
455
+ return next;
456
+ }
457
+
458
+ private emitMutationSkipped(
459
+ connectionId: string,
460
+ detail: {
461
+ operation: "activate" | "setUserId" | "clearUserId" | "join" | "leave";
462
+ roomId?: WebSocketRoomId;
463
+ userId?: WebSocketUserId;
464
+ },
465
+ ): void {
466
+ this.telemetryController.emit({
467
+ name: "ws.registry.mutation.skipped",
468
+ level: "debug",
469
+ connectionId,
470
+ detail: { ...detail, reason: "connectionMissing" },
471
+ });
306
472
  }
307
473
  }
308
474
 
475
+ // store 호출의 동기 throw를 rejection으로 바꾼다
476
+ function callStore<T>(call: () => MaybePromise<T>): Promise<T> {
477
+ return Promise.resolve().then(call);
478
+ }
479
+
480
+ function assertMatchingNodeId(
481
+ source: "presenceStore" | "clusterBus",
482
+ sourceNodeId: string | undefined,
483
+ nodeId: string,
484
+ ): void {
485
+ if (sourceNodeId !== undefined && sourceNodeId !== nodeId) {
486
+ throw new Error(
487
+ `WebSocket ${source}.nodeId (${sourceNodeId}) must match the runtime nodeId (${nodeId}).`,
488
+ );
489
+ }
490
+ }
491
+
492
+ function describeError(cause: unknown): string {
493
+ return cause instanceof Error ? cause.name : runtimeTypeOf(cause);
494
+ }
495
+
309
496
  function getConnectionTelemetryContext(
310
497
  connection: ManagedWebSocketConnection | undefined,
311
498
  ): WebSocketTelemetryConnectionContext {
@@ -316,7 +503,7 @@ function getConnectionTelemetryContext(
316
503
  return connection.getTelemetryContext();
317
504
  }
318
505
 
319
- // telemetry record의 userId는 string 정규화 — presence store는 number | string 그대로 보관함
506
+ // telemetry record의 userId는 string 정규화 — presence는 number | string 그대로 보관함
320
507
  function formatUserId(userId: WebSocketUserId | undefined): string | undefined {
321
508
  return userId === undefined ? undefined : String(userId);
322
509
  }
@@ -0,0 +1,39 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+
4
+ // 세션 ID는 `${nodeId}:${uuid}` 형식이다. uuid에는 ':'가 없으므로 마지막 ':'로 나누면
5
+ // 노드 ID에 ':' 같은 구분자가 들어 있어도 그대로 복원된다
6
+ const SESSION_ID_SEPARATOR = ":";
7
+ const SESSION_SUFFIX_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
8
+
9
+ /**
10
+ * 기본 노드 ID를 만든다. 형식은 `${hostname()}-${process.pid}-${짧은 랜덤}`이다.
11
+ * 컨테이너 재시작 뒤 hostname과 pid가 같아도 이전 노드의 stale 상태를 물려받지 않도록 랜덤 접미사를 붙인다.
12
+ */
13
+ export function createWebSocketNodeId(): string {
14
+ return `${hostname()}-${process.pid}-${randomBytes(6).toString("base64url")}`;
15
+ }
16
+
17
+ /**
18
+ * 노드 ID를 포함한 세션 ID를 만든다. `connections` audience는 이 ID만으로 대상 노드를 찾으므로 store 조회가 필요 없다.
19
+ */
20
+ export function createWebSocketSessionId(nodeId: string): string {
21
+ return `${nodeId}${SESSION_ID_SEPARATOR}${randomUUID()}`;
22
+ }
23
+
24
+ /**
25
+ * 세션 ID에서 노드 ID를 복원한다. Sonamu가 만든 형식이 아니면 undefined를 반환한다.
26
+ */
27
+ export function parseWebSocketSessionNodeId(sessionId: string): string | undefined {
28
+ const separatorIndex = sessionId.lastIndexOf(SESSION_ID_SEPARATOR);
29
+ if (separatorIndex < 0) {
30
+ return undefined;
31
+ }
32
+
33
+ const suffix = sessionId.slice(separatorIndex + SESSION_ID_SEPARATOR.length);
34
+ if (!SESSION_SUFFIX_PATTERN.test(suffix)) {
35
+ return undefined;
36
+ }
37
+
38
+ return sessionId.slice(0, separatorIndex);
39
+ }