@nwire/handler
v0.15.1
Published
Nwire — the operation primitive. Typed, callable, hookable value built on @nwire/hooks. defineHandler / defineError / defineResource / response factories / framework errors. Standalone; every transport (HTTP, queue, MCP, CLI) speaks the same handler shape
Readme
@nwire/handler
The operation primitive — one typed, callable, hookable value usable from every transport (HTTP, queue, MCP, CLI, direct test).
pnpm add @nwire/handlerWhy
Every backend ends up with the same shape: parse input, run a function with some deps, return a typed response or throw a typed error. Different teams wrap it in different abstractions (controllers, route handlers, command handlers, tRPC procedures, NestJS providers). They're all the same thing.
@nwire/handler is that thing, distilled: a callable, type-generic value. It's a Hook from @nwire/hooks underneath, so it gets .use() middleware, .on() listeners, .tap() observation, signal cancellation, and replay — for free.
Three rules:
- Ctx is generic. Whatever you pass to
.run(ctx)is what the handler body destructures, fully typed. - App pins ctx once.
defineHandlerWith<AppExtras>()returns a factory you import everywhere; handlers never annotate ctx. - No global pollution. Plugin contributions are imported types, intersected at the app boundary. Nothing is
declare module-ed.
Surface
// The primitive
function defineHandler<TInputSchema, TOutput, TExtras extends object = object>(
name: string,
config: HandlerConfig<TInputSchema, TOutput, TExtras>,
): HandlerDefinition<TInputSchema, TOutput, TExtras>;
// The app-boundary factory — pins TExtras once
function defineHandlerWith<TExtras extends object>(): typeof defineHandler bound to TExtras;
// Type alias for declaring a handler's ctx shape inline
type Ctx<TInput, TExtras extends object = object> =
{ input: TInput; signal: AbortSignal; set: … } & TExtras;
// Resources, errors, response factories
function defineResource(name, { schema, public, examples });
function defineError({ code, status, summary }); // callable: throw NotFound({ resourceId });
ok / created / accepted / noContent / notModified / gone
// Built-in errors
Unauthorized / Forbidden / NotFound / Conflict / Gone / BadRequest
// Type helpers
HandlerInput<H> / HandlerOutput<H> / HandlerExtras<H>Consumer example
app/define.ts — one file, pinned once:
import { defineHandlerWith, type Ctx } from "@nwire/handler";
import type { AuthCradle } from "@nwire/auth";
import type { DbCradle } from "@nwire/drizzle";
// Plugins export type fragments; app composes its cradle:
export type AppCradle = AuthCradle &
DbCradle & {
config: AppConfig;
logger: Logger;
};
// Extras the wires put onto ctx at request time:
type AppExtras = {
logger: Logger;
pg: PgClient;
user: { id: string; role: string; balance: number };
};
export const defineAction = defineHandlerWith<AppExtras>();
export type AppCtx<I = unknown> = Ctx<I, AppExtras>;app/orders/buy-wash.action.ts — a complete handler:
import { z } from "zod";
import { defineResource, defineError, NotFound } from "@nwire/handler";
import { defineAction } from "@/define";
const Wash = defineResource("Wash", {
schema: z.object({ id: z.string(), name: z.string(), price: z.number() }),
public: ["id", "name", "price"],
});
const InsufficientFunds = defineError({
code: "INSUFFICIENT_FUNDS",
status: 402,
summary: "balance too low for this wash",
});
export const BuyWash = defineAction("buyWash", {
input: z.object({ washId: z.string() }),
returns: Wash,
errors: [NotFound, InsufficientFunds],
handler: async ({ input, logger, pg, user, signal }) => {
const row = await pg.query("SELECT * FROM washes WHERE id=$1", [input.washId], { signal });
if (!row) throw NotFound({ resourceId: input.washId });
if (user.balance < row.price) throw InsufficientFunds({ have: user.balance, need: row.price });
logger.log(`${user.id} bought ${row.id}`);
return row;
},
});
// Per-handler middleware via the hook substrate
BuyWash.use(async (ctx, next) => {
const t = performance.now();
await next();
ctx.logger.log(`buyWash ${(performance.now() - t).toFixed(1)}ms`);
});wires/api.wire.ts — boot, container, request composition:
import { createContainer } from "@nwire/container";
import type { AppCradle } from "@/define";
import { BuyWash } from "@/orders/buy-wash.action";
const root = createContainer<AppCradle>();
root.register("config", { port: 3000 });
root.register("logger", () => ({ log: (s) => console.log(s) }));
root.register("db.pg", () => new PgClient(root.cradle.config.port));
const server = createServer(async (req, res) => {
const user = await authenticate(req);
const result = await BuyWash(JSON.parse(await readBody(req))).run({
ctx: {
logger: root.cradle.logger,
pg: root.cradle["db.pg"],
user,
},
signal: req.signal,
});
res.writeHead(201).end(JSON.stringify(result));
});app/orders/buy-wash.test.ts — unit test, no container:
import { expect, test } from "vitest";
import { BuyWash, InsufficientFunds } from "./buy-wash.action";
test("rejects when balance < price", async () => {
await expect(
BuyWash({ washId: "w1" }).run({
ctx: {
logger: { log: () => {} },
pg: { query: async () => ({ id: "w1", name: "Quick", price: 100 }) },
user: { id: "u-1", role: "user", balance: 5 },
},
}),
).rejects.toMatchObject({
code: "INSUFFICIENT_FUNDS",
status: 402,
context: { have: 5, need: 100 },
});
});The four contracts in one model
| Concern | How |
| ---------------- | ------------------------------------------------------------------------------------------- |
| Input validation | input: zodSchema — parsed before the handler body runs |
| Output shape | returns: Resource \| ResponseSpec[] — narrows the handler's return type, drives OpenAPI |
| Throws | errors: [NotFound, …] — typed throwables, drive 4xx OpenAPI bodies |
| Cancellation | signal: AbortSignal on ctx — forward to fetch/pg/etc., bail via signal.throwIfAborted() |
Cancellation in practice
The signal flows through nested .run() calls automatically:
const ParentOp = defineAction("parent", {
handler: async ({ signal }) => ChildOp().run({ signal }),
});
// caller-supplied signal → ParentOp.run({ signal }) → ChildOp.run({ signal })
// abort the caller's controller → both bailWhen no signal is supplied, ctx gets a non-aborting placeholder — code can always pass ctx.signal through to fetch/pg/redis without null checks.
Errors
throw NotFound; // bare value (it IS an Error)
throw NotFound({ resourceId: input.id }); // callable, contextualised cloneCaught at the transport layer — REST → status + { code, message, context }, GraphQL → extensions.code, CLI → exit status + stderr.
Built on @nwire/hooks
Every handler is a Hook<HandlerRunCtx> underneath. .use(), .on(), .tap(), .runDetailed() all work. The user's handler function is the innermost chain step, registered at construction with Number.MIN_SAFE_INTEGER priority so every .use() you add wraps around it. Telemetry, replay, observation come along for free.
Scope
Standalone — no DI, no events, no logger, no transport opinions. App composes ctx by value; transports compose ctx at boot. Forge / HTTP / queue / MCP wires layer on top.
