@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.js
CHANGED
|
@@ -22,6 +22,11 @@ const STREAM_CURSOR_HEADER = `Stream-Cursor`;
|
|
|
22
22
|
*/
|
|
23
23
|
const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
|
|
24
24
|
/**
|
|
25
|
+
* Response/request header indicating stream is closed (EOF).
|
|
26
|
+
* When present with value "true", the stream is permanently closed.
|
|
27
|
+
*/
|
|
28
|
+
const STREAM_CLOSED_HEADER = `Stream-Closed`;
|
|
29
|
+
/**
|
|
25
30
|
* Request header for writer coordination sequence.
|
|
26
31
|
* Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
|
|
27
32
|
*/
|
|
@@ -70,8 +75,17 @@ const LIVE_QUERY_PARAM = `live`;
|
|
|
70
75
|
*/
|
|
71
76
|
const CURSOR_QUERY_PARAM = `cursor`;
|
|
72
77
|
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
78
|
+
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
|
|
79
|
+
*/
|
|
80
|
+
const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`;
|
|
81
|
+
/**
|
|
82
|
+
* SSE control event field for stream closed state.
|
|
83
|
+
* Note: Different from HTTP header name (camelCase vs Header-Case).
|
|
84
|
+
*/
|
|
85
|
+
const SSE_CLOSED_FIELD = `streamClosed`;
|
|
86
|
+
/**
|
|
87
|
+
* Content types that are natively compatible with SSE (UTF-8 text).
|
|
88
|
+
* Binary content types are also supported via automatic base64 encoding.
|
|
75
89
|
*/
|
|
76
90
|
const SSE_COMPATIBLE_CONTENT_TYPES = [`text/`, `application/json`];
|
|
77
91
|
/**
|
|
@@ -201,6 +215,23 @@ var MissingStreamUrlError = class extends Error {
|
|
|
201
215
|
}
|
|
202
216
|
};
|
|
203
217
|
/**
|
|
218
|
+
* Error thrown when attempting to append to a closed stream.
|
|
219
|
+
*/
|
|
220
|
+
var StreamClosedError = class extends DurableStreamError {
|
|
221
|
+
code = `STREAM_CLOSED`;
|
|
222
|
+
status = 409;
|
|
223
|
+
streamClosed = true;
|
|
224
|
+
/**
|
|
225
|
+
* The final offset of the stream, if available from the response.
|
|
226
|
+
*/
|
|
227
|
+
finalOffset;
|
|
228
|
+
constructor(url, finalOffset) {
|
|
229
|
+
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url);
|
|
230
|
+
this.name = `StreamClosedError`;
|
|
231
|
+
this.finalOffset = finalOffset;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
204
235
|
* Error thrown when signal option is invalid.
|
|
205
236
|
*/
|
|
206
237
|
var InvalidSignalError = class extends Error {
|
|
@@ -480,7 +511,8 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
480
511
|
type: `control`,
|
|
481
512
|
streamNextOffset: control.streamNextOffset,
|
|
482
513
|
streamCursor: control.streamCursor,
|
|
483
|
-
upToDate: control.upToDate
|
|
514
|
+
upToDate: control.upToDate,
|
|
515
|
+
streamClosed: control.streamClosed
|
|
484
516
|
};
|
|
485
517
|
} catch (err) {
|
|
486
518
|
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
|
|
@@ -488,8 +520,10 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
488
520
|
}
|
|
489
521
|
}
|
|
490
522
|
currentEvent = { data: [] };
|
|
491
|
-
} else if (line.startsWith(`event:`))
|
|
492
|
-
|
|
523
|
+
} else if (line.startsWith(`event:`)) {
|
|
524
|
+
const eventType = line.slice(6);
|
|
525
|
+
currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType;
|
|
526
|
+
} else if (line.startsWith(`data:`)) {
|
|
493
527
|
const content = line.slice(5);
|
|
494
528
|
currentEvent.data.push(content.startsWith(` `) ? content.slice(1) : content);
|
|
495
529
|
}
|
|
@@ -508,7 +542,8 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
508
542
|
type: `control`,
|
|
509
543
|
streamNextOffset: control.streamNextOffset,
|
|
510
544
|
streamCursor: control.streamCursor,
|
|
511
|
-
upToDate: control.upToDate
|
|
545
|
+
upToDate: control.upToDate,
|
|
546
|
+
streamClosed: control.streamClosed
|
|
512
547
|
};
|
|
513
548
|
} catch (err) {
|
|
514
549
|
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
|
|
@@ -520,6 +555,207 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
520
555
|
}
|
|
521
556
|
}
|
|
522
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
|
+
|
|
523
759
|
//#endregion
|
|
524
760
|
//#region src/response.ts
|
|
525
761
|
/**
|
|
@@ -539,9 +775,7 @@ var StreamResponseImpl = class {
|
|
|
539
775
|
#statusText;
|
|
540
776
|
#ok;
|
|
541
777
|
#isLoading;
|
|
542
|
-
#
|
|
543
|
-
#cursor;
|
|
544
|
-
#upToDate;
|
|
778
|
+
#syncState;
|
|
545
779
|
#isJsonMode;
|
|
546
780
|
#abortController;
|
|
547
781
|
#fetchNext;
|
|
@@ -556,20 +790,21 @@ var StreamResponseImpl = class {
|
|
|
556
790
|
#unsubscribeFromVisibilityChanges;
|
|
557
791
|
#pausePromise;
|
|
558
792
|
#pauseResolve;
|
|
559
|
-
#justResumedFromPause = false;
|
|
560
793
|
#sseResilience;
|
|
561
|
-
#
|
|
562
|
-
#consecutiveShortSSEConnections = 0;
|
|
563
|
-
#sseFallbackToLongPoll = false;
|
|
794
|
+
#encoding;
|
|
564
795
|
#responseStream;
|
|
565
796
|
constructor(config) {
|
|
566
797
|
this.url = config.url;
|
|
567
798
|
this.contentType = config.contentType;
|
|
568
799
|
this.live = config.live;
|
|
569
800
|
this.startOffset = config.startOffset;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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);
|
|
573
808
|
this.#headers = config.firstResponse.headers;
|
|
574
809
|
this.#status = config.firstResponse.status;
|
|
575
810
|
this.#statusText = config.firstResponse.statusText;
|
|
@@ -586,6 +821,7 @@ var StreamResponseImpl = class {
|
|
|
586
821
|
backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
|
|
587
822
|
logWarnings: config.sseResilience?.logWarnings ?? true
|
|
588
823
|
};
|
|
824
|
+
this.#encoding = config.encoding;
|
|
589
825
|
this.#closed = new Promise((resolve, reject) => {
|
|
590
826
|
this.#closedResolve = resolve;
|
|
591
827
|
this.#closedReject = reject;
|
|
@@ -625,6 +861,7 @@ var StreamResponseImpl = class {
|
|
|
625
861
|
#pause() {
|
|
626
862
|
if (this.#state === `active`) {
|
|
627
863
|
this.#state = `pause-requested`;
|
|
864
|
+
this.#syncState = this.#syncState.pause();
|
|
628
865
|
this.#pausePromise = new Promise((resolve) => {
|
|
629
866
|
this.#pauseResolve = resolve;
|
|
630
867
|
});
|
|
@@ -638,8 +875,8 @@ var StreamResponseImpl = class {
|
|
|
638
875
|
#resume() {
|
|
639
876
|
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
640
877
|
if (this.#abortController.signal.aborted) return;
|
|
878
|
+
if (this.#syncState instanceof PausedState) this.#syncState = this.#syncState.resume().state;
|
|
641
879
|
this.#state = `active`;
|
|
642
|
-
this.#justResumedFromPause = true;
|
|
643
880
|
this.#pauseResolve?.();
|
|
644
881
|
this.#pausePromise = void 0;
|
|
645
882
|
this.#pauseResolve = void 0;
|
|
@@ -661,13 +898,16 @@ var StreamResponseImpl = class {
|
|
|
661
898
|
return this.#isLoading;
|
|
662
899
|
}
|
|
663
900
|
get offset() {
|
|
664
|
-
return this.#offset;
|
|
901
|
+
return this.#syncState.offset;
|
|
665
902
|
}
|
|
666
903
|
get cursor() {
|
|
667
|
-
return this.#cursor;
|
|
904
|
+
return this.#syncState.cursor;
|
|
668
905
|
}
|
|
669
906
|
get upToDate() {
|
|
670
|
-
return this.#upToDate;
|
|
907
|
+
return this.#syncState.upToDate;
|
|
908
|
+
}
|
|
909
|
+
get streamClosed() {
|
|
910
|
+
return this.#syncState.streamClosed;
|
|
671
911
|
}
|
|
672
912
|
#ensureJsonMode() {
|
|
673
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`);
|
|
@@ -690,105 +930,64 @@ var StreamResponseImpl = class {
|
|
|
690
930
|
}
|
|
691
931
|
/**
|
|
692
932
|
* Determine if we should continue with live updates based on live mode
|
|
693
|
-
* and whether we've received upToDate.
|
|
933
|
+
* and whether we've received upToDate or streamClosed.
|
|
694
934
|
*/
|
|
695
935
|
#shouldContinueLive() {
|
|
696
|
-
|
|
697
|
-
if (this.live === false) return false;
|
|
698
|
-
return true;
|
|
936
|
+
return this.#syncState.shouldContinueLive(this.#stopAfterUpToDate, this.live);
|
|
699
937
|
}
|
|
700
938
|
/**
|
|
701
939
|
* Update state from response headers.
|
|
702
940
|
*/
|
|
703
941
|
#updateStateFromResponse(response) {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
+
});
|
|
709
948
|
this.#headers = response.headers;
|
|
710
949
|
this.#status = response.status;
|
|
711
950
|
this.#statusText = response.statusText;
|
|
712
951
|
this.#ok = response.ok;
|
|
713
952
|
}
|
|
714
953
|
/**
|
|
715
|
-
* Extract stream metadata from Response headers.
|
|
716
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate for each
|
|
717
|
-
* specific Response, rather than reading from `this` which may be stale due to
|
|
718
|
-
* ReadableStream prefetching or timing issues.
|
|
719
|
-
*/
|
|
720
|
-
#getMetadataFromResponse(response) {
|
|
721
|
-
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
722
|
-
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
723
|
-
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
724
|
-
return {
|
|
725
|
-
offset: offset ?? this.offset,
|
|
726
|
-
cursor: cursor ?? this.cursor,
|
|
727
|
-
upToDate
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* Create a synthetic Response from SSE data with proper headers.
|
|
732
|
-
* Includes offset/cursor/upToDate in headers so subscribers can read them.
|
|
733
|
-
*/
|
|
734
|
-
#createSSESyntheticResponse(data, offset, cursor, upToDate) {
|
|
735
|
-
const headers = {
|
|
736
|
-
"content-type": this.contentType ?? `application/json`,
|
|
737
|
-
[STREAM_OFFSET_HEADER]: String(offset)
|
|
738
|
-
};
|
|
739
|
-
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
740
|
-
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
741
|
-
return new Response(data, {
|
|
742
|
-
status: 200,
|
|
743
|
-
headers
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
/**
|
|
747
954
|
* Update instance state from an SSE control event.
|
|
748
955
|
*/
|
|
749
956
|
#updateStateFromSSEControl(controlEvent) {
|
|
750
|
-
this.#
|
|
751
|
-
if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
|
|
752
|
-
if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
|
|
957
|
+
this.#syncState = this.#syncState.withSSEControl(controlEvent);
|
|
753
958
|
}
|
|
754
959
|
/**
|
|
755
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.
|
|
756
963
|
*/
|
|
757
964
|
#markSSEConnectionStart() {
|
|
758
|
-
this.#
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (this.#lastSSEConnectionStartTime === void 0) return 0;
|
|
766
|
-
const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime;
|
|
767
|
-
const wasAborted = this.#abortController.signal.aborted;
|
|
768
|
-
if (connectionDuration < this.#sseResilience.minConnectionDuration && !wasAborted) {
|
|
769
|
-
this.#consecutiveShortSSEConnections++;
|
|
770
|
-
if (this.#consecutiveShortSSEConnections >= this.#sseResilience.maxShortConnections) {
|
|
771
|
-
this.#sseFallbackToLongPoll = true;
|
|
772
|
-
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.");
|
|
773
|
-
return null;
|
|
774
|
-
} else {
|
|
775
|
-
const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, this.#consecutiveShortSSEConnections));
|
|
776
|
-
const delayMs = Math.floor(Math.random() * maxDelay);
|
|
777
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
778
|
-
return delayMs;
|
|
779
|
-
}
|
|
780
|
-
} else if (connectionDuration >= this.#sseResilience.minConnectionDuration) this.#consecutiveShortSSEConnections = 0;
|
|
781
|
-
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());
|
|
782
972
|
}
|
|
783
973
|
/**
|
|
784
974
|
* Try to reconnect SSE and return the new iterator, or null if reconnection
|
|
785
975
|
* is not possible or fails.
|
|
786
976
|
*/
|
|
787
977
|
async #trySSEReconnect() {
|
|
788
|
-
if (this.#
|
|
978
|
+
if (!this.#syncState.shouldUseSse()) return null;
|
|
789
979
|
if (!this.#shouldContinueLive() || !this.#startSSE) return null;
|
|
790
|
-
const
|
|
791
|
-
|
|
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
|
+
}
|
|
792
991
|
this.#markSSEConnectionStart();
|
|
793
992
|
this.#requestAbortController = new AbortController();
|
|
794
993
|
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
@@ -822,19 +1021,29 @@ var StreamResponseImpl = class {
|
|
|
822
1021
|
}
|
|
823
1022
|
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
|
|
824
1023
|
this.#updateStateFromSSEControl(event);
|
|
1024
|
+
if (event.upToDate) {
|
|
1025
|
+
const response = createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false, this.contentType, this.#encoding);
|
|
1026
|
+
return {
|
|
1027
|
+
type: `response`,
|
|
1028
|
+
response
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
825
1031
|
return { type: `continue` };
|
|
826
1032
|
}
|
|
827
1033
|
/**
|
|
828
1034
|
* Process an SSE data event by waiting for its corresponding control event.
|
|
829
1035
|
* In SSE protocol, control events come AFTER data events.
|
|
830
1036
|
* Multiple data events may arrive before a single control event - we buffer them.
|
|
1037
|
+
*
|
|
1038
|
+
* For base64 mode, each data event is independently base64 encoded, so we
|
|
1039
|
+
* collect them as an array and decode each separately.
|
|
831
1040
|
*/
|
|
832
1041
|
async #processSSEDataEvent(pendingData, sseEventIterator) {
|
|
833
|
-
|
|
1042
|
+
const bufferedDataParts = [pendingData];
|
|
834
1043
|
while (true) {
|
|
835
1044
|
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
|
|
836
1045
|
if (controlDone) {
|
|
837
|
-
const response =
|
|
1046
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed, this.contentType, this.#encoding, this.#isJsonMode);
|
|
838
1047
|
try {
|
|
839
1048
|
const newIterator = await this.#trySSEReconnect();
|
|
840
1049
|
return {
|
|
@@ -851,13 +1060,13 @@ var StreamResponseImpl = class {
|
|
|
851
1060
|
}
|
|
852
1061
|
if (controlEvent.type === `control`) {
|
|
853
1062
|
this.#updateStateFromSSEControl(controlEvent);
|
|
854
|
-
const response =
|
|
1063
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false, this.contentType, this.#encoding, this.#isJsonMode);
|
|
855
1064
|
return {
|
|
856
1065
|
type: `response`,
|
|
857
1066
|
response
|
|
858
1067
|
};
|
|
859
1068
|
}
|
|
860
|
-
|
|
1069
|
+
bufferedDataParts.push(controlEvent.data);
|
|
861
1070
|
}
|
|
862
1071
|
}
|
|
863
1072
|
/**
|
|
@@ -929,6 +1138,7 @@ var StreamResponseImpl = class {
|
|
|
929
1138
|
}
|
|
930
1139
|
}
|
|
931
1140
|
if (this.#shouldContinueLive()) {
|
|
1141
|
+
let resumingFromPause = false;
|
|
932
1142
|
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
933
1143
|
this.#state = `paused`;
|
|
934
1144
|
if (this.#pausePromise) await this.#pausePromise;
|
|
@@ -937,14 +1147,13 @@ var StreamResponseImpl = class {
|
|
|
937
1147
|
controller.close();
|
|
938
1148
|
return;
|
|
939
1149
|
}
|
|
1150
|
+
resumingFromPause = true;
|
|
940
1151
|
}
|
|
941
1152
|
if (this.#abortController.signal.aborted) {
|
|
942
1153
|
this.#markClosed();
|
|
943
1154
|
controller.close();
|
|
944
1155
|
return;
|
|
945
1156
|
}
|
|
946
|
-
const resumingFromPause = this.#justResumedFromPause;
|
|
947
|
-
this.#justResumedFromPause = false;
|
|
948
1157
|
this.#requestAbortController = new AbortController();
|
|
949
1158
|
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
|
|
950
1159
|
this.#updateStateFromResponse(response);
|
|
@@ -1110,23 +1319,28 @@ var StreamResponseImpl = class {
|
|
|
1110
1319
|
controller.enqueue(pendingItems.shift());
|
|
1111
1320
|
return;
|
|
1112
1321
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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();
|
|
1127
1340
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1341
|
+
this.#markClosed();
|
|
1342
|
+
controller.close();
|
|
1343
|
+
return;
|
|
1130
1344
|
},
|
|
1131
1345
|
cancel: () => {
|
|
1132
1346
|
reader.releaseLock();
|
|
@@ -1160,7 +1374,7 @@ var StreamResponseImpl = class {
|
|
|
1160
1374
|
while (!result.done) {
|
|
1161
1375
|
if (abortController.signal.aborted) break;
|
|
1162
1376
|
const response = result.value;
|
|
1163
|
-
const { offset, cursor, upToDate } =
|
|
1377
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1164
1378
|
const text = await response.text();
|
|
1165
1379
|
const content = text.trim() || `[]`;
|
|
1166
1380
|
let parsed;
|
|
@@ -1175,7 +1389,8 @@ var StreamResponseImpl = class {
|
|
|
1175
1389
|
items,
|
|
1176
1390
|
offset,
|
|
1177
1391
|
cursor,
|
|
1178
|
-
upToDate
|
|
1392
|
+
upToDate,
|
|
1393
|
+
streamClosed
|
|
1179
1394
|
});
|
|
1180
1395
|
result = await reader.read();
|
|
1181
1396
|
}
|
|
@@ -1205,13 +1420,14 @@ var StreamResponseImpl = class {
|
|
|
1205
1420
|
while (!result.done) {
|
|
1206
1421
|
if (abortController.signal.aborted) break;
|
|
1207
1422
|
const response = result.value;
|
|
1208
|
-
const { offset, cursor, upToDate } =
|
|
1423
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1209
1424
|
const buffer = await response.arrayBuffer();
|
|
1210
1425
|
await subscriber({
|
|
1211
1426
|
data: new Uint8Array(buffer),
|
|
1212
1427
|
offset,
|
|
1213
1428
|
cursor,
|
|
1214
|
-
upToDate
|
|
1429
|
+
upToDate,
|
|
1430
|
+
streamClosed
|
|
1215
1431
|
});
|
|
1216
1432
|
result = await reader.read();
|
|
1217
1433
|
}
|
|
@@ -1241,13 +1457,14 @@ var StreamResponseImpl = class {
|
|
|
1241
1457
|
while (!result.done) {
|
|
1242
1458
|
if (abortController.signal.aborted) break;
|
|
1243
1459
|
const response = result.value;
|
|
1244
|
-
const { offset, cursor, upToDate } =
|
|
1460
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
1245
1461
|
const text = await response.text();
|
|
1246
1462
|
await subscriber({
|
|
1247
1463
|
text,
|
|
1248
1464
|
offset,
|
|
1249
1465
|
cursor,
|
|
1250
|
-
upToDate
|
|
1466
|
+
upToDate,
|
|
1467
|
+
streamClosed
|
|
1251
1468
|
});
|
|
1252
1469
|
result = await reader.read();
|
|
1253
1470
|
}
|
|
@@ -1276,6 +1493,97 @@ var StreamResponseImpl = class {
|
|
|
1276
1493
|
return this.#closed;
|
|
1277
1494
|
}
|
|
1278
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
|
+
}
|
|
1279
1587
|
|
|
1280
1588
|
//#endregion
|
|
1281
1589
|
//#region src/utils.ts
|
|
@@ -1298,6 +1606,11 @@ async function handleErrorResponse(response, url, context) {
|
|
|
1298
1606
|
const status = response.status;
|
|
1299
1607
|
if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
|
|
1300
1608
|
if (status === 409) {
|
|
1609
|
+
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
|
|
1610
|
+
if (streamClosedHeader?.toLowerCase() === `true`) {
|
|
1611
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
1612
|
+
throw new StreamClosedError(url, finalOffset);
|
|
1613
|
+
}
|
|
1301
1614
|
const message = context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended`;
|
|
1302
1615
|
const code = context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ`;
|
|
1303
1616
|
throw new DurableStreamError(message, code, 409);
|
|
@@ -1484,7 +1797,10 @@ async function streamInternal(options) {
|
|
|
1484
1797
|
const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
|
|
1485
1798
|
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
|
|
1486
1799
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
1800
|
+
const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
1487
1801
|
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
|
|
1802
|
+
const sseDataEncoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER);
|
|
1803
|
+
const encoding = sseDataEncoding === `base64` ? `base64` : void 0;
|
|
1488
1804
|
const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
|
|
1489
1805
|
const nextUrl = new URL(url);
|
|
1490
1806
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
@@ -1529,11 +1845,13 @@ async function streamInternal(options) {
|
|
|
1529
1845
|
initialOffset,
|
|
1530
1846
|
initialCursor,
|
|
1531
1847
|
initialUpToDate,
|
|
1848
|
+
initialStreamClosed,
|
|
1532
1849
|
firstResponse,
|
|
1533
1850
|
abortController,
|
|
1534
1851
|
fetchNext,
|
|
1535
1852
|
startSSE,
|
|
1536
|
-
sseResilience: options.sseResilience
|
|
1853
|
+
sseResilience: options.sseResilience,
|
|
1854
|
+
encoding
|
|
1537
1855
|
});
|
|
1538
1856
|
}
|
|
1539
1857
|
|
|
@@ -1624,6 +1942,8 @@ var IdempotentProducer = class {
|
|
|
1624
1942
|
#queue;
|
|
1625
1943
|
#maxInFlight;
|
|
1626
1944
|
#closed = false;
|
|
1945
|
+
#closeResult = null;
|
|
1946
|
+
#pendingFinalMessage;
|
|
1627
1947
|
#epochClaimed;
|
|
1628
1948
|
#seqState = new Map();
|
|
1629
1949
|
/**
|
|
@@ -1713,11 +2033,17 @@ var IdempotentProducer = class {
|
|
|
1713
2033
|
await this.#queue.drained();
|
|
1714
2034
|
}
|
|
1715
2035
|
/**
|
|
1716
|
-
*
|
|
2036
|
+
* Stop the producer without closing the underlying stream.
|
|
1717
2037
|
*
|
|
1718
|
-
*
|
|
2038
|
+
* Use this when you want to:
|
|
2039
|
+
* - Hand off writing to another producer
|
|
2040
|
+
* - Keep the stream open for future writes
|
|
2041
|
+
* - Stop this producer but not signal EOF to readers
|
|
2042
|
+
*
|
|
2043
|
+
* Flushes any pending messages before detaching.
|
|
2044
|
+
* After calling detach(), further append() calls will throw.
|
|
1719
2045
|
*/
|
|
1720
|
-
async
|
|
2046
|
+
async detach() {
|
|
1721
2047
|
if (this.#closed) return;
|
|
1722
2048
|
this.#closed = true;
|
|
1723
2049
|
try {
|
|
@@ -1725,6 +2051,89 @@ var IdempotentProducer = class {
|
|
|
1725
2051
|
} catch {}
|
|
1726
2052
|
}
|
|
1727
2053
|
/**
|
|
2054
|
+
* Flush pending messages and close the underlying stream (EOF).
|
|
2055
|
+
*
|
|
2056
|
+
* This is the typical way to end a producer session. It:
|
|
2057
|
+
* 1. Flushes all pending messages
|
|
2058
|
+
* 2. Optionally appends a final message
|
|
2059
|
+
* 3. Closes the stream (no further appends permitted)
|
|
2060
|
+
*
|
|
2061
|
+
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
|
|
2062
|
+
* idempotent even with a final message because it uses producer headers
|
|
2063
|
+
* for deduplication. Safe to retry on network failures.
|
|
2064
|
+
*
|
|
2065
|
+
* @param finalMessage - Optional final message to append atomically with close
|
|
2066
|
+
* @returns CloseResult with the final offset
|
|
2067
|
+
*/
|
|
2068
|
+
async close(finalMessage) {
|
|
2069
|
+
if (this.#closed) {
|
|
2070
|
+
if (this.#closeResult) return this.#closeResult;
|
|
2071
|
+
await this.flush();
|
|
2072
|
+
const result$1 = await this.#doClose(this.#pendingFinalMessage);
|
|
2073
|
+
this.#closeResult = result$1;
|
|
2074
|
+
return result$1;
|
|
2075
|
+
}
|
|
2076
|
+
this.#closed = true;
|
|
2077
|
+
this.#pendingFinalMessage = finalMessage;
|
|
2078
|
+
await this.flush();
|
|
2079
|
+
const result = await this.#doClose(finalMessage);
|
|
2080
|
+
this.#closeResult = result;
|
|
2081
|
+
return result;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Actually close the stream with optional final message.
|
|
2085
|
+
* Uses producer headers for idempotency.
|
|
2086
|
+
*/
|
|
2087
|
+
async #doClose(finalMessage) {
|
|
2088
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`;
|
|
2089
|
+
const isJson = normalizeContentType$1(contentType) === `application/json`;
|
|
2090
|
+
let body;
|
|
2091
|
+
if (finalMessage !== void 0) {
|
|
2092
|
+
const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
|
|
2093
|
+
if (isJson) {
|
|
2094
|
+
const jsonStr = new TextDecoder().decode(bodyBytes);
|
|
2095
|
+
body = `[${jsonStr}]`;
|
|
2096
|
+
} else body = bodyBytes;
|
|
2097
|
+
}
|
|
2098
|
+
const seqForThisRequest = this.#nextSeq;
|
|
2099
|
+
const headers = {
|
|
2100
|
+
"content-type": contentType,
|
|
2101
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
2102
|
+
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
|
|
2103
|
+
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
|
|
2104
|
+
[STREAM_CLOSED_HEADER]: `true`
|
|
2105
|
+
};
|
|
2106
|
+
const response = await this.#fetchClient(this.#stream.url, {
|
|
2107
|
+
method: `POST`,
|
|
2108
|
+
headers,
|
|
2109
|
+
body,
|
|
2110
|
+
signal: this.#signal
|
|
2111
|
+
});
|
|
2112
|
+
if (response.status === 204) {
|
|
2113
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
2114
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2115
|
+
return { finalOffset };
|
|
2116
|
+
}
|
|
2117
|
+
if (response.status === 200) {
|
|
2118
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
2119
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2120
|
+
return { finalOffset };
|
|
2121
|
+
}
|
|
2122
|
+
if (response.status === 403) {
|
|
2123
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
|
|
2124
|
+
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
|
|
2125
|
+
if (this.#autoClaim) {
|
|
2126
|
+
const newEpoch = currentEpoch + 1;
|
|
2127
|
+
this.#epoch = newEpoch;
|
|
2128
|
+
this.#nextSeq = 0;
|
|
2129
|
+
return this.#doClose(finalMessage);
|
|
2130
|
+
}
|
|
2131
|
+
throw new StaleEpochError(currentEpoch);
|
|
2132
|
+
}
|
|
2133
|
+
const error = await FetchError.fromResponse(response, this.#stream.url);
|
|
2134
|
+
throw error;
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
1728
2137
|
* Increment epoch and reset sequence.
|
|
1729
2138
|
*
|
|
1730
2139
|
* Call this when restarting the producer to establish a new session.
|
|
@@ -2030,7 +2439,8 @@ var DurableStream = class DurableStream {
|
|
|
2030
2439
|
contentType: opts.contentType,
|
|
2031
2440
|
ttlSeconds: opts.ttlSeconds,
|
|
2032
2441
|
expiresAt: opts.expiresAt,
|
|
2033
|
-
body: opts.body
|
|
2442
|
+
body: opts.body,
|
|
2443
|
+
closed: opts.closed
|
|
2034
2444
|
});
|
|
2035
2445
|
return stream$1;
|
|
2036
2446
|
}
|
|
@@ -2083,13 +2493,15 @@ var DurableStream = class DurableStream {
|
|
|
2083
2493
|
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
2084
2494
|
const etag = response.headers.get(`etag`) ?? void 0;
|
|
2085
2495
|
const cacheControl = response.headers.get(`cache-control`) ?? void 0;
|
|
2496
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2086
2497
|
if (contentType) this.contentType = contentType;
|
|
2087
2498
|
return {
|
|
2088
2499
|
exists: true,
|
|
2089
2500
|
contentType,
|
|
2090
2501
|
offset,
|
|
2091
2502
|
etag,
|
|
2092
|
-
cacheControl
|
|
2503
|
+
cacheControl,
|
|
2504
|
+
streamClosed
|
|
2093
2505
|
};
|
|
2094
2506
|
}
|
|
2095
2507
|
/**
|
|
@@ -2101,6 +2513,7 @@ var DurableStream = class DurableStream {
|
|
|
2101
2513
|
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2102
2514
|
if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
|
|
2103
2515
|
if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
|
|
2516
|
+
if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
2104
2517
|
const body = encodeBody(opts?.body);
|
|
2105
2518
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2106
2519
|
method: `PUT`,
|
|
@@ -2127,6 +2540,57 @@ var DurableStream = class DurableStream {
|
|
|
2127
2540
|
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
2128
2541
|
}
|
|
2129
2542
|
/**
|
|
2543
|
+
* Close the stream, optionally with a final message.
|
|
2544
|
+
*
|
|
2545
|
+
* After closing:
|
|
2546
|
+
* - No further appends are permitted (server returns 409)
|
|
2547
|
+
* - Readers can observe the closed state and treat it as EOF
|
|
2548
|
+
* - The stream's data remains fully readable
|
|
2549
|
+
*
|
|
2550
|
+
* Closing is:
|
|
2551
|
+
* - **Durable**: The closed state is persisted
|
|
2552
|
+
* - **Monotonic**: Once closed, a stream cannot be reopened
|
|
2553
|
+
*
|
|
2554
|
+
* **Idempotency:**
|
|
2555
|
+
* - `close()` without body: Idempotent — safe to call multiple times
|
|
2556
|
+
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
|
|
2557
|
+
* if stream is already closed (use `IdempotentProducer.close()` for
|
|
2558
|
+
* idempotent close-with-body semantics)
|
|
2559
|
+
*
|
|
2560
|
+
* @returns CloseResult with the final offset
|
|
2561
|
+
* @throws StreamClosedError if called with body on an already-closed stream
|
|
2562
|
+
*/
|
|
2563
|
+
async close(opts) {
|
|
2564
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
2565
|
+
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
2566
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2567
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
2568
|
+
let body;
|
|
2569
|
+
if (opts?.body !== void 0) {
|
|
2570
|
+
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
2571
|
+
if (isJson) {
|
|
2572
|
+
const bodyStr = typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body);
|
|
2573
|
+
body = `[${bodyStr}]`;
|
|
2574
|
+
} else body = typeof opts.body === `string` ? opts.body : opts.body;
|
|
2575
|
+
}
|
|
2576
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2577
|
+
method: `POST`,
|
|
2578
|
+
headers: requestHeaders,
|
|
2579
|
+
body,
|
|
2580
|
+
signal: opts?.signal ?? this.#options.signal
|
|
2581
|
+
});
|
|
2582
|
+
if (response.status === 409) {
|
|
2583
|
+
const isClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2584
|
+
if (isClosed) {
|
|
2585
|
+
const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
2586
|
+
throw new StreamClosedError(this.url, finalOffset$1);
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
2590
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2591
|
+
return { finalOffset };
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2130
2594
|
* Append a single payload to the stream.
|
|
2131
2595
|
*
|
|
2132
2596
|
* When batching is enabled (default), multiple append() calls made while
|
|
@@ -2166,8 +2630,12 @@ var DurableStream = class DurableStream {
|
|
|
2166
2630
|
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2167
2631
|
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
|
|
2168
2632
|
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
2169
|
-
|
|
2170
|
-
|
|
2633
|
+
let encodedBody;
|
|
2634
|
+
if (isJson) {
|
|
2635
|
+
const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
|
|
2636
|
+
encodedBody = `[${bodyStr}]`;
|
|
2637
|
+
} else if (typeof body === `string`) encodedBody = body;
|
|
2638
|
+
else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
2171
2639
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2172
2640
|
method: `POST`,
|
|
2173
2641
|
headers: requestHeaders,
|
|
@@ -2237,8 +2705,31 @@ var DurableStream = class DurableStream {
|
|
|
2237
2705
|
const jsonStrings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
|
|
2238
2706
|
batchedBody = `[${jsonStrings.join(`,`)}]`;
|
|
2239
2707
|
} else {
|
|
2240
|
-
const
|
|
2241
|
-
|
|
2708
|
+
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
|
|
2709
|
+
const hasString = batch.some((m) => typeof m.data === `string`);
|
|
2710
|
+
if (hasUint8Array && !hasString) {
|
|
2711
|
+
const chunks = batch.map((m) => m.data);
|
|
2712
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
2713
|
+
const combined = new Uint8Array(totalLength);
|
|
2714
|
+
let offset = 0;
|
|
2715
|
+
for (const chunk of chunks) {
|
|
2716
|
+
combined.set(chunk, offset);
|
|
2717
|
+
offset += chunk.length;
|
|
2718
|
+
}
|
|
2719
|
+
batchedBody = combined;
|
|
2720
|
+
} else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
|
|
2721
|
+
else {
|
|
2722
|
+
const encoder = new TextEncoder();
|
|
2723
|
+
const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
|
|
2724
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
2725
|
+
const combined = new Uint8Array(totalLength);
|
|
2726
|
+
let offset = 0;
|
|
2727
|
+
for (const chunk of chunks) {
|
|
2728
|
+
combined.set(chunk, offset);
|
|
2729
|
+
offset += chunk.length;
|
|
2730
|
+
}
|
|
2731
|
+
batchedBody = combined;
|
|
2732
|
+
}
|
|
2242
2733
|
}
|
|
2243
2734
|
const signals = [];
|
|
2244
2735
|
if (this.#options.signal) signals.push(this.#options.signal);
|
|
@@ -2346,12 +2837,11 @@ var DurableStream = class DurableStream {
|
|
|
2346
2837
|
producer.append(chunk);
|
|
2347
2838
|
},
|
|
2348
2839
|
async close() {
|
|
2349
|
-
await producer.flush();
|
|
2350
2840
|
await producer.close();
|
|
2351
2841
|
if (writeError) throw writeError;
|
|
2352
2842
|
},
|
|
2353
2843
|
abort(_reason) {
|
|
2354
|
-
producer.
|
|
2844
|
+
producer.detach().catch((err) => {
|
|
2355
2845
|
opts?.onError?.(err);
|
|
2356
2846
|
});
|
|
2357
2847
|
}
|
|
@@ -2393,10 +2883,6 @@ var DurableStream = class DurableStream {
|
|
|
2393
2883
|
* ```
|
|
2394
2884
|
*/
|
|
2395
2885
|
async stream(options) {
|
|
2396
|
-
if (options?.live === `sse` && this.contentType) {
|
|
2397
|
-
const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) => this.contentType.startsWith(prefix));
|
|
2398
|
-
if (!isSSECompatible) throw new DurableStreamError(`SSE is not supported for content-type: ${this.contentType}`, `SSE_NOT_SUPPORTED`, 400);
|
|
2399
|
-
}
|
|
2400
2886
|
const mergedHeaders = {
|
|
2401
2887
|
...this.#options.headers,
|
|
2402
2888
|
...options?.headers
|
|
@@ -2481,4 +2967,4 @@ function validateOptions(options) {
|
|
|
2481
2967
|
}
|
|
2482
2968
|
|
|
2483
2969
|
//#endregion
|
|
2484
|
-
export { BackoffDefaults, CURSOR_QUERY_PARAM, DURABLE_STREAM_PROTOCOL_QUERY_PARAMS, DurableStream, DurableStreamError, FetchBackoffAbortError, FetchError, IdempotentProducer, InvalidSignalError, LIVE_QUERY_PARAM, MissingStreamUrlError, OFFSET_QUERY_PARAM, PRODUCER_EPOCH_HEADER, PRODUCER_EXPECTED_SEQ_HEADER, PRODUCER_ID_HEADER, PRODUCER_RECEIVED_SEQ_HEADER, PRODUCER_SEQ_HEADER, SSE_COMPATIBLE_CONTENT_TYPES, STREAM_CURSOR_HEADER, STREAM_EXPIRES_AT_HEADER, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_TTL_HEADER, STREAM_UP_TO_DATE_HEADER, SequenceGapError, StaleEpochError, _resetHttpWarningForTesting, asAsyncIterableReadableStream, createFetchWithBackoff, createFetchWithConsumedBody, stream, warnIfUsingHttpInBrowser };
|
|
2970
|
+
export { BackoffDefaults, CURSOR_QUERY_PARAM, DURABLE_STREAM_PROTOCOL_QUERY_PARAMS, DurableStream, DurableStreamError, FetchBackoffAbortError, FetchError, IdempotentProducer, InvalidSignalError, LIVE_QUERY_PARAM, MissingStreamUrlError, OFFSET_QUERY_PARAM, PRODUCER_EPOCH_HEADER, PRODUCER_EXPECTED_SEQ_HEADER, PRODUCER_ID_HEADER, PRODUCER_RECEIVED_SEQ_HEADER, PRODUCER_SEQ_HEADER, SSE_CLOSED_FIELD, SSE_COMPATIBLE_CONTENT_TYPES, STREAM_CLOSED_HEADER, STREAM_CURSOR_HEADER, STREAM_EXPIRES_AT_HEADER, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_TTL_HEADER, STREAM_UP_TO_DATE_HEADER, SequenceGapError, StaleEpochError, StreamClosedError, _resetHttpWarningForTesting, asAsyncIterableReadableStream, createFetchWithBackoff, createFetchWithConsumedBody, stream, warnIfUsingHttpInBrowser };
|