@kyneta/websocket-transport 1.1.0 → 1.2.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.
@@ -1,44 +1,38 @@
1
- // client-adapter — Websocket client adapter for @kyneta/exchange.
1
+ // client-transport — Websocket client transport for @kyneta/exchange.
2
2
  //
3
- // Connects to a Websocket server and handles bidirectional communication
4
- // using the kyneta wire format (CBOR codec + framing + fragmentation).
3
+ // Thin imperative shell around the pure client program (client-program.ts).
4
+ // The program produces data effects; this module interprets them as I/O.
5
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()
6
+ // FC/IS design:
7
+ // - client-program.ts: pure Mealy machine (functional core)
8
+ // - client-transport.ts: effect executor (imperative shell)
12
9
  //
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.
10
+ // Uses the kyneta wire format (CBOR codec + framing + fragmentation)
11
+ // for binary messages. Text frames carry the "ready" handshake and
12
+ // keepalive ping/pong.
21
13
 
14
+ import type { ObservableHandle, TransitionListener } from "@kyneta/machine"
15
+ import { createObservableProgram } from "@kyneta/machine"
22
16
  import type {
23
17
  Channel,
24
18
  ChannelMsg,
25
19
  GeneratedChannel,
26
20
  PeerId,
27
21
  TransportFactory,
28
- } from "@kyneta/exchange"
29
- import { Transport } from "@kyneta/exchange"
22
+ } from "@kyneta/transport"
23
+ import { Transport } from "@kyneta/transport"
30
24
  import {
31
- cborCodec,
32
- decodeBinaryFrame,
33
- encodeComplete,
25
+ decodeBinaryMessages,
26
+ encodeBinaryAndSend,
34
27
  FragmentReassembler,
35
- fragmentPayload,
36
- wrapCompleteMessage,
37
28
  } from "@kyneta/wire"
38
- import { WebsocketClientStateMachine } from "./client-state-machine.js"
29
+ import {
30
+ createWsClientProgram,
31
+ type WsClientEffect,
32
+ type WsClientMsg,
33
+ } from "./client-program.js"
39
34
  import type {
40
35
  DisconnectReason,
41
- TransitionListener,
42
36
  WebsocketClientState,
43
37
  WebsocketClientStateTransition,
44
38
  } from "./types.js"
