@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
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Client extension for GGWebSocketSchema - adds createClient method.
3
+ *
4
+ * Mirrors the server's onConnection handler exactly:
5
+ *
6
+ * server: ChatApi.register((incoming, outgoing) => {
7
+ * incoming.on({ ... })
8
+ * })
9
+ *
10
+ * client: const client = ChatApi.createClient({ url })
11
+ * await client.connect(({incoming, outgoing}) => {
12
+ * incoming.on({ ... })
13
+ * })
14
+ * await client.outgoing.xxx(...)
15
+ *
16
+ * The setup callback is the single place that wires handlers. It is re-run on
17
+ * every successful (re)connection, so auto-reconnect produces a fully-rewired
18
+ * client without any persistent-handler state to go stale.
19
+ *
20
+ * Works in both browser and Node.js contexts.
21
+ */
22
+
23
+ import {
24
+ FORBIDDEN,
25
+ GGContractExecutor,
26
+ GGContractMethod,
27
+ GGPromise,
28
+ NOT_AUTHORIZED,
29
+ SERVER_ERROR,
30
+ VALIDATION_ERROR,
31
+ } from "@grest-ts/schema"
32
+ import {GGWebSocketSchema} from "../schema/GGWebSocketSchema"
33
+ import {GGSocketPool} from "./GGSocketPool"
34
+ import {GGSocket} from "../socket/GGSocket"
35
+ import {GGWebSocketMiddleware} from "../schema/GGWebSocketMiddleware"
36
+
37
+ export interface GGHeartbeatConfig {
38
+ /** How often to send a PING. Default 30 000 ms. */
39
+ intervalMs?: number
40
+ /** Grace period for a PONG after each PING. Default 10 000 ms. */
41
+ timeoutMs?: number
42
+ }
43
+
44
+ export interface GGReconnectConfig {
45
+ /** First retry delay. Default 500 ms. */
46
+ initialDelayMs?: number
47
+ /** Delay cap for exponential backoff. Default 30 000 ms. */
48
+ maxDelayMs?: number
49
+ /** Backoff multiplier. Default 2. */
50
+ multiplier?: number
51
+ /** Give up after this many consecutive failures. Default Infinity. */
52
+ maxAttempts?: number
53
+ /**
54
+ * Predicate deciding whether an error during a reconnect attempt should
55
+ * trigger another retry. Default: retry on any error EXCEPT NOT_AUTHORIZED
56
+ * and FORBIDDEN — those are treated as permanent and fire a final onClose
57
+ * with reason "unrecoverable". Return true to retry, false to give up.
58
+ */
59
+ shouldRetry?: (error: Error) => boolean
60
+ /**
61
+ * Dead-connection detection via protocol PING/PONG. Off by default.
62
+ * Only supported on Node (browsers cannot initiate pings). Browser clients
63
+ * silently ignore this config.
64
+ */
65
+ heartbeat?: GGHeartbeatConfig
66
+ }
67
+
68
+ export interface GGWebSocketClientConfig<TQuery = undefined> {
69
+ /**
70
+ * WebSocket server URL, e.g. "ws://localhost:3000".
71
+ * If omitted, uses service discovery (requires @grest-ts/discovery).
72
+ * In browsers, pass an explicit URL (or "" for same-origin).
73
+ */
74
+ url?: string
75
+ /**
76
+ * Query parameters to include on connect. Typed from `queryOnConnect<T>()` if used.
77
+ * If the schema declares a query validator, it's applied here before connecting.
78
+ */
79
+ query?: TQuery
80
+ /**
81
+ * Extra middlewares merged on top of the schema's middlewares, in order.
82
+ * Use this to attach per-client concerns (e.g. a static auth token) without
83
+ * requiring callers to set up a GGContext around connect(). Manual
84
+ * header manipulation is intentionally not exposed — middleware is the API.
85
+ */
86
+ middlewares?: GGWebSocketMiddleware[]
87
+ /**
88
+ * Default timeout in ms for request/response outgoing calls. Defaults to 30 000.
89
+ * Fire-and-forget methods ignore this.
90
+ */
91
+ timeout?: number
92
+ /**
93
+ * Auto-reconnect on unexpected drops. Default off.
94
+ * `true` enables with defaults; pass an object to tune.
95
+ * Manual `disconnect()` / `close()` always wins over reconnect.
96
+ */
97
+ reconnect?: boolean | GGReconnectConfig
98
+ }
99
+
100
+ export interface GGWebSocketSetupTools<TServerToClientImpl, TClientToServer> {
101
+ incoming: {
102
+ /**
103
+ * Register handlers for serverToClient messages.
104
+ * Partial — register only methods you care about.
105
+ */
106
+ on(handlers: Partial<TServerToClientImpl>): void
107
+ }
108
+ outgoing: TClientToServer
109
+ }
110
+
111
+ /**
112
+ * Setup callback — receives handler registration tools + outgoing methods.
113
+ * Re-invoked on every successful (re)connection; keep it pure wrt setup actions.
114
+ *
115
+ * For correctness: register incoming handlers synchronously at the top of the
116
+ * callback (before any `await`), so no pushed message can slip through before
117
+ * handlers exist.
118
+ */
119
+ export type GGWebSocketSetup<TServerToClientImpl, TClientToServer> = (
120
+ tools: GGWebSocketSetupTools<TServerToClientImpl, TClientToServer>
121
+ ) => void | Promise<void>
122
+
123
+ /**
124
+ * Reason the client was finally closed (no further reconnects will happen).
125
+ *
126
+ * - "manual" — user called disconnect()/close()
127
+ * - "drop" — socket dropped and reconnect is disabled
128
+ * - "retries-exhausted" — reconnect enabled, hit maxAttempts
129
+ * - "unrecoverable" — reconnect skipped due to shouldRetry returning false
130
+ * (default: NOT_AUTHORIZED / FORBIDDEN)
131
+ */
132
+ export type GGWebSocketCloseReason = "manual" | "drop" | "retries-exhausted" | "unrecoverable"
133
+
134
+ export interface GGWebSocketClient<TClientToServer, TServerToClientImpl> {
135
+ /**
136
+ * Methods to call on the server (clientToServer).
137
+ * Throws `SERVER_ERROR` synchronously if called before connect().
138
+ */
139
+ readonly outgoing: TClientToServer
140
+
141
+ /** True when a socket is connected and the handshake has completed. */
142
+ readonly isConnected: boolean
143
+
144
+ /**
145
+ * Establish the connection and run the setup callback.
146
+ * If `reconnect` is enabled, the callback is re-invoked on every successful
147
+ * reconnection, so handlers + initial outgoing calls rebuild the full state.
148
+ */
149
+ connect(setup?: GGWebSocketSetup<TServerToClientImpl, TClientToServer>): Promise<void>
150
+
151
+ /**
152
+ * Gracefully close. Disables further auto-reconnect and drains pending calls.
153
+ */
154
+ disconnect(): Promise<void>
155
+
156
+ /**
157
+ * Immediately close. Disables further auto-reconnect.
158
+ */
159
+ close(): void
160
+
161
+ /**
162
+ * Fires once, when the client has stopped reconnecting.
163
+ * The `reason` identifies why; for terminal/error cases `error` carries the cause.
164
+ */
165
+ onClose(cb: (reason: GGWebSocketCloseReason, error?: Error) => void): this
166
+
167
+ /**
168
+ * Fires on every socket drop (before any reconnect attempt).
169
+ * `"manual"` = user called disconnect/close; `"drop"` = unexpected.
170
+ */
171
+ onDisconnect(cb: (reason: "manual" | "drop") => void): this
172
+
173
+ /**
174
+ * Fires on socket errors. Multiple events possible per connection lifetime.
175
+ */
176
+ onError(cb: (error: Error) => void): this
177
+ }
178
+
179
+ declare module "../schema/GGWebSocketSchema" {
180
+ interface GGWebSocketSchema<
181
+ TClientToServer,
182
+ TServerToClient,
183
+ TContext = {},
184
+ TQuery = undefined,
185
+ TClientToServerImpl = TClientToServer,
186
+ TServerToClientImpl = TServerToClient
187
+ > {
188
+ createClient(
189
+ config?: GGWebSocketClientConfig<TQuery>
190
+ ): GGWebSocketClient<TClientToServer, TServerToClientImpl>
191
+ }
192
+ }
193
+
194
+ interface NormalizedReconnect {
195
+ initialDelayMs: number
196
+ maxDelayMs: number
197
+ multiplier: number
198
+ maxAttempts: number
199
+ shouldRetry: (error: Error) => boolean
200
+ heartbeat?: {intervalMs: number; timeoutMs: number}
201
+ }
202
+
203
+ /** Default: don't retry if the server said "auth" — re-trying won't help. */
204
+ const defaultShouldRetry = (err: Error): boolean => {
205
+ if (err instanceof NOT_AUTHORIZED) return false
206
+ if (err instanceof FORBIDDEN) return false
207
+ return true
208
+ }
209
+
210
+ function normalizeReconnect(r: boolean | GGReconnectConfig | undefined): NormalizedReconnect | null {
211
+ if (!r) return null
212
+ const cfg = r === true ? {} : r
213
+ return {
214
+ initialDelayMs: cfg.initialDelayMs ?? 500,
215
+ maxDelayMs: cfg.maxDelayMs ?? 30_000,
216
+ multiplier: cfg.multiplier ?? 2,
217
+ maxAttempts: cfg.maxAttempts ?? Infinity,
218
+ shouldRetry: cfg.shouldRetry ?? defaultShouldRetry,
219
+ heartbeat: cfg.heartbeat
220
+ ? {
221
+ intervalMs: cfg.heartbeat.intervalMs ?? 30_000,
222
+ timeoutMs: cfg.heartbeat.timeoutMs ?? 10_000,
223
+ }
224
+ : undefined,
225
+ }
226
+ }
227
+
228
+ GGWebSocketSchema.prototype.createClient = function (
229
+ this: GGWebSocketSchema<any, any, any, any, any, any>,
230
+ config?: GGWebSocketClientConfig<any>
231
+ ): GGWebSocketClient<any, any> {
232
+ const contract = this.contract
233
+ if (!contract) {
234
+ throw new Error(`WebSocketSchema "${this.name}" has no contract.`)
235
+ }
236
+
237
+ const schemaName = this.name
238
+ const normalizedPath = this.path.startsWith("/") ? this.path : "/" + this.path
239
+ const schemaMiddlewares = this.middlewares || []
240
+ const queryValidator = this.queryValidator
241
+ const clientToServerContract = contract.clientToServer
242
+ const serverToClientContract = contract.serverToClient
243
+ const timeout = config?.timeout ?? 30_000
244
+ const reconnectConfig = normalizeReconnect(config?.reconnect)
245
+
246
+ let socket: GGSocket | undefined
247
+ let savedSetup: GGWebSocketSetup<any, any> | undefined
248
+ let reconnectAttempt = 0
249
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined
250
+ let finallyClosed = false
251
+ let finalCloseFired = false
252
+
253
+ const onCloseCallbacks: Array<(reason: GGWebSocketCloseReason, error?: Error) => void> = []
254
+ const onDisconnectCallbacks: Array<(reason: "manual" | "drop") => void> = []
255
+ const onErrorCallbacks: Array<(error: Error) => void> = []
256
+
257
+ // --------------------------------------------------------------------
258
+ // URL resolution + query validation
259
+ // --------------------------------------------------------------------
260
+
261
+ const resolveDomain = async (): Promise<string> => {
262
+ if (config?.url !== undefined) {
263
+ return config.url
264
+ }
265
+ try {
266
+ const {GG_DISCOVERY} = await import(/* @vite-ignore */ "@grest-ts/discovery")
267
+ return await GG_DISCOVERY.get().discoverApi(schemaName)
268
+ } catch (err) {
269
+ throw new SERVER_ERROR({
270
+ displayMessage: "Service discovery failed for WebSocket API " + schemaName,
271
+ originalError: err,
272
+ })
273
+ }
274
+ }
275
+
276
+ const validateQuery = (): any => {
277
+ if (!queryValidator) return config?.query
278
+ if (config?.query === undefined) return undefined
279
+ const parsed = queryValidator.safeParse(config.query, true)
280
+ if (parsed.success === false) {
281
+ throw new VALIDATION_ERROR(parsed.issues.toJSON(), {displayMessage: "Invalid query parameters"})
282
+ }
283
+ return parsed.value
284
+ }
285
+
286
+ // --------------------------------------------------------------------
287
+ // Outgoing — stable object; methods throw if called before/after connect
288
+ // --------------------------------------------------------------------
289
+
290
+ const outgoingImpl: Record<string, any> = {}
291
+ for (const methodName of Object.keys(clientToServerContract.methods)) {
292
+ const contractFn = clientToServerContract.methods[methodName] as GGContractMethod
293
+ const hasResponse = contractFn.success !== undefined
294
+ outgoingImpl[methodName] = async (data: any): Promise<any> => {
295
+ if (!socket) {
296
+ throw new SERVER_ERROR({
297
+ displayMessage: "WebSocket client is not connected. Call connect() first.",
298
+ })
299
+ }
300
+ return socket.send(`${schemaName}.${methodName}`, data, hasResponse, timeout)
301
+ }
302
+ }
303
+ const outgoing = clientToServerContract.implement(outgoingImpl as any, {skipLocatorRegistration: true})
304
+
305
+ // --------------------------------------------------------------------
306
+ // Setup tools factory — fresh per connect attempt
307
+ // --------------------------------------------------------------------
308
+
309
+ const buildSetupTools = (s: GGSocket): GGWebSocketSetupTools<any, any> => ({
310
+ incoming: {
311
+ on(handlers: Record<string, any>) {
312
+ for (const methodName of Object.keys(handlers)) {
313
+ const userHandler = handlers[methodName]
314
+ if (!userHandler) continue
315
+ const contractFn = serverToClientContract.methods[methodName] as GGContractMethod
316
+ if (!contractFn) {
317
+ throw new Error(`Method "${methodName}" is not defined in serverToClient of "${schemaName}"`)
318
+ }
319
+ const wrapped = (data: any) => new GGPromise(
320
+ GGContractExecutor.call(contractFn, data, undefined, async (validated) => userHandler(validated))
321
+ )
322
+ s.registerHandler({path: `${schemaName}.${methodName}`, handler: wrapped})
323
+ }
324
+ },
325
+ },
326
+ outgoing,
327
+ })
328
+
329
+ // --------------------------------------------------------------------
330
+ // Connection flow
331
+ // --------------------------------------------------------------------
332
+
333
+ /**
334
+ * Open one socket. Runs setup, wires close/error handlers, starts heartbeat.
335
+ * Throws on any failure — caller (initial connect or reconnect loop) decides
336
+ * retry policy. Never leaves a socket dangling on error.
337
+ */
338
+ const openOnce = async (): Promise<void> => {
339
+ const domain = await resolveDomain()
340
+ const validatedQuery = validateQuery()
341
+ const merged = [...schemaMiddlewares, ...(config?.middlewares ?? [])]
342
+ const newSocket = await GGSocketPool.connect({
343
+ domain,
344
+ path: normalizedPath,
345
+ query: validatedQuery,
346
+ middlewares: merged,
347
+ })
348
+
349
+ // #1: If user called disconnect() while we were awaiting the handshake,
350
+ // close the freshly-opened socket immediately — don't leak it.
351
+ if (finallyClosed) {
352
+ newSocket.close()
353
+ return
354
+ }
355
+
356
+ socket = newSocket
357
+
358
+ // setupPhase guards the onClose listener: while setup is in flight, a drop
359
+ // (whether from setup's own catch closing the socket, or an external close)
360
+ // only fires onDisconnect. Reconnect scheduling / finalClose are the job of
361
+ // the catch chain in this openOnce call or its caller (scheduleReconnect).
362
+ // After setup completes, setupPhase is cleared and onClose becomes the
363
+ // authoritative reconnect dispatcher.
364
+ let setupPhase = true
365
+
366
+ newSocket.onClose(() => {
367
+ if (socket === newSocket) {
368
+ socket = undefined
369
+ }
370
+ fireOnDisconnect(finallyClosed ? "manual" : "drop")
371
+ if (finallyClosed) {
372
+ fireFinalClose("manual")
373
+ return
374
+ }
375
+ if (setupPhase) {
376
+ // Catch chain owns retry decisions for setup-phase failures.
377
+ return
378
+ }
379
+ if (!reconnectConfig) {
380
+ fireFinalClose("drop")
381
+ return
382
+ }
383
+ if (reconnectAttempt >= reconnectConfig.maxAttempts) {
384
+ fireFinalClose("retries-exhausted")
385
+ return
386
+ }
387
+ scheduleReconnect()
388
+ })
389
+ for (const cb of onErrorCallbacks) newSocket.onError(cb)
390
+
391
+ // Heartbeat (if configured and adapter supports it — browsers no-op).
392
+ if (reconnectConfig?.heartbeat) {
393
+ newSocket.startHeartbeat(reconnectConfig.heartbeat)
394
+ }
395
+
396
+ // #2: Run setup with explicit cleanup on throw so we never leak a socket.
397
+ if (savedSetup) {
398
+ try {
399
+ await savedSetup(buildSetupTools(newSocket))
400
+ } catch (setupErr) {
401
+ if (socket === newSocket) {
402
+ socket = undefined
403
+ }
404
+ try { newSocket.close() } catch (_) {}
405
+ throw setupErr
406
+ }
407
+ }
408
+
409
+ setupPhase = false
410
+ reconnectAttempt = 0
411
+ }
412
+
413
+ /**
414
+ * Schedule a retry per the reconnect config. Called from the onClose handler
415
+ * (socket dropped) — never from initial connect().
416
+ */
417
+ const scheduleReconnect = () => {
418
+ if (!reconnectConfig) return
419
+ const attempt = reconnectAttempt++
420
+ const delay = Math.min(
421
+ reconnectConfig.initialDelayMs * Math.pow(reconnectConfig.multiplier, attempt),
422
+ reconnectConfig.maxDelayMs,
423
+ )
424
+ reconnectTimer = setTimeout(async () => {
425
+ reconnectTimer = undefined
426
+ if (finallyClosed) return
427
+ try {
428
+ await openOnce()
429
+ } catch (err) {
430
+ const error = err as Error
431
+ for (const cb of onErrorCallbacks) {
432
+ try { cb(error) } catch (_) {}
433
+ }
434
+ // #4: terminal errors skip further retries.
435
+ if (!reconnectConfig.shouldRetry(error)) {
436
+ fireFinalClose("unrecoverable", error)
437
+ return
438
+ }
439
+ if (reconnectAttempt < reconnectConfig.maxAttempts) {
440
+ scheduleReconnect()
441
+ } else {
442
+ fireFinalClose("retries-exhausted", error)
443
+ }
444
+ }
445
+ }, delay)
446
+ }
447
+
448
+ const fireOnDisconnect = (reason: "manual" | "drop") => {
449
+ for (const cb of onDisconnectCallbacks) {
450
+ try { cb(reason) } catch (_) {}
451
+ }
452
+ }
453
+
454
+ const fireFinalClose = (reason: GGWebSocketCloseReason, error?: Error) => {
455
+ if (finalCloseFired) return
456
+ finalCloseFired = true
457
+ for (const cb of onCloseCallbacks) {
458
+ try { cb(reason, error) } catch (_) {}
459
+ }
460
+ }
461
+
462
+ // --------------------------------------------------------------------
463
+ // Public client surface
464
+ // --------------------------------------------------------------------
465
+
466
+ const client: GGWebSocketClient<any, any> = {
467
+ outgoing,
468
+
469
+ get isConnected(): boolean {
470
+ return socket !== undefined
471
+ },
472
+
473
+ async connect(setup?: GGWebSocketSetup<any, any>): Promise<void> {
474
+ if (finallyClosed) {
475
+ throw new SERVER_ERROR({
476
+ displayMessage: "WebSocket client has been closed and cannot be reconnected. Create a new client.",
477
+ })
478
+ }
479
+ if (socket) return
480
+ savedSetup = setup
481
+ await openOnce()
482
+ },
483
+
484
+ async disconnect(): Promise<void> {
485
+ finallyClosed = true
486
+ if (reconnectTimer) {
487
+ clearTimeout(reconnectTimer)
488
+ reconnectTimer = undefined
489
+ }
490
+ const s = socket
491
+ socket = undefined
492
+ if (s) {
493
+ await s.teardown()
494
+ } else {
495
+ fireOnDisconnect("manual")
496
+ fireFinalClose("manual")
497
+ }
498
+ },
499
+
500
+ close(): void {
501
+ finallyClosed = true
502
+ if (reconnectTimer) {
503
+ clearTimeout(reconnectTimer)
504
+ reconnectTimer = undefined
505
+ }
506
+ const s = socket
507
+ socket = undefined
508
+ if (s) {
509
+ s.close()
510
+ } else {
511
+ fireOnDisconnect("manual")
512
+ fireFinalClose("manual")
513
+ }
514
+ },
515
+
516
+ onClose(cb: (reason: GGWebSocketCloseReason, error?: Error) => void): any {
517
+ onCloseCallbacks.push(cb)
518
+ return this
519
+ },
520
+
521
+ onDisconnect(cb: (reason: "manual" | "drop") => void): any {
522
+ onDisconnectCallbacks.push(cb)
523
+ return this
524
+ },
525
+
526
+ onError(cb: (error: Error) => void): any {
527
+ onErrorCallbacks.push(cb)
528
+ if (socket) socket.onError(cb)
529
+ return this
530
+ },
531
+ }
532
+
533
+ return client
534
+ }
@@ -14,5 +14,8 @@ export * from "./schema/GGWebSocketSchema";
14
14
  export * from "./schema/GGWebSocketMiddleware";
