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
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
const resolvedNodeId =
|
|
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
|
-
|
|
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
|
-
):
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
582
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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 을 한 곳에 모아 원자적으로 처리함
|