@durable-streams/client 0.2.0 → 0.2.2

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/src/response.ts CHANGED
@@ -7,14 +7,17 @@
7
7
 
8
8
  import { asAsyncIterableReadableStream } from "./asyncIterableReadableStream"
9
9
  import {
10
+ STREAM_CLOSED_HEADER,
10
11
  STREAM_CURSOR_HEADER,
11
12
  STREAM_OFFSET_HEADER,
12
13
  STREAM_UP_TO_DATE_HEADER,
13
14
  } from "./constants"
14
15
  import { DurableStreamError } from "./error"
15
16
  import { parseSSEStream } from "./sse"
17
+ import { LongPollState, PausedState, SSEState } from "./stream-response-state"
16
18
  import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
17
19
  import type { SSEControlEvent, SSEEvent } from "./sse"
20
+ import type { StreamResponseState } from "./stream-response-state"
18
21
  import type {
19
22
  ByteChunk,
20
23
  StreamResponse as IStreamResponse,
@@ -55,6 +58,8 @@ export interface StreamResponseConfig {
55
58
  initialCursor?: string
56
59
  /** Initial upToDate from first response headers */
57
60
  initialUpToDate: boolean
61
+ /** Initial streamClosed from first response headers */
62
+ initialStreamClosed: boolean
58
63
  /** The held first Response object */
59
64
  firstResponse: Response
60
65
  /** Abort controller for the session */
@@ -74,6 +79,8 @@ export interface StreamResponseConfig {
74
79
  ) => Promise<Response>
75
80
  /** SSE resilience options */
76
81
  sseResilience?: SSEResilienceOptions
82
+ /** Encoding for SSE data events */
83
+ encoding?: `base64`
77
84
  }
78
85
 
79
86
  /**
@@ -95,10 +102,8 @@ export class StreamResponseImpl<
95
102
  #ok: boolean
96
103
  #isLoading: boolean
97
104
 
98
- // --- Evolving state ---
99
- #offset: Offset
100
- #cursor?: string
101
- #upToDate: boolean
105
+ // --- Evolving state (immutable state machine) ---
106
+ #syncState: StreamResponseState
102
107
 
103
108
  // --- Internal state ---
104
109
  #isJsonMode: boolean
@@ -117,13 +122,12 @@ export class StreamResponseImpl<
117
122
  #unsubscribeFromVisibilityChanges?: () => void
118
123
  #pausePromise?: Promise<void>
119
124
  #pauseResolve?: () => void
120
- #justResumedFromPause = false
121
125
 
122
- // --- SSE Resilience State ---
126
+ // --- SSE Resilience Config ---
123
127
  #sseResilience: Required<SSEResilienceOptions>
124
- #lastSSEConnectionStartTime?: number
125
- #consecutiveShortSSEConnections = 0
126
- #sseFallbackToLongPoll = false
128
+
129
+ // --- SSE Encoding State ---
130
+ #encoding?: `base64`
127
131
 
128
132
  // Core primitive: a ReadableStream of Response objects
129
133
  #responseStream: ReadableStream<Response>
@@ -133,9 +137,18 @@ export class StreamResponseImpl<
133
137
  this.contentType = config.contentType
134
138
  this.live = config.live
135
139
  this.startOffset = config.startOffset
136
- this.#offset = config.initialOffset
137
- this.#cursor = config.initialCursor
138
- this.#upToDate = config.initialUpToDate
140
+
141
+ // Initialize immutable state machine — SSEState if SSE is available,
142
+ // LongPollState otherwise. The type encodes whether SSE has fallen back.
143
+ const syncFields = {
144
+ offset: config.initialOffset,
145
+ cursor: config.initialCursor,
146
+ upToDate: config.initialUpToDate,
147
+ streamClosed: config.initialStreamClosed,
148
+ }
149
+ this.#syncState = config.startSSE
150
+ ? new SSEState(syncFields)
151
+ : new LongPollState(syncFields)
139
152
 
140
153
  // Initialize response metadata from first response
141
154
  this.#headers = config.firstResponse.headers
@@ -162,6 +175,9 @@ export class StreamResponseImpl<
162
175
  logWarnings: config.sseResilience?.logWarnings ?? true,
163
176
  }
164
177
 
178
+ // Initialize SSE encoding
179
+ this.#encoding = config.encoding
180
+
165
181
  this.#closed = new Promise((resolve, reject) => {
166
182
  this.#closedResolve = resolve
167
183
  this.#closedReject = reject
@@ -233,6 +249,8 @@ export class StreamResponseImpl<
233
249
  #pause(): void {
234
250
  if (this.#state === `active`) {
235
251
  this.#state = `pause-requested`
252
+ // Wrap state in PausedState to preserve it across pause/resume
253
+ this.#syncState = this.#syncState.pause()
236
254
  // Create promise that pull() will await
237
255
  this.#pausePromise = new Promise((resolve) => {
238
256
  this.#pauseResolve = resolve
@@ -253,9 +271,13 @@ export class StreamResponseImpl<
253
271
  return
254
272
  }
255
273
 
274
+ // Unwrap PausedState to restore the inner state
275
+ if (this.#syncState instanceof PausedState) {
276
+ this.#syncState = this.#syncState.resume().state
277
+ }
278
+
256
279
  // Transition to active and resolve the pause promise
257
280
  this.#state = `active`
258
- this.#justResumedFromPause = true // Flag for single-shot skip of live param
259
281
  this.#pauseResolve?.()
260
282
  this.#pausePromise = undefined
261
283
  this.#pauseResolve = undefined
@@ -284,18 +306,22 @@ export class StreamResponseImpl<
284
306
  return this.#isLoading
285
307
  }
286
308
 
287
- // --- Evolving state getters ---
309
+ // --- Evolving state getters (delegated to state machine) ---
288
310
 
289
311
  get offset(): Offset {
290
- return this.#offset
312
+ return this.#syncState.offset
291
313
  }
292
314
 
293
315
  get cursor(): string | undefined {
294
- return this.#cursor
316
+ return this.#syncState.cursor
295
317
  }
296
318
 
297
319
  get upToDate(): boolean {
298
- return this.#upToDate
320
+ return this.#syncState.upToDate
321
+ }
322
+
323
+ get streamClosed(): boolean {
324
+ return this.#syncState.streamClosed
299
325
  }
300
326
 
301
327
  // =================================
@@ -338,26 +364,27 @@ export class StreamResponseImpl<
338
364
 
339
365
  /**
340
366
  * Determine if we should continue with live updates based on live mode
341
- * and whether we've received upToDate.
367
+ * and whether we've received upToDate or streamClosed.
342
368
  */
343
369
  #shouldContinueLive(): boolean {
344
- // Stop if we've received upToDate and a consumption method wants to stop after upToDate
345
- if (this.#stopAfterUpToDate && this.upToDate) return false
346
- // Stop if live mode is explicitly disabled
347
- if (this.live === false) return false
348
- return true
370
+ return this.#syncState.shouldContinueLive(
371
+ this.#stopAfterUpToDate,
372
+ this.live
373
+ )
349
374
  }
