Skip to Content

Plugins

Plugins are named objects with optional lifecycle hooks — onRequest, onResponse, onError. Bundle related concerns into a single plugin: for example, injecting an auth header and handling 401 token refresh belong together and can be expressed as one unit.

Plugin interface

interface ExecutorPlugin { name?: string onRequest?: (opts: ExecuteOptions) => ExecuteOptions | Promise<ExecuteOptions> onResponse?: (response: unknown, opts: ExecuteOptions) => unknown | Promise<unknown> onError?: (error: unknown, opts: ExecuteOptions) => never | Promise<never> }

All hooks are optional — implement only what you need.

onError must always throw. The return type is never, meaning you must re-throw the original error or throw a different one. You cannot swallow an error by returning normally.

name is optional but useful for debugging. When you log the active plugins on an executor, named plugins are identifiable. Future versions may support eject by name.

Execution order

Plugins run in declaration order — the first plugin in the array is the outermost wrapper:

createExecutor(transport, { plugins: [authPlugin, logger()] }) // onRequest: authPlugin → logger → transport // onResponse: logger → authPlugin // onError: logger → authPlugin

Built-in plugin

logger(options?)

Logs each request with method, URL, and duration.

OptionTypeDefaultDescription
log(msg: string, data?: unknown) => voidconsole.logCustom log function
logger() logger({ log: (msg, data) => myLogger.debug(msg, data) })

Custom plugins

Use definePlugin for full type inference on all hooks:

import { definePlugin } from '@routar/core' const correlationPlugin = definePlugin({ name: 'correlation-id', onRequest: (opts) => ({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() }, }), })

Real-world examples

Auth with token refresh

Bundle header injection and 401 recovery in one plugin. This is the primary motivation for the plugin design — these two concerns belong together:

const authPlugin = definePlugin({ name: 'auth', onRequest: async (opts) => ({ ...opts, headers: { ...opts.headers, Authorization: `Bearer ${await getToken()}` }, }), onError: async (err) => { if (err instanceof HttpError && err.status === 401) { await refreshToken() } throw err }, })

Error mapping

HTTP failures are already normalized to HttpError by every executor (fetch, Axios, ky), so onError sees the same error type regardless of transport — you do not need a plugin just to normalize transport errors. Use onError to map normalized errors to your own domain errors, or to trigger side effects (the original transport error is on err.cause):

import { HttpError } from '@routar/core' class SessionExpiredError extends Error {} const errorMapperPlugin = definePlugin({ name: 'error-mapper', onError: async (err) => { if (err instanceof HttpError && err.status === 401) { throw new SessionExpiredError('Please sign in again') } throw err }, })

Response transformation

Transform responses globally — useful for unwrapping API envelopes:

// If your API wraps responses as { data: ..., meta: ... } const unwrapPlugin = definePlugin({ onResponse: (res) => (res as { data: unknown }).data ?? res, })

retry and timeout

retry and timeout are available as options on createFetchExecutor:

import { createFetchExecutor } from '@routar/core' const executor = createFetchExecutor('https://api.example.com', { plugins: [authPlugin, logger()], retry: 3, timeout: 5_000, })

retry accepts a number or an object with shouldRetry:

retry: { count: 3, shouldRetry: (err) => !(err instanceof HttpError && err.status < 500) }

When the timeout fires, the rejection is a TimeoutError (distinguishable from a user-initiated AbortSignal):

import { TimeoutError } from '@routar/core' try { await api.getList() } catch (err) { if (err instanceof TimeoutError) console.log(`Timed out after ${err.ms}ms`) }

For axios and ky, configure timeout and retry on the underlying instance (axios.create({ timeout }), ky.create({ timeout, retry })).

Last updated on