@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 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="./assets/logo-wordmark.svg" alt="reqkit on npm" width="480" />
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; igno[...]
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 o[...]
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 ig[...]
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 respon[...]
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.0",
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
- function delay(ms) {
9
- return new Promise((resolve) => setTimeout(resolve, ms));
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;
@@ -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>