@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.js
CHANGED
|
@@ -555,6 +555,207 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
555
555
|
}
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
+
//#endregion
|
|
559
|
+
//#region src/stream-response-state.ts
|
|
560
|
+
/**
|
|
561
|
+
* Abstract base class for stream response state.
|
|
562
|
+
* All state transitions return new immutable state objects.
|
|
563
|
+
*/
|
|
564
|
+
var StreamResponseState = class {
|
|
565
|
+
shouldContinueLive(stopAfterUpToDate, liveMode) {
|
|
566
|
+
if (stopAfterUpToDate && this.upToDate) return false;
|
|
567
|
+
if (liveMode === false) return false;
|
|
568
|
+
if (this.streamClosed) return false;
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* State for long-poll mode. shouldUseSse() returns false.
|
|
574
|
+
*/
|
|
575
|
+
var LongPollState = class LongPollState extends StreamResponseState {
|
|
576
|
+
offset;
|
|
577
|
+
cursor;
|
|
578
|
+
upToDate;
|
|
579
|
+
streamClosed;
|
|
580
|
+
constructor(fields) {
|
|
581
|
+
super();
|
|
582
|
+
this.offset = fields.offset;
|
|
583
|
+
this.cursor = fields.cursor;
|
|
584
|
+
this.upToDate = fields.upToDate;
|
|
585
|
+
this.streamClosed = fields.streamClosed;
|
|
586
|
+
}
|
|
587
|
+
shouldUseSse() {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
withResponseMetadata(update) {
|
|
591
|
+
return new LongPollState({
|
|
592
|
+
offset: update.offset ?? this.offset,
|
|
593
|
+
cursor: update.cursor ?? this.cursor,
|
|
594
|
+
upToDate: update.upToDate,
|
|
595
|
+
streamClosed: this.streamClosed || update.streamClosed
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
withSSEControl(event) {
|
|
599
|
+
const streamClosed = this.streamClosed || (event.streamClosed ?? false);
|
|
600
|
+
return new LongPollState({
|
|
601
|
+
offset: event.streamNextOffset,
|
|
602
|
+
cursor: event.streamCursor || this.cursor,
|
|
603
|
+
upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
|
|
604
|
+
streamClosed
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
pause() {
|
|
608
|
+
return new PausedState(this);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
/**
|
|
612
|
+
* State for SSE mode. shouldUseSse() returns true.
|
|
613
|
+
* Tracks SSE connection resilience (short connection detection).
|
|
614
|
+
*/
|
|
615
|
+
var SSEState = class SSEState extends StreamResponseState {
|
|
616
|
+
offset;
|
|
617
|
+
cursor;
|
|
618
|
+
upToDate;
|
|
619
|
+
streamClosed;
|
|
620
|
+
consecutiveShortConnections;
|
|
621
|
+
connectionStartTime;
|
|
622
|
+
constructor(fields) {
|
|
623
|
+
super();
|
|
624
|
+
this.offset = fields.offset;
|
|
625
|
+
this.cursor = fields.cursor;
|
|
626
|
+
this.upToDate = fields.upToDate;
|
|
627
|
+
this.streamClosed = fields.streamClosed;
|
|
628
|
+
this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0;
|
|
629
|
+
this.connectionStartTime = fields.connectionStartTime;
|
|
630
|
+
}
|
|
631
|
+
shouldUseSse() {
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
withResponseMetadata(update) {
|
|
635
|
+
return new SSEState({
|
|
636
|
+
offset: update.offset ?? this.offset,
|
|
637
|
+
cursor: update.cursor ?? this.cursor,
|
|
638
|
+
upToDate: update.upToDate,
|
|
639
|
+
streamClosed: this.streamClosed || update.streamClosed,
|
|
640
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
641
|
+
connectionStartTime: this.connectionStartTime
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
withSSEControl(event) {
|
|
645
|
+
const streamClosed = this.streamClosed || (event.streamClosed ?? false);
|
|
646
|
+
return new SSEState({
|
|
647
|
+
offset: event.streamNextOffset,
|
|
648
|
+
cursor: event.streamCursor || this.cursor,
|
|
649
|
+
upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
|
|
650
|
+
streamClosed,
|
|
651
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
652
|
+
connectionStartTime: this.connectionStartTime
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
startConnection(now) {
|
|
656
|
+
return new SSEState({
|
|
657
|
+
offset: this.offset,
|
|
658
|
+
cursor: this.cursor,
|
|
659
|
+
upToDate: this.upToDate,
|
|
660
|
+
streamClosed: this.streamClosed,
|
|
661
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
662
|
+
connectionStartTime: now
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
handleConnectionEnd(now, wasAborted, config) {
|
|
666
|
+
if (this.connectionStartTime === void 0) return {
|
|
667
|
+
action: `healthy`,
|
|
668
|
+
state: this
|
|
669
|
+
};
|
|
670
|
+
const duration = now - this.connectionStartTime;
|
|
671
|
+
if (duration < config.minConnectionDuration && !wasAborted) {
|
|
672
|
+
const newCount = this.consecutiveShortConnections + 1;
|
|
673
|
+
if (newCount >= config.maxShortConnections) return {
|
|
674
|
+
action: `fallback`,
|
|
675
|
+
state: new LongPollState({
|
|
676
|
+
offset: this.offset,
|
|
677
|
+
cursor: this.cursor,
|
|
678
|
+
upToDate: this.upToDate,
|
|
679
|
+
streamClosed: this.streamClosed
|
|
680
|
+
})
|
|
681
|
+
};
|
|
682
|
+
return {
|
|
683
|
+
action: `reconnect`,
|
|
684
|
+
state: new SSEState({
|
|
685
|
+
offset: this.offset,
|
|
686
|
+
cursor: this.cursor,
|
|
687
|
+
upToDate: this.upToDate,
|
|
688
|
+
streamClosed: this.streamClosed,
|
|
689
|
+
consecutiveShortConnections: newCount,
|
|
690
|
+
connectionStartTime: this.connectionStartTime
|
|
691
|
+
}),
|
|
692
|
+
backoffAttempt: newCount
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
if (duration >= config.minConnectionDuration) return {
|
|
696
|
+
action: `healthy`,
|
|
697
|
+
state: new SSEState({
|
|
698
|
+
offset: this.offset,
|
|
699
|
+
cursor: this.cursor,
|
|
700
|
+
upToDate: this.upToDate,
|
|
701
|
+
streamClosed: this.streamClosed,
|
|
702
|
+
consecutiveShortConnections: 0,
|
|
703
|
+
connectionStartTime: this.connectionStartTime
|
|
704
|
+
})
|
|
705
|
+
};
|
|
706
|
+
return {
|
|
707
|
+
action: `healthy`,
|
|
708
|
+
state: this
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
pause() {
|
|
712
|
+
return new PausedState(this);
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
/**
|
|
716
|
+
* Paused state wrapper. Delegates all sync field access to the inner state.
|
|
717
|
+
* resume() returns the wrapped state unchanged (identity preserved).
|
|
718
|
+
*/
|
|
719
|
+
var PausedState = class PausedState extends StreamResponseState {
|
|
720
|
+
#inner;
|
|
721
|
+
constructor(inner) {
|
|
722
|
+
super();
|
|
723
|
+
this.#inner = inner;
|
|
724
|
+
}
|
|
725
|
+
get offset() {
|
|
726
|
+
return this.#inner.offset;
|
|
727
|
+
}
|
|
728
|
+
get cursor() {
|
|
729
|
+
return this.#inner.cursor;
|
|
730
|
+
}
|
|
731
|
+
get upToDate() {
|
|
732
|
+
return this.#inner.upToDate;
|
|
733
|
+
}
|
|
734
|
+
get streamClosed() {
|
|
735
|
+
return this.#inner.streamClosed;
|
|
736
|
+
}
|
|
737
|
+
shouldUseSse() {
|
|
738
|
+
return this.#inner.shouldUseSse();
|
|
739
|
+
}
|
|
740
|
+
withResponseMetadata(update) {
|
|
741
|
+
const newInner = this.#inner.withResponseMetadata(update);
|
|
742
|
+
return new PausedState(newInner);
|
|
743
|
+
}
|
|
744
|
+
withSSEControl(event) {
|
|
745
|
+
const newInner = this.#inner.withSSEControl(event);
|
|
746
|
+
return new PausedState(newInner);
|
|
747
|
+
}
|
|
748
|
+
pause() {
|
|
749
|
+
return this;
|
|
750
|
+
}
|
|
751
|
+
resume() {
|
|
752
|
+
return {
|
|
753
|
+
state: this.#inner,
|
|
754
|
+
justResumed: true
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
558
759
|
//#endregion
|
|
559
760
|
//#region src/response.ts
|
|
560
761
|
/**
|
|
@@ -574,10 +775,7 @@ var StreamResponseImpl = class {
|
|
|
574
775
|
#statusText;
|
|
575
776
|
#ok;
|
|
576
777
|
#isLoading;
|
|
577
|
-
#
|
|
578
|
-
#cursor;
|
|
579
|
-
#upToDate;
|
|
580
|
-
#streamClosed;
|
|
778
|
+
#syncState;
|
|
581
779
|
#isJsonMode;
|
|
582
780
|
#abortController;
|
|
583
781
|
#fetchNext;
|
|
@@ -592,11 +790,7 @@ var StreamResponseImpl = class {
|
|
|
592
790
|
#unsubscribeFromVisibilityChanges;
|
|
593
791
|
#pausePromise;
|
|
594
792
|
#pauseResolve;
|
|
595
|
-
#justResumedFromPause = false;
|
|
596
793
|
#sseResilience;
|
|
597
|
-
#lastSSEConnectionStartTime;
|
|
598
|
-
#consecutiveShortSSEConnections = 0;
|
|
599
|
-
#sseFallbackToLongPoll = false;
|
|
600
794
|
#encoding;
|
|
601
795
|
#responseStream;
|
|
602
796
|
constructor(config) {
|
|
@@ -604,10 +798,13 @@ var StreamResponseImpl = class {
|
|
|
604
798
|
this.contentType = config.contentType;
|
|
605
799
|
this.live = config.live;
|
|
606
800
|
this.startOffset = config.startOffset;
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
801
|
+
const syncFields = {
|
|
802
|
+
offset: config.initialOffset,
|
|
803
|
+
cursor: config.initialCursor,
|
|
804
|
+
upToDate: config.initialUpToDate,
|
|
805
|
+
streamClosed: config.initialStreamClosed
|
|
806
|
+
};
|
|
807
|
+
this.#syncState = config.startSSE ? new SSEState(syncFields) : new LongPollState(syncFields);
|
|
611
808
|
this.#headers = config.firstResponse.headers;
|
|
612
809
|
this.#status = config.firstResponse.status;
|
|
613
810
|
this.#statusText = config.firstResponse.statusText;
|
|
@@ -664,6 +861,7 @@ var StreamResponseImpl = class {
|
|
|
664
861
|
#pause() {
|
|
665
862
|
if (this.#state === `active`) {
|
|
666
863
|
this.#state = `pause-requested`;
|
|
864
|
+
this.#syncState = this.#syncState.pause();
|
|
667
865
|
this.#pausePromise = new Promise((resolve) => {
|
|
668
866
|
this.#pauseResolve = resolve;
|
|
669
867
|
});
|
|
@@ -677,8 +875,8 @@ var StreamResponseImpl = class {
|
|
|
677
875
|
#resume() {
|
|
678
876
|
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
679
877
|
if (this.#abortController.signal.aborted) return;
|
|
878
|
+
if (this.#syncState instanceof PausedState) this.#syncState = this.#syncState.resume().state;
|
|
680
879
|
this.#state = `active`;
|
|
681
|
-
this.#justResumedFromPause = true;
|
|
682
880
|
this.#pauseResolve?.();
|
|
683
881
|
this.#pausePromise = void 0;
|
|
684
882
|
this.#pauseResolve = void 0;
|
|
@@ -700,16 +898,16 @@ var StreamResponseImpl = class {
|
|
|
700
898
|
return this.#isLoading;
|
|
701
899
|
}
|
|
702
900
|
get offset() {
|
|
703
|
-
return this.#offset;
|
|
901
|
+
return this.#syncState.offset;
|
|
704
902
|
}
|
|
705
903
|
get cursor() {
|
|
706
|
-
return this.#cursor;
|
|
904
|
+
return this.#syncState.cursor;
|
|
707
905
|
}
|
|
708
906
|
get upToDate() {
|
|
709
|
-
return this.#upToDate;
|
|
907
|
+
return this.#syncState.upToDate;
|
|
710
908
|
}
|
|
711
909
|
get streamClosed() {
|
|
712
|
-
return this.#streamClosed;
|
|
910
|
+
return this.#syncState.streamClosed;
|
|
713
911
|
}
|
|
714
912
|
#ensureJsonMode() {
|
|
715
913
|
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`);
|
|
@@ -735,159 +933,61 @@ var StreamResponseImpl = class {
|
|
|
735
933
|
* and whether we've received upToDate or streamClosed.
|
|
736
934
|
*/
|
|
737
935
|
#shouldContinueLive() {
|
|
738
|
-
|
|
739
|
-
if (this.live === false) return false;
|
|
740
|
-
if (this.#streamClosed) return false;
|
|
741
|
-
return true;
|
|
936
|
+
return this.#syncState.shouldContinueLive(this.#stopAfterUpToDate, this.live);
|
|
742
937
|
}
|
|
743
938
|
/**
|
|
744
939
|
* Update state from response headers.
|
|
745
940
|
*/
|
|
746
941
|
#updateStateFromResponse(response) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (streamClosedHeader?.toLowerCase() === `true`) this.#streamClosed = true;
|
|
942
|
+
this.#syncState = this.#syncState.withResponseMetadata({
|
|
943
|
+
offset: response.headers.get(STREAM_OFFSET_HEADER) || void 0,
|
|
944
|
+
cursor: response.headers.get(STREAM_CURSOR_HEADER) || void 0,
|
|
945
|
+
upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
|
|
946
|
+
streamClosed: response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
947
|
+
});
|
|
754
948
|
this.#headers = response.headers;
|
|
755
949
|
this.#status = response.status;
|
|
756
950
|
this.#statusText = response.statusText;
|
|
757
951
|
this.#ok = response.ok;
|
|
758
952
|
}
|
|
759
953
|
/**
|
|
760
|
-
* Extract stream metadata from Response headers.
|
|
761
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
|
|
762
|
-
* specific Response, rather than reading from `this` which may be stale due to
|
|
763
|
-
* ReadableStream prefetching or timing issues.
|
|
764
|
-
*/
|
|
765
|
-
#getMetadataFromResponse(response) {
|
|
766
|
-
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
767
|
-
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
768
|
-
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
769
|
-
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
770
|
-
return {
|
|
771
|
-
offset: offset ?? this.offset,
|
|
772
|
-
cursor: cursor ?? this.cursor,
|
|
773
|
-
upToDate,
|
|
774
|
-
streamClosed: streamClosed || this.streamClosed
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
/**
|
|
778
|
-
* Decode base64 string to Uint8Array.
|
|
779
|
-
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
780
|
-
*/
|
|
781
|
-
#decodeBase64(base64Str) {
|
|
782
|
-
const cleaned = base64Str.replace(/[\n\r]/g, ``);
|
|
783
|
-
if (cleaned.length === 0) return new Uint8Array(0);
|
|
784
|
-
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
|
|
785
|
-
try {
|
|
786
|
-
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
|
|
787
|
-
else {
|
|
788
|
-
const binaryStr = atob(cleaned);
|
|
789
|
-
const bytes = new Uint8Array(binaryStr.length);
|
|
790
|
-
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
791
|
-
return bytes;
|
|
792
|
-
}
|
|
793
|
-
} catch (err) {
|
|
794
|
-
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Create a synthetic Response from SSE data with proper headers.
|
|
799
|
-
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
800
|
-
*/
|
|
801
|
-
#createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed) {
|
|
802
|
-
return this.#createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed);
|
|
803
|
-
}
|
|
804
|
-
/**
|
|
805
|
-
* Create a synthetic Response from multiple SSE data parts.
|
|
806
|
-
* For base64 mode, each part is independently encoded, so we decode each
|
|
807
|
-
* separately and concatenate the binary results.
|
|
808
|
-
* For text mode, parts are simply concatenated as strings.
|
|
809
|
-
*/
|
|
810
|
-
#createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed) {
|
|
811
|
-
const headers = {
|
|
812
|
-
"content-type": this.contentType ?? `application/json`,
|
|
813
|
-
[STREAM_OFFSET_HEADER]: String(offset)
|
|
814
|
-
};
|
|
815
|
-
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
816
|
-
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
817
|
-
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
|
|
818
|
-
let body;
|
|
819
|
-
if (this.#encoding === `base64`) {
|
|
820
|
-
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => this.#decodeBase64(part));
|
|
821
|
-
if (decodedParts.length === 0) body = new ArrayBuffer(0);
|
|
822
|
-
else if (decodedParts.length === 1) {
|
|
823
|
-
const decoded = decodedParts[0];
|
|
824
|
-
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
|
|
825
|
-
} else {
|
|
826
|
-
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
|
|
827
|
-
const combined = new Uint8Array(totalLength);
|
|
828
|
-
let offset$1 = 0;
|
|
829
|
-
for (const part of decodedParts) {
|
|
830
|
-
combined.set(part, offset$1);
|
|
831
|
-
offset$1 += part.length;
|
|
832
|
-
}
|
|
833
|
-
body = combined.buffer;
|
|
834
|
-
}
|
|
835
|
-
} else body = dataParts.join(``);
|
|
836
|
-
return new Response(body, {
|
|
837
|
-
status: 200,
|
|
838
|
-
headers
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
/**
|
|
842
954
|
* Update instance state from an SSE control event.
|
|
843
955
|
*/
|
|
844
956
|
#updateStateFromSSEControl(controlEvent) {
|
|
845
|
-
this.#
|
|
846
|
-
if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
|
|
847
|
-
if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
|
|
848
|
-
if (controlEvent.streamClosed) {
|
|
849
|
-
this.#streamClosed = true;
|
|
850
|
-
this.#upToDate = true;
|
|
851
|
-
}
|
|
957
|
+
this.#syncState = this.#syncState.withSSEControl(controlEvent);
|
|
852
958
|
}
|
|
853
959
|
/**
|
|
854
960
|
* Mark the start of an SSE connection for duration tracking.
|
|
961
|
+
* If the state is not SSEState (e.g., auto-detected SSE from content-type),
|
|
962
|
+
* transitions to SSEState first.
|
|
855
963
|
*/
|
|
856
964
|
#markSSEConnectionStart() {
|
|
857
|
-
this.#
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if (this.#lastSSEConnectionStartTime === void 0) return 0;
|
|
865
|
-
const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime;
|
|
866
|
-
const wasAborted = this.#abortController.signal.aborted;
|
|
867
|
-
if (connectionDuration < this.#sseResilience.minConnectionDuration && !wasAborted) {
|
|
868
|
-
this.#consecutiveShortSSEConnections++;
|
|
869
|
-
if (this.#consecutiveShortSSEConnections >= this.#sseResilience.maxShortConnections) {
|
|
870
|
-
this.#sseFallbackToLongPoll = true;
|
|
871
|
-
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.");
|
|
872
|
-
return null;
|
|
873
|
-
} else {
|
|
874
|
-
const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, this.#consecutiveShortSSEConnections));
|
|
875
|
-
const delayMs = Math.floor(Math.random() * maxDelay);
|
|
876
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
877
|
-
return delayMs;
|
|
878
|
-
}
|
|
879
|
-
} else if (connectionDuration >= this.#sseResilience.minConnectionDuration) this.#consecutiveShortSSEConnections = 0;
|
|
880
|
-
return 0;
|
|
965
|
+
if (!(this.#syncState instanceof SSEState)) this.#syncState = new SSEState({
|
|
966
|
+
offset: this.#syncState.offset,
|
|
967
|
+
cursor: this.#syncState.cursor,
|
|
968
|
+
upToDate: this.#syncState.upToDate,
|
|
969
|
+
streamClosed: this.#syncState.streamClosed
|
|
970
|
+
});
|
|
971
|
+
this.#syncState = this.#syncState.startConnection(Date.now());
|
|
881
972
|
}
|
|
882
973
|
/**
|
|
883
974
|
* Try to reconnect SSE and return the new iterator, or null if reconnection
|
|
884
975
|
* is not possible or fails.
|
|
885
976
|
*/
|
|
886
977
|
async #trySSEReconnect() {
|
|
887
|
-
if (this.#
|
|
978
|
+
if (!this.#syncState.shouldUseSse()) return null;
|
|
888
979
|
if (!this.#shouldContinueLive() || !this.#startSSE) return null;
|
|
889
|
-
const
|
|
890
|
-
|
|
980
|
+
const result = this.#syncState.handleConnectionEnd(Date.now(), this.#abortController.signal.aborted, this.#sseResilience);
|
|
981
|
+
this.#syncState = result.state;
|
|
982
|
+
if (result.action === `fallback`) {
|
|
983
|
+
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.");
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
if (result.action === `reconnect`) {
|
|
987
|
+
const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, result.backoffAttempt));
|
|
988
|
+
const delayMs = Math.floor(Math.random() * maxDelay);
|
|
989
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
990
|
+
}
|
|
891
991
|
this.#markSSEConnectionStart();
|
|
892
992
|
this.#requestAbortController = new AbortController();
|
|
893
993
|
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
@@ -922,7 +1022,7 @@ var StreamResponseImpl = class {
|
|
|
922
1022
|
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
|
|
923
1023
|
this.#updateStateFromSSEControl(event);
|
|
924
1024
|
if (event.upToDate) {
|
|
925
|
-
const response =
|
|
1025
|
+
const response = createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false, this.contentType, this.#encoding);
|
|
926
1026
|
return {
|
|
927
1027
|
type: `response`,
|
|
928
1028
|
response
|
|
@@ -943,7 +1043,7 @@ var StreamResponseImpl = class {
|
|
|
943
1043
|
while (true) {
|
|
944
1044
|
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
|
|
945
1045
|
if (controlDone) {
|
|
946
|
-
const response =
|
|
1046
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed, this.contentType, this.#encoding, this.#isJsonMode);
|
|
947
1047
|
try {
|
|
948
1048
|
const newIterator = await this.#trySSEReconnect();
|
|
949
1049
|
return {
|
|
@@ -960,7 +1060,7 @@ var StreamResponseImpl = class {
|
|
|
960
1060
|
}
|
|
961
1061
|
if (controlEvent.type === `control`) {
|
|
962
1062
|
this.#updateStateFromSSEControl(controlEvent);
|
|
963
|
-
const response =
|
|
1063
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false, this.contentType, this.#encoding, this.#isJsonMode);
|
|
964
1064
|
return {
|
|
965
1065
|
type: `response`,
|
|
966
1066
|
response
|
|
@@ -1038,6 +1138,7 @@ var StreamResponseImpl = class {
|
|
|
1038
1138
|
}
|
|
1039
1139
|
}
|
|
1040
1140
|
if (this.#shouldContinueLive()) {
|
|
1141
|
+
let resumingFromPause = false;
|
|
1041
1142
|
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
1042
1143
|
this.#state = `paused`;
|
|
1043
1144
|
if (this.#pausePromise) await this.#pausePromise;
|
|
@@ -1046,14 +1147,13 @@ var StreamResponseImpl = class {
|
|
|
1046
1147
|
controller.close();
|
|
1047
1148
|
return;
|
|
1048
1149
|
}
|
|
1150
|
+
resumingFromPause = true;
|
|
1049
1151
|
}
|
|
1050
1152
|
if (this.#abortController.signal.aborted) {
|
|
1051
1153
|
this.#markClosed();
|
|
1052
1154
|
controller.close();
|
|
1053
1155
|
return;
|
|
1054
1156
|
}
|
|
1055
|
-
const resumingFromPause = this.#justResumedFromPause;
|
|
1056
|
-
this.#justResumedFromPause = false;
|
|
1057
1157
|
this.#requestAbortController = new AbortController();
|
|
1058
1158
|
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
|
|
1059
1159
|
this.#updateStateFromResponse(response);
|
|
@@ -1219,23 +1319,28 @@ var StreamResponseImpl = class {
|
|
|
1219
1319
|
controller.enqueue(pendingItems.shift());
|
|
1220
1320
|
return;
|
|
1221
1321
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1322
|
+
let result = await reader.read();
|
|
1323
|
+
while (!result.done) {
|
|
1324
|
+
const response = result.value;
|
|
1325
|
+
const text = await response.text();
|
|
1326
|
+
const content = text.trim() || `[]`;
|
|
1327
|
+
let parsed;
|
|
1328
|
+
try {
|
|
1329
|
+
parsed = JSON.parse(content);
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
|
|
1332
|
+
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
|
|
1333
|
+
}
|
|
1334
|
+
pendingItems = Array.isArray(parsed) ? parsed : [parsed];
|
|
1335
|
+
if (pendingItems.length > 0) {
|
|
1336
|
+
controller.enqueue(pendingItems.shift());
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
result = await reader.read();
|
|
1236
1340
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1341
|
+
this.#markClosed();
|
|
1342
|
+
controller.close();
|
|
1343
|
+
return;
|
|
1239
1344
|
},
|
|
1240
1345
|
cancel: () => {
|
|
1241
1346
|
reader.releaseLock();
|
|
@@ -1269,7 +1374,7 @@ var StreamResponseImpl = class {
|
|
|
1269
1374
|
while (!result.done) {
|
|
1270
1375
|
if (abortController.signal.aborted) break;
|
|
1271
1376
|
const response = result.value;
|
|
1272
|
-
const { offset, cursor, upToDate, streamClosed } =
|
|
1377
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1273
1378
|
const text = await response.text();
|
|
1274
1379
|
const content = text.trim() || `[]`;
|
|
1275
1380
|
let parsed;
|
|
@@ -1315,7 +1420,7 @@ var StreamResponseImpl = class {
|
|
|
1315
1420
|
while (!result.done) {
|
|
1316
1421
|
if (abortController.signal.aborted) break;
|
|
1317
1422
|
const response = result.value;
|
|
1318
|
-
const { offset, cursor, upToDate, streamClosed } =
|
|
1423
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1319
1424
|
const buffer = await response.arrayBuffer();
|
|
1320
1425
|
await subscriber({
|
|
1321
1426
|
data: new Uint8Array(buffer),
|
|
@@ -1352,7 +1457,7 @@ var StreamResponseImpl = class {
|
|
|
1352
1457
|
while (!result.done) {
|
|
1353
1458
|
if (abortController.signal.aborted) break;
|
|
1354
1459
|
const response = result.value;
|
|
1355
|
-
const { offset, cursor, upToDate, streamClosed } =
|
|
1460
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1356
1461
|
const text = await response.text();
|
|
1357
1462
|
await subscriber({
|
|
1358
1463
|
text,
|
|
@@ -1388,6 +1493,97 @@ var StreamResponseImpl = class {
|
|
|
1388
1493
|
return this.#closed;
|
|
1389
1494
|
}
|
|
1390
1495
|
};
|
|
1496
|
+
/**
|
|
1497
|
+
* Extract stream metadata from Response headers.
|
|
1498
|
+
* Falls back to the provided defaults when headers are absent.
|
|
1499
|
+
*/
|
|
1500
|
+
function getMetadataFromResponse(response, fallbackOffset, fallbackCursor, fallbackStreamClosed) {
|
|
1501
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
1502
|
+
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
1503
|
+
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
1504
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
1505
|
+
return {
|
|
1506
|
+
offset: offset ?? fallbackOffset,
|
|
1507
|
+
cursor: cursor ?? fallbackCursor,
|
|
1508
|
+
upToDate,
|
|
1509
|
+
streamClosed: streamClosed || fallbackStreamClosed
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Decode base64 string to Uint8Array.
|
|
1514
|
+
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
1515
|
+
*/
|
|
1516
|
+
function decodeBase64(base64Str) {
|
|
1517
|
+
const cleaned = base64Str.replace(/[\n\r]/g, ``);
|
|
1518
|
+
if (cleaned.length === 0) return new Uint8Array(0);
|
|
1519
|
+
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
|
|
1520
|
+
try {
|
|
1521
|
+
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
|
|
1522
|
+
else {
|
|
1523
|
+
const binaryStr = atob(cleaned);
|
|
1524
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
1525
|
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
1526
|
+
return bytes;
|
|
1527
|
+
}
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Create a synthetic Response from SSE data with proper headers.
|
|
1534
|
+
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
1535
|
+
*/
|
|
1536
|
+
function createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed, contentType, encoding) {
|
|
1537
|
+
return createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed, contentType, encoding);
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Create a synthetic Response from multiple SSE data parts.
|
|
1541
|
+
* For base64 mode, each part is independently encoded, so we decode each
|
|
1542
|
+
* separately and concatenate the binary results.
|
|
1543
|
+
* For text mode, parts are simply concatenated as strings.
|
|
1544
|
+
*/
|
|
1545
|
+
function createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed, contentType, encoding, isJsonMode) {
|
|
1546
|
+
const headers = {
|
|
1547
|
+
"content-type": contentType ?? `application/json`,
|
|
1548
|
+
[STREAM_OFFSET_HEADER]: String(offset)
|
|
1549
|
+
};
|
|
1550
|
+
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
1551
|
+
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
1552
|
+
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
|
|
1553
|
+
let body;
|
|
1554
|
+
if (encoding === `base64`) {
|
|
1555
|
+
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => decodeBase64(part));
|
|
1556
|
+
if (decodedParts.length === 0) body = new ArrayBuffer(0);
|
|
1557
|
+
else if (decodedParts.length === 1) {
|
|
1558
|
+
const decoded = decodedParts[0];
|
|
1559
|
+
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
|
|
1560
|
+
} else {
|
|
1561
|
+
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
|
|
1562
|
+
const combined = new Uint8Array(totalLength);
|
|
1563
|
+
let offset$1 = 0;
|
|
1564
|
+
for (const part of decodedParts) {
|
|
1565
|
+
combined.set(part, offset$1);
|
|
1566
|
+
offset$1 += part.length;
|
|
1567
|
+
}
|
|
1568
|
+
body = combined.buffer;
|
|
1569
|
+
}
|
|
1570
|
+
} else if (isJsonMode) {
|
|
1571
|
+
const mergedParts = [];
|
|
1572
|
+
for (const part of dataParts) {
|
|
1573
|
+
const trimmed = part.trim();
|
|
1574
|
+
if (trimmed.length === 0) continue;
|
|
1575
|
+
if (trimmed.startsWith(`[`) && trimmed.endsWith(`]`)) {
|
|
1576
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
1577
|
+
if (inner.length > 0) mergedParts.push(inner);
|
|
1578
|
+
} else mergedParts.push(trimmed);
|
|
1579
|
+
}
|
|
1580
|
+
body = `[${mergedParts.join(`,`)}]`;
|
|
1581
|
+
} else body = dataParts.join(``);
|
|
1582
|
+
return new Response(body, {
|
|
1583
|
+
status: 200,
|
|
1584
|
+
headers
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1391
1587
|
|
|
1392
1588
|
//#endregion
|
|
1393
1589
|
//#region src/utils.ts
|