Middleware
Code that runs before the handler on every matching request.
A middleware is a function that runs before the route handler, once per request. It receives the request event, the same object useEvent returns. With it, a middleware can authenticate, set headers, share data with the handler, or answer the request instead of the handler.
import { defineMiddleware } from 'ohnejs';
export default defineMiddleware((event) => {
event.response.headers.set('x-request-id', crypto.randomUUID());
});The rules are simple:
- Return nothing, and the request continues to the next middleware, then the handler.
- Return any value, and it becomes the response, serialized exactly like a handler's return. The handler never runs.
Throw or return an HTTPError, and the response uses its status.
Global middleware
Middleware lives in each layer's dirs.middleware directory, middleware/ by default. Where a file sits decides when it runs:
- Under
global/, it runs on every request a route answers, before any opt-in middleware. A path no route matches answers404or405before any middleware. - Anywhere else, it is opt-in, so it runs only on the routes that select it.
Globals from every layer run together in name order, so a numeric prefix orders them: global/10-request-id.ts runs before global/20-locale.ts. A digit sorts before any letter, so prefixed globals run before unprefixed ones.
A global that should only act on part of the app limits itself with matchPath, which tests the current request's path against one or more patterns:
import { defineMiddleware, matchPath, unauthorized } from 'ohnejs';
export default defineMiddleware(async (event) => {
if (!matchPath('/admin/**')) return;
const session = await readSession(event.request);
if (!session) return unauthorized();
event.context.auth = session;
});- A pattern with a
[param]matches like a route:/authors/[id]. - Anything else is a glob:
*matches one segment, and**any depth. - A glob also matches the path with one trailing
/dropped, as a route does, so/admincovers/admin/. matchPathsees the path the router matches, so/%61dmin/xis/admin/x.matchesPath(path, ...patterns)is the same test, for when you have a path instead of a request.
The example stores the session on event.context, the per-request object that a middleware fills and a handler reads. The event shows how to type it by augmenting EventContext.
Route middleware
Every middleware outside global/ has a name and does nothing until a route selects it through defineHandler's middleware option:
import { defineMiddleware } from 'ohnejs';
export default defineMiddleware((event) => {
console.info(`${event.request.method} ${event.url.pathname} from ${event.ip}`);
});import { defineHandler } from 'ohnejs';
export default defineHandler(() => search(), { middleware: ['audit-log'] });The route runs every global middleware, then audit-log, then the handler.
- An array lists the named middleware to run, in that order. Duplicates and unknown names are dropped.
- The globals always run. The option adds on top of them and cannot disable them.
The option also takes a function. It receives every named middleware in the app and returns the ones to run:
import { defineHandler } from 'ohnejs';
export default defineHandler(() => report(), {
middleware: (available) => available.filter((name) => name !== 'audit-log'),
});The selection is computed once per route, not on every request.
Names and layers
The file's path under middleware/ names it, in kebab-case:
audit-log.tsisaudit-log.shop/audit.tsisshop-audit.global/auth.tsisglobal-auth, since theglobal/prefix is part of the name.
A _-prefixed file or directory is a helper and is ignored. It is an error when two files in one layer resolve to the same name.
Layers merge by name: when two layers define the same name, the closer layer's file wins. So an app replaces a layer's middleware by shadowing it with a file at the same path. The tier must match, so a name cannot be global in one layer and opt-in in another.
Per-request control
The middleware:resolve hook filters or reorders the resolved list just before it runs. The list has the globals first, then the route's selection. Use the hook only for dynamic, per-request decisions. Normal selection belongs on the route.
import { hook, matchesPath } from 'ohnejs';
hook('middleware:resolve', (names, event) =>
matchesPath(event.url.pathname, '/public/**')
? names.filter((name) => name !== 'global-session')
: names,
);CORS
The ohnejs/base layer ships middleware/global/cors.ts. The dashboard's origin may make requests with credentials, and every other origin gets no CORS headers. The dashboard's origin is the first of these that is set:
- The
DASHBOARD_URLenv var. dashboard.originin config.dashboard.hostondashboard.port, where an unset or wildcard host meanslocalhost.
Without the ohnejs/base layer, every response carries Access-Control-Allow-Origin: * without credentials, so cookies stay safe.
To pick the origins yourself, shadow that file and keep the dashboard's origin in the list, here https://admin.example.com:
import { cors } from 'ohnejs';
export default cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
credentials: true,
});Keeping the dashboard's origin and credentials: true is what lets the dashboard write with its session cookie. Any other origin you list with credentials may write with the cookie too.
Only a file that resolves to the same name, global-cors, shadows the shipped policy. Any other name, such as global/10-cors.ts, adds a second global middleware, and both policies run.
Adding cors() replaces the open default. Its options:
origin- a single origin, a list, or'*'. Origins match exactly: scheme, host, and port. There is no reflection mode, so an origin you did not list gets no CORS headers, and the browser blocks the read.credentials- sendAccess-Control-Allow-Credentials, so cookies and HTTP auth work across origins. Combining it withorigin: '*'throws, since the browser does not allow credentials with a wildcard.methods- preflight allow-list. Defaults toGET,HEAD,PUT,PATCH,POST,DELETE.allowHeaders- request headers to allow. When omitted, the preflight allows the headers the browser asked for. A list must include the ones the dashboard sends:content-type, andupload-offsetfor resumable uploads.exposeHeaders- response headers that scripts may read, in addition to the safelisted ones.maxAge- seconds the browser may cache a preflight, so later requests to the same URL skip it. Defaults to7200, two hours.0turns the cache off.
It answers a preflight OPTIONS itself, with 204 and the allow headers, so you need no .options route.
An auth middleware that rejects anonymous requests must sort after cors. A preflight carries no credentials, and rejecting it blocks the real request that follows it. Globals from every layer share one name order: global/session.ts above runs after cors, while global/auth.ts and global/20-auth.ts run before it.
A global that reads the session must sort after cors too. The session check reads the CORS headers to trust the dashboard's origin, and without them the dashboard's writes get a 403.
CORS controls what a browser will read. With credentials: true it also names the cross-origin pages that may send the session cookie on a write. It never replaces authorization. Authorization is handled separately, and deployment covers the production setup.