15
15
 
16
16
  // Client
17
- export * from "./client/GGSocketClient";
18
- export * from "./client/GGSocketPool";
17
+ export * from "./client/GGSocketPool";
18
+ export * from "./client/GGWebSocketSchema.createClient";
19
+
20
+ // Extensions
21
+ import "./client/GGWebSocketSchema.createClient";
package/src/index-node.ts CHANGED
@@ -21,8 +21,9 @@ export * from "./server/GGSocketServer";
21
21
  export * from "./server/GGWebSocketSchema.startServer";
22
22
 
23
23
  // Client
24
- export * from "./client/GGSocketClient";
25
24
  export * from "./client/GGSocketPool";
25
+ export * from "./client/GGWebSocketSchema.createClient";
26
26
 
27
27
  // Extensions
28
28
  import "./server/GGWebSocketSchema.startServer";
29
+ import "./client/GGWebSocketSchema.createClient";
@@ -1,3 +1,5 @@
1
+ import type {GGSchema} from "@grest-ts/schema";
2
+
1
3
  /**
2
4
  * WebSocket-specific middleware interface.
3
5
  * Independent from @grest-ts/http - WebSocket has its own middleware system.
@@ -22,6 +24,18 @@ export interface GGWebSocketHandshakeContext {
22
24
  * Unlike HTTP middleware, WebSocket middleware works with handshake data.
23
25
  */
