@kyneta/websocket-transport 1.1.0
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/README.md +255 -0
- package/dist/bun.d.ts +91 -0
- package/dist/bun.js +48 -0
- package/dist/bun.js.map +1 -0
- package/dist/chunk-5FHT54WT.js +109 -0
- package/dist/chunk-5FHT54WT.js.map +1 -0
- package/dist/client.d.ts +185 -0
- package/dist/client.js +418 -0
- package/dist/client.js.map +1 -0
- package/dist/server.d.ts +161 -0
- package/dist/server.js +315 -0
- package/dist/server.js.map +1 -0
- package/dist/types-DG_89zA4.d.ts +149 -0
- package/package.json +54 -0
- package/src/__tests__/client-state-machine.test.ts +472 -0
- package/src/bun-websocket.ts +163 -0
- package/src/bun.ts +24 -0
- package/src/client-state-machine.ts +78 -0
- package/src/client-transport.ts +711 -0
- package/src/client.ts +39 -0
- package/src/connection.ts +224 -0
- package/src/server-transport.ts +282 -0
- package/src/server.ts +39 -0
- package/src/types.ts +308 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// client-adapter — Websocket client adapter for @kyneta/exchange.
|
|
2
|
+
//
|
|
3
|
+
// Connects to a Websocket server and handles bidirectional communication
|
|
4
|
+
// using the kyneta wire format (CBOR codec + framing + fragmentation).
|
|
5
|
+
//
|
|
6
|
+
// Features:
|
|
7
|
+
// - State machine with validated transitions (disconnected → connecting → connected → ready)
|
|
8
|
+
// - Exponential backoff reconnection with jitter
|
|
9
|
+
// - Keepalive ping/pong (text frames, default 30s)
|
|
10
|
+
// - Transport-level fragmentation for large payloads
|
|
11
|
+
// - Observable connection state via subscribeToTransitions()
|
|
12
|
+
//
|
|
13
|
+
// The connection handshake:
|
|
14
|
+
// 1. Client creates Websocket, waits for open
|
|
15
|
+
// 2. Server sends text "ready" signal
|
|
16
|
+
// 3. Client creates channel + calls establishChannel()
|
|
17
|
+
// 4. Synchronizer exchanges establish-request / establish-response
|
|
18
|
+
//
|
|
19
|
+
// Ported from @loro-extended/adapter-websocket's WsClientNetworkAdapter
|
|
20
|
+
// with kyneta naming conventions and the kyneta 5-message protocol.
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
Channel,
|
|
24
|
+
ChannelMsg,
|
|
25
|
+
GeneratedChannel,
|
|
26
|
+
PeerId,
|
|
27
|
+
TransportFactory,
|
|
28
|
+
} from "@kyneta/exchange"
|
|
29
|
+
import { Transport } from "@kyneta/exchange"
|
|
30
|
+
import {
|
|
31
|
+
cborCodec,
|
|
32
|
+
decodeBinaryFrame,
|
|
33
|
+
encodeComplete,
|
|
34
|
+
FragmentReassembler,
|
|
35
|
+
fragmentPayload,
|
|
36
|
+
wrapCompleteMessage,
|
|
37
|
+
} from "@kyneta/wire"
|
|
38
|
+
import { WebsocketClientStateMachine } from "./client-state-machine.js"
|
|
39
|
+
import type {
|
|
40
|
+
DisconnectReason,
|
|
41
|
+
TransitionListener,
|
|
42
|
+
WebsocketClientState,
|
|
43
|
+
WebsocketClientStateTransition,
|
|
44
|
+
} from "./types.js"
|
|
45
|
+
|
|
46
|
+
// Re-export state types for convenience
|
|
47
|
+
export type {
|
|
48
|
+
DisconnectReason,
|
|
49
|
+
WebsocketClientState,
|
|
50
|
+
WebsocketClientStateTransition,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Options
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Default fragment threshold in bytes.
|
|
59
|
+
* AWS API Gateway has a 128KB limit, so 100KB provides a safe margin.
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Options for the Websocket client adapter (browser connections).
|
|
65
|
+
*/
|
|
66
|
+
export interface WebsocketClientOptions {
|
|
67
|
+
/** Websocket URL to connect to. Can be a string or a function of peerId. */
|
|
68
|
+
url: string | ((peerId: PeerId) => string)
|
|
69
|
+
|
|
70
|
+
/** Optional custom WebSocket implementation (for Node.js or testing). */
|
|
71
|
+
WebSocket?: typeof globalThis.WebSocket
|
|
72
|
+
|
|
73
|
+
/** Reconnection options. */
|
|
74
|
+
reconnect?: {
|
|
75
|
+
enabled: boolean
|
|
76
|
+
maxAttempts?: number
|
|
77
|
+
baseDelay?: number
|
|
78
|
+
maxDelay?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Keepalive interval in ms (default: 30000). */
|
|
82
|
+
keepaliveInterval?: number
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fragment threshold in bytes. Messages larger than this are fragmented.
|
|
86
|
+
* Set to 0 to disable fragmentation (not recommended for cloud deployments).
|
|
87
|
+
* Default: 100KB
|
|
88
|
+
*/
|
|
89
|
+
fragmentThreshold?: number
|
|
90
|
+
|
|
91
|
+
/** Lifecycle event callbacks. */
|
|
92
|
+
lifecycle?: WebsocketClientLifecycleEvents
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Lifecycle event callbacks for the Websocket client.
|
|
97
|
+
*/
|
|
98
|
+
export interface WebsocketClientLifecycleEvents {
|
|
99
|
+
/** Called on every state transition (delivered async via microtask). */
|
|
100
|
+
onStateChange?: (transition: WebsocketClientStateTransition) => void
|
|
101
|
+
|
|
102
|
+
/** Called when the connection is lost. */
|
|
103
|
+
onDisconnect?: (reason: DisconnectReason) => void
|
|
104
|
+
|
|
105
|
+
/** Called when a reconnection attempt is scheduled. */
|
|
106
|
+
onReconnecting?: (attempt: number, nextAttemptMs: number) => void
|
|
107
|
+
|
|
108
|
+
/** Called when reconnection succeeds after a previous connection. */
|
|
109
|
+
onReconnected?: () => void
|
|
110
|
+
|
|
111
|
+
/** Called when the server sends the "ready" signal. */
|
|
112
|
+
onReady?: () => void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Options for service-to-service Websocket connections.
|
|
117
|
+
* Extends WebsocketClientOptions with header support for authentication.
|
|
118
|
+
*
|
|
119
|
+
* Note: Headers are a Bun/Node-specific extension. The browser WebSocket API
|
|
120
|
+
* does not support custom headers per the WHATWG spec.
|
|
121
|
+
*/
|
|
122
|
+
export interface ServiceWebsocketClientOptions extends WebsocketClientOptions {
|
|
123
|
+
/**
|
|
124
|
+
* Headers to send during Websocket upgrade.
|
|
125
|
+
* Used for authentication in service-to-service communication.
|
|
126
|
+
*/
|
|
127
|
+
headers?: Record<string, string>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Default reconnection options.
|
|
132
|
+
*/
|
|
133
|
+
const DEFAULT_RECONNECT = {
|
|
134
|
+
enabled: true,
|
|
135
|
+
maxAttempts: 10,
|
|
136
|
+
baseDelay: 1000,
|
|
137
|
+
maxDelay: 30000,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// WebsocketClientTransport
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Websocket client network adapter for @kyneta/exchange.
|
|
146
|
+
*
|
|
147
|
+
* Connects to a Websocket server, sends and receives ChannelMsg via
|
|
148
|
+
* the kyneta wire format (CBOR codec + framing + fragmentation).
|
|
149
|
+
*
|
|
150
|
+
* Prefer the factory functions for construction:
|
|
151
|
+
* - `createWebsocketClient()` — browser-to-server
|
|
152
|
+
* - `createServiceWebsocketClient()` — service-to-service (with headers)
|
|
153
|
+
*/
|
|
154
|
+
export class WebsocketClientTransport extends Transport<void> {
|
|
155
|
+
#peerId?: PeerId
|
|
156
|
+
#socket?: WebSocket
|
|
157
|
+
#serverChannel?: Channel
|
|
158
|
+
#keepaliveTimer?: ReturnType<typeof setInterval>
|
|
159
|
+
#reconnectTimer?: ReturnType<typeof setTimeout>
|
|
160
|
+
#options: ServiceWebsocketClientOptions
|
|
161
|
+
#WebSocketImpl: typeof globalThis.WebSocket
|
|
162
|
+
#shouldReconnect = true
|
|
163
|
+
#wasConnectedBefore = false
|
|
164
|
+
|
|
165
|
+
// State machine
|
|
166
|
+
readonly #stateMachine = new WebsocketClientStateMachine()
|
|
167
|
+
|
|
168
|
+
// Fragmentation
|
|
169
|
+
readonly #fragmentThreshold: number
|
|
170
|
+
readonly #reassembler: FragmentReassembler
|
|
171
|
+
|
|
172
|
+
constructor(options: ServiceWebsocketClientOptions) {
|
|
173
|
+
super({ transportType: "websocket-client" })
|
|
174
|
+
this.#options = options
|
|
175
|
+
this.#WebSocketImpl = options.WebSocket ?? globalThis.WebSocket
|
|
176
|
+
this.#fragmentThreshold =
|
|
177
|
+
options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
|
|
178
|
+
this.#reassembler = new FragmentReassembler({
|
|
179
|
+
timeoutMs: 10_000,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Set up lifecycle event forwarding
|
|
183
|
+
this.#setupLifecycleEvents()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ==========================================================================
|
|
187
|
+
// Lifecycle event forwarding
|
|
188
|
+
// ==========================================================================
|
|
189
|
+
|
|
190
|
+
#setupLifecycleEvents(): void {
|
|
191
|
+
this.#stateMachine.subscribeToTransitions(transition => {
|
|
192
|
+
// Forward to onStateChange callback
|
|
193
|
+
this.#options.lifecycle?.onStateChange?.(transition)
|
|
194
|
+
|
|
195
|
+
const { from, to } = transition
|
|
196
|
+
|
|
197
|
+
// onDisconnect: transitioning TO disconnected
|
|
198
|
+
if (to.status === "disconnected" && to.reason) {
|
|
199
|
+
this.#options.lifecycle?.onDisconnect?.(to.reason)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// onReconnecting: transitioning TO reconnecting
|
|
203
|
+
if (to.status === "reconnecting") {
|
|
204
|
+
this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// onReconnected: from reconnecting/connecting TO connected/ready (after prior connection)
|
|
208
|
+
if (
|
|
209
|
+
this.#wasConnectedBefore &&
|
|
210
|
+
(from.status === "reconnecting" || from.status === "connecting") &&
|
|
211
|
+
(to.status === "connected" || to.status === "ready")
|
|
212
|
+
) {
|
|
213
|
+
this.#options.lifecycle?.onReconnected?.()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// onReady: transitioning TO ready
|
|
217
|
+
if (to.status === "ready") {
|
|
218
|
+
this.#options.lifecycle?.onReady?.()
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ==========================================================================
|
|
224
|
+
// State observation API
|
|
225
|
+
// ==========================================================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get the current state of the connection.
|
|
229
|
+
*/
|
|
230
|
+
getState(): WebsocketClientState {
|
|
231
|
+
return this.#stateMachine.getState()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Subscribe to state transitions.
|
|
236
|
+
* @returns Unsubscribe function
|
|
237
|
+
*/
|
|
238
|
+
subscribeToTransitions(listener: TransitionListener): () => void {
|
|
239
|
+
return this.#stateMachine.subscribeToTransitions(listener)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Wait for a specific state.
|
|
244
|
+
*/
|
|
245
|
+
waitForState(
|
|
246
|
+
predicate: (state: WebsocketClientState) => boolean,
|
|
247
|
+
options?: { timeoutMs?: number },
|
|
248
|
+
): Promise<WebsocketClientState> {
|
|
249
|
+
return this.#stateMachine.waitForState(predicate, options)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Wait for a specific status.
|
|
254
|
+
*/
|
|
255
|
+
waitForStatus(
|
|
256
|
+
status: WebsocketClientState["status"],
|
|
257
|
+
options?: { timeoutMs?: number },
|
|
258
|
+
): Promise<WebsocketClientState> {
|
|
259
|
+
return this.#stateMachine.waitForStatus(status, options)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Check if the client is ready (server ready signal received).
|
|
264
|
+
*/
|
|
265
|
+
get isReady(): boolean {
|
|
266
|
+
return this.#stateMachine.isReady()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ==========================================================================
|
|
270
|
+
// Adapter abstract method implementations
|
|
271
|
+
// ==========================================================================
|
|
272
|
+
|
|
273
|
+
protected generate(): GeneratedChannel {
|
|
274
|
+
return {
|
|
275
|
+
transportType: this.transportType,
|
|
276
|
+
send: (msg: ChannelMsg) => {
|
|
277
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const frame = encodeComplete(cborCodec, msg)
|
|
282
|
+
|
|
283
|
+
// Fragment large payloads for cloud infrastructure compatibility
|
|
284
|
+
if (
|
|
285
|
+
this.#fragmentThreshold > 0 &&
|
|
286
|
+
frame.length > this.#fragmentThreshold
|
|
287
|
+
) {
|
|
288
|
+
const fragments = fragmentPayload(frame, this.#fragmentThreshold)
|
|
289
|
+
for (const fragment of fragments) {
|
|
290
|
+
this.#socket.send(fragment)
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
// Wrap with MESSAGE_COMPLETE prefix for transport layer consistency
|
|
294
|
+
this.#socket.send(wrapCompleteMessage(frame))
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
stop: () => {
|
|
298
|
+
// Don't call disconnect() here — channel.stop() is called when
|
|
299
|
+
// the channel is removed, which can happen during handleClose().
|
|
300
|
+
// The actual disconnect is handled by onStop() or handleClose().
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async onStart(): Promise<void> {
|
|
306
|
+
if (!this.identity) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
"Adapter not properly initialized — identity not available",
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
this.#peerId = this.identity.peerId
|
|
312
|
+
this.#shouldReconnect = true
|
|
313
|
+
this.#wasConnectedBefore = false
|
|
314
|
+
await this.#connect()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async onStop(): Promise<void> {
|
|
318
|
+
this.#shouldReconnect = false
|
|
319
|
+
this.#reassembler.dispose()
|
|
320
|
+
this.#disconnect({ type: "intentional" })
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ==========================================================================
|
|
324
|
+
// Connection management
|
|
325
|
+
// ==========================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Connect to the Websocket server.
|
|
329
|
+
*/
|
|
330
|
+
async #connect(): Promise<void> {
|
|
331
|
+
const currentState = this.#stateMachine.getState()
|
|
332
|
+
if (currentState.status === "connecting") {
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!this.#peerId) {
|
|
337
|
+
throw new Error("Cannot connect: peerId not set")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Determine attempt number
|
|
341
|
+
const attempt =
|
|
342
|
+
currentState.status === "reconnecting" ? currentState.attempt : 1
|
|
343
|
+
|
|
344
|
+
this.#stateMachine.transition({ status: "connecting", attempt })
|
|
345
|
+
|
|
346
|
+
// Resolve URL
|
|
347
|
+
const url =
|
|
348
|
+
typeof this.#options.url === "function"
|
|
349
|
+
? this.#options.url(this.#peerId)
|
|
350
|
+
: this.#options.url
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
// Create WebSocket with optional headers (Bun-specific extension)
|
|
354
|
+
if (
|
|
355
|
+
this.#options.headers &&
|
|
356
|
+
Object.keys(this.#options.headers).length > 0
|
|
357
|
+
) {
|
|
358
|
+
// Bun extends the standard WebSocket API with a non-standard constructor
|
|
359
|
+
type BunWebSocketConstructor = new (
|
|
360
|
+
url: string,
|
|
361
|
+
options: { headers: Record<string, string> },
|
|
362
|
+
) => WebSocket
|
|
363
|
+
const BunWebSocket = this
|
|
364
|
+
.#WebSocketImpl as unknown as BunWebSocketConstructor
|
|
365
|
+
this.#socket = new BunWebSocket(url, {
|
|
366
|
+
headers: this.#options.headers,
|
|
367
|
+
})
|
|
368
|
+
} else {
|
|
369
|
+
this.#socket = new this.#WebSocketImpl(url)
|
|
370
|
+
}
|
|
371
|
+
this.#socket.binaryType = "arraybuffer"
|
|
372
|
+
|
|
373
|
+
// IMPORTANT: Set up message handler IMMEDIATELY after creating the socket.
|
|
374
|
+
// This must happen BEFORE waiting for the open event to avoid a race
|
|
375
|
+
// condition where the server sends "ready" before the handler is attached.
|
|
376
|
+
this.#socket.addEventListener("message", event => {
|
|
377
|
+
this.#handleMessage(event)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
await new Promise<void>((resolve, reject) => {
|
|
381
|
+
if (!this.#socket) {
|
|
382
|
+
reject(new Error("Socket not created"))
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const onOpen = () => {
|
|
387
|
+
cleanup()
|
|
388
|
+
resolve()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const onError = (event: Event) => {
|
|
392
|
+
cleanup()
|
|
393
|
+
reject(new Error(`WebSocket connection failed: ${event}`))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const onClose = () => {
|
|
397
|
+
cleanup()
|
|
398
|
+
reject(new Error("WebSocket closed during connection"))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const cleanup = () => {
|
|
402
|
+
this.#socket?.removeEventListener("open", onOpen)
|
|
403
|
+
this.#socket?.removeEventListener("error", onError)
|
|
404
|
+
this.#socket?.removeEventListener("close", onClose)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.#socket.addEventListener("open", onOpen)
|
|
408
|
+
this.#socket.addEventListener("error", onError)
|
|
409
|
+
this.#socket.addEventListener("close", onClose)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Socket is now open — transition to connected
|
|
413
|
+
this.#stateMachine.transition({ status: "connected" })
|
|
414
|
+
|
|
415
|
+
// Set up close handler for disconnections after connection is established
|
|
416
|
+
this.#socket.addEventListener("close", event => {
|
|
417
|
+
this.#handleClose(event.code, event.reason)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// Start keepalive
|
|
421
|
+
this.#startKeepalive()
|
|
422
|
+
|
|
423
|
+
// Note: Channel creation is deferred until we receive the "ready" signal
|
|
424
|
+
// from the server. This ensures the server is fully set up before we
|
|
425
|
+
// start sending messages.
|
|
426
|
+
} catch (error) {
|
|
427
|
+
// Transition to reconnecting or disconnected
|
|
428
|
+
this.#scheduleReconnect({
|
|
429
|
+
type: "error",
|
|
430
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Disconnect from the Websocket server.
|
|
437
|
+
*/
|
|
438
|
+
#disconnect(reason: DisconnectReason): void {
|
|
439
|
+
this.#stopKeepalive()
|
|
440
|
+
this.#clearReconnectTimer()
|
|
441
|
+
|
|
442
|
+
if (this.#socket) {
|
|
443
|
+
this.#socket.close(1000, "Client disconnecting")
|
|
444
|
+
this.#socket = undefined
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (this.#serverChannel) {
|
|
448
|
+
this.removeChannel(this.#serverChannel.channelId)
|
|
449
|
+
this.#serverChannel = undefined
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Only transition if not already disconnected
|
|
453
|
+
const currentState = this.#stateMachine.getState()
|
|
454
|
+
if (currentState.status !== "disconnected") {
|
|
455
|
+
this.#stateMachine.transition({ status: "disconnected", reason })
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ==========================================================================
|
|
460
|
+
// Message handling
|
|
461
|
+
// ==========================================================================
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Handle incoming Websocket messages.
|
|
465
|
+
*/
|
|
466
|
+
#handleMessage(event: MessageEvent): void {
|
|
467
|
+
const data = event.data
|
|
468
|
+
|
|
469
|
+
// Handle text messages (keepalive and ready signal)
|
|
470
|
+
if (typeof data === "string") {
|
|
471
|
+
if (data === "ready") {
|
|
472
|
+
this.#handleServerReady()
|
|
473
|
+
}
|
|
474
|
+
// Ignore pong responses
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Handle binary messages through reassembler
|
|
479
|
+
if (data instanceof ArrayBuffer) {
|
|
480
|
+
const result = this.#reassembler.receiveRaw(new Uint8Array(data))
|
|
481
|
+
|
|
482
|
+
if (result.status === "complete") {
|
|
483
|
+
try {
|
|
484
|
+
const frame = decodeBinaryFrame(result.data)
|
|
485
|
+
const messages = cborCodec.decode(frame.content.payload)
|
|
486
|
+
for (const msg of messages) {
|
|
487
|
+
this.#handleChannelMessage(msg)
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.error("Failed to decode message:", error)
|
|
491
|
+
}
|
|
492
|
+
} else if (result.status === "error") {
|
|
493
|
+
console.error("Fragment reassembly error:", result.error)
|
|
494
|
+
}
|
|
495
|
+
// "pending" status means we're waiting for more fragments — nothing to do
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Handle the "ready" signal from the server.
|
|
501
|
+
*
|
|
502
|
+
* Creates the channel and starts the establishment handshake.
|
|
503
|
+
* The "ready" signal is a transport-level indicator that the server's
|
|
504
|
+
* Websocket handler is ready. After receiving it, we create our channel
|
|
505
|
+
* and send a real establish-request.
|
|
506
|
+
*/
|
|
507
|
+
#handleServerReady(): void {
|
|
508
|
+
const currentState = this.#stateMachine.getState()
|
|
509
|
+
if (currentState.status === "ready") {
|
|
510
|
+
// Already received ready signal, ignore duplicate
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Handle race condition: if we receive "ready" while still in "connecting" state,
|
|
515
|
+
// the server sent the ready signal before our open promise resolved.
|
|
516
|
+
// Transition through "connected" first to maintain valid state machine transitions.
|
|
517
|
+
if (currentState.status === "connecting") {
|
|
518
|
+
this.#stateMachine.transition({ status: "connected" })
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Transition to ready state
|
|
522
|
+
this.#stateMachine.transition({ status: "ready" })
|
|
523
|
+
this.#wasConnectedBefore = true
|
|
524
|
+
|
|
525
|
+
// Create channel if not exists
|
|
526
|
+
if (this.#serverChannel) {
|
|
527
|
+
this.removeChannel(this.#serverChannel.channelId)
|
|
528
|
+
this.#serverChannel = undefined
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
this.#serverChannel = this.addChannel()
|
|
532
|
+
|
|
533
|
+
// Send real establish-request over the wire
|
|
534
|
+
// The server will respond with establish-response containing its actual identity
|
|
535
|
+
this.establishChannel(this.#serverChannel.channelId)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Handle a decoded channel message.
|
|
540
|
+
*/
|
|
541
|
+
#handleChannelMessage(msg: ChannelMsg): void {
|
|
542
|
+
if (!this.#serverChannel) {
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Deliver synchronously — the Synchronizer's receive queue prevents recursion
|
|
547
|
+
this.#serverChannel.onReceive(msg)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Handle Websocket close.
|
|
552
|
+
*/
|
|
553
|
+
#handleClose(code: number, reason: string): void {
|
|
554
|
+
this.#stopKeepalive()
|
|
555
|
+
|
|
556
|
+
if (this.#serverChannel) {
|
|
557
|
+
this.removeChannel(this.#serverChannel.channelId)
|
|
558
|
+
this.#serverChannel = undefined
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Schedule reconnect or transition to disconnected
|
|
562
|
+
this.#scheduleReconnect({ type: "closed", code, reason })
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ==========================================================================
|
|
566
|
+
// Keepalive
|
|
567
|
+
// ==========================================================================
|
|
568
|
+
|
|
569
|
+
#startKeepalive(): void {
|
|
570
|
+
this.#stopKeepalive()
|
|
571
|
+
|
|
572
|
+
const interval = this.#options.keepaliveInterval ?? 30_000
|
|
573
|
+
|
|
574
|
+
this.#keepaliveTimer = setInterval(() => {
|
|
575
|
+
if (this.#socket?.readyState === WebSocket.OPEN) {
|
|
576
|
+
this.#socket.send("ping")
|
|
577
|
+
}
|
|
578
|
+
}, interval)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#stopKeepalive(): void {
|
|
582
|
+
if (this.#keepaliveTimer) {
|
|
583
|
+
clearInterval(this.#keepaliveTimer)
|
|
584
|
+
this.#keepaliveTimer = undefined
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ==========================================================================
|
|
589
|
+
// Reconnection
|
|
590
|
+
// ==========================================================================
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Schedule a reconnection attempt or transition to disconnected.
|
|
594
|
+
*/
|
|
595
|
+
#scheduleReconnect(reason: DisconnectReason): void {
|
|
596
|
+
const currentState = this.#stateMachine.getState()
|
|
597
|
+
|
|
598
|
+
// If already disconnected, don't transition again
|
|
599
|
+
if (currentState.status === "disconnected") {
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const reconnectOpts = {
|
|
604
|
+
...DEFAULT_RECONNECT,
|
|
605
|
+
...this.#options.reconnect,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (!this.#shouldReconnect || !reconnectOpts.enabled) {
|
|
609
|
+
this.#stateMachine.transition({ status: "disconnected", reason })
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Get current attempt count from state
|
|
614
|
+
const currentAttempt =
|
|
615
|
+
currentState.status === "reconnecting"
|
|
616
|
+
? currentState.attempt
|
|
617
|
+
: currentState.status === "connecting"
|
|
618
|
+
? (currentState as { attempt: number }).attempt
|
|
619
|
+
: 0
|
|
620
|
+
|
|
621
|
+
if (currentAttempt >= reconnectOpts.maxAttempts) {
|
|
622
|
+
this.#stateMachine.transition({
|
|
623
|
+
status: "disconnected",
|
|
624
|
+
reason: { type: "max-retries-exceeded", attempts: currentAttempt },
|
|
625
|
+
})
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const nextAttempt = currentAttempt + 1
|
|
630
|
+
|
|
631
|
+
// Exponential backoff with jitter
|
|
632
|
+
const delay = Math.min(
|
|
633
|
+
reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1000,
|
|
634
|
+
reconnectOpts.maxDelay,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
this.#stateMachine.transition({
|
|
638
|
+
status: "reconnecting",
|
|
639
|
+
attempt: nextAttempt,
|
|
640
|
+
nextAttemptMs: delay,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
644
|
+
this.#connect()
|
|
645
|
+
}, delay)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
#clearReconnectTimer(): void {
|
|
649
|
+
if (this.#reconnectTimer) {
|
|
650
|
+
clearTimeout(this.#reconnectTimer)
|
|
651
|
+
this.#reconnectTimer = undefined
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// Factory functions
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Create a Websocket client adapter factory for browser-to-server connections.
|
|
662
|
+
*
|
|
663
|
+
* Returns an `TransportFactory` — a closure that creates a fresh adapter
|
|
664
|
+
* instance when called. Pass directly to `Exchange({ transports: [...] })`.
|
|
665
|
+
*
|
|
666
|
+
* @example
|
|
667
|
+
* ```typescript
|
|
668
|
+
* import { createWebsocketClient } from "@kyneta/websocket-network-adapter/client"
|
|
669
|
+
*
|
|
670
|
+
* const exchange = new Exchange({
|
|
671
|
+
* transports: [createWebsocketClient({
|
|
672
|
+
* url: "ws://localhost:3000/ws",
|
|
673
|
+
* reconnect: { enabled: true },
|
|
674
|
+
* })],
|
|
675
|
+
* })
|
|
676
|
+
* ```
|
|
677
|
+
*/
|
|
678
|
+
export function createWebsocketClient(
|
|
679
|
+
options: WebsocketClientOptions,
|
|
680
|
+
): TransportFactory {
|
|
681
|
+
return () => new WebsocketClientTransport(options)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Create a Websocket client adapter for service-to-service connections.
|
|
686
|
+
*
|
|
687
|
+
* This factory is for backend environments (Bun, Node.js) where you need
|
|
688
|
+
* to pass authentication headers during the Websocket upgrade.
|
|
689
|
+
*
|
|
690
|
+
* Note: Headers are a Bun/Node-specific extension. The browser WebSocket API
|
|
691
|
+
* does not support custom headers. For browser clients, use
|
|
692
|
+
* `createWebsocketClient()` and authenticate via URL query parameters.
|
|
693
|
+
*
|
|
694
|
+
* @example
|
|
695
|
+
* ```typescript
|
|
696
|
+
* import { createServiceWebsocketClient } from "@kyneta/websocket-network-adapter/client"
|
|
697
|
+
*
|
|
698
|
+
* const exchange = new Exchange({
|
|
699
|
+
* transports: [createServiceWebsocketClient({
|
|
700
|
+
* url: "ws://primary-server:3000/ws",
|
|
701
|
+
* headers: { Authorization: "Bearer token" },
|
|
702
|
+
* reconnect: { enabled: true },
|
|
703
|
+
* })],
|
|
704
|
+
* })
|
|
705
|
+
* ```
|
|
706
|
+
*/
|
|
707
|
+
export function createServiceWebsocketClient(
|
|
708
|
+
options: ServiceWebsocketClientOptions,
|
|
709
|
+
): TransportFactory {
|
|
710
|
+
return () => new WebsocketClientTransport(options)
|
|
711
|
+
}
|