@durable-streams/client 0.2.1 → 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
@@ -14,8 +14,10 @@ import {
14
14
  } from "./constants"
15
15
  import { DurableStreamError } from "./error"
16
16
  import { parseSSEStream } from "./sse"
17
+ import { LongPollState, PausedState, SSEState } from "./stream-response-state"
17
18
  import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
18
19
  import type { SSEControlEvent, SSEEvent } from "./sse"
20
+ import type { StreamResponseState } from "./stream-response-state"
19
21
  import type {
20
22
  ByteChunk,
21
23
  StreamResponse as IStreamResponse,
@@ -100,11 +102,8 @@ export class StreamResponseImpl<
100
102
  #ok: boolean
101
103
  #isLoading: boolean
102
104
 
103
- // --- Evolving state ---
104
- #offset: Offset
105
- #cursor?: string
106
- #upToDate: boolean
107
- #streamClosed: boolean
105
+ // --- Evolving state (immutable state machine) ---
106
+ #syncState: StreamResponseState
108
107
 
109
108
  // --- Internal state ---
110
109
  #isJsonMode: boolean
@@ -123,13 +122,9 @@ export class StreamResponseImpl<
123
122
  #unsubscribeFromVisibilityChanges?: () => void
124
123
  #pausePromise?: Promise<void>
125
124
  #pauseResolve?: () => void
126
- #justResumedFromPause = false
127
125
 
128
- // --- SSE Resilience State ---
126
+ // --- SSE Resilience Config ---
129
127
  #sseResilience: Required<SSEResilienceOptions>
130
- #lastSSEConnectionStartTime?: number
131
- #consecutiveShortSSEConnections = 0
132
- #sseFallbackToLongPoll = false
133
128
 
134
129
  // --- SSE Encoding State ---
135
130
  #encoding?: `base64`
@@ -142,10 +137,18 @@ export class StreamResponseImpl<
142
137
  this.contentType = config.contentType
143
138
  this.live = config.live
144
139
  this.startOffset = config.startOffset
145
- this.#offset = config.initialOffset
146
- this.#cursor = config.initialCursor
147
- this.#upToDate = config.initialUpToDate
148
- this.#streamClosed = config.initialStreamClosed
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)
149
152
 
150
153
  // Initialize response metadata from first response
151
154
  this.#headers = config.firstResponse.headers
@@ -246,6 +249,8 @@ export class StreamResponseImpl<
246
249
  #pause(): void {
247
250
  if (this.#state === `active`) {
248
251
  this.#state = `pause-requested`
252
+ // Wrap state in PausedState to preserve it across pause/resume
253
+ this.#syncState = this.#syncState.pause()
249
254
  // Create promise that pull() will await
250
255
  this.#pausePromise = new Promise((resolve) => {
251
256
  this.#pauseResolve = resolve
@@ -266,9 +271,13 @@ export class StreamResponseImpl<
266
271
  return
267
272
  }
268
273
 
274
+ // Unwrap PausedState to restore the inner state
275
+ if (this.#syncState instanceof PausedState) {
276
+ this.#syncState = this.#syncState.resume().state
277
+ }
278
+
269
279
  // Transition to active and resolve the pause promise
270
280
  this.#state = `active`
271
- this.#justResumedFromPause = true // Flag for single-shot skip of live param
272
281
  this.#pauseResolve?.()
273
282
  this.#pausePromise = undefined
274
283
  this.#pauseResolve = undefined
@@ -297,22 +306,22 @@ export class StreamResponseImpl<
297
306
  return this.#isLoading
298
307
  }
299
308
 
300
- // --- Evolving state getters ---
309
+ // --- Evolving state getters (delegated to state machine) ---
301
310
 
302
311
  get offset(): Offset {
303
- return this.#offset
312
+ return this.#syncState.offset
304
313
  }
305
314
 
306
315
  get cursor(): string | undefined {
307
- return this.#cursor
316
+ return this.#syncState.cursor
308
317
  }
309
318
 
310
319
  get upToDate(): boolean {
311
- return this.#upToDate
320
+ return this.#syncState.upToDate
312
321
  }
313
322
 
314
323
  get streamClosed(): boolean {
315
- return this.#streamClosed
324
+ return this.#syncState.streamClosed
316
325
  }
317
326
 
318
327
  // =================================
@@ -358,29 +367,24 @@ export class StreamResponseImpl<
358
367
  * and whether we've received upToDate or streamClosed.
