Skip to content

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

typescript
interface FetchPlugin<H = unknown, P = unknown, S = unknown> {

    name: string;
    install(engine: FetchEnginePublic<H, P, S>): () => void;
}
PropertyDescription
nameString 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

typescript
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

typescript
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:

typescript
// 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):

typescript
// ❌ 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 explicit retry config 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

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

PluginConfig ShorthandHook PhasePriorityPurpose
retryPluginretryexecute (pipe)-20Exponential backoff retry
dedupePlugindedupePolicyexecute (pipe)-30Deduplicate in-flight requests
cachePlugincachePolicybeforeRequest + afterRequest-30 / -10Response caching with SWR
rateLimitPluginrateLimitPolicybeforeRequest-20Token bucket rate limiting
cookiePlugincookiesbeforeRequest + afterRequest-25 / -5RFC 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 hooks

Cache checks run first so cached responses skip rate limiting entirely. Dedupe wraps retry so duplicate callers share the retried result.

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:

typescript
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:

typescript
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() available

Persistence Adapter

Provide a CookieAdapter to persist cookies across process restarts, or share them across multiple FetchEngine instances (horizontal scaling):

typescript
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 request

adapter.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:

typescript
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:

typescript
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:

typescript
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):

typescript
const cookies = cookiePlugin({
    exclude: ['cdn.example.com', /\.cloudfront\.net$/],
});

Direct Jar Access

typescript
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

typescript
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-Cookie processing (§5.2) — all attributes, Max-Age over Expires precedence
  • Storage model (§5.3) — all 11 fields, duplicate handling, eviction
  • Cookie header 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)
typescript
const api = new FetchEngine({
    baseUrl: 'https://api.example.com',
    plugins: [authPlugin(getToken), loggingPlugin()]
});

// All plugin hooks are removed
api.destroy();