@durable-streams/client 0.2.0 → 0.2.1

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/constants.ts CHANGED
@@ -26,6 +26,12 @@ export const STREAM_CURSOR_HEADER = `Stream-Cursor`
26
26
  */
27
27
  export const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`
28
28
 
29
+ /**
30
+ * Response/request header indicating stream is closed (EOF).
31
+ * When present with value "true", the stream is permanently closed.
32
+ */
33
+ export const STREAM_CLOSED_HEADER = `Stream-Closed`
34
+
29
35
  // ============================================================================
30
36
  // Request Headers
31
37
  // ============================================================================
@@ -97,6 +103,11 @@ export const LIVE_QUERY_PARAM = `live`
97
103
  */
98
104
  export const CURSOR_QUERY_PARAM = `cursor`
99
105
 
106
+ /**
107
+ * Response header indicating SSE data encoding (e.g., base64 for binary streams).
108
+ */
109
+ export const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`
110
+
100
111
  // ============================================================================
101
112
  // SSE Control Event Fields (camelCase per PROTOCOL.md Section 5.7)
102
113
  // ============================================================================
@@ -113,13 +124,19 @@ export const SSE_OFFSET_FIELD = `streamNextOffset`
113
124
  */
114
125
  export const SSE_CURSOR_FIELD = `streamCursor`
115
126
 
127
+ /**
128
+ * SSE control event field for stream closed state.
129
+ * Note: Different from HTTP header name (camelCase vs Header-Case).
130
+ */
131
+ export const SSE_CLOSED_FIELD = `streamClosed`
132
+
116
133
  // ============================================================================
117
134
  // Internal Constants
118
135
  // ============================================================================
119
136
 
120
137
  /**
121
- * Content types that support SSE mode.
122
- * SSE is only valid for text/* or application/json streams.
138
+ * Content types that are natively compatible with SSE (UTF-8 text).
139
+ * Binary content types are also supported via automatic base64 encoding.
123
140
  */
124
141
  export const SSE_COMPATIBLE_CONTENT_TYPES: ReadonlyArray<string> = [
125
142
  `text/`,
package/src/error.ts CHANGED
@@ -178,6 +178,26 @@ export class MissingStreamUrlError extends Error {
178
178
  }
179
179
  }
180
180
 
181
+ /**
182
+ * Error thrown when attempting to append to a closed stream.
183
+ */
184
+ export class StreamClosedError extends DurableStreamError {
185
+ readonly code = `STREAM_CLOSED` as const
186
+ readonly status = 409
187
+ readonly streamClosed = true
188
+
189
+ /**
190
+ * The final offset of the stream, if available from the response.
191
+ */
192
+ readonly finalOffset?: string
193
+
194
+ constructor(url?: string, finalOffset?: string) {
195
+ super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url)
196
+ this.name = `StreamClosedError`
197
+ this.finalOffset = finalOffset
198
+ }
199
+ }
200
+
181
201
  /**
182
202
  * Error thrown when signal option is invalid.
183
203
  */
@@ -17,11 +17,12 @@ import {
17
17
  PRODUCER_ID_HEADER,
18
18
  PRODUCER_RECEIVED_SEQ_HEADER,
19
19
  PRODUCER_SEQ_HEADER,
20
+ STREAM_CLOSED_HEADER,
20
21
  STREAM_OFFSET_HEADER,
21
22
  } from "./constants"
22
23
  import type { queueAsPromised } from "fastq"
23
24
  import type { DurableStream } from "./stream"
24
- import type { IdempotentProducerOptions, Offset } from "./types"
25
+ import type { CloseResult, IdempotentProducerOptions, Offset } from "./types"
25
26
 
