From 6b761f5fc38efdb34291b7429ccd1bff2a8ffbea Mon Sep 17 00:00:00 2001 From: gkoos Date: Sat, 14 Mar 2026 03:12:53 +0000 Subject: [PATCH 1/2] Plugin architecture implemented Circuit breaker/Deduplication refactored into plugins Tests/docs improved --- README.md | 46 +- docs/advanced.md | 23 +- docs/api.md | 342 +++++-------- docs/compatibility.md | 8 +- docs/deduplication.md | 102 ++-- docs/examples.md | 53 +- docs/hooks.md | 24 +- docs/index.md | 19 +- docs/migration.md | 452 ++++-------------- docs/plugins.md | 148 ++++++ package.json | 10 + src/circuit.ts | 94 ---- src/client.ts | 294 +++++------- src/hooks.ts | 2 - src/index.ts | 10 +- src/plugins.ts | 83 ++++ src/plugins/circuit.ts | 101 ++++ src/plugins/dedupe.ts | 141 ++++++ src/timeout.ts | 12 - src/types.ts | 26 +- test/{ => core}/client.error.test.ts | 21 +- test/{ => core}/client.fetchHandler.test.ts | 2 +- test/{ => core}/client.hooks.test.ts | 123 ++++- test/{ => core}/client.override.test.ts | 4 +- test/{ => core}/client.pending.test.ts | 2 +- test/{ => core}/client.test.ts | 62 ++- .../client.circuitbreaker.test.ts | 103 ++-- test/integration/plugins.integration.test.ts | 123 +++++ test/plugins/circuit.plugin.test.ts | 120 +++++ test/plugins/dedupe.plugin.test.ts | 208 ++++++++ test/{ => plugins}/dedupeRequestHash.test.ts | 5 +- test/plugins/plugin.pipeline.test.ts | 244 ++++++++++ test/timeout.test.ts | 38 -- tsup.config.ts | 2 +- 34 files changed, 1884 insertions(+), 1163 deletions(-) create mode 100644 docs/plugins.md delete mode 100644 src/circuit.ts create mode 100644 src/plugins.ts create mode 100644 src/plugins/circuit.ts create mode 100644 src/plugins/dedupe.ts delete mode 100644 src/timeout.ts rename test/{ => core}/client.error.test.ts (95%) rename test/{ => core}/client.fetchHandler.test.ts (99%) rename test/{ => core}/client.hooks.test.ts (74%) rename test/{ => core}/client.override.test.ts (96%) rename test/{ => core}/client.pending.test.ts (99%) rename test/{ => core}/client.test.ts (93%) rename test/{ => integration}/client.circuitbreaker.test.ts (65%) create mode 100644 test/integration/plugins.integration.test.ts create mode 100644 test/plugins/circuit.plugin.test.ts create mode 100644 test/plugins/dedupe.plugin.test.ts rename test/{ => plugins}/dedupeRequestHash.test.ts (97%) create mode 100644 test/plugins/plugin.pipeline.test.ts delete mode 100644 test/timeout.test.ts diff --git a/README.md b/README.md index d851b26..4cffbae 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,21 @@ 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. +ffetch uses a plugin architecture for optional features, so you only include what you need. + **Key Features:** - **Timeouts** – per-request or global - **Retries** – exponential backoff + jitter -- **Circuit breaker** – automatic failure protection -- **Deduplication** – automatic deduping of in-flight identical requests +- **Plugin architecture** – extensible lifecycle-based plugins for optional behavior - **Hooks** – logging, auth, metrics, request/response transformation - **Pending requests** – real-time monitoring of active requests - **Per-request overrides** – customize behavior on a per-request basis - **Universal** – Node.js, Browser, Cloudflare Workers, React Native - **Zero runtime deps** – ships as dual ESM/CJS - **Configurable error handling** – custom error types and `throwOnHttpError` flag to throw on HTTP errors +- **Circuit breaker plugin (optional, prebuilt)** – automatic failure protection +- **Deduplication plugin (optional, prebuilt)** – automatic deduping of in-flight identical requests ## Quick Start @@ -39,13 +42,14 @@ npm install @fetchkit/ffetch ### Basic Usage ```typescript -import createClient from '@fetchkit/ffetch' +import { createClient } from '@fetchkit/ffetch' +import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe' -// Create a client with timeout, retries, and deduplication +// Create a client with timeout, retries, and deduplication plugin const api = createClient({ timeout: 5000, retries: 3, - dedupe: true, // Enable deduplication globally + plugins: [dedupePlugin()], retryDelay: ({ attempt }) => 2 ** attempt * 100 + Math.random() * 100, }) @@ -64,7 +68,7 @@ const [r1, r2] = await Promise.all([p1, p2]) ```typescript // Example: SvelteKit, Next.js, Nuxt, or node-fetch -import createClient from '@fetchkit/ffetch' +import { createClient } from '@fetchkit/ffetch' // Pass your framework's fetch implementation const api = createClient({ @@ -84,19 +88,31 @@ const response = await api('/api/data') ```typescript // Production-ready client with error handling and monitoring +import { createClient } from '@fetchkit/ffetch' +import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe' +import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit' + const client = createClient({ timeout: 10000, retries: 2, - dedupe: true, - dedupeHashFn: (params) => `${params.method}|${params.url}|${params.body}`, - circuit: { threshold: 5, reset: 30000 }, fetchHandler: fetch, // Use custom fetch if needed + plugins: [ + dedupePlugin({ + hashFn: (params) => `${params.method}|${params.url}|${params.body}`, + ttl: 30_000, + sweepInterval: 5_000, + }), + circuitPlugin({ + threshold: 5, + reset: 30_000, + onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url), + onCircuitClose: (req) => console.info('Circuit closed after:', req.url), + }), + ], hooks: { before: async (req) => console.log('→', req.url), after: async (req, res) => console.log('←', res.status), onError: async (req, err) => console.error('Error:', err.message), - onCircuitOpen: (req) => console.warn('Circuit opened due to:', req.url), - onCircuitClose: (req) => console.info('Circuit closed after:', req.url), }, }) @@ -130,6 +146,7 @@ Native `fetch`'s controversial behavior of not throwing errors for HTTP error st | --------------------------------------------- | ------------------------------------------------------------------------- | | **[Complete Documentation](./docs/index.md)** | **Start here** - Documentation index and overview | | **[API Reference](./docs/api.md)** | Complete API documentation and configuration options | +| **[Plugin Architecture](./docs/plugins.md)** | Plugin lifecycle, custom plugin authoring, and integration patterns | | **[Deduplication](./docs/deduplication.md)** | How deduplication works, hash config, optional TTL cleanup, limitations | | **[Error Handling](./docs/errorhandling.md)** | Strategies for managing errors, including `throwOnHttpError` | | **[Advanced Features](./docs/advanced.md)** | Per-request overrides, pending requests, circuit breakers, custom errors | @@ -161,7 +178,7 @@ npm install abort-controller-x ```html ``` @@ -118,8 +129,8 @@ self.addEventListener('fetch', async (event) => { #### Web Workers ```javascript -// Works in web workers -importScripts('https://unpkg.com/@fetchkit/ffetch/dist/index.min.js') +// Use a module worker and import the ESM build +import { createClient } from 'https://unpkg.com/@fetchkit/ffetch/dist/index.min.js' const client = createClient() self.postMessage(await client('/api/data').then((r) => r.json())) @@ -202,10 +213,6 @@ function checkCompatibility() { throw new Error('AbortSignal not supported. Please add a polyfill.') } - if (typeof AbortSignal.timeout !== 'function') { - throw new Error('AbortSignal.timeout not supported. Please add a polyfill.') - } - if (typeof AbortSignal.any !== 'function') { throw new Error( 'AbortSignal.any is required for combining multiple signals. Please install a polyfill.' @@ -234,22 +241,16 @@ const client = createClient({ ```html ``` -### UMD (Legacy) +### UMD -```html - - -``` +`ffetch` does not currently publish a UMD build. Use the ESM build shown above (or import from the package in a bundler/runtime environment). ## Framework Integration @@ -355,7 +356,7 @@ onUnmounted(() => { ### SSR Frameworks: SvelteKit, Next.js, Nuxt -For SvelteKit, Next.js, and Nuxt, you must pass the exact fetch instance provided by the framework in your handler or context. This is not the global fetch, and the parameter name may vary (often `fetch`, but check your framework docs). +For SvelteKit, Next.js, and Nuxt, it is recommended to pass the fetch instance provided by the framework in your handler or context for SSR-safe behavior. The parameter name may vary (often `fetch`, but check your framework docs). **SvelteKit example:** @@ -392,7 +393,7 @@ export default async function handler(request) { } ``` -> Always use the fetch instance provided by the framework in your handler/context, not the global fetch. The parameter name may vary, but it is always context-specific. +> Recommended: use the fetch instance provided by the framework in handler/context code. ffetch also supports any fetch-compatible implementation and falls back to global fetch when no `fetchHandler` is provided. ## Troubleshooting @@ -401,7 +402,9 @@ export default async function handler(request) { #### "AbortSignal.timeout is not a function" ``` -Solution: Add a polyfill for AbortSignal.timeout +ffetch has an internal fallback, so this is usually non-fatal. + +Optional: add a polyfill for AbortSignal.timeout npm install abortcontroller-polyfill ``` diff --git a/docs/examples.md b/docs/examples.md index 6540045..f2b84f0 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -24,7 +24,7 @@ if (client.circuitOpen) { } ``` -// If the client is not configured with a circuit breaker, client.circuitOpen will always be false. +// `client.circuitOpen` is available when `circuitPlugin(...)` is installed on the client. ### Simple HTTP Client diff --git a/docs/hooks.md b/docs/hooks.md index edca6cb..9d1efe7 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -4,15 +4,29 @@ Hooks allow you to observe, log, or modify the request/response lifecycle. All h ## Lifecycle Hooks +In this document, **core hooks** means the callbacks under `createClient({ hooks: ... })`: + +- `before` +- `after` +- `onError` +- `onRetry` +- `onTimeout` +- `onAbort` +- `onComplete` +- `transformRequest` +- `transformResponse` + +These are separate from plugin lifecycle callbacks (`setup`, `preRequest`, `wrapDispatch`, `onSuccess`, `onError`, `onFinally`). + ### Available Hooks - `before(req)`: Called before each request is sent - `after(req, res)`: Called after a successful response -- `onError(req, err)`: Called when a request fails with any error +- `onError(req, err)`: Called when core request execution fails (before plugin post-processing) - `onRetry(req, attempt, err, res)`: Called before each retry attempt - `onTimeout(req)`: Called when a request times out - `onAbort(req)`: Called when a request is aborted by the user -- `onComplete(req, res, err)`: Called after every request, whether it succeeded or failed +- `onComplete(req, res, err)`: Called when core request execution completes (last among core hooks) ### Basic Hooks Example @@ -256,10 +270,11 @@ const client = createClient({ retries: 3, hooks: { onRetry: async (req, attempt, err, res) => { - console.log(`Retry ${attempt - 1}/3 for ${req.url}`) + // `attempt` is zero-based in onRetry (0 = first retry) + console.log(`Retry ${attempt + 1}/3 for ${req.url}`) // Add exponential backoff delay - const delay = Math.min(1000 * Math.pow(2, attempt - 2), 10000) + const delay = Math.min(1000 * Math.pow(2, attempt), 10000) console.log(`Waiting ${delay}ms before retry...`) await new Promise((resolve) => setTimeout(resolve, delay)) @@ -276,7 +291,7 @@ const client = createClient({ ### onCircuitOpen -Called when the circuit transitions to open after consecutive failures. Receives the request that triggered the open event. +Called when the circuit opens after consecutive failures, and also when a request is blocked while the circuit is already open. Receives the request associated with that event. Signature: `(req: Request) => void | Promise` @@ -313,17 +328,20 @@ When a request is made, hooks execute in this order: 3. **Request is sent** 4. `transformResponse` - Modify the incoming response (if successful) 5. `after` - Called after successful response -6. `onComplete` - Always called last +6. `onComplete` - Last among core hooks If an error occurs or retry is needed: -1. `onError` - Called on any error +1. `onError` - Called on core execution errors 2. `onRetry` - Called before retry attempts 3. `onTimeout` - Called on timeout errors 4. `onAbort` - Called on abort errors -5. Plugin `onCircuitOpen` callback - Called when circuit breaker transitions to open +5. Plugin `onCircuitOpen` callback - Called when circuit opens, and on blocked requests while already open 6. Plugin `onCircuitClose` callback - Called when circuit breaker transitions to closed -7. `onComplete` - Always called last +7. `onComplete` - Last among core hooks + +After core hooks run, plugin lifecycle callbacks may still run (for example plugin `onSuccess`, `onError`, and `onFinally`). +If a plugin throws after core completion, core `onError` is not re-fired and core `onComplete` may already have run with success arguments. ## Per-Request Hooks diff --git a/docs/migration.md b/docs/migration.md index fa98d3d..8cd9c5c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -127,11 +127,7 @@ const plugins = [circuitPlugin({ threshold: 5, reset: 30_000 })] as const const client = createClient({ plugins }) ``` -## 6) Runtime Guard for Legacy Options - -v5 throws a clear runtime error if removed options are still present in client options or per-request init. - -## 7) Quick Upgrade Checklist +## 6) Quick Upgrade Checklist 1. Replace default root import with named root import. 2. Add plugin imports from: