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 normalization

Different transports throw different error types. Normalize them for consistent error handling across your app:

import { isAxiosError } from 'axios' import { HttpError } from '@routar/core' const errorNormalizerPlugin = definePlugin({ name: 'error-normalizer', onError: async (err) => { if (isAxiosError(err)) { throw new HttpError(err.response?.status ?? 0, err.message, err.response?.data) } 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