26
27
  /**
27
28
  * Error thrown when a producer's epoch is stale (zombie fencing).
@@ -138,6 +139,8 @@ export class IdempotentProducer {
138
139
  readonly #queue: queueAsPromised<BatchTask>
139
140
  readonly #maxInFlight: number
140
141
  #closed = false
142
+ #closeResult: CloseResult | null = null
143
+ #pendingFinalMessage?: Uint8Array | string
141
144
 
142
145
  // When autoClaim is true, we must wait for the first batch to complete
143
146
  // before allowing pipelining (to know what epoch was claimed)
@@ -318,11 +321,17 @@ export class IdempotentProducer {
318
321
  }
319
322
 
320
323
  /**
321
- * Flush pending messages and close the producer.
324
+ * Stop the producer without closing the underlying stream.
322
325
  *
323
- * After calling close(), further append() calls will throw.
326
+ * Use this when you want to:
327
+ * - Hand off writing to another producer
328
+ * - Keep the stream open for future writes
329
+ * - Stop this producer but not signal EOF to readers
330
+ *
331
+ * Flushes any pending messages before detaching.
332
+ * After calling detach(), further append() calls will throw.
324
333
  */
325
- async close(): Promise<void> {
334
+ async detach(): Promise<void> {
326
335
  if (this.#closed) return
327
336
 
328
337
  this.#closed = true
@@ -330,8 +339,138 @@ export class IdempotentProducer {
330
339
  try {
331
340
  await this.flush()
332
341
  } catch {
333
- // Ignore errors during close
342
+ // Ignore errors during detach
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Flush pending messages and close the underlying stream (EOF).
348
+ *
349
+ * This is the typical way to end a producer session. It:
350
+ * 1. Flushes all pending messages
351
+ * 2. Optionally appends a final message
352
+ * 3. Closes the stream (no further appends permitted)
353
+ *
354
+ * **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
355
+ * idempotent even with a final message because it uses producer headers
356
+ * for deduplication. Safe to retry on network failures.
357
+ *
358
+ * @param finalMessage - Optional final message to append atomically with close
359
+ * @returns CloseResult with the final offset
360
+ */
361
+ async close(finalMessage?: Uint8Array | string): Promise<CloseResult> {
362
+ if (this.#closed) {
363
+ // Already closed - return cached result for idempotency
364
+ if (this.#closeResult) {
365
+ return this.#closeResult
366
+ }
367
+ // Retry path: flush() threw on a previous attempt, so we need to re-run
368
+ // the entire close sequence with the stored finalMessage
369
+ await this.flush()
370
+ const result = await this.#doClose(this.#pendingFinalMessage)
371
+ this.#closeResult = result
372
+ return result
373
+ }
374
+
375
+ this.#closed = true
376
+
377
+ // Store finalMessage for retry safety (if flush() throws, we can retry)
378
+ this.#pendingFinalMessage = finalMessage
379
+
380
+ // Flush pending messages first
381
+ await this.flush()
382
+
383
+ // Close the stream with optional final message
384
+ const result = await this.#doClose(finalMessage)
385
+ this.#closeResult = result
386
+ return result
387
+ }
388
+
389
+ /**
390
+ * Actually close the stream with optional final message.
391
+ * Uses producer headers for idempotency.
392
+ */
393
+ async #doClose(finalMessage?: Uint8Array | string): Promise<CloseResult> {
394
+ const contentType = this.#stream.contentType ?? `application/octet-stream`
395
+ const isJson = normalizeContentType(contentType) === `application/json`
396
+
397
+ // Build body if final message is provided
398
+ let body: BodyInit | undefined
399
+ if (finalMessage !== undefined) {
400
+ const bodyBytes =
401
+ typeof finalMessage === `string`
402
+ ? new TextEncoder().encode(finalMessage)
403
+ : finalMessage
404
+
405
+ if (isJson) {
406
+ // For JSON mode, wrap in array
407
+ const jsonStr = new TextDecoder().decode(bodyBytes)
408
+ body = `[${jsonStr}]`
409
+ } else {
410
+ body = bodyBytes as unknown as BodyInit
411
+ }
412
+ }
413
+
414
+ // Capture the sequence number for this request (for retry safety)
415
+ // We only increment #nextSeq after a successful response
416
+ const seqForThisRequest = this.#nextSeq
417
+
418
+ // Build headers with producer info and Stream-Closed
419
+ const headers: Record<string, string> = {
420
+ "content-type": contentType,
421
+ [PRODUCER_ID_HEADER]: this.#producerId,
422
+ [PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
423
+ [PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
424
+ [STREAM_CLOSED_HEADER]: `true`,
425
+ }
426
+
427
+ const response = await this.#fetchClient(this.#stream.url, {
428
+ method: `POST`,
429
+ headers,
430
+ body,
431
+ signal: this.#signal,
432
+ })
433
+
434
+ // Handle 204 (duplicate close - idempotent success)
435
+ if (response.status === 204) {
436
+ // Only increment seq on success (retry-safe)
437
+ this.#nextSeq = seqForThisRequest + 1
438
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
439
+ return { finalOffset }
440
+ }
441
+
442
+ // Handle success
443
+ if (response.status === 200) {
444
+ // Only increment seq on success (retry-safe)
445
+ this.#nextSeq = seqForThisRequest + 1
446
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
447
+ return { finalOffset }
334
448
  }
449
+
450
+ // Handle errors
451
+ if (response.status === 403) {
452
+ // Stale epoch
453
+ const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER)
454
+ const currentEpoch = currentEpochStr
455
+ ? parseInt(currentEpochStr, 10)
456
+ : this.#epoch
457
+
458
+ if (this.#autoClaim) {
459
+ // Auto-claim: retry with epoch+1
460
+ const newEpoch = currentEpoch + 1
461
+ this.#epoch = newEpoch
462
+ // Reset sequence for new epoch - set to 0 so the recursive call uses seq 0
463
+ // (the first operation in a new epoch should be seq 0)
464
+ this.#nextSeq = 0
465
+ return this.#doClose(finalMessage)
466
+ }
467
+
468
+ throw new StaleEpochError(currentEpoch)
469
+ }
470
+
471
+ // Other errors
472
+ const error = await FetchError.fromResponse(response, this.#stream.url)
473
+ throw error
335
474
  }
336
475
 
337
476
  /**
package/src/index.ts CHANGED
@@ -65,6 +65,10 @@ export type {
65
65
  HeadResult,
66
66
  LegacyLiveMode,
67
67
 
68
+ // Close types
69
+ CloseResult,
70
+ CloseOptions,
71
+
68
72
  // Idempotent producer types
69
73
  IdempotentProducerOptions,
70
74
  IdempotentAppendResult,
@@ -89,6 +93,7 @@ export {
89
93
  DurableStreamError,
90
94
  MissingStreamUrlError,
91
95
  InvalidSignalError,
96
+ StreamClosedError,
92
97
  } from "./error"
93
98
 
94
99
  // ============================================================================
@@ -110,6 +115,7 @@ export {
110
115
  STREAM_OFFSET_HEADER,
111
116
  STREAM_CURSOR_HEADER,
112
117
  STREAM_UP_TO_DATE_HEADER,
118
+ STREAM_CLOSED_HEADER,
113
119
  STREAM_SEQ_HEADER,
114
120
  STREAM_TTL_HEADER,
115
121
  STREAM_EXPIRES_AT_HEADER,
@@ -117,6 +123,7 @@ export {
117
123
  LIVE_QUERY_PARAM,
118
124
  CURSOR_QUERY_PARAM,
119
125
  SSE_COMPATIBLE_CONTENT_TYPES,
126
+ SSE_CLOSED_FIELD,
120
127
  DURABLE_STREAM_PROTOCOL_QUERY_PARAMS,
121
128
  // Idempotent producer headers
122
129
  PRODUCER_ID_HEADER,
package/src/response.ts CHANGED
@@ -7,6 +7,7 @@
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,
@@ -55,6 +56,8 @@ export interface StreamResponseConfig {
55
56
  initialCursor?: string
56
57
  /** Initial upToDate from first response headers */
57
58
  initialUpToDate: boolean
59
+ /** Initial streamClosed from first response headers */
60
+ initialStreamClosed: boolean
58
61
  /** The held first Response object */
59
62
  firstResponse: Response
60
63
  /** Abort controller for the session */
@@ -74,6 +77,8 @@ export interface StreamResponseConfig {
74
77
  ) => Promise<Response>
75
78
  /** SSE resilience options */
76
79
  sseResilience?: SSEResilienceOptions
80
+ /** Encoding for SSE data events */
81
+ encoding?: `base64`
77
82
  }
78
83
 
79
84
  /**
@@ -99,6 +104,7 @@ export class StreamResponseImpl<
99
104
  #offset: Offset
100
105
  #cursor?: string
101
106
  #upToDate: boolean
107
+ #streamClosed: boolean
102
108
 
103
109
  // --- Internal state ---
104
110
  #isJsonMode: boolean
@@ -125,6 +131,9 @@ export class StreamResponseImpl<
125
131
  #consecutiveShortSSEConnections = 0
126
132
  #sseFallbackToLongPoll = false
127
133
 
134
+ // --- SSE Encoding State ---
135
+ #encoding?: `base64`
136
+
128
137
  // Core primitive: a ReadableStream of Response objects
129
138
  #responseStream: ReadableStream<Response>
130
139
 
@@ -136,6 +145,7 @@ export class StreamResponseImpl<
136
145
  this.#offset = config.initialOffset
137
146
  this.#cursor = config.initialCursor
138
147
  this.#upToDate = config.initialUpToDate
148
+ this.#streamClosed = config.initialStreamClosed
139
149
 
140
150
  // Initialize response metadata from first response
141
151
  this.#headers = config.firstResponse.headers
@@ -162,6 +172,9 @@ export class StreamResponseImpl<
162
172
  logWarnings: config.sseResilience?.logWarnings ?? true,
163
173
  }
164
174
 
175
+ // Initialize SSE encoding
176
+ this.#encoding = config.encoding
177
+
165
178
  this.#closed = new Promise((resolve, reject) => {
166
179
  this.#closedResolve = resolve
167
180
  this.#closedReject = reject
@@ -298,6 +311,10 @@ export class StreamResponseImpl<
298
311
  return this.#upToDate
299
312
  }
300
313
 
314
+ get streamClosed(): boolean {
315
+ return this.#streamClosed
316
+ }
317
+
301
318
  // =================================
302
319
  // Internal helpers
303
320
  // =================================
@@ -338,13 +355,15 @@ export class StreamResponseImpl<
338
355
 
339
356
  /**
340
357
  * Determine if we should continue with live updates based on live mode
341
- * and whether we've received upToDate.
358
+ * and whether we've received upToDate or streamClosed.
342
359
  */
343
360
  #shouldContinueLive(): boolean {
344
361
  // Stop if we've received upToDate and a consumption method wants to stop after upToDate
345
362
  if (this.#stopAfterUpToDate && this.upToDate) return false
346
363
  // Stop if live mode is explicitly disabled
347
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
348
367
  return true
349
368
  }
350
369
 
@@ -358,6 +377,10 @@ export class StreamResponseImpl<
358
377
  const cursor = response.headers.get(STREAM_CURSOR_HEADER)
359
378
  if (cursor) this.#cursor = cursor
360
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
+ }
361
384
 
362
385
  // Update response metadata to reflect latest server response
363
386
  this.#headers = response.headers
@@ -368,7 +391,7 @@ export class StreamResponseImpl<
368
391
 
369
392
  /**
370
393
  * Extract stream metadata from Response headers.
371
- * Used by subscriber APIs to get the correct offset/cursor/upToDate for each
394
+ * Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
372
395
  * specific Response, rather than reading from `this` which may be stale due to
373
396
  * ReadableStream prefetching or timing issues.
374
397
  */
@@ -376,26 +399,94 @@ export class StreamResponseImpl<
376
399
  offset: Offset
377
400
  cursor: string | undefined
378
401
  upToDate: boolean
402
+ streamClosed: boolean
379
403
  } {
380
404
  const offset = response.headers.get(STREAM_OFFSET_HEADER)
381
405
  const cursor = response.headers.get(STREAM_CURSOR_HEADER)
382
406
  const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
407
+ const streamClosed =
408
+ response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
383
409
  return {
384
410
  offset: offset ?? this.offset, // Fall back to instance state if no header
385
411
  cursor: cursor ?? this.cursor,
386
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
+ )
387
455
  }
388
456
  }
