@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/dist/index.cjs CHANGED
@@ -46,6 +46,11 @@ const STREAM_CURSOR_HEADER = `Stream-Cursor`;
46
46
  */
47
47
  const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
48
48
  /**
49
+ * Response/request header indicating stream is closed (EOF).
50
+ * When present with value "true", the stream is permanently closed.
51
+ */
52
+ const STREAM_CLOSED_HEADER = `Stream-Closed`;
53
+ /**
49
54
  * Request header for writer coordination sequence.
50
55
  * Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
51
56
  */
@@ -94,8 +99,17 @@ const LIVE_QUERY_PARAM = `live`;
94
99
  */
95
100
  const CURSOR_QUERY_PARAM = `cursor`;
96
101
  /**
97
- * Content types that support SSE mode.
98
- * SSE is only valid for text/* or application/json streams.
102
+ * Response header indicating SSE data encoding (e.g., base64 for binary streams).
103
+ */
104
+ const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`;
105
+ /**
106
+ * SSE control event field for stream closed state.
107
+ * Note: Different from HTTP header name (camelCase vs Header-Case).
108
+ */
109
+ const SSE_CLOSED_FIELD = `streamClosed`;
110
+ /**
111
+ * Content types that are natively compatible with SSE (UTF-8 text).
112
+ * Binary content types are also supported via automatic base64 encoding.
99
113
  */
100
114
  const SSE_COMPATIBLE_CONTENT_TYPES = [`text/`, `application/json`];
101
115
  /**
@@ -225,6 +239,23 @@ var MissingStreamUrlError = class extends Error {
225
239
  }
226
240
  };
227
241
  /**
242
+ * Error thrown when attempting to append to a closed stream.
243
+ */
244
+ var StreamClosedError = class extends DurableStreamError {
245
+ code = `STREAM_CLOSED`;
246
+ status = 409;
247
+ streamClosed = true;
248
+ /**
249
+ * The final offset of the stream, if available from the response.
250
+ */
251
+ finalOffset;
252
+ constructor(url, finalOffset) {
253
+ super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url);
254
+ this.name = `StreamClosedError`;
255
+ this.finalOffset = finalOffset;
256
+ }
257
+ };
258
+ /**
228
259
  * Error thrown when signal option is invalid.
229
260
  */
230
261
  var InvalidSignalError = class extends Error {
@@ -504,7 +535,8 @@ async function* parseSSEStream(stream$1, signal) {
504
535
  type: `control`,
505
536
  streamNextOffset: control.streamNextOffset,
506
537
  streamCursor: control.streamCursor,
507
- upToDate: control.upToDate
538
+ upToDate: control.upToDate,
539
+ streamClosed: control.streamClosed
508
540
  };
509
541
  } catch (err) {
510
542
  const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
@@ -512,8 +544,10 @@ async function* parseSSEStream(stream$1, signal) {
512
544
  }
513
545
  }
514
546
  currentEvent = { data: [] };
515
- } else if (line.startsWith(`event:`)) currentEvent.type = line.slice(6).trim();
516
- else if (line.startsWith(`data:`)) {
547
+ } else if (line.startsWith(`event:`)) {
548
+ const eventType = line.slice(6);
549
+ currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType;
550
+ } else if (line.startsWith(`data:`)) {
517
551
  const content = line.slice(5);
518
552
  currentEvent.data.push(content.startsWith(` `) ? content.slice(1) : content);
519
553
  }
@@ -532,7 +566,8 @@ async function* parseSSEStream(stream$1, signal) {
532
566
  type: `control`,
533
567
  streamNextOffset: control.streamNextOffset,
534
568
  streamCursor: control.streamCursor,
535
- upToDate: control.upToDate
569
+ upToDate: control.upToDate,
570
+ streamClosed: control.streamClosed
536
571
  };
537
572
  } catch (err) {
538
573
  const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
@@ -544,6 +579,207 @@ async function* parseSSEStream(stream$1, signal) {
544
579
  }
545
580
  }
546
581
 
582
+ //#endregion
583
+ //#region src/stream-response-state.ts
584
+ /**
585
+ * Abstract base class for stream response state.
586
+ * All state transitions return new immutable state objects.
587
+ */
588
+ var StreamResponseState = class {
589
+ shouldContinueLive(stopAfterUpToDate, liveMode) {
590
+ if (stopAfterUpToDate && this.upToDate) return false;
591
+ if (liveMode === false) return false;
592
+ if (this.streamClosed) return false;
593
+ return true;
594
+ }
595
+ };
596
+ /**
597
+ * State for long-poll mode. shouldUseSse() returns false.
598
+ */
599
+ var LongPollState = class LongPollState extends StreamResponseState {
600
+ offset;
601
+ cursor;
602
+ upToDate;
603
+ streamClosed;
604
+ constructor(fields) {
605
+ super();
606
+ this.offset = fields.offset;
607
+ this.cursor = fields.cursor;
608
+ this.upToDate = fields.upToDate;
609
+ this.streamClosed = fields.streamClosed;
610
+ }
611
+ shouldUseSse() {
612
+ return false;
613
+ }
614
+ withResponseMetadata(update) {
615
+ return new LongPollState({
616
+ offset: update.offset ?? this.offset,
617
+ cursor: update.cursor ?? this.cursor,
618
+ upToDate: update.upToDate,
619
+ streamClosed: this.streamClosed || update.streamClosed
620
+ });
621
+ }
622
+ withSSEControl(event) {
623
+ const streamClosed = this.streamClosed || (event.streamClosed ?? false);
624
+ return new LongPollState({
625
+ offset: event.streamNextOffset,
626
+ cursor: event.streamCursor || this.cursor,
627
+ upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
628
+ streamClosed
629
+ });
630
+ }
631
+ pause() {
632
+ return new PausedState(this);
633
+ }
634
+ };
635
+ /**
636
+ * State for SSE mode. shouldUseSse() returns true.
637
+ * Tracks SSE connection resilience (short connection detection).
638
+ */
639
+ var SSEState = class SSEState extends StreamResponseState {
640
+ offset;
641
+ cursor;
642
+ upToDate;
643
+ streamClosed;
644
+ consecutiveShortConnections;
645
+ connectionStartTime;
646
+ constructor(fields) {
647
+ super();
648
+ this.offset = fields.offset;
649
+ this.cursor = fields.cursor;
650
+ this.upToDate = fields.upToDate;
651
+ this.streamClosed = fields.streamClosed;
652
+ this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0;
653
+ this.connectionStartTime = fields.connectionStartTime;
654
+ }
655
+ shouldUseSse() {
656
+ return true;
657
+ }
658
+ withResponseMetadata(update) {
659
+ return new SSEState({
660
+ offset: update.offset ?? this.offset,
661
+ cursor: update.cursor ?? this.cursor,
662
+ upToDate: update.upToDate,
663
+ streamClosed: this.streamClosed || update.streamClosed,
664
+ consecutiveShortConnections: this.consecutiveShortConnections,
665
+ connectionStartTime: this.connectionStartTime
666
+ });
667
+ }
668
+ withSSEControl(event) {
669
+ const streamClosed = this.streamClosed || (event.streamClosed ?? false);
670
+ return new SSEState({
671
+ offset: event.streamNextOffset,
672
+ cursor: event.streamCursor || this.cursor,
673
+ upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
674
+ streamClosed,
675
+ consecutiveShortConnections: this.consecutiveShortConnections,
676
+ connectionStartTime: this.connectionStartTime
677
+ });
678
+ }
679
+ startConnection(now) {
680
+ return new SSEState({
681
+ offset: this.offset,
682
+ cursor: this.cursor,
683
+ upToDate: this.upToDate,
684
+ streamClosed: this.streamClosed,
685
+ consecutiveShortConnections: this.consecutiveShortConnections,
686
+ connectionStartTime: now
687
+ });
688
+ }
689
+ handleConnectionEnd(now, wasAborted, config) {
690
+ if (this.connectionStartTime === void 0) return {
691
+ action: `healthy`,
692
+ state: this
693
+ };
694
+ const duration = now - this.connectionStartTime;
695
+ if (duration < config.minConnectionDuration && !wasAborted) {
696
+ const newCount = this.consecutiveShortConnections + 1;
697
+ if (newCount >= config.maxShortConnections) return {
698
+ action: `fallback`,
699
+ state: new LongPollState({
700
+ offset: this.offset,
701
+ cursor: this.cursor,
702
+ upToDate: this.upToDate,
703
+ streamClosed: this.streamClosed
704
+ })
705
+ };
706
+ return {
707
+ action: `reconnect`,
708
+ state: new SSEState({
709
+ offset: this.offset,
710
+ cursor: this.cursor,
711
+ upToDate: this.upToDate,
712
+ streamClosed: this.streamClosed,
713
+ consecutiveShortConnections: newCount,
714
+ connectionStartTime: this.connectionStartTime
715
+ }),
716
+ backoffAttempt: newCount
717
+ };
718
+ }
719
+ if (duration >= config.minConnectionDuration) return {
720
+ action: `healthy`,
721
+ state: new SSEState({
722
+ offset: this.offset,
723
+ cursor: this.cursor,
724
+ upToDate: this.upToDate,
725
+ streamClosed: this.streamClosed,
726
+ consecutiveShortConnections: 0,
727
+ connectionStartTime: this.connectionStartTime
728
+ })
729
+ };
730
+ return {
731
+ action: `healthy`,
732
+ state: this
733
+ };
734
+ }
735
+ pause() {
736
+ return new PausedState(this);
737
+ }
738
+ };
739
+ /**
740
+ * Paused state wrapper. Delegates all sync field access to the inner state.
741
+ * resume() returns the wrapped state unchanged (identity preserved).
742
+ */
743
+ var PausedState = class PausedState extends StreamResponseState {
744
+ #inner;
745
+ constructor(inner) {
746
+ super();
747
+ this.#inner = inner;
748
+ }
749
+ get offset() {
750
+ return this.#inner.offset;
751
+ }
752
+ get cursor() {
753
+ return this.#inner.cursor;
754
+ }
755
+ get upToDate() {
756
+ return this.#inner.upToDate;
757
+ }
758
+ get streamClosed() {
759
+ return this.#inner.streamClosed;
760
+ }
761
+ shouldUseSse() {
762
+ return this.#inner.shouldUseSse();
763
+ }
764
+ withResponseMetadata(update) {
765
+ const newInner = this.#inner.withResponseMetadata(update);
766
+ return new PausedState(newInner);
767
+ }
768
+ withSSEControl(event) {
769
+ const newInner = this.#inner.withSSEControl(event);
770
+ return new PausedState(newInner);
771
+ }
772
+ pause() {
773
+ return this;
774
+ }
775
+ resume() {
776
+ return {
777
+ state: this.#inner,
778
+ justResumed: true
779
+ };
780
+ }
781
+ };
782
+
547
783
  //#endregion
548
784
  //#region src/response.ts
549
785
  /**
@@ -563,9 +799,7 @@ var StreamResponseImpl = class {
563
799
  #statusText;
564
800
  #ok;
565
801
  #isLoading;
566
- #offset;
567
- #cursor;
568
- #upToDate;
802
+ #syncState;
569
803
  #isJsonMode;
570
804
  #abortController;
571
805
  #fetchNext;
@@ -580,20 +814,21 @@ var StreamResponseImpl = class {
580
814
  #unsubscribeFromVisibilityChanges;
581
815
  #pausePromise;
582
816
  #pauseResolve;
583
- #justResumedFromPause = false;
584
817
  #sseResilience;
585
- #lastSSEConnectionStartTime;
586
- #consecutiveShortSSEConnections = 0;
587
- #sseFallbackToLongPoll = false;
818
+ #encoding;
588
819
  #responseStream;
589
820
  constructor(config) {
590
821
  this.url = config.url;
591
822
  this.contentType = config.contentType;
592
823
  this.live = config.live;
593
824
  this.startOffset = config.startOffset;
594
- this.#offset = config.initialOffset;
595
- this.#cursor = config.initialCursor;
596
- this.#upToDate = config.initialUpToDate;
825
+ const syncFields = {
826
+ offset: config.initialOffset,
827
+ cursor: config.initialCursor,
828
+ upToDate: config.initialUpToDate,
829
+ streamClosed: config.initialStreamClosed
830
+ };
831
+ this.#syncState = config.startSSE ? new SSEState(syncFields) : new LongPollState(syncFields);
597
832
  this.#headers = config.firstResponse.headers;
598
833
  this.#status = config.firstResponse.status;
599
834
  this.#statusText = config.firstResponse.statusText;
@@ -610,6 +845,7 @@ var StreamResponseImpl = class {
610
845
  backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
611
846
  logWarnings: config.sseResilience?.logWarnings ?? true
612
847
  };
848
+ this.#encoding = config.encoding;
613
849
  this.#closed = new Promise((resolve, reject) => {
614
850
  this.#closedResolve = resolve;
615
851
  this.#closedReject = reject;
@@ -649,6 +885,7 @@ var StreamResponseImpl = class {
649
885
  #pause() {
650
886
  if (this.#state === `active`) {
651
887
  this.#state = `pause-requested`;
888
+ this.#syncState = this.#syncState.pause();
652
889
  this.#pausePromise = new Promise((resolve) => {
653
890
  this.#pauseResolve = resolve;
654
891
  });
@@ -662,8 +899,8 @@ var StreamResponseImpl = class {
662
899
  #resume() {
663
900
  if (this.#state === `paused` || this.#state === `pause-requested`) {
664
901
  if (this.#abortController.signal.aborted) return;
902
+ if (this.#syncState instanceof PausedState) this.#syncState = this.#syncState.resume().state;
665
903
  this.#state = `active`;
666
- this.#justResumedFromPause = true;
667
904
  this.#pauseResolve?.();
668
905
  this.#pausePromise = void 0;
669
906
  this.#pauseResolve = void 0;
@@ -685,13 +922,16 @@ var StreamResponseImpl = class {
685
922
  return this.#isLoading;
686
923
  }
687
924
  get offset() {
688
- return this.#offset;
925
+ return this.#syncState.offset;
689
926
  }
690
927
  get cursor() {
691
- return this.#cursor;
928
+ return this.#syncState.cursor;
692
929
  }
693
930
  get upToDate() {
694
- return this.#upToDate;
931
+ return this.#syncState.upToDate;
932
+ }
933
+ get streamClosed() {
934
+ return this.#syncState.streamClosed;
695
935
  }
696
936
  #ensureJsonMode() {
697
937
  if (!this.#isJsonMode) throw new DurableStreamError(`JSON methods are only valid for JSON-mode streams. Content-Type is "${this.contentType}" and json hint was not set.`, `BAD_REQUEST`);
@@ -714,105 +954,64 @@ var StreamResponseImpl = class {
714
954
  }
715
955
  /**
716
956
  * Determine if we should continue with live updates based on live mode
717
- * and whether we've received upToDate.
957
+ * and whether we've received upToDate or streamClosed.
718
958
  */
719
959
  #shouldContinueLive() {
720
- if (this.#stopAfterUpToDate && this.upToDate) return false;
721
- if (this.live === false) return false;
722
- return true;
960
+ return this.#syncState.shouldContinueLive(this.#stopAfterUpToDate, this.live);
723
961
  }
724
962
  /**
725
963
  * Update state from response headers.
726
964
  */
727
965
  #updateStateFromResponse(response) {
728
- const offset = response.headers.get(STREAM_OFFSET_HEADER);
729
- if (offset) this.#offset = offset;
730
- const cursor = response.headers.get(STREAM_CURSOR_HEADER);
731
- if (cursor) this.#cursor = cursor;
732
- this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
966
+ this.#syncState = this.#syncState.withResponseMetadata({
967
+ offset: response.headers.get(STREAM_OFFSET_HEADER) || void 0,
968
+ cursor: response.headers.get(STREAM_CURSOR_HEADER) || void 0,
969
+ upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
970
+ streamClosed: response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
971
+ });
733
972
  this.#headers = response.headers;
734
973
  this.#status = response.status;
735
974
  this.#statusText = response.statusText;
736
975
  this.#ok = response.ok;
737
976
  }
738
977
  /**
739
- * Extract stream metadata from Response headers.
740
- * Used by subscriber APIs to get the correct offset/cursor/upToDate for each
741
- * specific Response, rather than reading from `this` which may be stale due to
742
- * ReadableStream prefetching or timing issues.
743
- */
744
- #getMetadataFromResponse(response) {
745
- const offset = response.headers.get(STREAM_OFFSET_HEADER);
746
- const cursor = response.headers.get(STREAM_CURSOR_HEADER);
747
- const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
748
- return {
749
- offset: offset ?? this.offset,
750
- cursor: cursor ?? this.cursor,
751
- upToDate
752
- };
753
- }
754
- /**
755
- * Create a synthetic Response from SSE data with proper headers.
756
- * Includes offset/cursor/upToDate in headers so subscribers can read them.
757
- */
758
- #createSSESyntheticResponse(data, offset, cursor, upToDate) {
759
- const headers = {
760
- "content-type": this.contentType ?? `application/json`,
761
- [STREAM_OFFSET_HEADER]: String(offset)
762
- };
763
- if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
764
- if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
765
- return new Response(data, {
766
- status: 200,
767
- headers
768
- });
769
- }
770
- /**
771
978
  * Update instance state from an SSE control event.
772
979
  */
773
980
  #updateStateFromSSEControl(controlEvent) {
774
- this.#offset = controlEvent.streamNextOffset;
775
- if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
776
- if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
981
+ this.#syncState = this.#syncState.withSSEControl(controlEvent);
777
982
  }
778
983
  /**
779
984
  * Mark the start of an SSE connection for duration tracking.
985
+ * If the state is not SSEState (e.g., auto-detected SSE from content-type),
986
+ * transitions to SSEState first.
780
987
  */
781
988
  #markSSEConnectionStart() {
782
- this.#lastSSEConnectionStartTime = Date.now();
783
- }
784
- /**
785
- * Handle SSE connection end - check duration and manage fallback state.
786
- * Returns a delay to wait before reconnecting, or null if should not reconnect.
787
- */
788
- async #handleSSEConnectionEnd() {
789
- if (this.#lastSSEConnectionStartTime === void 0) return 0;
790
- const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime;
791
- const wasAborted = this.#abortController.signal.aborted;
792
- if (connectionDuration < this.#sseResilience.minConnectionDuration && !wasAborted) {
793
- this.#consecutiveShortSSEConnections++;
794
- if (this.#consecutiveShortSSEConnections >= this.#sseResilience.maxShortConnections) {
795
- this.#sseFallbackToLongPoll = true;
796
- if (this.#sseResilience.logWarnings) console.warn("[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). Falling back to long polling. Your proxy must support streaming SSE responses (not buffer the complete response). Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.");
797
- return null;
798
- } else {
799
- const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, this.#consecutiveShortSSEConnections));
800
- const delayMs = Math.floor(Math.random() * maxDelay);
801
- await new Promise((resolve) => setTimeout(resolve, delayMs));
802
- return delayMs;
803
- }
804
- } else if (connectionDuration >= this.#sseResilience.minConnectionDuration) this.#consecutiveShortSSEConnections = 0;
805
- return 0;
989
+ if (!(this.#syncState instanceof SSEState)) this.#syncState = new SSEState({
990
+ offset: this.#syncState.offset,
991
+ cursor: this.#syncState.cursor,
992
+ upToDate: this.#syncState.upToDate,
993
+ streamClosed: this.#syncState.streamClosed
994
+ });
995
+ this.#syncState = this.#syncState.startConnection(Date.now());
806
996
  }
807
997
  /**
808
998
  * Try to reconnect SSE and return the new iterator, or null if reconnection
809
999
  * is not possible or fails.
810
1000
  */
811
1001
  async #trySSEReconnect() {
812
- if (this.#sseFallbackToLongPoll) return null;
1002
+ if (!this.#syncState.shouldUseSse()) return null;
813
1003
  if (!this.#shouldContinueLive() || !this.#startSSE) return null;
814
- const delayOrNull = await this.#handleSSEConnectionEnd();
815
- if (delayOrNull === null) return null;
1004
+ const result = this.#syncState.handleConnectionEnd(Date.now(), this.#abortController.signal.aborted, this.#sseResilience);
1005
+ this.#syncState = result.state;
1006
+ if (result.action === `fallback`) {
1007
+ if (this.#sseResilience.logWarnings) console.warn("[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). Falling back to long polling. Your proxy must support streaming SSE responses (not buffer the complete response). Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.");
1008
+ return null;
1009
+ }
1010
+ if (result.action === `reconnect`) {
1011
+ const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, result.backoffAttempt));
1012
+ const delayMs = Math.floor(Math.random() * maxDelay);
1013
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1014
+ }
816
1015
  this.#markSSEConnectionStart();
817
1016
  this.#requestAbortController = new AbortController();
818
1017
  const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
@@ -846,19 +1045,29 @@ var StreamResponseImpl = class {
846
1045
  }
847
1046
  if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
848
1047
  this.#updateStateFromSSEControl(event);
1048
+ if (event.upToDate) {
1049
+ const response = createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false, this.contentType, this.#encoding);
1050
+ return {
1051
+ type: `response`,
1052
+ response
1053
+ };
1054
+ }
849
1055
  return { type: `continue` };
850
1056
  }
851
1057
  /**
852
1058
  * Process an SSE data event by waiting for its corresponding control event.
853
1059
  * In SSE protocol, control events come AFTER data events.
854
1060
  * Multiple data events may arrive before a single control event - we buffer them.
1061
+ *
1062
+ * For base64 mode, each data event is independently base64 encoded, so we
1063
+ * collect them as an array and decode each separately.
855
1064
  */
856
1065
  async #processSSEDataEvent(pendingData, sseEventIterator) {
857
- let bufferedData = pendingData;
1066
+ const bufferedDataParts = [pendingData];
858
1067
  while (true) {
859
1068
  const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
860
1069
  if (controlDone) {
861
- const response = this.#createSSESyntheticResponse(bufferedData, this.offset, this.cursor, this.upToDate);
1070
+ const response = createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed, this.contentType, this.#encoding, this.#isJsonMode);
862
1071
  try {
863
1072
  const newIterator = await this.#trySSEReconnect();
864
1073
  return {
@@ -875,13 +1084,13 @@ var StreamResponseImpl = class {
875
1084
  }
876
1085
  if (controlEvent.type === `control`) {
877
1086
  this.#updateStateFromSSEControl(controlEvent);
878
- const response = this.#createSSESyntheticResponse(bufferedData, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false);
1087
+ const response = createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false, this.contentType, this.#encoding, this.#isJsonMode);
879
1088
  return {
880
1089
  type: `response`,
881
1090
  response
882
1091
  };
883
1092
  }
884
- bufferedData += controlEvent.data;
1093
+ bufferedDataParts.push(controlEvent.data);
885
1094
  }
886
1095
  }
887
1096
  /**
@@ -953,6 +1162,7 @@ var StreamResponseImpl = class {
953
1162
  }
954
1163
  }
955
1164
  if (this.#shouldContinueLive()) {
1165
+ let resumingFromPause = false;
956
1166
  if (this.#state === `pause-requested` || this.#state === `paused`) {
957
1167
  this.#state = `paused`;
958
1168
  if (this.#pausePromise) await this.#pausePromise;
@@ -961,14 +1171,13 @@ var StreamResponseImpl = class {
961
1171
  controller.close();
962
1172
  return;
963
1173
  }
1174
+ resumingFromPause = true;
964
1175
  }
965
1176
  if (this.#abortController.signal.aborted) {
966
1177
  this.#markClosed();
967
1178
  controller.close();
968
1179
  return;
969
1180
  }
970
- const resumingFromPause = this.#justResumedFromPause;
971
- this.#justResumedFromPause = false;
972
1181
  this.#requestAbortController = new AbortController();
973
1182
  const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
974
1183
  this.#updateStateFromResponse(response);
@@ -1134,23 +1343,28 @@ var StreamResponseImpl = class {
1134
1343
  controller.enqueue(pendingItems.shift());
1135
1344
  return;
1136
1345
  }
1137
- const { done, value: response } = await reader.read();
1138
- if (done) {
1139
- this.#markClosed();
1140
- controller.close();
1141
- return;
1142
- }
1143
- const text = await response.text();
1144
- const content = text.trim() || `[]`;
1145
- let parsed;
1146
- try {
1147
- parsed = JSON.parse(content);
1148
- } catch (err) {
1149
- const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1150
- throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1346
+ let result = await reader.read();
1347
+ while (!result.done) {
1348
+ const response = result.value;
1349
+ const text = await response.text();
1350
+ const content = text.trim() || `[]`;
1351
+ let parsed;
1352
+ try {
1353
+ parsed = JSON.parse(content);
1354
+ } catch (err) {
1355
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1356
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1357
+ }
1358
+ pendingItems = Array.isArray(parsed) ? parsed : [parsed];
1359
+ if (pendingItems.length > 0) {
1360
+ controller.enqueue(pendingItems.shift());
1361
+ return;
1362
+ }
1363
+ result = await reader.read();
1151
1364
  }
1152
- pendingItems = Array.isArray(parsed) ? parsed : [parsed];
1153
- if (pendingItems.length > 0) controller.enqueue(pendingItems.shift());
1365
+ this.#markClosed();
1366
+ controller.close();
1367
+ return;
1154
1368
  },
1155
1369
  cancel: () => {
1156
1370
  reader.releaseLock();
@@ -1184,7 +1398,7 @@ var StreamResponseImpl = class {
1184
1398
  while (!result.done) {
1185
1399
  if (abortController.signal.aborted) break;
1186
1400
  const response = result.value;
1187
- const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
1401
+ const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
1188
1402
  const text = await response.text();
1189
1403
  const content = text.trim() || `[]`;
1190
1404
  let parsed;
@@ -1199,7 +1413,8 @@ var StreamResponseImpl = class {
1199
1413
  items,
1200
1414
  offset,
1201
1415
  cursor,
1202
- upToDate
1416
+ upToDate,
1417
+ streamClosed
1203
1418
  });
1204
1419
  result = await reader.read();
1205
1420
  }
@@ -1229,13 +1444,14 @@ var StreamResponseImpl = class {
1229
1444
  while (!result.done) {
1230
1445
  if (abortController.signal.aborted) break;
1231
1446
  const response = result.value;
1232
- const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
1447
+ const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
1233
1448
  const buffer = await response.arrayBuffer();
1234
1449
  await subscriber({
1235
1450
  data: new Uint8Array(buffer),
1236
1451
  offset,
1237
1452
  cursor,
1238
- upToDate
1453
+ upToDate,
1454
+ streamClosed
1239
1455
  });
1240
1456
  result = await reader.read();
1241
1457
  }
@@ -1265,13 +1481,14 @@ var StreamResponseImpl = class {
1265
1481
  while (!result.done) {
1266
1482
  if (abortController.signal.aborted) break;
1267
1483
  const response = result.value;
1268
- const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
1484
+ const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
1269
1485
  const text = await response.text();
1270
1486
  await subscriber({
1271
1487
  text,
1272
1488
  offset,
1273
1489
  cursor,
1274
- upToDate
1490
+ upToDate,
1491
+ streamClosed
1275
1492
  });
1276
1493
  result = await reader.read();
1277
1494
  }
@@ -1300,6 +1517,97 @@ var StreamResponseImpl = class {
1300
1517
  return this.#closed;
1301
1518
  }
1302
1519
  };
1520
+ /**
1521
+ * Extract stream metadata from Response headers.
1522
+ * Falls back to the provided defaults when headers are absent.
1523
+ */
1524
+ function getMetadataFromResponse(response, fallbackOffset, fallbackCursor, fallbackStreamClosed) {
1525
+ const offset = response.headers.get(STREAM_OFFSET_HEADER);
1526
+ const cursor = response.headers.get(STREAM_CURSOR_HEADER);
1527
+ const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
1528
+ const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
1529
+ return {
1530
+ offset: offset ?? fallbackOffset,
1531
+ cursor: cursor ?? fallbackCursor,
1532
+ upToDate,
1533
+ streamClosed: streamClosed || fallbackStreamClosed
1534
+ };
1535
+ }
1536
+ /**
1537
+ * Decode base64 string to Uint8Array.
1538
+ * Per protocol: concatenate data lines, remove \n and \r, then decode.
1539
+ */
1540
+ function decodeBase64(base64Str) {
1541
+ const cleaned = base64Str.replace(/[\n\r]/g, ``);
1542
+ if (cleaned.length === 0) return new Uint8Array(0);
1543
+ if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
1544
+ try {
1545
+ if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
1546
+ else {
1547
+ const binaryStr = atob(cleaned);
1548
+ const bytes = new Uint8Array(binaryStr.length);
1549
+ for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
1550
+ return bytes;
1551
+ }
1552
+ } catch (err) {
1553
+ throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
1554
+ }
1555
+ }
1556
+ /**
1557
+ * Create a synthetic Response from SSE data with proper headers.
1558
+ * Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
1559
+ */
1560
+ function createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed, contentType, encoding) {
1561
+ return createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed, contentType, encoding);
1562
+ }
1563
+ /**
1564
+ * Create a synthetic Response from multiple SSE data parts.
1565
+ * For base64 mode, each part is independently encoded, so we decode each
1566
+ * separately and concatenate the binary results.
1567
+ * For text mode, parts are simply concatenated as strings.
1568
+ */
1569
+ function createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed, contentType, encoding, isJsonMode) {
1570
+ const headers = {
1571
+ "content-type": contentType ?? `application/json`,
1572
+ [STREAM_OFFSET_HEADER]: String(offset)
1573
+ };
1574
+ if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
1575
+ if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
1576
+ if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
1577
+ let body;
1578
+ if (encoding === `base64`) {
1579
+ const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => decodeBase64(part));
1580
+ if (decodedParts.length === 0) body = new ArrayBuffer(0);
1581
+ else if (decodedParts.length === 1) {
1582
+ const decoded = decodedParts[0];
1583
+ body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
1584
+ } else {
1585
+ const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
1586
+ const combined = new Uint8Array(totalLength);
1587
+ let offset$1 = 0;
1588
+ for (const part of decodedParts) {
1589
+ combined.set(part, offset$1);
1590
+ offset$1 += part.length;
1591
+ }
1592
+ body = combined.buffer;
1593
+ }
1594
+ } else if (isJsonMode) {
1595
+ const mergedParts = [];
1596
+ for (const part of dataParts) {
1597
+ const trimmed = part.trim();
1598
+ if (trimmed.length === 0) continue;
1599
+ if (trimmed.startsWith(`[`) && trimmed.endsWith(`]`)) {
1600
+ const inner = trimmed.slice(1, -1).trim();
1601
+ if (inner.length > 0) mergedParts.push(inner);
1602
+ } else mergedParts.push(trimmed);
1603
+ }
1604
+ body = `[${mergedParts.join(`,`)}]`;
1605
+ } else body = dataParts.join(``);
1606
+ return new Response(body, {
1607
+ status: 200,
1608
+ headers
1609
+ });
1610
+ }
1303
1611
 
1304
1612
  //#endregion
1305
1613
  //#region src/utils.ts
@@ -1322,6 +1630,11 @@ async function handleErrorResponse(response, url, context) {
1322
1630
  const status = response.status;
1323
1631
  if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
1324
1632
  if (status === 409) {
1633
+ const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
1634
+ if (streamClosedHeader?.toLowerCase() === `true`) {
1635
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
1636
+ throw new StreamClosedError(url, finalOffset);
1637
+ }
1325
1638
  const message = context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended`;