359
368
  */
360
369
  #shouldContinueLive(): boolean {
361
- // Stop if we've received upToDate and a consumption method wants to stop after upToDate
362
- if (this.#stopAfterUpToDate && this.upToDate) return false
363
- // Stop if live mode is explicitly disabled
364
- if (this.live === false) return false
365
- // Stop if stream is closed (EOF) - no more data will ever be appended
366
- if (this.#streamClosed) return false
367
- return true
370
+ return this.#syncState.shouldContinueLive(
371
+ this.#stopAfterUpToDate,
372
+ this.live
373
+ )
368
374
  }
369
375
 
370
376
  /**
371
377
  * Update state from response headers.
372
378
  */
373
379
  #updateStateFromResponse(response: Response): void {
374
- // Update stream-specific state
375
- const offset = response.headers.get(STREAM_OFFSET_HEADER)
376
- if (offset) this.#offset = offset
377
- const cursor = response.headers.get(STREAM_CURSOR_HEADER)
378
- if (cursor) this.#cursor = cursor
379
- this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
380
- const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER)
381
- if (streamClosedHeader?.toLowerCase() === `true`) {
382
- this.#streamClosed = true
383
- }
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
+ })
384
388
 
385
389
  // Update response metadata to reflect latest server response
386
390
  this.#headers = response.headers
@@ -389,238 +393,28 @@ export class StreamResponseImpl<
389
393
  this.#ok = response.ok
390
394
  }
391
395
 
