Skip to content

Commit 5502cd5

Browse files
authored
Plugin architecture implemented
2 parents fc025eb + 1428df6 commit 5502cd5

34 files changed

Lines changed: 1958 additions & 1219 deletions

README.md

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,21 @@
1515

1616
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.
1717

18+
ffetch uses a plugin architecture for optional features, so you only include what you need.
19+
1820
**Key Features:**
1921

2022
- **Timeouts** – per-request or global
2123
- **Retries** – exponential backoff + jitter
22-
- **Circuit breaker** – automatic failure protection
23-
- **Deduplication** – automatic deduping of in-flight identical requests
24+
- **Plugin architecture** – extensible lifecycle-based plugins for optional behavior
2425
- **Hooks** – logging, auth, metrics, request/response transformation
2526
- **Pending requests** – real-time monitoring of active requests
2627
- **Per-request overrides** – customize behavior on a per-request basis
2728
- **Universal** – Node.js, Browser, Cloudflare Workers, React Native
2829
- **Zero runtime deps** – ships as dual ESM/CJS
2930
- **Configurable error handling** – custom error types and `throwOnHttpError` flag to throw on HTTP errors
31+
- **Circuit breaker plugin (optional, prebuilt)** – automatic failure protection
32+
- **Deduplication plugin (optional, prebuilt)** – automatic deduping of in-flight identical requests
3033

3134
## Quick Start
3235

@@ -39,13 +42,14 @@ npm install @fetchkit/ffetch
3942
### Basic Usage
4043

4144
```typescript
42-
import createClient from '@fetchkit/ffetch'
45+
import { createClient } from '@fetchkit/ffetch'
46+
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
4347

44-
// Create a client with timeout, retries, and deduplication
48+
// Create a client with timeout, retries, and deduplication plugin
4549
const api = createClient({
4650
timeout: 5000,
4751
retries: 3,
48-
dedupe: true, // Enable deduplication globally
52+
plugins: [dedupePlugin()],
4953
retryDelay: ({ attempt }) => 2 ** attempt * 100 + Math.random() * 100,
5054
})
5155

@@ -64,7 +68,7 @@ const [r1, r2] = await Promise.all([p1, p2])
6468

6569
```typescript
6670
// Example: SvelteKit, Next.js, Nuxt, or node-fetch
67-
import createClient from '@fetchkit/ffetch'
71+
import { createClient } from '@fetchkit/ffetch'
6872

