@durable-streams/client 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/intent.js +6 -0
- package/dist/index.cjs +364 -168
- package/dist/index.js +364 -168
- 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/response.ts +332 -303
- package/src/stream-response-state.ts +306 -0
package/dist/index.cjs
CHANGED
|
@@ -579,6 +579,207 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
579
579
|
}
|
|
580
580
|
}
|
|
581
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
|
+
|
|
582
783
|
//#endregion
|
|
583
784
|
//#region src/response.ts
|
|
584
785
|
/**
|
|
@@ -598,10 +799,7 @@ var StreamResponseImpl = class {
|
|
|
598
799
|
#statusText;
|
|
599
800
|
#ok;
|
|
600
801
|
#isLoading;
|
|
601
|
-
#
|
|
602
|
-
#cursor;
|
|
603
|
-
#upToDate;
|
|
604
|
-
#streamClosed;
|
|
802
|
+
#syncState;
|
|
605
803
|
#isJsonMode;
|
|
606
804
|
#abortController;
|
|
607
805
|
#fetchNext;
|
|
@@ -616,11 +814,7 @@ var StreamResponseImpl = class {
|
|
|
616
814
|
#unsubscribeFromVisibilityChanges;
|
|
617
815
|
#pausePromise;
|
|
618
816
|
#pauseResolve;
|
|
619
|
-
#justResumedFromPause = false;
|
|
620
817
|
#sseResilience;
|
|
621
|
-
#lastSSEConnectionStartTime;
|
|
622
|
-
#consecutiveShortSSEConnections = 0;
|
|
623
|
-
#sseFallbackToLongPoll = false;
|
|
624
818
|
#encoding;
|
|
625
819
|
#responseStream;
|
|
626
820
|
constructor(config) {
|
|
@@ -628,10 +822,13 @@ var StreamResponseImpl = class {
|
|
|
628
822
|
this.contentType = config.contentType;
|
|
629
823
|
this.live = config.live;
|
|
630
824
|
this.startOffset = config.startOffset;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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);
|
|
635
832
|
this.#headers = config.firstResponse.headers;
|
|
636
833
|
this.#status = config.firstResponse.status;
|
|
637
834
|
this.#statusText = config.firstResponse.statusText;
|
|
@@ -688,6 +885,7 @@ var StreamResponseImpl = class {
|
|
|
688
885
|
#pause() {
|
|
689
886
|
if (this.#state === `active`) {
|
|
690
887
|
this.#state = `pause-requested`;
|
|
888
|
+
this.#syncState = this.#syncState.pause();
|
|
691
889
|
this.#pausePromise = new Promise((resolve) => {
|
|
692
890
|
this.#pauseResolve = resolve;
|
|
693
891
|
});
|
|
@@ -701,8 +899,8 @@ var StreamResponseImpl = class {
|
|
|
701
899
|
#resume() {
|
|
702
900
|
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
703
901
|
if (this.#abortController.signal.aborted) return;
|
|
902
|
+
if (this.#syncState instanceof PausedState) this.#syncState = this.#syncState.resume().state;
|
|
704
903
|
this.#state = `active`;
|
|
705
|
-
this.#justResumedFromPause = true;
|
|
706
904
|
this.#pauseResolve?.();
|
|
707
905
|
this.#pausePromise = void 0;
|
|
708
906
|
this.#pauseResolve = void 0;
|
|
@@ -724,16 +922,16 @@ var StreamResponseImpl = class {
|
|
|
724
922
|
return this.#isLoading;
|
|
725
923
|
}
|
|
726
924
|
get offset() {
|
|
727
|
-
return this.#offset;
|
|
925
|
+
return this.#syncState.offset;
|
|
728
926
|
}
|
|
729
927
|
get cursor() {
|
|
730
|
-
return this.#cursor;
|
|
928
|
+
return this.#syncState.cursor;
|
|
731
929
|
}
|
|
732
930
|
get upToDate() {
|
|
733
|
-
return this.#upToDate;
|
|
931
|
+
return this.#syncState.upToDate;
|
|
734
932
|
}
|
|
735
933
|
get streamClosed() {
|
|
736
|
-
return this.#streamClosed;
|
|
934
|
+
return this.#syncState.streamClosed;
|
|
737
935
|
}
|
|
738
936
|
#ensureJsonMode() {
|
|
739
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`);
|
|
@@ -759,159 +957,61 @@ var StreamResponseImpl = class {
|
|
|
759
957
|
* and whether we've received upToDate or streamClosed.
|
|
760
958
|
*/
|
|
761
959
|
#shouldContinueLive() {
|
|
762
|
-
|
|
763
|
-
if (this.live === false) return false;
|
|
764
|
-
if (this.#streamClosed) return false;
|
|
765
|
-
return true;
|
|
960
|
+
return this.#syncState.shouldContinueLive(this.#stopAfterUpToDate, this.live);
|
|
766
961
|
}
|
|
767
962
|
/**
|
|
768
963
|
* Update state from response headers.
|
|
769
964
|
*/
|
|
770
965
|
#updateStateFromResponse(response) {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (streamClosedHeader?.toLowerCase() === `true`) this.#streamClosed = true;
|
|
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
|
+
});
|
|
778
972
|
this.#headers = response.headers;
|
|
779
973
|
this.#status = response.status;
|
|
780
974
|
this.#statusText = response.statusText;
|
|
781
975
|
this.#ok = response.ok;
|
|
782
976
|
}
|
|
783
977
|
/**
|
|
784
|
-
* Extract stream metadata from Response headers.
|
|
785
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
|
|
786
|
-
* specific Response, rather than reading from `this` which may be stale due to
|
|
787
|
-
* ReadableStream prefetching or timing issues.
|
|
788
|
-
*/
|
|
789
|
-
#getMetadataFromResponse(response) {
|
|
790
|
-
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
791
|
-
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
792
|
-
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
793
|
-
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
794
|
-
return {
|
|
795
|
-
offset: offset ?? this.offset,
|
|
796
|
-
cursor: cursor ?? this.cursor,
|
|
797
|
-
upToDate,
|
|
798
|
-
streamClosed: streamClosed || this.streamClosed
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
/**
|
|
802
|
-
* Decode base64 string to Uint8Array.
|
|
803
|
-
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
804
|
-
*/
|
|
805
|
-
#decodeBase64(base64Str) {
|
|
806
|
-
const cleaned = base64Str.replace(/[\n\r]/g, ``);
|
|
807
|
-
if (cleaned.length === 0) return new Uint8Array(0);
|
|
808
|
-
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
|
|
809
|
-
try {
|
|
810
|
-
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
|
|
811
|
-
else {
|
|
812
|
-
const binaryStr = atob(cleaned);
|
|
813
|
-
const bytes = new Uint8Array(binaryStr.length);
|
|
814
|
-
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
815
|
-
return bytes;
|
|
816
|
-
}
|
|
817
|
-
} catch (err) {
|
|
818
|
-
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* Create a synthetic Response from SSE data with proper headers.
|
|
823
|
-
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
824
|
-
*/
|
|
825
|
-
#createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed) {
|
|
826
|
-
return this.#createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed);
|
|
827
|
-
}
|
|
828
|
-
/**
|
|
829
|
-
* Create a synthetic Response from multiple SSE data parts.
|
|
830
|
-
* For base64 mode, each part is independently encoded, so we decode each
|
|
831
|
-
* separately and concatenate the binary results.
|
|
832
|
-
* For text mode, parts are simply concatenated as strings.
|
|
833
|
-
*/
|
|
834
|
-
#createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed) {
|
|
835
|
-
const headers = {
|
|
836
|
-
"content-type": this.contentType ?? `application/json`,
|
|
837
|
-
[STREAM_OFFSET_HEADER]: String(offset)
|
|
838
|
-
};
|
|
839
|
-
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
840
|
-
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
841
|
-
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
|
|
842
|
-
let body;
|
|
843
|
-
if (this.#encoding === `base64`) {
|
|
844
|
-
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => this.#decodeBase64(part));
|
|
845
|
-
if (decodedParts.length === 0) body = new ArrayBuffer(0);
|
|
846
|
-
else if (decodedParts.length === 1) {
|
|
847
|
-
const decoded = decodedParts[0];
|
|
848
|
-
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
|
|
849
|
-
} else {
|
|
850
|
-
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
|
|
851
|
-
const combined = new Uint8Array(totalLength);
|
|
852
|
-
let offset$1 = 0;
|
|
853
|
-
for (const part of decodedParts) {
|
|
854
|
-
combined.set(part, offset$1);
|
|
855
|
-
offset$1 += part.length;
|
|
856
|
-
}
|
|
857
|
-
body = combined.buffer;
|
|
858
|
-
}
|
|
859
|
-
} else body = dataParts.join(``);
|
|
860
|
-
return new Response(body, {
|
|
861
|
-
status: 200,
|
|
862
|
-
headers
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
/**
|
|
866
978
|
* Update instance state from an SSE control event.
|
|
867
979
|
*/
|
|
868
980
|
#updateStateFromSSEControl(controlEvent) {
|
|
869
|
-
this.#
|
|
870
|
-
if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
|
|
871
|
-
if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
|
|
872
|
-
if (controlEvent.streamClosed) {
|
|
873
|
-
this.#streamClosed = true;
|
|
874
|
-
this.#upToDate = true;
|
|
875
|
-
}
|
|
981
|
+
this.#syncState = this.#syncState.withSSEControl(controlEvent);
|
|
876
982
|
}
|
|
877
983
|
/**
|
|
878
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.
|
|
879
987
|
*/
|
|
880
988
|
#markSSEConnectionStart() {
|
|
881
|
-
this.#
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (this.#lastSSEConnectionStartTime === void 0) return 0;
|
|
889
|
-
const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime;
|
|
890
|
-
const wasAborted = this.#abortController.signal.aborted;
|
|
891
|
-
if (connectionDuration < this.#sseResilience.minConnectionDuration && !wasAborted) {
|
|
892
|
-
this.#consecutiveShortSSEConnections++;
|
|
893
|
-
if (this.#consecutiveShortSSEConnections >= this.#sseResilience.maxShortConnections) {
|
|
894
|
-
this.#sseFallbackToLongPoll = true;
|
|
895
|
-
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.");
|
|
896
|
-
return null;
|
|
897
|
-
} else {
|
|
898
|
-
const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, this.#consecutiveShortSSEConnections));
|
|
899
|
-
const delayMs = Math.floor(Math.random() * maxDelay);
|
|
900
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
901
|
-
return delayMs;
|
|
902
|
-
}
|
|
903
|
-
} else if (connectionDuration >= this.#sseResilience.minConnectionDuration) this.#consecutiveShortSSEConnections = 0;
|
|
904
|
-
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());
|
|
905
996
|
}
|
|
906
997
|
/**
|
|
907
998
|
* Try to reconnect SSE and return the new iterator, or null if reconnection
|
|
908
999
|
* is not possible or fails.
|
|
909
1000
|
*/
|
|
910
1001
|
async #trySSEReconnect() {
|
|
911
|
-
if (this.#
|
|
1002
|
+
if (!this.#syncState.shouldUseSse()) return null;
|
|
912
1003
|
if (!this.#shouldContinueLive() || !this.#startSSE) return null;
|
|
913
|
-
const
|
|
914
|
-
|
|
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
|
+
}
|
|
915
1015
|
this.#markSSEConnectionStart();
|
|
916
1016
|
this.#requestAbortController = new AbortController();
|
|
917
1017
|
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
@@ -946,7 +1046,7 @@ var StreamResponseImpl = class {
|
|
|
946
1046
|
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
|
|
947
1047
|
this.#updateStateFromSSEControl(event);
|
|
948
1048
|
if (event.upToDate) {
|
|
949
|
-
const response =
|
|
1049
|
+
const response = createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false, this.contentType, this.#encoding);
|
|
950
1050
|
return {
|
|
951
1051
|
type: `response`,
|
|
952
1052
|
response
|
|
@@ -967,7 +1067,7 @@ var StreamResponseImpl = class {
|
|
|
967
1067
|
while (true) {
|
|
968
1068
|
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
|
|
969
1069
|
if (controlDone) {
|
|
970
|
-
const response =
|
|
1070
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed, this.contentType, this.#encoding, this.#isJsonMode);
|
|
971
1071
|
try {
|
|
972
1072
|
const newIterator = await this.#trySSEReconnect();
|
|
973
1073
|
return {
|
|
@@ -984,7 +1084,7 @@ var StreamResponseImpl = class {
|
|
|
984
1084
|
}
|
|
985
1085
|
if (controlEvent.type === `control`) {
|
|
986
1086
|
this.#updateStateFromSSEControl(controlEvent);
|
|
987
|
-
const response =
|
|
1087
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false, this.contentType, this.#encoding, this.#isJsonMode);
|
|
988
1088
|
return {
|
|
989
1089
|
type: `response`,
|
|
990
1090
|
response
|
|
@@ -1062,6 +1162,7 @@ var StreamResponseImpl = class {
|
|
|
1062
1162
|
}
|
|
1063
1163
|
}
|
|
1064
1164
|
if (this.#shouldContinueLive()) {
|
|
1165
|
+
let resumingFromPause = false;
|
|
1065
1166
|
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
1066
1167
|
this.#state = `paused`;
|
|
1067
1168
|
if (this.#pausePromise) await this.#pausePromise;
|
|
@@ -1070,14 +1171,13 @@ var StreamResponseImpl = class {
|
|
|
1070
1171
|
controller.close();
|
|
1071
1172
|
return;
|
|
1072
1173
|
}
|
|
1174
|
+
resumingFromPause = true;
|
|
1073
1175
|
}
|
|
1074
1176
|
if (this.#abortController.signal.aborted) {
|
|
1075
1177
|
this.#markClosed();
|
|
1076
1178
|
controller.close();
|
|
1077
1179
|
return;
|
|
1078
1180
|
}
|
|
1079
|
-
const resumingFromPause = this.#justResumedFromPause;
|
|
1080
|
-
this.#justResumedFromPause = false;
|
|
1081
1181
|
this.#requestAbortController = new AbortController();
|
|
1082
1182
|
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
|
|
1083
1183
|
this.#updateStateFromResponse(response);
|
|
@@ -1243,23 +1343,28 @@ var StreamResponseImpl = class {
|
|
|
1243
1343
|
controller.enqueue(pendingItems.shift());
|
|
1244
1344
|
return;
|
|
1245
1345
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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();
|
|
1260
1364
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1365
|
+
this.#markClosed();
|
|
1366
|
+
controller.close();
|
|
1367
|
+
return;
|
|
1263
1368
|
},
|
|
1264
1369
|
cancel: () => {
|
|
1265
1370
|
reader.releaseLock();
|
|
@@ -1293,7 +1398,7 @@ var StreamResponseImpl = class {
|
|
|
1293
1398
|
while (!result.done) {
|
|
1294
1399
|
if (abortController.signal.aborted) break;
|
|
1295
1400
|
const response = result.value;
|
|
1296
|
-
const { offset, cursor, upToDate, streamClosed } =
|
|
1401
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1297
1402
|
const text = await response.text();
|
|
1298
1403
|
const content = text.trim() || `[]`;
|
|
1299
1404
|
let parsed;
|
|
@@ -1339,7 +1444,7 @@ var StreamResponseImpl = class {
|
|
|
1339
1444
|
while (!result.done) {
|
|
1340
1445
|
if (abortController.signal.aborted) break;
|
|
1341
1446
|
const response = result.value;
|
|
1342
|
-
const { offset, cursor, upToDate, streamClosed } =
|
|
1447
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1343
1448
|
const buffer = await response.arrayBuffer();
|
|
1344
1449
|
await subscriber({
|
|
1345
1450
|
data: new Uint8Array(buffer),
|
|
@@ -1376,7 +1481,7 @@ var StreamResponseImpl = class {
|
|
|
1376
1481
|
while (!result.done) {
|
|
1377
1482
|
if (abortController.signal.aborted) break;
|
|
1378
1483
|
const response = result.value;
|
|
1379
|
-
const { offset, cursor, upToDate, streamClosed } =
|
|
1484
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1380
1485
|
const text = await response.text();
|
|
1381
1486
|
await subscriber({
|
|
1382
1487
|
text,
|
|
@@ -1412,6 +1517,97 @@ var StreamResponseImpl = class {
|
|
|
1412
1517
|
return this.#closed;
|
|
1413
1518
|
}
|
|
1414
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
|
+
}
|
|
1415
1611
|
|
|
1416
1612
|
//#endregion
|
|
1417
1613
|
//#region src/utils.ts
|