ayjnt
All examples
middlewareauthgroups

Multilayer middleware

Root → leaf file-based middleware chain. One `middleware.ts` adds logging + timing headers to every response; a nested one gates a subtree with bearer-token auth. Route groups let you share middleware without nesting URLs.

What you'll learn
  • How nested `middleware.ts` files compose root → leaf
  • Short-circuiting with 403 vs wrapping the response
  • Route groups — folders in parens, stripped from URL, still contribute to the chain
  • `c.set` / `c.get` for per-request stash that flows between middleware
01 step

Start from the blank scaffold

Every ayjnt example starts here. `bunx ayjnt new` drops a one-agent project with a single `alive` agent that responds "I'm alive" to any request — enough to prove the pipeline works before you replace it with the real thing.

~/my-agent-app
my-app/ (blank scaffold)
agent.ts
package.json
tsconfig.json
.gitignore
README.md
02 step

Build the folder shape

The file tree is the middleware chain. Every `middleware.ts` from the project root down to the agent folder gets collected and run in order. Route groups (parens) contribute to the chain but don't appear in the URL.

~/my-agent-app
my-app/
middleware.tsroot: log + timing
agent.ts
middleware.tsauth gate
agent.ts
03 step

Root middleware — wrap every response

Runs for every request. After `await next()`, you can mutate the response — here we tack on `x-response-time-ms`. The trick: pass `res.body` through as a stream; reading it (with `await res.text()`) would consume it and leave the client with an empty body.

agents/middleware.ts ts
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 elapsed = Date.now() - start;

  const headers = new Headers(res.headers);
  headers.set("x-response-time-ms", String(elapsed));
  return new Response(res.body, {
    status: res.status,
    statusText: res.statusText,
    headers,
  });
};

export default middleware;
04 step

Nested middleware — guard a subtree

Same contract, but it applies only under `/admin/*`. Short-circuits with 403 if the bearer is wrong. Note that the root middleware still wraps the 403 response — `x-response-time-ms` is present on failures too.

agents/admin/middleware.ts ts
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);   // stash for downstream middleware
  return next();
};

export default middleware;
05 step

The chain, per route

For each incoming request, ayjnt walks from the project root down to the agent folder, collecting every `middleware.ts` it encounters. Route groups like `(public)` strip from the URL but still contribute to the chain.

chain.ts ts
// GET /public/status/42
//   agents/middleware.ts  → agent
//
// GET /admin/users/alice
//   agents/middleware.ts
//     → agents/admin/middleware.ts
//       → agent
//
// POST /admin/users/alice  (no auth)
//   agents/middleware.ts
//     → agents/admin/middleware.ts    → short-circuits with 403
//       (agent not reached, but the root still wraps the 403
//        with x-response-time-ms)
06 step

Run it + hit each route

Public route: no auth, 200. Admin without auth: 403, still includes the timing header (root wraps the short-circuit). Admin with bearer: 200.

~/my-agent-app
07 step

What it looks like

You're looking at the timing header on the 403 — that's the root middleware wrapping the short-circuit. Proves the chain runs as intended.

chain + wrapped responses result
     request
     │
     ▼
┌ agents/middleware.ts (root) ────────────────────────────┐
│  log, start timer                                       │
│  ┌─ agents/admin/middleware.ts ──────────────────────┐  │
│  │  check auth header                                │  │
│  │  ┌─ agents/admin/users/agent.ts ──────────────┐   │  │
│  │  │  return Response.json({ visits, … })        │   │  │
│  │  └─────────────────────────────────────────────┘   │  │
│  │  ↑ returned to admin middleware                    │  │
│  └────────────────────────────────────────────────────┘  │
│  new Response(res.body, …                                │
│    headers.set("x-response-time-ms", elapsed))           │
└──────────────────────────────────────────────────────────┘
     │
     ▼
     response
08 step

Deploy

`ayjnt deploy` checks your git tree is clean + synced with origin, regenerates the wrangler config from scratch, then shells out to `wrangler deploy`. The committed migrations.json file is the source of truth for what's in production.

~/my-agent-app