@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/README.md +201 -8
- package/bin/intent.js +6 -0
- package/dist/index.cjs +624 -135
- package/dist/index.d.cts +139 -9
- package/dist/index.d.ts +139 -9
- package/dist/index.js +622 -136
- package/package.json +10 -3
- package/skills/getting-started/SKILL.md +223 -0
- package/skills/go-to-production/SKILL.md +243 -0
- package/skills/reading-streams/SKILL.md +247 -0
- package/skills/reading-streams/references/stream-response-methods.md +133 -0
- package/skills/server-deployment/SKILL.md +211 -0
- package/skills/writing-data/SKILL.md +311 -0
- package/src/constants.ts +19 -2
- package/src/error.ts +20 -0
- package/src/idempotent-producer.ts +144 -5
- package/src/index.ts +7 -0
- package/src/response.ts +376 -188
- package/src/sse.ts +10 -1
- package/src/stream-api.ts +13 -0
- package/src/stream-response-state.ts +306 -0
- package/src/stream.ts +147 -26
- package/src/types.ts +73 -0
- package/src/utils.ts +10 -1
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
|
-
*
|
|
98
|
-
|
|
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:`))
|
|
516
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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.#
|
|
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.#
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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.#
|
|
1002
|
+
if (!this.#syncState.shouldUseSse()) return null;
|
|
813
1003
|
if (!this.#shouldContinueLive() || !this.#startSSE) return null;
|
|
814
|
-
const
|
|
815
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
-
*
|
|
2060
|
+
* Stop the producer without closing the underlying stream.
|
|
1741
2061
|
*
|
|
1742
|
-
*
|
|
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
|
|
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
|
-
|
|
2194
|
-
|
|
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
|
|
2265
|
-
|
|
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.
|
|
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
|