@boy-offi9-inc/reqkit 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boy Offi9
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ <p align="center">
2
+ <a href="https://www.npmjs.com/package/@boy-offi9-inc/reqkit" aria-label="npm">
3
+ <img src="./assets/logo-wordmark.svg" alt="reqkit on npm" width="480" />
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
9
+ <img src="https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white" alt="Node >=18">
10
+ <img src="https://img.shields.io/badge/npm-%40boy--offi9--inc%2Freqkit-CB3837?logo=npm&logoColor=white" alt="npm package">
11
+ <img src="https://img.shields.io/badge/dependencies-zero-brightgreen" alt="Zero dependencies">
12
+ <img src="https://img.shields.io/badge/module-CJS%20%2B%20ESM-blue" alt="CJS + ESM">
13
+ <img src="https://img.shields.io/badge/types-included-3178C6?logo=typescript&logoColor=white" alt="TypeScript types included">
14
+ </p>
15
+
16
+ # reqkit
17
+
18
+ Small, composable HTTP helper functions. Not a client, not an axios replacement — works alongside `fetch`, `axios`, or anything else you're already using. Pull in the one function you need; igno[...]
19
+
20
+ ```js
21
+ const { withRetry, normalizeError } = require("@boy-offi9-inc/reqkit");
22
+
23
+ const data = await withRetry(() => fetch(url).then(r => r.json()));
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Why this exists
29
+
30
+ Most HTTP retry/timeout wrappers bundle everything into a client you have to fully adopt. This is the opposite: standalone functions that compose with whatever you're already using, and a couple o[...]
31
+
32
+ **Honest note:** `withRetry`, `withTimeout`, and `normalizeError` solve a well-covered problem — there are other solid packages doing similar things (`fetchy`, `fetchpilot`, `p-retry`, to name a[...]
33
+
34
+ - **`retryAfterAware`** — actually reads `Retry-After`/`X-RateLimit-Reset` headers instead of blind backoff on 429s. Most retry libraries treat rate-limit responses like any other failure and ig[...]
35
+ - **`withProgress`** — progress callback for any streamed response body (downloads, large payloads, anything), not tied to a specific HTTP client.
36
+
37
+ Zero dependencies. CJS + ESM. TypeScript types included.
38
+
39
+ ---
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ npm install @boy-offi9-inc/reqkit
45
+ ```
46
+
47
+ ---
48
+
49
+ ## API
50
+
51
+ ### `withRetry(fn, opts?)`
52
+
53
+ Retries an async function with exponential backoff + jitter.
54
+
55
+ ```js
56
+ const data = await withRetry(
57
+ () => fetch(url).then(r => r.json()),
58
+ { retries: 3, baseDelayMs: 300, onRetry: (err, attempt, delayMs) => console.log(`retry ${attempt} in ${delayMs}ms`) }
59
+ );
60
+ ```
61
+
62
+ | Option | Default | Description |
63
+ |---|---|---|
64
+ | `retries` | `3` | max retry attempts after the first try |
65
+ | `baseDelayMs` | `300` | initial delay |
66
+ | `maxDelayMs` | `10000` | delay ceiling |
67
+ | `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` |
68
+ | `onRetry` | — | `(err, attempt, delayMs) => void` |
69
+
70
+ ### `withTimeout(fn, ms)`
71
+
72
+ Races an async function against a timeout, passing it an `AbortSignal` so the underlying request can actually be cancelled.
73
+
74
+ ```js
75
+ const data = await withTimeout(
76
+ signal => fetch(url, { signal }).then(r => r.json()),
77
+ 5000
78
+ );
79
+ // throws TimeoutError on timeout, regardless of whether fn cooperates with the signal
80
+ ```
81
+
82
+ ### `normalizeError(err)`
83
+
84
+ One consistent error shape regardless of source (fetch, axios, node http, generic):
85
+
86
+ ```js
87
+ const { message, status, code, isNetworkError, isTimeout } = normalizeError(err);
88
+ ```
89
+
90
+ Every field is always present (`null`/`false` when not applicable) — no existence checks needed.
91
+
92
+ ### `parseJsonSafe(input)`
93
+
94
+ Never throws on malformed JSON. Accepts a `Response` or a raw string.
95
+
96
+ ```js
97
+ const { data, error } = await parseJsonSafe(response);
98
+ if (error) { /* handle malformed JSON without a try/catch */ }
99
+ ```
100
+
101
+ ### `buildQuery(params)`
102
+
103
+ Minimal query string builder — skips `null`/`undefined`, repeats the key for arrays, encodes everything.
104
+
105
+ ```js
106
+ buildQuery({ q: "hello world", tag: ["a", "b"] });
107
+ // "q=hello%20world&tag=a&tag=b"
108
+ ```
109
+
110
+ For full parsing/nested-object support, use [`query-string`](https://www.npmjs.com/package/query-string) instead — this only covers the common flat-object case with zero dependencies.
111
+
112
+ ### `retryAfterAware(fn, opts?)`
113
+
114
+ Retries a fetch-like call, honoring `Retry-After`/`X-RateLimit-Reset` headers on 429/503 instead of blind backoff.
115
+
116
+ ```js
117
+ const response = await retryAfterAware(
118
+ () => fetch(url),
119
+ { retries: 3, retryStatusCodes: [429, 503] }
120
+ );
121
+ ```
122
+
123
+ | Option | Default | Description |
124
+ |---|---|---|
125
+ | `retries` | `3` | |
126
+ | `retryStatusCodes` | `[429, 503]` | |
127
+ | `fallbackDelayMs` | `1000` | used when no usable header is present |
128
+ | `maxDelayMs` | `60000` | |
129
+ | `onRetry` | — | `(status, waitMs, attempt) => void` |
130
+
131
+ ### `withProgress(response, onProgress?)`
132
+
133
+ Reads a streamed `Response` body while reporting progress, returns the collected bytes as a `Uint8Array`.
134
+
135
+ ```js
136
+ const bytes = await withProgress(response, ({ loaded, total, percent }) => {
137
+ console.log(`${percent ?? "?"}% (${loaded}/${total ?? "?"} bytes)`);
138
+ });
139
+ ```
140
+
141
+ Works without `Content-Length` too — `total`/`percent` are just `null` in that case, `loaded` is still accurate. Falls back to a single-shot read (still calling `onProgress` once) if the respon[...]
142
+
143
+ ---
144
+
145
+ ## Design notes
146
+
147
+ - **Zero dependencies.** Nothing to audit, nothing to break underneath you.
148
+ - **CJS + ESM.** `require()` and `import` both work.
149
+ - **Composable, not a client.** Every function takes and returns plain values (`Response`, plain objects, `Uint8Array`) — nothing proprietary to learn.
150
+ - **TypeScript types included**, hand-written (no build step to go wrong).
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg width="640" height="160" viewBox="0 0 640 160" xmlns="http://www.w3.org/2000/svg">
3
+ <defs>
4
+ <linearGradient id="wm-bg" x1="0" y1="0" x2="640" y2="160" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0" stop-color="#101825"/>
6
+ <stop offset="1" stop-color="#080c14"/>
7
+ </linearGradient>
8
+ <linearGradient id="wm-icon-bg" x1="0" y1="0" x2="240" y2="240" gradientUnits="userSpaceOnUse">
9
+ <stop offset="0" stop-color="#16202e"/>
10
+ <stop offset="1" stop-color="#0c121c"/>
11
+ </linearGradient>
12
+ <linearGradient id="wm-arc" x1="175" y1="88" x2="98" y2="60" gradientUnits="userSpaceOnUse">
13
+ <stop offset="0" stop-color="#3d8bff"/>
14
+ <stop offset="1" stop-color="#ffb43d"/>
15
+ </linearGradient>
16
+ </defs>
17
+
18
+ <rect x="0" y="0" width="640" height="160" rx="20" fill="url(#wm-bg)"/>
19
+
20
+ <!-- icon, scaled down from the 240x240 mark -->
21
+ <g transform="translate(24,20) scale(0.5)">
22
+ <rect x="6" y="6" width="228" height="228" rx="46" fill="url(#wm-icon-bg)"/>
23
+
24
+ <path d="M 175.4 88 A 64 64 0 1 1 98.1 59.9"
25
+ fill="none" stroke="url(#wm-arc)" stroke-width="13" stroke-linecap="round"/>
26
+
27
+ <polygon points="113.1,54.4 103.1,67.7 96.9,50.8" fill="#ffb43d"/>
28
+
29
+ <circle cx="169" cy="161.1" r="7" fill="#3d8bff"/>
30
+ <circle cx="78.9" cy="169" r="6" fill="#6f9fff"/>
31
+ <circle cx="59.9" cy="98.1" r="5" fill="#a3c2ff"/>
32
+ </g>
33
+
34
+ <!-- wordmark -->
35
+ <text x="164" y="82" font-family="'JetBrains Mono', 'Courier New', monospace" font-size="42" font-weight="700" fill="#eef2f7">req<tspan fill="#ffb43d">kit</tspan></text>
36
+ <text x="165" y="112" font-family="'JetBrains Mono', 'Courier New', monospace" font-size="16" fill="#6b7688">HTTP helpers — retry, timeout, rate-limit aware</text>
37
+ </svg>
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@boy-offi9-inc/reqkit",
3
+ "version": "0.1.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.",
5
+ "main": "./src/index.js",
6
+ "types": "./types/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./types/index.d.ts",
10
+ "require": "./src/index.js",
11
+ "import": "./src/index.mjs"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "sideEffects": false,
16
+ "scripts": {
17
+ "test": "node --test"
18
+ },
19
+ "keywords": [
20
+ "http",
21
+ "fetch",
22
+ "retry",
23
+ "backoff",
24
+ "timeout",
25
+ "rate-limit",
26
+ "retry-after",
27
+ "download-progress",
28
+ "error-normalization",
29
+ "utility"
30
+ ],
31
+ "author": "Boy Offi9",
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "files": [
40
+ "src",
41
+ "types",
42
+ "README.md",
43
+ "LICENSE",
44
+ "assets"
45
+ ]
46
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Builds a query string from a params object. Skips null/undefined values,
3
+ * repeats the key for arrays (`?tag=a&tag=b`), and encodes everything.
4
+ *
5
+ * Deliberately minimal — for full query-string parsing/nested-object support,
6
+ * reach for `query-string` (sindresorhus) instead. This just covers the
7
+ * common "build a query string from a flat params object" case with zero deps.
8
+ *
9
+ * @param {Record<string, unknown>} params
10
+ */
11
+ function buildQuery(params) {
12
+ if (!params) return "";
13
+
14
+ const pairs = [];
15
+ for (const [key, value] of Object.entries(params)) {
16
+ if (value === null || value === undefined) continue;
17
+
18
+ if (Array.isArray(value)) {
19
+ for (const v of value) {
20
+ if (v === null || v === undefined) continue;
21
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
22
+ }
23
+ } else {
24
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
25
+ }
26
+ }
27
+
28
+ return pairs.join("&");
29
+ }
30
+
31
+ module.exports = { buildQuery };
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ const { withRetry } = require("./withRetry");
2
+ const { withTimeout, TimeoutError } = require("./withTimeout");
3
+ const { normalizeError } = require("./normalizeError");
4
+ const { parseJsonSafe } = require("./parseJsonSafe");
5
+ const { buildQuery } = require("./buildQuery");
6
+ const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
7
+ const { withProgress } = require("./withProgress");
8
+
9
+ module.exports = {
10
+ withRetry,
11
+ withTimeout,
12
+ TimeoutError,
13
+ normalizeError,
14
+ parseJsonSafe,
15
+ buildQuery,
16
+ retryAfterAware,
17
+ parseRetryAfter,
18
+ getWaitMsFromHeaders,
19
+ withProgress,
20
+ };
package/src/index.mjs ADDED
@@ -0,0 +1,16 @@
1
+ import reqkit from "./index.js";
2
+
3
+ export const {
4
+ withRetry,
5
+ withTimeout,
6
+ TimeoutError,
7
+ normalizeError,
8
+ parseJsonSafe,
9
+ buildQuery,
10
+ retryAfterAware,
11
+ parseRetryAfter,
12
+ getWaitMsFromHeaders,
13
+ withProgress,
14
+ } = reqkit;
15
+
16
+ export default reqkit;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Normalizes errors from fetch, axios, node's http/https, and generic
3
+ * Error objects into one consistent shape:
4
+ * { message, status, code, isNetworkError, isTimeout, cause }
5
+ *
6
+ * Every field is always present (null/false when not applicable) so callers
7
+ * can destructure without existence checks.
8
+ */
9
+ function normalizeError(err) {
10
+ const base = {
11
+ message: "Unknown error",
12
+ status: null,
13
+ code: null,
14
+ isNetworkError: false,
15
+ isTimeout: false,
16
+ cause: err,
17
+ };
18
+
19
+ if (!err) return base;
20
+
21
+ // axios error shape: err.response.{status,data}, err.request, err.code
22
+ if (err.isAxiosError || (err.response && typeof err.response.status === "number" && err.config)) {
23
+ return {
24
+ message: err.response?.data?.message || err.message || "Request failed",
25
+ status: err.response?.status ?? null,
26
+ code: err.code ?? null,
27
+ isNetworkError: !err.response && Boolean(err.request),
28
+ isTimeout: err.code === "ECONNABORTED" || /timeout/i.test(err.message || ""),
29
+ cause: err,
30
+ };
31
+ }
32
+
33
+ // fetch Response passed in directly (not thrown, but common enough to handle)
34
+ if (typeof err.status === "number" && typeof err.ok === "boolean") {
35
+ return {
36
+ message: err.statusText || `Request failed with status ${err.status}`,
37
+ status: err.status,
38
+ code: null,
39
+ isNetworkError: false,
40
+ isTimeout: false,
41
+ cause: err,
42
+ };
43
+ }
44
+
45
+ // our own TimeoutError, or DOMException from AbortController
46
+ if (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "ETIMEDOUT") {
47
+ return {
48
+ message: err.message || "Request timed out",
49
+ status: null,
50
+ code: "ETIMEDOUT",
51
+ isNetworkError: false,
52
+ isTimeout: true,
53
+ cause: err,
54
+ };
55
+ }
56
+
57
+ // node http/https / undici network-level failures
58
+ if (err.code && /^E[A-Z]+$/.test(err.code)) {
59
+ return {
60
+ message: err.message || `Network error (${err.code})`,
61
+ status: null,
62
+ code: err.code,
63
+ isNetworkError: true,
64
+ isTimeout: false,
65
+ cause: err,
66
+ };
67
+ }
68
+
69
+ return {
70
+ message: err.message || String(err),
71
+ status: err.status ?? err.statusCode ?? null,
72
+ code: err.code ?? null,
73
+ isNetworkError: false,
74
+ isTimeout: false,
75
+ cause: err,
76
+ };
77
+ }
78
+
79
+ module.exports = { normalizeError };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Parses a fetch Response (or raw string) as JSON without throwing.
3
+ * Returns { data, error } — exactly one is non-null.
4
+ *
5
+ * @param {Response | string} input
6
+ */
7
+ async function parseJsonSafe(input) {
8
+ let text;
9
+ try {
10
+ text = typeof input === "string" ? input : await input.text();
11
+ } catch (err) {
12
+ return { data: null, error: err };
13
+ }
14
+
15
+ if (!text) {
16
+ return { data: null, error: null };
17
+ }
18
+
19
+ try {
20
+ return { data: JSON.parse(text), error: null };
21
+ } catch (err) {
22
+ return { data: null, error: err };
23
+ }
24
+ }
25
+
26
+ module.exports = { parseJsonSafe };
@@ -0,0 +1,90 @@
1
+ function delay(ms) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }
4
+
5
+ /**
6
+ * Parses a Retry-After header value into milliseconds. Per HTTP spec it's
7
+ * either an integer number of seconds, or an HTTP-date.
8
+ */
9
+ function parseRetryAfter(headerValue) {
10
+ if (!headerValue) return null;
11
+
12
+ const seconds = Number(headerValue);
13
+ if (!Number.isNaN(seconds)) {
14
+ return Math.max(0, seconds * 1000);
15
+ }
16
+
17
+ const dateMs = Date.parse(headerValue);
18
+ if (!Number.isNaN(dateMs)) {
19
+ return Math.max(0, dateMs - Date.now());
20
+ }
21
+
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Reads a wait time from common rate-limit headers, trying Retry-After first,
27
+ * then falling back to X-RateLimit-Reset (epoch seconds or ms) if present.
28
+ */
29
+ function getWaitMsFromHeaders(headers) {
30
+ if (!headers || typeof headers.get !== "function") return null;
31
+
32
+ const retryAfter = parseRetryAfter(headers.get("retry-after"));
33
+ if (retryAfter !== null) return retryAfter;
34
+
35
+ const resetHeader = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset");
36
+ if (resetHeader) {
37
+ const resetValue = Number(resetHeader);
38
+ if (!Number.isNaN(resetValue)) {
39
+ // Could be epoch seconds or epoch ms — treat values below a
40
+ // year-2100-in-seconds threshold as seconds.
41
+ const resetMs = resetValue > 4102444800 ? resetValue : resetValue * 1000;
42
+ return Math.max(0, resetMs - Date.now());
43
+ }
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Retries a fetch-like call, honoring Retry-After / X-RateLimit-Reset
51
+ * headers on 429/503 responses instead of blind exponential backoff — most
52
+ * generic retry libraries treat rate-limit responses like any other failure
53
+ * and ignore the header the server is explicitly giving you.
54
+ *
55
+ * `fn` should return a fetch Response (or anything with `.status` and
56
+ * `.headers.get()`).
57
+ *
58
+ * @param {() => Promise<Response>} fn
59
+ * @param {object} [opts]
60
+ * @param {number} [opts.retries=3]
61
+ * @param {number[]} [opts.retryStatusCodes=[429, 503]]
62
+ * @param {number} [opts.fallbackDelayMs=1000] - used when no usable header is present
63
+ * @param {number} [opts.maxDelayMs=60000]
64
+ * @param {(status: number, waitMs: number, attempt: number) => void} [opts.onRetry]
65
+ */
66
+ async function retryAfterAware(fn, opts = {}) {
67
+ const {
68
+ retries = 3,
69
+ retryStatusCodes = [429, 503],
70
+ fallbackDelayMs = 1000,
71
+ maxDelayMs = 60000,
72
+ onRetry,
73
+ } = opts;
74
+
75
+ for (let attempt = 0; attempt <= retries; attempt++) {
76
+ const response = await fn(attempt);
77
+
78
+ if (!retryStatusCodes.includes(response.status) || attempt === retries) {
79
+ return response;
80
+ }
81
+
82
+ const headerWaitMs = getWaitMsFromHeaders(response.headers);
83
+ const waitMs = Math.min(headerWaitMs ?? fallbackDelayMs * 2 ** attempt, maxDelayMs);
84
+
85
+ if (onRetry) onRetry(response.status, waitMs, attempt);
86
+ await delay(waitMs);
87
+ }
88
+ }
89
+
90
+ module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reads a fetch Response body while reporting progress, and returns the
3
+ * fully collected bytes as a Uint8Array. Works for any streamed response,
4
+ * not just file downloads — same shape whether it's a media file, a large
5
+ * JSON payload, or anything else worth showing progress for.
6
+ *
7
+ * If the server doesn't send Content-Length, `total` will be null in the
8
+ * progress callback — the loaded count is still accurate.
9
+ *
10
+ * @param {Response} response
11
+ * @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} onProgress
12
+ * @returns {Promise<Uint8Array>}
13
+ */
14
+ async function withProgress(response, onProgress) {
15
+ if (!response.body || typeof response.body.getReader !== "function") {
16
+ // No streaming support available — fall back to a single-shot read,
17
+ // still reporting one final progress event so callers don't need a branch.
18
+ const buf = new Uint8Array(await response.arrayBuffer());
19
+ if (onProgress) {
20
+ onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
21
+ }
22
+ return buf;
23
+ }
24
+
25
+ const contentLength = response.headers?.get?.("content-length");
26
+ const total = contentLength ? Number(contentLength) : null;
27
+
28
+ const reader = response.body.getReader();
29
+ const chunks = [];
30
+ let loaded = 0;
31
+
32
+ for (;;) {
33
+ const { done, value } = await reader.read();
34
+ if (done) break;
35
+
36
+ chunks.push(value);
37
+ loaded += value.byteLength;
38
+
39
+ if (onProgress) {
40
+ onProgress({
41
+ loaded,
42
+ total,
43
+ percent: total ? Math.round((loaded / total) * 100) : null,
44
+ });
45
+ }
46
+ }
47
+
48
+ const result = new Uint8Array(loaded);
49
+ let offset = 0;
50
+ for (const chunk of chunks) {
51
+ result.set(chunk, offset);
52
+ offset += chunk.byteLength;
53
+ }
54
+ return result;
55
+ }
56
+
57
+ module.exports = { withProgress };
@@ -0,0 +1,52 @@
1
+ function defaultShouldRetry(err) {
2
+ // Retry network-level failures by default. HTTP status handling is the
3
+ // caller's job (they know what "failure" means for their response shape) —
4
+ // see retryAfterAware.js for a version that understands status codes.
5
+ return Boolean(err) && err.name !== "AbortError";
6
+ }
7
+
8
+ function delay(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ /**
13
+ * Retries an async function with exponential backoff + jitter.
14
+ *
15
+ * @param {() => Promise<any>} fn
16
+ * @param {object} [opts]
17
+ * @param {number} [opts.retries=3] - max retry attempts after the first try
18
+ * @param {number} [opts.baseDelayMs=300] - initial delay
19
+ * @param {number} [opts.maxDelayMs=10000] - delay ceiling
20
+ * @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
21
+ * @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
22
+ */
23
+ async function withRetry(fn, opts = {}) {
24
+ const {
25
+ retries = 3,
26
+ baseDelayMs = 300,
27
+ maxDelayMs = 10000,
28
+ shouldRetry = defaultShouldRetry,
29
+ onRetry,
30
+ } = opts;
31
+
32
+ let lastErr;
33
+ for (let attempt = 0; attempt <= retries; attempt++) {
34
+ try {
35
+ return await fn(attempt);
36
+ } catch (err) {
37
+ lastErr = err;
38
+ const isLastAttempt = attempt === retries;
39
+ if (isLastAttempt || !shouldRetry(err, attempt)) {
40
+ throw err;
41
+ }
42
+ const exponential = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
43
+ const jitter = Math.random() * exponential * 0.2;
44
+ const waitMs = Math.round(exponential + jitter);
45
+ if (onRetry) onRetry(err, attempt, waitMs);
46
+ await delay(waitMs);
47
+ }
48
+ }
49
+ throw lastErr;
50
+ }
51
+
52
+ module.exports = { withRetry };
@@ -0,0 +1,41 @@
1
+ class TimeoutError extends Error {
2
+ constructor(ms) {
3
+ super(`Timed out after ${ms}ms`);
4
+ this.name = "TimeoutError";
5
+ this.code = "ETIMEDOUT";
6
+ }
7
+ }
8
+
9
+ /**
10
+ * Races an async function against a timeout. If `fn` accepts an AbortSignal
11
+ * as its argument, it's passed one so the underlying request can actually be
12
+ * cancelled (not just abandoned) — e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
13
+ *
14
+ * @param {(signal: AbortSignal) => Promise<any>} fn
15
+ * @param {number} ms
16
+ */
17
+ async function withTimeout(fn, ms) {
18
+ const controller = new AbortController();
19
+ const timer = setTimeout(() => controller.abort(), ms);
20
+
21
+ const timeoutPromise = new Promise((_, reject) => {
22
+ controller.signal.addEventListener("abort", () => reject(new TimeoutError(ms)));
23
+ });
24
+
25
+ try {
26
+ return await Promise.race([fn(controller.signal), timeoutPromise]);
27
+ } catch (err) {
28
+ // If our timeout is what triggered the abort, always surface TimeoutError —
29
+ // regardless of whether fn's own rejection or our timeout promise "won"
30
+ // the race (that's a listener-registration-order detail callers shouldn't
31
+ // have to think about).
32
+ if (controller.signal.aborted) {
33
+ throw new TimeoutError(ms);
34
+ }
35
+ throw err;
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ module.exports = { withTimeout, TimeoutError };
@@ -0,0 +1,63 @@
1
+ export interface RetryOptions {
2
+ retries?: number;
3
+ baseDelayMs?: number;
4
+ maxDelayMs?: number;
5
+ shouldRetry?: (err: unknown, attempt: number) => boolean;
6
+ onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
7
+ }
8
+
9
+ export function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryOptions): Promise<T>;
10
+
11
+ export class TimeoutError extends Error {
12
+ code: "ETIMEDOUT";
13
+ constructor(ms: number);
14
+ }
15
+
16
+ export function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T>;
17
+
18
+ export interface NormalizedError {
19
+ message: string;
20
+ status: number | null;
21
+ code: string | null;
22
+ isNetworkError: boolean;
23
+ isTimeout: boolean;
24
+ cause: unknown;
25
+ }
26
+
27
+ export function normalizeError(err: unknown): NormalizedError;
28
+
29
+ export interface JsonSafeResult<T = unknown> {
30
+ data: T | null;
31
+ error: Error | null;
32
+ }
33
+
34
+ export function parseJsonSafe<T = unknown>(input: Response | string): Promise<JsonSafeResult<T>>;
35
+
36
+ export function buildQuery(params: Record<string, unknown> | null | undefined): string;
37
+
38
+ export interface RetryAfterAwareOptions {
39
+ retries?: number;
40
+ retryStatusCodes?: number[];
41
+ fallbackDelayMs?: number;
42
+ maxDelayMs?: number;
43
+ onRetry?: (status: number, waitMs: number, attempt: number) => void;
44
+ }
45
+
46
+ export function retryAfterAware(
47
+ fn: (attempt: number) => Promise<Response>,
48
+ opts?: RetryAfterAwareOptions
49
+ ): Promise<Response>;
50
+
51
+ export function parseRetryAfter(headerValue: string | null): number | null;
52
+ export function getWaitMsFromHeaders(headers: Headers | null | undefined): number | null;
53
+
54
+ export interface DownloadProgress {
55
+ loaded: number;
56
+ total: number | null;
57
+ percent: number | null;
58
+ }
59
+
60
+ export function withProgress(
61
+ response: Response,
62
+ onProgress?: (progress: DownloadProgress) => void
63
+ ): Promise<Uint8Array>;