Beyond the defaults
Plugins
Extend the Routecraft runtime: observe it, add hooks to the chain around every route, add route methods, and replace what the framework ships.
What is a plugin?
A plugin is a plain descriptor that the runtime installs, orders, binds, starts and stops. Every feature Routecraft ships beyond the route grammar is a plugin: direct() endpoints, retries, caching, authentication, deferral, HTTP, ops. A plugin you write has the same shape and the same reach as those.
A plugin can:
- observe and emit events
- offer a capability to other plugins and adapters through a port, or require one
- add hooks to the chain that runs around every route
- add methods to the route builder
- make a typed view of the exchange readable as
ex.<namespace>(a facet) - register routes of its own
Plugins vs capabilities: a capability defines what your system does. A plugin extends how the runtime behaves. Logging, metrics, tracing, tenancy, auth and connection pooling are plugin concerns, not capability concerns.
This page explains each part with a worked example. Every field, member and fault code is listed in the Plugins reference.
A first plugin
definePlugin() declares a plugin. It returns the descriptor unchanged and keeps its literal types, so a project built from it is typed by exactly what it declares.
import { definePlugin } from '@routecraft/routecraft'
export const audit = definePlugin({
id: 'acme.audit',
bind(c) {
c.observe('route:exchange:failed', ({ details }) => {
c.logger.warn({ route: details.routeId }, 'exchange failed')
})
},
})
The id is dotted and unique per application. The plugin's namespace is the last segment of the id (audit) unless it declares namespace; its facet, route options and events live under that name.
A plugin never receives the CraftContext. bind, start and stop get a PluginContext, which reaches the application through ports, events, its own routes and a small set of execution verbs. That is the same reach a first-party plugin has.
Installing plugins with defineProject
A project declares its plugins and configuration once, in craft.config.ts, with defineProject(). The CLI reads the default export.
// craft.config.ts
import { defineProject, definePlugin } from '@routecraft/routecraft'
const audit = definePlugin({ id: 'acme.audit' })
export default defineProject({
plugins: [audit],
shutdown: { timeout: '20s' },
})
defineProject({ plugins, ...config }) returns { craft, config, plugins }. Its craft() is typed by exactly the installed plugins plus the default plugins: a step or facet of a plugin the project does not install is a compile error. Routes that use a plugin's steps or facet are built with the project's craft():
// capabilities/orders/route.ts
import project from '../../craft.config'
export default project
.craft()
.id('orders')
.from<Order>(direct())
.withTax(0.21)
.to(log())
Here withTax is a step the project's pricing plugin adds; Steps shows how it is declared.
The root craft() export is typed by the plugins @routecraft/routecraft ships. It is the right builder for a route that uses only those, and for a single file run with craft run, where export const craftConfig = defineConfig({ ... }) beside the route still works.
Every key the config accepts is documented in the Configuration reference. A config key such as deferral: {} or http: {...} installs the matching plugin with its options, so plugins lists only what has no key.
Default plugins
Every application installs these ahead of its own plugins:
An application plugin with the same id takes a default's place. A plugin that declares replaces for a default's port is selected over it, which is how you replace a position.
Lifecycle
A plugin has three optional lifecycle functions. Which one a piece of work belongs in depends on what has to exist already for it to be correct.
Between bind and start the application freezes and compiles its routes. Ports, routes and hooks are accepted only while plugins bind.
import { definePlugin } from '@routecraft/routecraft'
export function heartbeat(every = 60_000) {
return definePlugin({
id: 'acme.heartbeat',
start(c) {
const timer = setInterval(() => c.logger.info({}, 'still running'), every)
timer.unref?.()
c.onDispose(() => clearInterval(timer))
},
})
}
c.onDispose(fn) registers a release that runs at stop, after the plugin's own stop, in reverse registration order. Every disposer runs even when another throws, and the disposers a bind registered run even when that bind throws afterwards, so register each release right after the acquisition it undoes.
To abort a boot, throw from bind or start: the application unwinds and the original error surfaces unchanged. To stand the application down without failing the boot, call c.execution.requestStop(). The full ordering and unwind rules, the stop argument, and the fault each stage raises are in the reference.
Ports
Plugins share state only through ports. A port is a named, versioned token for a capability: one plugin provides it, others require it. A consumer asks for a capability, never for a particular plugin, which is what lets someone else replace the provider.
import { definePlugin, port } from '@routecraft/routecraft'
export interface Approvals {
request(subject: string, reason: string): Promise<string>
}
export const APPROVALS = port<Approvals>('acme.approvals@1')
export const approvals = definePlugin({
id: 'acme.approvals',
provides: [APPROVALS],
bind(c) {
c.provide(APPROVALS, {
request: async (subject, reason) => {
c.logger.info({ subject, reason }, 'approval requested')
return 'pending'
},
})
},
})
export const escalation = definePlugin({
id: 'acme.escalation',
requires: [APPROVALS],
bind(c) {
const service = c.require(APPROVALS)
c.observe('route:exchange:failed', ({ details }) => {
void service.request(details.routeId, 'route failed')
})
},
})
port<T>("owner.capability@version")is called once, at module scope, and exported. The token is the identity, so two copies of the module that declares it are caught at install (RC1103).requireslists ports the plugin cannot run without;optionallists ports it uses when present and reads withc.lookup(port). The kernel orders plugins so a provider binds before its consumers, whatever order the application lists them in.provideslists ports the plugin hands over withc.provide(port, value)inbind. A declared port left unprovided isRC1109.
An adapter reaches a port at runtime through the context it runs in: context.lookup(PORT) or context.require(PORT). That is how context-wide merged options reach an adapter.
Replacing a position
The chain positions .authorize(), .throttle(), .circuitBreaker(), .retry(), .timeout(), .concurrency() and .cache() configure are filled by default plugins through three ports: ENFORCEMENT, RESILIENCE and CACHE. The builder methods stay the same for route authors; what fills the position is a provider. To replace one, provide the port and declare replaces:
import { definePlugin, RESILIENCE, resilienceProvider } from '@routecraft/routecraft'
export const tracedRetry = definePlugin({
id: 'acme.traced-retry',
provides: [RESILIENCE],
replaces: [RESILIENCE],
bind(c) {
c.provide(RESILIENCE, {
...resilienceProvider,
retry(options) {
const inner = resilienceProvider.retry(options)
return {
run(position) {
c.logger.debug({ route: position.routeId }, 'entering retry')
return inner.run(position)
},
}
},
})
},
})
A position cannot be moved, removed or added. A route that configures a position no installed plugin provides refuses to start with RC1111. The positions and their order are described on the Filter Chain page.
A replacement fills both scopes. The same methods called after .from() wrap a single step, and the wrapper resolves the same provider when the exchange runs: the run it hands the position carries scope: 'step' and the wrapped step's label instead of 'route', so one retry implementation serves a route and a step alike. throttle takes the scope as its second argument, and a step-scope .cache() goes through the CACHE provider's wrap.
Replacing a security-relevant port takes on what the default guaranteed. Decorating the exported default, as above, keeps it:
AUTHORITY:isAuthenticis true only for what the authority minted or branded, never for a restored record.ENFORCEMENT: refusals keep their codes, and a door treats a refusal as the caller's only when the shipped gate raised it, so composeenforcementProvider's gate rather than throwing your own.CACHE: the default key carries the principal and the route, or one caller's cached body is served to another.CONTINUATIONS: the signer refuses a forged or expired token, and a missing secret isRC5040outside a namedNODE_ENV.
The application names every replaced port once at boot, at warn for AUTHORITY and ENFORCEMENT.
Hooks
The chain around every route has fixed positions that belong to the framework, and slots between them where any number of plugins add hooks:
A hook names a slot and a phase, never another plugin. Inside a slot every observe hook runs, then every mutate hook, then every validate hook. Within a phase, hooks run in the order the application lists the plugins.
observe: read, never change
An observe hook reads the exchange and returns nothing. Returning a value is RC1115.
import { definePlugin } from '@routecraft/routecraft'
export const timing = definePlugin({
id: 'acme.timing',
hooks: {
exit: {
id: 'logCompleted',
phase: 'observe',
run(exchange, info) {
exchange.logger.info({ route: info.routeId }, 'completed')
},
},
},
})
mutate: change headers or body
A mutate hook returns the headers to set and, optionally, a replacement body. Returning nothing leaves the exchange as it is. Declaring the headers it writes lets the application report a conflict between two plugins at start rather than on the first request that hits both.
import { definePlugin } from '@routecraft/routecraft'
export const tenancy = definePlugin({
id: 'acme.tenancy',
hooks: {
beforeAuth: {
id: 'normaliseTenant',
phase: 'mutate',
writes: ['x-tenant'],
run(exchange) {
const tenant = exchange.headers['x-tenant']
if (typeof tenant !== 'string') return undefined
return { headers: { 'x-tenant': tenant.trim().toLowerCase() } }
},
},
},
})
validate: allow or refuse
A validate hook allows the exchange by returning nothing and refuses it by returning refuse(reason, { kind }). A refusal fails the run with RC5068 naming the hook and the reason, and that failure reaches the route's .error() and the error slot like any other. A validate hook that returns anything else is RC1115.
The kind is the hook's say over how a door answers the caller, in a vocabulary no transport owns: forbidden unless given, or invalid, unauthenticated, not_found, conflict, gone, rate_limited or unavailable. The http() source and the ops dispatch door map it to a status (403, 400, 401, 404, 409, 410, 429, 503) and the MCP server to a tool error naming it, each carrying the reason and never the hook. Write the reason for the caller: it crosses the wire, clipped like a schema issue.
import { definePlugin, principalOf, refuse } from '@routecraft/routecraft'
export const sameTenant = definePlugin({
id: 'acme.same-tenant',
hooks: {
afterAuth: {
id: 'principalMatchesTenant',
phase: 'validate',
tags: ['tenant-scoped'],
run(exchange) {
const tenant = exchange.headers['x-tenant']
const principal = principalOf(exchange)
if (principal?.claims?.['tenant'] !== tenant) {
return refuse(`principal is not a member of tenant ${String(tenant)}`)
}
return undefined
},
},
},
})
routes and tags scope a hook to some routes, by id or by tag; with neither it applies to every route. exit has no validate phase.
The error slot
An error hook hears every failure the route's own .error() did not settle. In observe it only hears. In mutate it may decide: return a recovery body, recovery.drop(), recovery.defer(...) or recovery.rethrow(), or undefined to pass to the next hook. The first answer decides.
import { definePlugin, RoutecraftError } from '@routecraft/routecraft'
export const staleQuotes = definePlugin({
id: 'acme.stale-quotes',
hooks: {
error: {
id: 'serveStale',
phase: 'mutate',
tags: ['quotes'],
run(error, exchange, info) {
const timedOut = error instanceof RoutecraftError && error.rc === 'RC5011'
if (!timedOut) return undefined
exchange.logger.warn({ route: info.routeId }, 'serving a stale quote')
return { stale: true }
},
},
},
})
The hook's info carries routeId, tags, execution (1 on the first run, 2 once the exchange is a resumed continuation) and forward, which sends to a direct() endpoint under the failing exchange's principal.
A hook that may answer recovery.defer() declares mayDefer: true, which makes every route it applies to deferrable, so the application refuses to start without a deferral runtime. It declares schema beside it when the resume payload has a shape: the resume door reads the schema back off the hook and validates the payload against it, exactly as it does for a .defer() step. The error ladder from step to route to slot is described on the .error() reference.
Settling conflicts
The application, not the plugins, decides when two hooks disagree. In config, hooks.order sets the exact order of one phase of one slot, and hooks.disable switches a hook off. Both address a hook as pluginId/hookId:
import { defineProject } from '@routecraft/routecraft'
export default defineProject({
hooks: {
order: { 'beforeAuth/mutate': ['acme.tenancy/normaliseTenant'] },
disable: ['acme.timing/logCompleted'],
},
})
A name that matches no installed hook is RC1112. A position is never disabled.
Points
The slots are the framework's moments. A point is a moment a plugin declares inside its own step, so that other plugins can hook it the same way: an approvals plugin declares approvals.decided and runs the hooks placed there at the instant a decision lands, and an audit plugin written by someone else records every decision without the approvals plugin knowing it exists.
A point is declared in points and invoked from the step with ctx.invoke(name, exchange), which runs the hooks there phase by phase and returns the exchange as the last mutate left it. The step is written in the raw form, because the function form of step() hands the step only its abort signal:
import { definePlugin, step, OperationType } from '@routecraft/routecraft'
interface Decision {
readonly request: string
readonly approved: boolean
}
export const approvals = definePlugin({
id: 'acme.approvals',
points: [{ name: 'approvals.decided' }],
steps: {
decided: () =>
step<Decision, Decision>({
operation: OperationType.PROCESS,
adapter: { adapterId: 'acme.approvals.decided' },
async execute(exchange, ctx) {
return { kind: 'continue', exchange: await ctx.invoke('approvals.decided', exchange) }
},
}),
},
})
Another plugin hooks the point under hooks.points, with the same phases as a slot:
import { definePlugin, refuse } from '@routecraft/routecraft'
export const audit = definePlugin({
id: 'acme.audit',
hooks: {
points: {
'approvals.decided': {
id: 'recordDecision',
phase: 'validate',
run(exchange) {
const { request, approved } = exchange.body as { request: string; approved: boolean }
exchange.logger.info({ request, approved }, 'decision recorded')
return request.length === 0 ? refuse('a decision names its request', { kind: 'invalid' }) : undefined
},
},
},
},
})
Point names are dotted under the declaring plugin's namespace. A hook at a point nobody declares is RC1112; two plugins declaring one point, or a point named after a slot, is RC1113. The ops route detail lists the hooks at every point beside the slot hooks, so an operator can see what runs on a route.
Steps
A plugin's steps become builder methods. Each entry takes the method's arguments and returns a step built with step<In, Out>(), which types the method by the body it accepts and the body it leaves:
import { definePlugin, step } from '@routecraft/routecraft'
interface Order {
readonly id: string
readonly total: number
}
export const pricing = definePlugin({
id: 'acme.pricing',
steps: {
withTax: (rate: number) =>
step<Order, Order & { readonly gross: number }>((exchange) => ({
...exchange.body,
gross: Math.round(exchange.body.total * (1 + rate)),
})),
},
})
On a project that installs pricing, .withTax(0.21) exists on a route whose body is an Order and leaves it as Order & { gross: number }. On a route carrying a string, the method does not exist.
Body is a placeholder for "whatever the body is where the method is called". A step typed step<Body, Body> whose arguments mention Body gives a body-preserving method whose callbacks are typed at the route's current body:
import { definePlugin, step, type Body } from '@routecraft/routecraft'
export const dedupeKeys = definePlugin({
id: 'acme.keys',
steps: {
stamp: (key: (body: Body) => string) =>
step<Body, Body & { readonly ref: string }>((exchange) =>
Object.assign({}, exchange.body, { ref: key(exchange.body) }),
),
},
})
A method whose type depends on a generic at the call site cannot be expressed by a steps entry. Declare its type by merging into StepMethods under the plugin's namespace; the steps entry of the same name still provides the runtime:
import {
definePlugin,
step,
type BuilderState,
type Retyped,
type SetBody,
} from '@routecraft/routecraft'
export const pick = definePlugin({
id: 'acme.pick',
steps: {
pick: (key: string) =>
step<Record<string, unknown>, unknown>((exchange) => exchange.body[key]),
},
})
declare module '@routecraft/routecraft' {
interface StepMethods<S extends BuilderState, This> {
pick: {
pick<K extends keyof S['body']>(key: K): Retyped<This, SetBody<S, S['body'][K]>>
}
}
}
A route that uses a plugin's step refuses to start unless that plugin is installed (RC1111). Two plugins declaring one step, or a step named after a builder method, is RC1116.
The .log(), .debug(), .map() and .schema() methods are ordinary builder methods. .authenticate() and .delegate() are the auth plugin's steps, and .defer() and .resume() are the deferral plugin's.
Facets
A plugin's facet makes a typed view of the exchange readable as ex.<namespace> in route callables. It is computed from the body and headers on every read and never stored, so it cannot disagree with what it derives from.
import { definePlugin, type Exchange } from '@routecraft/routecraft'
export const tenant = definePlugin({
id: 'acme.tenant',
facet: (exchange: Exchange) => ({
id: exchange.headers['x-tenant'] as string | undefined,
}),
})
In a route built with a project that installs tenant, .transform((body, ex) => ex.tenant.id) is typed. A facet named after an exchange field (id, headers, body, logger, context) or method is RC1114.
The shipped facets follow the same rule. ex.auth.principal is the authenticated principal, provided by the auth plugin; ex.deferral is the deferral plugin's view. Code that holds a plain Exchange rather than a route's typed one, such as an adapter or a hook, reads the same values with principalOf(exchange) and deferralOf(exchange).
Registering routes from a plugin
A plugin may contribute routes in bind. They are registered with the application's own routes and compiled with them:
import { craft, definePlugin, log, simple } from '@routecraft/routecraft'
export const adminRoutes = definePlugin({
id: 'acme.admin',
bind(c) {
if (process.env['ENABLE_ADMIN'] !== 'true') return
c.routes.register(
...craft().id('admin-health').from(simple({ ok: true })).to(log()).build(),
)
},
})
c.routes.list() and c.routes.get(id) give read-only views of what is registered. During bind they show only routes other plugins registered, because the application's own routes are registered after every plugin has bound.
Setting global adapter defaults
A common reason to reach for a plugin is to set default options for adapters once, so they are not repeated in every capability. Core adapters have dedicated config keys:
import { defineProject } from '@routecraft/routecraft'
export default defineProject({
cron: { timezone: 'UTC', maxJitter: 2000 },
})
Ecosystem packages add their own keys when imported:
import { defineProject } from '@routecraft/routecraft'
import '@routecraft/ai'
export default defineProject({
cron: { timezone: 'UTC' },
llm: {
providers: { anthropic: { apiKey: process.env['ANTHROPIC_API_KEY'] ?? '' } },
},
})
Every cron() source and llm() destination in the application inherits those defaults unless overridden per adapter. For how merged options work and how to add them to an adapter of your own, see Merged Options.
Managing external services
A plugin can own long-lived external processes. The built-in mcpPlugin spawns stdio MCP server subprocesses, monitors their health, and restarts them with exponential backoff when they crash:
import { defineProject } from '@routecraft/routecraft'
import '@routecraft/ai'
export default defineProject({
mcp: {
clients: {
filesystem: {
transport: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
},
},
maxRestarts: 5,
},
})
The plugin starts each subprocess when the application starts and stops them when it stops. A plugin that owns a listener or subscription past the routes declares keepsAlive: true, so the application keeps running when every route has completed.
Related
Plugins reference
Every descriptor field, context member, lifecycle stage and fault code.
Filter Chain
The positions and slots around every route, in order.
Monitoring
Logging, telemetry, and writing a custom monitoring plugin.
Merged options
Set adapter defaults once and share them across the context.