docs / Runtime
Middleware
fn(ctx, next) middleware, the MiddlewareContext, next(rewrite), building responses, and examples for logging, auth, CORS and rewriting.
Middleware runs before a page renders and is shared by every request that maps to the directory it lives in. Drop a `middleware.ts` file into any route directory: `app/middleware.ts` runs for the whole app, `app/blog/middleware.ts` additionally runs for `/blog` paths and anything under them. Directories are executed parent-first in an onion/chain model.
import type { MiddlewareContext } from '@vesk/types';
export async function middleware(
ctx: MiddlewareContext,
next: (rewrite?: string) => Promise<Response>,
) {
const start = Date.now();
const response = await next();
console.log(
`${ctx.request.method} ${ctx.url.pathname} -> ${response.status} (${Date.now() - start}ms)`,
);
return response;
}
The file is plain TypeScript and is loaded as a module, so imports work normally. The loader accepts the middleware as the named export `middleware` or as a `default` export. Middleware must live in a TypeScript file — a `middleware.vsk` is skipped with a structure warning.
MiddlewareContext
The middleware signature is `fn(ctx, next)` — a single context object plus the chain continuation. There is no response object on the context; short-circuiting and headers are done by returning a `Response` (see below).
| Member | Description |
|---|---|
| `request` | The incoming `Request` (a `VeskRequest` in the dev/prod pipeline) — method, headers, URL, body. |
| `params` | Route parameters for the matched path, as `Record<string, string>`. |
| `url` | The parsed `URL` of the request. Updated to the rewrite target when `next(rewrite)` is called. |
| `locals` | The shared per-request store — seeded from server events and visible to later middleware and the page. |
| `cookies` | Request cookies parsed as `Record<string, string>`. |
| `set(key, value)` | Writes a value into `locals`. Key and value types come from `VeskLocals` or from `MiddlewareContext<L>`. |
| `get(key)` | Reads a value from `locals` with the same key-aware typing. |
| `ctx[key]` | The runtime proxy falls back to `locals`, but undeclared TypeScript index properties are `unknown`; prefer `ctx.get('key')` in typed code. |
The context has no `next()`, `setHeader()`, `redirect()` or `rewrite()` methods. `next` is the second parameter of the middleware function; headers, redirects and rewrites are expressed through returned `Response` objects (see next section).
Global typed locals
Vesk applications can declare their request/server locals once by augmenting `VeskLocals` from `@vesk/types`. The default `MiddlewareContext`, `ServerEventContext`, and `locals()` then use those key/value types everywhere. Unaugmented projects keep arbitrary keys as `unknown`, preserving existing behavior.
import type { User } from './types'
declare module '@vesk/types' {
interface VeskLocals {
user: User | null
requestId: string
}
}
import type { MiddlewareContext } from '@vesk/types'
export async function middleware(
ctx: MiddlewareContext,
next: () => Promise<Response>,
) {
ctx.set('requestId', crypto.randomUUID())
ctx.set('user', { id: '1', name: 'Ada' })
const user = ctx.get('user')
// user: User | null
return next()
}
The generic is compile-time only; it does not validate runtime values. For an isolated module, use `MiddlewareContext<MyLocals>` or the per-call form `ctx.set<MyLocals>('user', value)`. A later `ctx.get('user')` is typed from the globally augmented `VeskLocals`, not from the previous call's type argument. `createLocals<T>()` is a separate standalone store, not the ambient request locals object.
ServerEventContext
`app/_events.ts` lifecycle handlers receive a `ServerEventContext`. It has the same typed locals/set/get surface, plus optional `server`, `port`, and `host` fields on persistent Node servers. Values set during `onStart` are server/isolate-wide and are pre-seeded into subsequent request locals.
import type { ServerEventContext } from '@vesk/types'
export async function onStart(ctx: ServerEventContext) {
ctx.set('requestId', 'server-boot-id')
}
export async function onRequest(ctx: ServerEventContext) {
const requestId = ctx.get('requestId')
}
next(), responses and rewriting
`next()` continues the chain into the following middleware (and finally the page render). It returns a `Promise<Response>`, not a guaranteed `VeskResponse`: downstream middleware, SSR, redirects, and native responses may all return a standard `Response`. You can `await next()` and post-process the rendered response, or return it yourself. HTTP headers are plain `Headers`; construct a new `Response` or use `VeskResponse` from `@vesk/runtime/server` when building an enhanced response.
`next(rewrite)` continues the chain but rewrites the request URL internally: the recorded `rewriteUrl` is passed to the renderer and `ctx.url` becomes the rewrite target — the client keeps the original URL while the page for the rewrite renders. This is useful for canonical hosts, locale prefixes or fallbacks.
import type { MiddlewareContext } from '@vesk/types';
export default async function middleware(
ctx: MiddlewareContext,
next: (rewrite?: string) => Promise<Response>,
) {
if (ctx.url.hostname === 'www.example.com') {
const canonical = 'https://example.com' + ctx.url.pathname + ctx.url.search;
return next(canonical);
}
return next();
}
Returning a `Response` — instead of calling `next()` — short-circuits the chain: whatever you return is the final response. Returning `next()` (or `await next()`) passes the rest of the chain through. Creating a response is done with the standard `Response` API or the `VeskResponse` helpers from `@vesk/runtime/server`.
Response headers, cookies and redirects
There is no `ctx.setHeader()`. To set headers or cookies on the final page response, wrap the response you get from `await next()` into a new `Response`, or build answers with `VeskResponse.json().setSecurityHeader(...).setCookie(...)` and return them.
import type { MiddlewareContext } from '@vesk/types';
export async function middleware(ctx: MiddlewareContext, next: () => Promise<Response>) {
const response = await next();
const headers = new Headers(response.headers);
headers.set(
'X-Request-Id',
ctx.request.headers.get('x-request-id') || Math.random().toString(36).slice(2),
);
headers.append('Set-Cookie', 'visited=1; Path=/; HttpOnly');
return new Response(response.body, { status: response.status, headers });
}
To redirect, throw `redirect(url, status)` from `@vesk/runtime/server` — it raises a `Redirect` error that the runner turns into a `Location` response. `Response.redirect(url, status)` and `VeskResponse.redirect(url)` are alternatives.
Examples
Auth: read the session from `ctx.cookies`, store the user in `ctx.locals`, and `redirect()` guests to the login page (this throws, so nothing below it runs).
import type { MiddlewareContext } from '@vesk/types';
import { redirect } from '@vesk/runtime/server';
export async function middleware(ctx: MiddlewareContext, next: () => Promise<Response>) {
const token = ctx.cookies.session;
if (!token) {
redirect('/login'); // throws -> 302 Location: /login
}
ctx.set('user', { name: 'Ada', role: 'admin' });
return next();
}
The page reads that user back through `locals()` from `@vesk/runtime/server`, keeping the pipeline server-side only — it renders once during SSR and never runs on the client, so the account data never crosses the wire.
import { locals } from '@vesk/runtime/server';
component UserBanner() {
const user = locals().user;
<p>Signed in as {user?.name}</p>
}
export default component Page() {
<section>
<UserBanner />
<h1>Dashboard</h1>
</section>
}
CORS: the `cors()` helper from `@vesk/runtime/server` returns a small middleware that answers `OPTIONS` preflights with a 204 and exposes `applyCors(response)` to stamp the CORS headers onto the rendered response.
import type { MiddlewareContext } from '@vesk/types';
import { cors } from '@vesk/runtime/server';
const handle = cors({
origin: 'https://app.example.com',
methods: 'GET, POST, OPTIONS',
allowedHeaders: 'Content-Type, Authorization',
credentials: true,
});
export async function middleware(ctx: MiddlewareContext, next: () => Promise<Response>) {
const preflight = handle(ctx.request);
if (preflight instanceof Response) {
return preflight;
}
const response = await next();
return handle.applyCors(response);
}