@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.
@@ -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
+ }