389
457
 
390
458
  /**
391
459
  * Create a synthetic Response from SSE data with proper headers.
392
- * Includes offset/cursor/upToDate in headers so subscribers can read them.
460
+ * Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
393
461
  */
394
462
  #createSSESyntheticResponse(
395
463
  data: string,
396
464
  offset: Offset,
397
465
  cursor: string | undefined,
398
- upToDate: boolean
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
399
490
  ): Response {
400
491
  const headers: Record<string, string> = {
401
492
  "content-type": this.contentType ?? `application/json`,
@@ -407,7 +498,47 @@ export class StreamResponseImpl<
407
498
  if (upToDate) {
408
499
  headers[STREAM_UP_TO_DATE_HEADER] = `true`
409
500
  }
410
- return new Response(data, { status: 200, headers })
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 })
411
542
  }
412
543
 
413
544
  /**
@@ -421,6 +552,11 @@ export class StreamResponseImpl<
421
552
  if (controlEvent.upToDate !== undefined) {
422
553
  this.#upToDate = controlEvent.upToDate
423
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
+ }
424
560
  }
425
561
 
426
562
  /**
@@ -578,8 +714,22 @@ export class StreamResponseImpl<
578
714
  return this.#processSSEDataEvent(event.data, sseEventIterator)
579
715
  }
580
716
 
581
- // Control event without preceding data - update state and continue
717
+ // Control event without preceding data - update state
582
718
  this.#updateStateFromSSEControl(event)
719
+
720
+ // If upToDate is signaled, yield an empty response so subscribers receive the signal
721
+ // This is important for empty streams and for subscribers waiting for catch-up completion
722
+ if (event.upToDate) {
723
+ const response = this.#createSSESyntheticResponse(
724
+ ``,
725
+ event.streamNextOffset,
726
+ event.streamCursor,
727
+ true,
728
+ event.streamClosed ?? false
729
+ )
730
+ return { type: `response`, response }
731
+ }
732
+
583
733
  return { type: `continue` }
584
734
  }
585
735
 
@@ -587,6 +737,9 @@ export class StreamResponseImpl<
587
737
  * Process an SSE data event by waiting for its corresponding control event.
588
738
  * In SSE protocol, control events come AFTER data events.
589
739
  * Multiple data events may arrive before a single control event - we buffer them.
740
+ *
741
+ * For base64 mode, each data event is independently base64 encoded, so we
742
+ * collect them as an array and decode each separately.
590
743
  */
