@grest-ts/websocket 0.0.5
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/LICENSE +21 -0
- package/dist/src/adapter/BrowserSocketAdapter.d.ts +18 -0
- package/dist/src/adapter/BrowserSocketAdapter.d.ts.map +1 -0
- package/dist/src/adapter/BrowserSocketAdapter.js +51 -0
- package/dist/src/adapter/BrowserSocketAdapter.js.map +1 -0
- package/dist/src/adapter/NodeSocketAdapter.d.ts +21 -0
- package/dist/src/adapter/NodeSocketAdapter.d.ts.map +1 -0
- package/dist/src/adapter/NodeSocketAdapter.js +52 -0
- package/dist/src/adapter/NodeSocketAdapter.js.map +1 -0
- package/dist/src/adapter/getDefaultAdapter.d.ts +5 -0
- package/dist/src/adapter/getDefaultAdapter.d.ts.map +1 -0
- package/dist/src/adapter/getDefaultAdapter.js +15 -0
- package/dist/src/adapter/getDefaultAdapter.js.map +1 -0
- package/dist/src/client/GGSocketClient.d.ts +10 -0
- package/dist/src/client/GGSocketClient.d.ts.map +1 -0
- package/dist/src/client/GGSocketClient.js +17 -0
- package/dist/src/client/GGSocketClient.js.map +1 -0
- package/dist/src/client/GGSocketPool.d.ts +63 -0
- package/dist/src/client/GGSocketPool.d.ts.map +1 -0
- package/dist/src/client/GGSocketPool.js +203 -0
- package/dist/src/client/GGSocketPool.js.map +1 -0
- package/dist/src/index-browser.d.ts +11 -0
- package/dist/src/index-browser.d.ts.map +1 -0
- package/dist/src/index-browser.js +17 -0
- package/dist/src/index-browser.js.map +1 -0
- package/dist/src/index-node.d.ts +14 -0
- package/dist/src/index-node.d.ts.map +1 -0
- package/dist/src/index-node.js +22 -0
- package/dist/src/index-node.js.map +1 -0
- package/dist/src/schema/GGWebSocketMiddleware.d.ts +40 -0
- package/dist/src/schema/GGWebSocketMiddleware.d.ts.map +1 -0
- package/dist/src/schema/GGWebSocketMiddleware.js +6 -0
- package/dist/src/schema/GGWebSocketMiddleware.js.map +1 -0
- package/dist/src/schema/GGWebSocketSchema.d.ts +31 -0
- package/dist/src/schema/GGWebSocketSchema.d.ts.map +1 -0
- package/dist/src/schema/GGWebSocketSchema.js +32 -0
- package/dist/src/schema/GGWebSocketSchema.js.map +1 -0
- package/dist/src/schema/webSocketSchema.d.ts +52 -0
- package/dist/src/schema/webSocketSchema.d.ts.map +1 -0
- package/dist/src/schema/webSocketSchema.js +62 -0
- package/dist/src/schema/webSocketSchema.js.map +1 -0
- package/dist/src/server/GGSocketServer.d.ts +34 -0
- package/dist/src/server/GGSocketServer.d.ts.map +1 -0
- package/dist/src/server/GGSocketServer.js +186 -0
- package/dist/src/server/GGSocketServer.js.map +1 -0
- package/dist/src/server/GGWebSocketMetrics.d.ts +43 -0
- package/dist/src/server/GGWebSocketMetrics.d.ts.map +1 -0
- package/dist/src/server/GGWebSocketMetrics.js +38 -0
- package/dist/src/server/GGWebSocketMetrics.js.map +1 -0
- package/dist/src/server/GGWebSocketSchema.startServer.d.ts +33 -0
- package/dist/src/server/GGWebSocketSchema.startServer.d.ts.map +1 -0
- package/dist/src/server/GGWebSocketSchema.startServer.js +80 -0
- package/dist/src/server/GGWebSocketSchema.startServer.js.map +1 -0
- package/dist/src/server/GG_WS_CONNECTION.d.ts +12 -0
- package/dist/src/server/GG_WS_CONNECTION.d.ts.map +1 -0
- package/dist/src/server/GG_WS_CONNECTION.js +8 -0
- package/dist/src/server/GG_WS_CONNECTION.js.map +1 -0
- package/dist/src/server/GG_WS_MESSAGE.d.ts +10 -0
- package/dist/src/server/GG_WS_MESSAGE.d.ts.map +1 -0
- package/dist/src/server/GG_WS_MESSAGE.js +7 -0
- package/dist/src/server/GG_WS_MESSAGE.js.map +1 -0
- package/dist/src/socket/GGSocket.d.ts +81 -0
- package/dist/src/socket/GGSocket.d.ts.map +1 -0
- package/dist/src/socket/GGSocket.js +315 -0
- package/dist/src/socket/GGSocket.js.map +1 -0
- package/dist/src/socket/SocketAdapter.d.ts +13 -0
- package/dist/src/socket/SocketAdapter.d.ts.map +1 -0
- package/dist/src/socket/SocketAdapter.js +2 -0
- package/dist/src/socket/SocketAdapter.js.map +1 -0
- package/dist/src/socket/SocketMessage.d.ts +47 -0
- package/dist/src/socket/SocketMessage.d.ts.map +1 -0
- package/dist/src/socket/SocketMessage.js +51 -0
- package/dist/src/socket/SocketMessage.js.map +1 -0
- package/dist/src/socket/WebSocketTypes.d.ts +18 -0
- package/dist/src/socket/WebSocketTypes.d.ts.map +1 -0
- package/dist/src/socket/WebSocketTypes.js +5 -0
- package/dist/src/socket/WebSocketTypes.js.map +1 -0
- package/dist/src/socket/utils/PendingRequestsMap.d.ts +32 -0
- package/dist/src/socket/utils/PendingRequestsMap.d.ts.map +1 -0
- package/dist/src/socket/utils/PendingRequestsMap.js +104 -0
- package/dist/src/socket/utils/PendingRequestsMap.js.map +1 -0
- package/dist/src/tsconfig.json +17 -0
- package/dist/testkit/client/GGSocketCall.d.ts +37 -0
- package/dist/testkit/client/GGSocketCall.d.ts.map +1 -0
- package/dist/testkit/client/GGSocketCall.js +44 -0
- package/dist/testkit/client/GGSocketCall.js.map +1 -0
- package/dist/testkit/client/GGWebSocketSchema.callOn.d.ts +70 -0
- package/dist/testkit/client/GGWebSocketSchema.callOn.d.ts.map +1 -0
- package/dist/testkit/client/GGWebSocketSchema.callOn.js +135 -0
- package/dist/testkit/client/GGWebSocketSchema.callOn.js.map +1 -0
- package/dist/testkit/index-testkit.d.ts +4 -0
- package/dist/testkit/index-testkit.d.ts.map +1 -0
- package/dist/testkit/index-testkit.js +3 -0
- package/dist/testkit/index-testkit.js.map +1 -0
- package/dist/tsconfig.publish.tsbuildinfo +1 -0
- package/package.json +74 -0
- package/src/adapter/BrowserSocketAdapter.ts +63 -0
- package/src/adapter/NodeSocketAdapter.ts +67 -0
- package/src/adapter/getDefaultAdapter.ts +14 -0
- package/src/client/GGSocketClient.ts +25 -0
- package/src/client/GGSocketPool.ts +244 -0
- package/src/index-browser.ts +21 -0
- package/src/index-node.ts +28 -0
- package/src/schema/GGWebSocketMiddleware.ts +43 -0
- package/src/schema/GGWebSocketSchema.ts +57 -0
- package/src/schema/webSocketSchema.ts +109 -0
- package/src/server/GGSocketServer.ts +217 -0
- package/src/server/GGWebSocketMetrics.ts +58 -0
- package/src/server/GGWebSocketSchema.startServer.ts +136 -0
- package/src/server/GG_WS_CONNECTION.ts +10 -0
- package/src/server/GG_WS_MESSAGE.ts +9 -0
- package/src/socket/GGSocket.ts +394 -0
- package/src/socket/SocketAdapter.ts +21 -0
- package/src/socket/SocketMessage.ts +97 -0
- package/src/socket/WebSocketTypes.ts +19 -0
- package/src/socket/utils/PendingRequestsMap.ts +128 -0
- package/src/tsconfig.json +17 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}));
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server extension for WebSocketSchema - adds startServer and register methods
|
|
3
|
+
* This file should only be imported in server (Node.js) context
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {GGSocket} from "../socket/GGSocket"
|
|
7
|
+
import {GGWebSocketSchema} from "../schema/GGWebSocketSchema";
|
|
8
|
+
import {GGWebSocketMiddleware} from "../schema/GGWebSocketMiddleware";
|
|
9
|
+
import {GGSocketServer} from "./GGSocketServer";
|
|
10
|
+
import {GGLocator} from "@grest-ts/locator";
|
|
11
|
+
import {WebSocketIncoming, WebSocketOutgoing} from "../socket/WebSocketTypes";
|
|
12
|
+
import {GG_HTTP_SERVER, GGHttpServer} from "@grest-ts/http";
|
|
13
|
+
|
|
14
|
+
export interface WebSocketSchemaConfig {
|
|
15
|
+
/**
|
|
16
|
+
* The HTTP server adapter to attach the WebSocket server to.
|
|
17
|
+
* If not provided, will look up from locator.
|
|
18
|
+
*/
|
|
19
|
+
http?: GGHttpServer;
|
|
20
|
+
/**
|
|
21
|
+
* Additional middlewares to apply to all connections.
|
|
22
|
+
*/
|
|
23
|
+
middlewares?: GGWebSocketMiddleware[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare module "../schema/GGWebSocketSchema" {
|
|
27
|
+
interface GGWebSocketSchema<TClientToServer, TServerToClient, TContext = {}, TQuery = undefined, TClientToServerImpl = TClientToServer> {
|
|
28
|
+
/**
|
|
29
|
+
* Start the WebSocket server for this API.
|
|
30
|
+
*/
|
|
31
|
+
startServer(
|
|
32
|
+
onConnection: (incoming: WebSocketIncoming<TClientToServerImpl>, outgoing: WebSocketOutgoing<TServerToClient>) => void,
|
|
33
|
+
config: WebSocketSchemaConfig
|
|
34
|
+
): GGSocketServer<TContext, TQuery>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register this WebSocket API with the default HTTP server.
|
|
38
|
+
* Uses GGHttpServerAdapter from locator if not explicitly provided.
|
|
39
|
+
*/
|
|
40
|
+
register(
|
|
41
|
+
onConnection: (incoming: WebSocketIncoming<TClientToServerImpl>, outgoing: WebSocketOutgoing<TServerToClient>) => void,
|
|
42
|
+
config?: WebSocketSchemaConfig
|
|
43
|
+
): void
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
GGWebSocketSchema.prototype.startServer = function (
|
|
48
|
+
this: GGWebSocketSchema<any, any, any, any>,
|
|
49
|
+
onConnection: any,
|
|
50
|
+
config: WebSocketSchemaConfig
|
|
51
|
+
): GGSocketServer<any, any> {
|
|
52
|
+
const contract = this.contract
|
|
53
|
+
if (!contract) {
|
|
54
|
+
throw new Error(`WebSocketSchema "${this.name}" has no contract.`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalizedPath = this.path.startsWith('/') ? this.path : '/' + this.path
|
|
58
|
+
const schemaName = this.name
|
|
59
|
+
const http = config.http ?? GGLocator.getScope().get(GG_HTTP_SERVER);
|
|
60
|
+
|
|
61
|
+
// @TODO We might want some lookup here based on path/middlewares etc. If I use same socket for multiple paths, we need to reuse also same GGSocketServer.
|
|
62
|
+
const socketServer = new GGSocketServer(http, {
|
|
63
|
+
apiName: schemaName,
|
|
64
|
+
path: normalizedPath,
|
|
65
|
+
middlewares: [...this.middlewares, ...(config?.middlewares ?? [])]
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
socketServer.onConnection((socket: GGSocket) => {
|
|
69
|
+
const clientToServerContract = contract.clientToServer
|
|
70
|
+
const serverToClientContract = contract.serverToClient
|
|
71
|
+
|
|
72
|
+
const incoming: any = {
|
|
73
|
+
on(handlers: any) {
|
|
74
|
+
const impl: Record<string, any> = {};
|
|
75
|
+
for (const methodName of Object.keys(clientToServerContract.methods)) {
|
|
76
|
+
const methodDef = clientToServerContract.methods[methodName] as any;
|
|
77
|
+
const params = methodDef.params;
|
|
78
|
+
impl[methodName] = (data: any) => {
|
|
79
|
+
// If method has params info, unpack data object to positional args
|
|
80
|
+
if (params && params.length > 0 && data && typeof data === 'object') {
|
|
81
|
+
const args = params.map((p: any) => data[p.name]);
|
|
82
|
+
return handlers[methodName](...args);
|
|
83
|
+
}
|
|
84
|
+
// Single or no parameter - pass directly
|
|
85
|
+
return handlers[methodName](data);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const incomingInstance = clientToServerContract.implement(impl);
|
|
90
|
+
|
|
91
|
+
for (const methodName of Object.keys(clientToServerContract.methods)) {
|
|
92
|
+
socket.registerHandler({
|
|
93
|
+
path: `${schemaName}.${methodName}`,
|
|
94
|
+
handler: (incomingInstance as any)[methodName]
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const impl: Record<string, any> = {};
|
|
101
|
+
for (const methodName of Object.keys(serverToClientContract.methods)) {
|
|
102
|
+
const method = serverToClientContract.methods[methodName];
|
|
103
|
+
const expectsResponse = 'success' in method;
|
|
104
|
+
impl[methodName] = (data: any) => {
|
|
105
|
+
return socket.send(`${schemaName}.${methodName}`, data, expectsResponse);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const outgoingInstance = serverToClientContract.implement(impl);
|
|
110
|
+
(outgoingInstance as any).onClose = (callback: () => void) => {
|
|
111
|
+
socket.onClose(callback)
|
|
112
|
+
}
|
|
113
|
+
onConnection(incoming, outgoingInstance)
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return socketServer;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
GGWebSocketSchema.prototype.register = function (
|
|
120
|
+
this: GGWebSocketSchema<any, any, any, any>,
|
|
121
|
+
onConnection: any,
|
|
122
|
+
config?: WebSocketSchemaConfig
|
|
123
|
+
): void {
|
|
124
|
+
let httpServer = config?.http;
|
|
125
|
+
if (!httpServer) {
|
|
126
|
+
httpServer = GGLocator.getScope().get(GG_HTTP_SERVER);
|
|
127
|
+
}
|
|
128
|
+
if (!httpServer) {
|
|
129
|
+
throw new Error(`No HTTP server found. Make sure to register GGHttpServerAdapter in the scope or pass it via config`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.startServer(onConnection, {
|
|
133
|
+
http: httpServer,
|
|
134
|
+
middlewares: config?.middlewares
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import {GGContextKey} from "@grest-ts/context";
|
|
2
|
+
import {IsNumber, IsObject, IsString} from "@grest-ts/schema";
|
|
3
|
+
|
|
4
|
+
const IsWsConnectionContext = IsObject({
|
|
5
|
+
port: IsNumber.orUndefined,
|
|
6
|
+
path: IsString
|
|
7
|
+
});
|
|
8
|
+
export type WsConnectionContext = typeof IsWsConnectionContext.infer;
|
|
9
|
+
|
|
10
|
+
export const GG_WS_CONNECTION = new GGContextKey<WsConnectionContext>('ws-connection', IsWsConnectionContext);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {GGContextKey} from "@grest-ts/context";
|
|
2
|
+
import {IsObject, IsString} from "@grest-ts/schema";
|
|
3
|
+
|
|
4
|
+
const IsWsMessageContext = IsObject({
|
|
5
|
+
path: IsString
|
|
6
|
+
});
|
|
7
|
+
export type WsMessageContext = typeof IsWsMessageContext.infer;
|
|
8
|
+
|
|
9
|
+
export const GG_WS_MESSAGE = new GGContextKey<WsMessageContext>('ws-message', IsWsMessageContext);
|