350
375
 
351
376
  /**
352
377
  * Update state from response headers.
353
378
  */
354
379
  #updateStateFromResponse(response: Response): void {
355
- // Update stream-specific state
356
- const offset = response.headers.get(STREAM_OFFSET_HEADER)
357
- if (offset) this.#offset = offset
358
- const cursor = response.headers.get(STREAM_CURSOR_HEADER)
359
- if (cursor) this.#cursor = cursor
360
- this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
380
+ // Immutable state transition
381
+ this.#syncState = this.#syncState.withResponseMetadata({
382
+ offset: response.headers.get(STREAM_OFFSET_HEADER) || undefined,
383
+ cursor: response.headers.get(STREAM_CURSOR_HEADER) || undefined,
384
+ upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
385
+ streamClosed:
386
+ response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`,
387
+ })
361
388
 
362
389
  // Update response metadata to reflect latest server response
363
390
  this.#headers = response.headers
@@ -366,125 +393,28 @@ export class StreamResponseImpl<
366
393
  this.#ok = response.ok
367
394
  }
368
395
 
369
- /**
370
- * Extract stream metadata from Response headers.
371
- * Used by subscriber APIs to get the correct offset/cursor/upToDate for each
372
- * specific Response, rather than reading from `this` which may be stale due to
373
- * ReadableStream prefetching or timing issues.
374
- */
375
- #getMetadataFromResponse(response: Response): {
376
- offset: Offset
377
- cursor: string | undefined
378
- upToDate: boolean
379
- } {
380
- const offset = response.headers.get(STREAM_OFFSET_HEADER)
381
- const cursor = response.headers.get(STREAM_CURSOR_HEADER)
382
- const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
383
- return {
384
- offset: offset ?? this.offset, // Fall back to instance state if no header
385
- cursor: cursor ?? this.cursor,
386
- upToDate,
387
- }
388
- }
389
-
390
- /**
391
- * Create a synthetic Response from SSE data with proper headers.
392
- * Includes offset/cursor/upToDate in headers so subscribers can read them.
393
- */
394
- #createSSESyntheticResponse(
395
- data: string,
396
- offset: Offset,
397
- cursor: string | undefined,
398
- upToDate: boolean
399
- ): Response {
400
- const headers: Record<string, string> = {
401
- "content-type": this.contentType ?? `application/json`,
402
- [STREAM_OFFSET_HEADER]: String(offset),
403
- }
404
- if (cursor) {
405
- headers[STREAM_CURSOR_HEADER] = cursor
406
- }
407
- if (upToDate) {
408
- headers[STREAM_UP_TO_DATE_HEADER] = `true`
409
- }
410
- return new Response(data, { status: 200, headers })
411
- }
412
-
413
396
  /**
414
397
  * Update instance state from an SSE control event.
415
398
  */
416
399
  #updateStateFromSSEControl(controlEvent: SSEControlEvent): void {
417
- this.#offset = controlEvent.streamNextOffset
418
- if (controlEvent.streamCursor) {
419
- this.#cursor = controlEvent.streamCursor
420
- }
421
- if (controlEvent.upToDate !== undefined) {
422
- this.#upToDate = controlEvent.upToDate
423
- }
400
+ this.#syncState = this.#syncState.withSSEControl(controlEvent)
424
401
  }
425
402
 
426
403
  /**
427
404
  * Mark the start of an SSE connection for duration tracking.
405
+ * If the state is not SSEState (e.g., auto-detected SSE from content-type),
406
+ * transitions to SSEState first.
428
407
  */
429
408
  #markSSEConnectionStart(): void {
430
- this.#lastSSEConnectionStartTime = Date.now()
431
- }
432
-
433
- /**
434
- * Handle SSE connection end - check duration and manage fallback state.
435
- * Returns a delay to wait before reconnecting, or null if should not reconnect.
436
- */
437
- async #handleSSEConnectionEnd(): Promise<number | null> {
438
- if (this.#lastSSEConnectionStartTime === undefined) {
439
- return 0 // No tracking, allow immediate reconnect
440
- }
441
-
442
- const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime
443
- const wasAborted = this.#abortController.signal.aborted
444
-
445
- if (
446
- connectionDuration < this.#sseResilience.minConnectionDuration &&
447
- !wasAborted
448
- ) {
449
- // Connection was too short - likely proxy buffering or misconfiguration
450
- this.#consecutiveShortSSEConnections++
451
-
452
- if (
453
- this.#consecutiveShortSSEConnections >=
454
- this.#sseResilience.maxShortConnections
455
- ) {
456
- // Too many short connections - fall back to long polling
457
- this.#sseFallbackToLongPoll = true
458
-
459
- if (this.#sseResilience.logWarnings) {
460
- console.warn(
461
- `[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
462
- `Falling back to long polling. ` +
463
- `Your proxy must support streaming SSE responses (not buffer the complete response). ` +
464
- `Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.`
465
- )
466
- }
467
- return null // Signal to not reconnect SSE
468
- } else {
469
- // Add exponential backoff with full jitter to prevent tight infinite loop
470
- // Formula: random(0, min(cap, base * 2^attempt))
471
- const maxDelay = Math.min(
472
- this.#sseResilience.backoffMaxDelay,
473
- this.#sseResilience.backoffBaseDelay *
474
- Math.pow(2, this.#consecutiveShortSSEConnections)
475
- )
476
- const delayMs = Math.floor(Math.random() * maxDelay)
477
- await new Promise((resolve) => setTimeout(resolve, delayMs))
478
- return delayMs
479
- }
480
- } else if (
481
- connectionDuration >= this.#sseResilience.minConnectionDuration
482
- ) {
483
- // Connection was healthy - reset counter
484
- this.#consecutiveShortSSEConnections = 0
409
+ if (!(this.#syncState instanceof SSEState)) {
410
+ this.#syncState = new SSEState({
411
+ offset: this.#syncState.offset,
412
+ cursor: this.#syncState.cursor,
413
+ upToDate: this.#syncState.upToDate,
414
+ streamClosed: this.#syncState.streamClosed,
415
+ })
485
416
  }
