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();