@boy-offi9-inc/reqkit 0.5.0 → 0.6.0

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 CHANGED
@@ -48,14 +48,14 @@ const pages = await limit.map(urls, (url) =>
48
48
  )
49
49
  );
50
50
  ```
51
+
51
52
  ---
52
53
 
53
54
  ## Playground
54
55
 
55
- Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming and a resumable download that survives a dropped connection. It runs against a fake in-memory server, so there's nothing to install and no network involved.
56
-
57
- Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.) The repo's `pages.yml` workflow publishes the same folder to GitHub Pages — enable it once under Settings → Pages → Source: "GitHub Actions".
56
+ Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming, an SSE stream that reconnects itself, and a resumable download that survives a dropped connection. It runs against a fake in-memory server, so there's nothing to install and no network involved.
58
57
 
58
+ Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.)
59
59
  ## Works with what you already use
60
60
 
61
61
  Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming live in [RECIPES.md](./RECIPES.md): keep your client, bolt on just the piece you're missing (`Retry-After`-aware retries, a rate limiter, a circuit breaker).
@@ -91,6 +91,7 @@ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming li
91
91
  | Stop hammering a service that keeps failing | [`createCircuitBreaker`](#createcircuitbreakeropts) |
92
92
  | Cache GETs (TTL, stale-while-revalidate, ETag/304) | [`createFetch` → `cache`](#caching) |
93
93
  | Resume a download after the connection drops | [`downloadResumable`](#downloadresumableurl-opts) |
94
+ | Stay connected to an SSE endpoint (auto-reconnect, `Last-Event-ID`) | [`streamSSE`](#auto-reconnecting-sse-streamsse) |
94
95
  | Read SSE / NDJSON / lines from a streamed body (LLM tokens!) | [`readSSE` / `readNDJSON` / `readLines`](#streaming-readsse-readndjson-readlines) |
95
96
  | Test retry/timeout code without a network | [`createMockFetch`](#createmockfetch) |
96
97
  | All of the above, wired up | [`createFetch`](#createfetchdefaults) |
@@ -360,6 +361,33 @@ for await (const line of readLines(response)) console.log(line);
360
361
 
361
362
  `readSSE` follows the spec: multi-line `data:` is joined with `\n`, comments are ignored, `event` defaults to `"message"`, `id` carries over, `retry` is parsed, and an event cut off by the end of the stream is dropped. `readNDJSON` skips blank lines and names the bad line on a parse error (or pass `skipInvalid: true`). Line splitting handles `\n`, `\r\n`, `\r`, a BOM, and multi-byte characters split across chunks.
362
363
 
364
+ ### Auto-reconnecting SSE: `streamSSE`
365
+
366
+ `readSSE` parses a stream you already have; **`streamSSE` opens it for you and keeps it open.** When the connection drops (or the server closes it) it reconnects by itself and sends `Last-Event-ID`, so the server can resume where you left off — what the browser's `EventSource` does, but built on `fetch`: custom headers, POST bodies, `AbortSignal`, your own client.
367
+
368
+ ```js
369
+ import { streamSSE } from "@boy-offi9-inc/reqkit";
370
+
371
+ for await (const { id, data } of streamSSE("https://api.example.com/events", {
372
+ headers: () => ({ authorization: `Bearer ${getToken()}` }), // re-evaluated on every reconnect
373
+ onReconnect: ({ reason, delayMs, lastEventId }) => console.log(`reconnecting in ${delayMs}ms from id ${lastEventId}`),
374
+ signal,
375
+ })) {
376
+ handle(data);
377
+ }
378
+ ```
379
+
380
+ | Behavior | Details |
381
+ |---|---|
382
+ | Resume | `Last-Event-ID` (the last `id:` seen) is sent on every reconnect; `lastEventId` option resumes on the first connect too |
383
+ | Delay | the server's `retry: <ms>` field sets the base delay (like `EventSource`); waits then back off exponentially up to `maxDelayMs` while connecting keeps failing; `Retry-After` on 429/503 is honored |
384
+ | Giving up | `retries` (default 10) counts consecutive attempts **without progress** — a connection that delivered an event resets it. Then it throws the last error |
385
+ | Stopping | HTTP `204` ends the stream (the standard "don't reconnect"); `stopWhen: e => e.data === "[DONE]"`; `break`ing out of the loop or aborting `signal` closes the connection |
386
+ | Fatal | a non-retryable status (401, 404…) throws `HTTPError` right away; a response that isn't `text/event-stream` throws (`code: "SSE_BAD_CONTENT_TYPE"`) |
387
+ | POST streams | **only GET reconnects by default** — re-sending a POST (say, an LLM prompt) could repeat side effects, so POST streams just end or throw unless you pass `reconnect: true` |
388
+
389
+ It takes any fetch: `streamSSE(url, { fetch: api })` with a `createFetch` instance works too.
390
+
363
391
  ### `downloadResumable(url, opts?)`
364
392
 
365
393
  Downloads into memory and, if the connection drops mid-body, **resumes with an HTTP `Range` request** instead of starting over. It sends `If-Range` with the first response's strong ETag (or `Last-Modified`), so a file that changed on the server is restarted rather than stitched together wrongly; if the server ignores `Range`, it restarts from zero.
package/dist/reqkit.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @boy-offi9-inc/reqkit v0.5.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
1
+ /*! @boy-offi9-inc/reqkit v0.6.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
2
2
  const __defs = {
3
3
  "./HTTPError": function (module, exports, require) {
4
4
  class HTTPError extends Error {
@@ -1413,6 +1413,7 @@ const { createMemoryCache } = require("./createMemoryCache");
1413
1413
  const { createMockFetch } = require("./createMockFetch");
1414
1414
  const { downloadResumable } = require("./downloadResumable");
1415
1415
  const { readLines, readNDJSON, readSSE } = require("./streams");
1416
+ const { streamSSE } = require("./streamSSE");
1416
1417
 
1417
1418
  module.exports = {
1418
1419
  withRetry,
@@ -1441,6 +1442,7 @@ module.exports = {
1441
1442
  readLines,
1442
1443
  readNDJSON,
1443
1444
  readSSE,
1445
+ streamSSE,
1444
1446
  };
1445
1447
 
1446
1448
  },
@@ -1501,7 +1503,7 @@ function isNetworkCode(code) {
1501
1503
 
1502
1504
  // Browsers and Node throw a bare TypeError for network-level fetch failures.
1503
1505
  const FETCH_NETWORK_MESSAGE =
1504
- /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch/i;
1506
+ /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch|^terminated$|^network error$/i;
1505
1507
 
1506
1508
  function isFetchNetworkTypeError(err) {
1507
1509
  return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
@@ -1677,6 +1679,206 @@ function retryAfterAware(fn, opts = {}) {
1677
1679
 
1678
1680
  module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
1679
1681
 
1682
+ },
1683
+ "./streamSSE": function (module, exports, require) {
1684
+ const { readSSE } = require("./streams");
1685
+ const { HTTPError } = require("./HTTPError");
1686
+ const { normalizeError } = require("./normalizeError");
1687
+ const { getWaitMsFromHeaders, discardBody } = require("./_http");
1688
+ const { abortableSleep } = require("./_sleep");
1689
+
1690
+ function isRetryableStatus(status) {
1691
+ return status === 408 || status === 425 || status === 429 || status >= 500;
1692
+ }
1693
+
1694
+ function isRetryableError(err) {
1695
+ const n = normalizeError(err);
1696
+ return !n.isAborted && (n.isNetworkError || n.isTimeout);
1697
+ }
1698
+
1699
+ async function resolveHeaders(source) {
1700
+ const value = typeof source === "function" ? await source() : source;
1701
+ return new Headers(value || undefined);
1702
+ }
1703
+
1704
+ /**
1705
+ * Connects to a Server-Sent Events endpoint and yields its events — and when
1706
+ * the connection drops (or the server closes it), reconnects by itself,
1707
+ * sending `Last-Event-ID` so the server can resume where you left off. This is
1708
+ * what the browser's `EventSource` does, but with `fetch`: custom headers,
1709
+ * POST bodies, AbortSignal, your own retry/backoff rules.
1710
+ *
1711
+ * ```js
1712
+ * for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
1713
+ * console.log(data);
1714
+ * }
1715
+ * ```
1716
+ *
1717
+ * Reconnect behavior:
1718
+ * - `Last-Event-ID` is sent on every reconnect (the last `id:` seen).
1719
+ * - A server `retry: <ms>` field sets the base reconnect delay, like EventSource.
1720
+ * - Waits back off exponentially (capped at `maxDelayMs`) while connecting keeps
1721
+ * failing; 429/503 `Retry-After` is honored. A connection that delivered at
1722
+ * least one event resets the failure count (only no-progress attempts count
1723
+ * toward `retries`).
1724
+ * - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
1725
+ * status (e.g. 401/404) or a wrong content-type throws immediately.
1726
+ * - Only GET reconnects by default. For other methods (e.g. a POST that starts
1727
+ * an LLM stream) re-sending the request could repeat side effects, so
1728
+ * nothing is retried unless you pass `reconnect: true`.
1729
+ * - Breaking out of the loop, or aborting `signal`, closes the connection.
1730
+ *
1731
+ * @param {string} url
1732
+ * @param {object} [opts]
1733
+ * @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. a createFetch() instance
1734
+ * @param {string} [opts.method="GET"]
1735
+ * @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [opts.headers] -
1736
+ * a function is called before every (re)connect — handy for refreshing a token
1737
+ * @param {BodyInit} [opts.body]
1738
+ * @param {AbortSignal} [opts.signal]
1739
+ * @param {string} [opts.lastEventId] - resume from here on the very first connect
1740
+ * @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
1741
+ * @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
1742
+ * @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
1743
+ * @param {number} [opts.maxDelayMs=30000]
1744
+ * @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
1745
+ * @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
1746
+ * @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
1747
+ * @returns {AsyncGenerator<{event: string, data: string, id: string, retry: number|undefined}>}
1748
+ */
1749
+ async function* streamSSE(url, opts = {}) {
1750
+ const {
1751
+ fetch: fetchFn = globalThis.fetch,
1752
+ method = "GET",
1753
+ headers,
1754
+ body,
1755
+ signal,
1756
+ reconnect = method === "GET" || method === "HEAD",
1757
+ retries = 10,
1758
+ reconnectDelayMs = 1000,
1759
+ maxDelayMs = 30000,
1760
+ stopWhen,
1761
+ onOpen,
1762
+ onReconnect,
1763
+ } = opts;
1764
+
1765
+ let lastId = opts.lastEventId || "";
1766
+ let baseDelay = reconnectDelayMs;
1767
+ let failures = 0; // consecutive connections that delivered nothing
1768
+ let connections = 0;
1769
+
1770
+ for (;;) {
1771
+ signal?.throwIfAborted?.();
1772
+ let failure = null; // { reason, error?, status?, response? }
1773
+ let res = null;
1774
+
1775
+ // ---- connect -----------------------------------------------------------
1776
+ try {
1777
+ const h = await resolveHeaders(headers);
1778
+ if (!h.has("accept")) h.set("accept", "text/event-stream");
1779
+ if (!h.has("cache-control")) h.set("cache-control", "no-cache");
1780
+ if (lastId) h.set("last-event-id", lastId);
1781
+ res = await fetchFn(url, { method, headers: h, body, signal });
1782
+ } catch (err) {
1783
+ if (signal?.aborted) throw signal.reason ?? err;
1784
+ if (err instanceof HTTPError) {
1785
+ if (!isRetryableStatus(err.status)) throw err;
1786
+ failure = { reason: "status", status: err.status, response: err.response, error: err };
1787
+ } else if (isRetryableError(err)) {
1788
+ failure = { reason: "error", error: err };
1789
+ } else {
1790
+ throw err;
1791
+ }
1792
+ }
1793
+
1794
+ // ---- inspect the response ---------------------------------------------
1795
+ if (!failure) {
1796
+ if (res.status === 204) {
1797
+ await discardBody(res);
1798
+ return; // the server's way of saying "don't reconnect"
1799
+ }
1800
+ if (!res.ok) {
1801
+ if (!isRetryableStatus(res.status)) {
1802
+ await discardBody(res);
1803
+ throw new HTTPError(res, { method, url });
1804
+ }
1805
+ failure = { reason: "status", status: res.status, response: res };
1806
+ } else {
1807
+ const type = res.headers.get("content-type") || "";
1808
+ if (!/text\/event-stream/i.test(type)) {
1809
+ await discardBody(res);
1810
+ throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
1811
+ code: "SSE_BAD_CONTENT_TYPE",
1812
+ });
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ // ---- stream ------------------------------------------------------------
1818
+ let gotEvent = false;
1819
+ if (!failure) {
1820
+ connections++;
1821
+ if (onOpen) onOpen({ connections, lastEventId: lastId });
1822
+ try {
1823
+ for await (const event of readSSE(res, { signal })) {
1824
+ gotEvent = true;
1825
+ lastId = event.id;
1826
+ if (event.retry !== undefined) baseDelay = event.retry;
1827
+ yield event;
1828
+ if (stopWhen && stopWhen(event)) return;
1829
+ }
1830
+ failure = { reason: "end" };
1831
+ } catch (err) {
1832
+ if (signal?.aborted) throw signal.reason ?? err;
1833
+ if (!isRetryableError(err)) throw err;
1834
+ failure = { reason: "error", error: err };
1835
+ }
1836
+ }
1837
+
1838
+ // ---- decide whether and when to reconnect ------------------------------
1839
+ if (!reconnect) {
1840
+ if (failure.reason === "end") return;
1841
+ if (failure.reason === "status") {
1842
+ throw failure.error || new HTTPError(failure.response, { method, url });
1843
+ }
1844
+ throw failure.error;
1845
+ }
1846
+
1847
+ // A connection that delivered events made progress: reconnect promptly and
1848
+ // start the failure count over. Only no-progress attempts count toward `retries`.
1849
+ failures = gotEvent ? 0 : failures + 1;
1850
+ if (failures > retries) {
1851
+ if (failure.reason === "status") throw failure.error || new HTTPError(failure.response, { method, url });
1852
+ if (failure.reason === "error") throw failure.error;
1853
+ throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
1854
+ code: "SSE_GAVE_UP",
1855
+ });
1856
+ }
1857
+
1858
+ let delayMs = Math.min(baseDelay * 2 ** Math.max(failures - 1, 0), maxDelayMs);
1859
+ if (failure.reason === "status" && failure.response) {
1860
+ const headerMs = getWaitMsFromHeaders(failure.response.headers);
1861
+ if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
1862
+ await discardBody(failure.response);
1863
+ }
1864
+ delayMs += Math.random() * delayMs * 0.1; // a little jitter so clients don't reconnect in lockstep
1865
+
1866
+ if (onReconnect) {
1867
+ onReconnect({
1868
+ reason: failure.reason,
1869
+ error: failure.error,
1870
+ status: failure.status,
1871
+ attempt: Math.max(failures, 1),
1872
+ delayMs,
1873
+ lastEventId: lastId,
1874
+ });
1875
+ }
1876
+ await abortableSleep(delayMs, signal);
1877
+ }
1878
+ }
1879
+
1880
+ module.exports = { streamSSE };
1881
+
1680
1882
  },
1681
1883
  "./streams": function (module, exports, require) {
1682
1884
  const encoder = new TextEncoder();
@@ -2296,6 +2498,7 @@ export const {
2296
2498
  readNDJSON,
2297
2499
  readSSE,
2298
2500
  retryAfterAware,
2501
+ streamSSE,
2299
2502
  withDedupe,
2300
2503
  withProgress,
2301
2504
  withRetry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Small, composable HTTP helpers — retry with backoff and Retry-After, timeouts, normalized errors, rate limiter, circuit breaker, response cache, resumable downloads, SSE/NDJSON streams, mock fetch, and an opt-in createFetch. Not a client; works alongside fetch/axios/anything.",
5
5
  "main": "./src/index.js",
6
6
  "types": "./types/index.d.ts",
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ const { createMemoryCache } = require("./createMemoryCache");
14
14
  const { createMockFetch } = require("./createMockFetch");
15
15
  const { downloadResumable } = require("./downloadResumable");
16
16
  const { readLines, readNDJSON, readSSE } = require("./streams");
17
+ const { streamSSE } = require("./streamSSE");
17
18
 
18
19
  module.exports = {
19
20
  withRetry,
@@ -42,4 +43,5 @@ module.exports = {
42
43
  readLines,
43
44
  readNDJSON,
44
45
  readSSE,
46
+ streamSSE,
45
47
  };
package/src/index.mjs CHANGED
@@ -27,6 +27,7 @@ export const {
27
27
  readLines,
28
28
  readNDJSON,
29
29
  readSSE,
30
+ streamSSE,
30
31
  } = reqkit;
31
32
 
32
33
  export default reqkit;
@@ -54,7 +54,7 @@ function isNetworkCode(code) {
54
54
 
55
55
  // Browsers and Node throw a bare TypeError for network-level fetch failures.
56
56
  const FETCH_NETWORK_MESSAGE =
57
- /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch/i;
57
+ /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch|^terminated$|^network error$/i;
58
58
 
59
59
  function isFetchNetworkTypeError(err) {
60
60
  return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
@@ -0,0 +1,197 @@
1
+ const { readSSE } = require("./streams");
2
+ const { HTTPError } = require("./HTTPError");
3
+ const { normalizeError } = require("./normalizeError");
4
+ const { getWaitMsFromHeaders, discardBody } = require("./_http");
5
+ const { abortableSleep } = require("./_sleep");
6
+
7
+ function isRetryableStatus(status) {
8
+ return status === 408 || status === 425 || status === 429 || status >= 500;
9
+ }
10
+
11
+ function isRetryableError(err) {
12
+ const n = normalizeError(err);
13
+ return !n.isAborted && (n.isNetworkError || n.isTimeout);
14
+ }
15
+
16
+ async function resolveHeaders(source) {
17
+ const value = typeof source === "function" ? await source() : source;
18
+ return new Headers(value || undefined);
19
+ }
20
+
21
+ /**
22
+ * Connects to a Server-Sent Events endpoint and yields its events — and when
23
+ * the connection drops (or the server closes it), reconnects by itself,
24
+ * sending `Last-Event-ID` so the server can resume where you left off. This is
25
+ * what the browser's `EventSource` does, but with `fetch`: custom headers,
26
+ * POST bodies, AbortSignal, your own retry/backoff rules.
27
+ *
28
+ * ```js
29
+ * for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
30
+ * console.log(data);
31
+ * }
32
+ * ```
33
+ *
34
+ * Reconnect behavior:
35
+ * - `Last-Event-ID` is sent on every reconnect (the last `id:` seen).
36
+ * - A server `retry: <ms>` field sets the base reconnect delay, like EventSource.
37
+ * - Waits back off exponentially (capped at `maxDelayMs`) while connecting keeps
38
+ * failing; 429/503 `Retry-After` is honored. A connection that delivered at
39
+ * least one event resets the failure count (only no-progress attempts count
40
+ * toward `retries`).
41
+ * - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
42
+ * status (e.g. 401/404) or a wrong content-type throws immediately.
43
+ * - Only GET reconnects by default. For other methods (e.g. a POST that starts
44
+ * an LLM stream) re-sending the request could repeat side effects, so
45
+ * nothing is retried unless you pass `reconnect: true`.
46
+ * - Breaking out of the loop, or aborting `signal`, closes the connection.
47
+ *
48
+ * @param {string} url
49
+ * @param {object} [opts]
50
+ * @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. a createFetch() instance
51
+ * @param {string} [opts.method="GET"]
52
+ * @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [opts.headers] -
53
+ * a function is called before every (re)connect — handy for refreshing a token
54
+ * @param {BodyInit} [opts.body]
55
+ * @param {AbortSignal} [opts.signal]
56
+ * @param {string} [opts.lastEventId] - resume from here on the very first connect
57
+ * @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
58
+ * @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
59
+ * @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
60
+ * @param {number} [opts.maxDelayMs=30000]
61
+ * @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
62
+ * @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
63
+ * @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
64
+ * @returns {AsyncGenerator<{event: string, data: string, id: string, retry: number|undefined}>}
65
+ */
66
+ async function* streamSSE(url, opts = {}) {
67
+ const {
68
+ fetch: fetchFn = globalThis.fetch,
69
+ method = "GET",
70
+ headers,
71
+ body,
72
+ signal,
73
+ reconnect = method === "GET" || method === "HEAD",
74
+ retries = 10,
75
+ reconnectDelayMs = 1000,
76
+ maxDelayMs = 30000,
77
+ stopWhen,
78
+ onOpen,
79
+ onReconnect,
80
+ } = opts;
81
+
82
+ let lastId = opts.lastEventId || "";
83
+ let baseDelay = reconnectDelayMs;
84
+ let failures = 0; // consecutive connections that delivered nothing
85
+ let connections = 0;
86
+
87
+ for (;;) {
88
+ signal?.throwIfAborted?.();
89
+ let failure = null; // { reason, error?, status?, response? }
90
+ let res = null;
91
+
92
+ // ---- connect -----------------------------------------------------------
93
+ try {
94
+ const h = await resolveHeaders(headers);
95
+ if (!h.has("accept")) h.set("accept", "text/event-stream");
96
+ if (!h.has("cache-control")) h.set("cache-control", "no-cache");
97
+ if (lastId) h.set("last-event-id", lastId);
98
+ res = await fetchFn(url, { method, headers: h, body, signal });
99
+ } catch (err) {
100
+ if (signal?.aborted) throw signal.reason ?? err;
101
+ if (err instanceof HTTPError) {
102
+ if (!isRetryableStatus(err.status)) throw err;
103
+ failure = { reason: "status", status: err.status, response: err.response, error: err };
104
+ } else if (isRetryableError(err)) {
105
+ failure = { reason: "error", error: err };
106
+ } else {
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ // ---- inspect the response ---------------------------------------------
112
+ if (!failure) {
113
+ if (res.status === 204) {
114
+ await discardBody(res);
115
+ return; // the server's way of saying "don't reconnect"
116
+ }
117
+ if (!res.ok) {
118
+ if (!isRetryableStatus(res.status)) {
119
+ await discardBody(res);
120
+ throw new HTTPError(res, { method, url });
121
+ }
122
+ failure = { reason: "status", status: res.status, response: res };
123
+ } else {
124
+ const type = res.headers.get("content-type") || "";
125
+ if (!/text\/event-stream/i.test(type)) {
126
+ await discardBody(res);
127
+ throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
128
+ code: "SSE_BAD_CONTENT_TYPE",
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // ---- stream ------------------------------------------------------------
135
+ let gotEvent = false;
136
+ if (!failure) {
137
+ connections++;
138
+ if (onOpen) onOpen({ connections, lastEventId: lastId });
139
+ try {
140
+ for await (const event of readSSE(res, { signal })) {
141
+ gotEvent = true;
142
+ lastId = event.id;
143
+ if (event.retry !== undefined) baseDelay = event.retry;
144
+ yield event;
145
+ if (stopWhen && stopWhen(event)) return;
146
+ }
147
+ failure = { reason: "end" };
148
+ } catch (err) {
149
+ if (signal?.aborted) throw signal.reason ?? err;
150
+ if (!isRetryableError(err)) throw err;
151
+ failure = { reason: "error", error: err };
152
+ }
153
+ }
154
+
155
+ // ---- decide whether and when to reconnect ------------------------------
156
+ if (!reconnect) {
157
+ if (failure.reason === "end") return;
158
+ if (failure.reason === "status") {
159
+ throw failure.error || new HTTPError(failure.response, { method, url });
160
+ }
161
+ throw failure.error;
162
+ }
163
+
164
+ // A connection that delivered events made progress: reconnect promptly and
165
+ // start the failure count over. Only no-progress attempts count toward `retries`.
166
+ failures = gotEvent ? 0 : failures + 1;
167
+ if (failures > retries) {
168
+ if (failure.reason === "status") throw failure.error || new HTTPError(failure.response, { method, url });
169
+ if (failure.reason === "error") throw failure.error;
170
+ throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
171
+ code: "SSE_GAVE_UP",
172
+ });
173
+ }
174
+
175
+ let delayMs = Math.min(baseDelay * 2 ** Math.max(failures - 1, 0), maxDelayMs);
176
+ if (failure.reason === "status" && failure.response) {
177
+ const headerMs = getWaitMsFromHeaders(failure.response.headers);
178
+ if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
179
+ await discardBody(failure.response);
180
+ }
181
+ delayMs += Math.random() * delayMs * 0.1; // a little jitter so clients don't reconnect in lockstep
182
+
183
+ if (onReconnect) {
184
+ onReconnect({
185
+ reason: failure.reason,
186
+ error: failure.error,
187
+ status: failure.status,
188
+ attempt: Math.max(failures, 1),
189
+ delayMs,
190
+ lastEventId: lastId,
191
+ });
192
+ }
193
+ await abortableSleep(delayMs, signal);
194
+ }
195
+ }
196
+
197
+ module.exports = { streamSSE };
package/types/index.d.ts CHANGED
@@ -405,3 +405,39 @@ export function readNDJSON<T = unknown>(
405
405
  opts?: StreamOptions & { skipInvalid?: boolean }
406
406
  ): AsyncGenerator<T, void, undefined>;
407
407
  export function readSSE(source: StreamSource, opts?: StreamOptions): AsyncGenerator<SSEEvent, void, undefined>;
408
+
409
+ export interface StreamSSEOptions {
410
+ /** e.g. a createFetch() instance. Default: globalThis.fetch. */
411
+ fetch?: typeof fetch;
412
+ /** Default "GET". Only GET/HEAD reconnect unless `reconnect: true`. */
413
+ method?: string;
414
+ /** A function is re-evaluated before every (re)connect — handy for refreshing a token. */
415
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
416
+ body?: BodyInit | null;
417
+ signal?: AbortSignal;
418
+ /** Resume from this id on the very first connect. */
419
+ lastEventId?: string;
420
+ /** Default: true for GET/HEAD, false otherwise. */
421
+ reconnect?: boolean;
422
+ /** Consecutive no-progress connection failures before giving up. Default 10 (Infinity = never). */
423
+ retries?: number;
424
+ /** Base reconnect delay; the server's `retry:` field overrides it. Default 1000. */
425
+ reconnectDelayMs?: number;
426
+ /** Cap on the exponential backoff. Default 30000. */
427
+ maxDelayMs?: number;
428
+ /** End the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`. */
429
+ stopWhen?: (event: SSEEvent) => boolean;
430
+ onOpen?: (info: { connections: number; lastEventId: string }) => void;
431
+ onReconnect?: (info: {
432
+ reason: "error" | "end" | "status";
433
+ error?: unknown;
434
+ status?: number;
435
+ /** Consecutive reconnects without progress (at least 1). */
436
+ attempt: number;
437
+ delayMs: number;
438
+ lastEventId: string;
439
+ }) => void;
440
+ }
441
+
442
+ /** Auto-reconnecting SSE: resumes with Last-Event-ID, honors `retry:` and Retry-After. */
443
+ export function streamSSE(url: string, opts?: StreamSSEOptions): AsyncGenerator<SSEEvent, void, undefined>;