Skip to content

Hooks ​

FetchEngine uses a 3-phase hook pipeline powered by @logosdx/hooks to intercept every request. Hooks replace the legacy modifyConfig pattern with a more powerful, composable approach.

The Pipeline ​

Every request flows through three hook phases:

beforeRequest (run)     →  execute (pipe)           →  afterRequest (run)
Modify args,               Wrap the network call        Modify response,
short-circuit              (retry, dedupe, etc.)        store in cache
with cached response
  1. beforeRequest — Linear hooks that can modify request args or return a cached response
  2. execute — Onion-style middleware that wraps fetch() (retry wraps dedupe wraps the call)
  3. afterRequest — Linear hooks that can modify or cache the response

Adding Hooks ​

Access the hook engine via api.hooks:

typescript
const api = new FetchEngine({ baseUrl: 'https://api.example.com' });

// Add a hook — returns a cleanup function
const cleanup = api.hooks.add('beforeRequest', (url, opts, ctx) => {

    ctx.args(url, {
        ...opts,
        headers: { ...opts.headers, Authorization: `Bearer ${getToken()}` }
    });
});

// Remove it later
cleanup();

hooks.add(name, callback, options?) ​

typescript
api.hooks.add(
    'beforeRequest',  // 'beforeRequest' | 'execute' | 'afterRequest'
    callback,
    {
        once: true,          // Auto-remove after first call
        times: 3,            // Auto-remove after N calls
        ignoreOnFail: true,  // Swallow errors, continue chain
        priority: 10,        // Lower numbers run first (default: 0)
    }
);

Returns a cleanup function that removes the hook.

hooks.clear() ​

Remove all registered hooks:

typescript
api.hooks.clear();

This is called automatically by api.destroy().

beforeRequest ​

Linear hooks that run sequentially before the network call. Use these for authentication, request modification, or returning cached responses.

Signature:

typescript
(url: URL, opts: InternalReqOptions, ctx: HookContext) => void | Promise<void>

Examples:

typescript
// Add auth header to every request
api.hooks.add('beforeRequest', (url, opts, ctx) => {

    ctx.args(url, {
        ...opts,
        headers: { ...opts.headers, Authorization: `Bearer ${opts.state.token}` }
    });
});

// Short-circuit with a cached response
api.hooks.add('beforeRequest', (url, opts, ctx) => {

    const cached = myCache.get(url.toString());

    if (cached) {
        return ctx.returns(cached);
    }
});

// Conditional logging
api.hooks.add('beforeRequest', (url, opts, ctx) => {

    if (opts.method === 'POST') {
        console.log('POST to', url.pathname, opts.body);
    }
});

execute ​

Onion-style middleware that wraps the core fetch() call. Each handler receives next to call the next layer. The innermost layer is the actual network request.

Signature:

typescript
(next: () => Promise<FetchResponse>, opts: InternalReqOptions, ctx: PipeContext) => Promise<FetchResponse>

Examples:

typescript
// Timing middleware
api.hooks.add('execute', async (next, opts) => {

    const start = Date.now();
    const response = await next();
    console.log(`${opts.method} ${opts.path} took ${Date.now() - start}ms`);

    return response;
});

// Circuit breaker
api.hooks.add('execute', async (next, opts, ctx) => {

    if (circuitOpen) {
        throw new Error('Circuit breaker open');
    }

    return next();
});

afterRequest ​

Linear hooks that run after every completed exchange — any status, including non-2xx (a non-2xx exchange resolves, it never throws). Use these for response transformation, caching, or logging; check response.ok when the hook should only act on success.

Signature:

typescript
(response: FetchResponse, url: URL, opts: InternalReqOptions, ctx: HookContext) => void | Promise<void>

Examples:

typescript
// Cache successful responses
api.hooks.add('afterRequest', (response, url, opts, ctx) => {

    if (opts.method === 'GET' && response.status === 200) {
        myCache.set(url.toString(), response);
    }
});

// Replace the response
api.hooks.add('afterRequest', (response, url, opts, ctx) => {

    return ctx.returns({
        ...response,
        data: transformData(response.data)
    });
});

HookContext ​

The context object provides flow control within beforeRequest and afterRequest hooks.

MethodDescription
ctx.args(...)Replace args for downstream hooks (continue chain)
return ctx.args(...)Replace args AND stop the chain
return ctx.returns(value)Inject a result and stop the chain
ctx.fail(message)Abort with an error
ctx.removeHook()Remove this callback from all future runs
ctx.scopeShared state bag across all three phases (see HookScope)

PipeContext ​

The context object for execute hooks is simpler — flow control is managed by calling or skipping next().

MethodDescription
ctx.args(...)Replace args for inner layers
ctx.scopeShared state bag across all three phases

HookScope ​

A Map-backed state bag shared across all three hook phases within a single request. Use it to pass data between beforeRequest and afterRequest without polluting opts.

typescript
const TRACE_KEY = Symbol('trace');

api.hooks.add('beforeRequest', (url, opts, ctx) => {

    ctx.scope.set(TRACE_KEY, { startedAt: Date.now() });
});

api.hooks.add('afterRequest', (response, url, opts, ctx) => {

    const trace = ctx.scope.get(TRACE_KEY);
    console.log(`Request took ${Date.now() - trace.startedAt}ms`);
});

Convention: Use Symbol keys for private plugin state, string keys for cross-plugin contracts.

Per-Request Hooks ​

Add hooks to a single request via CallConfig.hooks. These run last (after all engine-level hooks) and are not registered permanently.

typescript
await api.get('/users', {
    hooks: {
        beforeRequest: (url, opts, ctx) => {
            ctx.args(url, {
                ...opts,
                headers: { ...opts.headers, 'X-Custom': 'value' }
            });
        },
        afterRequest: (response, url, opts) => {
            console.log('Response status:', response.status);
        }
    }
});

Per-request hooks support beforeRequest and afterRequest only.

Priority ​

Hooks run in priority order (lower numbers first). Built-in plugins use negative priorities so user hooks (default 0) always run after them:

beforeRequest:
  -30  Cache plugin (return hit before consuming rate-limit tokens)
  -20  Rate limit plugin (gate on cache miss)
    0  Your hooks (default)
    ∞  Per-request hooks (always last)

execute:
  -30  Dedupe plugin (join in-flight requests)
  -20  Retry plugin (exponential backoff)
    0  Your hooks (default)

afterRequest:
  -10  Cache plugin (store fresh response)
    0  Your hooks (default)

Set priority explicitly when ordering matters:

typescript
// Run before default hooks
api.hooks.add('beforeRequest', earlyHandler, { priority: -5 });

// Run after default hooks
api.hooks.add('beforeRequest', lateHandler, { priority: 10 });

Common Patterns ​

Authentication ​

typescript
api.hooks.add('beforeRequest', (url, opts, ctx) => {

    const token = opts.state.authToken;

    if (token) {
        ctx.args(url, {
            ...opts,
            headers: { ...opts.headers, Authorization: `Bearer ${token}` }
        });
    }
});

Request/Response Logging ​

typescript
api.hooks.add('beforeRequest', (url, opts, ctx) => {

    console.log(`→ ${opts.method} ${url.pathname}`);
});

api.hooks.add('afterRequest', (response, url, opts) => {

    console.log(`← ${response.status} ${opts.method} ${url.pathname}`);
});

Conditional Header Injection ​

typescript
api.hooks.add('beforeRequest', (url, opts, ctx) => {

    if (/^\/admin/.test(url.pathname)) {
        ctx.args(url, {
            ...opts,
            headers: { ...opts.headers, 'X-Admin-Token': getAdminToken() }
        });
    }
});