Middleware + plugins
Two ways to add cross-cutting behaviour:
- Middleware — per-request transport-level interceptor (auth, logs, CORS, rate limiting). Runs on every wired route.
- Plugin — app-level. Provides container bindings, hooks framework events, runs boot/shutdown work, intercepts dispatches.
Use middleware when the concern is HTTP-specific. Use a plugin when the concern crosses transports or hooks into the app lifecycle.
HTTP middleware
httpKoa({ middleware: [...] }) runs Koa middleware before every wired handler:
import type Koa from "koa";
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";
const requireApiKey: Koa.Middleware = async (ctx, next) => {
if (ctx.request.headers["x-api-key"] !== process.env.API_KEY) {
ctx.status = 401;
ctx.body = { error: { code: "unauthorized" } };
return;
}
await next();
};
await endpoint("api", { port: 3000 })
.use(httpKoa({ middleware: [requireApiKey] }))
.mount(app)
.run();Per-route middleware lives on the route binding:
app.wire(
post("/admin/purge", { middleware: [requireAdmin] }),
purgeHandler,
);@nwire/express accepts the same shape with Express middleware via expressAdapter({ middleware: [...] }).
Reusable handler middleware
For chains that wrap a handler (auth checks, logging, retries) at the transport-agnostic layer, attach a hook step to the handler with .use(...):
import { defineHandler } from "@nwire/handler";
import { UnauthorizedError } from "@nwire/auth";
import { z } from "zod";
const requireUser = async (ctx: { user?: unknown }, next: () => Promise<void>) => {
if (!ctx.user) throw new UnauthorizedError();
return next();
};
const protectedHandler = defineHandler("protectedHandler", {
input: z.object({ /* ... */ }),
handler: async (ctx) => {
// ctx.user is guaranteed present here
},
}).use(requireUser);defineHandler returns a Hook — .use(step) wraps the handler in registration order, outermost first. Chain steps see the same typed ctx (input + extras) the handler does.
Plugins
definePlugin covers everything cross-cutting at the app level:
import { definePlugin, AppBooted } from "@nwire/app";
export const dbPlugin = () =>
definePlugin("db", ({ bind, boot, dispose }) => {
let pool: Pool;
bind("db", () => pool);
boot(async () => {
pool = createPool(process.env.DATABASE_URL!);
await pool.connect();
});
dispose(async () => {
await pool.end();
});
});Register on the app:
import { createApp } from "@nwire/app";
const app = createApp({
appName: "orders",
plugins: [dbPlugin()],
});To observe framework lifecycle events, write a plugin and use its on — the payload is typed from the named hook:
import { definePlugin } from "@nwire/app";
const readyLog = definePlugin("ready-log", ({ on }) => {
on("AppBooted", ({ appName, bootedAt }) => {
console.log(`${appName} booted at ${bootedAt}`);
});
});What plugin context exposes
definePlugin(name, ({ bind, on, boot, dispose, container, runtime, hooks, defineHook }) => ...):
bind(name, factory, opts?)— register a value in the app's container; resolvable viactx.resolve(name)in handlerson(hookName, fn)— observe a framework lifecycle hook ("AppBooted","AppReady","AppShuttingDown", …); the payload is typed from the named hookboot(fn)— async work afterbinds have run, before the app serves trafficdispose(fn)— reverse-order async work onapp.stop()container— the App'sContainerfor direct register/resolveruntime— the runtime for dispatching, subscribing to events, registering action middleware viaruntime.use(...)hooks— theFrameworkHooksregistry for tap/wrapdefineHook(name)— declare a custom hook slot other plugins can tap
Forge plugin
@nwire/forge's forgePlugins() is the most common plugin set — it wires the command pipeline and the read engine, and folds actors, projections, and workflows. It reads those primitives off the app's registry by kind, so you pass it infrastructure (stores, the bus), not primitives. It returns an array, so spread it into plugins:
import { createApp, defineRegistry } from "@nwire/app";
import { forgePlugins } from "@nwire/forge";
const app = createApp({
appName: "orders",
registry: defineRegistry({
handlers: [placeOrder, getOrdersByCustomer],
actors: [Order],
projections: [ordersByCustomer],
workflows: [autoCharge],
}),
plugins: [...forgePlugins()],
});Every primitive in createApp({ registry }) — actions and queries alike — gets wired at boot, and forge picks up the actors, projections, and workflows it finds.
Common plugin packages
identityPlugin({ adapter })—@nwire/auth, verifies the user fromAuthorization: Bearer ...and seedsenvelope.user.rbacPlugin({ buildAbility })—@nwire/rbac, enforces thepolicytag on every action via CASL abilities.tracingPlugin()—@nwire/observability, emits OTel spans for every dispatch.drizzlePlugin({ db })—@nwire/drizzle, exposes the Drizzle client + runs migrations on boot.
Where to next
definePluginreference — every builder method, dispatch modes, lifecycle ordering.- Auth — Logto / Auth — better-auth — full identity setup using plugins.
- RBAC — CASL-backed permissions plugin.