@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,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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface SocketAdapter {
|
|
2
|
+
send(message: string): void;
|
|
3
|
+
|
|
4
|
+
close(): void;
|
|
5
|
+
|
|
6
|
+
onOpen(handler: () => void): void;
|
|
7
|
+
|
|
8
|
+
onMessage(handler: (data: string) => void): void;
|
|
9
|
+
|
|
10
|
+
onClose(handler: () => void): void;
|
|
11
|
+
|
|
12
|
+
onError(handler: (error: Error) => void): void;
|
|
13
|
+
|
|
14
|
+
offOpen(handler: () => void): void;
|
|
15
|
+
|
|
16
|
+
offMessage(handler: (data: string) => void): void;
|
|
17
|
+
|
|
18
|
+
offClose(handler: () => void): void;
|
|
19
|
+
|
|
20
|
+
offError(handler: (error: Error) => void): void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {tPendingMessageId} from "./utils/PendingRequestsMap";
|
|
2
|
+
import {OK_JSON} from "@grest-ts/schema";
|
|
3
|
+
|
|
4
|
+
export const DELIMITER = ":"
|
|
5
|
+
|
|
6
|
+
// Message types (single character for fast type checking)
|
|
7
|
+
export enum MessageType {
|
|
8
|
+
HANDSHAKE = "h", // Handshake request (client -> server with headers)
|
|
9
|
+
HANDSHAKE_OK = "k", // Handshake success (server -> client)
|
|
10
|
+
HANDSHAKE_ERR = "x", // Handshake error (server -> client)
|
|
11
|
+
MSG = "m", // Regular message (send-and-forget)
|
|
12
|
+
REQ = "r", // Request (expects response)
|
|
13
|
+
RES = "s", // Successful response
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SocketMessage {
|
|
17
|
+
type: MessageType;
|
|
18
|
+
path: string;
|
|
19
|
+
data?: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HandshakeMessage extends SocketMessage {
|
|
23
|
+
type: MessageType.HANDSHAKE;
|
|
24
|
+
data: Record<string, string>; // Headers
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HandshakeOkMessage extends SocketMessage {
|
|
28
|
+
type: MessageType.HANDSHAKE_OK;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HandshakeErrMessage extends SocketMessage {
|
|
32
|
+
type: MessageType.HANDSHAKE_ERR;
|
|
33
|
+
data: any; // Error details
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RegularMessage extends SocketMessage {
|
|
37
|
+
type: MessageType.MSG;
|
|
38
|
+
data?: any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RequestMessage extends SocketMessage {
|
|
42
|
+
type: MessageType.REQ;
|
|
43
|
+
id: tPendingMessageId;
|
|
44
|
+
data?: any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ResponseMessage extends SocketMessage {
|
|
48
|
+
type: MessageType.RES;
|
|
49
|
+
id: tPendingMessageId;
|
|
50
|
+
data: OK_JSON<any>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type AnyMessage = HandshakeMessage | HandshakeOkMessage | HandshakeErrMessage | RegularMessage | RequestMessage | ResponseMessage;
|
|
54
|
+
|
|
55
|
+
export class Message {
|
|
56
|
+
|
|
57
|
+
public static create(type: MessageType, path: string, id: tPendingMessageId | "", data: any): string {
|
|
58
|
+
const dataStr = data !== undefined ? JSON.stringify(data) : "";
|
|
59
|
+
return type + DELIMITER + String(path) + DELIMITER + (id || "") + DELIMITER + dataStr;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static parse(msg: unknown): AnyMessage | undefined {
|
|
63
|
+
if (!msg) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const parts = String(msg).split(DELIMITER);
|
|
67
|
+
|
|
68
|
+
// Extract the first 4 parts (type, path, id) and everything else is data
|
|
69
|
+
const type = parts[0];
|
|
70
|
+
const path = parts[1];
|
|
71
|
+
const id = parts[2];
|
|
72
|
+
const data = parts.length > 3 ? parts.slice(3).join(DELIMITER) : undefined;
|
|
73
|
+
|
|
74
|
+
// Handshake messages don't require path
|
|
75
|
+
const isHandshake = type === MessageType.HANDSHAKE ||
|
|
76
|
+
type === MessageType.HANDSHAKE_OK ||
|
|
77
|
+
type === MessageType.HANDSHAKE_ERR;
|
|
78
|
+
|
|
79
|
+
if (!type || (!isHandshake && !path)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
let dataParsed: any = undefined;
|
|
83
|
+
if (data) {
|
|
84
|
+
try {
|
|
85
|
+
dataParsed = JSON.parse(data);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// If JSON parse fails, keep as undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
type: type as MessageType,
|
|
92
|
+
path,
|
|
93
|
+
id: (id || undefined) as any,
|
|
94
|
+
data: dataParsed
|
|
95
|
+
} as AnyMessage;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type helpers for WebSocket connection handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Incoming handler for clientToServer messages.
|
|
7
|
+
* Call .on() with handlers for each method.
|
|
8
|
+
*/
|
|
9
|
+
export type WebSocketIncoming<TClientToServer> = {
|
|
10
|
+
on(handlers: TClientToServer): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Outgoing interface for serverToClient messages.
|
|
15
|
+
* Includes all serverToClient methods plus onClose for lifecycle.
|
|
16
|
+
*/
|
|
17
|
+
export type WebSocketOutgoing<TServerToClient> = TServerToClient & {
|
|
18
|
+
onClose(callback: () => void): void
|
|
19
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {ERROR_JSON, OK_JSON, SERVER_ERROR} from "@grest-ts/schema";
|
|
2
|
+
|
|
3
|
+
interface PendingRequest {
|
|
4
|
+
resolve: (value: any) => void;
|
|
5
|
+
reject: (error: any) => void;
|
|
6
|
+
timeout: any;
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type tPendingMessageId = string & { tt: never };
|
|
11
|
+
|
|
12
|
+
export class PendingRequestsMap {
|
|
13
|
+
private readonly requests: Map<tPendingMessageId, PendingRequest> = new Map();
|
|
14
|
+
private requestIdCounter: number = 1;
|
|
15
|
+
// Callbacks waiting for the map to drain (become empty)
|
|
16
|
+
private drainCallbacks: Array<() => void> = [];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the number of pending requests
|
|
20
|
+
*/
|
|
21
|
+
public get size(): number {
|
|
22
|
+
return this.requests.size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if there are any pending requests
|
|
27
|
+
*/
|
|
28
|
+
public hasPending(): boolean {
|
|
29
|
+
return this.requests.size > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wait for all pending requests to complete (resolve or reject).
|
|
34
|
+
* Uses event-driven notification instead of polling.
|
|
35
|
+
* @param timeoutMs - Maximum time to wait for pending requests
|
|
36
|
+
* @returns Promise that resolves when all pending requests are done or timeout is reached
|
|
37
|
+
*/
|
|
38
|
+
public waitForPending(timeoutMs: number = 5000): Promise<void> {
|
|
39
|
+
if (this.requests.size === 0) {
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new Promise<void>((resolve) => {
|
|
44
|
+
const cleanup = () => {
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
const idx = this.drainCallbacks.indexOf(onDrain);
|
|
47
|
+
if (idx !== -1) this.drainCallbacks.splice(idx, 1);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onDrain = () => {
|
|
51
|
+
cleanup();
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const timeout = setTimeout(() => {
|
|
56
|
+
cleanup();
|
|
57
|
+
resolve(); // Resolve on timeout (caller checks hasPending)
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
|
|
60
|
+
this.drainCallbacks.push(onDrain);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Notify drain callbacks if map is empty
|
|
66
|
+
*/
|
|
67
|
+
private notifyDrainIfEmpty(): void {
|
|
68
|
+
if (this.requests.size === 0 && this.drainCallbacks.length > 0) {
|
|
69
|
+
const callbacks = [...this.drainCallbacks];
|
|
70
|
+
this.drainCallbacks = [];
|
|
71
|
+
callbacks.forEach(cb => cb());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public create(
|
|
76
|
+
path: string,
|
|
77
|
+
timeoutMs: number,
|
|
78
|
+
callback: (id: tPendingMessageId, waitForResponsePromise: Promise<any>) => Promise<any>
|
|
79
|
+
): Promise<any> {
|
|
80
|
+
const requestId = String(this.requestIdCounter++) as tPendingMessageId;
|
|
81
|
+
const reqPromise = new Promise((resolve, reject) => {
|
|
82
|
+
const timeout = setTimeout(() => {
|
|
83
|
+
this.requests.delete(requestId);
|
|
84
|
+
this.notifyDrainIfEmpty();
|
|
85
|
+
reject(new SERVER_ERROR({
|
|
86
|
+
displayMessage: 'Request timeout',
|
|
87
|
+
debugData: {
|
|
88
|
+
timeout: timeoutMs,
|
|
89
|
+
}
|
|
90
|
+
}));
|
|
91
|
+
}, timeoutMs);
|
|
92
|
+
this.requests.set(requestId, {resolve, reject, timeout, path});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return callback(requestId, reqPromise)
|
|
96
|
+
.catch((error) => {
|
|
97
|
+
// If callback throws an error, clean up the pending request
|
|
98
|
+
const pending = this.requests.get(requestId);
|
|
99
|
+
if (pending) {
|
|
100
|
+
clearTimeout(pending.timeout);
|
|
101
|
+
this.requests.delete(requestId);
|
|
102
|
+
this.notifyDrainIfEmpty();
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public resolve(requestId: tPendingMessageId, value: OK_JSON<any> | ERROR_JSON): boolean {
|
|
109
|
+
const pending = this.requests.get(requestId);
|
|
110
|
+
if (!pending) {
|
|
111
|
+
throw new Error('Pending request not found: ' + requestId);
|
|
112
|
+
}
|
|
113
|
+
clearTimeout(pending.timeout);
|
|
114
|
+
this.requests.delete(requestId);
|
|
115
|
+
pending.resolve(value);
|
|
116
|
+
this.notifyDrainIfEmpty();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public rejectAll(error: typeof SERVER_ERROR.infer): void {
|
|
121
|
+
this.requests.forEach((pending) => {
|
|
122
|
+
clearTimeout(pending.timeout);
|
|
123
|
+
pending.resolve(error.toJSON());
|
|
124
|
+
});
|
|
125
|
+
this.requests.clear();
|
|
126
|
+
this.notifyDrainIfEmpty();
|
|
127
|
+
}
|
|
128
|
+
}
|