486
-
487
- return 0 // Allow immediate reconnect
417
+ this.#syncState = (this.#syncState as SSEState).startConnection(Date.now())
488
418
  }
489
419
 
490
420
  /**
@@ -496,8 +426,8 @@ export class StreamResponseImpl<
496
426
  void,
497
427
  undefined
498
428
  > | null> {
499
- // Check if we should fall back to long-poll due to repeated short connections
500
- if (this.#sseFallbackToLongPoll) {
429
+ // Check if we should fall back to long-poll (state type encodes this)
430
+ if (!this.#syncState.shouldUseSse()) {
501
431
  return null // Will cause fallback to long-poll
502
432
  }
503
433
 
@@ -505,12 +435,37 @@ export class StreamResponseImpl<
505
435
  return null
506
436
  }
507
437
 
508
- // Handle short connection detection and backoff
509
- const delayOrNull = await this.#handleSSEConnectionEnd()
510
- if (delayOrNull === null) {
438
+ // Pure state transition: check connection duration, manage counters
439
+ const result = (this.#syncState as SSEState).handleConnectionEnd(
440
+ Date.now(),
441
+ this.#abortController.signal.aborted,
442
+ this.#sseResilience
443
+ )
444
+ this.#syncState = result.state
445
+
446
+ if (result.action === `fallback`) {
447
+ if (this.#sseResilience.logWarnings) {
448
+ console.warn(
449
+ `[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
450
+ `Falling back to long polling. ` +
451
+ `Your proxy must support streaming SSE responses (not buffer the complete response). ` +
452
+ `Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.`
453
+ )
454
+ }
511
455
  return null // Fallback to long-poll was triggered
512
456
  }
513
457
 
458
+ if (result.action === `reconnect`) {
459
+ // Host applies jitter/delay — state machine only returns backoffAttempt
460
+ const maxDelay = Math.min(
461
+ this.#sseResilience.backoffMaxDelay,
462
+ this.#sseResilience.backoffBaseDelay *
463
+ Math.pow(2, result.backoffAttempt)
464
+ )
465
+ const delayMs = Math.floor(Math.random() * maxDelay)
466
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
467
+ }
468
+
514
469
  // Track new connection start
515
470
  this.#markSSEConnectionStart()
516
471
 
@@ -578,8 +533,24 @@ export class StreamResponseImpl<
578
533
  return this.#processSSEDataEvent(event.data, sseEventIterator)
579
534
  }
580
535
 
581
- // Control event without preceding data - update state and continue
536
+ // Control event without preceding data - update state
582
537
  this.#updateStateFromSSEControl(event)
538
+
539
+ // If upToDate is signaled, yield an empty response so subscribers receive the signal
540
+ // This is important for empty streams and for subscribers waiting for catch-up completion
541
+ if (event.upToDate) {
542
+ const response = createSSESyntheticResponse(
543
+ ``,
544
+ event.streamNextOffset,
545
+ event.streamCursor,
546
+ true,
547
+ event.streamClosed ?? false,
548
+ this.contentType,
549
+ this.#encoding
550
+ )
551
+ return { type: `response`, response }
552
+ }
553
+
583
554
  return { type: `continue` }
584
555
  }
585
556
 
@@ -587,6 +558,9 @@ export class StreamResponseImpl<
587
558
  * Process an SSE data event by waiting for its corresponding control event.
588
559
  * In SSE protocol, control events come AFTER data events.
589
560
  * Multiple data events may arrive before a single control event - we buffer them.
561
+ *
562
+ * For base64 mode, each data event is independently base64 encoded, so we
563
+ * collect them as an array and decode each separately.
590
564
  */
