@boy-offi9-inc/reqkit 0.2.0 → 0.5.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.2.0",
4
- "description": "Small, composable HTTP helper functions — retry with backoff, timeouts, normalized errors, safe JSON parsing, rate-limit-aware retry, and download progress. Not a client, works alongside fetch/axios/anything.",
3
+ "version": "0.5.0",
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",
7
7
  "exports": {
@@ -15,12 +15,19 @@
15
15
  "default": "./src/index.js"
16
16
  }
17
17
  },
18
+ "./browser": {
19
+ "types": "./types/index.d.mts",
20
+ "default": "./dist/reqkit.mjs"
21
+ },
18
22
  "./package.json": "./package.json"
19
23
  },
20
24
  "sideEffects": false,
21
25
  "scripts": {
26
+ "build": "node scripts/build-esm.js",
22
27
  "test": "node --test",
23
- "typecheck": "tsc -p tsconfig.json"
28
+ "typecheck": "tsc -p tsconfig.json",
29
+ "smoke": "node smoke/run.mjs",
30
+ "prepublishOnly": "npm run build && npm test && npm run typecheck"
24
31
  },
25
32
  "keywords": [
26
33
  "http",
@@ -32,7 +39,25 @@
32
39
  "retry-after",
33
40
  "download-progress",
34
41
  "error-normalization",
35
- "utility"
42
+ "utility",
43
+ "limiter",
44
+ "concurrency",
45
+ "dedupe",
46
+ "http-client",
47
+ "fetch-wrapper",
48
+ "abort-signal",
49
+ "circuit-breaker",
50
+ "cache",
51
+ "sse",
52
+ "ndjson",
53
+ "streaming",
54
+ "resumable-download",
55
+ "mock",
56
+ "browser",
57
+ "deno",
58
+ "bun",
59
+ "cloudflare-workers",
60
+ "esm"
36
61
  ],
37
62
  "author": "Boy Offi9",
38
63
  "license": "MIT",
@@ -53,7 +78,9 @@
53
78
  "files": [
54
79
  "src",
55
80
  "types",
81
+ "dist",
56
82
  "README.md",
83
+ "RECIPES.md",
57
84
  "LICENSE"
58
85
  ],
