ZenZip logozenzip

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()#

server.ts
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 — /api matches /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.

routes.ts
// 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#

errors.ts
// 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.

errors.ts
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#

api.ts
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 with app.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
MiddlewareWhat 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

PropertyWhat it is
req.paramspath parameters from :name segments
req.queryparsed query (repeated keys → arrays)
req.bodyparsed body (read once, shared with middleware)
req.pathpathname without the query string
req.appthe owning app — trigger/push/emit from a handler
req.get(name)case-insensitive header lookup

Response

MethodWhat 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.localsper-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:

mount.ts
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 (via req.raw/reply.raw), or http.createServer.
  • app.toFetchHandler() — a Web Request → Response handler 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.