@durable-streams/client 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +126 -21
- package/dist/index.js +126 -21
- package/package.json +2 -2
- package/src/response.ts +189 -5
- package/src/stream-api.ts +11 -5
package/dist/index.cjs
CHANGED
|
@@ -541,6 +541,10 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
541
541
|
//#endregion
|
|
542
542
|
//#region src/response.ts
|
|
543
543
|
/**
|
|
544
|
+
* Constant used as abort reason when pausing the stream due to visibility change.
|
|
545
|
+
*/
|
|
546
|
+
const PAUSE_STREAM = `PAUSE_STREAM`;
|
|
547
|
+
/**
|
|
544
548
|
* Implementation of the StreamResponse interface.
|
|
545
549
|
*/
|
|
546
550
|
var StreamResponseImpl = class {
|
|
@@ -565,6 +569,12 @@ var StreamResponseImpl = class {
|
|
|
565
569
|
#closed;
|
|
566
570
|
#stopAfterUpToDate = false;
|
|
567
571
|
#consumptionMethod = null;
|
|
572
|
+
#state = `active`;
|
|
573
|
+
#requestAbortController;
|
|
574
|
+
#unsubscribeFromVisibilityChanges;
|
|
575
|
+
#pausePromise;
|
|
576
|
+
#pauseResolve;
|
|
577
|
+
#justResumedFromPause = false;
|
|
568
578
|
#sseResilience;
|
|
569
579
|
#lastSSEConnectionStartTime;
|
|
570
580
|
#consecutiveShortSSEConnections = 0;
|
|
@@ -599,6 +609,59 @@ var StreamResponseImpl = class {
|
|
|
599
609
|
this.#closedReject = reject;
|
|
600
610
|
});
|
|
601
611
|
this.#responseStream = this.#createResponseStream(config.firstResponse);
|
|
612
|
+
this.#abortController.signal.addEventListener(`abort`, () => {
|
|
613
|
+
this.#requestAbortController?.abort(this.#abortController.signal.reason);
|
|
614
|
+
this.#pauseResolve?.();
|
|
615
|
+
this.#pausePromise = void 0;
|
|
616
|
+
this.#pauseResolve = void 0;
|
|
617
|
+
}, { once: true });
|
|
618
|
+
this.#subscribeToVisibilityChanges();
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Subscribe to document visibility changes to pause/resume syncing.
|
|
622
|
+
* When the page is hidden, we pause to save battery and bandwidth.
|
|
623
|
+
* When visible again, we resume syncing.
|
|
624
|
+
*/
|
|
625
|
+
#subscribeToVisibilityChanges() {
|
|
626
|
+
if (typeof document === `object` && typeof document.hidden === `boolean` && typeof document.addEventListener === `function`) {
|
|
627
|
+
const visibilityHandler = () => {
|
|
628
|
+
if (document.hidden) this.#pause();
|
|
629
|
+
else this.#resume();
|
|
630
|
+
};
|
|
631
|
+
document.addEventListener(`visibilitychange`, visibilityHandler);
|
|
632
|
+
this.#unsubscribeFromVisibilityChanges = () => {
|
|
633
|
+
if (typeof document === `object`) document.removeEventListener(`visibilitychange`, visibilityHandler);
|
|
634
|
+
};
|
|
635
|
+
if (document.hidden) this.#pause();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Pause the stream when page becomes hidden.
|
|
640
|
+
* Aborts any in-flight request to free resources.
|
|
641
|
+
* Creates a promise that pull() will await while paused.
|
|
642
|
+
*/
|
|
643
|
+
#pause() {
|
|
644
|
+
if (this.#state === `active`) {
|
|
645
|
+
this.#state = `pause-requested`;
|
|
646
|
+
this.#pausePromise = new Promise((resolve) => {
|
|
647
|
+
this.#pauseResolve = resolve;
|
|
648
|
+
});
|
|
649
|
+
this.#requestAbortController?.abort(PAUSE_STREAM);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Resume the stream when page becomes visible.
|
|
654
|
+
* Resolves the pause promise to unblock pull().
|
|
655
|
+
*/
|
|
656
|
+
#resume() {
|
|
657
|
+
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
658
|
+
if (this.#abortController.signal.aborted) return;
|
|
659
|
+
this.#state = `active`;
|
|
660
|
+
this.#justResumedFromPause = true;
|
|
661
|
+
this.#pauseResolve?.();
|
|
662
|
+
this.#pausePromise = void 0;
|
|
663
|
+
this.#pauseResolve = void 0;
|
|
664
|
+
}
|
|
602
665
|
}
|
|
603
666
|
get headers() {
|
|
604
667
|
return this.#headers;
|
|
@@ -619,9 +682,11 @@ var StreamResponseImpl = class {
|
|
|
619
682
|
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`);
|
|
620
683
|
}
|
|
621
684
|
#markClosed() {
|
|
685
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
622
686
|
this.#closedResolve();
|
|
623
687
|
}
|
|
624
688
|
#markError(err) {
|
|
689
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
625
690
|
this.#closedReject(err);
|
|
626
691
|
}
|
|
627
692
|
/**
|
|
@@ -734,8 +799,9 @@ var StreamResponseImpl = class {
|
|
|
734
799
|
const delayOrNull = await this.#handleSSEConnectionEnd();
|
|
735
800
|
if (delayOrNull === null) return null;
|
|
736
801
|
this.#markSSEConnectionStart();
|
|
737
|
-
|
|
738
|
-
|
|
802
|
+
this.#requestAbortController = new AbortController();
|
|
803
|
+
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
804
|
+
if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#requestAbortController.signal);
|
|
739
805
|
return null;
|
|
740
806
|
}
|
|
741
807
|
/**
|
|
@@ -821,7 +887,8 @@ var StreamResponseImpl = class {
|
|
|
821
887
|
const isSSE = firstResponse.headers.get(`content-type`)?.includes(`text/event-stream`) ?? false;
|
|
822
888
|
if (isSSE && firstResponse.body) {
|
|
823
889
|
this.#markSSEConnectionStart();
|
|
824
|
-
|
|
890
|
+
this.#requestAbortController = new AbortController();
|
|
891
|
+
sseEventIterator = parseSSEStream(firstResponse.body, this.#requestAbortController.signal);
|
|
825
892
|
} else {
|
|
826
893
|
controller.enqueue(firstResponse);
|
|
827
894
|
if (this.upToDate && !this.#shouldContinueLive()) {
|
|
@@ -832,33 +899,63 @@ var StreamResponseImpl = class {
|
|
|
832
899
|
return;
|
|
833
900
|
}
|
|
834
901
|
}
|
|
835
|
-
if (sseEventIterator)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
controller.enqueue(result.response);
|
|
841
|
-
return;
|
|
842
|
-
case `closed`:
|
|
902
|
+
if (sseEventIterator) {
|
|
903
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
904
|
+
this.#state = `paused`;
|
|
905
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
906
|
+
if (this.#abortController.signal.aborted) {
|
|
843
907
|
this.#markClosed();
|
|
844
908
|
controller.close();
|
|
845
909
|
return;
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
910
|
+
}
|
|
911
|
+
const newIterator = await this.#trySSEReconnect();
|
|
912
|
+
if (newIterator) sseEventIterator = newIterator;
|
|
913
|
+
else {
|
|
914
|
+
this.#markClosed();
|
|
915
|
+
controller.close();
|
|
849
916
|
return;
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
while (true) {
|
|
920
|
+
const result = await this.#processSSEEvents(sseEventIterator);
|
|
921
|
+
switch (result.type) {
|
|
922
|
+
case `response`:
|
|
923
|
+
if (result.newIterator) sseEventIterator = result.newIterator;
|
|
924
|
+
controller.enqueue(result.response);
|
|
925
|
+
return;
|
|
926
|
+
case `closed`:
|
|
927
|
+
this.#markClosed();
|
|
928
|
+
controller.close();
|
|
929
|
+
return;
|
|
930
|
+
case `error`:
|
|
931
|
+
this.#markError(result.error);
|
|
932
|
+
controller.error(result.error);
|
|
933
|
+
return;
|
|
934
|
+
case `continue`:
|
|
935
|
+
if (result.newIterator) sseEventIterator = result.newIterator;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
853
938
|
}
|
|
854
939
|
}
|
|
855
940
|
if (this.#shouldContinueLive()) {
|
|
941
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
942
|
+
this.#state = `paused`;
|
|
943
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
944
|
+
if (this.#abortController.signal.aborted) {
|
|
945
|
+
this.#markClosed();
|
|
946
|
+
controller.close();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
856
950
|
if (this.#abortController.signal.aborted) {
|
|
857
951
|
this.#markClosed();
|
|
858
952
|
controller.close();
|
|
859
953
|
return;
|
|
860
954
|
}
|
|
861
|
-
const
|
|
955
|
+
const resumingFromPause = this.#justResumedFromPause;
|
|
956
|
+
this.#justResumedFromPause = false;
|
|
957
|
+
this.#requestAbortController = new AbortController();
|
|
958
|
+
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
|
|
862
959
|
this.#updateStateFromResponse(response);
|
|
863
960
|
controller.enqueue(response);
|
|
864
961
|
return;
|
|
@@ -866,6 +963,10 @@ var StreamResponseImpl = class {
|
|
|
866
963
|
this.#markClosed();
|
|
867
964
|
controller.close();
|
|
868
965
|
} catch (err) {
|
|
966
|
+
if (this.#requestAbortController?.signal.aborted && this.#requestAbortController.signal.reason === PAUSE_STREAM) {
|
|
967
|
+
if (this.#state === `pause-requested`) this.#state = `paused`;
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
869
970
|
if (this.#abortController.signal.aborted) {
|
|
870
971
|
this.#markClosed();
|
|
871
972
|
controller.close();
|
|
@@ -877,6 +978,7 @@ var StreamResponseImpl = class {
|
|
|
877
978
|
},
|
|
878
979
|
cancel: () => {
|
|
879
980
|
this.#abortController.abort();
|
|
981
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
880
982
|
this.#markClosed();
|
|
881
983
|
}
|
|
882
984
|
});
|
|
@@ -1158,6 +1260,7 @@ var StreamResponseImpl = class {
|
|
|
1158
1260
|
}
|
|
1159
1261
|
cancel(reason) {
|
|
1160
1262
|
this.#abortController.abort(reason);
|
|
1263
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
1161
1264
|
this.#markClosed();
|
|
1162
1265
|
}
|
|
1163
1266
|
get closed() {
|
|
@@ -1372,11 +1475,13 @@ async function streamInternal(options) {
|
|
|
1372
1475
|
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
|
|
1373
1476
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
1374
1477
|
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
|
|
1375
|
-
const fetchNext = async (offset, cursor, signal) => {
|
|
1478
|
+
const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
|
|
1376
1479
|
const nextUrl = new URL(url);
|
|
1377
1480
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
1378
|
-
if (
|
|
1379
|
-
|
|
1481
|
+
if (!resumingFromPause) {
|
|
1482
|
+
if (live === `auto` || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
|
|
1483
|
+
else if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
|
|
1484
|
+
}
|
|
1380
1485
|
if (cursor) nextUrl.searchParams.set(`cursor`, cursor);
|
|
1381
1486
|
const nextParams = await resolveParams(options.params);
|
|
1382
1487
|
for (const [key, value] of Object.entries(nextParams)) nextUrl.searchParams.set(key, value);
|
package/dist/index.js
CHANGED
|
@@ -517,6 +517,10 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
517
517
|
//#endregion
|
|
518
518
|
//#region src/response.ts
|
|
519
519
|
/**
|
|
520
|
+
* Constant used as abort reason when pausing the stream due to visibility change.
|
|
521
|
+
*/
|
|
522
|
+
const PAUSE_STREAM = `PAUSE_STREAM`;
|
|
523
|
+
/**
|
|
520
524
|
* Implementation of the StreamResponse interface.
|
|
521
525
|
*/
|
|
522
526
|
var StreamResponseImpl = class {
|
|
@@ -541,6 +545,12 @@ var StreamResponseImpl = class {
|
|
|
541
545
|
#closed;
|
|
542
546
|
#stopAfterUpToDate = false;
|
|
543
547
|
#consumptionMethod = null;
|
|
548
|
+
#state = `active`;
|
|
549
|
+
#requestAbortController;
|
|
550
|
+
#unsubscribeFromVisibilityChanges;
|
|
551
|
+
#pausePromise;
|
|
552
|
+
#pauseResolve;
|
|
553
|
+
#justResumedFromPause = false;
|
|
544
554
|
#sseResilience;
|
|
545
555
|
#lastSSEConnectionStartTime;
|
|
546
556
|
#consecutiveShortSSEConnections = 0;
|
|
@@ -575,6 +585,59 @@ var StreamResponseImpl = class {
|
|
|
575
585
|
this.#closedReject = reject;
|
|
576
586
|
});
|
|
577
587
|
this.#responseStream = this.#createResponseStream(config.firstResponse);
|
|
588
|
+
this.#abortController.signal.addEventListener(`abort`, () => {
|
|
589
|
+
this.#requestAbortController?.abort(this.#abortController.signal.reason);
|
|
590
|
+
this.#pauseResolve?.();
|
|
591
|
+
this.#pausePromise = void 0;
|
|
592
|
+
this.#pauseResolve = void 0;
|
|
593
|
+
}, { once: true });
|
|
594
|
+
this.#subscribeToVisibilityChanges();
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Subscribe to document visibility changes to pause/resume syncing.
|
|
598
|
+
* When the page is hidden, we pause to save battery and bandwidth.
|
|
599
|
+
* When visible again, we resume syncing.
|
|
600
|
+
*/
|
|
601
|
+
#subscribeToVisibilityChanges() {
|
|
602
|
+
if (typeof document === `object` && typeof document.hidden === `boolean` && typeof document.addEventListener === `function`) {
|
|
603
|
+
const visibilityHandler = () => {
|
|
604
|
+
if (document.hidden) this.#pause();
|
|
605
|
+
else this.#resume();
|
|
606
|
+
};
|
|
607
|
+
document.addEventListener(`visibilitychange`, visibilityHandler);
|
|
608
|
+
this.#unsubscribeFromVisibilityChanges = () => {
|
|
609
|
+
if (typeof document === `object`) document.removeEventListener(`visibilitychange`, visibilityHandler);
|
|
610
|
+
};
|
|
611
|
+
if (document.hidden) this.#pause();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Pause the stream when page becomes hidden.
|
|
616
|
+
* Aborts any in-flight request to free resources.
|
|
617
|
+
* Creates a promise that pull() will await while paused.
|
|
618
|
+
*/
|
|
619
|
+
#pause() {
|
|
620
|
+
if (this.#state === `active`) {
|
|
621
|
+
this.#state = `pause-requested`;
|
|
622
|
+
this.#pausePromise = new Promise((resolve) => {
|
|
623
|
+
this.#pauseResolve = resolve;
|
|
624
|
+
});
|
|
625
|
+
this.#requestAbortController?.abort(PAUSE_STREAM);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Resume the stream when page becomes visible.
|
|
630
|
+
* Resolves the pause promise to unblock pull().
|
|
631
|
+
*/
|
|
632
|
+
#resume() {
|
|
633
|
+
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
634
|
+
if (this.#abortController.signal.aborted) return;
|
|
635
|
+
this.#state = `active`;
|
|
636
|
+
this.#justResumedFromPause = true;
|
|
637
|
+
this.#pauseResolve?.();
|
|
638
|
+
this.#pausePromise = void 0;
|
|
639
|
+
this.#pauseResolve = void 0;
|
|
640
|
+
}
|
|
578
641
|
}
|
|
579
642
|
get headers() {
|
|
580
643
|
return this.#headers;
|
|
@@ -595,9 +658,11 @@ var StreamResponseImpl = class {
|
|
|
595
658
|
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`);
|
|
596
659
|
}
|
|
597
660
|
#markClosed() {
|
|
661
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
598
662
|
this.#closedResolve();
|
|
599
663
|
}
|
|
600
664
|
#markError(err) {
|
|
665
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
601
666
|
this.#closedReject(err);
|
|
602
667
|
}
|
|
603
668
|
/**
|
|
@@ -710,8 +775,9 @@ var StreamResponseImpl = class {
|
|
|
710
775
|
const delayOrNull = await this.#handleSSEConnectionEnd();
|
|
711
776
|
if (delayOrNull === null) return null;
|
|
712
777
|
this.#markSSEConnectionStart();
|
|
713
|
-
|
|
714
|
-
|
|
778
|
+
this.#requestAbortController = new AbortController();
|
|
779
|
+
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
780
|
+
if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#requestAbortController.signal);
|
|
715
781
|
return null;
|
|
716
782
|
}
|
|
717
783
|
/**
|
|
@@ -797,7 +863,8 @@ var StreamResponseImpl = class {
|
|
|
797
863
|
const isSSE = firstResponse.headers.get(`content-type`)?.includes(`text/event-stream`) ?? false;
|
|
798
864
|
if (isSSE && firstResponse.body) {
|
|
799
865
|
this.#markSSEConnectionStart();
|
|
800
|
-
|
|
866
|
+
this.#requestAbortController = new AbortController();
|
|
867
|
+
sseEventIterator = parseSSEStream(firstResponse.body, this.#requestAbortController.signal);
|
|
801
868
|
} else {
|
|
802
869
|
controller.enqueue(firstResponse);
|
|
803
870
|
if (this.upToDate && !this.#shouldContinueLive()) {
|
|
@@ -808,33 +875,63 @@ var StreamResponseImpl = class {
|
|
|
808
875
|
return;
|
|
809
876
|
}
|
|
810
877
|
}
|
|
811
|
-
if (sseEventIterator)
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
controller.enqueue(result.response);
|
|
817
|
-
return;
|
|
818
|
-
case `closed`:
|
|
878
|
+
if (sseEventIterator) {
|
|
879
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
880
|
+
this.#state = `paused`;
|
|
881
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
882
|
+
if (this.#abortController.signal.aborted) {
|
|
819
883
|
this.#markClosed();
|
|
820
884
|
controller.close();
|
|
821
885
|
return;
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
886
|
+
}
|
|
887
|
+
const newIterator = await this.#trySSEReconnect();
|
|
888
|
+
if (newIterator) sseEventIterator = newIterator;
|
|
889
|
+
else {
|
|
890
|
+
this.#markClosed();
|
|
891
|
+
controller.close();
|
|
825
892
|
return;
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
while (true) {
|
|
896
|
+
const result = await this.#processSSEEvents(sseEventIterator);
|
|
897
|
+
switch (result.type) {
|
|
898
|
+
case `response`:
|
|
899
|
+
if (result.newIterator) sseEventIterator = result.newIterator;
|
|
900
|
+
controller.enqueue(result.response);
|
|
901
|
+
return;
|
|
902
|
+
case `closed`:
|
|
903
|
+
this.#markClosed();
|
|
904
|
+
controller.close();
|
|
905
|
+
return;
|
|
906
|
+
case `error`:
|
|
907
|
+
this.#markError(result.error);
|
|
908
|
+
controller.error(result.error);
|
|
909
|
+
return;
|
|
910
|
+
case `continue`:
|
|
911
|
+
if (result.newIterator) sseEventIterator = result.newIterator;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
829
914
|
}
|
|
830
915
|
}
|
|
831
916
|
if (this.#shouldContinueLive()) {
|
|
917
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
918
|
+
this.#state = `paused`;
|
|
919
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
920
|
+
if (this.#abortController.signal.aborted) {
|
|
921
|
+
this.#markClosed();
|
|
922
|
+
controller.close();
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
832
926
|
if (this.#abortController.signal.aborted) {
|
|
833
927
|
this.#markClosed();
|
|
834
928
|
controller.close();
|
|
835
929
|
return;
|
|
836
930
|
}
|
|
837
|
-
const
|
|
931
|
+
const resumingFromPause = this.#justResumedFromPause;
|
|
932
|
+
this.#justResumedFromPause = false;
|
|
933
|
+
this.#requestAbortController = new AbortController();
|
|
934
|
+
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
|
|
838
935
|
this.#updateStateFromResponse(response);
|
|
839
936
|
controller.enqueue(response);
|
|
840
937
|
return;
|
|
@@ -842,6 +939,10 @@ var StreamResponseImpl = class {
|
|
|
842
939
|
this.#markClosed();
|
|
843
940
|
controller.close();
|
|
844
941
|
} catch (err) {
|
|
942
|
+
if (this.#requestAbortController?.signal.aborted && this.#requestAbortController.signal.reason === PAUSE_STREAM) {
|
|
943
|
+
if (this.#state === `pause-requested`) this.#state = `paused`;
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
845
946
|
if (this.#abortController.signal.aborted) {
|
|
846
947
|
this.#markClosed();
|
|
847
948
|
controller.close();
|
|
@@ -853,6 +954,7 @@ var StreamResponseImpl = class {
|
|
|
853
954
|
},
|
|
854
955
|
cancel: () => {
|
|
855
956
|
this.#abortController.abort();
|
|
957
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
856
958
|
this.#markClosed();
|
|
857
959
|
}
|
|
858
960
|
});
|
|
@@ -1134,6 +1236,7 @@ var StreamResponseImpl = class {
|
|
|
1134
1236
|
}
|
|
1135
1237
|
cancel(reason) {
|
|
1136
1238
|
this.#abortController.abort(reason);
|
|
1239
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
1137
1240
|
this.#markClosed();
|
|
1138
1241
|
}
|
|
1139
1242
|
get closed() {
|
|
@@ -1348,11 +1451,13 @@ async function streamInternal(options) {
|
|
|
1348
1451
|
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
|
|
1349
1452
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
1350
1453
|
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
|
|
1351
|
-
const fetchNext = async (offset, cursor, signal) => {
|
|
1454
|
+
const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
|
|
1352
1455
|
const nextUrl = new URL(url);
|
|
1353
1456
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
1354
|
-
if (
|
|
1355
|
-
|
|
1457
|
+
if (!resumingFromPause) {
|
|
1458
|
+
if (live === `auto` || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
|
|
1459
|
+
else if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
|
|
1460
|
+
}
|
|
1356
1461
|
if (cursor) nextUrl.searchParams.set(`cursor`, cursor);
|
|
1357
1462
|
const nextParams = await resolveParams(options.params);
|
|
1358
1463
|
for (const [key, value] of Object.entries(nextParams)) nextUrl.searchParams.set(key, value);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/client",
|
|
3
3
|
"description": "TypeScript client for the Durable Streams protocol",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.5",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"fast-check": "^4.4.0",
|
|
49
49
|
"tsdown": "^0.9.0",
|
|
50
|
-
"@durable-streams/server": "0.1.
|
|
50
|
+
"@durable-streams/server": "0.1.6"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
|
53
53
|
"node": ">=18.0.0"
|
package/src/response.ts
CHANGED
|
@@ -25,6 +25,16 @@ import type {
|
|
|
25
25
|
TextChunk,
|
|
26
26
|
} from "./types"
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Constant used as abort reason when pausing the stream due to visibility change.
|
|
30
|
+
*/
|
|
31
|
+
const PAUSE_STREAM = `PAUSE_STREAM`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* State machine for visibility-based pause/resume.
|
|
35
|
+
*/
|
|
36
|
+
type StreamState = `active` | `pause-requested` | `paused`
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
39
|
* Internal configuration for creating a StreamResponse.
|
|
30
40
|
*/
|
|
@@ -53,7 +63,8 @@ export interface StreamResponseConfig {
|
|
|
53
63
|
fetchNext: (
|
|
54
64
|
offset: Offset,
|
|
55
65
|
cursor: string | undefined,
|
|
56
|
-
signal: AbortSignal
|
|
66
|
+
signal: AbortSignal,
|
|
67
|
+
resumingFromPause?: boolean
|
|
57
68
|
) => Promise<Response>
|
|
58
69
|
/** Function to start SSE connection and return a Response with SSE body */
|
|
59
70
|
startSSE?: (
|
|
@@ -100,6 +111,14 @@ export class StreamResponseImpl<
|
|
|
100
111
|
#stopAfterUpToDate = false
|
|
101
112
|
#consumptionMethod: string | null = null
|
|
102
113
|
|
|
114
|
+
// --- Visibility/Pause State ---
|
|
115
|
+
#state: StreamState = `active`
|
|
116
|
+
#requestAbortController?: AbortController
|
|
117
|
+
#unsubscribeFromVisibilityChanges?: () => void
|
|
118
|
+
#pausePromise?: Promise<void>
|
|
119
|
+
#pauseResolve?: () => void
|
|
120
|
+
#justResumedFromPause = false
|
|
121
|
+
|
|
103
122
|
// --- SSE Resilience State ---
|
|
104
123
|
#sseResilience: Required<SSEResilienceOptions>
|
|
105
124
|
#lastSSEConnectionStartTime?: number
|
|
@@ -150,6 +169,97 @@ export class StreamResponseImpl<
|
|
|
150
169
|
|
|
151
170
|
// Create the core response stream
|
|
152
171
|
this.#responseStream = this.#createResponseStream(config.firstResponse)
|
|
172
|
+
|
|
173
|
+
// Install single abort listener that propagates to current request controller
|
|
174
|
+
// and unblocks any paused pull() (avoids accumulating one listener per request)
|
|
175
|
+
this.#abortController.signal.addEventListener(
|
|
176
|
+
`abort`,
|
|
177
|
+
() => {
|
|
178
|
+
this.#requestAbortController?.abort(this.#abortController.signal.reason)
|
|
179
|
+
// Unblock pull() if paused, so it can see the abort and close
|
|
180
|
+
this.#pauseResolve?.()
|
|
181
|
+
this.#pausePromise = undefined
|
|
182
|
+
this.#pauseResolve = undefined
|
|
183
|
+
},
|
|
184
|
+
{ once: true }
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// Subscribe to visibility changes for pause/resume (browser only)
|
|
188
|
+
this.#subscribeToVisibilityChanges()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Subscribe to document visibility changes to pause/resume syncing.
|
|
193
|
+
* When the page is hidden, we pause to save battery and bandwidth.
|
|
194
|
+
* When visible again, we resume syncing.
|
|
195
|
+
*/
|
|
196
|
+
#subscribeToVisibilityChanges(): void {
|
|
197
|
+
// Only subscribe in browser environments
|
|
198
|
+
if (
|
|
199
|
+
typeof document === `object` &&
|
|
200
|
+
typeof document.hidden === `boolean` &&
|
|
201
|
+
typeof document.addEventListener === `function`
|
|
202
|
+
) {
|
|
203
|
+
const visibilityHandler = (): void => {
|
|
204
|
+
if (document.hidden) {
|
|
205
|
+
this.#pause()
|
|
206
|
+
} else {
|
|
207
|
+
this.#resume()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
document.addEventListener(`visibilitychange`, visibilityHandler)
|
|
212
|
+
|
|
213
|
+
// Store cleanup function to remove the event listener
|
|
214
|
+
// Check document still exists (may be undefined in tests after cleanup)
|
|
215
|
+
this.#unsubscribeFromVisibilityChanges = () => {
|
|
216
|
+
if (typeof document === `object`) {
|
|
217
|
+
document.removeEventListener(`visibilitychange`, visibilityHandler)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check initial state - page might already be hidden when stream starts
|
|
222
|
+
if (document.hidden) {
|
|
223
|
+
this.#pause()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Pause the stream when page becomes hidden.
|
|
230
|
+
* Aborts any in-flight request to free resources.
|
|
231
|
+
* Creates a promise that pull() will await while paused.
|
|
232
|
+
*/
|
|
233
|
+
#pause(): void {
|
|
234
|
+
if (this.#state === `active`) {
|
|
235
|
+
this.#state = `pause-requested`
|
|
236
|
+
// Create promise that pull() will await
|
|
237
|
+
this.#pausePromise = new Promise((resolve) => {
|
|
238
|
+
this.#pauseResolve = resolve
|
|
239
|
+
})
|
|
240
|
+
// Abort current request if any
|
|
241
|
+
this.#requestAbortController?.abort(PAUSE_STREAM)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Resume the stream when page becomes visible.
|
|
247
|
+
* Resolves the pause promise to unblock pull().
|
|
248
|
+
*/
|
|
249
|
+
#resume(): void {
|
|
250
|
+
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
251
|
+
// Don't resume if the user's signal is already aborted
|
|
252
|
+
if (this.#abortController.signal.aborted) {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Transition to active and resolve the pause promise
|
|
257
|
+
this.#state = `active`
|
|
258
|
+
this.#justResumedFromPause = true // Flag for single-shot skip of live param
|
|
259
|
+
this.#pauseResolve?.()
|
|
260
|
+
this.#pausePromise = undefined
|
|
261
|
+
this.#pauseResolve = undefined
|
|
262
|
+
}
|
|
153
263
|
}
|
|
154
264
|
|
|
155
265
|
// --- Response metadata getters ---
|
|
@@ -189,10 +299,12 @@ export class StreamResponseImpl<
|
|
|
189
299
|
}
|
|
190
300
|
|
|
191
301
|
#markClosed(): void {
|
|
302
|
+
this.#unsubscribeFromVisibilityChanges?.()
|
|
192
303
|
this.#closedResolve()
|
|
193
304
|
}
|
|
194
305
|
|
|
195
306
|
#markError(err: Error): void {
|
|
307
|
+
this.#unsubscribeFromVisibilityChanges?.()
|
|
196
308
|
this.#closedReject(err)
|
|
197
309
|
}
|
|
198
310
|
|
|
@@ -388,13 +500,19 @@ export class StreamResponseImpl<
|
|
|
388
500
|
// Track new connection start
|
|
389
501
|
this.#markSSEConnectionStart()
|
|
390
502
|
|
|
503
|
+
// Create new per-request abort controller for this SSE connection
|
|
504
|
+
this.#requestAbortController = new AbortController()
|
|
505
|
+
|
|
391
506
|
const newSSEResponse = await this.#startSSE(
|
|
392
507
|
this.offset,
|
|
393
508
|
this.cursor,
|
|
394
|
-
this.#
|
|
509
|
+
this.#requestAbortController.signal
|
|
395
510
|
)
|
|
396
511
|
if (newSSEResponse.body) {
|
|
397
|
-
return parseSSEStream(
|
|
512
|
+
return parseSSEStream(
|
|
513
|
+
newSSEResponse.body,
|
|
514
|
+
this.#requestAbortController.signal
|
|
515
|
+
)
|
|
398
516
|
}
|
|
399
517
|
return null
|
|
400
518
|
}
|
|
@@ -548,10 +666,12 @@ export class StreamResponseImpl<
|
|
|
548
666
|
if (isSSE && firstResponse.body) {
|
|
549
667
|
// Track SSE connection start for resilience monitoring
|
|
550
668
|
this.#markSSEConnectionStart()
|
|
669
|
+
// Create per-request abort controller for SSE connection
|
|
670
|
+
this.#requestAbortController = new AbortController()
|
|
551
671
|
// Start parsing SSE events
|
|
552
672
|
sseEventIterator = parseSSEStream(
|
|
553
673
|
firstResponse.body,
|
|
554
|
-
this.#
|
|
674
|
+
this.#requestAbortController.signal
|
|
555
675
|
)
|
|
556
676
|
// Fall through to SSE processing below
|
|
557
677
|
} else {
|
|
@@ -570,6 +690,30 @@ export class StreamResponseImpl<
|
|
|
570
690
|
|
|
571
691
|
// SSE mode: process events from the SSE stream
|
|
572
692
|
if (sseEventIterator) {
|
|
693
|
+
// Check for pause state before processing SSE events
|
|
694
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
695
|
+
this.#state = `paused`
|
|
696
|
+
if (this.#pausePromise) {
|
|
697
|
+
await this.#pausePromise
|
|
698
|
+
}
|
|
699
|
+
// After resume, check if we should still continue
|
|
700
|
+
if (this.#abortController.signal.aborted) {
|
|
701
|
+
this.#markClosed()
|
|
702
|
+
controller.close()
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
// Reconnect SSE after resume
|
|
706
|
+
const newIterator = await this.#trySSEReconnect()
|
|
707
|
+
if (newIterator) {
|
|
708
|
+
sseEventIterator = newIterator
|
|
709
|
+
} else {
|
|
710
|
+
// Could not reconnect - close the stream
|
|
711
|
+
this.#markClosed()
|
|
712
|
+
controller.close()
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
573
717
|
// Keep reading events until we get data or stream ends
|
|
574
718
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
575
719
|
while (true) {
|
|
@@ -604,16 +748,39 @@ export class StreamResponseImpl<
|
|
|
604
748
|
|
|
605
749
|
// Long-poll mode: continue with live updates if needed
|
|
606
750
|
if (this.#shouldContinueLive()) {
|
|
751
|
+
// If paused or pause-requested, await the pause promise
|
|
752
|
+
// This blocks pull() until resume() is called, avoiding deadlock
|
|
753
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
754
|
+
this.#state = `paused`
|
|
755
|
+
if (this.#pausePromise) {
|
|
756
|
+
await this.#pausePromise
|
|
757
|
+
}
|
|
758
|
+
// After resume, check if we should still continue
|
|
759
|
+
if (this.#abortController.signal.aborted) {
|
|
760
|
+
this.#markClosed()
|
|
761
|
+
controller.close()
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
607
766
|
if (this.#abortController.signal.aborted) {
|
|
608
767
|
this.#markClosed()
|
|
609
768
|
controller.close()
|
|
610
769
|
return
|
|
611
770
|
}
|
|
612
771
|
|
|
772
|
+
// Consume the single-shot resume flag (only first fetch after resume skips live param)
|
|
773
|
+
const resumingFromPause = this.#justResumedFromPause
|
|
774
|
+
this.#justResumedFromPause = false
|
|
775
|
+
|
|
776
|
+
// Create a new AbortController for this request (so we can abort on pause)
|
|
777
|
+
this.#requestAbortController = new AbortController()
|
|
778
|
+
|
|
613
779
|
const response = await this.#fetchNext(
|
|
614
780
|
this.offset,
|
|
615
781
|
this.cursor,
|
|
616
|
-
this.#
|
|
782
|
+
this.#requestAbortController.signal,
|
|
783
|
+
resumingFromPause
|
|
617
784
|
)
|
|
618
785
|
|
|
619
786
|
this.#updateStateFromResponse(response)
|
|
@@ -626,6 +793,21 @@ export class StreamResponseImpl<
|
|
|
626
793
|
this.#markClosed()
|
|
627
794
|
controller.close()
|
|
628
795
|
} catch (err) {
|
|
796
|
+
// Check if this was a pause-triggered abort
|
|
797
|
+
// Treat PAUSE_STREAM aborts as benign regardless of current state
|
|
798
|
+
// (handles race where resume() was called before abort completed)
|
|
799
|
+
if (
|
|
800
|
+
this.#requestAbortController?.signal.aborted &&
|
|
801
|
+
this.#requestAbortController.signal.reason === PAUSE_STREAM
|
|
802
|
+
) {
|
|
803
|
+
// Only transition to paused if we're still in pause-requested state
|
|
804
|
+
if (this.#state === `pause-requested`) {
|
|
805
|
+
this.#state = `paused`
|
|
806
|
+
}
|
|
807
|
+
// Return - either we're paused, or already resumed and next pull will proceed
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
|
|
629
811
|
if (this.#abortController.signal.aborted) {
|
|
630
812
|
this.#markClosed()
|
|
631
813
|
controller.close()
|
|
@@ -638,6 +820,7 @@ export class StreamResponseImpl<
|
|
|
638
820
|
|
|
639
821
|
cancel: () => {
|
|
640
822
|
this.#abortController.abort()
|
|
823
|
+
this.#unsubscribeFromVisibilityChanges?.()
|
|
641
824
|
this.#markClosed()
|
|
642
825
|
},
|
|
643
826
|
})
|
|
@@ -1044,6 +1227,7 @@ export class StreamResponseImpl<
|
|
|
1044
1227
|
|
|
1045
1228
|
cancel(reason?: unknown): void {
|
|
1046
1229
|
this.#abortController.abort(reason)
|
|
1230
|
+
this.#unsubscribeFromVisibilityChanges?.()
|
|
1047
1231
|
this.#markClosed()
|
|
1048
1232
|
}
|
|
1049
1233
|
|
package/src/stream-api.ts
CHANGED
|
@@ -191,16 +191,22 @@ async function streamInternal<TJson = unknown>(
|
|
|
191
191
|
const fetchNext = async (
|
|
192
192
|
offset: Offset,
|
|
193
193
|
cursor: string | undefined,
|
|
194
|
-
signal: AbortSignal
|
|
194
|
+
signal: AbortSignal,
|
|
195
|
+
resumingFromPause?: boolean
|
|
195
196
|
): Promise<Response> => {
|
|
196
197
|
const nextUrl = new URL(url)
|
|
197
198
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset)
|
|
198
199
|
|
|
199
200
|
// For subsequent requests in auto mode, use long-poll
|
|
200
|
-
if
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// BUT: if we're resuming from a paused state, don't set live mode
|
|
202
|
+
// to avoid a long-poll that holds for 20sec - we want an immediate response
|
|
203
|
+
// so the UI can show "connected" status quickly
|
|
204
|
+
if (!resumingFromPause) {
|
|
205
|
+
if (live === `auto` || live === `long-poll`) {
|
|
206
|
+
nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`)
|
|
207
|
+
} else if (live === `sse`) {
|
|
208
|
+
nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`)
|
|
209
|
+
}
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
if (cursor) {
|