@fetchkit/ffetch 4.3.0 → 5.0.1
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 -18
- package/dist/index.cjs +118 -238
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -27
- package/dist/index.d.ts +13 -27
- package/dist/index.js +118 -239
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +1 -1
- package/dist/plugins/circuit.cjs +122 -0
- package/dist/plugins/circuit.cjs.map +1 -0
- package/dist/plugins/circuit.d.cts +15 -0
- package/dist/plugins/circuit.d.ts +15 -0
- package/dist/plugins/circuit.js +85 -0
- package/dist/plugins/circuit.js.map +1 -0
- package/dist/plugins/dedupe.cjs +167 -0
- package/dist/plugins/dedupe.cjs.map +1 -0
- package/dist/plugins/dedupe.d.cts +22 -0
- package/dist/plugins/dedupe.d.ts +22 -0
- package/dist/plugins/dedupe.js +139 -0
- package/dist/plugins/dedupe.js.map +1 -0
- package/dist/plugins-9qcU31nU.d.cts +58 -0
- package/dist/plugins-9qcU31nU.d.ts +58 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -15,18 +15,22 @@
|
|
|
15
15
|
|
|
16
16
|
ffetch can wrap any fetch-compatible implementation (native fetch, node-fetch, undici, or framework-provided fetch), making it flexible for SSR, edge, and custom environments.
|
|
17
17
|
|
|
18
|
+
ffetch uses a plugin architecture for optional features, so you only include what you need.
|
|
19
|
+
|
|
18
20
|
**Key Features:**
|
|
19
21
|
|
|
20
22
|
- **Timeouts** – per-request or global
|
|
21
23
|
- **Retries** – exponential backoff + jitter
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
+
- **Abort-aware retries** – aborting during backoff cancels immediately
|
|
25
|
+
- **Plugin architecture** – extensible lifecycle-based plugins for optional behavior
|
|
24
26
|
- **Hooks** – logging, auth, metrics, request/response transformation
|
|
25
27
|
- **Pending requests** – real-time monitoring of active requests
|
|
26
28
|
- **Per-request overrides** – customize behavior on a per-request basis
|
|
27
29
|
- **Universal** – Node.js, Browser, Cloudflare Workers, React Native
|
|
28
30
|
- **Zero runtime deps** – ships as dual ESM/CJS
|
|
29
31
|
- **Configurable error handling** – custom error types and `throwOnHttpError` flag to throw on HTTP errors
|
|
32
|
+
- **Circuit breaker plugin (optional, prebuilt)** – automatic failure protection
|
|
33
|
+
- **Deduplication plugin (optional, prebuilt)** – automatic deduping of in-flight identical requests
|
|
30
34
|
|
|
31
35
|
## Quick Start
|
|
32
36
|
|
|
@@ -39,13 +43,14 @@ npm install @fetchkit/ffetch
|
|
|
39
43
|
### Basic Usage
|
|
40
44
|
|
|
41
45
|
```typescript
|
|
42
|
-
import createClient from '@fetchkit/ffetch'
|
|
46
|
+
import { createClient } from '@fetchkit/ffetch'
|
|
47
|
+
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
|
|
43
48
|
|
|
44
|
-
// Create a client with timeout, retries, and deduplication
|
|
49
|
+
// Create a client with timeout, retries, and deduplication plugin
|
|
45
50
|
const api = createClient({
|
|
46
51
|
timeout: 5000,
|
|
47
52
|
retries: 3,
|
|
48
|
-
|
|
53
|
+
plugins: [dedupePlugin()],
|
|
49
54
|
retryDelay: ({ attempt }) => 2 ** attempt * 100 + Math.random() * 100,
|
|
50
55
|
})
|
|
51
56
|
|
|
@@ -64,7 +69,7 @@ const [r1, r2] = await Promise.all([p1, p2])
|
|
|
64
69
|
|
|
65
70
|
```typescript
|
|
66
71
|
// Example: SvelteKit, Next.js, Nuxt, or node-fetch
|
|
67
|
-
import createClient from '@fetchkit/ffetch'
|
|
72
|
+
import { createClient } from '@fetchkit/ffetch'
|
|
68
73
|
|
|
69
74
|
// Pass your framework's fetch implementation
|
|
70
75
|
const api = createClient({
|
|
@@ -84,19 +89,31 @@ const response = await api('/api/data')
|
|
|
84
89
|
|
|
85
90
|
```typescript
|
|
86
91
|
// Production-ready client with error handling and monitoring
|
|
92
|
+
import { createClient } from '@fetchkit/ffetch'
|
|
93
|
+
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
|
|
94
|
+
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
|
|
95
|
+
|
|
87
96
|
const client = createClient({
|
|
88
97
|
timeout: 10000,
|
|
89
98
|
retries: 2,
|
|
90
|
-
dedupe: true,
|
|
91
|
-
dedupeHashFn: (params) => `${params.method}|${params.url}|${params.body}`,
|
|
92
|
-
circuit: { threshold: 5, reset: 30000 },
|
|
93
99
|
fetchHandler: fetch, // Use custom fetch if needed
|
|
100
|
+
plugins: [
|
|
101
|
+
dedupePlugin({
|
|
102
|
+
hashFn: (params) => `${params.method}|${params.url}|${params.body}`,
|
|
103
|
+
ttl: 30_000,
|
|
104
|
+
sweepInterval: 5_000,
|
|
105
|
+
}),
|
|
106
|
+
circuitPlugin({
|
|
107
|
+
threshold: 5,
|
|
108
|
+
reset: 30_000,
|
|
109
|
+
onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url),
|
|
110
|
+
onCircuitClose: (req) => console.info('Circuit closed after:', req.url),
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
94
113
|
hooks: {
|
|
95
114
|
before: async (req) => console.log('→', req.url),
|
|
96
115
|
after: async (req, res) => console.log('←', res.status),
|
|
97
116
|
onError: async (req, err) => console.error('Error:', err.message),
|
|
98
|
-
onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url),
|
|
99
|
-
onCircuitClose: (req) => console.info('Circuit closed after:', req.url),
|
|
100
117
|
},
|
|
101
118
|
})
|
|
102
119
|
|
|
@@ -130,6 +147,7 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
|
|
|
130
147
|
| --------------------------------------------- | ------------------------------------------------------------------------- |
|
|
131
148
|
| **[Complete Documentation](./docs/index.md)** | **Start here** - Documentation index and overview |
|
|
132
149
|
| **[API Reference](./docs/api.md)** | Complete API documentation and configuration options |
|
|
150
|
+
| **[Plugin Architecture](./docs/plugins.md)** | Plugin lifecycle, custom plugin authoring, and integration patterns |
|
|
133
151
|
| **[Deduplication](./docs/deduplication.md)** | How deduplication works, hash config, optional TTL cleanup, limitations |
|
|
134
152
|
| **[Error Handling](./docs/errorhandling.md)** | Strategies for managing errors, including `throwOnHttpError` |
|
|
135
153
|
| **[Advanced Features](./docs/advanced.md)** | Per-request overrides, pending requests, circuit breakers, custom errors |
|
|
@@ -139,12 +157,12 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
|
|
|
139
157
|
|
|
140
158
|
## Environment Requirements
|
|
141
159
|
|
|
142
|
-
`ffetch`
|
|
160
|
+
`ffetch` works best with native `AbortSignal.any` support:
|
|
143
161
|
|
|
144
|
-
- **Node.js 20.6+** (
|
|
145
|
-
- **Modern browsers
|
|
162
|
+
- **Node.js 20.6+** (native `AbortSignal.any`)
|
|
163
|
+
- **Modern browsers with `AbortSignal.any`** (for example: Chrome 117+, Firefox 117+, Safari 17+, Edge 117+)
|
|
146
164
|
|
|
147
|
-
If your environment does not support `AbortSignal.any` (Node.js < 20.6, older browsers), you
|
|
165
|
+
If your environment does not support `AbortSignal.any` (Node.js < 20.6, older browsers), you can still use ffetch by installing an `AbortSignal.any` polyfill. `AbortSignal.timeout` is optional because ffetch includes an internal timeout fallback. See the [compatibility guide](./docs/compatibility.md) for instructions.
|
|
148
166
|
|
|
149
167
|
**Custom fetch support:**
|
|
150
168
|
You can pass any fetch-compatible implementation (native fetch, node-fetch, undici, SvelteKit, Next.js, Nuxt, or a polyfill) via the `fetchHandler` option. This makes ffetch fully compatible with SSR, edge, metaframework environments, custom backends, and test runners.
|
|
@@ -161,7 +179,7 @@ npm install abort-controller-x
|
|
|
161
179
|
|
|
162
180
|
```html
|
|
163
181
|
<script type="module">
|
|
164
|
-
import createClient from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js'
|
|
182
|
+
import { createClient } from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js'
|
|
165
183
|
|
|
166
184
|
const api = createClient({ timeout: 5000 })
|
|
167
185
|
const data = await api('/api/data').then((r) => r.json())
|
|
@@ -170,9 +188,9 @@ npm install abort-controller-x
|
|
|
170
188
|
|
|
171
189
|
## Deduplication Limitations
|
|
172
190
|
|
|
173
|
-
- Deduplication is **off** by default. Enable it via
|
|
191
|
+
- Deduplication is **off** by default. Enable it via `plugins: [dedupePlugin()]`.
|
|
174
192
|
- The default hash function is `dedupeRequestHash`, which handles common body types and skips deduplication for streams and FormData.
|
|
175
|
-
- Optional stale-entry cleanup: `
|
|
193
|
+
- Optional stale-entry cleanup: `dedupePlugin({ ttl, sweepInterval })` enables map-entry eviction. TTL eviction only removes dedupe keys; it does not reject already in-flight promises.
|
|
176
194
|
- **Stream bodies** (`ReadableStream`, `FormData`): Deduplication is skipped for requests with these body types, as they cannot be reliably hashed or replayed.
|
|
177
195
|
- **Non-idempotent requests**: Use deduplication with caution for non-idempotent methods (e.g., POST), as it may suppress multiple intended requests.
|
|
178
196
|
- **Custom hash function**: Ensure your hash function uniquely identifies requests to avoid accidental deduplication.
|
|
@@ -185,6 +203,7 @@ See [deduplication.md](./docs/deduplication.md) for full details.
|
|
|
185
203
|
| -------------------- | ------------------------- | -------------------- | -------------------------------------------------------------------------------------- |
|
|
186
204
|
| Timeouts | ❌ Manual AbortController | ✅ Built-in | ✅ Built-in with fallbacks |
|
|
187
205
|
| Retries | ❌ Manual implementation | ❌ Manual or plugins | ✅ Smart exponential backoff |
|
|
206
|
+
| Plugin Architecture | ❌ Not available | ⚠️ Interceptors only | ✅ First-class plugin pipeline (optional built-in + custom plugins) |
|
|
188
207
|
| Circuit Breaker | ❌ Not available | ❌ Manual or plugins | ✅ Automatic failure protection |
|
|
189
208
|
| Deduplication | ❌ Not available | ❌ Not available | ✅ Automatic deduplication of in-flight identical requests |
|
|
190
209
|
| Request Monitoring | ❌ Manual tracking | ❌ Manual tracking | ✅ Built-in pending requests |
|
package/dist/index.cjs
CHANGED
|
@@ -82,43 +82,10 @@ __export(index_exports, {
|
|
|
82
82
|
NetworkError: () => NetworkError,
|
|
83
83
|
RetryLimitError: () => RetryLimitError,
|
|
84
84
|
TimeoutError: () => TimeoutError,
|
|
85
|
-
createClient: () => createClient
|
|
86
|
-
default: () => index_default
|
|
85
|
+
createClient: () => createClient
|
|
87
86
|
});
|
|
88
87
|
module.exports = __toCommonJS(index_exports);
|
|
89
88
|
|
|
90
|
-
// src/dedupeRequestHash.ts
|
|
91
|
-
function dedupeRequestHash(params) {
|
|
92
|
-
const { method, url, body } = params;
|
|
93
|
-
let bodyString = "";
|
|
94
|
-
if (body instanceof FormData) {
|
|
95
|
-
return void 0;
|
|
96
|
-
}
|
|
97
|
-
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
|
|
98
|
-
return void 0;
|
|
99
|
-
}
|
|
100
|
-
if (typeof body === "string") {
|
|
101
|
-
bodyString = body;
|
|
102
|
-
} else if (body instanceof URLSearchParams) {
|
|
103
|
-
bodyString = body.toString();
|
|
104
|
-
} else if (body instanceof ArrayBuffer) {
|
|
105
|
-
bodyString = Buffer.from(body).toString("base64");
|
|
106
|
-
} else if (body instanceof Uint8Array) {
|
|
107
|
-
bodyString = Buffer.from(body).toString("base64");
|
|
108
|
-
} else if (body instanceof Blob) {
|
|
109
|
-
bodyString = `[blob:${body.type}:${body.size}]`;
|
|
110
|
-
} else if (body == null) {
|
|
111
|
-
bodyString = "";
|
|
112
|
-
} else {
|
|
113
|
-
try {
|
|
114
|
-
bodyString = JSON.stringify(body);
|
|
115
|
-
} catch {
|
|
116
|
-
bodyString = "[unserializable-body]";
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return `${method.toUpperCase()}|${url}|${bodyString}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
89
|
// src/retry.ts
|
|
123
90
|
var defaultDelay = (ctx) => {
|
|
124
91
|
const retryAfter = ctx.response?.headers.get("Retry-After");
|
|
@@ -130,7 +97,29 @@ var defaultDelay = (ctx) => {
|
|
|
130
97
|
}
|
|
131
98
|
return 2 ** ctx.attempt * 200 + Math.random() * 100;
|
|
132
99
|
};
|
|
133
|
-
|
|
100
|
+
function waitForRetryDelay(ms, signal) {
|
|
101
|
+
if (ms <= 0) return Promise.resolve();
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
if (!signal) {
|
|
104
|
+
setTimeout(resolve, ms);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (signal.aborted) {
|
|
108
|
+
resolve();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const onAbort = () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
resolve();
|
|
114
|
+
};
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
signal.removeEventListener("abort", onAbort);
|
|
117
|
+
resolve();
|
|
118
|
+
}, ms);
|
|
119
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function retry(fn, retries, delay, shouldRetry2 = () => true, request, signal) {
|
|
134
123
|
let lastErr;
|
|
135
124
|
let lastRes;
|
|
136
125
|
for (let i = 0; i <= retries; i++) {
|
|
@@ -146,7 +135,7 @@ async function retry(fn, retries, delay, shouldRetry2 = () => true, request) {
|
|
|
146
135
|
ctx.error = void 0;
|
|
147
136
|
if (i < retries && shouldRetry2(ctx)) {
|
|
148
137
|
const wait = typeof delay === "function" ? delay(ctx) : delay;
|
|
149
|
-
await
|
|
138
|
+
await waitForRetryDelay(wait, signal);
|
|
150
139
|
continue;
|
|
151
140
|
}
|
|
152
141
|
return lastRes;
|
|
@@ -155,7 +144,7 @@ async function retry(fn, retries, delay, shouldRetry2 = () => true, request) {
|
|
|
155
144
|
ctx.error = err;
|
|
156
145
|
if (i === retries || !shouldRetry2(ctx)) throw err;
|
|
157
146
|
const wait = typeof delay === "function" ? delay(ctx) : delay;
|
|
158
|
-
await
|
|
147
|
+
await waitForRetryDelay(wait, signal);
|
|
159
148
|
}
|
|
160
149
|
}
|
|
161
150
|
throw lastErr;
|
|
@@ -171,128 +160,49 @@ function shouldRetry(ctx) {
|
|
|
171
160
|
return response.status >= 500 || response.status === 429;
|
|
172
161
|
}
|
|
173
162
|
|
|
174
|
-
// src/circuit.ts
|
|
175
|
-
init_error();
|
|
176
|
-
var CircuitBreaker = class {
|
|
177
|
-
constructor(threshold, resetTimeout) {
|
|
178
|
-
this.threshold = threshold;
|
|
179
|
-
this.resetTimeout = resetTimeout;
|
|
180
|
-
this.failures = 0;
|
|
181
|
-
this.nextAttempt = 0;
|
|
182
|
-
this.isOpen = false;
|
|
183
|
-
}
|
|
184
|
-
// Returns true if the circuit breaker is currently open (blocking requests).
|
|
185
|
-
get open() {
|
|
186
|
-
return this.isOpen;
|
|
187
|
-
}
|
|
188
|
-
// Call this after each request to record the result and update circuit state.
|
|
189
|
-
// Returns true if the result was counted as a failure.
|
|
190
|
-
recordResult(response, error, req) {
|
|
191
|
-
if (error && !(error instanceof RetryLimitError)) {
|
|
192
|
-
this.setLastOpenRequest(req);
|
|
193
|
-
this.onFailure();
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
if (response && (response.status >= 500 || response.status === 429)) {
|
|
197
|
-
this.setLastOpenRequest(req);
|
|
198
|
-
this.onFailure();
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
if (req) this.setLastSuccessRequest(req);
|
|
202
|
-
this.onSuccess();
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
setHooks(hooks) {
|
|
206
|
-
this.hooks = hooks;
|
|
207
|
-
}
|
|
208
|
-
setLastOpenRequest(req) {
|
|
209
|
-
this.lastOpenRequest = req;
|
|
210
|
-
}
|
|
211
|
-
setLastSuccessRequest(req) {
|
|
212
|
-
this.lastSuccessRequest = req;
|
|
213
|
-
}
|
|
214
|
-
async invoke(fn) {
|
|
215
|
-
if (Date.now() < this.nextAttempt)
|
|
216
|
-
throw new CircuitOpenError("Circuit is open");
|
|
217
|
-
try {
|
|
218
|
-
const res = await fn();
|
|
219
|
-
this.onSuccess();
|
|
220
|
-
return res;
|
|
221
|
-
} catch (err) {
|
|
222
|
-
this.onFailure();
|
|
223
|
-
throw err;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
onSuccess() {
|
|
227
|
-
const wasOpen = this.isOpen;
|
|
228
|
-
this.failures = 0;
|
|
229
|
-
if (wasOpen) {
|
|
230
|
-
this.isOpen = false;
|
|
231
|
-
if (this.hooks?.onCircuitClose && this.lastSuccessRequest) {
|
|
232
|
-
this.hooks.onCircuitClose(this.lastSuccessRequest);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
this.lastSuccessRequest = void 0;
|
|
236
|
-
}
|
|
237
|
-
onFailure() {
|
|
238
|
-
this.failures++;
|
|
239
|
-
if (this.failures >= this.threshold) {
|
|
240
|
-
this.nextAttempt = Date.now() + this.resetTimeout;
|
|
241
|
-
this.isOpen = true;
|
|
242
|
-
if (this.hooks?.onCircuitOpen && this.lastOpenRequest) {
|
|
243
|
-
this.hooks.onCircuitOpen(this.lastOpenRequest);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
163
|
// src/client.ts
|
|
250
164
|
init_error();
|
|
251
165
|
function createClient(opts = {}) {
|
|
252
|
-
let dedupeSweeper;
|
|
253
|
-
function startDedupeSweeper() {
|
|
254
|
-
if (dedupeSweeper || !dedupeTTL) return;
|
|
255
|
-
dedupeSweeper = setInterval(() => {
|
|
256
|
-
const now = Date.now();
|
|
257
|
-
for (const [key, entry] of dedupeMap.entries()) {
|
|
258
|
-
if (now - entry.createdAt > dedupeTTL) {
|
|
259
|
-
dedupeMap.delete(key);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
if (dedupeMap.size === 0 && dedupeSweeper) {
|
|
263
|
-
clearInterval(dedupeSweeper);
|
|
264
|
-
dedupeSweeper = void 0;
|
|
265
|
-
}
|
|
266
|
-
}, dedupeSweepInterval);
|
|
267
|
-
}
|
|
268
|
-
function stopDedupeSweeperIfIdle() {
|
|
269
|
-
if (dedupeMap.size === 0 && dedupeSweeper) {
|
|
270
|
-
clearInterval(dedupeSweeper);
|
|
271
|
-
dedupeSweeper = void 0;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const dedupeMap = /* @__PURE__ */ new Map();
|
|
275
166
|
const {
|
|
276
167
|
timeout: clientDefaultTimeout = 5e3,
|
|
277
168
|
retries: clientDefaultRetries = 0,
|
|
278
169
|
retryDelay: clientDefaultRetryDelay = defaultDelay,
|
|
279
170
|
shouldRetry: clientDefaultShouldRetry = shouldRetry,
|
|
280
171
|
hooks: clientDefaultHooks = {},
|
|
281
|
-
circuit: clientDefaultCircuit,
|
|
282
172
|
fetchHandler,
|
|
283
|
-
|
|
284
|
-
dedupeHashFn = dedupeRequestHash,
|
|
285
|
-
dedupeTTL,
|
|
286
|
-
dedupeSweepInterval = 5e3
|
|
173
|
+
plugins: inputPlugins = []
|
|
287
174
|
} = opts;
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
175
|
+
const extensionDescriptors = /* @__PURE__ */ Object.create(null);
|
|
176
|
+
const plugins = inputPlugins.map((plugin, index) => ({ plugin, index })).sort((a, b) => {
|
|
177
|
+
const aOrder = a.plugin.order ?? 0;
|
|
178
|
+
const bOrder = b.plugin.order ?? 0;
|
|
179
|
+
if (aOrder !== bOrder) return aOrder - bOrder;
|
|
180
|
+
return a.index - b.index;
|
|
181
|
+
}).map((entry) => entry.plugin);
|
|
182
|
+
for (const plugin of plugins) {
|
|
183
|
+
plugin.setup?.({
|
|
184
|
+
defineExtension: (key, descriptor) => {
|
|
185
|
+
const propertyKey = key;
|
|
186
|
+
if (propertyKey in extensionDescriptors) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Plugin extension collision for property "${String(propertyKey)}"`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if ("get" in descriptor) {
|
|
192
|
+
extensionDescriptors[propertyKey] = {
|
|
193
|
+
get: descriptor.get,
|
|
194
|
+
enumerable: descriptor.enumerable ?? true,
|
|
195
|
+
configurable: false
|
|
196
|
+
};
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
extensionDescriptors[propertyKey] = {
|
|
200
|
+
value: descriptor.value,
|
|
201
|
+
writable: false,
|
|
202
|
+
enumerable: descriptor.enumerable ?? true,
|
|
203
|
+
configurable: false
|
|
204
|
+
};
|
|
205
|
+
}
|
|
296
206
|
});
|
|
297
207
|
}
|
|
298
208
|
const pendingRequests = [];
|
|
@@ -302,55 +212,39 @@ function createClient(opts = {}) {
|
|
|
302
212
|
}
|
|
303
213
|
}
|
|
304
214
|
const client = async (input, init = {}) => {
|
|
305
|
-
const effectiveDedupe = typeof init.dedupe !== "undefined" ? init.dedupe : dedupe;
|
|
306
|
-
const effectiveDedupeHashFn = init.dedupeHashFn || dedupeHashFn;
|
|
307
|
-
let dedupeKey;
|
|
308
215
|
let request = new Request(input, init);
|
|
309
|
-
if (effectiveDedupe) {
|
|
310
|
-
dedupeKey = effectiveDedupeHashFn({
|
|
311
|
-
method: request.method,
|
|
312
|
-
url: request.url,
|
|
313
|
-
body: init.body ?? null,
|
|
314
|
-
headers: request.headers,
|
|
315
|
-
signal: init.signal === void 0 || init.signal === null ? void 0 : init.signal,
|
|
316
|
-
requestInit: init,
|
|
317
|
-
request
|
|
318
|
-
});
|
|
319
|
-
if (dedupeKey) {
|
|
320
|
-
if (dedupeMap.has(dedupeKey)) {
|
|
321
|
-
return dedupeMap.get(dedupeKey).promise;
|
|
322
|
-
}
|
|
323
|
-
let settled = false;
|
|
324
|
-
let resolveFn;
|
|
325
|
-
let rejectFn;
|
|
326
|
-
const inFlightPromise = new Promise((resolve, reject) => {
|
|
327
|
-
resolveFn = (value) => {
|
|
328
|
-
if (!settled) {
|
|
329
|
-
settled = true;
|
|
330
|
-
resolve(value);
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
rejectFn = (reason) => {
|
|
334
|
-
if (!settled) {
|
|
335
|
-
settled = true;
|
|
336
|
-
reject(reason);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
339
|
-
});
|
|
340
|
-
dedupeMap.set(dedupeKey, {
|
|
341
|
-
promise: inFlightPromise,
|
|
342
|
-
resolve: resolveFn,
|
|
343
|
-
reject: rejectFn,
|
|
344
|
-
createdAt: Date.now()
|
|
345
|
-
});
|
|
346
|
-
if (dedupeTTL) startDedupeSweeper();
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
216
|
const effectiveHooks = { ...clientDefaultHooks, ...init.hooks || {} };
|
|
350
217
|
if (effectiveHooks.transformRequest) {
|
|
351
218
|
request = await effectiveHooks.transformRequest(request);
|
|
352
219
|
}
|
|
353
220
|
await effectiveHooks.before?.(request);
|
|
221
|
+
const effectiveRetries = init.retries ?? clientDefaultRetries;
|
|
222
|
+
const effectiveRetryDelay = typeof init.retryDelay !== "undefined" ? init.retryDelay : clientDefaultRetryDelay;
|
|
223
|
+
const effectiveShouldRetry = init.shouldRetry ?? clientDefaultShouldRetry;
|
|
224
|
+
const effectiveTimeout = init.timeout ?? clientDefaultTimeout;
|
|
225
|
+
const userSignal = init.signal;
|
|
226
|
+
const transformedSignal = request.signal;
|
|
227
|
+
const pluginContext = {
|
|
228
|
+
request,
|
|
229
|
+
init,
|
|
230
|
+
state: /* @__PURE__ */ Object.create(null),
|
|
231
|
+
metadata: {
|
|
232
|
+
startedAt: Date.now(),
|
|
233
|
+
timeoutMs: effectiveTimeout,
|
|
234
|
+
signals: {
|
|
235
|
+
user: userSignal === void 0 || userSignal === null ? void 0 : userSignal,
|
|
236
|
+
transformed: transformedSignal
|
|
237
|
+
},
|
|
238
|
+
retry: {
|
|
239
|
+
configuredRetries: effectiveRetries,
|
|
240
|
+
configuredDelay: effectiveRetryDelay,
|
|
241
|
+
attempt: 0
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
for (const plugin of plugins) {
|
|
246
|
+
await plugin.preRequest?.(pluginContext);
|
|
247
|
+
}
|
|
354
248
|
const effectiveThrowOnHttpError = typeof init.throwOnHttpError !== "undefined" ? init.throwOnHttpError : opts.throwOnHttpError ?? false;
|
|
355
249
|
function createTimeoutSignal(timeout) {
|
|
356
250
|
if (typeof AbortSignal?.timeout === "function") {
|
|
@@ -365,14 +259,12 @@ function createClient(opts = {}) {
|
|
|
365
259
|
);
|
|
366
260
|
return controller2.signal;
|
|
367
261
|
}
|
|
368
|
-
const effectiveTimeout = init.timeout ?? clientDefaultTimeout;
|
|
369
|
-
const userSignal = init.signal;
|
|
370
|
-
const transformedSignal = request.signal;
|
|
371
262
|
let timeoutSignal = void 0;
|
|
372
263
|
let combinedSignal = void 0;
|
|
373
264
|
let controller = void 0;
|
|
374
265
|
if (effectiveTimeout > 0) {
|
|
375
266
|
timeoutSignal = createTimeoutSignal(effectiveTimeout);
|
|
267
|
+
pluginContext.metadata.signals.timeout = timeoutSignal;
|
|
376
268
|
}
|
|
377
269
|
const signals = [];
|
|
378
270
|
if (userSignal) signals.push(userSignal);
|
|
@@ -392,14 +284,16 @@ function createClient(opts = {}) {
|
|
|
392
284
|
combinedSignal = AbortSignal.any(signals);
|
|
393
285
|
controller = new AbortController();
|
|
394
286
|
}
|
|
287
|
+
pluginContext.metadata.signals.combined = combinedSignal;
|
|
395
288
|
const retryWithHooks = async () => {
|
|
396
|
-
const effectiveRetries = init.retries ?? clientDefaultRetries;
|
|
397
|
-
const effectiveRetryDelay = typeof init.retryDelay !== "undefined" ? init.retryDelay : clientDefaultRetryDelay;
|
|
398
|
-
const effectiveShouldRetry = init.shouldRetry ?? clientDefaultShouldRetry;
|
|
399
289
|
let attempt = 0;
|
|
400
290
|
const shouldRetryWithHook = (ctx) => {
|
|
401
291
|
attempt = ctx.attempt;
|
|
292
|
+
pluginContext.metadata.retry.attempt = attempt;
|
|
293
|
+
pluginContext.metadata.retry.lastError = ctx.error;
|
|
294
|
+
pluginContext.metadata.retry.lastResponse = ctx.response;
|
|
402
295
|
const retrying = effectiveShouldRetry(ctx);
|
|
296
|
+
pluginContext.metadata.retry.shouldRetryResult = retrying;
|
|
403
297
|
if (retrying && attempt <= effectiveRetries) {
|
|
404
298
|
effectiveHooks.onRetry?.(
|
|
405
299
|
request,
|
|
@@ -445,12 +339,10 @@ function createClient(opts = {}) {
|
|
|
445
339
|
const handler = init.fetchHandler ?? fetchHandler ?? fetch;
|
|
446
340
|
const response = await handler(reqWithSignal);
|
|
447
341
|
lastResponse = response;
|
|
448
|
-
|
|
449
|
-
breaker.recordResult(response, void 0, request);
|
|
450
|
-
}
|
|
342
|
+
pluginContext.metadata.retry.lastResponse = response;
|
|
451
343
|
return response;
|
|
452
344
|
} catch (err) {
|
|
453
|
-
|
|
345
|
+
pluginContext.metadata.retry.lastError = err;
|
|
454
346
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
455
347
|
if (timeoutSignal?.aborted && (!userSignal || !userSignal.aborted)) {
|
|
456
348
|
effectiveHooks.onTimeout?.(request);
|
|
@@ -475,7 +367,8 @@ function createClient(opts = {}) {
|
|
|
475
367
|
effectiveRetries,
|
|
476
368
|
effectiveRetryDelay,
|
|
477
369
|
shouldRetryWithHook,
|
|
478
|
-
request
|
|
370
|
+
request,
|
|
371
|
+
combinedSignal
|
|
479
372
|
);
|
|
480
373
|
if (effectiveHooks.transformResponse) {
|
|
481
374
|
res = await effectiveHooks.transformResponse(res, request);
|
|
@@ -491,6 +384,7 @@ function createClient(opts = {}) {
|
|
|
491
384
|
}
|
|
492
385
|
return res;
|
|
493
386
|
} catch (err) {
|
|
387
|
+
pluginContext.metadata.retry.lastError = err;
|
|
494
388
|
if (lastResponse) {
|
|
495
389
|
const resp = lastResponse;
|
|
496
390
|
if (effectiveThrowOnHttpError && (resp.status >= 400 && resp.status < 500 && resp.status !== 429 || resp.status >= 500 || resp.status === 429)) {
|
|
@@ -528,47 +422,39 @@ function createClient(opts = {}) {
|
|
|
528
422
|
throw retryErr;
|
|
529
423
|
}
|
|
530
424
|
};
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
await effectiveHooks.onError?.(request, err);
|
|
538
|
-
await effectiveHooks.onComplete?.(request, void 0, err);
|
|
539
|
-
}
|
|
540
|
-
throw err;
|
|
541
|
-
}) : retryWithHooks();
|
|
542
|
-
if (effectiveDedupe && dedupeKey && dedupeMap.has(dedupeKey)) {
|
|
543
|
-
const entry = dedupeMap.get(dedupeKey);
|
|
544
|
-
if (entry) {
|
|
545
|
-
actualPromise.then(
|
|
546
|
-
(result) => entry.resolve(result),
|
|
547
|
-
(error) => entry.reject(error)
|
|
548
|
-
);
|
|
549
|
-
dedupeMap.set(dedupeKey, {
|
|
550
|
-
promise: actualPromise,
|
|
551
|
-
resolve: entry.resolve,
|
|
552
|
-
reject: entry.reject,
|
|
553
|
-
createdAt: entry.createdAt
|
|
554
|
-
});
|
|
425
|
+
const baseDispatch = async () => retryWithHooks();
|
|
426
|
+
let dispatch = baseDispatch;
|
|
427
|
+
for (let i = plugins.length - 1; i >= 0; i--) {
|
|
428
|
+
const plugin = plugins[i];
|
|
429
|
+
if (plugin.wrapDispatch) {
|
|
430
|
+
dispatch = plugin.wrapDispatch(dispatch);
|
|
555
431
|
}
|
|
556
432
|
}
|
|
433
|
+
const actualPromise = dispatch(pluginContext).then(async (response) => {
|
|
434
|
+
for (const plugin of plugins) {
|
|
435
|
+
await plugin.onSuccess?.(pluginContext, response);
|
|
436
|
+
}
|
|
437
|
+
return response;
|
|
438
|
+
}).catch(async (err) => {
|
|
439
|
+
for (const plugin of plugins) {
|
|
440
|
+
await plugin.onError?.(pluginContext, err);
|
|
441
|
+
}
|
|
442
|
+
throw err;
|
|
443
|
+
});
|
|
557
444
|
const pendingEntry = {
|
|
558
445
|
promise: actualPromise,
|
|
559
446
|
request,
|
|
560
447
|
controller
|
|
561
448
|
};
|
|
562
449
|
pendingRequests.push(pendingEntry);
|
|
563
|
-
return actualPromise.finally(() => {
|
|
450
|
+
return actualPromise.finally(async () => {
|
|
451
|
+
for (const plugin of plugins) {
|
|
452
|
+
await plugin.onFinally?.(pluginContext);
|
|
453
|
+
}
|
|
564
454
|
const index = pendingRequests.indexOf(pendingEntry);
|
|
565
455
|
if (index > -1) {
|
|
566
456
|
pendingRequests.splice(index, 1);
|
|
567
457
|
}
|
|
568
|
-
if (effectiveDedupe && dedupeKey && dedupeMap.get(dedupeKey)?.promise === actualPromise) {
|
|
569
|
-
dedupeMap.delete(dedupeKey);
|
|
570
|
-
stopDedupeSweeperIfIdle();
|
|
571
|
-
}
|
|
572
458
|
});
|
|
573
459
|
};
|
|
574
460
|
Object.defineProperty(client, "pendingRequests", {
|
|
@@ -584,18 +470,12 @@ function createClient(opts = {}) {
|
|
|
584
470
|
enumerable: false,
|
|
585
471
|
configurable: false
|
|
586
472
|
});
|
|
587
|
-
Object.
|
|
588
|
-
get() {
|
|
589
|
-
return breaker ? breaker.open : false;
|
|
590
|
-
},
|
|
591
|
-
enumerable: true
|
|
592
|
-
});
|
|
473
|
+
Object.defineProperties(client, extensionDescriptors);
|
|
593
474
|
return client;
|
|
594
475
|
}
|
|
595
476
|
|
|
596
477
|
// src/index.ts
|
|
597
478
|
init_error();
|
|
598
|
-
var index_default = createClient;
|
|
599
479
|
// Annotate the CommonJS export names for ESM import in node:
|
|
600
480
|
0 && (module.exports = {
|
|
601
481
|
AbortError,
|