591
744
  async #processSSEDataEvent(
592
745
  pendingData: string,
@@ -600,7 +753,8 @@ export class StreamResponseImpl<
600
753
  | { type: `error`; error: Error }
601
754
  > {
602
755
  // Buffer to accumulate data from multiple consecutive data events
603
- let bufferedData = pendingData
756
+ // For base64 mode, we collect as array since each event is independently encoded
757
+ const bufferedDataParts: Array<string> = [pendingData]
604
758
 
605
759
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
606
760
  while (true) {
@@ -609,11 +763,12 @@ export class StreamResponseImpl<
609
763
 
610
764
  if (controlDone) {
611
765
  // Stream ended without control event - yield buffered data with current state
612
- const response = this.#createSSESyntheticResponse(
613
- bufferedData,
766
+ const response = this.#createSSESyntheticResponseFromParts(
767
+ bufferedDataParts,
614
768
  this.offset,
615
769
  this.cursor,
616
- this.upToDate
770
+ this.upToDate,
771
+ this.streamClosed
617
772
  )
618
773
 
619
774
  // Try to reconnect
@@ -636,18 +791,19 @@ export class StreamResponseImpl<
636
791
  if (controlEvent.type === `control`) {
637
792
  // Update state and create response with correct metadata
638
793
  this.#updateStateFromSSEControl(controlEvent)
639
- const response = this.#createSSESyntheticResponse(
640
- bufferedData,
794
+ const response = this.#createSSESyntheticResponseFromParts(
795
+ bufferedDataParts,
641
796
  controlEvent.streamNextOffset,
642
797
  controlEvent.streamCursor,
643
- controlEvent.upToDate ?? false
798
+ controlEvent.upToDate ?? false,
799
+ controlEvent.streamClosed ?? false
644
800
  )
645
801
  return { type: `response`, response }
646
802
  }
647
803
 
648
804
  // Got another data event before control - buffer it
649
805
  // Server sends multiple data events followed by one control event
650
- bufferedData += controlEvent.data
806
+ bufferedDataParts.push(controlEvent.data)
651
807
  }
