Skip to content

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:

ts
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:

ts
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(...):

ts
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:

ts
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:

ts
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:

ts
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 via ctx.resolve(name) in handlers
  • on(hookName, fn) — observe a framework lifecycle hook ("AppBooted", "AppReady", "AppShuttingDown", …); the payload is typed from the named hook
  • boot(fn) — async work after binds have run, before the app serves traffic
  • dispose(fn) — reverse-order async work on app.stop()
  • container — the App's Container for direct register/resolve
  • runtime — the runtime for dispatching, subscribing to events, registering action middleware via runtime.use(...)
  • hooks — the FrameworkHooks registry for tap/wrap
  • defineHook(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:

ts
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 from Authorization: Bearer ... and seeds envelope.user.
  • rbacPlugin({ buildAbility })@nwire/rbac, enforces the policy tag 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

MIT licensed.