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.

app/middleware.ts
									
										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.

app/types.d.ts
									
										import type { User } from './types'

declare module '@vesk/types' {
  interface VeskLocals {
    user: User | null
    requestId: string
  }
}
									
								
app/middleware.ts
									
										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.

app/_events.ts
									
										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.

app/middleware.ts
									
										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.

app/middleware.ts
									
										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).

app/dashboard/middleware.ts
									
										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.

app/dashboard/page.vsk
										
											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.

app/middleware.ts
									
										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);
}