@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,217 +1,217 @@
1
- /**
2
- * Manages WebSocket connections for a single endpoint
3
- * Handles connection acceptance, effect parsing via handshake, and socket lifecycle
4
- */
5
-
6
- import {WebSocket, WebSocketServer} from "ws";
7
- import {ERROR, GGValidator} from "@grest-ts/schema";
8
- import {NodeSocketAdapter} from "../adapter/NodeSocketAdapter";
9
- import * as http from "http";
10
- import * as url from "url";
11
- import {GGLog} from "@grest-ts/logger";
12
- import {GG_WS_CONNECTION} from "./GG_WS_CONNECTION";
13
- import {GGWebSocketMetrics} from "./GGWebSocketMetrics";
14
- import {Message, MessageType} from "../socket/SocketMessage";
15
- import {GG_TRACE} from "@grest-ts/trace";
16
- import {GGSocket} from "../socket/GGSocket";
17
- import {GGLocator, GGLocatorScope} from "@grest-ts/locator";
18
- import {GG_METRICS} from "@grest-ts/metrics";
19
- import {withTimeout} from "@grest-ts/common";
20
- import {GGContext} from "@grest-ts/context";
21
- import {GG_DISCOVERY} from "@grest-ts/discovery";
22
- import {GGWebSocketHandshakeContext, GGWebSocketMiddleware} from "../schema/GGWebSocketMiddleware";
23
- import {GGHttpServer} from "@grest-ts/http";
24
-
25
- export interface GGSocketServerConfig<TContext, Query> {
26
- path: string;
27
- apiName: string;
28
- queryValidator?: GGValidator<Query>;
29
- middlewares: readonly GGWebSocketMiddleware[];
30
- }
31
-
32
- export class GGSocketServer<TContext, Query> {
33
-
34
- private readonly wss: WebSocketServer;
35
- private readonly http: GGHttpServer;
36
- public readonly path: string;
37
- private readonly apiName: string;
38
- private readonly middlewares: readonly GGWebSocketMiddleware[];
39
- private readonly queryValidator: GGValidator<Query>;
40
-
41
- private readonly activeSockets: Set<GGSocket> = new Set();
42
- private readonly onConnectionHandlers: Array<(socket: GGSocket, query: Query) => void> = [];
43
-
44
- // Capture context at construction - WebSocket events lose AsyncLocalStorage context
45
- private readonly scope: GGLocatorScope;
46
-
47
- constructor(http: GGHttpServer, config: GGSocketServerConfig<TContext, Query>) {
48
- this.scope = GGLocator.getScope();
49
- this.path = config.path;
50
- this.apiName = config.apiName;
51
- this.middlewares = config.middlewares;
52
- this.queryValidator = config.queryValidator;
53
- this.wss = new WebSocketServer({server: http.httpServer, path: this.path});
54
- this.wss.on('connection', this.scope.wrapWithEnter(this._onConnection));
55
- this.http = http
56
- .onStart(() => {
57
- GGLog.info(this, "WebSocket server started", {api: config.apiName, path: config.path});
58
- if (GG_DISCOVERY.has()) {
59
- GG_DISCOVERY.get().registerRoutes([{
60
- runtime: this.scope.serviceName,
61
- api: this.apiName,
62
- pathPrefix: this.path,
63
- protocol: "ws",
64
- port: http.port
65
- }]);
66
- }
67
- })
68
- .onTeardown(async () => {
69
- GGLog.info(this, "WebSocket server closing");
70
- try {
71
- this.wss.close(); // We use external http, so this does not close existing connections.
72
- } catch (error) {
73
- GGLog.error(this, error);
74
- }
75
- await Promise.allSettled(Array.from(this.activeSockets).map(socket => socket.teardown()));
76
- GGLog.info(this, "WebSocket server stopped");
77
- });
78
- }
79
-
80
- public onConnection(handler: (socket: GGSocket, query: Query) => void): void {
81
- this.onConnectionHandlers.push(handler);
82
- }
83
-
84
- private _onConnection = async (ws: WebSocket, req: http.IncomingMessage) => {
85
- const connectionLabels = {api: this.apiName, path: this.path};
86
-
87
- const context = new GGContext("ws-connection");
88
- await context.run(async () => {
89
- GG_TRACE.init()
90
- GG_WS_CONNECTION.set({
91
- port: this.http.port,
92
- path: this.path
93
- })
94
- try {
95
- // Parse and validate query parameters
96
- const parsedUrl = url.parse(req.url || '', true);
97
- let queryArgs = parsedUrl.query as Query;
98
-
99
- if (this.queryValidator) {
100
- const result = this.queryValidator.safeParse(queryArgs, true)
101
- if (result.success === false) {
102
- ws.close(4000, "Invalid query parameters");
103
- GGLog.warn(this, "REJECTED - bad query", result.issues);
104
- if (GG_METRICS.has()) GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'QUERY_INVALID'});
105
- return;
106
- }
107
- queryArgs = result.value as Query
108
- }
109
-
110
- const adapter = new NodeSocketAdapter(ws);
111
-
112
- // Wait for handshake message with headers
113
- const handshakeResult = await this.handleHandshake(context, adapter, queryArgs);
114
-
115
- if (!handshakeResult.success) {
116
- GGLog.warn(this, "REJECTED - handshake failed", (handshakeResult as { success: false; error: any }).error);
117
- if (GG_METRICS.has()) GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'HANDSHAKE_FAILED'});
118
- adapter.send(Message.create(MessageType.HANDSHAKE_ERR, "", "", (handshakeResult as { success: false; error: any }).error));
119
- ws.close(4001, "Handshake failed");
120
- return;
121
- }
122
-
123
- // Send handshake success
124
- adapter.send(Message.create(MessageType.HANDSHAKE_OK, "", "", null));
125
-
126
- GGLog.debug(this, "New websocket connection", queryArgs);
127
- const socket = new GGSocket(adapter, {
128
- apiName: this.apiName,
129
- socketPath: this.path,
130
- connectionContext: context
131
- });
132
- this.activeSockets.add(socket);
133
-
134
- // Track connection metrics
135
- if (GG_METRICS.has()) {
136
- GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'OK'});
137
- GGWebSocketMetrics.connectionsActive.inc(1, connectionLabels);
138
- }
139
-
140
- socket.onClose(() => {
141
- this.activeSockets.delete(socket);
142
- if (GG_METRICS.has()) GGWebSocketMetrics.connectionsActive.dec(1, connectionLabels);
143
- });
144
-
145
- // Run connection handlers inside the connectionScope so they can access context
146
- for (const handler of this.onConnectionHandlers) {
147
- try {
148
- handler(socket, queryArgs);
149
- } catch (error) {
150
- GGLog.error(this, error);
151
- }
152
- }
153
- } catch (error) {
154
- GGLog.error(this, error);
155
- if (GG_METRICS.has()) GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'ERROR'});
156
- // Only close if socket is still open (fix #5 - avoid double close)
157
- if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
158
- ws.close(4001, "Connection rejected");
159
- }
160
- }
161
- });
162
- }
163
-
164
- /**
165
- * Handle handshake message from client.
166
- * Waits for HANDSHAKE message with headers, runs effects, returns context.
167
- */
168
- private async handleHandshake(
169
- context: GGContext,
170
- adapter: NodeSocketAdapter,
171
- queryArgs: Query
172
- ): Promise<{ success: true } | { success: false; error: any }> {
173
- type HandshakeResult = { success: true } | { success: false; error: any };
174
- return withTimeout<HandshakeResult>(
175
- new Promise<HandshakeResult>((resolve) => {
176
- // Wrap with scope.wrapWithEnter to preserve locator scope when callback fires
177
- const onMessage = this.scope.wrapWithEnter((data: string) => {
178
- const msg = Message.parse(data);
179
- if (!msg) return;
180
-
181
- if (msg.type === MessageType.HANDSHAKE) {
182
- adapter.offMessage(onMessage);
183
- context.run(async () => {
184
- try {
185
- // Build handshake context from headers
186
- const headers = msg.data || {};
187
- const handshakeContext: GGWebSocketHandshakeContext = {
188
- headers,
189
- queryArgs: queryArgs as Record<string, string>
190
- };
191
-
192
- // Run middlewares
193
- for (const middleware of this.middlewares) {
194
- middleware.parseHandshake?.(handshakeContext);
195
- await middleware.process?.();
196
- }
197
- resolve({success: true});
198
- } catch (error: any) {
199
- const errorJson = error instanceof ERROR
200
- ? error.toJSON()
201
- : {message: String(error)};
202
- resolve({success: false, error: errorJson});
203
- }
204
- });
205
- }
206
- });
207
-
208
- adapter.onMessage(onMessage);
209
- }),
210
- 5000,
211
- 'Handshake timeout'
212
- ).catch((error): { success: false; error: any } => ({
213
- success: false,
214
- error: {message: error.message || 'Handshake timeout'}
215
- }));
216
- }
217
- }
1
+ /**
2
+ * Manages WebSocket connections for a single endpoint
3
+ * Handles connection acceptance, effect parsing via handshake, and socket lifecycle
4
+ */
5
+
6
+ import {WebSocket, WebSocketServer} from "ws";
7
+ import {ERROR, GGValidator} from "@grest-ts/schema";
8
+ import {NodeSocketAdapter} from "../adapter/NodeSocketAdapter";
9
+ import * as http from "http";
10
+ import * as url from "url";
11
+ import {GGLog} from "@grest-ts/logger";
12
+ import {GG_WS_CONNECTION} from "./GG_WS_CONNECTION";
13
+ import {GGWebSocketMetrics} from "./GGWebSocketMetrics";
14
+ import {Message, MessageType} from "../socket/SocketMessage";
15
+ import {GG_TRACE} from "@grest-ts/trace";
16
+ import {GGSocket} from "../socket/GGSocket";
17
+ import {GGLocator, GGLocatorScope} from "@grest-ts/locator";
18
+ import {GG_METRICS} from "@grest-ts/metrics";
19
+ import {withTimeout} from "@grest-ts/common";
20
+ import {GGContext} from "@grest-ts/context";
21
+ import {GG_DISCOVERY} from "@grest-ts/discovery";
22
+ import {GGWebSocketHandshakeContext, GGWebSocketMiddleware} from "../schema/GGWebSocketMiddleware";
23
+ import {GGHttpServer} from "@grest-ts/http";
24
+
25
+ export interface GGSocketServerConfig<TContext, Query> {
26
+ path: string;
27
+ apiName: string;
28
+ queryValidator?: GGValidator<Query>;
29
+ middlewares: readonly GGWebSocketMiddleware[];
30
+ }
31
+
32
+ export class GGSocketServer<TContext, Query> {
33
+
34
+ private readonly wss: WebSocketServer;
35
+ private readonly http: GGHttpServer;
36
+ public readonly path: string;
37
+ private readonly apiName: string;
38
+ private readonly middlewares: readonly GGWebSocketMiddleware[];
39
+ private readonly queryValidator: GGValidator<Query>;
40
+
41
+ private readonly activeSockets: Set<GGSocket> = new Set();
42
+ private readonly onConnectionHandlers: Array<(socket: GGSocket, query: Query) => void> = [];
43
+
44
+ // Capture context at construction - WebSocket events lose AsyncLocalStorage context
45
+ private readonly scope: GGLocatorScope;
46
+
47
+ constructor(http: GGHttpServer, config: GGSocketServerConfig<TContext, Query>) {
48
+ this.scope = GGLocator.getScope();
49
+ this.path = config.path;
50
+ this.apiName = config.apiName;
51
+ this.middlewares = config.middlewares;
52
+ this.queryValidator = config.queryValidator;
53
+ this.wss = new WebSocketServer({server: http.httpServer, path: this.path});
54
+ this.wss.on('connection', this.scope.wrapWithEnter(this._onConnection));
55
+ this.http = http
56
+ .onStart(() => {
57
+ GGLog.info(this, "WebSocket server started", {api: config.apiName, path: config.path});
58
+ if (GG_DISCOVERY.has()) {
59
+ GG_DISCOVERY.get().registerRoutes([{
60
+ runtime: this.scope.serviceName,
61
+ api: this.apiName,
62
+ pathPrefix: this.path,
63
+ protocol: "ws",
64
+ port: http.port
65
+ }]);
66
+ }
67
+ })
68
+ .onTeardown(async () => {
69
+ GGLog.info(this, "WebSocket server closing");
70
+ try {
71
+ this.wss.close(); // We use external http, so this does not close existing connections.
72
+ } catch (error) {
73
+ GGLog.error(this, error);
74
+ }
75
+ await Promise.allSettled(Array.from(this.activeSockets).map(socket => socket.teardown()));
76
+ GGLog.info(this, "WebSocket server stopped");
77
+ });
78
+ }
79
+
80
+ public onConnection(handler: (socket: GGSocket, query: Query) => void): void {
81
+ this.onConnectionHandlers.push(handler);
82
+ }
83
+
84
+ private _onConnection = async (ws: WebSocket, req: http.IncomingMessage) => {
85
+ const connectionLabels = {api: this.apiName, path: this.path};
86
+
87
+ const context = new GGContext("ws-connection");
88
+ await context.run(async () => {
89
+ GG_TRACE.init()
90
+ GG_WS_CONNECTION.set({
91
+ port: this.http.port,
92
+ path: this.path
93
+ })
94
+ try {
95
+ // Parse and validate query parameters
96
+ const parsedUrl = url.parse(req.url || '', true);
97
+ let queryArgs = parsedUrl.query as Query;
98
+
99
+ if (this.queryValidator) {
100
+ const result = this.queryValidator.safeParse(queryArgs, true)
101
+ if (result.success === false) {
102
+ ws.close(4000, "Invalid query parameters");
103
+ GGLog.warn(this, "REJECTED - bad query", result.issues);
104
+ if (GG_METRICS.has()) GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'QUERY_INVALID'});
105
+ return;
106
+ }
107
+ queryArgs = result.value as Query
108
+ }
109
+
110
+ const adapter = new NodeSocketAdapter(ws);
111
+
112
+ // Wait for handshake message with headers
113
+ const handshakeResult = await this.handleHandshake(context, adapter, queryArgs);
114
+
115
+ if (!handshakeResult.success) {
116
+ GGLog.warn(this, "REJECTED - handshake failed", (handshakeResult as { success: false; error: any }).error);
117
+ if (GG_METRICS.has()) GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'HANDSHAKE_FAILED'});
118
+ adapter.send(Message.create(MessageType.HANDSHAKE_ERR, "", "", (handshakeResult as { success: false; error: any }).error));
119
+ ws.close(4001, "Handshake failed");
120
+ return;
121
+ }
122
+
123
+ // Send handshake success
124
+ adapter.send(Message.create(MessageType.HANDSHAKE_OK, "", "", null));
125
+
126
+ GGLog.debug(this, "New websocket connection", queryArgs);
127
+ const socket = new GGSocket(adapter, {
128
+ apiName: this.apiName,
129
+ socketPath: this.path,
130
+ connectionContext: context
131
+ });
132
+ this.activeSockets.add(socket);
133
+
134
+ // Track connection metrics
135
+ if (GG_METRICS.has()) {
136
+ GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'OK'});
137
+ GGWebSocketMetrics.connectionsActive.inc(1, connectionLabels);
138
+ }
139
+
140
+ socket.onClose(() => {
141
+ this.activeSockets.delete(socket);
142
+ if (GG_METRICS.has()) GGWebSocketMetrics.connectionsActive.dec(1, connectionLabels);
143
+ });
144
+
145
+ // Run connection handlers inside the connectionScope so they can access context
146
+ for (const handler of this.onConnectionHandlers) {
147
+ try {
148
+ handler(socket, queryArgs);
149
+ } catch (error) {
150
+ GGLog.error(this, error);
151
+ }
152
+ }
153
+ } catch (error) {
154
+ GGLog.error(this, error);
155
+ if (GG_METRICS.has()) GGWebSocketMetrics.connections.inc(1, {...connectionLabels, result: 'ERROR'});
156
+ // Only close if socket is still open (fix #5 - avoid double close)
157
+ if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
158
+ ws.close(4001, "Connection rejected");
159
+ }
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Handle handshake message from client.
166
+ * Waits for HANDSHAKE message with headers, runs effects, returns context.
167
+ */
168
+ private async handleHandshake(
169
+ context: GGContext,
170
+ adapter: NodeSocketAdapter,
171
+ queryArgs: Query
172
+ ): Promise<{ success: true } | { success: false; error: any }> {
173
+ type HandshakeResult = { success: true } | { success: false; error: any };
174
+ return withTimeout<HandshakeResult>(
175
+ new Promise<HandshakeResult>((resolve) => {
176
+ // Wrap with scope.wrapWithEnter to preserve locator scope when callback fires
177
+ const onMessage = this.scope.wrapWithEnter((data: string) => {
178
+ const msg = Message.parse(data);
179
+ if (!msg) return;
180
+
181
+ if (msg.type === MessageType.HANDSHAKE) {
182
+ adapter.offMessage(onMessage);
183
+ context.run(async () => {
184
+ try {
185
+ // Build handshake context from headers
186
+ const headers = msg.data || {};
187
+ const handshakeContext: GGWebSocketHandshakeContext = {
188
+ headers,
189
+ queryArgs: queryArgs as Record<string, string>
190
+ };
191
+
192
+ // Run middlewares
193
+ for (const middleware of this.middlewares) {
194
+ middleware.parseHandshake?.(handshakeContext);
195
+ await middleware.process?.();
196
+ }
197
+ resolve({success: true});
198
+ } catch (error: any) {
199
+ const errorJson = error instanceof ERROR
200
+ ? error.toJSON()
201
+ : {message: String(error)};
202
+ resolve({success: false, error: errorJson});
203
+ }
204
+ });
205
+ }
206
+ });
207
+
208
+ adapter.onMessage(onMessage);
209
+ }),
210
+ 5000,
211
+ 'Handshake timeout'
212
+ ).catch((error): { success: false; error: any } => ({
213
+ success: false,
214
+ error: {message: error.message || 'Handshake timeout'}
215
+ }));
216
+ }
217
+ }
@@ -1,58 +1,58 @@
1
- import {GGCounterKey, GGGaugeKey, GGHistogramKey, GGMetrics} from "@grest-ts/metrics";
2
- import {EXISTS, FORBIDDEN, NOT_AUTHORIZED, NOT_FOUND, OK, ROUTE_NOT_FOUND, SERVER_ERROR, VALIDATION_ERROR} from "@grest-ts/schema";
3
-
4
- /**
5
- * Result type for WebSocket operations.
6
- * Known types listed for documentation, but accepts any string for custom error types.
7
- */
8
- type ResultType =
9
- | typeof OK.TYPE
10
- | typeof VALIDATION_ERROR.TYPE
11
- | typeof NOT_AUTHORIZED.TYPE
12
- | typeof FORBIDDEN.TYPE
13
- | typeof NOT_FOUND.TYPE
14
- | typeof ROUTE_NOT_FOUND.TYPE
15
- | typeof EXISTS.TYPE
16
- | typeof SERVER_ERROR.TYPE
17
- | string;
18
-
19
- type SocketConnectionResult = 'OK' | 'AUTH_FAILED' | 'QUERY_INVALID' | string;
20
-
21
- export const GGWebSocketMetrics = GGMetrics.define('/websocket/', () => ({
22
- connectionsActive: new GGGaugeKey<{ api: string, path: string }>('connections_active', {
23
- help: 'Active WebSocket connections',
24
- labelNames: ['api', 'path'],
25
- groupBy: {labels: ["api"]}
26
- }),
27
- connections: new GGCounterKey<{ api: string, path: string, result: SocketConnectionResult }>('connections_total', {
28
- help: 'Total WebSocket connection attempts',
29
- labelNames: ['api', 'path', 'result'],
30
- groupBy: {labels: ["api"]}
31
- }),
32
-
33
- // Incoming (server handling REQ + MSG)
34
- requests: new GGCounterKey<{ api: string, path: string, method: string, result: ResultType }>('requests_total', {
35
- help: 'Total incoming WebSocket commands (REQ + MSG)',
36
- labelNames: ['api', 'path', 'method', 'result'],
37
- groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
38
- }),
39
- requestDuration: new GGHistogramKey<{ api: string, path: string, method: string }>('request_duration_ms', {
40
- help: 'Incoming WebSocket command processing duration in milliseconds',
41
- labelNames: ['api', 'path', 'method'],
42
- buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
43
- groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
44
- }),
45
-
46
- // Outgoing (client calls via socket)
47
- outRequests: new GGCounterKey<{ api: string, path: string, method: string, result: ResultType }>('out_requests_total', {
48
- help: 'Total outgoing WebSocket commands (REQ + MSG)',
49
- labelNames: ['api', 'path', 'method', 'result'],
50
- groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
51
- }),
52
- outRequestDuration: new GGHistogramKey<{ api: string, path: string, method: string }>('out_request_duration_ms', {
53
- help: 'Outgoing WebSocket request round-trip duration in milliseconds (REQ only)',
54
- labelNames: ['api', 'path', 'method'],
55
- buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500],
56
- groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
57
- }),
58
- }));
1
+ import {GGCounterKey, GGGaugeKey, GGHistogramKey, GGMetrics} from "@grest-ts/metrics";
2
+ import {EXISTS, FORBIDDEN, NOT_AUTHORIZED, NOT_FOUND, OK, ROUTE_NOT_FOUND, SERVER_ERROR, VALIDATION_ERROR} from "@grest-ts/schema";
3
+
4
+ /**
5
+ * Result type for WebSocket operations.
6
+ * Known types listed for documentation, but accepts any string for custom error types.
7
+ */
8
+ type ResultType =
9
+ | typeof OK.TYPE
10
+ | typeof VALIDATION_ERROR.TYPE
11
+ | typeof NOT_AUTHORIZED.TYPE
12
+ | typeof FORBIDDEN.TYPE
13
+ | typeof NOT_FOUND.TYPE
14
+ | typeof ROUTE_NOT_FOUND.TYPE
15
+ | typeof EXISTS.TYPE
16
+ | typeof SERVER_ERROR.TYPE
17
+ | string;
18
+
19
+ type SocketConnectionResult = 'OK' | 'AUTH_FAILED' | 'QUERY_INVALID' | string;
20
+
21
+ export const GGWebSocketMetrics = GGMetrics.define('/websocket/', () => ({
22
+ connectionsActive: new GGGaugeKey<{ api: string, path: string }>('connections_active', {
23
+ help: 'Active WebSocket connections',
24
+ labelNames: ['api', 'path'],
25
+ groupBy: {labels: ["api"]}
26
+ }),
27
+ connections: new GGCounterKey<{ api: string, path: string, result: SocketConnectionResult }>('connections_total', {
28
+ help: 'Total WebSocket connection attempts',
29
+ labelNames: ['api', 'path', 'result'],
30
+ groupBy: {labels: ["api"]}
31
+ }),
32
+
33
+ // Incoming (server handling REQ + MSG)
34
+ requests: new GGCounterKey<{ api: string, path: string, method: string, result: ResultType }>('requests_total', {
35
+ help: 'Total incoming WebSocket commands (REQ + MSG)',
36
+ labelNames: ['api', 'path', 'method', 'result'],
37
+ groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
38
+ }),
39
+ requestDuration: new GGHistogramKey<{ api: string, path: string, method: string }>('request_duration_ms', {
40
+ help: 'Incoming WebSocket command processing duration in milliseconds',
41
+ labelNames: ['api', 'path', 'method'],
42
+ buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
43
+ groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
44
+ }),
45
+
46
+ // Outgoing (client calls via socket)
47
+ outRequests: new GGCounterKey<{ api: string, path: string, method: string, result: ResultType }>('out_requests_total', {
48
+ help: 'Total outgoing WebSocket commands (REQ + MSG)',
49
+ labelNames: ['api', 'path', 'method', 'result'],
50
+ groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
51
+ }),
52
+ outRequestDuration: new GGHistogramKey<{ api: string, path: string, method: string }>('out_request_duration_ms', {
53
+ help: 'Outgoing WebSocket request round-trip duration in milliseconds (REQ only)',
54
+ labelNames: ['api', 'path', 'method'],
55
+ buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500],
56
+ groupBy: {labels: ["api", "method"], template: "{api}.{method}"}
57
+ }),
58
+ }));