@grest-ts/websocket 0.0.5 → 0.0.7

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.
@@ -1,394 +1,394 @@
1
- /**
2
- * Unified WebSocket implementation for both client and server
3
- * Handles messaging only - context is set from effects at connection time
4
- */
5
-
6
- import {Message, MessageType, RegularMessage, RequestMessage} from "./SocketMessage";
7
- import {SocketAdapter} from "./SocketAdapter";
8
- import {PendingRequestsMap} from "./utils/PendingRequestsMap";
9
- import {GG_WS_MESSAGE} from "../server/GG_WS_MESSAGE";
10
- import {GGWebSocketMetrics} from "../server/GGWebSocketMetrics";
11
- import {GGLog} from "@grest-ts/logger";
12
- import {ERROR, GGPromise, ROUTE_NOT_FOUND, SERVER_ERROR} from "@grest-ts/schema";
13
- import {GG_METRICS} from "@grest-ts/metrics";
14
- import {GGLocator} from "@grest-ts/locator";
15
- import {GGContext} from "@grest-ts/context";
16
- import {GG_TRACE} from "@grest-ts/trace";
17
-
18
- /**
19
- * Handler configuration for socket messages.
20
- * Handler is expected to be already wrapped with contract validation.
21
- */
22
- export interface SocketHandlerConfig {
23
- path: string;
24
- handler: (data?: any) => GGPromise<any, any> | Promise<any> | void;
25
- }
26
-
27
- export interface GGSocketConfig {
28
- apiName?: string;
29
- socketPath?: string;
30
- /** Optional wrapper that runs handlers in the context established at connection time */
31
- connectionContext?: GGContext;
32
- }
33
-
34
- interface MetricLabels {
35
- api: string;
36
- path: string;
37
- method: string;
38
- }
39
-
40
- export class GGSocket {
41
-
42
- private readonly socket: SocketAdapter;
43
- private readonly handlers: Map<string, SocketHandlerConfig> = new Map();
44
- private readonly pendingRequests = new PendingRequestsMap();
45
-
46
- private unknownMessageHandler?: (path: string, data: any) => void;
47
-
48
- private isActive = true;
49
- private isCleanedUp = false;
50
- private tearingDownPromise: Promise<void>;
51
-
52
- private readonly onTearDownCallbacks: Array<() => Promise<void>> = [];
53
- private readonly onCloseCallbacks: Array<() => void> = [];
54
- private readonly onErrorCallbacks: Array<(error: Error) => void> = [];
55
-
56
- // Metrics labels (optional - set when created by server)
57
- private readonly apiName: string;
58
- private readonly socketPath: string;
59
- private readonly connectionContext: GGContext;
60
-
61
- constructor(socket: SocketAdapter, config?: GGSocketConfig) {
62
- this.socket = socket;
63
- this.apiName = config?.apiName ?? 'unknown';
64
- this.socketPath = config?.socketPath ?? 'unknown';
65
- // Default to passthrough if no context wrapper provided
66
- this.connectionContext = config?.connectionContext ?? new GGContext('__unnamed_GGSocket_context');
67
-
68
- // Capture contexts at construction - socket events lose AsyncLocalStorage context
69
- const scope = GGLocator.getScope();
70
-
71
- this.socket.onMessage(async (data: string) => {
72
- scope.ensureEntered();
73
- const context = new GGContext("ws-message", this.connectionContext);
74
- await context.run(async () => {
75
- GG_TRACE.init();
76
-
77
- const msg = Message.parse(data);
78
- if (!msg) {
79
- return;
80
- }
81
-
82
- if (this.isActive) {
83
- if (msg.type === MessageType.MSG || msg.type === MessageType.REQ) {
84
- GG_WS_MESSAGE.set({path: msg.path});
85
- await this.handleIncomingMessage(msg);
86
- } else if (msg.type === MessageType.RES) {
87
- this.pendingRequests.resolve(msg.id, msg.data);
88
- }
89
- }
90
- })
91
- });
92
-
93
- this.socket.onClose(() => {
94
- scope.ensureEntered();
95
- this.connectionContext.run(() => {
96
- this.isActive = false;
97
- if (!this.isCleanedUp) {
98
- this.isCleanedUp = true;
99
- this.pendingRequests.rejectAll(new SERVER_ERROR({displayMessage: "Socket connection closed!"}));
100
- this.onCloseCallbacks.forEach((onClose) => {
101
- try {
102
- onClose?.();
103
- } catch (e) {
104
- this.onErrorCallbacks.forEach(cb => cb(e));
105
- }
106
- });
107
- }
108
- });
109
- });
110
-
111
- this.socket.onError((error: Error) => {
112
- scope.ensureEntered();
113
- this.connectionContext.run(() => {
114
- this.onErrorCallbacks.forEach(cb => cb(error));
115
- });
116
- });
117
- }
118
-
119
- // --------------------------------------------------------------------------------------
120
- // Incoming message handling
121
- // --------------------------------------------------------------------------------------
122
-
123
- private async handleIncomingMessage(msg: RegularMessage | RequestMessage): Promise<void> {
124
- const startTime = performance.now();
125
- const labels: MetricLabels = {api: this.apiName, path: this.socketPath, method: msg.path};
126
- const expectsResponse = msg.type === MessageType.REQ;
127
-
128
- try {
129
- const handlerDef = this.handlers.get(msg.path);
130
-
131
- if (!handlerDef) {
132
- this.handleMissingRoute(msg, labels, startTime, expectsResponse);
133
- return;
134
- }
135
-
136
- // Handler returns GGPromise - use asResult() to get OK | ERROR
137
- const ggPromise = handlerDef.handler(msg.data);
138
- const res = await (ggPromise as any).asResult();
139
-
140
- if (expectsResponse) {
141
- this.sendResponse(msg as RequestMessage, res, labels, startTime);
142
- } else {
143
- this.handleFireAndForgetResult(res, labels, startTime);
144
- }
145
- } catch (error) {
146
- GGLog.error(this, error);
147
- this.onErrorCallbacks.forEach(cb => cb(error as Error));
148
- }
149
- }
150
-
151
- private handleMissingRoute(
152
- msg: RegularMessage | RequestMessage,
153
- labels: MetricLabels,
154
- startTime: number,
155
- expectsResponse: boolean
156
- ): void {
157
- if (expectsResponse) {
158
- const error = new ROUTE_NOT_FOUND({displayMessage: "Route not found: " + msg.path});
159
- GGLog.error(this, error);
160
- this.sendResponse(msg as RequestMessage, error, labels, startTime);
161
- } else {
162
- if (this.unknownMessageHandler) {
163
- this.unknownMessageHandler(msg.path, msg.data);
164
- } else {
165
- GGLog.warn(this, 'Unknown method ' + msg.path);
166
- }
167
- this.recordInMetric(labels, 'ROUTE_NOT_FOUND', startTime);
168
- }
169
- }
170
-
171
- private sendResponse(
172
- msg: RequestMessage,
173
- res: any,
174
- labels: MetricLabels,
175
- startTime: number
176
- ): void {
177
- const isError = res instanceof ERROR || (res && res.success === false);
178
- const resultType = isError ? res.type : 'OK';
179
- // res is OK_JSON or ERROR - send as-is (it's already in the right format)
180
- const data = isError && res instanceof ERROR ? res.toJSON() : res;
181
-
182
- this.recordInMetric(labels, resultType, startTime);
183
-
184
- try {
185
- this.socket.send(Message.create(MessageType.RES, msg.path, msg.id, data));
186
- } catch (error) {
187
- GGLog.error(this, "ERROR_SENDING_RESPONSE", ERROR.fromUnknown(error));
188
- }
189
- }
190
-
191
- private handleFireAndForgetResult(res: any, labels: MetricLabels, startTime: number): void {
192
- const isError = res instanceof ERROR || (res && res.success === false);
193
- if (isError) {
194
- this.recordInMetric(labels, res.type, startTime);
195
- this.onErrorCallbacks.forEach(cb => cb(res));
196
- } else {
197
- this.recordInMetric(labels, 'OK', startTime);
198
- }
199
- }
200
-
201
- // --------------------------------------------------------------------------------------
202
- // Outgoing message handling
203
- // --------------------------------------------------------------------------------------
204
-
205
- /**
206
- * Send a message over the socket (raw transport).
207
- * Data is expected to be already validated by the contract layer.
208
- *
209
- * @param path - The message path/route
210
- * @param body - The message data (already validated)
211
- * @param expectsResponse - Whether to wait for a response
212
- */
213
- public async send(
214
- path: string,
215
- body: any,
216
- expectsResponse: boolean
217
- ): Promise<any> {
218
- const labels: MetricLabels = {api: this.apiName, path: this.socketPath, method: path};
219
- const startTime = performance.now();
220
-
221
- if (!this.isActive) {
222
- this.recordOutMetric(labels, 'CONNECTION_CLOSED');
223
- throw new Error('Cannot send: WebSocket is not connected');
224
- }
225
-
226
- if (expectsResponse) {
227
- return this.pendingRequests.create(path, 30000, async (id, waitForResponse) => {
228
- this.socket.send(Message.create(MessageType.REQ, path, id, body));
229
- const result = await waitForResponse;
230
- const resultType = result?.success === true ? 'OK' : (result?.type ?? 'SERVER_ERROR');
231
- this.recordOutMetric(labels, resultType, startTime);
232
- return result;
233
- });
234
- } else {
235
- this.socket.send(Message.create(MessageType.MSG, path, "", body));
236
- this.recordOutMetric(labels, 'OK');
237
- }
238
- }
239
-
240
- // --------------------------------------------------------------------------------------
241
- // Metrics helpers
242
- // --------------------------------------------------------------------------------------
243
-
244
- private recordInMetric(labels: MetricLabels, result: string, startTime?: number): void {
245
- if (GG_METRICS.has()) {
246
- GGWebSocketMetrics.requests.inc(1, {...labels, result});
247
- if (startTime !== undefined) {
248
- GGWebSocketMetrics.requestDuration.observe(performance.now() - startTime, labels);
249
- }
250
- }
251
- }
252
-
253
- private recordOutMetric(labels: MetricLabels, result: string, startTime?: number): void {
254
- if (GG_METRICS.has()) {
255
- GGWebSocketMetrics.outRequests.inc(1, {...labels, result});
256
- if (startTime !== undefined) {
257
- GGWebSocketMetrics.outRequestDuration.observe(performance.now() - startTime, labels);
258
- }
259
- }
260
- }
261
-
262
- // --------------------------------------------------------------------------------------
263
- // Handler registration
264
- // --------------------------------------------------------------------------------------
265
-
266
- public registerHandler(config: SocketHandlerConfig): void {
267
- this.handlers.set(config.path, config);
268
- }
269
-
270
- public unregisterHandler(path: string): void {
271
- this.handlers.delete(path);
272
- }
273
-
274
- public setUnknownMessageHandler(handler: (path: string, data: any) => void): void {
275
- this.unknownMessageHandler = handler;
276
- }
277
-
278
- // --------------------------------------------------------------------------------------
279
- // Lifecycle callbacks
280
- // --------------------------------------------------------------------------------------
281
-
282
- public onError(onError: (error: Error) => void): this {
283
- this.onErrorCallbacks.push(onError);
284
- return this;
285
- }
286
-
287
- public onTearDown(onClosing: () => Promise<void>): this {
288
- this.onTearDownCallbacks.push(onClosing);
289
- return this;
290
- }
291
-
292
- public onClose(onClose: () => void): this {
293
- this.onCloseCallbacks.push(onClose);
294
- return this;
295
- }
296
-
297
- // --------------------------------------------------------------------------------------
298
- // Teardown
299
- // --------------------------------------------------------------------------------------
300
-
301
- /**
302
- * Get the number of pending outgoing requests
303
- */
304
- public get pendingRequestCount(): number {
305
- return this.pendingRequests.size;
306
- }
307
-
308
- /**
309
- * Starts the socket closing process gracefully.
310
- * 1. First waits for pending outgoing requests to complete (up to pendingRequestsTimeoutMs)
311
- * 2. Then runs user teardown handlers
312
- * 3. Finally closes the socket
313
- *
314
- * @param pendingRequestsTimeoutMs - Max time to wait for pending requests (default: 5000ms)
315
- * @param callbacksTimeoutMs - Max time to wait for teardown callbacks (default: 5000ms)
316
- */
317
- public async teardown(pendingRequestsTimeoutMs: number = 5000, callbacksTimeoutMs: number = 5000): Promise<void> {
318
- if (this.tearingDownPromise) {
319
- GGLog.warn(this, 'Already tearing down!');
320
- return this.tearingDownPromise;
321
- }
322
- GGLog.debug(this, 'Teardown started');
323
-
324
- this.tearingDownPromise = (async () => {
325
- // Step 1: Wait for pending outgoing requests to complete
326
- if (this.pendingRequests.hasPending()) {
327
- GGLog.debug(this, `Waiting for ${this.pendingRequests.size} pending request(s) to complete...`);
328
- await this.pendingRequests.waitForPending(pendingRequestsTimeoutMs);
329
- if (this.pendingRequests.hasPending()) {
330
- GGLog.warn(this, `Timeout waiting for pending requests, ${this.pendingRequests.size} request(s) still pending`);
331
- }
332
- }
333
-
334
- // Step 2: Run user teardown callbacks with timeout
335
- await this.runTeardownCallbacks(callbacksTimeoutMs);
336
-
337
- // Step 3: Close the socket
338
- this.close();
339
- })();
340
-
341
- return this.tearingDownPromise;
342
- }
343
-
344
- /**
345
- * Run all teardown callbacks concurrently with a timeout.
346
- */
347
- private runTeardownCallbacks(timeoutMs: number): Promise<void> {
348
- if (this.onTearDownCallbacks.length === 0) {
349
- return Promise.resolve();
350
- }
351
-
352
- return new Promise<void>((resolve) => {
353
- let remaining = this.onTearDownCallbacks.length;
354
- let resolved = false;
355
-
356
- const finish = () => {
357
- if (!resolved) {
358
- resolved = true;
359
- clearTimeout(timeout);
360
- resolve();
361
- }
362
- };
363
-
364
- const timeout = setTimeout(() => {
365
- GGLog.warn(this, `Teardown timeout - ${remaining} callback(s) still pending after ${timeoutMs}ms`);
366
- finish();
367
- }, timeoutMs);
368
-
369
- for (const callback of this.onTearDownCallbacks) {
370
- callback()
371
- .catch((err) => GGLog.error(this, 'Error in teardown callback', err))
372
- .finally(() => {
373
- remaining--;
374
- if (remaining === 0) finish();
375
- });
376
- }
377
- });
378
- }
379
-
380
- /**
381
- * Immediately closes the socket.
382
- */
383
- public close(): void {
384
- if (this.isActive) {
385
- this.isActive = false;
386
- try {
387
- this.socket.close();
388
- } catch (e) {
389
- // Ignore close errors
390
- }
391
- }
392
- }
393
-
394
- }
1
+ /**
2
+ * Unified WebSocket implementation for both client and server
3
+ * Handles messaging only - context is set from effects at connection time
4
+ */
5
+
6
+ import {Message, MessageType, RegularMessage, RequestMessage} from "./SocketMessage";
7
+ import {SocketAdapter} from "./SocketAdapter";
8
+ import {PendingRequestsMap} from "./utils/PendingRequestsMap";
9
+ import {GG_WS_MESSAGE} from "../server/GG_WS_MESSAGE";
10
+ import {GGWebSocketMetrics} from "../server/GGWebSocketMetrics";
11
+ import {GGLog} from "@grest-ts/logger";
12
+ import {ERROR, GGPromise, ROUTE_NOT_FOUND, SERVER_ERROR} from "@grest-ts/schema";
13
+ import {GG_METRICS} from "@grest-ts/metrics";
14
+ import {GGLocator} from "@grest-ts/locator";
15
+ import {GGContext} from "@grest-ts/context";
16
+ import {GG_TRACE} from "@grest-ts/trace";
17
+
18
+ /**
19
+ * Handler configuration for socket messages.
20
+ * Handler is expected to be already wrapped with contract validation.
21
+ */
22
+ export interface SocketHandlerConfig {
23
+ path: string;
24
+ handler: (data?: any) => GGPromise<any, any> | Promise<any> | void;
25
+ }
26
+
27
+ export interface GGSocketConfig {
28
+ apiName?: string;
29
+ socketPath?: string;
30
+ /** Optional wrapper that runs handlers in the context established at connection time */
31
+ connectionContext?: GGContext;
32
+ }
33
+
34
+ interface MetricLabels {
35
+ api: string;
36
+ path: string;
37
+ method: string;
38
+ }
39
+
40
+ export class GGSocket {
41
+
42
+ private readonly socket: SocketAdapter;
43
+ private readonly handlers: Map<string, SocketHandlerConfig> = new Map();
44
+ private readonly pendingRequests = new PendingRequestsMap();
45
+
46
+ private unknownMessageHandler?: (path: string, data: any) => void;
47
+
48
+ private isActive = true;
49
+ private isCleanedUp = false;
50
+ private tearingDownPromise: Promise<void>;
51
+
52
+ private readonly onTearDownCallbacks: Array<() => Promise<void>> = [];
53
+ private readonly onCloseCallbacks: Array<() => void> = [];
54
+ private readonly onErrorCallbacks: Array<(error: Error) => void> = [];
55
+
56
+ // Metrics labels (optional - set when created by server)
57
+ private readonly apiName: string;
58
+ private readonly socketPath: string;
59
+ private readonly connectionContext: GGContext;
60
+
61
+ constructor(socket: SocketAdapter, config?: GGSocketConfig) {
62
+ this.socket = socket;
63
+ this.apiName = config?.apiName ?? 'unknown';
64
+ this.socketPath = config?.socketPath ?? 'unknown';
65
+ // Default to passthrough if no context wrapper provided
66
+ this.connectionContext = config?.connectionContext ?? new GGContext('__unnamed_GGSocket_context');
67
+
68
+ // Capture contexts at construction - socket events lose AsyncLocalStorage context
69
+ const scope = GGLocator.getScope();
70
+
71
+ this.socket.onMessage(async (data: string) => {
72
+ scope.ensureEntered();
73
+ const context = new GGContext("ws-message", this.connectionContext);
74
+ await context.run(async () => {
75
+ GG_TRACE.init();
76
+
77
+ const msg = Message.parse(data);
78
+ if (!msg) {
79
+ return;
80
+ }
81
+
82
+ if (this.isActive) {
83
+ if (msg.type === MessageType.MSG || msg.type === MessageType.REQ) {
84
+ GG_WS_MESSAGE.set({path: msg.path});
85
+ await this.handleIncomingMessage(msg);
86
+ } else if (msg.type === MessageType.RES) {
87
+ this.pendingRequests.resolve(msg.id, msg.data);
88
+ }
89
+ }
90
+ })
91
+ });
92
+
93
+ this.socket.onClose(() => {
94
+ scope.ensureEntered();
95
+ this.connectionContext.run(() => {
96
+ this.isActive = false;
97
+ if (!this.isCleanedUp) {
98
+ this.isCleanedUp = true;
99
+ this.pendingRequests.rejectAll(new SERVER_ERROR({displayMessage: "Socket connection closed!"}));
100
+ this.onCloseCallbacks.forEach((onClose) => {
101
+ try {
102
+ onClose?.();
103
+ } catch (e) {
104
+ this.onErrorCallbacks.forEach(cb => cb(e));
105
+ }
106
+ });
107
+ }
108
+ });
109
+ });
110
+
111
+ this.socket.onError((error: Error) => {
112
+ scope.ensureEntered();
113
+ this.connectionContext.run(() => {
114
+ this.onErrorCallbacks.forEach(cb => cb(error));
115
+ });
116
+ });
117
+ }
118
+
119
+ // --------------------------------------------------------------------------------------
120
+ // Incoming message handling
121
+ // --------------------------------------------------------------------------------------
122
+
123
+ private async handleIncomingMessage(msg: RegularMessage | RequestMessage): Promise<void> {
124
+ const startTime = performance.now();
125
+ const labels: MetricLabels = {api: this.apiName, path: this.socketPath, method: msg.path};
126
+ const expectsResponse = msg.type === MessageType.REQ;
127
+
128
+ try {
129
+ const handlerDef = this.handlers.get(msg.path);
130
+
131
+ if (!handlerDef) {
132
+ this.handleMissingRoute(msg, labels, startTime, expectsResponse);
133
+ return;
134
+ }
135
+
136
+ // Handler returns GGPromise - use asResult() to get OK | ERROR
137
+ const ggPromise = handlerDef.handler(msg.data);
138
+ const res = await (ggPromise as any).asResult();
139
+
140
+ if (expectsResponse) {
141
+ this.sendResponse(msg as RequestMessage, res, labels, startTime);
142
+ } else {
143
+ this.handleFireAndForgetResult(res, labels, startTime);
144
+ }
145
+ } catch (error) {
146
+ GGLog.error(this, error);
147
+ this.onErrorCallbacks.forEach(cb => cb(error as Error));
148
+ }
149
+ }
150
+
151
+ private handleMissingRoute(
152
+ msg: RegularMessage | RequestMessage,
153
+ labels: MetricLabels,
154
+ startTime: number,
155
+ expectsResponse: boolean
156
+ ): void {
157
+ if (expectsResponse) {
158
+ const error = new ROUTE_NOT_FOUND({displayMessage: "Route not found: " + msg.path});
159
+ GGLog.error(this, error);
160
+ this.sendResponse(msg as RequestMessage, error, labels, startTime);
161
+ } else {
162
+ if (this.unknownMessageHandler) {
163
+ this.unknownMessageHandler(msg.path, msg.data);
164
+ } else {
165
+ GGLog.warn(this, 'Unknown method ' + msg.path);
166
+ }
167
+ this.recordInMetric(labels, 'ROUTE_NOT_FOUND', startTime);
168
+ }
169
+ }
170
+
171
+ private sendResponse(
172
+ msg: RequestMessage,
173
+ res: any,
174
+ labels: MetricLabels,
175
+ startTime: number
176
+ ): void {
177
+ const isError = res instanceof ERROR || (res && res.success === false);
178
+ const resultType = isError ? res.type : 'OK';
179
+ // res is OK_JSON or ERROR - send as-is (it's already in the right format)
180
+ const data = isError && res instanceof ERROR ? res.toJSON() : res;
181
+
182
+ this.recordInMetric(labels, resultType, startTime);
183
+
184
+ try {
185
+ this.socket.send(Message.create(MessageType.RES, msg.path, msg.id, data));
186
+ } catch (error) {
187
+ GGLog.error(this, "ERROR_SENDING_RESPONSE", ERROR.fromUnknown(error));
188
+ }
189
+ }
190
+
191
+ private handleFireAndForgetResult(res: any, labels: MetricLabels, startTime: number): void {
192
+ const isError = res instanceof ERROR || (res && res.success === false);
193
+ if (isError) {
194
+ this.recordInMetric(labels, res.type, startTime);
195
+ this.onErrorCallbacks.forEach(cb => cb(res));
196
+ } else {
197
+ this.recordInMetric(labels, 'OK', startTime);
198
+ }
199
+ }
200
+
201
+ // --------------------------------------------------------------------------------------
202
+ // Outgoing message handling
203
+ // --------------------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Send a message over the socket (raw transport).
207
+ * Data is expected to be already validated by the contract layer.
208
+ *
209
+ * @param path - The message path/route
210
+ * @param body - The message data (already validated)
211
+ * @param expectsResponse - Whether to wait for a response
212
+ */
213
+ public async send(
214
+ path: string,
215
+ body: any,
216
+ expectsResponse: boolean
217
+ ): Promise<any> {
218
+ const labels: MetricLabels = {api: this.apiName, path: this.socketPath, method: path};
219
+ const startTime = performance.now();
220
+
221
+ if (!this.isActive) {
222
+ this.recordOutMetric(labels, 'CONNECTION_CLOSED');
223
+ throw new Error('Cannot send: WebSocket is not connected');
224
+ }
225
+
226
+ if (expectsResponse) {
227
+ return this.pendingRequests.create(path, 30000, async (id, waitForResponse) => {
228
+ this.socket.send(Message.create(MessageType.REQ, path, id, body));
229
+ const result = await waitForResponse;
230
+ const resultType = result?.success === true ? 'OK' : (result?.type ?? 'SERVER_ERROR');
231
+ this.recordOutMetric(labels, resultType, startTime);
232
+ return result;
233
+ });
234
+ } else {
235
+ this.socket.send(Message.create(MessageType.MSG, path, "", body));
236
+ this.recordOutMetric(labels, 'OK');
237
+ }
238
+ }
239
+
240
+ // --------------------------------------------------------------------------------------
241
+ // Metrics helpers
242
+ // --------------------------------------------------------------------------------------
243
+
244
+ private recordInMetric(labels: MetricLabels, result: string, startTime?: number): void {
245
+ if (GG_METRICS.has()) {
246
+ GGWebSocketMetrics.requests.inc(1, {...labels, result});
247
+ if (startTime !== undefined) {
248
+ GGWebSocketMetrics.requestDuration.observe(performance.now() - startTime, labels);
249
+ }
250
+ }
251
+ }
252
+
253
+ private recordOutMetric(labels: MetricLabels, result: string, startTime?: number): void {
254
+ if (GG_METRICS.has()) {
255
+ GGWebSocketMetrics.outRequests.inc(1, {...labels, result});
256
+ if (startTime !== undefined) {
257
+ GGWebSocketMetrics.outRequestDuration.observe(performance.now() - startTime, labels);
258
+ }
259
+ }
260
+ }
261
+
262
+ // --------------------------------------------------------------------------------------
263
+ // Handler registration
264
+ // --------------------------------------------------------------------------------------
265
+
266
+ public registerHandler(config: SocketHandlerConfig): void {
267
+ this.handlers.set(config.path, config);
268
+ }
269
+
270
+ public unregisterHandler(path: string): void {
271
+ this.handlers.delete(path);
272
+ }
273
+
274
+ public setUnknownMessageHandler(handler: (path: string, data: any) => void): void {
275
+ this.unknownMessageHandler = handler;
276
+ }
277
+
278
+ // --------------------------------------------------------------------------------------
279
+ // Lifecycle callbacks
280
+ // --------------------------------------------------------------------------------------
281
+
282
+ public onError(onError: (error: Error) => void): this {
283
+ this.onErrorCallbacks.push(onError);
284
+ return this;
285
+ }
286
+
287
+ public onTearDown(onClosing: () => Promise<void>): this {
288
+ this.onTearDownCallbacks.push(onClosing);
289
+ return this;
290
+ }
291
+
292
+ public onClose(onClose: () => void): this {
293
+ this.onCloseCallbacks.push(onClose);
294
+ return this;
295
+ }
296
+
297
+ // --------------------------------------------------------------------------------------
298
+ // Teardown
299
+ // --------------------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Get the number of pending outgoing requests
303
+ */
304
+ public get pendingRequestCount(): number {
305
+ return this.pendingRequests.size;
306
+ }
307
+
308
+ /**
309
+ * Starts the socket closing process gracefully.
310
+ * 1. First waits for pending outgoing requests to complete (up to pendingRequestsTimeoutMs)
311
+ * 2. Then runs user teardown handlers
312
+ * 3. Finally closes the socket
313
+ *
314
+ * @param pendingRequestsTimeoutMs - Max time to wait for pending requests (default: 5000ms)
315
+ * @param callbacksTimeoutMs - Max time to wait for teardown callbacks (default: 5000ms)
316
+ */
317
+ public async teardown(pendingRequestsTimeoutMs: number = 5000, callbacksTimeoutMs: number = 5000): Promise<void> {
318
+ if (this.tearingDownPromise) {
319
+ GGLog.warn(this, 'Already tearing down!');
320
+ return this.tearingDownPromise;
321
+ }
322
+ GGLog.debug(this, 'Teardown started');
323
+
324
+ this.tearingDownPromise = (async () => {
325
+ // Step 1: Wait for pending outgoing requests to complete
326
+ if (this.pendingRequests.hasPending()) {
327
+ GGLog.debug(this, `Waiting for ${this.pendingRequests.size} pending request(s) to complete...`);
328
+ await this.pendingRequests.waitForPending(pendingRequestsTimeoutMs);
329
+ if (this.pendingRequests.hasPending()) {
330
+ GGLog.warn(this, `Timeout waiting for pending requests, ${this.pendingRequests.size} request(s) still pending`);
331
+ }
332
+ }
333
+
334
+ // Step 2: Run user teardown callbacks with timeout
335
+ await this.runTeardownCallbacks(callbacksTimeoutMs);
336
+
337
+ // Step 3: Close the socket
338
+ this.close();
339
+ })();
340
+
341
+ return this.tearingDownPromise;
342
+ }
343
+
344
+ /**
345
+ * Run all teardown callbacks concurrently with a timeout.
346
+ */
347
+ private runTeardownCallbacks(timeoutMs: number): Promise<void> {
348
+ if (this.onTearDownCallbacks.length === 0) {
349
+ return Promise.resolve();
350
+ }
351
+
352
+ return new Promise<void>((resolve) => {
353
+ let remaining = this.onTearDownCallbacks.length;
354
+ let resolved = false;
355
+
356
+ const finish = () => {
357
+ if (!resolved) {
358
+ resolved = true;
359
+ clearTimeout(timeout);
360
+ resolve();
361
+ }
362
+ };
363
+
364
+ const timeout = setTimeout(() => {
365
+ GGLog.warn(this, `Teardown timeout - ${remaining} callback(s) still pending after ${timeoutMs}ms`);
366
+ finish();
367
+ }, timeoutMs);
368
+
369
+ for (const callback of this.onTearDownCallbacks) {
370
+ callback()
371
+ .catch((err) => GGLog.error(this, 'Error in teardown callback', err))
372
+ .finally(() => {
373
+ remaining--;
374
+ if (remaining === 0) finish();
375
+ });
376
+ }
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Immediately closes the socket.
382
+ */
383
+ public close(): void {
384
+ if (this.isActive) {
385
+ this.isActive = false;
386
+ try {
387
+ this.socket.close();
388
+ } catch (e) {
389
+ // Ignore close errors
390
+ }
391
+ }
392
+ }
393
+
394
+ }