591
565
  async #processSSEDataEvent(
592
566
  pendingData: string,
@@ -600,7 +574,8 @@ export class StreamResponseImpl<
600
574
  | { type: `error`; error: Error }
601
575
  > {
602
576
  // Buffer to accumulate data from multiple consecutive data events
603
- let bufferedData = pendingData
577
+ // For base64 mode, we collect as array since each event is independently encoded
578
+ const bufferedDataParts: Array<string> = [pendingData]
604
579
 
605
580
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
606
581
  while (true) {
@@ -609,11 +584,15 @@ export class StreamResponseImpl<
609
584
 
610
585
  if (controlDone) {
611
586
  // Stream ended without control event - yield buffered data with current state
612
- const response = this.#createSSESyntheticResponse(
613
- bufferedData,
587
+ const response = createSSESyntheticResponseFromParts(
588
+ bufferedDataParts,
614
589
  this.offset,
615
590
  this.cursor,
616
- this.upToDate
591
+ this.upToDate,
592
+ this.streamClosed,
593
+ this.contentType,
594
+ this.#encoding,
595
+ this.#isJsonMode
617
596
  )
618
597
 
619
598
  // Try to reconnect
@@ -636,18 +615,22 @@ export class StreamResponseImpl<
636
615
  if (controlEvent.type === `control`) {
637
616
  // Update state and create response with correct metadata
638
617
  this.#updateStateFromSSEControl(controlEvent)
639
- const response = this.#createSSESyntheticResponse(
640
- bufferedData,
618
+ const response = createSSESyntheticResponseFromParts(
619
+ bufferedDataParts,
641
620
  controlEvent.streamNextOffset,
642
621
  controlEvent.streamCursor,
643
- controlEvent.upToDate ?? false
622
+ controlEvent.upToDate ?? false,
623
+ controlEvent.streamClosed ?? false,
624
+ this.contentType,
625
+ this.#encoding,
626
+ this.#isJsonMode
644
627
  )
645
628
  return { type: `response`, response }
646
629
  }
647
630
 
648
631
  // Got another data event before control - buffer it
649
632
  // Server sends multiple data events followed by one control event
650
- bufferedData += controlEvent.data
633
+ bufferedDataParts.push(controlEvent.data)
651
634
  }
652
635
  }
653
636
 
@@ -762,8 +745,10 @@ export class StreamResponseImpl<
762
745
 
763
746
  // Long-poll mode: continue with live updates if needed
764
747
  if (this.#shouldContinueLive()) {
765
- // If paused or pause-requested, await the pause promise
766
- // This blocks pull() until resume() is called, avoiding deadlock
748
+ // Determine if we're resuming from pause local variable replaces
749
+ // the old #justResumedFromPause one-shot field. If we enter the pause
750
+ // branch and wake up without abort, we just resumed.
751
+ let resumingFromPause = false
767
752
  if (this.#state === `pause-requested` || this.#state === `paused`) {
768
753
  this.#state = `paused`
769
754
  if (this.#pausePromise) {
@@ -775,6 +760,7 @@ export class StreamResponseImpl<
775
760
  controller.close()
776
761
  return
777
762
  }
763
+ resumingFromPause = true
778
764
  }
779
765
 
780
766
  if (this.#abortController.signal.aborted) {
@@ -783,10 +769,6 @@ export class StreamResponseImpl<
783
769
  return
784
770
  }
785
771
 
786
- // Consume the single-shot resume flag (only first fetch after resume skips live param)
787
- const resumingFromPause = this.#justResumedFromPause
788
- this.#justResumedFromPause = false
789
-
790
772
  // Create a new AbortController for this request (so we can abort on pause)
791
773
  this.#requestAbortController = new AbortController()
792
774
 
@@ -1034,34 +1016,40 @@ export class StreamResponseImpl<
1034
1016
  return
1035
1017
  }
1036
1018
 
1037
- // Get next response
1038
- const { done, value: response } = await reader.read()
1039
- if (done) {
1040
- this.#markClosed()
1041
- controller.close()
1042
- return
1043
- }
1019
+ // Keep reading until we can enqueue at least one item.
1020
+ // This avoids stalling when a response contains an empty JSON array.
1021
+ let result = await reader.read()
1022
+ while (!result.done) {
1023
+ const response = result.value
1044
1024
 
1045
- // Parse JSON and flatten arrays (handle empty responses gracefully)
1046
- const text = await response.text()
1047
- const content = text.trim() || `[]` // Default to empty array if no content or whitespace
1048
- let parsed: TJson | Array<TJson>
1049
- try {
1050
- parsed = JSON.parse(content) as TJson | Array<TJson>
1051
- } catch (err) {
1052
- const preview =
1053
- content.length > 100 ? content.slice(0, 100) + `...` : content
1054
- throw new DurableStreamError(
1055
- `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1056
- `PARSE_ERROR`
1057
- )
1058
- }
1059
- pendingItems = Array.isArray(parsed) ? parsed : [parsed]
1025
+ // Parse JSON and flatten arrays (handle empty responses gracefully)
1026
+ const text = await response.text()
1027
+ const content = text.trim() || `[]` // Default to empty array if no content or whitespace
1028
+ let parsed: TJson | Array<TJson>
1029
+ try {
1030
+ parsed = JSON.parse(content) as TJson | Array<TJson>
1031
+ } catch (err) {
1032
+ const preview =
1033
+ content.length > 100 ? content.slice(0, 100) + `...` : content
1034
+ throw new DurableStreamError(
1035
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1036
+ `PARSE_ERROR`
1037
+ )
1038
+ }
1039
+ pendingItems = Array.isArray(parsed) ? parsed : [parsed]
1060
1040
 
1061
- // Enqueue first item
1062
- if (pendingItems.length > 0) {
1063
- controller.enqueue(pendingItems.shift())
1041
+ if (pendingItems.length > 0) {
1042
+ controller.enqueue(pendingItems.shift())
1043
+ return
1044
+ }
1045
+
1046
+ // Empty JSON batch; read the next response.
1047
+ result = await reader.read()
1064
1048
  }
1049
+
1050
+ this.#markClosed()
1051
+ controller.close()
1052
+ return
1065
1053
  },
1066
1054
 
1067
1055
  cancel: () => {
@@ -1114,8 +1102,13 @@ export class StreamResponseImpl<
1114
1102
 
1115
1103
  // Get metadata from Response headers (not from `this` which may be stale)
1116
1104
  const response = result.value
1117
- const { offset, cursor, upToDate } =
1118
- this.#getMetadataFromResponse(response)
1105
+ const { offset, cursor, upToDate, streamClosed } =
1106
+ getMetadataFromResponse(
1107
+ response,
1108
+ this.offset,
1109
+ this.cursor,
1110
+ this.streamClosed
1111
+ )
1119
1112
 
1120
1113
  // Get response text first (handles empty responses gracefully)
1121
1114
  const text = await response.text()
@@ -1139,6 +1132,7 @@ export class StreamResponseImpl<
1139
1132
  offset,
1140
1133
  cursor,
1141
1134
  upToDate,
1135
+ streamClosed,
1142
1136
  })
1143
1137
 
1144
1138
  result = await reader.read()
@@ -1181,8 +1175,13 @@ export class StreamResponseImpl<
1181
1175
 
1182
1176
  // Get metadata from Response headers (not from `this` which may be stale)
1183
1177
  const response = result.value
1184
- const { offset, cursor, upToDate } =
1185
- this.#getMetadataFromResponse(response)
1178
+ const { offset, cursor, upToDate, streamClosed } =
1179
+ getMetadataFromResponse(
1180
+ response,
1181
+ this.offset,
1182
+ this.cursor,
1183
+ this.streamClosed
1184
+ )
1186
1185
 
1187
1186
  const buffer = await response.arrayBuffer()
1188
1187
 
@@ -1192,6 +1191,7 @@ export class StreamResponseImpl<
1192
1191
  offset,
1193
1192
  cursor,
1194
1193
  upToDate,
1194
+ streamClosed,
1195
1195
  })
1196
1196
 
1197
1197
  result = await reader.read()
@@ -1234,8 +1234,13 @@ export class StreamResponseImpl<
1234
1234
 
1235
1235
  // Get metadata from Response headers (not from `this` which may be stale)
1236
1236
  const response = result.value
1237
- const { offset, cursor, upToDate } =
1238
- this.#getMetadataFromResponse(response)
1237
+ const { offset, cursor, upToDate, streamClosed } =
1238
+ getMetadataFromResponse(
1239
+ response,
1240
+ this.offset,
1241
+ this.cursor,
1242
+ this.streamClosed
1243
+ )
1239
1244
 
1240
1245
  const text = await response.text()
1241
1246
 
@@ -1245,6 +1250,7 @@ export class StreamResponseImpl<
1245
1250
  offset,
1246
1251
  cursor,
1247
1252
  upToDate,
1253
+ streamClosed,
1248
1254
  })
1249
1255
 
1250
1256
  result = await reader.read()
@@ -1286,3 +1292,185 @@ export class StreamResponseImpl<
1286
1292
  return this.#closed
1287
1293
  }
1288
1294
  }
1295
+
1296
+ // =================================
1297
+ // Pure helper functions
1298
+ // =================================
1299
+
1300
+ /**
1301
+ * Extract stream metadata from Response headers.
1302
+ * Falls back to the provided defaults when headers are absent.
1303
+ */
1304
+ function getMetadataFromResponse(
1305
+ response: Response,
1306
+ fallbackOffset: Offset,
1307
+ fallbackCursor: string | undefined,
1308
+ fallbackStreamClosed: boolean
1309
+ ): {
1310
+ offset: Offset
1311
+ cursor: string | undefined
1312
+ upToDate: boolean
1313
+ streamClosed: boolean
1314
+ } {
1315
+ const offset = response.headers.get(STREAM_OFFSET_HEADER)
1316
+ const cursor = response.headers.get(STREAM_CURSOR_HEADER)
1317
+ const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
1318
+ const streamClosed =
1319
+ response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
1320
+ return {
1321
+ offset: offset ?? fallbackOffset,
1322
+ cursor: cursor ?? fallbackCursor,
1323
+ upToDate,
1324
+ streamClosed: streamClosed || fallbackStreamClosed,
1325
+ }
1326
+ }
1327
+
1328
+ /**
1329
+ * Decode base64 string to Uint8Array.
1330
+ * Per protocol: concatenate data lines, remove \n and \r, then decode.
1331
+ */
1332
+ function decodeBase64(base64Str: string): Uint8Array {
1333
+ // Remove all newlines and carriage returns per protocol
1334
+ const cleaned = base64Str.replace(/[\n\r]/g, ``)
1335
+
1336
+ // Empty string is valid
1337
+ if (cleaned.length === 0) {
1338
+ return new Uint8Array(0)
1339
+ }
1340
+
1341
+ // Validate length is multiple of 4
1342
+ if (cleaned.length % 4 !== 0) {
1343
+ throw new DurableStreamError(
1344
+ `Invalid base64 data: length ${cleaned.length} is not a multiple of 4`,
1345
+ `PARSE_ERROR`
1346
+ )
1347
+ }
1348
+
1349
+ try {
1350
+ // Prefer Buffer (native C++ in Node) over atob (requires JS charCodeAt loop)
1351
+ if (typeof Buffer !== `undefined`) {
1352
+ return new Uint8Array(Buffer.from(cleaned, `base64`))
1353
+ } else {
1354
+ const binaryStr = atob(cleaned)
1355
+ const bytes = new Uint8Array(binaryStr.length)
1356
+ for (let i = 0; i < binaryStr.length; i++) {
1357
+ bytes[i] = binaryStr.charCodeAt(i)
1358
+ }
1359
+ return bytes
1360
+ }
1361
+ } catch (err) {
1362
+ throw new DurableStreamError(
1363
+ `Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`,
1364
+ `PARSE_ERROR`
1365
+ )
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * Create a synthetic Response from SSE data with proper headers.
1371
+ * Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
1372
+ */
1373
+ function createSSESyntheticResponse(
1374
+ data: string,
1375
+ offset: Offset,
1376
+ cursor: string | undefined,
1377
+ upToDate: boolean,
1378
+ streamClosed: boolean,
1379
+ contentType: string | undefined,
1380
+ encoding: `base64` | undefined
1381
+ ): Response {
1382
+ return createSSESyntheticResponseFromParts(
1383
+ [data],
1384
+ offset,
1385
+ cursor,
1386
+ upToDate,
1387
+ streamClosed,
1388
+ contentType,
1389
+ encoding
1390
+ )
1391
+ }
1392
+
1393
+ /**
1394
+ * Create a synthetic Response from multiple SSE data parts.
1395
+ * For base64 mode, each part is independently encoded, so we decode each
1396
+ * separately and concatenate the binary results.
1397
+ * For text mode, parts are simply concatenated as strings.
1398
+ */
1399
+ function createSSESyntheticResponseFromParts(
1400
+ dataParts: Array<string>,
1401
+ offset: Offset,
1402
+ cursor: string | undefined,
1403
+ upToDate: boolean,
1404
+ streamClosed: boolean,
1405
+ contentType: string | undefined,
1406
+ encoding: `base64` | undefined,
1407
+ isJsonMode?: boolean
1408
+ ): Response {
1409
+ const headers: Record<string, string> = {
1410
+ "content-type": contentType ?? `application/json`,
1411
+ [STREAM_OFFSET_HEADER]: String(offset),
1412
+ }
1413
+ if (cursor) {
1414
+ headers[STREAM_CURSOR_HEADER] = cursor
1415
+ }
1416
+ if (upToDate) {
1417
+ headers[STREAM_UP_TO_DATE_HEADER] = `true`
1418
+ }
1419
+ if (streamClosed) {
1420
+ headers[STREAM_CLOSED_HEADER] = `true`
1421
+ }
1422
+
1423
+ // Decode base64 if encoding is used
1424
+ let body: BodyInit
1425
+ if (encoding === `base64`) {
1426
+ // Each data part is independently base64 encoded, decode each separately
1427
+ const decodedParts = dataParts
1428
+ .filter((part) => part.length > 0)
1429
+ .map((part) => decodeBase64(part))
1430
+
1431
+ if (decodedParts.length === 0) {
1432
+ // No data - return empty body
1433
+ body = new ArrayBuffer(0)
1434
+ } else if (decodedParts.length === 1) {
1435
+ // Single part - use directly
1436
+ const decoded = decodedParts[0]!
1437
+ body = decoded.buffer.slice(
1438
+ decoded.byteOffset,
1439
+ decoded.byteOffset + decoded.byteLength
1440
+ ) as ArrayBuffer
1441
+ } else {
1442
+ // Multiple parts - concatenate binary data
1443
+ const totalLength = decodedParts.reduce(
1444
+ (sum, part) => sum + part.length,
1445
+ 0
1446
+ )
1447
+ const combined = new Uint8Array(totalLength)
1448
+ let offset = 0
1449
+ for (const part of decodedParts) {
1450
+ combined.set(part, offset)
1451
+ offset += part.length
1452
+ }
1453
+ body = combined.buffer
1454
+ }
1455
+ } else if (isJsonMode) {
1456
+ const mergedParts: Array<string> = []
1457
+ for (const part of dataParts) {
1458
+ const trimmed = part.trim()
1459
+ if (trimmed.length === 0) continue
1460
+
1461
+ if (trimmed.startsWith(`[`) && trimmed.endsWith(`]`)) {
1462
+ const inner = trimmed.slice(1, -1).trim()
1463
+ if (inner.length > 0) {
1464
+ mergedParts.push(inner)
1465
+ }
1466
+ } else {
1467
+ mergedParts.push(trimmed)
1468
+ }
1469
+ }
1470
+ body = `[${mergedParts.join(`,`)}]`
1471
+ } else {
1472
+ body = dataParts.join(``)
1473
+ }
1474
+
1475
+ return new Response(body, { status: 200, headers })
1476
+ }