59
86
  "devDependencies": {
@@ -0,0 +1,14 @@
1
+ class HTTPError extends Error {
2
+ constructor(response, request) {
3
+ const statusText = response.statusText ? ` ${response.statusText}` : "";
4
+ super(`Request failed with status ${response.status}${statusText}: ${request.method} ${request.url}`);
5
+ this.name = "HTTPError";
6
+ this.status = response.status;
7
+ this.statusText = response.statusText || "";
8
+ this.response = response;
9
+ this.url = request.url;
10
+ this.method = request.method;
11
+ }
12
+ }
13
+
14
+ module.exports = { HTTPError };
package/src/_cache.js ADDED
@@ -0,0 +1,55 @@
1
+ // Helpers for createFetch's response cache.
2
+
3
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
4
+
5
+ function cacheKey(method, url, headers) {
6
+ // Every request header is part of the key, so responses fetched with one
7
+ // user's Authorization header can never be served to another.
8
+ return `${method} ${url} ${JSON.stringify([...headers.entries()].sort())}`;
9
+ }
10
+
11
+ /**
12
+ * Reads a response (via a clone — the original stays usable) into a plain,
13
+ * storable entry. Returns null when the response must not be cached.
14
+ */
15
+ async function snapshotResponse(response, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
16
+ if (response.status !== 200) return null;
17
+
18
+ const cacheControl = (response.headers.get("cache-control") || "").toLowerCase();
19
+ if (/\bno-store\b/.test(cacheControl)) return null;
20
+ if ((response.headers.get("vary") || "").trim() === "*") return null;
21
+
22
+ const declared = Number(response.headers.get("content-length"));
23
+ if (response.headers.has("content-length") && declared > maxBodyBytes) return null;
24
+
25
+ const copy = response.clone();
26
+ const buffer = await copy.arrayBuffer();
27
+ if (buffer.byteLength > maxBodyBytes) return null;
28
+
29
+ const headers = [];
30
+ response.headers.forEach((value, key) => {
31
+ // The body we stored is already decoded; replaying these would corrupt it.
32
+ if (key === "content-encoding" || key === "content-length" || key === "transfer-encoding") return;
33
+ headers.push([key, value]);
34
+ });
35
+
36
+ return {
37
+ status: response.status,
38
+ statusText: response.statusText,
39
+ headers,
40
+ body: new Uint8Array(buffer),
41
+ storedAt: Date.now(),
42
+ etag: response.headers.get("etag"),
43
+ lastModified: response.headers.get("last-modified"),
44
+ };
45
+ }
46
+
47
+ function entryToResponse(entry) {
48
+ return new Response(entry.body.slice(), {
49
+ status: entry.status,
50
+ statusText: entry.statusText,
51
+ headers: entry.headers,
52
+ });
53
+ }
54
+
55
+ module.exports = { cacheKey, snapshotResponse, entryToResponse, DEFAULT_MAX_BODY_BYTES };
package/src/_http.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Parses a Retry-After header value into milliseconds. Per HTTP spec it's
3
+ * either an integer number of seconds, or an HTTP-date.
4
+ */
5
+ function parseRetryAfter(headerValue) {
6
+ if (headerValue === null || headerValue === undefined) return null;
7
+ const value = String(headerValue).trim();
8
+ if (!value) return null;
9
+
10
+ // delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
11
+ if (/^\d+(\.\d+)?$/.test(value)) {
12
+ return Number(value) * 1000;
13
+ }
14
+
15
+ const dateMs = Date.parse(value);
16
+ if (!Number.isNaN(dateMs)) {
17
+ return Math.max(0, dateMs - Date.now());
18
+ }
19
+
20
+ return null;
21
+ }
22
+
23
+ // Reset headers come in two flavors: a delta in seconds ("30", the IETF
24
+ // RateLimit-Reset draft, Discord's *-Reset-After) or an absolute epoch
25
+ // (GitHub/Twitter style, seconds or ms). Anything under ~31 years in seconds
26
+ // can't be a real epoch, so treat it as a delta.
27
+ function parseResetMs(value) {
28
+ if (value === null || value === undefined || String(value).trim() === "") return null;
29
+ const n = Number(value);
30
+ if (Number.isNaN(n) || n < 0) return null;
31
+ if (n < 1e9) return n * 1000;
32
+ const epochMs = n > 4102444800 ? n : n * 1000;
33
+ return Math.max(0, epochMs - Date.now());
34
+ }
35
+
36
+ function readHeader(headers, ...names) {
37
+ for (const name of names) {
38
+ const v = headers.get(name);
39
+ if (v !== null && v !== undefined && v !== "") return v;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Reads a wait time from common rate-limit headers, trying Retry-After first,
46
+ * then falling back to X-RateLimit-Reset(-After) if present.
47
+ */
48
+ function getWaitMsFromHeaders(headers) {
49
+ if (!headers || typeof headers.get !== "function") return null;
50
+
51
+ const retryAfter = parseRetryAfter(headers.get("retry-after"));
52
+ if (retryAfter !== null) return retryAfter;
53
+
54
+ return parseResetMs(
55
+ readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Everything useful a response says about rate limits:
61
+ * { remaining: number|null, resetMs: number|null, retryAfterMs: number|null }
62
+ * Understands X-RateLimit-*, X-Rate-Limit-*, RateLimit-* and Retry-After.
63
+ */
64
+ function getRateLimitInfo(headers) {
65
+ const info = { remaining: null, resetMs: null, retryAfterMs: null };
66
+ if (!headers || typeof headers.get !== "function") return info;
67
+
68
+ const remaining = readHeader(headers, "x-ratelimit-remaining", "x-rate-limit-remaining", "ratelimit-remaining");
69
+ if (remaining !== null && !Number.isNaN(Number(remaining))) info.remaining = Number(remaining);
70
+
71
+ info.resetMs = parseResetMs(
72
+ readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
73
+ );
74
+ info.retryAfterMs = parseRetryAfter(headers.get("retry-after"));
75
+ return info;
76
+ }
77
+
78
+ async function discardBody(response) {
79
+ // An unread body keeps the underlying connection busy; cancel it before
80
+ // we sit through a backoff and fire the next request.
81
+ try {
82
+ if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
83
+ await response.body.cancel();
84
+ }
85
+ } catch {
86
+ // locked / already consumed / not a web stream — safe to ignore
87
+ }
88
+ }
89
+
90
+ module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody };
package/src/_sleep.js CHANGED
@@ -29,4 +29,23 @@ function sleep(ms, signal, cause) {
29
29
  });
30
30
  }
31
31
 
32
- module.exports = { RetryAbortedError, sleep };
32
+ /** Like sleep(), but rejects with the signal's own reason (an AbortError by default). */
33
+ function abortableSleep(ms, signal) {
34
+ return new Promise((resolve, reject) => {
35
+ if (signal?.aborted) {
36
+ reject(signal.reason);
37
+ return;
38
+ }
39
+ const onAbort = () => {
40
+ clearTimeout(timer);
41
+ reject(signal.reason);
42
+ };
43
+ const timer = setTimeout(() => {
44
+ signal?.removeEventListener("abort", onAbort);
45
+ resolve();
46
+ }, ms);
47
+ signal?.addEventListener("abort", onAbort, { once: true });
48
+ });
49
+ }
50
+
51
+ module.exports = { RetryAbortedError, sleep, abortableSleep };
@@ -0,0 +1,124 @@
1
+ class CircuitOpenError extends Error {
2
+ constructor(retryAfterMs) {
3
+ super("Circuit is open — failing fast instead of calling a service that keeps failing");
4
+ this.name = "CircuitOpenError";
5
+ // Deliberately not an E-code: this is *not* a network error and must not be retried as one.
6
+ this.code = "CIRCUIT_OPEN";
7
+ this.retryAfterMs = retryAfterMs;
8
+ }
9
+ }
10
+
11
+ function defaultIsFailure(valueOrError, isError) {
12
+ if (isError) return !(valueOrError && valueOrError.name === "AbortError");
13
+ return Boolean(valueOrError) && typeof valueOrError.status === "number" && valueOrError.status >= 500;
14
+ }
15
+
16
+ /**
17
+ * Circuit breaker: after `failureThreshold` consecutive failures it "opens"
18
+ * and fails fast with CircuitOpenError for `resetTimeoutMs`, so a dying
19
+ * service isn't hammered (and your callers don't wait on it). Then it goes
20
+ * "half-open" and lets `halfOpenMax` probe through: success closes the
21
+ * circuit, failure re-opens it.
22
+ *
23
+ * By default a thrown error (except AbortError) or a result with status >= 500
24
+ * counts as a failure; override with `isFailure(valueOrError, isError)`.
25
+ *
26
+ * @param {object} [opts]
27
+ * @param {number} [opts.failureThreshold=5]
28
+ * @param {number} [opts.resetTimeoutMs=30000]
29
+ * @param {number} [opts.halfOpenMax=1]
30
+ * @param {(valueOrError: any, isError: boolean) => boolean} [opts.isFailure]
31
+ * @param {(state: string, prev: string) => void} [opts.onStateChange]
32
+ */
33
+ function createCircuitBreaker(opts = {}) {
34
+ const {
35
+ failureThreshold = 5,
36
+ resetTimeoutMs = 30000,
37
+ halfOpenMax = 1,
38
+ isFailure = defaultIsFailure,
39
+ onStateChange,
40
+ } = opts;
41
+
42
+ let state = "closed";
43
+ let failures = 0;
44
+ let openedAt = 0;
45
+ let probes = 0;
46
+
47
+ function setState(next) {
48
+ if (next === state) return;
49
+ const prev = state;
50
+ state = next;
51
+ if (onStateChange) onStateChange(next, prev);
52
+ }
53
+
54
+ function open() {
55
+ openedAt = Date.now();
56
+ setState("open");
57
+ }
58
+
59
+ function current() {
60
+ if (state === "open" && Date.now() - openedAt >= resetTimeoutMs) {
61
+ probes = 0;
62
+ setState("half-open");
63
+ }
64
+ return state;
65
+ }
66
+
67
+ function recordFailure() {
68
+ if (state === "half-open") {
69
+ open();
70
+ return;
71
+ }
72
+ failures++;
73
+ if (failures >= failureThreshold) open();
74
+ }
75
+
76
+ function recordSuccess() {
77
+ failures = 0;
78
+ if (state === "half-open") setState("closed");
79
+ }
80
+
81
+ async function run(fn) {
82
+ const s = current();
83
+ if (s === "open") {
84
+ throw new CircuitOpenError(Math.max(0, resetTimeoutMs - (Date.now() - openedAt)));
85
+ }
86
+ let probing = false;
87
+ if (s === "half-open") {
88
+ if (probes >= halfOpenMax) throw new CircuitOpenError(0);
89
+ probes++;
90
+ probing = true;
91
+ }
92
+
93
+ let result;
94
+ try {
95
+ result = await fn();
96
+ } catch (err) {
97
+ if (isFailure(err, true)) recordFailure();
98
+ else if (probing) probes--; // e.g. an abort — free the probe slot
99
+ throw err;
100
+ }
101
+
102
+ if (isFailure(result, false)) recordFailure();
103
+ else recordSuccess();
104
+ return result;
105
+ }
106
+
107
+ return {
108
+ run,
109
+ get state() {
110
+ return current();
111
+ },
112
+ get failures() {
113
+ return failures;
114
+ },
115
+ /** Force the circuit closed and forget the failure count. */
116
+ reset() {
117
+ failures = 0;
118
+ probes = 0;
119
+ setState("closed");
120
+ },
121
+ };
122
+ }
123
+
124
+ module.exports = { createCircuitBreaker, CircuitOpenError };