6973
// Pass your framework's fetch implementation
7074
const api = createClient({
@@ -84,19 +88,31 @@ const response = await api('/api/data')
8488

8589
```typescript
8690
// Production-ready client with error handling and monitoring
91+
import { createClient } from '@fetchkit/ffetch'
92+
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe'
93+
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
94+
8795
const client = createClient({
8896
timeout: 10000,
8997
retries: 2,
90-
dedupe: true,
91-
dedupeHashFn: (params) => `${params.method}|${params.url}|${params.body}`,
92-
circuit: { threshold: 5, reset: 30000 },
9398
fetchHandler: fetch, // Use custom fetch if needed
99+
plugins: [
100+
dedupePlugin({
101+
hashFn: (params) => `${params.method}|${params.url}|${params.body}`,
102+
ttl: 30_000,
103+
sweepInterval: 5_000,
104+
}),
105+
circuitPlugin({
106+
threshold: 5,
107+
reset: 30_000,
108+
onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url),
109+
onCircuitClose: (req) => console.info('Circuit closed after:', req.url),
110+
}),
111+
],
94112
hooks: {
95113
before: async (req) => console.log('', req.url),
96114
after: async (req, res) => console.log('', res.status),
97115
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),
100116
},
101117
})
102118

@@ -130,6 +146,7 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
130146
| --------------------------------------------- | ------------------------------------------------------------------------- |
131147
| **[Complete Documentation](./docs/index.md)** | **Start here** - Documentation index and overview |
132148
| **[API Reference](./docs/api.md)** | Complete API documentation and configuration options |
149+
| **[Plugin Architecture](./docs/plugins.md)** | Plugin lifecycle, custom plugin authoring, and integration patterns |
133150
| **[Deduplication](./docs/deduplication.md)** | How deduplication works, hash config, optional TTL cleanup, limitations |
134151
| **[Error Handling](./docs/errorhandling.md)** | Strategies for managing errors, including `throwOnHttpError` |
135152
| **[Advanced Features](./docs/advanced.md)** | Per-request overrides, pending requests, circuit breakers, custom errors |
@@ -139,12 +156,12 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st
139156

140157
## Environment Requirements
141158

142-
`ffetch` requires modern AbortSignal APIs:
159+
`ffetch` works best with native `AbortSignal.any` support:
143160

144-
- **Node.js 20.6+** (for AbortSignal.any)
145-
- **Modern browsers** (Chrome 117+, Firefox 117+, Safari 17+, Edge 117+)
161+
- **Node.js 20.6+** (native `AbortSignal.any`)
162+
- **Modern browsers with `AbortSignal.any`** (for example: Chrome 117+, Firefox 117+, Safari 17+, Edge 117+)
146163

147-
If your environment does not support `AbortSignal.any` (Node.js < 20.6, older browsers), you **must install a polyfill** before using ffetch. See the [compatibility guide](./docs/compatibility.md) for instructions.
164+
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.
148165

149166
**Custom fetch support:**
150167
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 +178,7 @@ npm install abort-controller-x
161178

162179
```html
163180
<script type="module">
164-
import createClient from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js'
181+
import { createClient } from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js'
165182
166183
const api = createClient({ timeout: 5000 })
167184
const data = await api('/api/data').then((r) => r.json())
@@ -170,9 +187,9 @@ npm install abort-controller-x
170187

171188
## Deduplication Limitations
172189

173-
- Deduplication is **off** by default. Enable it via the `dedupe` option.
190+
- Deduplication is **off** by default. Enable it via `plugins: [dedupePlugin()]`.
174191
- The default hash function is `dedupeRequestHash`, which handles common body types and skips deduplication for streams and FormData.
175-
- Optional stale-entry cleanup: `dedupeTTL` enables map-entry eviction, and `dedupeSweepInterval` controls how often eviction runs. TTL eviction only removes dedupe keys; it does not reject already in-flight promises.
192+
- 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.
176193
- **Stream bodies** (`ReadableStream`, `FormData`): Deduplication is skipped for requests with these body types, as they cannot be reliably hashed or replayed.
177194
- **Non-idempotent requests**: Use deduplication with caution for non-idempotent methods (e.g., POST), as it may suppress multiple intended requests.
178195
- **Custom hash function**: Ensure your hash function uniquely identifies requests to avoid accidental deduplication.
@@ -185,6 +202,7 @@ See [deduplication.md](./docs/deduplication.md) for full details.
185202
| -------------------- | ------------------------- | -------------------- | -------------------------------------------------------------------------------------- |
186203
| Timeouts | ❌ Manual AbortController | ✅ Built-in | ✅ Built-in with fallbacks |
187204
| Retries | ❌ Manual implementation | ❌ Manual or plugins | ✅ Smart exponential backoff |
205+
| Plugin Architecture | ❌ Not available | ⚠️ Interceptors only | ✅ First-class plugin pipeline (optional built-in + custom plugins) |
188206
| Circuit Breaker | ❌ Not available | ❌ Manual or plugins | ✅ Automatic failure protection |
189207
| Deduplication | ❌ Not available | ❌ Not available | ✅ Automatic deduplication of in-flight identical requests |
190208
| Request Monitoring | ❌ Manual tracking | ❌ Manual tracking | ✅ Built-in pending requests |

docs/advanced.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ You can provide a function for `retryDelay` that receives a context object:
126126
```typescript
127127
const client = createClient({
128128
retryDelay: ({ attempt, request, response, error }) => {
129-
// attempt: number (starts at 2 for first retry)
129+
// attempt: number (starts at 1 for first retry decision)
130130
// request: Request
131131
// response: Response | undefined
132132
// error: unknown
@@ -198,23 +198,29 @@ This is useful for:
198198
- Implementing custom fallback or degraded mode logic
199199
- Integrating with dashboards or metrics
200200

201-
> **Note:** If the client is not configured with a circuit breaker (`circuit` option omitted), `client.circuitOpen` will always be `false` and the property is inert.
201+
> **Note:** `client.circuitOpen` is provided by `circuitPlugin`. If that plugin is not installed, this extension is not available on the client.
202202
203203
### How it Works
204204

205205
- When the number of consecutive failures reaches the `threshold`, the circuit "opens" and all further requests fail fast with a `CircuitOpenError`
206206
- After the `reset` period (in milliseconds), the circuit "closes" and requests are allowed again
207207
- If a request succeeds, the failure count resets
208+
- If `onCircuitOpen` is configured, it runs both when the circuit opens and when requests are blocked while it is already open
208209

209210
### Configuration
210211

211212
```typescript
213+
import { createClient } from '@fetchkit/ffetch'
214+
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
215+
212216
const client = createClient({
213217
retries: 0, // let circuit breaker handle failures
214-
circuit: {
215-
threshold: 5, // Open after 5 consecutive failures
216-
reset: 30_000, // Close after 30 seconds
217-
},
218+
plugins: [
219+
circuitPlugin({
220+
threshold: 5, // Open after 5 consecutive failures
221+
reset: 30_000, // Close after 30 seconds
222+
}),
223+
],
218224
})
219225
```
220226

@@ -227,12 +233,15 @@ const client = createClient({
227233

228234
```typescript
229235
// Different thresholds for different endpoints
236+
import { createClient } from '@fetchkit/ffetch'
237+
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit'
238+
230239
const apiClient = createClient({
231-
circuit: { threshold: 10, reset: 60_000 }, // More tolerant for API
240+
plugins: [circuitPlugin({ threshold: 10, reset: 60_000 })], // More tolerant for API
232241
})
233242

234243
const healthClient = createClient({
235-
circuit: { threshold: 3, reset: 10_000 }, // Less tolerant for health checks
244+
plugins: [circuitPlugin({ threshold: 3, reset: 10_000 })], // Less tolerant for health checks
236245
})
237246
```
238247

@@ -256,7 +265,8 @@ const healthClient = createClient({
256265
### Error Handling Example
257266

258267
```typescript
259-
import createClient, {
268+
import {
269+
createClient,
260270
TimeoutError,
261271
AbortError,
262272
CircuitOpenError,

0 commit comments

Comments
 (0)