1326
1639
  const code = context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ`;
1327
1640
  throw new DurableStreamError(message, code, 409);
@@ -1508,7 +1821,10 @@ async function streamInternal(options) {
1508
1821
  const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
1509
1822
  const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
1510
1823
  const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
1824
+ const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
1511
1825
  const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
1826
+ const sseDataEncoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER);
1827
+ const encoding = sseDataEncoding === `base64` ? `base64` : void 0;
1512
1828
  const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
1513
1829
  const nextUrl = new URL(url);
1514
1830
  nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
@@ -1553,11 +1869,13 @@ async function streamInternal(options) {
1553
1869
  initialOffset,
1554
1870
  initialCursor,
1555
1871
  initialUpToDate,
1872
+ initialStreamClosed,
1556
1873
  firstResponse,
1557
1874
  abortController,
1558
1875
  fetchNext,
1559
1876
  startSSE,
1560
- sseResilience: options.sseResilience
1877
+ sseResilience: options.sseResilience,
1878
+ encoding
1561
1879
  });
1562
1880
  }
1563
1881
 
@@ -1648,6 +1966,8 @@ var IdempotentProducer = class {
1648
1966
  #queue;
1649
1967
  #maxInFlight;
1650
1968
  #closed = false;
1969
+ #closeResult = null;
1970
+ #pendingFinalMessage;
1651
1971
  #epochClaimed;
1652
1972
  #seqState = new Map();
1653
1973
  /**
@@ -1737,11 +2057,17 @@ var IdempotentProducer = class {
1737
2057
  await this.#queue.drained();
1738
2058
  }
1739
2059
  /**
1740
- * Flush pending messages and close the producer.
2060
+ * Stop the producer without closing the underlying stream.
1741
2061
  *
1742
- * After calling close(), further append() calls will throw.
2062
+ * Use this when you want to:
2063
+ * - Hand off writing to another producer
2064
+ * - Keep the stream open for future writes
2065
+ * - Stop this producer but not signal EOF to readers
2066
+ *
2067
+ * Flushes any pending messages before detaching.
2068
+ * After calling detach(), further append() calls will throw.
1743
2069
  */
1744
- async close() {
2070
+ async detach() {
1745
2071
  if (this.#closed) return;
1746
2072
  this.#closed = true;
1747
2073
  try {
@@ -1749,6 +2075,89 @@ var IdempotentProducer = class {
1749
2075
  } catch {}
1750
2076
  }
1751
2077
  /**
2078
+ * Flush pending messages and close the underlying stream (EOF).
2079
+ *
2080
+ * This is the typical way to end a producer session. It:
2081
+ * 1. Flushes all pending messages
2082
+ * 2. Optionally appends a final message
2083
+ * 3. Closes the stream (no further appends permitted)
2084
+ *
2085
+ * **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
2086
+ * idempotent even with a final message because it uses producer headers
2087
+ * for deduplication. Safe to retry on network failures.
2088
+ *
2089
+ * @param finalMessage - Optional final message to append atomically with close
2090
+ * @returns CloseResult with the final offset
2091
+ */
2092
+ async close(finalMessage) {
2093
+ if (this.#closed) {
2094
+ if (this.#closeResult) return this.#closeResult;
2095
+ await this.flush();
2096
+ const result$1 = await this.#doClose(this.#pendingFinalMessage);
2097
+ this.#closeResult = result$1;
2098
+ return result$1;
2099
+ }
2100
+ this.#closed = true;
2101
+ this.#pendingFinalMessage = finalMessage;
2102
+ await this.flush();
2103
+ const result = await this.#doClose(finalMessage);
2104
+ this.#closeResult = result;
2105
+ return result;
2106
+ }
2107
+ /**
2108
+ * Actually close the stream with optional final message.
2109
+ * Uses producer headers for idempotency.
2110
+ */
2111
+ async #doClose(finalMessage) {
2112
+ const contentType = this.#stream.contentType ?? `application/octet-stream`;
2113
+ const isJson = normalizeContentType$1(contentType) === `application/json`;
2114
+ let body;
2115
+ if (finalMessage !== void 0) {
2116
+ const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
2117
+ if (isJson) {
2118
+ const jsonStr = new TextDecoder().decode(bodyBytes);
2119
+ body = `[${jsonStr}]`;
2120
+ } else body = bodyBytes;
2121
+ }
2122
+ const seqForThisRequest = this.#nextSeq;
2123
+ const headers = {
2124
+ "content-type": contentType,
2125
+ [PRODUCER_ID_HEADER]: this.#producerId,
2126
+ [PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
2127
+ [PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
2128
+ [STREAM_CLOSED_HEADER]: `true`
2129
+ };
2130
+ const response = await this.#fetchClient(this.#stream.url, {
2131
+ method: `POST`,
2132
+ headers,
2133
+ body,
2134
+ signal: this.#signal
2135
+ });
2136
+ if (response.status === 204) {
2137
+ this.#nextSeq = seqForThisRequest + 1;
2138
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
2139
+ return { finalOffset };
2140
+ }
2141
+ if (response.status === 200) {
2142
+ this.#nextSeq = seqForThisRequest + 1;
2143
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
2144
+ return { finalOffset };
2145
+ }
2146
+ if (response.status === 403) {
2147
+ const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
2148
+ const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
2149
+ if (this.#autoClaim) {
2150
+ const newEpoch = currentEpoch + 1;
2151
+ this.#epoch = newEpoch;
2152
+ this.#nextSeq = 0;
2153
+ return this.#doClose(finalMessage);
2154
+ }
2155
+ throw new StaleEpochError(currentEpoch);
2156
+ }
2157
+ const error = await FetchError.fromResponse(response, this.#stream.url);
2158
+ throw error;
2159
+ }
2160
+ /**
1752
2161
  * Increment epoch and reset sequence.
1753
2162
  *
1754
2163
  * Call this when restarting the producer to establish a new session.
@@ -2054,7 +2463,8 @@ var DurableStream = class DurableStream {
2054
2463
  contentType: opts.contentType,
2055
2464
  ttlSeconds: opts.ttlSeconds,
2056
2465
  expiresAt: opts.expiresAt,
2057
- body: opts.body
2466
+ body: opts.body,
2467
+ closed: opts.closed
2058
2468
  });
2059
2469
  return stream$1;
2060
2470
  }
@@ -2107,13 +2517,15 @@ var DurableStream = class DurableStream {
2107
2517
  const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
2108
2518
  const etag = response.headers.get(`etag`) ?? void 0;
2109
2519
  const cacheControl = response.headers.get(`cache-control`) ?? void 0;
2520
+ const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
2110
2521
  if (contentType) this.contentType = contentType;
2111
2522
  return {
2112
2523
  exists: true,
2113
2524
  contentType,
2114
2525
  offset,
2115
2526
  etag,
2116
- cacheControl
2527
+ cacheControl,
2528
+ streamClosed
2117
2529
  };
2118
2530
  }
2119
2531
  /**
@@ -2125,6 +2537,7 @@ var DurableStream = class DurableStream {
2125
2537
  if (contentType) requestHeaders[`content-type`] = contentType;
2126
2538
  if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
2127
2539
  if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
2540
+ if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
2128
2541
  const body = encodeBody(opts?.body);
2129
2542
  const response = await this.#fetchClient(fetchUrl.toString(), {
2130
2543
  method: `PUT`,
@@ -2151,6 +2564,57 @@ var DurableStream = class DurableStream {
2151
2564
  if (!response.ok) await handleErrorResponse(response, this.url);
2152
2565
  }
2153
2566
  /**
2567
+ * Close the stream, optionally with a final message.
2568
+ *
2569
+ * After closing:
2570
+ * - No further appends are permitted (server returns 409)
2571
+ * - Readers can observe the closed state and treat it as EOF
2572
+ * - The stream's data remains fully readable
2573
+ *
2574
+ * Closing is:
2575
+ * - **Durable**: The closed state is persisted
2576
+ * - **Monotonic**: Once closed, a stream cannot be reopened
2577
+ *
2578
+ * **Idempotency:**
2579
+ * - `close()` without body: Idempotent — safe to call multiple times
2580
+ * - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
2581
+ * if stream is already closed (use `IdempotentProducer.close()` for
2582
+ * idempotent close-with-body semantics)
2583
+ *
2584
+ * @returns CloseResult with the final offset
2585
+ * @throws StreamClosedError if called with body on an already-closed stream
2586
+ */
2587
+ async close(opts) {
2588
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2589
+ const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
2590
+ if (contentType) requestHeaders[`content-type`] = contentType;
2591
+ requestHeaders[STREAM_CLOSED_HEADER] = `true`;
2592
+ let body;
2593
+ if (opts?.body !== void 0) {
2594
+ const isJson = normalizeContentType(contentType) === `application/json`;
2595
+ if (isJson) {
2596
+ const bodyStr = typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body);
2597
+ body = `[${bodyStr}]`;
2598
+ } else body = typeof opts.body === `string` ? opts.body : opts.body;
2599
+ }
2600
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2601
+ method: `POST`,
2602
+ headers: requestHeaders,
2603
+ body,
2604
+ signal: opts?.signal ?? this.#options.signal
2605
+ });
2606
+ if (response.status === 409) {
2607
+ const isClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
2608
+ if (isClosed) {
2609
+ const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
2610
+ throw new StreamClosedError(this.url, finalOffset$1);
2611
+ }
2612
+ }
2613
+ if (!response.ok) await handleErrorResponse(response, this.url);
2614
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
2615
+ return { finalOffset };
2616
+ }
2617
+ /**
2154
2618
  * Append a single payload to the stream.
2155
2619
  *
2156
2620
  * When batching is enabled (default), multiple append() calls made while
@@ -2190,8 +2654,12 @@ var DurableStream = class DurableStream {
2190
2654
  if (contentType) requestHeaders[`content-type`] = contentType;
2191
2655
  if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
2192
2656
  const isJson = normalizeContentType(contentType) === `application/json`;
2193
- const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
2194
- const encodedBody = isJson ? `[${bodyStr}]` : bodyStr;
2657
+ let encodedBody;
2658
+ if (isJson) {
2659
+ const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
2660
+ encodedBody = `[${bodyStr}]`;
2661
+ } else if (typeof body === `string`) encodedBody = body;
2662
+ else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
2195
2663
  const response = await this.#fetchClient(fetchUrl.toString(), {
2196
2664
  method: `POST`,
2197
2665
  headers: requestHeaders,
@@ -2261,8 +2729,31 @@ var DurableStream = class DurableStream {
2261
2729
  const jsonStrings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
2262
2730
  batchedBody = `[${jsonStrings.join(`,`)}]`;
2263
2731
  } else {
2264
- const strings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
2265
- batchedBody = strings.join(``);
2732
+ const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
2733
+ const hasString = batch.some((m) => typeof m.data === `string`);
2734
+ if (hasUint8Array && !hasString) {
2735
+ const chunks = batch.map((m) => m.data);
2736
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
2737
+ const combined = new Uint8Array(totalLength);
2738
+ let offset = 0;
2739
+ for (const chunk of chunks) {
2740
+ combined.set(chunk, offset);
2741
+ offset += chunk.length;
2742
+ }
2743
+ batchedBody = combined;
2744
+ } else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
2745
+ else {
2746
+ const encoder = new TextEncoder();
2747
+ const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
2748
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
2749
+ const combined = new Uint8Array(totalLength);
2750
+ let offset = 0;
2751
+ for (const chunk of chunks) {
2752
+ combined.set(chunk, offset);
2753
+ offset += chunk.length;
2754
+ }
2755
+ batchedBody = combined;
2756
+ }
2266
2757
  }
2267
2758
  const signals = [];
2268
2759
  if (this.#options.signal) signals.push(this.#options.signal);
@@ -2370,12 +2861,11 @@ var DurableStream = class DurableStream {
2370
2861
  producer.append(chunk);
2371
2862
  },
2372
2863
  async close() {
2373
- await producer.flush();
2374
2864
  await producer.close();
2375
2865
  if (writeError) throw writeError;
2376
2866
  },
2377
2867
  abort(_reason) {
2378
- producer.close().catch((err) => {
2868
+ producer.detach().catch((err) => {
2379
2869
  opts?.onError?.(err);
2380
2870
  });
2381
2871
  }
@@ -2417,10 +2907,6 @@ var DurableStream = class DurableStream {
2417
2907
  * ```
