stoneware

Documentation

Middleware and APIs

One file that runs on every request, and what changed for API routes.

Add routes/_middleware.ts and it runs on every request. Return a Response to answer there and stop; return nothing to carry on to the route.

routes/_middleware.tstsx
import type { MiddlewareContext } from "stoneware";

export default function middleware({ request, url, locals }: MiddlewareContext) {
  if (url.pathname.startsWith("/admin") && !isAuthed(request)) {
    return new Response(null, { status: 302, headers: { Location: "/login" } });
  }

  locals.user = getUser(request);
}

Whatever middleware puts on locals reaches the page or API route that handles the request. It is typed by declaration merging, so the framework never has to guess your shape.

anywhere in your projecttsx
declare module "stoneware" {
  interface Locals {
    user?: { id: string; name: string };
  }
}

Where it runs, and why that is the whole design

  static assets
       │
       ▼
  CSRF verification      ← always first
       │
       ▼
  _middleware.ts         ← after verification, before matching
       │
       ▼
  route match  ──► 404
       │
       ▼
  page or API handler
       │
       ▼
  security headers       ← always last, single exit
the request pipeline

After CSRF, never before. Middleware is ordinary project code, and code that ran ahead of verification could act on a request that was about to be rejected — which is how a framework acquires a documented way around its own protection.

Before route matching, so it also sees requests that are about to 404. A redirect rule for a page you deleted is worth nothing if it only fires for paths that still resolve.

There is deliberately no next() and no way to wrap the finished response. Security headers are applied at one exit; middleware that could rewrite the response could remove them.

JSON errors

An API client that hits a failing route used to receive the HTML error page — a fetch() would resolve with <!DOCTYPE html> in the body and nothing usable in it. Errors are now negotiated.

same route, two callerstxt
fetch("/api/thing")            →  { "error": "Not Found", "status": 404 }
browser navigates to it       →  the _404 page, as before

Decided from the Accept header rather than from the path, because a route under routes/api/ that someone navigates to in a browser is still a navigation. In development the response carries detail and stack; production sends neither.

CORS

Off unless you configure it. An API that only your own pages call never needed it, and enabling it by default would quietly make every internal endpoint callable from anywhere.

stoneware.config.tstsx
export default defineConfig({
  cors: {
    origin: ["https://app.example.com"],
    credentials: true,
  },
});
  • An allowed origin is echoed back rather than answered with *, and the response gets Vary: Origin — without it a shared cache can hand one origin's response to another.
  • Preflights are answered before CSRF, because a browser sends OPTIONS with no body and no token by design and will not send the real request until it succeeds.
  • origin: "*" together with credentials: true throws at startup. Browsers reject that pairing outright, so failing at boot names the problem instead of leaving an unexplained console error.
A cross-origin POST still needs its CSRF token. CORS decides who may read a response; it does not decide who may act. That is the assumption most often got wrong, so it is asserted by a test rather than left to a sentence in a document.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.