@boy-offi9-inc/reqkit 0.1.2 → 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
@@ -33,6 +33,7 @@ Most HTTP retry/timeout wrappers bundle everything into a client you have to ful
33
33
 
34
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
 
@@ -140,6 +149,28 @@ const bytes = await withProgress(response, ({ loaded, total, percent }) => {
140
149
 
141
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.
142
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
+ ```
173
+
143
174
  ---
144
175
 
145
176
  ## Design notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.1.2",
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;