652
808
  }
653
809
 
@@ -1114,7 +1270,7 @@ export class StreamResponseImpl<
1114
1270
 
1115
1271
  // Get metadata from Response headers (not from `this` which may be stale)
1116
1272
  const response = result.value
1117
- const { offset, cursor, upToDate } =
1273
+ const { offset, cursor, upToDate, streamClosed } =
1118
1274
  this.#getMetadataFromResponse(response)
1119
1275
 
1120
1276
  // Get response text first (handles empty responses gracefully)
@@ -1139,6 +1295,7 @@ export class StreamResponseImpl<
1139
1295
  offset,
1140
1296
  cursor,
1141
1297
  upToDate,
1298
+ streamClosed,
1142
1299
  })
1143
1300
 
1144
1301
  result = await reader.read()
@@ -1181,7 +1338,7 @@ export class StreamResponseImpl<
1181
1338
 
1182
1339
  // Get metadata from Response headers (not from `this` which may be stale)
1183
1340
  const response = result.value
1184
- const { offset, cursor, upToDate } =
1341
+ const { offset, cursor, upToDate, streamClosed } =
1185
1342
  this.#getMetadataFromResponse(response)
1186
1343
 
1187
1344
  const buffer = await response.arrayBuffer()
@@ -1192,6 +1349,7 @@ export class StreamResponseImpl<
1192
1349
  offset,
1193
1350
  cursor,
1194
1351
  upToDate,
1352
+ streamClosed,
1195
1353
  })
1196
1354
 
1197
1355
  result = await reader.read()
@@ -1234,7 +1392,7 @@ export class StreamResponseImpl<
1234
1392
 
1235
1393
  // Get metadata from Response headers (not from `this` which may be stale)
1236
1394
  const response = result.value
1237
- const { offset, cursor, upToDate } =
1395
+ const { offset, cursor, upToDate, streamClosed } =
1238
1396
  this.#getMetadataFromResponse(response)
1239
1397
 
1240
1398
  const text = await response.text()
@@ -1245,6 +1403,7 @@ export class StreamResponseImpl<
1245
1403
  offset,
1246
1404
  cursor,
1247
1405
  upToDate,
1406
+ streamClosed,
1248
1407
  })
1249
1408
 
1250
1409
  result = await reader.read()