@kyneta/sse-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,722 @@
1
+ // client-adapter — SSE client adapter for @kyneta/exchange.
2
+ //
3
+ // Connects to an SSE server using two HTTP channels:
4
+ // - EventSource (GET) for server→client messages
5
+ // - fetch POST for client→server messages
6
+ //
7
+ // Both directions use the text wire format (textCodec + text framing).
8
+ //
9
+ // Features:
10
+ // - State machine with validated transitions (disconnected → connecting → connected)
11
+ // - Exponential backoff reconnection with jitter
12
+ // - POST retry with exponential backoff
13
+ // - Text-level fragmentation for large payloads
14
+ // - Inbound TextReassembler for fragmented SSE messages
15
+ // - Observable connection state via subscribeToTransitions()
16
+ //
17
+ // The connection handshake:
18
+ // 1. Client creates EventSource, waits for open
19
+ // 2. EventSource.onopen fires → client creates channel + calls establishChannel()
20
+ // 3. Synchronizer exchanges establish-request / establish-response via POST + SSE
21
+ //
22
+ // On EventSource.onerror, the adapter closes the EventSource immediately and
23
+ // takes over reconnection via the state machine's backoff logic, rather than
24
+ // letting the browser's built-in EventSource reconnection run.
25
+
26
+ import type {
27
+ Channel,
28
+ ChannelMsg,
29
+ GeneratedChannel,
30
+ PeerId,
31
+ StateTransition,
32
+ TransitionListener,
33
+ TransportFactory,
34
+ } from "@kyneta/exchange"
35
+ import { Transport } from "@kyneta/exchange"
36
+ import {
37
+ encodeTextComplete,
38
+ fragmentTextPayload,
39
+ TextReassembler,
40
+ textCodec,
41
+ } from "@kyneta/wire"
42
+ import { SseClientStateMachine } from "./client-state-machine.js"
43
+ import type {
44
+ DisconnectReason,
45
+ SseClientLifecycleEvents,
46
+ SseClientState,
47
+ } from "./types.js"
48
+
49
+ // Re-export state types for convenience
50
+ export type { DisconnectReason, SseClientLifecycleEvents, SseClientState }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Options
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Default fragment threshold in characters.
58
+ * 60K chars provides a safety margin below typical 100KB body-parser limits,
59
+ * accounting for JSON overhead and potential base64 expansion.
60
+ */
61
+ export const DEFAULT_FRAGMENT_THRESHOLD = 60_000
62
+
63
+ /**
64
+ * Options for the SSE client adapter.
65
+ */
66
+ export interface SseClientOptions {
67
+ /** URL for POST requests (client→server). String or function of peerId. */
68
+ postUrl: string | ((peerId: PeerId) => string)
69
+
70
+ /** URL for SSE EventSource (server→client). String or function of peerId. */
71
+ eventSourceUrl: string | ((peerId: PeerId) => string)
72
+
73
+ /** Reconnection options for EventSource. */
74
+ reconnect?: {
75
+ enabled?: boolean
76
+ maxAttempts?: number
77
+ baseDelay?: number
78
+ maxDelay?: number
79
+ }
80
+
81
+ /** POST retry options. */
82
+ postRetry?: {
83
+ maxAttempts?: number
84
+ baseDelay?: number
85
+ maxDelay?: number
86
+ }
87
+
88
+ /** Fragment threshold in characters. Default: 60000 (60K chars). */
89
+ fragmentThreshold?: number
90
+
91
+ /** Lifecycle event callbacks. */
92
+ lifecycle?: SseClientLifecycleEvents
93
+ }
94
+
95
+ /**
96
+ * Default reconnection options.
97
+ */
98
+ const DEFAULT_RECONNECT = {
99
+ enabled: true,
100
+ maxAttempts: 10,
101
+ baseDelay: 1000,
102
+ maxDelay: 30000,
103
+ }
104
+
105
+ /**
106
+ * Default POST retry options.
107
+ */
108
+ const DEFAULT_POST_RETRY = {
109
+ maxAttempts: 3,
110
+ baseDelay: 1000,
111
+ maxDelay: 10000,
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // SseClientTransport
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * SSE client network adapter for @kyneta/exchange.
120
+ *
121
+ * Uses two HTTP channels:
122
+ * - **EventSource** (GET, long-lived) for server→client messages
123
+ * - **fetch POST** for client→server messages
124
+ *
125
+ * Both directions use the text wire format (`textCodec` + text framing).
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * import { createSseClient } from "@kyneta/sse-network-adapter/client"
130
+ *
131
+ * const adapter = createSseClient({
132
+ * postUrl: "/sync",
133
+ * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
134
+ * reconnect: { enabled: true },
135
+ * })
136
+ *
137
+ * const exchange = new Exchange({
138
+ * identity: { peerId: "browser-client" },
139
+ * transports: [adapter],
140
+ * })
141
+ * ```
142
+ */
143
+ export class SseClientTransport extends Transport<void> {
144
+ #peerId?: PeerId
145
+ #eventSource?: EventSource
146
+ #serverChannel?: Channel
147
+ #reconnectTimer?: ReturnType<typeof setTimeout>
148
+ #options: SseClientOptions
149
+ #shouldReconnect = true
150
+ #wasConnectedBefore = false
151
+
152
+ // State machine
153
+ readonly #stateMachine = new SseClientStateMachine()
154
+
155
+ // Fragmentation
156
+ readonly #fragmentThreshold: number
157
+
158
+ // Inbound reassembly for fragmented SSE messages from server
159
+ readonly #reassembler: TextReassembler
160
+
161
+ // POST retry
162
+ #currentRetryAbortController?: AbortController
163
+
164
+ constructor(options: SseClientOptions) {
165
+ super({ transportType: "sse-client" })
166
+ this.#options = options
167
+ this.#fragmentThreshold =
168
+ options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
169
+ this.#reassembler = new TextReassembler({
170
+ timeoutMs: 10_000,
171
+ })
172
+
173
+ // Set up lifecycle event forwarding
174
+ this.#setupLifecycleEvents()
175
+ }
176
+
177
+ // ==========================================================================
178
+ // Lifecycle event forwarding
179
+ // ==========================================================================
180
+
181
+ #setupLifecycleEvents(): void {
182
+ this.#stateMachine.subscribeToTransitions(transition => {
183
+ // Forward to onStateChange callback
184
+ this.#options.lifecycle?.onStateChange?.(transition)
185
+
186
+ const { from, to } = transition
187
+
188
+ // onDisconnect: transitioning TO disconnected
189
+ if (to.status === "disconnected" && to.reason) {
190
+ this.#options.lifecycle?.onDisconnect?.(to.reason)
191
+ }
192
+
193
+ // onReconnecting: transitioning TO reconnecting
194
+ if (to.status === "reconnecting") {
195
+ this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)
196
+ }
197
+
198
+ // onReconnected: from reconnecting/connecting TO connected (after prior connection)
199
+ if (
200
+ this.#wasConnectedBefore &&
201
+ (from.status === "reconnecting" || from.status === "connecting") &&
202
+ to.status === "connected"
203
+ ) {
204
+ this.#options.lifecycle?.onReconnected?.()
205
+ }
206
+ })
207
+ }
208
+
209
+ // ==========================================================================
210
+ // State observation API
211
+ // ==========================================================================
212
+
213
+ /**
214
+ * Get the current state of the connection.
215
+ */
216
+ getState(): SseClientState {
217
+ return this.#stateMachine.getState()
218
+ }
219
+
220
+ /**
221
+ * Subscribe to state transitions.
222
+ * @returns Unsubscribe function
223
+ */
224
+ subscribeToTransitions(
225
+ listener: TransitionListener<SseClientState>,
226
+ ): () => void {
227
+ return this.#stateMachine.subscribeToTransitions(listener)
228
+ }
229
+
230
+ /**
231
+ * Wait for a specific state.
232
+ */
233
+ waitForState(
234
+ predicate: (state: SseClientState) => boolean,
235
+ options?: { timeoutMs?: number },
236
+ ): Promise<SseClientState> {
237
+ return this.#stateMachine.waitForState(predicate, options)
238
+ }
239
+
240
+ /**
241
+ * Wait for a specific status.
242
+ */
243
+ waitForStatus(
244
+ status: SseClientState["status"],
245
+ options?: { timeoutMs?: number },
246
+ ): Promise<SseClientState> {
247
+ return this.#stateMachine.waitForStatus(status, options)
248
+ }
249
+
250
+ /**
251
+ * Check if the client is connected (EventSource open, channel established).
252
+ */
253
+ get isConnected(): boolean {
254
+ return this.#stateMachine.isConnected()
255
+ }
256
+
257
+ // ==========================================================================
258
+ // Adapter abstract method implementations
259
+ // ==========================================================================
260
+
261
+ protected generate(): GeneratedChannel {
262
+ return {
263
+ transportType: this.transportType,
264
+ send: (msg: ChannelMsg) => {
265
+ if (!this.#peerId) {
266
+ return
267
+ }
268
+
269
+ // Check if EventSource is closed before sending
270
+ // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED
271
+ if (!this.#eventSource || this.#eventSource.readyState === 2) {
272
+ return
273
+ }
274
+
275
+ // Resolve the postUrl with the peerId
276
+ const resolvedPostUrl =
277
+ typeof this.#options.postUrl === "function"
278
+ ? this.#options.postUrl(this.#peerId)
279
+ : this.#options.postUrl
280
+
281
+ // Encode to text wire format
282
+ const textFrame = encodeTextComplete(textCodec, msg)
283
+
284
+ // Fragment large payloads
285
+ if (
286
+ this.#fragmentThreshold > 0 &&
287
+ textFrame.length > this.#fragmentThreshold
288
+ ) {
289
+ const payload = JSON.stringify(textCodec.encode(msg))
290
+ const fragments = fragmentTextPayload(
291
+ payload,
292
+ this.#fragmentThreshold,
293
+ )
294
+ for (const fragment of fragments) {
295
+ void this.#sendTextWithRetry(resolvedPostUrl, fragment)
296
+ }
297
+ } else {
298
+ void this.#sendTextWithRetry(resolvedPostUrl, textFrame)
299
+ }
300
+ },
301
+ stop: () => {
302
+ // Don't call disconnect() here — channel.stop() is called when
303
+ // the channel is removed, which can happen during handleClose().
304
+ // The actual disconnect is handled by onStop() or handleClose().
305
+ },
306
+ }
307
+ }
308
+
309
+ async onStart(): Promise<void> {
310
+ if (!this.identity) {
311
+ throw new Error(
312
+ "Adapter not properly initialized — identity not available",
313
+ )
314
+ }
315
+ this.#peerId = this.identity.peerId
316
+ this.#shouldReconnect = true
317
+ this.#wasConnectedBefore = false
318
+ this.#connect()
319
+ }
320
+
321
+ async onStop(): Promise<void> {
322
+ this.#shouldReconnect = false
323
+ this.#reassembler.dispose()
324
+ this.#currentRetryAbortController?.abort()
325
+ this.#currentRetryAbortController = undefined
326
+ this.#disconnect({ type: "intentional" })
327
+ }
328
+
329
+ // ==========================================================================
330
+ // Connection management
331
+ // ==========================================================================
332
+
333
+ /**
334
+ * Connect to the SSE server by creating an EventSource.
335
+ */
336
+ #connect(): void {
337
+ const currentState = this.#stateMachine.getState()
338
+ if (currentState.status === "connecting") {
339
+ return
340
+ }
341
+
342
+ if (!this.#peerId) {
343
+ throw new Error("Cannot connect: peerId not set")
344
+ }
345
+
346
+ // Determine attempt number
347
+ const attempt =
348
+ currentState.status === "reconnecting" ? currentState.attempt : 1
349
+
350
+ this.#stateMachine.transition({ status: "connecting", attempt })
351
+
352
+ // Resolve URL
353
+ const url =
354
+ typeof this.#options.eventSourceUrl === "function"
355
+ ? this.#options.eventSourceUrl(this.#peerId)
356
+ : this.#options.eventSourceUrl
357
+
358
+ try {
359
+ this.#eventSource = new EventSource(url)
360
+
361
+ this.#eventSource.onopen = () => {
362
+ this.#handleOpen()
363
+ }
364
+
365
+ this.#eventSource.onmessage = (event: MessageEvent) => {
366
+ this.#handleMessage(event)
367
+ }
368
+
369
+ this.#eventSource.onerror = () => {
370
+ this.#handleError()
371
+ }
372
+ } catch (error) {
373
+ // EventSource constructor threw (e.g. invalid URL)
374
+ this.#scheduleReconnect({
375
+ type: "error",
376
+ error: error instanceof Error ? error : new Error(String(error)),
377
+ })
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Disconnect from the SSE server.
383
+ */
384
+ #disconnect(reason: DisconnectReason): void {
385
+ this.#clearReconnectTimer()
386
+
387
+ if (this.#eventSource) {
388
+ this.#eventSource.onopen = null
389
+ this.#eventSource.onmessage = null
390
+ this.#eventSource.onerror = null
391
+ this.#eventSource.close()
392
+ this.#eventSource = undefined
393
+ }
394
+
395
+ if (this.#serverChannel) {
396
+ this.removeChannel(this.#serverChannel.channelId)
397
+ this.#serverChannel = undefined
398
+ }
399
+
400
+ // Only transition if not already disconnected
401
+ const currentState = this.#stateMachine.getState()
402
+ if (currentState.status !== "disconnected") {
403
+ this.#stateMachine.transition({ status: "disconnected", reason })
404
+ }
405
+ }
406
+
407
+ // ==========================================================================
408
+ // Event handlers
409
+ // ==========================================================================
410
+
411
+ /**
412
+ * Handle EventSource open event.
413
+ *
414
+ * The SSE connection is usable immediately — no "ready" signal needed.
415
+ * Create the channel and initiate establishment.
416
+ */
417
+ #handleOpen(): void {
418
+ const currentState = this.#stateMachine.getState()
419
+
420
+ // Handle potential race: onopen before state machine caught up
421
+ if (
422
+ currentState.status !== "connecting" &&
423
+ currentState.status !== "connected"
424
+ ) {
425
+ // Might be in reconnecting → connecting path; just ignore
426
+ return
427
+ }
428
+
429
+ if (currentState.status === "connecting") {
430
+ this.#stateMachine.transition({ status: "connected" })
431
+ }
432
+
433
+ this.#wasConnectedBefore = true
434
+
435
+ // Cancel any pending POST retries from previous connection
436
+ if (this.#currentRetryAbortController) {
437
+ this.#currentRetryAbortController.abort()
438
+ this.#currentRetryAbortController = undefined
439
+ }
440
+
441
+ // Create channel if not exists
442
+ if (this.#serverChannel) {
443
+ this.removeChannel(this.#serverChannel.channelId)
444
+ this.#serverChannel = undefined
445
+ }
446
+
447
+ this.#serverChannel = this.addChannel()
448
+
449
+ // Initiate establishment handshake
450
+ this.establishChannel(this.#serverChannel.channelId)
451
+ }
452
+
453
+ /**
454
+ * Handle incoming SSE message.
455
+ *
456
+ * Each SSE `data:` event contains a text wire frame string.
457
+ * Feed it through the TextReassembler to handle both complete
458
+ * and fragmented frames.
459
+ */
460
+ #handleMessage(event: MessageEvent): void {
461
+ if (!this.#serverChannel) {
462
+ return
463
+ }
464
+
465
+ const data = event.data
466
+ if (typeof data !== "string") {
467
+ return
468
+ }
469
+
470
+ // Feed through reassembler (handles both complete and fragment frames)
471
+ const result = this.#reassembler.receive(data)
472
+
473
+ if (result.status === "complete") {
474
+ try {
475
+ // Two-step decode: Frame<string> → JSON.parse → textCodec.decode
476
+ const parsed = JSON.parse(result.frame.content.payload)
477
+ const messages = textCodec.decode(parsed)
478
+ for (const msg of messages) {
479
+ this.#serverChannel.onReceive(msg)
480
+ }
481
+ } catch (error) {
482
+ console.error("Failed to decode SSE message:", error)
483
+ }
484
+ } else if (result.status === "error") {
485
+ console.error("SSE message reassembly error:", result.error)
486
+ }
487
+ // "pending" status means we're waiting for more fragments — nothing to do
488
+ }
489
+
490
+ /**
491
+ * Handle EventSource error.
492
+ *
493
+ * Closes the EventSource immediately and takes over reconnection
494
+ * via the state machine's backoff logic. This prevents the browser's
495
+ * built-in EventSource reconnection from running.
496
+ */
497
+ #handleError(): void {
498
+ // Close immediately to prevent browser auto-reconnect
499
+ if (this.#eventSource) {
500
+ this.#eventSource.onopen = null
501
+ this.#eventSource.onmessage = null
502
+ this.#eventSource.onerror = null
503
+ this.#eventSource.close()
504
+ this.#eventSource = undefined
505
+ }
506
+
507
+ if (this.#serverChannel) {
508
+ this.removeChannel(this.#serverChannel.channelId)
509
+ this.#serverChannel = undefined
510
+ }
511
+
512
+ // Schedule reconnect or transition to disconnected
513
+ this.#scheduleReconnect({
514
+ type: "error",
515
+ error: new Error("EventSource connection error"),
516
+ })
517
+ }
518
+
519
+ // ==========================================================================
520
+ // POST sending with retry
521
+ // ==========================================================================
522
+
523
+ /**
524
+ * Send a text frame via POST with retry logic.
525
+ */
526
+ async #sendTextWithRetry(url: string, textFrame: string): Promise<void> {
527
+ let attempt = 0
528
+ const postRetryOpts = {
529
+ ...DEFAULT_POST_RETRY,
530
+ ...this.#options.postRetry,
531
+ }
532
+ const { maxAttempts, baseDelay, maxDelay } = postRetryOpts
533
+
534
+ while (attempt < maxAttempts) {
535
+ try {
536
+ if (!this.#currentRetryAbortController) {
537
+ this.#currentRetryAbortController = new AbortController()
538
+ }
539
+
540
+ if (!this.#peerId) {
541
+ throw new Error("PeerId not available for retry")
542
+ }
543
+
544
+ const response = await fetch(url, {
545
+ method: "POST",
546
+ headers: {
547
+ "Content-Type": "text/plain",
548
+ "X-Peer-Id": this.#peerId,
549
+ },
550
+ body: textFrame,
551
+ signal: this.#currentRetryAbortController.signal,
552
+ })
553
+
554
+ if (!response.ok) {
555
+ // Don't retry on client errors (4xx)
556
+ if (response.status >= 400 && response.status < 500) {
557
+ throw new Error(`Failed to send message: ${response.statusText}`)
558
+ }
559
+ throw new Error(`Server error: ${response.statusText}`)
560
+ }
561
+
562
+ // Success
563
+ this.#currentRetryAbortController = undefined
564
+ return
565
+ } catch (error: unknown) {
566
+ attempt++
567
+
568
+ const err = error as Error
569
+
570
+ // If aborted, stop retrying
571
+ if (err.name === "AbortError") {
572
+ throw error
573
+ }
574
+
575
+ // If controller was cleared (e.g. by onopen), stop retrying
576
+ if (!this.#currentRetryAbortController) {
577
+ const abortError = new Error("Retry aborted by connection reset")
578
+ abortError.name = "AbortError"
579
+ throw abortError
580
+ }
581
+
582
+ // If max attempts reached, throw the last error
583
+ if (attempt >= maxAttempts) {
584
+ this.#currentRetryAbortController = undefined
585
+ throw error
586
+ }
587
+
588
+ // Calculate delay with exponential backoff and jitter
589
+ const delay = Math.min(
590
+ baseDelay * 2 ** (attempt - 1) + Math.random() * 100,
591
+ maxDelay,
592
+ )
593
+
594
+ // Wait for delay or abort signal
595
+ await new Promise<void>((resolve, reject) => {
596
+ if (this.#currentRetryAbortController?.signal.aborted) {
597
+ const error = new Error("Retry aborted")
598
+ error.name = "AbortError"
599
+ reject(error)
600
+ return
601
+ }
602
+
603
+ const timer = setTimeout(() => {
604
+ cleanup()
605
+ resolve()
606
+ }, delay)
607
+
608
+ const onAbort = () => {
609
+ clearTimeout(timer)
610
+ cleanup()
611
+ const error = new Error("Retry aborted")
612
+ error.name = "AbortError"
613
+ reject(error)
614
+ }
615
+
616
+ const cleanup = () => {
617
+ this.#currentRetryAbortController?.signal.removeEventListener(
618
+ "abort",
619
+ onAbort,
620
+ )
621
+ }
622
+
623
+ this.#currentRetryAbortController?.signal.addEventListener(
624
+ "abort",
625
+ onAbort,
626
+ )
627
+ })
628
+ }
629
+ }
630
+ }
631
+
632
+ // ==========================================================================
633
+ // Reconnection
634
+ // ==========================================================================
635
+
636
+ /**
637
+ * Schedule a reconnection attempt or transition to disconnected.
638
+ */
639
+ #scheduleReconnect(reason: DisconnectReason): void {
640
+ const currentState = this.#stateMachine.getState()
641
+
642
+ // If already disconnected, don't transition again
643
+ if (currentState.status === "disconnected") {
644
+ return
645
+ }
646
+
647
+ const reconnectOpts = {
648
+ ...DEFAULT_RECONNECT,
649
+ ...this.#options.reconnect,
650
+ }
651
+
652
+ if (!this.#shouldReconnect || !reconnectOpts.enabled) {
653
+ this.#stateMachine.transition({ status: "disconnected", reason })
654
+ return
655
+ }
656
+
657
+ // Get current attempt count from state
658
+ const currentAttempt =
659
+ currentState.status === "reconnecting"
660
+ ? currentState.attempt
661
+ : currentState.status === "connecting"
662
+ ? (currentState as { attempt: number }).attempt
663
+ : 0
664
+
665
+ if (currentAttempt >= reconnectOpts.maxAttempts) {
666
+ this.#stateMachine.transition({
667
+ status: "disconnected",
668
+ reason: { type: "max-retries-exceeded", attempts: currentAttempt },
669
+ })
670
+ return
671
+ }
672
+
673
+ const nextAttempt = currentAttempt + 1
674
+
675
+ // Exponential backoff with jitter
676
+ const delay = Math.min(
677
+ reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1000,
678
+ reconnectOpts.maxDelay,
679
+ )
680
+
681
+ this.#stateMachine.transition({
682
+ status: "reconnecting",
683
+ attempt: nextAttempt,
684
+ nextAttemptMs: delay,
685
+ })
686
+
687
+ this.#reconnectTimer = setTimeout(() => {
688
+ this.#connect()
689
+ }, delay)
690
+ }
691
+
692
+ #clearReconnectTimer(): void {
693
+ if (this.#reconnectTimer) {
694
+ clearTimeout(this.#reconnectTimer)
695
+ this.#reconnectTimer = undefined
696
+ }
697
+ }
698
+ }
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // Factory function
702
+ // ---------------------------------------------------------------------------
703
+
704
+ /**
705
+ * Create an SSE client adapter for browser-to-server connections.
706
+ *
707
+ * @example
708
+ * ```typescript
709
+ * import { createSseClient } from "@kyneta/sse-network-adapter/client"
710
+ *
711
+ * const exchange = new Exchange({
712
+ * transports: [createSseClient({
713
+ * postUrl: "/sync",
714
+ * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
715
+ * reconnect: { enabled: true },
716
+ * })],
717
+ * })
718
+ * ```
719
+ */
720
+ export function createSseClient(options: SseClientOptions): TransportFactory {
721
+ return () => new SseClientTransport(options)
722
+ }