@@ -61,7 +55,7 @@ export type {
61
55
  export const DEFAULT_FRAGMENT_THRESHOLD = 100 * 1024
62
56
 
63
57
  /**
64
- * Options for the Websocket client adapter (browser connections).
58
+ * Options for the Websocket client transport (browser connections).
65
59
  */
66
60
  export interface WebsocketClientOptions {
67
61
  /** Websocket URL to connect to. Can be a string or a function of peerId. */
@@ -72,7 +66,7 @@ export interface WebsocketClientOptions {
72
66
 
73
67
  /** Reconnection options. */
74
68
  reconnect?: {
75
- enabled: boolean
69
+ enabled?: boolean
76
70
  maxAttempts?: number
77
71
  baseDelay?: number
78
72
  maxDelay?: number
@@ -127,43 +121,37 @@ export interface ServiceWebsocketClientOptions extends WebsocketClientOptions {
127
121
  headers?: Record<string, string>
128
122
  }
129
123
 
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
124
  // ---------------------------------------------------------------------------
141
125
  // WebsocketClientTransport
142
126
  // ---------------------------------------------------------------------------
143
127
 
144
128
  /**
145
- * Websocket client network adapter for @kyneta/exchange.
129
+ * Websocket client network transport for @kyneta/exchange.
146
130
  *
147
131
  * Connects to a Websocket server, sends and receives ChannelMsg via
148
132
  * the kyneta wire format (CBOR codec + framing + fragmentation).
149
133
  *
134
+ * Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —
135
+ * a pure Mealy machine whose transitions are deterministically testable.
136
+ * This class is the imperative shell that interprets data effects as I/O.
137
+ *
150
138
  * Prefer the factory functions for construction:
151
139
  * - `createWebsocketClient()` — browser-to-server
152
140
  * - `createServiceWebsocketClient()` — service-to-service (with headers)
153
141
  */
154
142
  export class WebsocketClientTransport extends Transport<void> {
155
143
  #peerId?: PeerId
144
+ #options: ServiceWebsocketClientOptions
145
+ #WebSocketImpl: typeof globalThis.WebSocket
146
+
147
+ // Observable program handle — created in constructor, drives all state
148
+ #handle: ObservableHandle<WsClientMsg, WebsocketClientState>
149
+
150
+ // Executor-local I/O state — not in the program model
156
151
  #socket?: WebSocket
157
152
  #serverChannel?: Channel
158
153
  #keepaliveTimer?: ReturnType<typeof setInterval>
159
154
  #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
155
 
168
156
  // Fragmentation
169
157
  readonly #fragmentThreshold: number
@@ -179,174 +167,115 @@ export class WebsocketClientTransport extends Transport<void> {
179
167
  timeoutMs: 10_000,
180
168
  })
181
169
 
170
+ const program = createWsClientProgram({
171
+ reconnect: options.reconnect,
172
+ })
173
+
174
+ this.#handle = createObservableProgram(program, (effect, dispatch) => {
175
+ this.#executeEffect(effect, dispatch)
176
+ })
177
+
182
178
  // Set up lifecycle event forwarding
183
179
  this.#setupLifecycleEvents()
184
180
  }
185
181
 
186
182
  // ==========================================================================
187
- // Lifecycle event forwarding
183
+ // Effect executor — interprets data effects as I/O
188
184
  // ==========================================================================
189
185
 
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)
186
+ #executeEffect(
187
+ effect: WsClientEffect,
188
+ dispatch: (msg: WsClientMsg) => void,
189
+ ): void {
190
+ switch (effect.type) {
191
+ case "create-websocket": {
192
+ this.#doCreateWebsocket(dispatch)
193
+ break
205
194
  }
206
195
 
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?.()
196
+ case "close-websocket": {
197
+ if (this.#socket) {
198
+ this.#socket.close(1000, "Client disconnecting")
199
+ this.#socket = undefined
200
+ }
201
+ break
219
202
  }
220
- })
221
- }
222
203
 
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
- }
204
+ case "add-channel-and-establish": {
205
+ // Clean up previous channel if it exists (e.g. after reconnect)
206
+ if (this.#serverChannel) {
207
+ this.removeChannel(this.#serverChannel.channelId)
208
+ this.#serverChannel = undefined
209
+ }
261
210
 
262
- /**
263
- * Check if the client is ready (server ready signal received).
264
- */
265
- get isReady(): boolean {
266
- return this.#stateMachine.isReady()
267
- }
211
+ this.#serverChannel = this.addChannel()
268
212
 
269
- // ==========================================================================
270
- // Adapter abstract method implementations
271
- // ==========================================================================
213
+ // Establish immediately — the server already signaled ready
214
+ this.establishChannel(this.#serverChannel.channelId)
215
+ break
216
+ }
272
217
 
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
218
+ case "remove-channel": {
219
+ if (this.#serverChannel) {
220
+ this.removeChannel(this.#serverChannel.channelId)
221
+ this.#serverChannel = undefined
279
222
  }
223
+ break
224
+ }
280
225
 
281
- const frame = encodeComplete(cborCodec, msg)
226
+ case "start-reconnect-timer": {
227
+ this.#reconnectTimer = setTimeout(() => {
228
+ this.#reconnectTimer = undefined
229
+ dispatch({ type: "reconnect-timer-fired" })
230
+ }, effect.delayMs)
231
+ break
232
+ }
282
233
 
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))
234
+ case "cancel-reconnect-timer": {
235
+ if (this.#reconnectTimer !== undefined) {
236
+ clearTimeout(this.#reconnectTimer)
237
+ this.#reconnectTimer = undefined
295
238
  }
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
- }
239
+ break
240
+ }
304
241
 
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
- }
242
+ case "start-keepalive": {
243
+ this.#startKeepalive()
244
+ break
245
+ }
316
246
 
317
- async onStop(): Promise<void> {
318
- this.#shouldReconnect = false
319
- this.#reassembler.dispose()
320
- this.#disconnect({ type: "intentional" })
247
+ case "stop-keepalive": {
248
+ this.#stopKeepalive()
249
+ break
250
+ }
251
+ }
321
252
  }
322
253
 
323
254
  // ==========================================================================
324
- // Connection management
255
+ // WebSocket creation — the core I/O operation
325
256
  // ==========================================================================
326
257
 
327
258
  /**
328
- * Connect to the Websocket server.
259
+ * Create a WebSocket and wire up event handlers to dispatch messages.
260
+ *
261
+ * The message handler is set up IMMEDIATELY after creation (before
262
+ * the open event) to handle the race condition where the server sends
263
+ * "ready" before the client's open promise resolves.
329
264
  */
