Middleware
Middleware is a plain function that sits between a request and an agent. Put one in any folder under agents/ and it applies to everything below.
The contract
type Middleware<Env = unknown> = (
c: Context<Env>,
next: () => Promise<Response>,
) => Promise<Response> | Response;
A middleware receives a Context (details below) and a next callable. It returns a Response either
by calling next() (hand off to the next layer) or by
returning directly (short-circuit).
Your first middleware
Create agents/middleware.ts at the root of your agents/ folder:
import type { Middleware } from "ayjnt/middleware";
const middleware: Middleware = async (c, next) => {
const start = Date.now();
console.log(`${c.request.method} ${c.url.pathname}`);
const res = await next();
const ms = Date.now() - start;
const headers = new Headers(res.headers);
headers.set("x-response-time-ms", String(ms));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
};
export default middleware;
This one logs every request and stamps a x-response-time-ms header on every response — including
responses from agents deeper in the tree. The pattern for
“wrap the response after it comes back” is: const res = await next(); return new Response(res.body, ...).
If you write await res.text() or await res.json() on the response from next()
and don't reconstruct a new Response with a fresh body,
you'll send an empty body to the client. Streams can only be
read once.
The pattern in the example — passing res.body through
to a new Response — keeps the body as a stream that the client
eventually consumes.
Short-circuiting
Return a Response without calling next()
and the rest of the chain (plus the agent) is skipped:
import type { Middleware } from "ayjnt/middleware";
const middleware: Middleware = async (c, next) => {
const auth = c.request.headers.get("authorization");
if (auth !== "Bearer letmein") {
return c.text("forbidden", 403);
}
c.set("authenticated", true);
return next();
};
export default middleware;
Requests to anything under agents/admin/ without the
right bearer token get a 403 without the agent ever
running. The outer root-level middleware still gets to wrap the
response on the way out (timing header, etc.) because it's in
the chain above the gate.
The chain, root → leaf
Every middleware.ts from the project root down to the
agent folder contributes one layer. The scanner walks up from the
agent's folder and collects them in order:
Request: /admin/users/alice
Chain:
agents/middleware.ts (timing + logging)
agents/admin/middleware.ts (bearer token check)
AdminUsersAgent.onRequest (the agent itself)
Returning: agent response → admin wraps → root wraps → client
Route-group folders ((public)/, (auth)/)
contribute to the chain but don't appear in the URL, which is
useful for sharing auth across several agents that don't share a
URL prefix. See
File conventions
for the route-group pattern.
The Context object
type Context<Env = unknown> = {
readonly request: Request; // original incoming request
readonly url: URL; // parsed URL
readonly env: Env; // bindings, as typed by Env
readonly executionCtx: ExecutionContext; // waitUntil, passThroughOnException
readonly params: {
instanceId: string; // first path segment after route prefix
pathSuffix: string; // everything after that, always starts "/"
};
json(body: unknown, init?: number | ResponseInit): Response;
text(body: string, init?: number | ResponseInit): Response;
html(body: string, init?: number | ResponseInit): Response;
redirect(location: string, status?: number): Response;
set(key: string, value: unknown): void;
get<T = unknown>(key: string): T | undefined;
};Response helpers
c.json(body), c.text(body), c.html(body), c.redirect(url, status).
The second argument to each accepts either a status number or a
full ResponseInit — matching Hono's signature. For
example:
return c.text("forbidden", 403);
return c.json({ error: "rate limited" }, { status: 429, headers: { "retry-after": "60" } });
return c.redirect("/login");c.set / c.get — per-request stash
Middleware can attach values to the context for downstream middleware to read. Scope is a single request; values don't leak:
// In root middleware — parse auth once
const token = await verifyJwt(c.request.headers.get("authorization"));
c.set("user", token.user);
// In a nested middleware — re-use without re-parsing
const user = c.get<User>("user");
if (user.role !== "admin") return c.text("forbidden", 403); c.set is visible to other middleware in the same
request — not to the agent itself. The agent runs inside a
Durable Object, which is a different execution context; the
per-request stash doesn't survive the DO boundary.
To pass a value from middleware to the agent, either set a request
header before next() or include it in the request
body. Many teams standardize on a set of x-auth-*
headers that root middleware populates.
Typing env
The Middleware type is generic over Env —
the second generic on Agent. Parameterize it for
autocomplete:
import type { Middleware } from "ayjnt/middleware";
import type { GeneratedEnv } from "@ayjnt/env";
type MyEnv = GeneratedEnv & {
KV: KVNamespace;
JWT_SECRET: string;
};
const middleware: Middleware<MyEnv> = async (c, next) => {
const cached = await c.env.KV.get(c.params.instanceId);
// ...
};
Most middleware declares its own Env alias to pick up
the bindings it cares about plus GeneratedEnv for the
DO namespaces.
Common patterns
CORS
const middleware: Middleware = async (c, next) => {
if (c.request.method === "OPTIONS") {
return new Response(null, {
headers: {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE",
"access-control-allow-headers": "content-type, authorization",
},
});
}
const res = await next();
const headers = new Headers(res.headers);
headers.set("access-control-allow-origin", "*");
return new Response(res.body, { status: res.status, headers });
};Rate limiting via KV
const middleware: Middleware<MyEnv> = async (c, next) => {
const ip = c.request.headers.get("cf-connecting-ip") ?? "anon";
const key = `rl:${ip}`;
const hits = Number(await c.env.KV.get(key)) || 0;
if (hits > 100) return c.json({ error: "rate limited" }, 429);
c.executionCtx.waitUntil(c.env.KV.put(key, String(hits + 1), { expirationTtl: 60 }));
return next();
};Request logging with timing
The root-middleware example at the top of this page is already the
canonical form. Add structured fields (user id from c.get, request id, status code) for your observability
backend of choice.
What order things execute in
For a request to /admin/users/bob with a root timing
middleware and an admin auth middleware:
- Root middleware starts: records
start = Date.now(). -
Root calls
next()→ admin middleware starts. - Admin checks bearer → calls
next(). - Agent
onRequestruns, returns a Response. - Admin receives response, returns it unchanged.
- Root receives response, wraps with timing header, returns.
- Client receives the final response.
If admin short-circuits (returns 403 without calling next), the agent doesn't run but root still wraps — the short-circuit response gets the timing header too.
Calling next() twice is a bug
If a single middleware calls await next() more than
once, the framework throws next() called multiple times in a middleware. There's
no useful semantic to it — either you meant to short-circuit
(don't call next) or to wrap (call once, use the response). The
error makes the bug loud.