@boy-offi9-inc/reqkit 0.1.0 → 0.1.7
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 +37 -6
- package/package.json +2 -3
- package/src/index.js +4 -1
- package/src/index.mjs +2 -0
- package/src/withDedupe.js +50 -0
- package/src/withRetry.js +36 -5
- package/types/index.d.ts +14 -0
- package/assets/logo-wordmark.svg +0 -37
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<a href="https://www.npmjs.com/package/@boy-offi9-inc/reqkit" aria-label="npm">
|
|
3
|
-
<img src="
|
|
3
|
+
<img src="https://i.ibb.co/1f63mDTg/logo-wordmark.png" alt="reqkit on npm" width="480" />
|
|
4
4
|
</a>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
# reqkit
|
|
17
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;
|
|
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; ignore the rest.
|
|
19
19
|
|
|
20
20
|
```js
|
|
21
21
|
const { withRetry, normalizeError } = require("@boy-offi9-inc/reqkit");
|
|
@@ -27,12 +27,13 @@ const data = await withRetry(() => fetch(url).then(r => r.json()));
|
|
|
27
27
|
|
|
28
28
|
## Why this exists
|
|
29
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
|
|
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 of small helpers that you can pick up independently.
|
|
31
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
|
|
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 few).
|
|
33
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
|
|
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 ignore the server's suggested retry time.
|
|
35
35
|
- **`withProgress`** — progress callback for any streamed response body (downloads, large payloads, anything), not tied to a specific HTTP client.
|
|
36
|
+
- **`withDedupe`** — coalesces concurrent calls with the same key into one in-flight call, so simultaneous requests for the same resource don't each hit the network.
|
|
36
37
|
|
|
37
38
|
Zero dependencies. CJS + ESM. TypeScript types included.
|
|
38
39
|
|
|
@@ -66,6 +67,14 @@ const data = await withRetry(
|
|
|
66
67
|
| `maxDelayMs` | `10000` | delay ceiling |
|
|
67
68
|
| `shouldRetry` | retries anything but AbortError | `(err, attempt) => boolean` |
|
|
68
69
|
| `onRetry` | — | `(err, attempt, delayMs) => void` |
|
|
70
|
+
| `signal` | — | `AbortSignal` — cancels the whole retry loop, including any pending backoff wait, throwing `RetryAbortedError` |
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// Cancel a long retry/backoff sequence externally — e.g. the user navigated away
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const promise = withRetry(() => fetch(url), { retries: 5, signal: controller.signal });
|
|
76
|
+
controller.abort(); // rejects with RetryAbortedError, even mid-backoff
|
|
77
|
+
```
|
|
69
78
|
|
|
70
79
|
### `withTimeout(fn, ms)`
|
|
71
80
|
|
|
@@ -138,7 +147,29 @@ const bytes = await withProgress(response, ({ loaded, total, percent }) => {
|
|
|
138
147
|
});
|
|
139
148
|
```
|
|
140
149
|
|
|
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
|
|
150
|
+
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 response body is not a stream.
|
|
151
|
+
|
|
152
|
+
### `withDedupe(fn, opts?)`
|
|
153
|
+
|
|
154
|
+
Coalesces concurrent calls with the same arguments into a single in-flight call — every caller gets the same result, but the underlying request only fires once. Useful when several parts of a UI ask for the same resource around the same time.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const getUser = withDedupe((id) => fetch(`/api/users/${id}`).then(r => r.json()));
|
|
158
|
+
|
|
159
|
+
// Only one network request goes out — both callers share it.
|
|
160
|
+
const [a, b] = await Promise.all([getUser(1), getUser(1)]);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
| Option | Default | Notes |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| `keyFn` | `(...args) => JSON.stringify(args)` | derives the dedupe key from call args |
|
|
166
|
+
|
|
167
|
+
This is deduplication of *concurrent* calls, not a cache — once a call settles (success or failure), the next call with the same key starts fresh.
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
// Custom key when the default arg-based key isn't quite right
|
|
171
|
+
const fn = withDedupe((req) => doFetch(req), { keyFn: (req) => req.url });
|
|
172
|
+
```
|
|
142
173
|
|
|
143
174
|
---
|
|
144
175
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
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
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
"src",
|
|
41
41
|
"types",
|
|
42
42
|
"README.md",
|
|
43
|
-
"LICENSE"
|
|
44
|
-
"assets"
|
|
43
|
+
"LICENSE"
|
|
45
44
|
]
|
|
46
45
|
}
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
const { withRetry } = require("./withRetry");
|
|
1
|
+
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
2
2
|
const { withTimeout, TimeoutError } = require("./withTimeout");
|
|
3
3
|
const { normalizeError } = require("./normalizeError");
|
|
4
4
|
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
5
5
|
const { buildQuery } = require("./buildQuery");
|
|
6
6
|
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
7
7
|
const { withProgress } = require("./withProgress");
|
|
8
|
+
const { withDedupe } = require("./withDedupe");
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
10
11
|
withRetry,
|
|
12
|
+
RetryAbortedError,
|
|
11
13
|
withTimeout,
|
|
12
14
|
TimeoutError,
|
|
13
15
|
normalizeError,
|
|
@@ -17,4 +19,5 @@ module.exports = {
|
|
|
17
19
|
parseRetryAfter,
|
|
18
20
|
getWaitMsFromHeaders,
|
|
19
21
|
withProgress,
|
|
22
|
+
withDedupe,
|
|
20
23
|
};
|
package/src/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import reqkit from "./index.js";
|
|
|
2
2
|
|
|
3
3
|
export const {
|
|
4
4
|
withRetry,
|
|
5
|
+
RetryAbortedError,
|
|
5
6
|
withTimeout,
|
|
6
7
|
TimeoutError,
|
|
7
8
|
normalizeError,
|
|
@@ -11,6 +12,7 @@ export const {
|
|
|
11
12
|
parseRetryAfter,
|
|
12
13
|
getWaitMsFromHeaders,
|
|
13
14
|
withProgress,
|
|
15
|
+
withDedupe,
|
|
14
16
|
} = reqkit;
|
|
15
17
|
|
|
16
18
|
export default reqkit;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function defaultKeyFn(...args) {
|
|
2
|
+
try {
|
|
3
|
+
return JSON.stringify(args);
|
|
4
|
+
} catch {
|
|
5
|
+
// Fall back to a stable-ish key if args aren't JSON-serializable
|
|
6
|
+
// (e.g. contain a function or circular ref).
|
|
7
|
+
return args.map((a) => String(a)).join("|");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps an async function so that concurrent calls with the same key share
|
|
13
|
+
* a single in-flight promise instead of firing duplicate requests. Common
|
|
14
|
+
* use case: several parts of a UI ask for the same resource at once (e.g. a
|
|
15
|
+
* user profile) — only one network request actually goes out, and every
|
|
16
|
+
* caller resolves with the same result.
|
|
17
|
+
*
|
|
18
|
+
* The in-flight entry is cleared as soon as the call settles (success or
|
|
19
|
+
* failure), so the next call — even with the same key — always starts a
|
|
20
|
+
* fresh request. This is deduplication of concurrent calls, not a cache.
|
|
21
|
+
*
|
|
22
|
+
* @param {(...args: any[]) => Promise<any>} fn
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
|
|
25
|
+
* from the call arguments. Defaults to JSON.stringify(args).
|
|
26
|
+
* @returns {(...args: any[]) => Promise<any>}
|
|
27
|
+
*/
|
|
28
|
+
function withDedupe(fn, opts = {}) {
|
|
29
|
+
const { keyFn = defaultKeyFn } = opts;
|
|
30
|
+
const inFlight = new Map();
|
|
31
|
+
|
|
32
|
+
return function deduped(...args) {
|
|
33
|
+
const key = keyFn(...args);
|
|
34
|
+
|
|
35
|
+
if (inFlight.has(key)) {
|
|
36
|
+
return inFlight.get(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const promise = Promise.resolve()
|
|
40
|
+
.then(() => fn(...args))
|
|
41
|
+
.finally(() => {
|
|
42
|
+
inFlight.delete(key);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
inFlight.set(key, promise);
|
|
46
|
+
return promise;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { withDedupe };
|
package/src/withRetry.js
CHANGED
|
@@ -5,20 +5,47 @@ function defaultShouldRetry(err) {
|
|
|
5
5
|
return Boolean(err) && err.name !== "AbortError";
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
class RetryAbortedError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super("Retry loop was aborted");
|
|
11
|
+
this.name = "RetryAbortedError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function delay(ms, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
reject(new RetryAbortedError());
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const timer = setTimeout(resolve, ms);
|
|
22
|
+
if (signal) {
|
|
23
|
+
signal.addEventListener(
|
|
24
|
+
"abort",
|
|
25
|
+
() => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
reject(new RetryAbortedError());
|
|
28
|
+
},
|
|
29
|
+
{ once: true }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
10
33
|
}
|
|
11
34
|
|
|
12
35
|
/**
|
|
13
36
|
* Retries an async function with exponential backoff + jitter.
|
|
14
37
|
*
|
|
15
|
-
* @param {() => Promise<any>} fn
|
|
38
|
+
* @param {(attempt: number) => Promise<any>} fn
|
|
16
39
|
* @param {object} [opts]
|
|
17
40
|
* @param {number} [opts.retries=3] - max retry attempts after the first try
|
|
18
41
|
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
19
42
|
* @param {number} [opts.maxDelayMs=10000] - delay ceiling
|
|
20
43
|
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry]
|
|
21
44
|
* @param {(err: any, attempt: number, delayMs: number) => void} [opts.onRetry]
|
|
45
|
+
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
46
|
+
* including any pending backoff wait, throwing RetryAbortedError. This is
|
|
47
|
+
* separate from a per-call AbortSignal you might pass into `fn` yourself —
|
|
48
|
+
* this one governs whether *another attempt starts at all*.
|
|
22
49
|
*/
|
|
23
50
|
async function withRetry(fn, opts = {}) {
|
|
24
51
|
const {
|
|
@@ -27,10 +54,14 @@ async function withRetry(fn, opts = {}) {
|
|
|
27
54
|
maxDelayMs = 10000,
|
|
28
55
|
shouldRetry = defaultShouldRetry,
|
|
29
56
|
onRetry,
|
|
57
|
+
signal,
|
|
30
58
|
} = opts;
|
|
31
59
|
|
|
32
60
|
let lastErr;
|
|
33
61
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
62
|
+
if (signal?.aborted) {
|
|
63
|
+
throw new RetryAbortedError();
|
|
64
|
+
}
|
|
34
65
|
try {
|
|
35
66
|
return await fn(attempt);
|
|
36
67
|
} catch (err) {
|
|
@@ -43,10 +74,10 @@ async function withRetry(fn, opts = {}) {
|
|
|
43
74
|
const jitter = Math.random() * exponential * 0.2;
|
|
44
75
|
const waitMs = Math.round(exponential + jitter);
|
|
45
76
|
if (onRetry) onRetry(err, attempt, waitMs);
|
|
46
|
-
await delay(waitMs);
|
|
77
|
+
await delay(waitMs, signal);
|
|
47
78
|
}
|
|
48
79
|
}
|
|
49
80
|
throw lastErr;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
|
-
module.exports = { withRetry };
|
|
83
|
+
module.exports = { withRetry, RetryAbortedError };
|
package/types/index.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ export interface RetryOptions {
|
|
|
4
4
|
maxDelayMs?: number;
|
|
5
5
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
6
6
|
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RetryAbortedError extends Error {
|
|
11
|
+
name: "RetryAbortedError";
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
export function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryOptions): Promise<T>;
|
|
@@ -61,3 +66,12 @@ export function withProgress(
|
|
|
61
66
|
response: Response,
|
|
62
67
|
onProgress?: (progress: DownloadProgress) => void
|
|
63
68
|
): Promise<Uint8Array>;
|
|
69
|
+
|
|
70
|
+
export interface DedupeOptions {
|
|
71
|
+
keyFn?: (...args: any[]) => string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function withDedupe<Fn extends (...args: any[]) => Promise<any>>(
|
|
75
|
+
fn: Fn,
|
|
76
|
+
opts?: DedupeOptions
|
|
77
|
+
): Fn;
|
package/assets/logo-wordmark.svg
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
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>
|