330
- async #connect(): Promise<void> {
331
- const currentState = this.#stateMachine.getState()
332
- if (currentState.status === "connecting") {
265
+ #doCreateWebsocket(dispatch: (msg: WsClientMsg) => void): void {
266
+ const peerId = this.#peerId
267
+ if (!peerId) {
268
+ dispatch({
269
+ type: "socket-error",
270
+ error: new Error("Cannot connect: peerId not set"),
271
+ })
333
272
  return
334
273
  }
335
274
 
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
275
  // Resolve URL
347
276
  const url =
348
277
  typeof this.#options.url === "function"
349
- ? this.#options.url(this.#peerId)
278
+ ? this.#options.url(peerId)
350
279
  : this.#options.url
351
280
 
352
281
  try {
@@ -355,7 +284,6 @@ export class WebsocketClientTransport extends Transport<void> {
355
284
  this.#options.headers &&
356
285
  Object.keys(this.#options.headers).length > 0
357
286
  ) {
358
- // Bun extends the standard WebSocket API with a non-standard constructor
359
287
  type BunWebSocketConstructor = new (
360
288
  url: string,
361
289
  options: { headers: Record<string, string> },
@@ -370,171 +298,112 @@ export class WebsocketClientTransport extends Transport<void> {
370
298
  }
371
299
  this.#socket.binaryType = "arraybuffer"
372
300
 
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
- }
301
+ const socket = this.#socket
395
302
 
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)
303
+ // Set up message handler IMMEDIATELY to handle the "ready" race condition.
304
+ // The server may send "ready" before the open event fires.
305
+ socket.addEventListener("message", (event: MessageEvent) => {
306
+ this.#handleMessage(event, dispatch)
410
307
  })
411
308
 
412
- // Socket is now open transition to connected
413
- this.#stateMachine.transition({ status: "connected" })
309
+ // Track whether we've dispatched a terminal event for this connection attempt
310
+ let settled = false
311
+
312
+ const onOpen = () => {
313
+ cleanup()
314
+ settled = true
315
+ dispatch({ type: "socket-opened" })
316
+
317
+ // After open, set up permanent close handler for post-connection closes
318
+ socket.addEventListener("close", (event: CloseEvent) => {
319
+ dispatch({
320
+ type: "socket-closed",
321
+ code: event.code,
322
+ reason: event.reason,
323
+ })
324
+ })
325
+ }
414
326
 
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
- })
327
+ const onError = () => {
328
+ if (settled) return
329
+ cleanup()
330
+ settled = true
331
+ dispatch({
332
+ type: "socket-error",
333
+ error: new Error("WebSocket connection failed"),
334
+ })
335
+ }
419
336
 
420
- // Start keepalive
421
- this.#startKeepalive()
337
+ const onClose = () => {
338
+ if (settled) return
339
+ cleanup()
340
+ settled = true
341
+ dispatch({
342
+ type: "socket-error",
343
+ error: new Error("WebSocket closed during connection"),
344
+ })
345
+ }
346
+
347
+ const cleanup = () => {
348
+ socket.removeEventListener("open", onOpen)
349
+ socket.removeEventListener("error", onError)
350
+ socket.removeEventListener("close", onClose)
351
+ }
422
352
 
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.
353
+ socket.addEventListener("open", onOpen)
354
+ socket.addEventListener("error", onError)
355
+ socket.addEventListener("close", onClose)
426
356
  } catch (error) {
427
- // Transition to reconnecting or disconnected
428
- this.#scheduleReconnect({
429
- type: "error",
357
+ dispatch({
358
+ type: "socket-error",
430
359
  error: error instanceof Error ? error : new Error(String(error)),
431
360
  })
432
361
  }
433
362
  }
434
363
 
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
364
  // ==========================================================================
460
- // Message handling
365
+ // Message handling — I/O parsing logic
461
366
  // ==========================================================================
462
367
 
463
368
  /**
464
369
  * Handle incoming Websocket messages.
370
+ *
371
+ * Text frames carry the "ready" handshake and keepalive pong.
372
+ * Binary frames carry CBOR-encoded ChannelMsg.
465
373
  */
