@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 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
- const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#abortController.signal);
738
- if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#abortController.signal);
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
- sseEventIterator = parseSSEStream(firstResponse.body, this.#abortController.signal);
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) while (true) {
836
- const result = await this.#processSSEEvents(sseEventIterator);
837
- switch (result.type) {
838
- case `response`:
839
- if (result.newIterator) sseEventIterator = result.newIterator;
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
- case `error`:
847
- this.#markError(result.error);
848
- controller.error(result.error);
910
+ }
911
+ const newIterator = await this.#trySSEReconnect();
912
+ if (newIterator) sseEventIterator = newIterator;
913
+ else {
914
+ this.#markClosed();
915
+ controller.close();
849
916
  return;
850
- case `continue`:
851
- if (result.newIterator) sseEventIterator = result.newIterator;
852
- continue;
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 response = await this.#fetchNext(this.offset, this.cursor, this.#abortController.signal);
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 (live === `auto` || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
1379
- else if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
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
- const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#abortController.signal);
714
- if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#abortController.signal);
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
- sseEventIterator = parseSSEStream(firstResponse.body, this.#abortController.signal);
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) while (true) {
812
- const result = await this.#processSSEEvents(sseEventIterator);
813
- switch (result.type) {
814
- case `response`:
815
- if (result.newIterator) sseEventIterator = result.newIterator;
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
- case `error`:
823
- this.#markError(result.error);
824
- controller.error(result.error);
886
+ }
887
+ const newIterator = await this.#trySSEReconnect();
888
+ if (newIterator) sseEventIterator = newIterator;
889
+ else {
890
+ this.#markClosed();
891
+ controller.close();
825
892
  return;
826
- case `continue`:
827
- if (result.newIterator) sseEventIterator = result.newIterator;
828
- continue;
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 response = await this.#fetchNext(this.offset, this.cursor, this.#abortController.signal);
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 (live === `auto` || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
1355
- else if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
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",
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.5"
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.#abortController.signal
509
+ this.#requestAbortController.signal
395
510
  )
396
511
  if (newSSEResponse.body) {
397
- return parseSSEStream(newSSEResponse.body, this.#abortController.signal)
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.#abortController.signal
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.#abortController.signal
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 (live === `auto` || live === `long-poll`) {
201
- nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`)
202
- } else if (live === `sse`) {
203
- nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`)
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) {