Migrating from 0.7.x to 0.8.0

What changed between Routecraft 0.7 and 0.8, and how to update.

0.8.0 is the plugin release. Every feature Routecraft ships beyond the route grammar is now a plugin built on the same sockets a third party uses: direct endpoints, retries, caching, authentication and deferral included. Routes built from the DSL are unchanged. What follows is every change that can break a 0.7 project, in the order most projects meet them.

  1. Plugins are descriptors. CraftPlugin, apply(ctx) and teardown(ctx, info) are removed; a plugin is definePlugin({ id, bind, start, stop, ... }) and never receives the CraftContext. Section 1.
  2. ex.principal is ex.auth.principal. Section 2.
  3. craft.config.ts default-exports defineProject(...). A plain config still loads. Section 3.
  4. registerDsl is removed. Route methods come from a plugin's steps. Section 4.
  5. Store keys that crossed plugins are ports. Section 5.

Projects that write no plugin of their own typically need section 2 and nothing else. Section 6 lists what is new, for context.


1. Plugins

A plugin is a plain descriptor built with definePlugin(). It declares a dotted id, and its lifecycle functions receive a PluginContext: ports, events, its own routes and a small set of execution verbs. A plugin still shaped with apply is refused at install with RC1117 naming the change.

import { type CraftPlugin } from '@routecraft/routecraft'