392
- /**
393
- * Extract stream metadata from Response headers.
394
- * Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
395
- * specific Response, rather than reading from `this` which may be stale due to
396
- * ReadableStream prefetching or timing issues.
397
- */
398
- #getMetadataFromResponse(response: Response): {
399
- offset: Offset
400
- cursor: string | undefined
401
- upToDate: boolean
402
- streamClosed: boolean
403
- } {
404
- const offset = response.headers.get(STREAM_OFFSET_HEADER)
405
- const cursor = response.headers.get(STREAM_CURSOR_HEADER)
406
- const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
407
- const streamClosed =
408
- response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
409
- return {
410
- offset: offset ?? this.offset, // Fall back to instance state if no header
411
- cursor: cursor ?? this.cursor,
412
- upToDate,
413
- streamClosed: streamClosed || this.streamClosed, // Once closed, always closed
414
- }
415
- }
416
-
417
- /**
418
- * Decode base64 string to Uint8Array.
419
- * Per protocol: concatenate data lines, remove \n and \r, then decode.
420
- */
421
- #decodeBase64(base64Str: string): Uint8Array {
422
- // Remove all newlines and carriage returns per protocol
423
- const cleaned = base64Str.replace(/[\n\r]/g, ``)
424
-
425
- // Empty string is valid
426
- if (cleaned.length === 0) {
427
- return new Uint8Array(0)
428
- }
429
-
430
- // Validate length is multiple of 4
431
- if (cleaned.length % 4 !== 0) {
432
- throw new DurableStreamError(
433
- `Invalid base64 data: length ${cleaned.length} is not a multiple of 4`,
434
- `PARSE_ERROR`
435
- )
436
- }
437
-
438
- try {
439
- // Prefer Buffer (native C++ in Node) over atob (requires JS charCodeAt loop)
440
- if (typeof Buffer !== `undefined`) {
441
- return new Uint8Array(Buffer.from(cleaned, `base64`))
442
- } else {
443
- const binaryStr = atob(cleaned)
444
- const bytes = new Uint8Array(binaryStr.length)
445
- for (let i = 0; i < binaryStr.length; i++) {
446
- bytes[i] = binaryStr.charCodeAt(i)
447
- }
448
- return bytes
449
- }
450
- } catch (err) {
451
- throw new DurableStreamError(
452
- `Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`,
453
- `PARSE_ERROR`
454
- )
455
- }
456
- }
457
-
458
- /**
459
- * Create a synthetic Response from SSE data with proper headers.
460
- * Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
461
- */
462
- #createSSESyntheticResponse(
463
- data: string,
464
- offset: Offset,
465
- cursor: string | undefined,
466
- upToDate: boolean,
467
- streamClosed: boolean
468
- ): Response {
469
- return this.#createSSESyntheticResponseFromParts(
470
- [data],
471
- offset,
472
- cursor,
473
- upToDate,
474
- streamClosed
475
- )
476
- }
477
-
478
- /**
479
- * Create a synthetic Response from multiple SSE data parts.
480
- * For base64 mode, each part is independently encoded, so we decode each
481
- * separately and concatenate the binary results.
482
- * For text mode, parts are simply concatenated as strings.
483
- */
484
- #createSSESyntheticResponseFromParts(
485
- dataParts: Array<string>,
486
- offset: Offset,
487
- cursor: string | undefined,
488
- upToDate: boolean,
489
- streamClosed: boolean
490
- ): Response {
491
- const headers: Record<string, string> = {
492
- "content-type": this.contentType ?? `application/json`,
493
- [STREAM_OFFSET_HEADER]: String(offset),
494
- }
495
- if (cursor) {
496
- headers[STREAM_CURSOR_HEADER] = cursor
497
- }
498
- if (upToDate) {
499
- headers[STREAM_UP_TO_DATE_HEADER] = `true`
500
- }
501
- if (streamClosed) {
502
- headers[STREAM_CLOSED_HEADER] = `true`
503
- }
504
-
505
- // Decode base64 if encoding is used
506
- let body: BodyInit
507
- if (this.#encoding === `base64`) {
508
- // Each data part is independently base64 encoded, decode each separately
509
- const decodedParts = dataParts
510
- .filter((part) => part.length > 0)
511
- .map((part) => this.#decodeBase64(part))
512
-
513
- if (decodedParts.length === 0) {
514
- // No data - return empty body
515
- body = new ArrayBuffer(0)
516
- } else if (decodedParts.length === 1) {
517
- // Single part - use directly
518
- const decoded = decodedParts[0]!
519
- body = decoded.buffer.slice(
520
- decoded.byteOffset,
521
- decoded.byteOffset + decoded.byteLength
522
- ) as ArrayBuffer
523
- } else {
524
- // Multiple parts - concatenate binary data
525
- const totalLength = decodedParts.reduce(
526
- (sum, part) => sum + part.length,
527
- 0
528
- )
529
- const combined = new Uint8Array(totalLength)
530
- let offset = 0
531
- for (const part of decodedParts) {
532
- combined.set(part, offset)
533
- offset += part.length
534
- }
535
- body = combined.buffer
536
- }
537
- } else {
538
- body = dataParts.join(``)
539
- }
540
-
541
- return new Response(body, { status: 200, headers })
542
- }
543
-
544
396
  /**
545
397
  * Update instance state from an SSE control event.
546
398
  */
547
399
  #updateStateFromSSEControl(controlEvent: SSEControlEvent): void {
548
- this.#offset = controlEvent.streamNextOffset
549
- if (controlEvent.streamCursor) {
550
- this.#cursor = controlEvent.streamCursor
551
- }
552
- if (controlEvent.upToDate !== undefined) {
553
- this.#upToDate = controlEvent.upToDate
554
- }
555
- if (controlEvent.streamClosed) {
556
- this.#streamClosed = true
557
- // A closed stream is definitionally up-to-date - no more data will ever be appended
558
- this.#upToDate = true
559
- }
400
+ this.#syncState = this.#syncState.withSSEControl(controlEvent)
560
401
  }
561
402
 
562
403
  /**
563
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.
564
407
  */
565
408
  #markSSEConnectionStart(): void {