2418
2908
  */
2419
2909
  async stream(options) {
2420
- if (options?.live === `sse` && this.contentType) {
2421
- const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) => this.contentType.startsWith(prefix));
2422
- if (!isSSECompatible) throw new DurableStreamError(`SSE is not supported for content-type: ${this.contentType}`, `SSE_NOT_SUPPORTED`, 400);
2423
- }
2424
2910
  const mergedHeaders = {
2425
2911
  ...this.#options.headers,
2426
2912
  ...options?.headers
@@ -2522,7 +3008,9 @@ exports.PRODUCER_EXPECTED_SEQ_HEADER = PRODUCER_EXPECTED_SEQ_HEADER
2522
3008
  exports.PRODUCER_ID_HEADER = PRODUCER_ID_HEADER
2523
3009
  exports.PRODUCER_RECEIVED_SEQ_HEADER = PRODUCER_RECEIVED_SEQ_HEADER
2524
3010
  exports.PRODUCER_SEQ_HEADER = PRODUCER_SEQ_HEADER
3011
+ exports.SSE_CLOSED_FIELD = SSE_CLOSED_FIELD
2525
3012
  exports.SSE_COMPATIBLE_CONTENT_TYPES = SSE_COMPATIBLE_CONTENT_TYPES
3013
+ exports.STREAM_CLOSED_HEADER = STREAM_CLOSED_HEADER
2526
3014
  exports.STREAM_CURSOR_HEADER = STREAM_CURSOR_HEADER
2527
3015
  exports.STREAM_EXPIRES_AT_HEADER = STREAM_EXPIRES_AT_HEADER
2528
3016
  exports.STREAM_OFFSET_HEADER = STREAM_OFFSET_HEADER
@@ -2531,6 +3019,7 @@ exports.STREAM_TTL_HEADER = STREAM_TTL_HEADER
2531
3019
  exports.STREAM_UP_TO_DATE_HEADER = STREAM_UP_TO_DATE_HEADER
2532
3020
  exports.SequenceGapError = SequenceGapError
2533
3021
  exports.StaleEpochError = StaleEpochError
3022
+ exports.StreamClosedError = StreamClosedError
2534
3023
  exports._resetHttpWarningForTesting = _resetHttpWarningForTesting
2535
3024
  exports.asAsyncIterableReadableStream = asAsyncIterableReadableStream
2536
3025
  exports.createFetchWithBackoff = createFetchWithBackoff