466
- #handleMessage(event: MessageEvent): void {
374
+ #handleMessage(
375
+ event: MessageEvent,
376
+ dispatch: (msg: WsClientMsg) => void,
377
+ ): void {
467
378
  const data = event.data
468
379
 
469
380
  // Handle text messages (keepalive and ready signal)
470
381
  if (typeof data === "string") {
471
382
  if (data === "ready") {
472
- this.#handleServerReady()
383
+ dispatch({ type: "server-ready" })
473
384
  }
474
- // Ignore pong responses
385
+ // Ignore pong responses and other text
475
386
  return
476
387
  }
477
388
 
478
- // Handle binary messages through reassembler
389
+ // Handle binary messages through shared decode pipeline
479
390
  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)
391
+ try {
392
+ const messages = decodeBinaryMessages(
393
+ new Uint8Array(data),
394
+ this.#reassembler,
395
+ )
396
+ if (messages) {
486
397
  for (const msg of messages) {
487
398
  this.#handleChannelMessage(msg)
488
399
  }
489
- } catch (error) {
490
- console.error("Failed to decode message:", error)
491
400
  }
492
- } else if (result.status === "error") {
493
- console.error("Fragment reassembly error:", result.error)
401
+ } catch (error) {
402
+ console.error("Failed to decode message:", error)
494
403
  }
495
- // "pending" status means we're waiting for more fragments — nothing to do
496
404
  }
497
405
  }
498
406
 
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
407
  /**
539
408
  * Handle a decoded channel message.
540
409
  */
@@ -547,21 +416,6 @@ export class WebsocketClientTransport extends Transport<void> {
547
416
  this.#serverChannel.onReceive(msg)
548
417
  }
549
418
 
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
419
  // ==========================================================================
566
420
  // Keepalive
567
421
  // ==========================================================================
@@ -586,70 +440,131 @@ export class WebsocketClientTransport extends Transport<void> {
586
440
  }
587
441
 
588
442
  // ==========================================================================
589
- // Reconnection
443
+ // Lifecycle event forwarding
444
+ // ==========================================================================
445
+
446
+ #setupLifecycleEvents(): void {
447
+ // wasConnectedBefore is observer-local state, not in the program model
448
+ let wasConnectedBefore = false
449
+
450
+ this.#handle.subscribeToTransitions(transition => {
451
+ // Forward to onStateChange callback
452
+ this.#options.lifecycle?.onStateChange?.(transition)
453
+
454
+ const { from, to } = transition
455
+
456
+ // onDisconnect: transitioning TO disconnected
457
+ if (to.status === "disconnected" && to.reason) {
458
+ this.#options.lifecycle?.onDisconnect?.(to.reason)
459
+ }
460
+
461
+ // onReconnecting: transitioning TO reconnecting
462
+ if (to.status === "reconnecting") {
463
+ this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)
464
+ }
465
+
466
+ // onReconnected: from reconnecting/connecting TO connected/ready (after prior connection)
467
+ if (
468
+ wasConnectedBefore &&
469
+ (from.status === "reconnecting" || from.status === "connecting") &&
470
+ (to.status === "connected" || to.status === "ready")
471
+ ) {
472
+ this.#options.lifecycle?.onReconnected?.()
473
+ }
474
+
475
+ // onReady: transitioning TO ready
476
+ if (to.status === "ready") {
477
+ this.#options.lifecycle?.onReady?.()
478
+ wasConnectedBefore = true
479
+ }
480
+ })
481
+ }
482
+
483
+ // ==========================================================================
484
+ // State observation — delegated to the observable handle
590
485
  // ==========================================================================
591
486
 
592
487
  /**
593
- * Schedule a reconnection attempt or transition to disconnected.
488
+ * Get the current connection state.
594
489
  */
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
- }
490
+ getState(): WebsocketClientState {
491
+ return this.#handle.getState()
492
+ }
602
493
 
603
- const reconnectOpts = {
604
- ...DEFAULT_RECONNECT,
605
- ...this.#options.reconnect,
606
- }
494
+ /**
495
+ * Subscribe to state transitions.
496
+ */
497
+ subscribeToTransitions(
498
+ listener: TransitionListener<WebsocketClientState>,
499
+ ): () => void {
500
+ return this.#handle.subscribeToTransitions(listener)
501
+ }
607
502
 
608
- if (!this.#shouldReconnect || !reconnectOpts.enabled) {
609
- this.#stateMachine.transition({ status: "disconnected", reason })
610
- return
611
- }
503
+ /**
504
+ * Wait for a specific state.
505
+ */
506
+ waitForState(
507
+ predicate: (state: WebsocketClientState) => boolean,
508
+ options?: { timeoutMs?: number },
509
+ ): Promise<WebsocketClientState> {
510
+ return this.#handle.waitForState(predicate, options)
511
+ }
612
512
 
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
- }
513
+ /**
514
+ * Wait for a specific status.
515
+ */
516
+ waitForStatus(
517
+ status: WebsocketClientState["status"],
518
+ options?: { timeoutMs?: number },
519
+ ): Promise<WebsocketClientState> {
520
+ return this.#handle.waitForStatus(status, options)
521
+ }
628
522
 
