@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 +31 -3
- package/dist/reqkit.mjs +205 -2
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/index.mjs +1 -0
- package/src/normalizeError.js +1 -1
- package/src/streamSSE.js +197 -0
- package/types/index.d.ts +36 -0
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.
|
|
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
|
|
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.
|
|
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
package/src/normalizeError.js
CHANGED
|
@@ -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
|
|
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 || "");
|
package/src/streamSSE.js
ADDED
|
@@ -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>;
|