Plugins
Plugins are reusable units of behavior that hook into FetchEngine's request lifecycle. Each plugin registers hooks and returns a cleanup function. FetchEngine ships with four built-in plugins (retry, dedupe, cache, rate-limit) and supports custom plugins.
The FetchPlugin Interface
interface FetchPlugin<H = unknown, P = unknown, S = unknown> {
name: string;
install(engine: FetchEnginePublic<H, P, S>): () => void;
}| Property | Description |
|---|---|
name | String identifier for the plugin |
install(engine) | Receives the engine, registers hooks, returns a cleanup function |
The engine parameter exposes engine.hooks (to register hooks) and the observer API (to emit events).
Installing Plugins
At Construction Time
import { FetchEngine, retryPlugin, cachePlugin, dedupePlugin, rateLimitPlugin } from '@logosdx/fetch';
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
plugins: [
retryPlugin({ maxAttempts: 3, baseDelay: 1000 }),
cachePlugin({ ttl: 60000, staleIn: 30000 }),
dedupePlugin(true),
rateLimitPlugin({ maxCalls: 100, windowMs: 60000 }),
]
});At Runtime
const uninstall = api.use(myPlugin);
// Later, remove the plugin's hooks
uninstall();Config Shorthand
The config options retry, cachePolicy, dedupePolicy, rateLimitPolicy, and cookies are shorthand that internally create and install the corresponding plugins:
// These two are equivalent:
const api1 = new FetchEngine({
baseUrl: '...',
cachePolicy: { ttl: 60000 },
dedupePolicy: true,
cookies: true,
});
const api2 = new FetchEngine({
baseUrl: '...',
plugins: [
cachePlugin({ ttl: 60000 }),
dedupePlugin(true),
cookiePlugin(),
]
});For full cookie control (adapter init()/flush(), direct jar access), use the explicit plugins form and hold a reference to the plugin instance.
Config keys and the plugins array compose — config keys always apply, and plugins adds custom plugins alongside them. One rule: a policy can only exist once. Passing both a policy's config key and its plugin (rateLimitPolicy + rateLimitPlugin(...), cookies + cookiePlugin(), and so on), or the same policy plugin twice, throws at construction — two instances of the same policy would silently double-consume (two rate-limit tokens per request, two caches, nested retries):
// ❌ Throws: 'rate-limit' is configured twice
const api = new FetchEngine({
baseUrl: '...',
rateLimitPolicy: { maxCalls: 60 },
plugins: [rateLimitPlugin({ maxCalls: 10 })]
});
// ✅ Custom plugins compose with config keys
const api = new FetchEngine({
baseUrl: '...',
rateLimitPolicy: { maxCalls: 60 },
plugins: [authPlugin(() => getToken())]
});Two softenings of the rule:
- A
retryPlugin(...)in the array with no explicitretryconfig key replaces the auto-installed default retry plugin — customization, not a conflict. - A falsy config key (
dedupePolicy: false,cookies: false) plus the same-name plugin warns once at construction and installs the plugin — the falsy key configured nothing, so the plugin is the unambiguous owner. Only truthy keys claim a policy and throw.
Writing a Custom Plugin
A plugin registers hooks in install() and returns a cleanup function.
Simple Plugin
function authPlugin(getToken: () => string): FetchPlugin {
return {
name: 'auth',
install(engine) {
return engine.hooks.add('beforeRequest', (url, opts, ctx) => {
const token = getToken();
ctx.args(url, {
...opts,
headers: { ...opts.headers, Authorization: `Bearer ${token}` }
});
});
}
};
}
// Usage
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
plugins: [authPlugin(() => localStorage.getItem('token')!)]
});Multi-Hook Plugin
When a plugin registers multiple hooks, return a composite cleanup:
function loggingPlugin(): FetchPlugin {
return {
name: 'logging',
install(engine) {
const cleanups: (() => void)[] = [];
cleanups.push(
engine.hooks.add('beforeRequest', (url, opts) => {
console.log(`→ ${opts.method} ${url.pathname}`);
})
);
cleanups.push(
engine.hooks.add('afterRequest', (response, url, opts) => {
console.log(`← ${response.status} ${opts.method} ${url.pathname}`);
})
);
return () => {
for (const cleanup of cleanups) cleanup();
};
}
};
}Plugin with Priority
Use negative priority to run before user hooks:
function corsPlugin(origin: string): FetchPlugin {
return {
name: 'cors',
install(engine) {
return engine.hooks.add('beforeRequest', (url, opts, ctx) => {
ctx.args(url, {
...opts,
headers: { ...opts.headers, Origin: origin }
});
}, { priority: -10 });
}
};
}Plugin with Shared State (HookScope)
Use ctx.scope to pass data between beforeRequest and afterRequest:
function timingPlugin(onComplete: (ms: number, path: string) => void): FetchPlugin {
const START_TIME = Symbol('startTime');
return {
name: 'timing',
install(engine) {
const cleanups: (() => void)[] = [];
cleanups.push(
engine.hooks.add('beforeRequest', (url, opts, ctx) => {
ctx.scope.set(START_TIME, Date.now());
}, { priority: -50 })
);
cleanups.push(
engine.hooks.add('afterRequest', (response, url, opts, ctx) => {
const start = ctx.scope.get<number>(START_TIME);
if (start) {
onComplete(Date.now() - start, opts.path);
}
}, { priority: 50 })
);
return () => {
for (const cleanup of cleanups) cleanup();
};
}
};
}Execute Middleware Plugin
Wrap the core fetch() call using the execute pipe hook.
A non-2xx exchange resolves — it never reaches a catch. A breaker that only counts throws would see transport failures but treat every 500 as a success, so count both channels explicitly:
import { attempt } from '@logosdx/utils';
function circuitBreakerPlugin(threshold: number): FetchPlugin {
let failures = 0;
let circuitOpen = false;
const recordFailure = () => {
failures++;
if (failures >= threshold) {
circuitOpen = true;
setTimeout(() => { circuitOpen = false; failures = 0; }, 30000);
}
};
return {
name: 'circuit-breaker',
install(engine) {
return engine.hooks.add('execute', async (next, opts) => {
if (circuitOpen) {
throw new Error('Circuit breaker is open');
}
const [response, err] = await attempt(() => next());
if (err) {
// Transport failure — dropped connection, timeout
recordFailure();
throw err;
}
if (!response.ok) {
// Server said no — counts against the breaker, but the
// response still resolves to the caller unchanged
recordFailure();
return response;
}
failures = 0;
return response;
}, { priority: -40 });
}
};
}Built-in Plugins
FetchEngine ships four plugins. Each can be used via config shorthand or imported directly.
| Plugin | Config Shorthand | Hook Phase | Priority | Purpose |
|---|---|---|---|---|
retryPlugin | retry | execute (pipe) | -20 | Exponential backoff retry |
dedupePlugin | dedupePolicy | execute (pipe) | -30 | Deduplicate in-flight requests |
cachePlugin | cachePolicy | beforeRequest + afterRequest | -30 / -10 | Response caching with SWR |
rateLimitPlugin | rateLimitPolicy | beforeRequest | -20 | Token bucket rate limiting |
cookiePlugin | cookies | beforeRequest + afterRequest | -25 / -5 | RFC 6265 cookie jar |
See Policies for detailed configuration of each built-in plugin.
Plugin Execution Order
beforeRequest (run):
-30 cachePlugin → return cached hit
-25 cookiePlugin → inject Cookie header
-20 rateLimitPlugin → wait or reject
0 user hooks
execute (pipe):
-30 dedupePlugin → join in-flight request
-20 retryPlugin → retry with backoff
core fetch()
afterRequest (run):
-10 cachePlugin → store response
-5 cookiePlugin → capture Set-Cookie
0 user hooksCache checks run first so cached responses skip rate limiting entirely. Dedupe wraps retry so duplicate callers share the retried result.
Cookie Plugin
The cookiePlugin implements a full RFC 6265-compliant cookie jar for FetchEngine. It is primarily useful in Node.js environments where the native fetch has no cookie jar — but it also works in browsers alongside the native jar.
Basic Usage
The fastest way to enable cookies is the config shorthand:
import { FetchEngine } from '@logosdx/fetch';
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
cookies: true,
});
// FetchEngine now automatically captures Set-Cookie response headers
// and sends Cookie request headers on subsequent requests.
await api.post('/auth/login', { email, password });
const profile = await api.get('/me'); // Cookie header included
if (profile.ok) console.log(profile.data);When you need adapter lifecycle control (init(), flush()) or direct jar access, use the explicit plugin form:
import { FetchEngine, cookiePlugin } from '@logosdx/fetch';
const cookies = cookiePlugin({ adapter: myRedisAdapter });
await cookies.init();
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
plugins: [cookies],
});
// cookies.jar, cookies.flush() availablePersistence Adapter
Provide a CookieAdapter to persist cookies across process restarts, or share them across multiple FetchEngine instances (horizontal scaling):
import { cookiePlugin } from '@logosdx/fetch';
// Redis example (horizontal scaling)
const cookies = cookiePlugin({
syncOnRequest: true, // re-load from Redis before every request
adapter: {
async load() {
const raw = await redis.get('fetch:cookies');
return raw ? JSON.parse(raw) : [];
},
async save(cookies) {
await redis.set('fetch:cookies', JSON.stringify(cookies));
}
}
});
await cookies.init(); // loads from adapter before first requestadapter.save() is microtask-coalesced. Any burst of jar mutations (e.g., a response with multiple Set-Cookie headers, or a jar.load() call) fires exactly one adapter.save(jar.all()) per tick. A persistence failure is silently swallowed so it cannot break the request pipeline — if you need error visibility during graceful shutdown, call await cookies.flush() which surfaces adapter rejections to the caller.
Pre-Seeding the Jar
Restore a session without a full adapter — pass cookies directly at construction:
const cookies = cookiePlugin({
cookies: storedSession, // Cookie[] from your own storage
});Session Cleanup
Session cookies (no Expires or Max-Age) are cleared on jar.clearSession(). Call this on logout:
cookies.jar.clearSession();Graceful Shutdown
Persistence is microtask-coalesced so bursts of mutations only hit your adapter once per tick. On process exit or logout, call flush() to force a final save and surface any adapter failures to the caller:
process.on('SIGTERM', async () => {
const [, err] = await attempt(() => cookies.flush());
if (err) logger.error('cookie flush failed', err);
process.exit(0);
});Without flush(), a mutation that happens in the last tick before exit may be lost because the microtask never runs.
Excluding Domains
Prevent cookies from being sent or captured for specific hosts (e.g., third-party CDNs):
const cookies = cookiePlugin({
exclude: ['cdn.example.com', /\.cloudfront\.net$/],
});Direct Jar Access
cookies.jar.all() // all stored cookies (no access-time update)
cookies.jar.get(new URL('https://...')) // matching cookies for a URL (bumps lastAccessTime)
cookies.jar.clear() // remove all cookies
cookies.jar.clear('example.com') // remove cookies for a domain
cookies.jar.clearSession() // remove session (non-persistent) cookies
cookies.jar.delete('example.com', '/', 'session') // remove specific cookie
await cookies.flush() // force pending coalesced save (graceful shutdown)Configuration
interface CookieConfig {
cookies?: Cookie[]; // pre-seed the jar
adapter?: CookieAdapter; // persistence adapter
syncOnRequest?: boolean; // re-load adapter before every request (default: false)
exclude?: (string | RegExp)[]; // domains to skip
maxCookieSize?: number; // bytes per cookie (default: 4096)
maxCookiesPerDomain?: number; // cookies per domain (default: 50)
maxCookies?: number; // total cookies (default: 3000)
httpApi?: boolean; // false = non-HTTP client, skip httpOnly (default: true)
}RFC 6265 Compliance
The plugin implements the full RFC 6265 specification:
- Date parsing algorithm (§5.1.1) — lenient tokenizing, not
Date.parse() - Domain matching (§5.1.3) — subdomain inclusion, IP address exclusion
- Path matching (§5.1.4) — prefix rules with slash boundaries
Set-Cookieprocessing (§5.2) — all attributes,Max-AgeoverExpiresprecedence- Storage model (§5.3) — all 11 fields, duplicate handling, eviction
Cookieheader construction (§5.4) — sort by path length, then creation time
Plugin Lifecycle
Plugins are cleaned up when:
- You call the cleanup function returned by
api.use(plugin) api.destroy()is called (cleans up all plugins)
const api = new FetchEngine({
baseUrl: 'https://api.example.com',
plugins: [authPlugin(getToken), loggingPlugin()]
});
// All plugin hooks are removed
api.destroy();