629
- const nextAttempt = currentAttempt + 1
523
+ /**
524
+ * Whether the client is ready (server ready signal received).
525
+ */
526
+ get isReady(): boolean {
527
+ return this.#handle.getState().status === "ready"
528
+ }
630
529
 
631
- // Exponential backoff with jitter
632
- const delay = Math.min(
633
- reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1000,
634
- reconnectOpts.maxDelay,
635
- )
530
+ // ==========================================================================
531
+ // Transport abstract method implementations
532
+ // ==========================================================================
636
533
 
637
- this.#stateMachine.transition({
638
- status: "reconnecting",
639
- attempt: nextAttempt,
640
- nextAttemptMs: delay,
641
- })
534
+ protected generate(): GeneratedChannel {
535
+ return {
536
+ transportType: this.transportType,
537
+ send: (msg: ChannelMsg) => {
538
+ const socket = this.#socket
539
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
540
+ return
541
+ }
642
542
 
643
- this.#reconnectTimer = setTimeout(() => {
644
- this.#connect()
645
- }, delay)
543
+ encodeBinaryAndSend(msg, this.#fragmentThreshold, data =>
544
+ socket.send(new Uint8Array(data).buffer),
545
+ )
546
+ },
547
+ stop: () => {
548
+ // Don't call disconnect here — channel.stop() is called when
549
+ // the channel is removed, which can happen during effect execution.
550
+ // The actual disconnect is handled by onStop() or the program.
551
+ },
552
+ }
646
553
  }
647
554
 
648
- #clearReconnectTimer(): void {
649
- if (this.#reconnectTimer) {
650
- clearTimeout(this.#reconnectTimer)
651
- this.#reconnectTimer = undefined
555
+ async onStart(): Promise<void> {
556
+ if (!this.identity) {
557
+ throw new Error(
558
+ "Transport not properly initialized — identity not available",
559
+ )
652
560
  }
561
+ this.#peerId = this.identity.peerId
562
+ this.#handle.dispatch({ type: "start" })
563
+ }
564
+
565
+ async onStop(): Promise<void> {
566
+ this.#reassembler.dispose()
567
+ this.#handle.dispatch({ type: "stop" })
653
568
  }
654
569
  }
655
570
 
@@ -658,14 +573,15 @@ export class WebsocketClientTransport extends Transport<void> {
658
573
  // ---------------------------------------------------------------------------
659
574
 
660
575
  /**
661
- * Create a Websocket client adapter factory for browser-to-server connections.
576
+ * Create a Websocket client transport factory for browser-to-server
577
+ * connections.
662
578
  *
663
- * Returns an `TransportFactory` — a closure that creates a fresh adapter
579
+ * Returns an `TransportFactory` — a closure that creates a fresh transport
664
580
  * instance when called. Pass directly to `Exchange({ transports: [...] })`.
665
581
  *
666
582
  * @example
667
583
  * ```typescript
668
- * import { createWebsocketClient } from "@kyneta/websocket-network-adapter/client"
584
+ * import { createWebsocketClient } from "@kyneta/websocket-transport/client"
669
585
  *
670
586
  * const exchange = new Exchange({
671
587
  * transports: [createWebsocketClient({
@@ -682,7 +598,7 @@ export function createWebsocketClient(
682
598
  }
683
599
 
684
600
  /**
685
- * Create a Websocket client adapter for service-to-service connections.
601
+ * Create a Websocket client transport for service-to-service connections.
686
602
  *
687
603
  * This factory is for backend environments (Bun, Node.js) where you need
688
604
  * to pass authentication headers during the Websocket upgrade.
@@ -693,7 +609,7 @@ export function createWebsocketClient(
693
609
  *
694
610
  * @example
695
611
  * ```typescript
696
- * import { createServiceWebsocketClient } from "@kyneta/websocket-network-adapter/client"
612
+ * import { createServiceWebsocketClient } from "@kyneta/websocket-transport/client"
697
613
  *
698
614
  * const exchange = new Exchange({
699
615
  * transports: [createServiceWebsocketClient({