sonamu 0.9.4 → 0.9.6
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/ai/providers/rtzr/utils.js +2 -2
- package/dist/api/config.d.ts +13 -2
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/context.d.ts +17 -7
- package/dist/api/context.d.ts.map +1 -1
- package/dist/api/context.js +1 -1
- package/dist/api/decorators.d.ts +18 -0
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +54 -3
- package/dist/api/index.js +8 -3
- package/dist/api/sonamu.d.ts +24 -9
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +365 -79
- package/dist/api/websocket-helpers.d.ts +24 -0
- package/dist/api/websocket-helpers.d.ts.map +1 -0
- package/dist/api/websocket-helpers.js +77 -0
- package/dist/bin/cli.js +12 -4
- package/dist/database/upsert-builder.js +4 -4
- package/dist/dict/sonamu-dictionary.js +6 -6
- package/dist/entity/entity-manager.js +1 -1
- package/dist/entity/entity.js +3 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +8 -9
- package/dist/stream/index.d.ts +6 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +13 -2
- package/dist/stream/ws-audience-resolver.d.ts +15 -0
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
- package/dist/stream/ws-audience-resolver.js +31 -0
- package/dist/stream/ws-audience.d.ts +28 -0
- package/dist/stream/ws-audience.d.ts.map +1 -0
- package/dist/stream/ws-audience.js +46 -0
- package/dist/stream/ws-cluster-bus.d.ts +23 -0
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
- package/dist/stream/ws-cluster-bus.js +18 -0
- package/dist/stream/ws-core.d.ts +15 -0
- package/dist/stream/ws-core.d.ts.map +1 -0
- package/dist/stream/ws-core.js +1 -0
- package/dist/stream/ws-delivery.d.ts +24 -0
- package/dist/stream/ws-delivery.d.ts.map +1 -0
- package/dist/stream/ws-delivery.js +103 -0
- package/dist/stream/ws-local-connection-store.d.ts +10 -0
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
- package/dist/stream/ws-local-connection-store.js +44 -0
- package/dist/stream/ws-presence-store.d.ts +61 -0
- package/dist/stream/ws-presence-store.d.ts.map +1 -0
- package/dist/stream/ws-presence-store.js +236 -0
- package/dist/stream/ws-registry.d.ts +42 -0
- package/dist/stream/ws-registry.d.ts.map +1 -0
- package/dist/stream/ws-registry.js +108 -0
- package/dist/stream/ws.d.ts +52 -0
- package/dist/stream/ws.d.ts.map +1 -0
- package/dist/stream/ws.js +397 -0
- package/dist/syncer/api-parser.d.ts.map +1 -1
- package/dist/syncer/api-parser.js +72 -2
- package/dist/syncer/checksum.d.ts.map +1 -1
- package/dist/syncer/checksum.js +13 -12
- package/dist/syncer/code-generator.d.ts.map +1 -1
- package/dist/syncer/code-generator.js +7 -4
- package/dist/syncer/event-batcher.d.ts +27 -0
- package/dist/syncer/event-batcher.d.ts.map +1 -0
- package/dist/syncer/event-batcher.js +69 -0
- package/dist/syncer/file-patterns.d.ts +48 -26
- package/dist/syncer/file-patterns.d.ts.map +1 -1
- package/dist/syncer/file-patterns.js +71 -23
- package/dist/syncer/file-tracking.d.ts +13 -0
- package/dist/syncer/file-tracking.d.ts.map +1 -0
- package/dist/syncer/file-tracking.js +33 -0
- package/dist/syncer/index.js +2 -2
- package/dist/syncer/module-loader.d.ts +2 -11
- package/dist/syncer/module-loader.d.ts.map +1 -1
- package/dist/syncer/module-loader.js +3 -3
- package/dist/syncer/syncer-actions.d.ts +39 -6
- package/dist/syncer/syncer-actions.d.ts.map +1 -1
- package/dist/syncer/syncer-actions.js +125 -10
- package/dist/syncer/syncer.d.ts +33 -19
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +168 -168
- package/dist/syncer/watcher.d.ts +8 -0
- package/dist/syncer/watcher.d.ts.map +1 -0
- package/dist/syncer/watcher.js +105 -0
- package/dist/tasks/workflow-manager.d.ts.map +1 -1
- package/dist/tasks/workflow-manager.js +2 -1
- package/dist/template/implementations/services.template.d.ts.map +1 -1
- package/dist/template/implementations/services.template.js +36 -1
- package/dist/testing/bootstrap.d.ts.map +1 -1
- package/dist/testing/bootstrap.js +8 -1
- package/dist/testing/data-explorer.d.ts.map +1 -1
- package/dist/testing/data-explorer.js +5 -3
- package/dist/testing/fixture-manager.js +1 -1
- package/dist/types/types.d.ts +2 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +2 -2
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +4 -3
- package/dist/ui/cdd-service.js +1 -1
- package/dist/ui-web/assets/{index-C5KUjXm0.js → index-BmThfg-s.js} +39 -39
- package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/dist/utils/async-utils.d.ts +27 -3
- package/dist/utils/async-utils.d.ts.map +1 -1
- package/dist/utils/async-utils.js +56 -6
- package/dist/utils/formatter.d.ts +7 -1
- package/dist/utils/formatter.d.ts.map +1 -1
- package/dist/utils/formatter.js +95 -60
- package/dist/utils/fs-utils.d.ts +2 -0
- package/dist/utils/fs-utils.d.ts.map +1 -1
- package/dist/utils/fs-utils.js +10 -2
- package/dist/utils/process-utils.d.ts +6 -0
- package/dist/utils/process-utils.d.ts.map +1 -1
- package/dist/utils/process-utils.js +16 -3
- package/dist/utils/utils.d.ts +1 -0
- package/dist/utils/utils.d.ts.map +1 -1
- package/dist/utils/utils.js +2 -2
- package/package.json +7 -5
- package/src/ai/providers/rtzr/utils.ts +1 -1
- package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
- package/src/api/__tests__/websocket-context.types.test.ts +58 -0
- package/src/api/config.ts +28 -2
- package/src/api/context.ts +21 -7
- package/src/api/decorators.ts +101 -1
- package/src/api/sonamu.ts +529 -127
- package/src/api/websocket-helpers.ts +122 -0
- package/src/bin/cli.ts +10 -2
- package/src/database/upsert-builder.ts +3 -3
- package/src/dict/sonamu-dictionary.ts +3 -3
- package/src/entity/entity.ts +1 -1
- package/src/index.ts +6 -0
- package/src/migration/code-generation.ts +6 -11
- package/src/shared/app.shared.ts.txt +312 -4
- package/src/shared/web.shared.ts.txt +340 -4
- package/src/stream/__tests__/ws-contracts.test.ts +381 -0
- package/src/stream/__tests__/ws.test.ts +449 -0
- package/src/stream/index.ts +6 -0
- package/src/stream/ws-audience-resolver.ts +35 -0
- package/src/stream/ws-audience.ts +62 -0
- package/src/stream/ws-cluster-bus.ts +32 -0
- package/src/stream/ws-core.ts +16 -0
- package/src/stream/ws-delivery.ts +138 -0
- package/src/stream/ws-local-connection-store.ts +44 -0
- package/src/stream/ws-presence-store.ts +326 -0
- package/src/stream/ws-registry.ts +138 -0
- package/src/stream/ws.ts +591 -0
- package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
- package/src/syncer/api-parser.ts +112 -1
- package/src/syncer/checksum.ts +23 -29
- package/src/syncer/code-generator.ts +4 -1
- package/src/syncer/event-batcher.ts +72 -0
- package/src/syncer/file-patterns.ts +98 -30
- package/src/syncer/file-tracking.ts +27 -0
- package/src/syncer/module-loader.ts +5 -12
- package/src/syncer/syncer-actions.ts +179 -17
- package/src/syncer/syncer.ts +250 -287
- package/src/syncer/watcher.ts +128 -0
- package/src/tasks/workflow-manager.ts +1 -0
- package/src/template/__tests__/services.template.websocket.test.ts +79 -0
- package/src/template/implementations/services.template.ts +69 -0
- package/src/testing/bootstrap.ts +8 -1
- package/src/testing/data-explorer.ts +3 -2
- package/src/types/types.ts +20 -2
- package/src/ui/api.ts +10 -1
- package/src/utils/async-utils.ts +71 -4
- package/src/utils/formatter.ts +114 -75
- package/src/utils/fs-utils.ts +9 -0
- package/src/utils/process-utils.ts +17 -0
- package/src/utils/utils.ts +1 -1
- package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
package/src/stream/ws.ts
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { type WebSocket } from "ws";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { type WebSocketAudience } from "./ws-audience";
|
|
7
|
+
import { type WebSocketClusterBus } from "./ws-cluster-bus";
|
|
8
|
+
import { type WebSocketPresenceStore } from "./ws-presence-store";
|
|
9
|
+
import {
|
|
10
|
+
type ManagedWebSocketConnection,
|
|
11
|
+
WebSocketRegistry,
|
|
12
|
+
type WebSocketRegistryOptions,
|
|
13
|
+
type WebSocketRoomId,
|
|
14
|
+
type WebSocketUserId,
|
|
15
|
+
} from "./ws-registry";
|
|
16
|
+
|
|
17
|
+
// transport-level 상수와 queue threshold를 한 파일에 모아 lifecycle/backpressure 정책을 중앙화함
|
|
18
|
+
const WS_CONNECTING = 0;
|
|
19
|
+
const WS_OPEN = 1;
|
|
20
|
+
const WS_CLOSED = 3;
|
|
21
|
+
// RFC 6455 close codes used by Sonamu's WebSocket runtime.
|
|
22
|
+
const WS_CLOSE_CODE_GOING_AWAY = 1001;
|
|
23
|
+
const WS_CLOSE_CODE_INVALID_FRAME_PAYLOAD_DATA = 1007;
|
|
24
|
+
const WS_CLOSE_CODE_POLICY_VIOLATION = 1008;
|
|
25
|
+
const WS_CLOSE_CODE_MESSAGE_TOO_BIG = 1009;
|
|
26
|
+
const WS_CLOSE_CODE_INTERNAL_ERROR = 1011;
|
|
27
|
+
const WS_CLOSE_CODE_TRY_AGAIN_LATER = 1013;
|
|
28
|
+
const MAX_PENDING_MESSAGES = 100;
|
|
29
|
+
const MAX_PENDING_OUTBOUND_MESSAGES = 1_000;
|
|
30
|
+
const MAX_SOCKET_BUFFERED_AMOUNT = 1_048_576;
|
|
31
|
+
const OUTBOUND_BATCH_SIZE = 50;
|
|
32
|
+
const OUTBOUND_RETRY_DELAY_MS = 5;
|
|
33
|
+
|
|
34
|
+
// envelope을 `{event, data}` 형태로 고정해 server handler와 generated client가 같은 framing contract를 쓰게 함
|
|
35
|
+
const WebSocketEnvelopeSchema = z.object({
|
|
36
|
+
event: z.string(),
|
|
37
|
+
data: z.unknown(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
type MessageHandler<T> = (data: T) => void | Promise<void>;
|
|
41
|
+
type CloseHandler = () => void | Promise<void>;
|
|
42
|
+
|
|
43
|
+
export type WebSocketEventMap = Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
type InferWebSocketEventMap<TSchema extends z.ZodRawShape> = z.infer<z.ZodObject<TSchema>>;
|
|
46
|
+
|
|
47
|
+
export type WebSocketOutEvents<TOut extends WebSocketEventMap = WebSocketEventMap> = TOut;
|
|
48
|
+
|
|
49
|
+
export type WebSocketInEvents<TIn extends WebSocketEventMap = WebSocketEventMap> = TIn;
|
|
50
|
+
|
|
51
|
+
export interface WebSocketConnection<
|
|
52
|
+
TOut extends WebSocketEventMap = WebSocketEventMap,
|
|
53
|
+
TIn extends WebSocketEventMap = WebSocketEventMap,
|
|
54
|
+
> extends ManagedWebSocketConnection {
|
|
55
|
+
transport: "ws";
|
|
56
|
+
onClose(callback: CloseHandler): void;
|
|
57
|
+
onMessage<K extends keyof WebSocketInEvents<TIn>>(
|
|
58
|
+
event: K,
|
|
59
|
+
handler: MessageHandler<WebSocketInEvents<TIn>[K]>,
|
|
60
|
+
): void;
|
|
61
|
+
publish<K extends keyof WebSocketOutEvents<TOut>>(
|
|
62
|
+
event: K,
|
|
63
|
+
data: WebSocketOutEvents<TOut>[K],
|
|
64
|
+
): void;
|
|
65
|
+
waitForClose(): Promise<void>;
|
|
66
|
+
join(roomId: WebSocketRoomId): void;
|
|
67
|
+
leave(roomId: WebSocketRoomId): void;
|
|
68
|
+
setUserId(userId: WebSocketUserId): void;
|
|
69
|
+
clearUserId(): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type AnyWebSocketConnection = WebSocketConnection<WebSocketEventMap, WebSocketEventMap>;
|
|
73
|
+
|
|
74
|
+
type ParsedEnvelope = z.infer<typeof WebSocketEnvelopeSchema>;
|
|
75
|
+
|
|
76
|
+
type WebSocketConnectionOptions<TOut extends z.ZodRawShape, TIn extends z.ZodRawShape> = {
|
|
77
|
+
namespace?: string;
|
|
78
|
+
heartbeat?: number;
|
|
79
|
+
maxPayload?: number;
|
|
80
|
+
active?: boolean;
|
|
81
|
+
outEvents: z.ZodObject<TOut>;
|
|
82
|
+
inEvents: z.ZodObject<TIn>;
|
|
83
|
+
registry: WebSocketRegistry;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type WebSocketRuntimeOptions = {
|
|
87
|
+
nodeId?: string;
|
|
88
|
+
presenceStore?: WebSocketPresenceStore;
|
|
89
|
+
clusterBus?: WebSocketClusterBus;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// registry를 소유하고 connection 생성/shutdown을 담당함. Sonamu 애플리케이션 수명주기와 같이 움직이도록 설계함
|
|
93
|
+
export class WebSocketRuntime {
|
|
94
|
+
readonly registry: WebSocketRegistry;
|
|
95
|
+
|
|
96
|
+
constructor(options: WebSocketRuntimeOptions = {}) {
|
|
97
|
+
const registryOptions: WebSocketRegistryOptions = {
|
|
98
|
+
nodeId: options.nodeId,
|
|
99
|
+
presenceStore: options.presenceStore,
|
|
100
|
+
clusterBus: options.clusterBus,
|
|
101
|
+
};
|
|
102
|
+
this.registry = new WebSocketRegistry(registryOptions);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
registerConnection<TOutSchema extends z.ZodRawShape, TInSchema extends z.ZodRawShape>(
|
|
106
|
+
socket: WebSocket,
|
|
107
|
+
options: Omit<WebSocketConnectionOptions<TOutSchema, TInSchema>, "registry">,
|
|
108
|
+
): WebSocketConnection<InferWebSocketEventMap<TOutSchema>, InferWebSocketEventMap<TInSchema>> {
|
|
109
|
+
return new WebSocketConnectionImpl(socket, {
|
|
110
|
+
...options,
|
|
111
|
+
registry: this.registry,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
activateConnection(connectionId: string): void {
|
|
116
|
+
this.registry.activate(connectionId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
broadcast(event: string, data: unknown, namespace?: string): void {
|
|
120
|
+
this.registry.broadcast(event, data, namespace);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
publishToRoom(roomId: WebSocketRoomId, event: string, data: unknown, namespace?: string): void {
|
|
124
|
+
this.registry.publishToRoom(roomId, event, data, namespace);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
publishToUser(userId: WebSocketUserId, event: string, data: unknown, namespace?: string): void {
|
|
128
|
+
this.registry.publishToUser(userId, event, data, namespace);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
publishToAudience(audience: WebSocketAudience, event: string, data: unknown): void {
|
|
132
|
+
this.registry.publishToAudience(audience, event, data);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 프로세스 종료 시 살아있는 연결이 남지 않도록 registry를 순회해 일괄 종료함
|
|
136
|
+
async shutdown(
|
|
137
|
+
code: number = WS_CLOSE_CODE_GOING_AWAY,
|
|
138
|
+
reason = "Server shutting down",
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
await this.registry.shutdown(code, reason);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createWebSocketRuntime(options: WebSocketRuntimeOptions = {}): WebSocketRuntime {
|
|
145
|
+
return new WebSocketRuntime(options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
class WebSocketConnectionImpl<
|
|
149
|
+
TOutSchema extends z.ZodRawShape,
|
|
150
|
+
TInSchema extends z.ZodRawShape,
|
|
151
|
+
> implements WebSocketConnection<
|
|
152
|
+
InferWebSocketEventMap<TOutSchema>,
|
|
153
|
+
InferWebSocketEventMap<TInSchema>
|
|
154
|
+
> {
|
|
155
|
+
readonly id = randomUUID();
|
|
156
|
+
readonly transport = "ws";
|
|
157
|
+
readonly namespace: string;
|
|
158
|
+
|
|
159
|
+
private readonly closeCallbacks: CloseHandler[] = [];
|
|
160
|
+
private readonly messageHandlers = new Map<string, Array<MessageHandler<unknown>>>();
|
|
161
|
+
private readonly pendingMessages: ParsedEnvelope[] = [];
|
|
162
|
+
private readonly pendingOutboundMessages: string[] = [];
|
|
163
|
+
private readonly closePromise: Promise<void>;
|
|
164
|
+
private readonly resolveClosePromise: () => void;
|
|
165
|
+
private readonly heartbeatMs: number;
|
|
166
|
+
private readonly maxPayload?: number;
|
|
167
|
+
private readonly eventSchemasIn: Record<string, z.ZodTypeAny>;
|
|
168
|
+
private readonly eventSchemasOut: Record<string, z.ZodTypeAny>;
|
|
169
|
+
|
|
170
|
+
private closedInternal = false;
|
|
171
|
+
private closeStarted = false;
|
|
172
|
+
private awaitingPong = false;
|
|
173
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
174
|
+
private messageQueue: Promise<void> = Promise.resolve();
|
|
175
|
+
private outboundFlushScheduled = false;
|
|
176
|
+
|
|
177
|
+
constructor(
|
|
178
|
+
private readonly socket: WebSocket,
|
|
179
|
+
private readonly options: WebSocketConnectionOptions<TOutSchema, TInSchema>,
|
|
180
|
+
) {
|
|
181
|
+
this.namespace = options.namespace ?? "default";
|
|
182
|
+
this.heartbeatMs = options.heartbeat ?? 30000;
|
|
183
|
+
this.maxPayload = options.maxPayload;
|
|
184
|
+
this.eventSchemasIn = options.inEvents.shape as unknown as Record<string, z.ZodTypeAny>;
|
|
185
|
+
this.eventSchemasOut = options.outEvents.shape as unknown as Record<string, z.ZodTypeAny>;
|
|
186
|
+
|
|
187
|
+
let resolveClosePromise!: () => void;
|
|
188
|
+
this.closePromise = new Promise<void>((resolve) => {
|
|
189
|
+
resolveClosePromise = resolve;
|
|
190
|
+
});
|
|
191
|
+
this.resolveClosePromise = resolveClosePromise;
|
|
192
|
+
|
|
193
|
+
this.options.registry.register(this, options.active ?? true);
|
|
194
|
+
this.socket.on("message", this.handleMessage);
|
|
195
|
+
this.socket.on("close", this.handleClose);
|
|
196
|
+
this.socket.on("error", this.handleError);
|
|
197
|
+
this.socket.on("pong", this.handlePong);
|
|
198
|
+
this.startHeartbeat();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
get closed(): boolean {
|
|
202
|
+
return this.closedInternal;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
onClose(callback: CloseHandler): void {
|
|
206
|
+
this.closeCallbacks.push(callback);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
onMessage<K extends keyof InferWebSocketEventMap<TInSchema>>(
|
|
210
|
+
event: K,
|
|
211
|
+
handler: MessageHandler<InferWebSocketEventMap<TInSchema>[K]>,
|
|
212
|
+
): void {
|
|
213
|
+
const eventKey = String(event);
|
|
214
|
+
const handlers = this.messageHandlers.get(eventKey) ?? [];
|
|
215
|
+
handlers.push(handler as MessageHandler<unknown>);
|
|
216
|
+
this.messageHandlers.set(eventKey, handlers);
|
|
217
|
+
this.flushPendingMessages(eventKey);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
publish<K extends keyof InferWebSocketEventMap<TOutSchema>>(
|
|
221
|
+
event: K,
|
|
222
|
+
data: InferWebSocketEventMap<TOutSchema>[K],
|
|
223
|
+
): void {
|
|
224
|
+
this.publishValidated(String(event), data);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
publishUntyped(event: string, data: unknown): void {
|
|
228
|
+
this.publishValidated(event, data);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
waitForClose(): Promise<void> {
|
|
232
|
+
return this.closePromise;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
join(roomId: WebSocketRoomId): void {
|
|
236
|
+
this.options.registry.join(this.id, roomId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
leave(roomId: WebSocketRoomId): void {
|
|
240
|
+
this.options.registry.leave(this.id, roomId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
setUserId(userId: WebSocketUserId): void {
|
|
244
|
+
this.options.registry.setUserId(this.id, userId);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
clearUserId(): void {
|
|
248
|
+
this.options.registry.clearUserId(this.id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// transport 종료 도중 예외가 나도 markClosed가 반드시 실행되도록 try/finally로 감쌈
|
|
252
|
+
close(code?: number, reason?: string): void {
|
|
253
|
+
if (this.closedInternal || this.closeStarted || this.socket.readyState === WS_CLOSED) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.closeStarted = true;
|
|
258
|
+
try {
|
|
259
|
+
this.closeTransport(code, reason);
|
|
260
|
+
} finally {
|
|
261
|
+
this.markClosed();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 인바운드 메시지를 순차 처리 큐에 올림. payload size → envelope 파싱 순으로 transport 레벨 검증을 우선 수행함
|
|
266
|
+
private readonly handleMessage = (raw: unknown) => {
|
|
267
|
+
this.enqueueMessageTask(async () => {
|
|
268
|
+
const text = normalizeMessage(raw);
|
|
269
|
+
if (this.maxPayload !== undefined && Buffer.byteLength(text) > this.maxPayload) {
|
|
270
|
+
this.close(WS_CLOSE_CODE_MESSAGE_TOO_BIG, "Message too large");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const parsedEnvelope = safeParseEnvelope(text);
|
|
275
|
+
if (!parsedEnvelope) {
|
|
276
|
+
this.close(WS_CLOSE_CODE_INVALID_FRAME_PAYLOAD_DATA, "Invalid message payload");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.options.registry.touch(this.id);
|
|
281
|
+
await this.dispatchEnvelope(parsedEnvelope);
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
private readonly handleClose = () => {
|
|
286
|
+
this.markClosed();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// 소켓이 transport error를 emit하면 즉시 1011 close로 수렴시켜 상태 누락을 막음
|
|
290
|
+
private readonly handleError = () => {
|
|
291
|
+
this.close(WS_CLOSE_CODE_INTERNAL_ERROR, "WebSocket transport error");
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
private readonly handlePong = () => {
|
|
295
|
+
this.awaitingPong = false;
|
|
296
|
+
this.options.registry.touch(this.id);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// event 존재 여부 → schema 검증 → handler 실행 순으로 분기함
|
|
300
|
+
// handler가 아직 등록되지 않은 초기 메시지는 버퍼에 보관했다가 onMessage 등록 시 flush함
|
|
301
|
+
// handler는 `await`으로 순차 실행해 한 connection 안의 메시지 순서를 보장함
|
|
302
|
+
private async dispatchEnvelope(envelope: ParsedEnvelope): Promise<void> {
|
|
303
|
+
const handlers = this.messageHandlers.get(envelope.event);
|
|
304
|
+
const schema = this.eventSchemasIn[envelope.event];
|
|
305
|
+
|
|
306
|
+
if (!schema) {
|
|
307
|
+
this.close(WS_CLOSE_CODE_POLICY_VIOLATION, "Unknown event");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const parsed = schema.safeParse(envelope.data);
|
|
312
|
+
if (!parsed.success) {
|
|
313
|
+
this.close(WS_CLOSE_CODE_INVALID_FRAME_PAYLOAD_DATA, "Invalid event data");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!handlers || handlers.length === 0) {
|
|
318
|
+
if (this.pendingMessages.length >= MAX_PENDING_MESSAGES) {
|
|
319
|
+
this.pendingMessages.shift();
|
|
320
|
+
}
|
|
321
|
+
this.pendingMessages.push(envelope);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (const handler of handlers) {
|
|
326
|
+
await handler(parsed.data);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private flushPendingMessages(event: string): void {
|
|
331
|
+
const remaining: ParsedEnvelope[] = [];
|
|
332
|
+
const toFlush: ParsedEnvelope[] = [];
|
|
333
|
+
|
|
334
|
+
for (const message of this.pendingMessages) {
|
|
335
|
+
if (message.event !== event) {
|
|
336
|
+
remaining.push(message);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
toFlush.push(message);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
this.pendingMessages.length = 0;
|
|
344
|
+
this.pendingMessages.push(...remaining);
|
|
345
|
+
|
|
346
|
+
for (const message of toFlush) {
|
|
347
|
+
this.enqueueMessageTask(async () => {
|
|
348
|
+
await this.dispatchEnvelope(message);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private publishValidated(event: string, data: unknown): void {
|
|
354
|
+
const schema = this.eventSchemasOut[event];
|
|
355
|
+
if (!schema) {
|
|
356
|
+
throw new Error(`Unknown websocket event: ${event}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const parsed = schema.safeParse(data);
|
|
360
|
+
if (!parsed.success) {
|
|
361
|
+
throw new Error(`Invalid websocket event payload: ${event}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (this.closedInternal || this.socket.readyState !== WS_OPEN) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this.enqueueOutboundMessage(
|
|
369
|
+
JSON.stringify({
|
|
370
|
+
event,
|
|
371
|
+
data: parsed.data,
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// listener 해제 / heartbeat 중단 / pending queue 비움 / registry unregister / onClose 실행 / waitForClose resolve 을 한 곳에 모아 원자적으로 처리함
|
|
377
|
+
// async onClose가 reject해도 unhandled rejection으로 새지 않도록 catch로 격리함
|
|
378
|
+
private markClosed(): void {
|
|
379
|
+
if (this.closedInternal) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.closedInternal = true;
|
|
384
|
+
this.closeStarted = false;
|
|
385
|
+
this.stopHeartbeat();
|
|
386
|
+
this.socket.off("message", this.handleMessage);
|
|
387
|
+
this.socket.off("close", this.handleClose);
|
|
388
|
+
this.socket.off("error", this.handleError);
|
|
389
|
+
this.socket.off("pong", this.handlePong);
|
|
390
|
+
this.awaitingPong = false;
|
|
391
|
+
this.pendingMessages.length = 0;
|
|
392
|
+
this.pendingOutboundMessages.length = 0;
|
|
393
|
+
this.options.registry.unregister(this.id);
|
|
394
|
+
for (const callback of this.closeCallbacks.splice(0)) {
|
|
395
|
+
try {
|
|
396
|
+
const result = callback();
|
|
397
|
+
if (isPromiseLike(result)) {
|
|
398
|
+
void result.catch(() => {
|
|
399
|
+
// async close callbacks must not escape as unhandled rejections
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
// close callbacks must not block transport cleanup
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
this.resolveClosePromise();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// pong이 오지 않은 상태에서 다음 tick이 오면 timeout close로 처리해 zombie connection을 정리함
|
|
410
|
+
private startHeartbeat(): void {
|
|
411
|
+
if (this.heartbeatMs <= 0) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.heartbeatTimer = setInterval(() => {
|
|
416
|
+
if (this.closedInternal || this.socket.readyState !== WS_OPEN) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.awaitingPong) {
|
|
421
|
+
this.close(WS_CLOSE_CODE_GOING_AWAY, "Heartbeat timeout");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
this.awaitingPong = true;
|
|
426
|
+
this.socket.ping();
|
|
427
|
+
}, this.heartbeatMs);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private stopHeartbeat(): void {
|
|
431
|
+
if (!this.heartbeatTimer) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
clearInterval(this.heartbeatTimer);
|
|
436
|
+
this.heartbeatTimer = null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// close가 실패해도 terminate 폴백까지 시도하고, 끝내 실패하면 markClosed에 상태 정리를 위임함
|
|
440
|
+
private closeTransport(code?: number, reason?: string): void {
|
|
441
|
+
try {
|
|
442
|
+
if (this.socket.readyState === WS_OPEN || this.socket.readyState === WS_CONNECTING) {
|
|
443
|
+
this.socket.close(code, truncateCloseReason(reason));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.socket.terminate();
|
|
448
|
+
} catch {
|
|
449
|
+
try {
|
|
450
|
+
this.socket.terminate();
|
|
451
|
+
} catch {
|
|
452
|
+
// transport is already broken; state cleanup is handled by markClosed()
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 인바운드 handler를 promise chain으로 serialize 하고, handler 예외는 connection-local 1011 close로 축소함
|
|
458
|
+
private enqueueMessageTask(task: () => Promise<void>): void {
|
|
459
|
+
this.messageQueue = this.messageQueue
|
|
460
|
+
.then(async () => {
|
|
461
|
+
if (this.closedInternal) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
await task();
|
|
466
|
+
})
|
|
467
|
+
.catch(() => {
|
|
468
|
+
this.close(WS_CLOSE_CODE_INTERNAL_ERROR, "Message handling failed");
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// queue가 한계에 도달하면 1013으로 닫아 느린 소비자가 메모리를 끝없이 잡아먹지 못하게 함
|
|
473
|
+
private enqueueOutboundMessage(payload: string): void {
|
|
474
|
+
if (this.pendingOutboundMessages.length >= MAX_PENDING_OUTBOUND_MESSAGES) {
|
|
475
|
+
this.close(WS_CLOSE_CODE_TRY_AGAIN_LATER, "WebSocket backpressure overflow");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
this.pendingOutboundMessages.push(payload);
|
|
480
|
+
this.scheduleOutboundFlush();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private scheduleOutboundFlush(delayMs: number = 0): void {
|
|
484
|
+
if (this.outboundFlushScheduled || this.closedInternal) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this.outboundFlushScheduled = true;
|
|
489
|
+
const flush = () => {
|
|
490
|
+
this.outboundFlushScheduled = false;
|
|
491
|
+
this.flushOutboundMessages();
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (delayMs > 0) {
|
|
495
|
+
setTimeout(flush, delayMs);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
setImmediate(flush);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// bufferedAmount가 임계치를 넘으면 flush를 미뤄 socket 내부 큐가 터지지 않도록 backpressure를 존중함
|
|
503
|
+
// 한 번에 배치 단위로만 send해 동기 루프가 이벤트 루프를 장시간 점유하지 않게 함
|
|
504
|
+
private flushOutboundMessages(): void {
|
|
505
|
+
if (this.closedInternal || this.socket.readyState !== WS_OPEN) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (this.socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT) {
|
|
510
|
+
this.scheduleOutboundFlush(OUTBOUND_RETRY_DELAY_MS);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
let sent = 0;
|
|
515
|
+
while (
|
|
516
|
+
sent < OUTBOUND_BATCH_SIZE &&
|
|
517
|
+
this.pendingOutboundMessages.length > 0 &&
|
|
518
|
+
this.socket.readyState === WS_OPEN
|
|
519
|
+
) {
|
|
520
|
+
const payload = this.pendingOutboundMessages.shift();
|
|
521
|
+
if (!payload) {
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
this.socket.send(payload);
|
|
527
|
+
} catch {
|
|
528
|
+
this.close(WS_CLOSE_CODE_INTERNAL_ERROR, "Outbound publish failed");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
sent += 1;
|
|
533
|
+
if (this.socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT) {
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (this.pendingOutboundMessages.length > 0) {
|
|
539
|
+
this.scheduleOutboundFlush(
|
|
540
|
+
this.socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT ? OUTBOUND_RETRY_DELAY_MS : 0,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function normalizeMessage(raw: unknown): string {
|
|
547
|
+
if (typeof raw === "string") {
|
|
548
|
+
return raw;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (raw instanceof Buffer) {
|
|
552
|
+
return raw.toString("utf-8");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (raw instanceof ArrayBuffer) {
|
|
556
|
+
return Buffer.from(raw).toString("utf-8");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (Array.isArray(raw)) {
|
|
560
|
+
return Buffer.concat(raw.filter((chunk): chunk is Buffer => chunk instanceof Buffer)).toString(
|
|
561
|
+
"utf-8",
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return JSON.stringify(raw);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function safeParseEnvelope(raw: string): ParsedEnvelope | null {
|
|
569
|
+
try {
|
|
570
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
571
|
+
const validated = WebSocketEnvelopeSchema.safeParse(parsed);
|
|
572
|
+
return validated.success ? validated.data : null;
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// RFC 6455가 close frame reason을 123 byte로 제한하므로 초과분은 잘라 전송 실패를 방지함
|
|
579
|
+
function truncateCloseReason(reason?: string): string | undefined {
|
|
580
|
+
if (!reason) {
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return Buffer.byteLength(reason, "utf-8") <= 123
|
|
585
|
+
? reason
|
|
586
|
+
: Buffer.from(reason).subarray(0, 123).toString("utf-8");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isPromiseLike(value: unknown): value is Promise<void> {
|
|
590
|
+
return typeof value === "object" && value !== null && "then" in value && "catch" in value;
|
|
591
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "fs/promises";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { registeredApis } from "../../api/decorators";
|
|
9
|
+
import { readApisFromFile } from "../api-parser";
|
|
10
|
+
|
|
11
|
+
describe("readApisFromFile websocket type refs", () => {
|
|
12
|
+
let originalApis: Array<(typeof registeredApis)[number]> = [];
|
|
13
|
+
let tempDir: string | null = null;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
originalApis = [...registeredApis];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
registeredApis.splice(0, registeredApis.length, ...originalApis);
|
|
21
|
+
if (tempDir) {
|
|
22
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
23
|
+
tempDir = null;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("captures websocket event identifiers for generated service imports", async () => {
|
|
28
|
+
registeredApis.push({
|
|
29
|
+
modelName: "ChatFrame",
|
|
30
|
+
methodName: "subscribeChat",
|
|
31
|
+
path: "/chat/subscribeChat",
|
|
32
|
+
options: {
|
|
33
|
+
httpMethod: "GET",
|
|
34
|
+
},
|
|
35
|
+
websocketOptions: {
|
|
36
|
+
outEvents: z.object({
|
|
37
|
+
ready: z.object({
|
|
38
|
+
ok: z.boolean(),
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
inEvents: z.object({
|
|
42
|
+
ping: z.object({
|
|
43
|
+
at: z.string(),
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
tempDir = await mkdtemp(path.join(tmpdir(), "sonamu-api-parser-"));
|
|
50
|
+
const filePath = path.join(tempDir, "chat.frame.ts");
|
|
51
|
+
await writeFile(
|
|
52
|
+
filePath,
|
|
53
|
+
`
|
|
54
|
+
class ChatFrameClass {
|
|
55
|
+
@api()
|
|
56
|
+
@websocket({
|
|
57
|
+
outEvents: ChatOutEvents,
|
|
58
|
+
inEvents: ChatInEvents,
|
|
59
|
+
})
|
|
60
|
+
async subscribeChat(): Promise<void> {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
`.trim(),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const [api] = await readApisFromFile(filePath as `${string}.ts`);
|
|
68
|
+
|
|
69
|
+
expect(api?.websocketOptions?.outEventsTypeRef).toEqual({
|
|
70
|
+
t: "ref",
|
|
71
|
+
id: "ChatOutEvents",
|
|
72
|
+
});
|
|
73
|
+
expect(api?.websocketOptions?.inEventsTypeRef).toEqual({
|
|
74
|
+
t: "ref",
|
|
75
|
+
id: "ChatInEvents",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|