@grest-ts/websocket 0.0.23 → 0.0.24

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.
Files changed (67) hide show
  1. package/README.md +150 -40
  2. package/dist/src/adapter/NodeSocketAdapter.d.ts +2 -0
  3. package/dist/src/adapter/NodeSocketAdapter.d.ts.map +1 -1
  4. package/dist/src/adapter/NodeSocketAdapter.js +6 -0
  5. package/dist/src/adapter/NodeSocketAdapter.js.map +1 -1
  6. package/dist/src/client/GGSocketPool.d.ts +21 -0
  7. package/dist/src/client/GGSocketPool.d.ts.map +1 -1
  8. package/dist/src/client/GGSocketPool.js +82 -63
  9. package/dist/src/client/GGSocketPool.js.map +1 -1
  10. package/dist/src/client/GGWebSocketSchema.createClient.d.ts +154 -0
  11. package/dist/src/client/GGWebSocketSchema.createClient.d.ts.map +1 -0
  12. package/dist/src/client/GGWebSocketSchema.createClient.js +345 -0
  13. package/dist/src/client/GGWebSocketSchema.createClient.js.map +1 -0
  14. package/dist/src/index-browser.d.ts +2 -1
  15. package/dist/src/index-browser.d.ts.map +1 -1
  16. package/dist/src/index-browser.js +3 -1
  17. package/dist/src/index-browser.js.map +1 -1
  18. package/dist/src/index-node.d.ts +2 -1
  19. package/dist/src/index-node.d.ts.map +1 -1
  20. package/dist/src/index-node.js +2 -1
  21. package/dist/src/index-node.js.map +1 -1
  22. package/dist/src/schema/GGWebSocketMiddleware.d.ts +12 -0
  23. package/dist/src/schema/GGWebSocketMiddleware.d.ts.map +1 -1
  24. package/dist/src/schema/GGWebSocketMiddleware.js +0 -4
  25. package/dist/src/schema/GGWebSocketMiddleware.js.map +1 -1
  26. package/dist/src/schema/GGWebSocketSchema.d.ts +4 -3
  27. package/dist/src/schema/GGWebSocketSchema.d.ts.map +1 -1
  28. package/dist/src/schema/GGWebSocketSchema.js +3 -1
  29. package/dist/src/schema/GGWebSocketSchema.js.map +1 -1
  30. package/dist/src/schema/webSocketSchema.d.ts +12 -6
  31. package/dist/src/schema/webSocketSchema.d.ts.map +1 -1
  32. package/dist/src/schema/webSocketSchema.js +9 -2
  33. package/dist/src/schema/webSocketSchema.js.map +1 -1
  34. package/dist/src/server/GGSocketServer.d.ts.map +1 -1
  35. package/dist/src/server/GGSocketServer.js +36 -2
  36. package/dist/src/server/GGSocketServer.js.map +1 -1
  37. package/dist/src/server/GGWebSocketSchema.startServer.d.ts +5 -3
  38. package/dist/src/server/GGWebSocketSchema.startServer.d.ts.map +1 -1
  39. package/dist/src/server/GGWebSocketSchema.startServer.js +7 -5
  40. package/dist/src/server/GGWebSocketSchema.startServer.js.map +1 -1
  41. package/dist/src/socket/GGSocket.d.ts +13 -1
  42. package/dist/src/socket/GGSocket.d.ts.map +1 -1
  43. package/dist/src/socket/GGSocket.js +52 -2
  44. package/dist/src/socket/GGSocket.js.map +1 -1
  45. package/dist/src/socket/SocketAdapter.d.ts +11 -0
  46. package/dist/src/socket/SocketAdapter.d.ts.map +1 -1
  47. package/dist/testkit/client/GGWebSocketSchema.callOn.d.ts +1 -1
  48. package/dist/testkit/client/GGWebSocketSchema.callOn.d.ts.map +1 -1
  49. package/dist/tsconfig.publish.tsbuildinfo +1 -1
  50. package/package.json +11 -11
  51. package/src/adapter/NodeSocketAdapter.ts +8 -0
  52. package/src/client/GGSocketPool.ts +90 -73
  53. package/src/client/GGWebSocketSchema.createClient.ts +534 -0
  54. package/src/index-browser.ts +5 -2
  55. package/src/index-node.ts +2 -1
  56. package/src/schema/GGWebSocketMiddleware.ts +14 -0
  57. package/src/schema/GGWebSocketSchema.ts +7 -3
  58. package/src/schema/webSocketSchema.ts +18 -8
  59. package/src/server/GGSocketServer.ts +51 -2
  60. package/src/server/GGWebSocketSchema.startServer.ts +14 -10
  61. package/src/socket/GGSocket.ts +56 -2
  62. package/src/socket/SocketAdapter.ts +13 -0
  63. package/dist/src/client/GGSocketClient.d.ts +0 -10
  64. package/dist/src/client/GGSocketClient.d.ts.map +0 -1
  65. package/dist/src/client/GGSocketClient.js +0 -17
  66. package/dist/src/client/GGSocketClient.js.map +0 -1
  67. package/src/client/GGSocketClient.ts +0 -25