566
- this.#lastSSEConnectionStartTime = Date.now()
567
- }
568
-
569
- /**
570
- * Handle SSE connection end - check duration and manage fallback state.
571
- * Returns a delay to wait before reconnecting, or null if should not reconnect.
572
- */
573
- async #handleSSEConnectionEnd(): Promise<number | null> {
574
- if (this.#lastSSEConnectionStartTime === undefined) {
575
- return 0 // No tracking, allow immediate reconnect
576
- }
577
-
578
- const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime
579
- const wasAborted = this.#abortController.signal.aborted
580
-
581
- if (
582
- connectionDuration < this.#sseResilience.minConnectionDuration &&
583
- !wasAborted
584
- ) {
585
- // Connection was too short - likely proxy buffering or misconfiguration
586
- this.#consecutiveShortSSEConnections++
587
-
588
- if (
589
- this.#consecutiveShortSSEConnections >=
590
- this.#sseResilience.maxShortConnections
591
- ) {
592
- // Too many short connections - fall back to long polling
593
- this.#sseFallbackToLongPoll = true
594
-
595
- if (this.#sseResilience.logWarnings) {
596
- console.warn(
597
- `[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
598
- `Falling back to long polling. ` +
599
- `Your proxy must support streaming SSE responses (not buffer the complete response). ` +
600
- `Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.`
601
- )
602
- }
603
- return null // Signal to not reconnect SSE
604
- } else {
605
- // Add exponential backoff with full jitter to prevent tight infinite loop
606
- // Formula: random(0, min(cap, base * 2^attempt))
607
- const maxDelay = Math.min(
608
- this.#sseResilience.backoffMaxDelay,
609
- this.#sseResilience.backoffBaseDelay *
610
- Math.pow(2, this.#consecutiveShortSSEConnections)
611
- )
612
- const delayMs = Math.floor(Math.random() * maxDelay)
613
- await new Promise((resolve) => setTimeout(resolve, delayMs))
614
- return delayMs
615
- }
616
- } else if (
617
- connectionDuration >= this.#sseResilience.minConnectionDuration
618
- ) {
619
- // Connection was healthy - reset counter
620
- 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
+ })
621
416
  }
622
-
623
- return 0 // Allow immediate reconnect
417
+ this.#syncState = (this.#syncState as SSEState).startConnection(Date.now())
624
418
  }
625
419
 
626
420
  /**
@@ -632,8 +426,8 @@ export class StreamResponseImpl<
632
426
  void,
633
427
  undefined
634
428
  > | null> {
635
- // Check if we should fall back to long-poll due to repeated short connections
636
- if (this.#sseFallbackToLongPoll) {
429
+ // Check if we should fall back to long-poll (state type encodes this)
430
+ if (!this.#syncState.shouldUseSse()) {
637
431
  return null // Will cause fallback to long-poll
638
432
  }
639
433
 
@@ -641,12 +435,37 @@ export class StreamResponseImpl<
641
435
  return null
642
436
  }
643
437
 
644
- // Handle short connection detection and backoff
645
- const delayOrNull = await this.#handleSSEConnectionEnd()
646
- 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
+ }
647
455
  return null // Fallback to long-poll was triggered
648
456
  }
649
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
+
650
469
  // Track new connection start
651
470
  this.#markSSEConnectionStart()
652
471
 
@@ -720,12 +539,14 @@ export class StreamResponseImpl<
720
539
  // If upToDate is signaled, yield an empty response so subscribers receive the signal
721
540
  // This is important for empty streams and for subscribers waiting for catch-up completion
722
541
  if (event.upToDate) {
723
- const response = this.#createSSESyntheticResponse(
542
+ const response = createSSESyntheticResponse(
724
543
  ``,
725
544
  event.streamNextOffset,
726
545
  event.streamCursor,
727
546
  true,
728
- event.streamClosed ?? false
547
+ event.streamClosed ?? false,
548
+ this.contentType,
549
+ this.#encoding
729
550
  )
730
551
  return { type: `response`, response }
731
552
  }
@@ -763,12 +584,15 @@ export class StreamResponseImpl<
763
584
 
764
585
  if (controlDone) {
765
586
  // Stream ended without control event - yield buffered data with current state
766
- const response = this.#createSSESyntheticResponseFromParts(
587
+ const response = createSSESyntheticResponseFromParts(
767
588
  bufferedDataParts,
768
589
  this.offset,
769
590
  this.cursor,
770
591
  this.upToDate,
771
- this.streamClosed
592
+ this.streamClosed,
593
+ this.contentType,
594
+ this.#encoding,
595
+ this.#isJsonMode
772
596
  )
773
597
 
774
598
  // Try to reconnect
@@ -791,12 +615,15 @@ export class StreamResponseImpl<
791
615
  if (controlEvent.type === `control`) {
792
616
  // Update state and create response with correct metadata
793
617
  this.#updateStateFromSSEControl(controlEvent)
794
- const response = this.#createSSESyntheticResponseFromParts(
618
+ const response = createSSESyntheticResponseFromParts(
795
619
  bufferedDataParts,
796
620
  controlEvent.streamNextOffset,
797
621
  controlEvent.streamCursor,
798
622
  controlEvent.upToDate ?? false,
799
- controlEvent.streamClosed ?? false
623
+ controlEvent.streamClosed ?? false,
624
+ this.contentType,
625
+ this.#encoding,
626
+ this.#isJsonMode
800
627
  )
801
628
  return { type: `response`, response }
802
629
  }
@@ -918,8 +745,10 @@ export class StreamResponseImpl<
918
745
 
919
746
  // Long-poll mode: continue with live updates if needed
920
747
  if (this.#shouldContinueLive()) {
921
- // If paused or pause-requested, await the pause promise
922
- // 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
923
752
  if (this.#state === `pause-requested` || this.#state === `paused`) {
924
753
  this.#state = `paused`
925
754
  if (this.#pausePromise) {
@@ -931,6 +760,7 @@ export class StreamResponseImpl<
931
760
  controller.close()
932
761
  return
933
762
  }
763
+ resumingFromPause = true
934
764
  }
935
765
 
936
766
  if (this.#abortController.signal.aborted) {
@@ -939,10 +769,6 @@ export class StreamResponseImpl<
939
769
  return
940
770
  }
941
771
 
942
- // Consume the single-shot resume flag (only first fetch after resume skips live param)
943
- const resumingFromPause = this.#justResumedFromPause
944
- this.#justResumedFromPause = false
945
-
946
772
  // Create a new AbortController for this request (so we can abort on pause)
947
773
  this.#requestAbortController = new AbortController()
948
774
 
@@ -1190,34 +1016,40 @@ export class StreamResponseImpl<
1190
1016
  return
1191
1017
  }
1192
1018
 
1193
- // Get next response
1194
- const { done, value: response } = await reader.read()
1195
- if (done) {
1196
- this.#markClosed()
1197
- controller.close()
1198
- return
1199
- }
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
1200
1024
 
1201
- // Parse JSON and flatten arrays (handle empty responses gracefully)
1202
- const text = await response.text()
1203
- const content = text.trim() || `[]` // Default to empty array if no content or whitespace
1204
- let parsed: TJson | Array<TJson>
1205
- try {
1206
- parsed = JSON.parse(content) as TJson | Array<TJson>
1207
- } catch (err) {
1208
- const preview =
1209
- content.length > 100 ? content.slice(0, 100) + `...` : content
1210
- throw new DurableStreamError(
1211
- `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1212
- `PARSE_ERROR`
1213
- )
1214
- }
1215
- 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]
1216
1040
 
1217
- // Enqueue first item
1218
- if (pendingItems.length > 0) {
1219
- 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()
1220
1048
  }
1049
+
1050
+ this.#markClosed()
1051
+ controller.close()
1052
+ return
1221
1053
  },
