@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.
- package/README.md +150 -40
- package/dist/src/adapter/NodeSocketAdapter.d.ts +2 -0
- package/dist/src/adapter/NodeSocketAdapter.d.ts.map +1 -1
- package/dist/src/adapter/NodeSocketAdapter.js +6 -0
- package/dist/src/adapter/NodeSocketAdapter.js.map +1 -1
- package/dist/src/client/GGSocketPool.d.ts +21 -0
- package/dist/src/client/GGSocketPool.d.ts.map +1 -1
- package/dist/src/client/GGSocketPool.js +82 -63
- package/dist/src/client/GGSocketPool.js.map +1 -1
- package/dist/src/client/GGWebSocketSchema.createClient.d.ts +154 -0
- package/dist/src/client/GGWebSocketSchema.createClient.d.ts.map +1 -0
- package/dist/src/client/GGWebSocketSchema.createClient.js +345 -0
- package/dist/src/client/GGWebSocketSchema.createClient.js.map +1 -0
- package/dist/src/index-browser.d.ts +2 -1
- package/dist/src/index-browser.d.ts.map +1 -1
- package/dist/src/index-browser.js +3 -1
- package/dist/src/index-browser.js.map +1 -1
- package/dist/src/index-node.d.ts +2 -1
- package/dist/src/index-node.d.ts.map +1 -1
- package/dist/src/index-node.js +2 -1
- package/dist/src/index-node.js.map +1 -1
- package/dist/src/schema/GGWebSocketMiddleware.d.ts +12 -0
- package/dist/src/schema/GGWebSocketMiddleware.d.ts.map +1 -1
- package/dist/src/schema/GGWebSocketMiddleware.js +0 -4
- package/dist/src/schema/GGWebSocketMiddleware.js.map +1 -1
- package/dist/src/schema/GGWebSocketSchema.d.ts +4 -3
- package/dist/src/schema/GGWebSocketSchema.d.ts.map +1 -1
- package/dist/src/schema/GGWebSocketSchema.js +3 -1
- package/dist/src/schema/GGWebSocketSchema.js.map +1 -1
- package/dist/src/schema/webSocketSchema.d.ts +12 -6
- package/dist/src/schema/webSocketSchema.d.ts.map +1 -1
- package/dist/src/schema/webSocketSchema.js +9 -2
- package/dist/src/schema/webSocketSchema.js.map +1 -1
- package/dist/src/server/GGSocketServer.d.ts.map +1 -1
- package/dist/src/server/GGSocketServer.js +36 -2
- package/dist/src/server/GGSocketServer.js.map +1 -1
- package/dist/src/server/GGWebSocketSchema.startServer.d.ts +5 -3
- package/dist/src/server/GGWebSocketSchema.startServer.d.ts.map +1 -1
- package/dist/src/server/GGWebSocketSchema.startServer.js +7 -5
- package/dist/src/server/GGWebSocketSchema.startServer.js.map +1 -1
- package/dist/src/socket/GGSocket.d.ts +13 -1
- package/dist/src/socket/GGSocket.d.ts.map +1 -1
- package/dist/src/socket/GGSocket.js +52 -2
- package/dist/src/socket/GGSocket.js.map +1 -1
- package/dist/src/socket/SocketAdapter.d.ts +11 -0
- package/dist/src/socket/SocketAdapter.d.ts.map +1 -1
- package/dist/testkit/client/GGWebSocketSchema.callOn.d.ts +1 -1
- package/dist/testkit/client/GGWebSocketSchema.callOn.d.ts.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +11 -11
- package/src/adapter/NodeSocketAdapter.ts +8 -0
- package/src/client/GGSocketPool.ts +90 -73
- package/src/client/GGWebSocketSchema.createClient.ts +534 -0
- package/src/index-browser.ts +5 -2
- package/src/index-node.ts +2 -1
- package/src/schema/GGWebSocketMiddleware.ts +14 -0
- package/src/schema/GGWebSocketSchema.ts +7 -3
- package/src/schema/webSocketSchema.ts +18 -8
- package/src/server/GGSocketServer.ts +51 -2
- package/src/server/GGWebSocketSchema.startServer.ts +14 -10
- package/src/socket/GGSocket.ts +56 -2
- package/src/socket/SocketAdapter.ts +13 -0
- package/dist/src/client/GGSocketClient.d.ts +0 -10
- package/dist/src/client/GGSocketClient.d.ts.map +0 -1
- package/dist/src/client/GGSocketClient.js +0 -17
- package/dist/src/client/GGSocketClient.js.map +0 -1
- 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
|
+
}
|
package/src/index-browser.ts
CHANGED
|
@@ -14,5 +14,8 @@ export * from "./schema/GGWebSocketSchema";
|
|
|
14
14
|
export * from "./schema/GGWebSocketMiddleware";
|
|
15
15
|
|
|
16
16
|
// Client
|
|
17
|
-
export * from "./client/
|
|
18
|
-
export * from "./client/
|
|
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
|
-
|
|
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
|
}
|