24
26
  export interface GGWebSocketMiddleware {
27
+ /**
28
+ * Handshake headers this middleware reads or writes, mapped to their value schemas.
29
+ * Keys are header names; values describe the header value format for docs and OpenAPI/AsyncAPI.
30
+ * Use {} if the middleware touches no custom handshake headers.
31
+ *
32
+ * @example
33
+ * headers: {
34
+ * "authorization": IsBearerToken.docs({description: "JWT access token"})
35
+ * }
36
+ */
37
+ readonly headers?: Record<string, GGSchema<string | undefined>>;
38
+
25
39
  /**
26
40
  * Client-side: Add headers to the handshake message before sending.
27
41
  * Called by GGSocketPool when establishing a connection.
@@ -1,5 +1,5 @@
1
1
  import {GGWebSocketMiddleware} from "./GGWebSocketMiddleware";
2
- import {GGContractApiDefinition, GGContractClass} from "@grest-ts/schema";
2
+ import {GGContractApiDefinition, GGContractClass, GGValidator} from "@grest-ts/schema";
3
3
 
4
4
  /**
5
5
  * WebSocket API Schema - pure data definition with typed context
@@ -16,11 +16,13 @@ export class GGWebSocketSchema<
16
16
  TServerToClient,
17
17
  TContext = {},
18
18
  TQuery = undefined,
19
- TClientToServerImpl = TClientToServer
19
+ TClientToServerImpl = TClientToServer,
20
+ TServerToClientImpl = TServerToClient
20
21
  > {
21
22
  public readonly name: string
22
23
  public readonly path: string
23
24
  public readonly middlewares: readonly GGWebSocketMiddleware[]
25
+ public readonly queryValidator?: GGValidator<TQuery>
24
26
  private readonly contractFactory: () => GGWebSocketContractRuntime
25
27
  private contractCache: GGWebSocketContractRuntime | null = null
26
28
 
@@ -28,11 +30,13 @@ export class GGWebSocketSchema<
28
30
  name: string,
29
31
  path: string,
30
32
  contractFactory: () => GGWebSocketContractRuntime,
31
- middlewares: readonly GGWebSocketMiddleware[] = []
33
+ middlewares: readonly GGWebSocketMiddleware[] = [],
34
+ queryValidator?: GGValidator<TQuery>
32
35
  ) {
33
36
  this.name = name
34
37
  this.path = path
35
38
  this.middlewares = middlewares
39
+ this.queryValidator = queryValidator
36
40
  this.contractFactory = contractFactory
37
41
  Object.freeze(this.middlewares)
38
42
  }
@@ -1,6 +1,6 @@
1
1
  import {GGWebSocketSchema, GGWebSocketContractRuntime} from "./GGWebSocketSchema";
2
2
  import {GGWebSocketMiddleware} from "./GGWebSocketMiddleware";
3
- import {GGContractClass, GGContractClient, GGContractImplementation, GGContractMethod} from "@grest-ts/schema";
3
+ import {GGContractClass, GGContractClient, GGContractImplementation, GGContractMethod, GGValidator} from "@grest-ts/schema";
4
4
 
5
5
  /**
6
6
  * Bidirectional websocket contract methods
@@ -53,7 +53,8 @@ export function webSocketSchema<TDef extends GGSocketContractMethods>(
53
53
  GGContractClient<TDef["serverToClient"]>,
54
54
  undefined,
55
55
  undefined,
56
- GGContractImplementation<TDef["clientToServer"]>
56
+ GGContractImplementation<TDef["clientToServer"]>,
57
+ GGContractImplementation<TDef["serverToClient"]>
57
58
  > {
58
59
  return new GGWebSocketSchemaBuilder(contract)
59
60
  }
@@ -63,10 +64,12 @@ class GGWebSocketSchemaBuilder<
63
64
  TServerToClient,
64
65
  TContext = undefined,
65
66
  TQuery = undefined,
66
- TClientToServerImpl = TClientToServer
67
+ TClientToServerImpl = TClientToServer,
68
+ TServerToClientImpl = TServerToClient
67
69
  > {
68
70
  private _path: string = ""
69
71
  private _middlewares: GGWebSocketMiddleware[] = []
72
+ private _queryValidator?: GGValidator<any>
70
73
 
71
74
  constructor(
72
75
  private readonly _contract: GGSocketContract
@@ -78,16 +81,22 @@ class GGWebSocketSchemaBuilder<
78
81
  return this
79
82
  }
80
83
 
81
- use<M extends GGWebSocketMiddleware>(middleware: M): GGWebSocketSchemaBuilder<TClientToServer, TServerToClient, TContext | M, TQuery, TClientToServerImpl> {
84
+ use<M extends GGWebSocketMiddleware>(middleware: M): GGWebSocketSchemaBuilder<TClientToServer, TServerToClient, TContext | M, TQuery, TClientToServerImpl, TServerToClientImpl> {
82
85
  this._middlewares.push(middleware)
83
86
  return this as any
84
87
  }
85
88
 
86
- queryOnConnect<TNewQuery>(): GGWebSocketSchemaBuilder<TClientToServer, TServerToClient, TContext, TNewQuery, TClientToServerImpl> {
89
+ /**
90
+ * Declare the query-parameter shape and validator for connections.
91
+ * The validator runs on the server (connections with invalid query are rejected
92
+ * before handshake) and on the client (invalid query throws before connecting).
93
+ */
94
+ queryOnConnect<TNewQuery>(validator: GGValidator<TNewQuery>): GGWebSocketSchemaBuilder<TClientToServer, TServerToClient, TContext, TNewQuery, TClientToServerImpl, TServerToClientImpl> {
95
+ this._queryValidator = validator
87
96
  return this as any
88
97
  }
89
98
 
90
- done(): GGWebSocketSchema<TClientToServer, TServerToClient, TContext, TQuery, TClientToServerImpl> {
99
+ done(): GGWebSocketSchema<TClientToServer, TServerToClient, TContext, TQuery, TClientToServerImpl, TServerToClientImpl> {
91
100
  const contract = this._contract;
92
101
  const contractFactory = (): GGWebSocketContractRuntime => {
93
102
  const methods = contract.methods;
@@ -99,11 +108,12 @@ class GGWebSocketSchemaBuilder<
99
108
  };
100
109
  };
101
110
 
102
- return new GGWebSocketSchema<TClientToServer, TServerToClient, TContext, TQuery, TClientToServerImpl>(
111
+ return new GGWebSocketSchema<TClientToServer, TServerToClient, TContext, TQuery, TClientToServerImpl, TServerToClientImpl>(
103
112
  contract.name,
104
113
  this._path,
105
114
  contractFactory,
106
- this._middlewares
115
+ this._middlewares,
116
+ this._queryValidator
107
117
  )
108
118
  }
109
119
  }