1222
1054
 
1223
1055
  cancel: () => {
@@ -1271,7 +1103,12 @@ export class StreamResponseImpl<
1271
1103
  // Get metadata from Response headers (not from `this` which may be stale)
1272
1104
  const response = result.value
1273
1105
  const { offset, cursor, upToDate, streamClosed } =
1274
- this.#getMetadataFromResponse(response)
1106
+ getMetadataFromResponse(
1107
+ response,
1108
+ this.offset,
1109
+ this.cursor,
1110
+ this.streamClosed
1111
+ )
1275
1112
 
1276
1113
  // Get response text first (handles empty responses gracefully)
1277
1114
  const text = await response.text()
@@ -1339,7 +1176,12 @@ export class StreamResponseImpl<
1339
1176
  // Get metadata from Response headers (not from `this` which may be stale)
1340
1177
  const response = result.value
1341
1178
  const { offset, cursor, upToDate, streamClosed } =
1342
- this.#getMetadataFromResponse(response)
1179
+ getMetadataFromResponse(
1180
+ response,
1181
+ this.offset,
1182
+ this.cursor,
1183
+ this.streamClosed
1184
+ )
1343
1185
 
1344
1186
  const buffer = await response.arrayBuffer()
1345
1187
 
@@ -1393,7 +1235,12 @@ export class StreamResponseImpl<
1393
1235
  // Get metadata from Response headers (not from `this` which may be stale)
1394
1236
  const response = result.value
1395
1237
  const { offset, cursor, upToDate, streamClosed } =
1396
- this.#getMetadataFromResponse(response)
1238
+ getMetadataFromResponse(
1239
+ response,
1240
+ this.offset,
1241
+ this.cursor,
1242
+ this.streamClosed
1243
+ )
1397
1244
 
1398
1245
  const text = await response.text()
1399
1246
 
@@ -1445,3 +1292,185 @@ export class StreamResponseImpl<
1445
1292
  return this.#closed
1446
1293
  }
1447
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
+ }