Skip to content

HTTP routes + Zod

Build a typed HTTP surface in three concepts: bindings (typed routes from @nwire/wires/http), wires (app.wire(binding, handler)), and an adapter (@nwire/koa or @nwire/express) that boots the wires under an endpoint.

Verbs + route bindings

Route factories produce RouteBinding values:

ts
import { get, post, put, patch, del } from "@nwire/wires/http";
import { z } from "zod";

const listUsers   = get("/users");
const getUser     = get("/users/:id", { params: z.object({ id: z.string() }) });
const createUser  = post("/users", { body: z.object({ email: z.email() }) });
const updateUser  = put("/users/:id", {
  params: z.object({ id: z.string() }),
  body:   z.object({ name: z.string().optional() }),
});
const deleteUser  = del("/users/:id", { params: z.object({ id: z.string() }) });

A RouteSchemas object accepts three optional fields:

  • params — URL path parameters (validated, typed in handler)
  • query — query string
  • body — request body (JSON, auto-parsed)

All three are merged into a single typed input object passed to the handler.

Wiring handlers

app.wire(binding, handler) pairs one binding with one function:

ts
import { createApp } from "@nwire/app";
import { get, post } from "@nwire/wires/http";
import { z } from "zod";

const app = createApp({ appName: "users-api" });

app.wire(get("/users"), async () => {
  return [{ id: "1", name: "Alice" }];
});

app.wire(
  get("/users/:id", { params: z.object({ id: z.string() }) }),
  async (input) => ({ id: input.id, name: "Alice" }),
);

app.wire(
  post("/users", {
    body: z.object({ email: z.email(), name: z.string() }),
  }),
  async (input) => {
    const id = crypto.randomUUID();
    return { $status: 201, body: { id, ...input } };
  },
);

The handler signature is (input, ctx) => result | Promise<result>. The two arguments are:

  • input — merged + validated params / query / body
  • ctx{ resolve<T>(name), logger, envelope, koa } (or express.req/res under @nwire/express)

ctx.envelope carries correlationId, causationId, tenant, userId, user. The envelope auto-propagates from x-correlation-id / x-causation-id request headers and threads through any child ctx.execute(...) / ctx.emit(...) calls.

Booting the app under an adapter

ts
import { endpoint } from "@nwire/endpoint";
import { httpKoa } from "@nwire/koa";

await endpoint("users-api", { port: 3000 })
  .use(httpKoa({ prefix: "/api/v1" }))
  .mount(app)
  .run();

httpKoa consumes wires whose binding.$adapter === "http". Stack more adapters (queueInMemory(), mcpAdapter()) on the same endpoint and the same handlers run under all of them.

Middleware

httpKoa({ middleware: [...] }) adds adapter-wide Koa middleware that runs before every wired route:

ts
import type Koa from "koa";