@@ -29,6 +29,53 @@ export interface GGSocketServerConfig<TContext, Query> {
29
29
  middlewares: readonly GGWebSocketMiddleware[];
30
30
  }
31
31
 
32
+ /**
33
+ * Shared path-dispatching upgrade registry per http.Server.
34
+ *
35
+ * The `ws` library's `{server, path}` mode aborts the HTTP handshake with 400
36
+ * whenever the upgrade path doesn't match — so attaching two WebSocketServer
37
+ * instances to the same http.Server causes whichever one fires first to reject
38
+ * requests meant for the other. We install a single shared 'upgrade' listener
39
+ * on each http.Server and dispatch by path instead.
40
+ */
41
+ interface WsRegistry {
42
+ readonly wssByPath: Map<string, WebSocketServer>
43
+ }
44
+
45
+ const wsRegistryByHttpServer = new WeakMap<http.Server, WsRegistry>();
46
+
47
+ function attachUpgradeDispatch(httpServer: http.Server, path: string, wss: WebSocketServer): void {
48
+ let registry = wsRegistryByHttpServer.get(httpServer);
49
+ if (!registry) {
50
+ registry = {wssByPath: new Map()};
51
+ wsRegistryByHttpServer.set(httpServer, registry);
52
+ const captured = registry;
53
+ httpServer.on('upgrade', (req, socket, head) => {
54
+ const pathname = (req.url ?? '').split('?')[0];
55
+ const matched = captured.wssByPath.get(pathname);
56
+ if (matched) {
57
+ matched.handleUpgrade(req, socket, head, (ws) => {
58
+ matched.emit('connection', ws, req);
59
+ });
60
+ } else {
61
+ socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
62
+ socket.destroy();
63
+ }
64
+ });
65
+ }
66
+ if (registry.wssByPath.has(path)) {
67
+ throw new Error(`WebSocket path "${path}" is already registered on this HTTP server.`);
68
+ }
69
+ registry.wssByPath.set(path, wss);
70
+ }
71
+
72
+ function detachUpgradeDispatch(httpServer: http.Server, path: string): void {
73
+ const registry = wsRegistryByHttpServer.get(httpServer);
74
+ if (registry) {
75
+ registry.wssByPath.delete(path);
76
+ }
77
+ }
78
+
32
79
  export class GGSocketServer<TContext, Query> {
33
80
 
34
81
  private readonly wss: WebSocketServer;
@@ -50,8 +97,9 @@ export class GGSocketServer<TContext, Query> {
50
97
  this.apiName = config.apiName;
51
98
  this.middlewares = config.middlewares;
52
99
  this.queryValidator = config.queryValidator;
53
- this.wss = new WebSocketServer({server: http.httpServer, path: this.path});
100
+ this.wss = new WebSocketServer({noServer: true});
54
101
  this.wss.on('connection', this.scope.wrapWithEnter(this._onConnection));
102
+ attachUpgradeDispatch(http.httpServer, this.path, this.wss);
55
103
  this.http = http
56
104
  .onStart(() => {
57
105
  GGLog.info(this, "WebSocket server started", {api: config.apiName, path: config.path});
@@ -68,7 +116,8 @@ export class GGSocketServer<TContext, Query> {
68
116
  .onTeardown(async () => {
69
117
  GGLog.info(this, "WebSocket server closing");
70
118
  try {
71
- this.wss.close(); // We use external http, so this does not close existing connections.
119
+ detachUpgradeDispatch(http.httpServer, this.path);
120
+ this.wss.close();
72
121
  } catch (error) {
73
122
  GGLog.error(this, error);
74
123
  }
@@ -24,12 +24,14 @@ export interface WebSocketSchemaConfig {
24
24
  }
25
25
 
26
26
  declare module "../schema/GGWebSocketSchema" {
27
- interface GGWebSocketSchema<TClientToServer, TServerToClient, TContext = {}, TQuery = undefined, TClientToServerImpl = TClientToServer> {
27
+ interface GGWebSocketSchema<TClientToServer, TServerToClient, TContext = {}, TQuery = undefined, TClientToServerImpl = TClientToServer, TServerToClientImpl = TServerToClient> {
28
28
  /**
29
29
  * Start the WebSocket server for this API.
30
+ * The onConnection handler receives validated query parameters as its 3rd argument
31
+ * (only populated when the schema declares `queryOnConnect(validator)`).
30
32
  */
31
33
  startServer(
32
- onConnection: (incoming: WebSocketIncoming<TClientToServerImpl>, outgoing: WebSocketOutgoing<TServerToClient>) => void,
34
+ onConnection: (incoming: WebSocketIncoming<TClientToServerImpl>, outgoing: WebSocketOutgoing<TServerToClient>, query: TQuery) => void,
33
35
  config: WebSocketSchemaConfig
34
36
  ): GGSocketServer<TContext, TQuery>
35
37
 
@@ -38,14 +40,14 @@ declare module "../schema/GGWebSocketSchema" {
38
40
  * Uses GGHttpServerAdapter from locator if not explicitly provided.
39
41
  */
40
42
  register(
41
- onConnection: (incoming: WebSocketIncoming<TClientToServerImpl>, outgoing: WebSocketOutgoing<TServerToClient>) => void,
43
+ onConnection: (incoming: WebSocketIncoming<TClientToServerImpl>, outgoing: WebSocketOutgoing<TServerToClient>, query: TQuery) => void,
42
44
  config?: WebSocketSchemaConfig
43
45
  ): void
44
46
  }
45
47
  }
46
48
 
47
49
  GGWebSocketSchema.prototype.startServer = function (
48
- this: GGWebSocketSchema<any, any, any, any>,
50
+ this: GGWebSocketSchema<any, any, any, any, any, any>,
49
51
  onConnection: any,
50
52
  config: WebSocketSchemaConfig
51
53
  ): GGSocketServer<any, any> {
@@ -57,15 +59,17 @@ GGWebSocketSchema.prototype.startServer = function (
57
59
  const normalizedPath = this.path.startsWith('/') ? this.path : '/' + this.path
58
60
  const schemaName = this.name
59
61
  const http = config.http ?? GGLocator.getScope().get(GG_HTTP_SERVER);
62
+ http._registerWebSocketSchema(this as any);
60
63
 
61
64
  // @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
65
  const socketServer = new GGSocketServer(http, {
63
66
  apiName: schemaName,
64
67
  path: normalizedPath,
65
- middlewares: [...this.middlewares, ...(config?.middlewares ?? [])]
68
+ middlewares: [...this.middlewares, ...(config?.middlewares ?? [])],
69
+ queryValidator: this.queryValidator,
66
70
  });
67
71
 
68
- socketServer.onConnection((socket: GGSocket) => {
72
+ socketServer.onConnection((socket: GGSocket, queryArgs: any) => {
69
73
  const clientToServerContract = contract.clientToServer
70
74
  const serverToClientContract = contract.serverToClient
71
75
 
@@ -86,7 +90,7 @@ GGWebSocketSchema.prototype.startServer = function (
86
90
  };
87
91
  }
88
92
 
89
- const incomingInstance = clientToServerContract.implement(impl);
93
+ const incomingInstance = clientToServerContract.implement(impl, {skipLocatorRegistration: true});
90
94
 
91
95
  for (const methodName of Object.keys(clientToServerContract.methods)) {
92
96
  socket.registerHandler({
@@ -106,18 +110,18 @@ GGWebSocketSchema.prototype.startServer = function (
106
110
  };
107
111
  }
108
112
 
109
- const outgoingInstance = serverToClientContract.implement(impl);
113
+ const outgoingInstance = serverToClientContract.implement(impl, {skipLocatorRegistration: true});
110
114
  (outgoingInstance as any).onClose = (callback: () => void) => {
111
115
  socket.onClose(callback)
112
116
  }
113
- onConnection(incoming, outgoingInstance)
117
+ onConnection(incoming, outgoingInstance, queryArgs)
114
118
  });
115
119
 
116
120
  return socketServer;
117
121
  }
118
122
 
119
123
  GGWebSocketSchema.prototype.register = function (
120
- this: GGWebSocketSchema<any, any, any, any>,
124
+ this: GGWebSocketSchema<any, any, any, any, any, any>,
121
125
  onConnection: any,
122
126
  config?: WebSocketSchemaConfig
123
127
  ): void {
@@ -232,11 +232,13 @@ export class GGSocket {
232
232
  * @param path - The message path/route
233
233
  * @param body - The message data (already validated)
234
234
  * @param expectsResponse - Whether to wait for a response
235
+ * @param timeoutMs - Timeout for req/res in ms. Ignored for fire-and-forget. Defaults to 30_000.
235
236
  */
236
237
  public async send(
237
238
  path: string,
238
239
  body: any,
239
- expectsResponse: boolean
240
+ expectsResponse: boolean,
241
+ timeoutMs: number = 30000
240
242
  ): Promise<any> {
241
243
  const labels: MetricLabels = {api: this.apiName, path: this.socketPath, method: path};
242
244
  const startTime = performance.now();
@@ -247,7 +249,7 @@ export class GGSocket {
247
249
  }
248
250
 
249
251
  if (expectsResponse) {
250
- return this.pendingRequests.create(path, 30000, async (id, waitForResponse) => {
252
+ return this.pendingRequests.create(path, timeoutMs, async (id, waitForResponse) => {
251
253
  this.socket.send(Message.create(MessageType.REQ, path, id, body));
252
254
  const result = await waitForResponse;
253
255
  const resultType = result?.success === true ? 'OK' : (result?.type ?? 'SERVER_ERROR');
@@ -307,6 +309,58 @@ export class GGSocket {
307
309
  return this;
308
310
  }
309
311
 
312
+ // --------------------------------------------------------------------------------------
313
+ // Heartbeat (PING/PONG) — dead-connection detection
314
+ // --------------------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Start a heartbeat loop that sends protocol-level PINGs and closes the socket
318
+ * if no PONG comes back within `intervalMs + timeoutMs`. Returns a stop function.
319
+ *
320
+ * No-op (returns an empty stop fn) if the underlying adapter does not support
321
+ * ping/pong — e.g. the browser WebSocket API cannot initiate pings.
322
+ */
323
+ public startHeartbeat(config: {intervalMs: number; timeoutMs: number}): () => void {
324
+ // Adapter doesn't support ping/pong (e.g. browser WebSocket) — no-op.
325
+ if (!this.socket.ping || !this.socket.onPong) {
326
+ return () => {};
327
+ }
328
+ // Socket is already closed — starting heartbeat would leak intervals
329
+ // because the onCloseCallbacks push below won't fire (isCleanedUp guard).
330
+ if (!this.isActive) {
331
+ return () => {};
332
+ }
333
+ let lastActivity = Date.now();
334
+ const onPong = () => { lastActivity = Date.now(); };
335
+ this.socket.onPong(onPong);
336
+
337
+ const sender = setInterval(() => {
338
+ if (!this.isActive) return;
339
+ try {
340
+ this.socket.ping!();
341
+ } catch (_) { /* adapter may throw if socket already closing */ }
342
+ }, config.intervalMs);
343
+
344
+ const watchdog = setInterval(() => {
345
+ if (!this.isActive) return;
346
+ if (Date.now() - lastActivity > config.intervalMs + config.timeoutMs) {
347
+ this.log.warn(this, 'Heartbeat timeout — no PONG received; closing socket');
348
+ clearInterval(sender);
349
+ clearInterval(watchdog);
350
+ this.close();
351
+ }
352
+ }, config.timeoutMs);
353
+
354
+ // Auto-cleanup on close
355
+ const cleanup = () => {
356
+ clearInterval(sender);
357
+ clearInterval(watchdog);
358
+ };
359
+ this.onCloseCallbacks.push(cleanup);
360
+
361
+ return cleanup;
362
+ }
363
+
310
364
  // --------------------------------------------------------------------------------------
311
365
  // Teardown
312
366
  // --------------------------------------------------------------------------------------
@@ -18,4 +18,17 @@ export interface SocketAdapter {
18
18
  offClose(handler: () => void): void;
19
19
 
20
20
  offError(handler: (error: Error) => void): void;
21
+
22
+ /**
23
+ * Send a protocol-level PING frame. The peer auto-responds with a PONG.
24
+ * Optional: browsers cannot initiate pings (the native WebSocket API does not expose it).
25
+ * Node can; use this for dead-connection detection.
26
+ */
27
+ ping?(): void;
28
+
29
+ /**
30
+ * Register a handler for PONG frames received from the peer.
31
+ * Paired with `ping()` — only supported by adapters that also support ping.
32
+ */
33
+ onPong?(handler: () => void): void;
21
34
  }
@@ -1,10 +0,0 @@
1
- import { GGSocket } from "../socket/GGSocket";
2
- import { GGContractMethod } from "@grest-ts/schema";
3
- export declare abstract class GGSocketClient {
4
- readonly socket: GGSocket;
5
- constructor(socket: GGSocket);
6
- onClose(onClose: () => void): this;
7
- close(): void;
8
- __defineApi<T extends Record<string, GGContractMethod<any, any, any>>>(api: T): T;
9
- }
10
- //# sourceMappingURL=GGSocketClient.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"GGSocketClient.d.ts","sourceRoot":"","sources":["../../../src/client/GGSocketClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,QAAQ,EAAC,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAElD,8BAAsB,cAAc;IAEhC,SAAgB,MAAM,EAAE,QAAQ,CAAA;gBAEpB,MAAM,EAAE,QAAQ;IAIrB,OAAO,CAAC,OAAO,EAAE,MAAM,IAAI;IAK3B,KAAK,IAAI,IAAI;IAIb,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC;CAI3F"}
@@ -1,17 +0,0 @@
1
- export class GGSocketClient {
2
- socket;
3
- constructor(socket) {
4
- this.socket = socket;
5
- }
6
- onClose(onClose) {
7
- this.socket.onClose(onClose);
8
- return this;
9
- }
10
- close() {
11
- this.socket.close();
12
- }
13
- __defineApi(api) {
14
- return api;
15
- }
16
- }
17
- //# sourceMappingURL=GGSocketClient.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"GGSocketClient.js","sourceRoot":"","sources":["../../../src/client/GGSocketClient.ts"],"names":[],"mappings":"AAGA,MAAM,OAAgB,cAAc;IAEhB,MAAM,CAAU;IAEhC,YAAY,MAAgB;QACxB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACzB,CAAC;IAEM,OAAO,CAAC,OAAmB;QAC9B,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QAC5B,OAAO,IAAI,CAAA;IACf,CAAC;IAEM,KAAK;QACR,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAA;IACvB,CAAC;IAEM,WAAW,CAA4D,GAAM;QAChF,OAAO,GAAG,CAAC;IACf,CAAC;CAEJ"}
@@ -1,25 +0,0 @@
1
- import {GGSocket} from "../socket/GGSocket";
2
- import {GGContractMethod} from "@grest-ts/schema";
3
-
4
- export abstract class GGSocketClient {
5
-
6
- public readonly socket: GGSocket
7
-
8
- constructor(socket: GGSocket) {
9
- this.socket = socket;
10
- }
11
-
12
- public onClose(onClose: () => void) {
13
- this.socket.onClose(onClose)
14
- return this
15
- }
16
-
17
- public close(): void {
18
- this.socket.close()
19
- }
20
-
21
- public __defineApi<T extends Record<string, GGContractMethod<any, any, any>>>(api: T): T {
22
- return api;
23
- }
24
-
25
- }