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:
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 stringbody— 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:
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 + validatedparams/query/bodyctx—{ resolve<T>(name), logger, envelope, koa }(orexpress.req/resunder@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
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:
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:
app.wire(
post("/admin/purge", { middleware: [requireAdmin] }),
async () => ({ purged: true }),
);Response shapes
| Return | What happens |
|---|---|
| plain object/array | 200 OK, application/json |
{ $kind: "response-instance", status, body } (from defineResource) | status + JSON body |
{ $status, body } legacy envelope | $status + body |
null / undefined | 204 No Content |
throw error | Top-level error mapper renders { error: { code, summary } }; defineError values carry their own status |
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:
{ "error": { "code": "validation_failed", "summary": "email: Invalid email address" } }| Failure | Status | code |
|---|---|---|
| Input fails the route's zod schema | 400 | validation_failed — summary names the first failing field |
Thrown defineError | its own status | its own code + summary |
| Other 4xx raised before the handler | that status | the error's own code, or bad_request |
| Any other throw | 500 | internal_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:
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:
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:
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:
{ "action": "publishPost", "input": { "title": "hello" } }actionis the canonical field.handlerandnamestill resolve as deprecated aliases for one release; a request that uses one gets adeprecationnote alongside its result telling the caller what to send instead.userId,tenant, anduseron the body thread into the handler's envelope, so an action that authorizes onenvelope.userIdsees 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:
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.