const requireApiKey: Koa.Middleware = async (ctx, next) => {
  if (ctx.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 binding:

ts
app.wire(
  post("/admin/purge", { middleware: [requireAdmin] }),
  async () => ({ purged: true }),
);

Response shapes

ReturnWhat happens
plain object/array200 OK, application/json
{ $kind: "response-instance", status, body } (from defineResource)status + JSON body
{ $status, body } legacy envelope$status + body
null / undefined204 No Content
throw errorTop-level error mapper renders { error: { code, summary } }; defineError values carry their own status
ts
return { id: "1", name: "Alice" };               // 200
return { $status: 201, body: created };          // 201
return undefined;                                 // 204
throw NotFound;                                   // → 404 (defineError)

The error contract

Every failure renders the same envelope, whichever surface it crossed — a wired route, Studio's Dispatch panel (POST /_nwire/dispatch), or an MCP client calling dispatch_action:

json
{ "error": { "code": "validation_failed", "summary": "email: Invalid email address" } }
FailureStatuscode
Input fails the route's zod schema400validation_failed — summary names the first failing field
Thrown defineErrorits own statusits own code + summary
Other 4xx raised before the handlerthat statusthe error's own code, or bad_request
Any other throw500internal_error — opaque

The 500 row is deliberate: a stray throw new Error("db creds …") never reaches a client verbatim, in any environment. On wired routes the adapter logs the real error server-side. httpKoa({ exposeErrors: true }) echoes the message for local debugging — off by default everywhere. One mapper serves the wired routes and /_nwire/dispatch, so the two surfaces can't drift.

Dependency injection

Register on the app's container via a plugin:

ts
import { createApp, definePlugin } from "@nwire/app";

class UserStore { /* ... */ }

const app = createApp({
  appName: "users",
  plugins: [
    definePlugin("user-store", ({ bind }) => {
      bind("users", new UserStore());
    }),
  ],
});

app.wire(get("/users"), async (_input, ctx) => {
  const users = ctx.resolve<UserStore>("users");
  return users.list();
});

For per-request scoping, ctx.resolve already comes from a per-request scope of the app's container — disposals registered via bind("...", v, { dispose }) fire at the end of the request.

OpenAPI + Scalar UI

Every wired route with an openapi: metadata block becomes an OpenAPI 3.1 operation. Set httpKoa({ openapi: { auto: true }, docs: true }) and the adapter walks your wires at boot:

ts
app.wire(
  post("/users", {
    body: UserInput,
    openapi: {
      operation: "CreateUser",
      summary:     "Create a user",
      description: "Idempotent — same email returns the existing row.",
      tags:        ["users"],
    },
  }),
  createUserHandler,
);

await endpoint("api", { port: 3000 })
  .use(httpKoa({
    prefix: "/api/v1",
    openapi: { auto: true, title: "Users API", version: "1.0.0" },
    docs: true,
  }))
  .mount(app)
  .run();

The spec lands at /openapi.json; the Scalar UI at /docs. See OpenAPI + Scalar for the full surface.

Inspect endpoints (Studio Live)

httpKoa({ inspect: true }) mounts /_nwire/* for Studio, MCP tooling, and ops dashboards:

ts
await endpoint("api", { port: 3000 })
  .use(httpKoa({ inspect: true }))
  .mount(app)
  .run();

Available routes: GET /_nwire/manifest, GET /_nwire/wires, GET /_nwire/runtime, GET /_nwire/config, GET /_nwire/events/recent, GET /_nwire/events/stream (SSE), GET /_nwire/telemetry/recent, GET /_nwire/telemetry/stream (SSE), GET /_nwire/openapi.json, GET /_nwire/supervisor, GET /_nwire/actors/:type, POST /_nwire/dispatch.

/events/* carry domain events — a handler's local emit and the cross-BC publish both land in the same ring buffer, from every composed app. /telemetry/* carry the full raw record stream (dispatches, hook steps, listener runs) — nwire watch tails /telemetry/stream. Gate behind an admin middleware in production via inspect: { middleware: [requireAdmin] }.

Dispatching by name

POST /_nwire/dispatch invokes any registered action — the code path Studio's Dispatch panel and MCP's dispatch_action both drive:

json
{ "action": "publishPost", "input": { "title": "hello" } }
  • action is the canonical field. handler and name still resolve as deprecated aliases for one release; a request that uses one gets a deprecation note alongside its result telling the caller what to send instead.
  • userId, tenant, and user on the body thread into the handler's envelope, so an action that authorizes on envelope.userId sees the caller's identity instead of running unauthenticated.
  • Success returns { "result": … }.

Failures follow the error contract, with one addition: a name with no registered handler answers 404 action_not_found before anything executes — never an opaque 500 from inside the dispatch.

Under NODE_ENV=production the endpoint answers 403 dispatch_disabled unless you gated the surface with your own middleware (inspect: { middleware: [requireAdmin] }) — remote handler invocation is never open by default. The read-only /_nwire/* routes still serve.

Foreign hosts (Express / NestJS)

Mount Nwire wires inside an existing Express app via nwireToExpressRouter:

ts
import express from "express";
import { nwireToExpressRouter } from "@nwire/express";

const legacy = express();
const app = createApp({ appName: "users" });
app.wire(get("/users"), listUsers);
await app.start();

legacy.use("/api/v1", nwireToExpressRouter(app));
legacy.listen(3000);

See Express interop and NestJS interop for the full migration paths.

MIT licensed.