export const audit: CraftPlugin = {
  apply(ctx) {
    ctx.on('route:exchange:failed', ({ details }) => {
      ctx.logger.warn({ route: details.routeId }, 'exchange failed')
    })
  },
}
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')
    })
  },
})
0.70.8
CraftPluginPlugin, declared with definePlugin()
name (optional)id (required, dotted, unique per application)
apply(ctx)bind(c)
teardown(ctx, info)stop(c, info); or register releases with c.onDispose(fn)
ctx.registerHandler(handler) and the handlers config key (#818)a hook in the error slot: hooks: { error: { id, phase: 'mutate', run } }, with mayDefer: true when it may park (section 6)
route:error-handler:* events with scope: "context" and handlerIndexscope: "slot" and hook (the pluginId/hookId), recoveryStrategy: "error-slot-hook"
ctx.on(event, handler)c.observe(event, handler)
ctx.emit(event, details)c.emit(event, details)
ctx.setStore / ctx.getStore to share state with an adapter or another plugina port: provides + c.provide(), requires + c.require() (section 5)
ctx.registerRoutes(...)c.routes.register(...), in bind only
ctx.stop() from a lifecycle functionc.execution.requestStop()
ctx.whenStarted()c.execution.whenStarted()
plugin:applying / plugin:appliedplugin:binding / plugin:bound

bind runs at the same point apply did: while the application is installed, before routes are registered. Plugins now bind in dependency order rather than strictly in list order: a plugin that requires a port binds after the plugin that provides it. The default plugins (routecraft.direct, routecraft.resilience, routecraft.cache, routecraft.principals, routecraft.auth) install ahead of the application's own, so a lifecycle event's pluginIndex counts them.

Two new families of fault name the plugin responsible: RC1101 to RC1117 for install, resolution, ordering and compile faults, and RC5068 when a validate hook refuses an exchange. See the errors reference.

2. ex.principal is ex.auth.principal

The authenticated principal is now the auth plugin's facet. In a route callable, read ex.auth.principal. Code that holds a plain Exchange (an adapter, a hook, a helper) reads principalOf(exchange). The header it derives from, routecraft.auth.principal, is unchanged.

craft()
  .id('whoami')
  .from(direct())
  .authenticate(() => ({ scheme: 'test', subject: 'ada' }))
  .transform((_body, ex) => ex.auth.principal?.subject)
  .to(log())

ex.deferral is unchanged in route callables; outside them, read deferralOf(exchange). The principal authority (mint, brand, isAuthentic, restore, isRestored, read) is the AUTHORITY port the default routecraft.principals plugin provides. Brand and check through authorityOf(exchangeOrContext) instead of markAuthentic, isAuthentic, markRestored and isRestored, which are no longer exported; the default authority is exported as defaultAuthority for a replacement that decorates it, and the restrict-principal-minting lint rule flags its mint() and brand() too. delegate() takes the authority as its fourth argument: delegate(subject, actor, options, authorityOf(exchange)).

3. defineProject

A project declares its plugins and configuration with defineProject(), and craft.config.ts default-exports the result:

// craft.config.ts
import { defineProject } from '@routecraft/routecraft'

export default defineProject({
  plugins: [],
  deferral: {},
})

defineProject returns { craft, config, plugins }. Its craft() is typed by exactly the installed plugins, so a route can use a plugin's steps and facet. A named craftConfig export or a default-exported config object still loads, so this change is not required; craft start reads a project first. See Plugins.

4. Route methods come from steps

registerDsl and augmenting StepBuilderBase are removed. A plugin's steps become builder methods, typed by step<In, Out>():

import { definePlugin, step } from '@routecraft/routecraft'

export const shout = definePlugin({
  id: 'acme.shout',
  steps: {
    shout: () => step<string, string>((exchange) => exchange.body.toUpperCase()),
  },
})

Build routes that use it with the project's craft(). A method generic at the call site declares its type by merging into StepMethods. See Steps.

5. Store keys that crossed plugins are ports

State one plugin hands another, or hands an adapter, is a port: a typed token declared once with port<T>("owner.capability@1"). These store keys are removed:

0.7 store key0.8 port
ADAPTER_DIRECT_REGISTRY, DIRECT_DEFAULTSDIRECT; list capabilities with ctx.capabilities()
OPS_HEALTH_STATE, OPS_RESOURCESOPS
DEFERRAL_RUNTIMECONTINUATIONS
CARDDAV_CLIENT_MANAGERCARDDAV
ADAPTER_AGENT_REGISTRY, ADAPTER_FN_REGISTRY, ADAPTER_AGENT_DEFAULT_OPTIONS and the MCP keysports of @routecraft/ai

A custom adapter that read context-wide defaults from a store key reads them from a port its companion plugin provides: context.lookup(MY_DEFAULTS). See Merged Options. An adapter's own per-context state may stay in the store.

CraftConfig.plugins is a readonly array.

A plugin that is not repeatable is installed once per application. A second llmPlugin(), embeddingPlugin() or shellPlugin() used to replace the first silently; it is now RC1101. Merge the options into one install.

The internal RouteDefinition fields preParseFilters, postParseFilters and postFromFilters are gone: a route definition carries its chain as configuration, and the chain is built when the route compiles.

6. What is new in 0.8.0

  • Ports. Plugins share anything through port<T>(), with requires, optional, provides and replaces.
  • Replaceable positions. .authorize(), the resilience operations and .cache() are unchanged on the builder, and the default plugins fill them through ENFORCEMENT, RESILIENCE and CACHE. A plugin may replace one, and the replacement fills the same methods placed after .from() too.
  • Hooks. Plugins add hooks to the slots of the chain (beforeAuth, afterAuth, admitted, perAttempt, exit, error) in the observe, mutate and validate phases. Every hook declares an id; hooks.order and hooks.disable in config address it as pluginId/id and settle conflicts. A validate hook's refuse(reason, { kind }) is answered at the door the caller came through with the status its kind maps to.
  • The error slot. A plugin hook can recover, drop or park (recovery.defer()) a failure the route's own .error() did not settle.
  • Error-path parks are validated. .error(handler, { schema }) and an error hook's schema declare what a resume payload must satisfy when the handler parks; the resume door validates it (RC5049) and refuses a resume whose schema changed (RC5048).
  • Facets. A plugin's facet is readable as ex.<namespace>.

The Plugins guide explains each part with a worked example, and the Plugins reference lists every field.