Express & Middleware
It's Express. app.use(), routers, the (req, res, next) signature, and built-in middleware sit right next to app.queue / app.workflow / app.agent — durability is opt-in depth, with zero new HTTP concepts to learn.
The HTTP layer is Express-shaped. If you know Express, you already know this: app.use(), mountable routers, the (req, res, next) signature, and 4-arg error middleware all behave the way you expect. When you need durability, app.queue(), app.workflow(), and app.agent() are on the same app.
Middleware — app.use()#
const app = zenzip();
// Global middleware — Express (req, res, next) signature.
app.use((req, res, next) => {
req.startedAt = Date.now();
next();
});
// Path-scoped middleware — runs only at/under the mount path.
app.use("/api", authMiddleware);
// Built-in middleware shipped with the framework.
app.use(zenzip.json()); // parse JSON bodies
app.use(zenzip.cors({ origin: "*" })); // CORS + OPTIONS preflight
app.use(zenzip.logger()); // one-line request logs- Middleware runs in registration order, before route dispatch.
app.use(fn)is global;app.use(path, fn)scopes to a mount path (segment-boundary match —/apimatches/api/users, not/apixyz).- Multiple handlers per call:
app.use(a, b, c)/app.use("/api", a, b). - Register middleware before
app.start().
Handler signatures#
Routes accept either shape — chosen automatically by arity. Two-or-more args is the Express handler; one arg is the original typed context.
// Express-familiar: (req, res) — req.params/query/body, res.json/send/status.
app.post("/orders", (req, res) => {
res.status(201).json({ id: req.body.id });
});
// Runtime-aware: durability primitives are reachable on req.app.
app.post("/checkout", async (req, res) => {
const { runId } = await req.app.workflow("order").trigger(req.body);
res.json({ runId });
});
// Original ZenZip context handler — still works unchanged (arity 1).
app.get("/users/:id", (ctx) => ({ id: ctx.params.id }));// (req, res, next) handler semantics:
// res.json(x) / res.send(x) → explicit response
// return value (no response) → 200 JSON (convenience)
// return undefined → 204
// next() → fall through to 404
// next(err) / throw → error middleware (then 500)Error handling#
// 4-arg error middleware — exactly like Express. Register it last.
app.use((err, req, res, next) => {
res.status(err.status ?? 500).json({ error: err.message });
});
// A throw / rejection anywhere — middleware OR route handler — skips
// forward to the next 4-arg error handler. With none registered, ZenZip
// writes 500 { error: message } for you.Familiar, not 100% spec
ZenZip matches Express's API shape and muscle memory — not every internals-poking middleware. Error middleware is identified the Express way: any handler with arity ≥ 4.
Typed error envelope. An unhandled throw becomes { error, code, status } with a mapped HTTP status. Throw HttpError for an explicit status/code; known framework errors map automatically — saturation (QueueFullError, CircuitOpenError) → 503, validation → 400, everything else → 500.
import { HttpError } from "zenzipjs";
app.get("/orders/:id", (ctx) => {
const order = db.find(ctx.params.id);
if (!order) throw new HttpError(404, "order not found", "NOT_FOUND");
return order;
});
// unhandled throw → { "error": "...", "code": "Error", "status": 500 }Routers & mounting#
const api = zenzip.Router();
api.get("/users/:id", getUser);
api.post("/users", createUser);
api.use(authMiddleware); // router-scoped middleware
app.use("/api/v1", api); // mount, exactly like express.Router()
// → GET /api/v1/users/:id, POST /api/v1/users
// Routers nest:
const v1 = zenzip.Router();
v1.use("/users", api); // → /api/v1/users/...zenzip.Router()groups routes + middleware; mount withapp.use(path, router).- Router-level
use()middleware is scoped to the mount path. Routers nest to any depth.
Built-in middleware#
app.use(zenzip.json()); // application/json → req.body
app.use(zenzip.urlencoded()); // form-urlencoded → req.body
app.use(zenzip.cors({ origin: true })); // reflect request origin
app.use(zenzip.logger({ log: (line) => myLogger.info(line) }));
app.use(zenzip.static("public")); // serve files from ./public
app.use(zenzip.static("public", { prefix: "/assets" }));
// Auth guard + request validation — path-scoped:
app.use("/admin", zenzip.auth({ tokens: [process.env.API_KEY!] }));
app.use("/admin", zenzip.auth({ verify: (t) => verifyJwt(t) })); // JWT/OIDC seam
app.use("/users", zenzip.validate({ body: userSchema })); // → req.user, auto-400| Middleware | What it does |
|---|---|
zenzip.json() | parse application/json bodies into req.body |
zenzip.urlencoded() | parse form-urlencoded bodies into req.body |
zenzip.cors(opts) | CORS headers + automatic OPTIONS preflight (origin string | string[] | true) |
zenzip.logger(opts) | METHOD /path STATUS DURms on response finish; pluggable sink |
zenzip.static(root, opts) | serve files from a directory; traversal-safe, falls through to routes; index + prefix options |
zenzip.auth(opts) | auth guard: Bearer/x-api-key against static tokens or a verify() callback (JWT/OIDC seam); attaches req.user, 401s otherwise |
zenzip.validate(opts) | request validation: body/query Standard Schema → auto-400 with issues, replaces with parsed value |
zenzip.secureHeaders(opts) | security headers: nosniff, X-Frame-Options, Referrer-Policy, HSTS; opt-in CSP (helmet-equivalent) |
zenzip.rateLimit(opts) | HTTP rate limit: fixed-window per key (default client IP) → 429 + X-RateLimit-* headers |
zenzip.csrf(opts) | CSRF protection: origin/referer check on state-changing methods (pairs with SameSite cookies); allowedOrigins or same-origin → 403 otherwise |
req / res augmentation#
req and res are thin augmentations of the node:http objects — so most of the Express middleware ecosystem is reusable.
Request
| Property | What it is |
|---|---|
req.params | path parameters from :name segments |
req.query | parsed query (repeated keys → arrays) |
req.body | parsed body (read once, shared with middleware) |
req.path | pathname without the query string |
req.app | the owning app — trigger/push/emit from a handler |
req.get(name) | case-insensitive header lookup |
Response
| Method | What it does |
|---|---|
res.status(code) | set status (chainable) |
res.json(data) | send a JSON body |
res.send(data) | object → JSON, string/Buffer → as-is |
res.sendStatus(code) | status + its reason phrase as body |
res.set(field, value) | set a header (or a map) |
res.redirect([status,] url) | redirect (default 302) |
res.locals | per-request scratch space |
Mounting & adapters#
Run ZenZip standalone with app.listen(), or hand its routes to any framework. Two handler shapes cover the field — both run the exact same routing + middleware path:
await app.start();
// Any raw (req, res) framework — Express, Connect, Fastify, plain node:http.
http.createServer(app.toNodeHandler()).listen(3000);
fastify.all("/*", (req, reply) => app.toNodeHandler()(req.raw, reply.raw));
// Web Fetch (Request → Response) — Next.js, Hono, Bun, Deno, edge.
const handler = app.toFetchHandler();
// Next.js App Router route handler:
export const POST = handler;
// Hono:
hono.all("*", (c) => handler(c.req.raw));app.toNodeHandler()— a raw(req, res)handler for Express, Connect, Fastify (viareq.raw/reply.raw), orhttp.createServer.app.toFetchHandler()— a WebRequest → Responsehandler for Next.js route handlers, Hono, Bun, Deno, and edge runtimes. (makeFetchHandler(router)is the lower-level form over a bare router.)
Either way, durability is one call away
However you mount it, handlers run in the same process as the runtime, so queue.push() / workflow.trigger() / app.emit() are local calls — not network hops.
Scope & positioning#
The Express layer is pure DX sugar over the node:http adapter — it never leaks into the engine or changes durability semantics. HTTP stays a Node adapter; per the benchmark verdict, ZenZip does not compete with Fastify/Hono on raw HTTP throughput. The point is that a small app needs nothing else, and a developer who knows Express is productive in minutes. First-class Fastify / Hono / Next.js adapters are tracked on the roadmap.