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 → authPluginBuilt-in plugin
logger(options?)
Logs each request with method, URL, and duration.
| Option | Type | Default | Description |
|---|---|---|---|
log | (msg: string, data?: unknown) => void | console.log | Custom 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 })).