docs / Runtime
Server APIs
Request context (useParams/useRequest/useBody), cookies(), headers(), locals(), VeskRequest / VeskResponse / ServerResponse, CORS, webhooks, hooks, validation, signed cookies and server events.
Vesk exposes server-side APIs for reading the current request, building responses, wiring CORS and webhooks, and hooking the app lifecycle. Everything below lives in `@vesk/runtime/server` and is used in API routes (`app/api/**/route.ts`), middleware, server components and `app/_events.ts`.
The request-context helpers — `useParams`, `useRequest`, `useBody`, `cookies()`, `headers()`, `locals()` — read from an ambient per-request store (`globalThis.__vesk_request`) that the dev server, the adapter and the API-route runner populate before your code runs. Only `useParams` is auto-imported inside component bodies; the other helpers and the response classes must be imported explicitly from `@vesk/runtime/server`.
Request context
While a request is being handled, Vesk keeps a small context object in the ambient store. In API routes it is `{ headers, url, method, cookies, locals, _request, params }`; in SSR renders the adapter seeds a `VeskRequest.from(request, { params, locals })` instance. The helpers below read from whichever object is live.
- `useParams(): Record<string, string>` — route parameters. For `/blog/42/hello` it returns `{ id: '42', slug: 'hello' }`. Auto-imported in component bodies.
- `useRequest(): RequestContext | null` — the live request context, or `null` outside a request. Carries `url`, `method`, `params`, `cookies`, `locals` and the underlying `_request`.
- `useBody(): Promise<unknown>` — the parsed request body: JSON for `content-type: application/json`, an object for `x-www-form-urlencoded`, otherwise text (with a JSON.parse fallback). Cached per request; returns `null` when there is no underlying request and throws if called outside a request context.
- `cookies(): CookieStore` — the current request's cookies.
- `headers(): Record<string, string | Function | undefined>` — the current request's normalized headers.
- `locals(): Record<string, unknown>` — per-request data shared between middleware, handlers and renders (empty object outside a request).
Reading params and headers in a component
import { headers } from '@vesk/runtime/server';
component ParamsPage() {
const params = useParams();
const lang = headers().get('accept-language');
<div class="p-4">
<h1>Item {params.id}</h1>
<p>Preferred language: {lang ?? 'none'}</p>
</div>
}
cookies()
`cookies()` returns the current request's cookie jar. It is a Proxy over `Record<string, string>` that also exposes three helper methods — and any cookie can be read directly by property name (`jar.session`). On the client it falls back to `document.cookie`.
- `get(name)` → `string | undefined`
- `getAll()` → `{ name, value }[]`
- `toString()` → `name=value; name=value`
import { headers } from '@vesk/runtime/server';
component ParamsPage() {
const params = useParams();
const lang = headers().get('accept-language');
<div class="p-4">
<h1>Item {params.id}</h1>
<p>Preferred language: {lang ?? 'none'}</p>
</div>
}
headers()
`headers()` normalizes the request headers to lowercase names and supports both method and property access — `headers().get('accept')` and `headers()['accept']` are equivalent. Values from multi-valued headers are joined with `', '`.
- `get(name)` → `string | null` (case-insensitive)
- `has(name)` → `boolean` (case-insensitive)
- `entries()` → `MapIterator` of `[name, value]` pairs
- Direct reads: `headers().accept`, `headers()['accept-language']`, …
locals()
`locals()` reads the per-request storage that middleware fills with `ctx.set(key, value)`. Values seeded in `onStart` via the server-wide context are also pre-seeded into every request's `locals()`, so boot-time setup is visible to middleware, handlers and renders alike.
import type { MiddlewareContext } from '@vesk/compiler';
export async function middleware(ctx: MiddlewareContext, next: () => Promise<void>) {
ctx.set('user', { id: 1, name: 'Alice' });
const startTime = Date.now();
await next();
console.log('took', Date.now() - startTime, 'ms');
}
import { headers } from '@vesk/runtime/server';
component ParamsPage() {
const params = useParams();
const lang = headers().get('accept-language');
<div class="p-4">
<h1>Item {params.id}</h1>
<p>Preferred language: {lang ?? 'none'}</p>
</div>
}
useRequest() and useBody()
`useRequest()` hands you the raw context; `useBody()` parses the body by `content-type` — `application/json` → object, `x-www-form-urlencoded` → object, anything else → text. Both are for server-side code (components, API routes, actions); `useBody` is cached per request.
import { headers } from '@vesk/runtime/server';
component ParamsPage() {
const params = useParams();
const lang = headers().get('accept-language');
<div class="p-4">
<h1>Item {params.id}</h1>
<p>Preferred language: {lang ?? 'none'}</p>
</div>
}
Full API route handler
API routes are TypeScript files under `app/api` exporting handlers named after HTTP methods. `useParams`, `useBody`, `useRequest`, `cookies()`, `headers()` and `locals()` all work inside them, next to the `VeskResponse` fluent builder.
import {
VeskResponse,
cookies,
headers,
locals,
useBody,
useParams,
useRequest,
} from '@vesk/runtime/server';
export interface Comment {
author: string;
text: string;
}
export async function POST() {
const params = useParams();
const jar = cookies();
const accept = headers().get('accept');
const userId = locals().userId;
const body = (await useBody()) as Comment;
const req = useRequest();
return VeskResponse.json(
{
ok: true,
postId: params.id,
text: body.text,
userId: userId ?? null,
session: jar.get('session') ?? null,
accept,
url: req?.url,
},
{ status: 201 },
).setCookie('draft-saved', '1', { maxAge: 86400 });
}
Building responses
Two response classes ship in `@vesk/runtime/server`: `ServerResponse` covers the routing primitives (redirect, rewrite, next), and `VeskResponse` extends it with a fluent builder for JSON, HTML, cookies, caching and security headers. Both extend the standard `Response`.
import { ServerResponse } from '@vesk/runtime/server';
export async function GET() {
return ServerResponse.redirect('/login', 307);
}
export async function POST() {
// Keep the request going, but render another route
return ServerResponse.rewrite('/api/internal');
}
export async function PUT() {
// No match here — fall through to the next handler
return ServerResponse.next();
}
- `ServerResponse.json(body, init?)` — JSON body + `Content-Type: application/json`.
- `ServerResponse.redirect(url, status = 307)` — `Location` header.
- `ServerResponse.rewrite(url)` — sets `x-vesk-rewrite`; the runtime re-dispatches the request.
- `ServerResponse.next()` — sets `x-vesk-next`; falls through to the next matching handler.
VeskResponse (fluent)
`VeskResponse` is a Proxy over a `ServerResponse` subclass, so it can be called with or without `new`. Every chainable method returns the same instance. Static constructors: `json`, `redirect`, `rewrite`, `next`, `html` and `stream`. The returned instance also has `text()` / `json()` for reading the body and a `status` getter that reflects `setStatus`. `build()` (also `text()` and `json()`) flushes queued security headers and cookies into real header values.
import { VeskRequest, VeskResponse } from '@vesk/runtime/server';
export async function GET(req: VeskRequest) {
return VeskResponse.json({ message: 'Hello from Vesk!' })
.setStatus(201)
.setCookie('session', 'abc123', { httpOnly: true, secure: true, path: '/', maxAge: 3600 })
.setCsp("default-src 'self'")
.cors({ origin: 'https://example.com', methods: 'GET,POST' });
}
export async function POST(req: VeskRequest) {
const body = await req.json();
return VeskResponse.json({ received: body, ok: true }, { status: 201 })
.setCookie('posted', 'true');
}
| Member | Effect |
|---|---|
| `static json(body, init?)` | JSON response with `Content-Type: application/json`. |
| `static redirect(url, status = 307)` | Response with a `Location` header. |
| `static rewrite(url)` | Sets `x-vesk-rewrite` for internal re-dispatch. |
| `static next()` | Sets `x-vesk-next` to fall through to the next handler. |
| `static html(html, init?)` | Response with `Content-Type: text/html; charset=utf-8`. |
| `static stream(readable, init?)` | Chunked response over a `ReadableStream` body (SSE, file streams). |
| `setStatus(code)` | Final status; the `status` getter returns it even if the initial `ResponseInit` differs. |
| `setCookie(name, value, opts?)` | Queues a `Set-Cookie` header. `HttpOnly` and `Secure` are on by default, `SameSite=Lax`, `Path=/`. Options: `httpOnly`, `secure`, `sameSite` (`'Lax' | 'Strict' | 'None'`), `maxAge`, `path`, `domain`. |
| `clearCookie(name, opts?)` | Queues an expiring (`Max-Age=0`) cookie. Options: `path`, `domain`. |
| `setCsp(policy | false)` | Sets `Content-Security-Policy` with a raw CSP string; `false` removes it. |
| `setSecurityHeader(name, value | false)` | Sets an arbitrary security header; `false` removes it. |
| `cache(ttlSeconds)` | `Cache-Control: public, max-age=<ttl>, s-maxage=<ttl>`. |
| `noCache()` | `no-store, no-cache, must-revalidate, proxy-revalidate` + `Pragma: no-cache` + `Expires: 0`. |
| `cors(opts?)` | Sets `Access-Control-*` headers: `origin`, `methods`, `headers`, `credentials` (credentials default true). |
| `build()` | Flushes security headers and queued cookies into the response headers and returns the instance. |
| `text()` / `json()` | Flush security headers, then read the body as text / JSON. |
| `status` (getter) | The `setStatus` status if set, otherwise the underlying `Response.status`. |
VeskRequest
`VeskRequest` extends `ServerRequest` (which extends the standard `Request` and adds `cookies`, `params` and `locals` stores). It adds request metadata accessors plus security setters. Construct it directly, or wrap an inbound platform `Request` with `VeskRequest.from()` to seed `params`/`locals` for renders.
| Member | What it exposes |
|---|---|
| `url`, `method`, `headers()` | Inherited from `Request`. |
| `query` | `Record<string, string>` of parsed search params (cached). |
| `parsedUrl` | Cached `URL` instance. |
| `host` | Host header honoring `x-forwarded-host` when trust proxy is enabled; falls back to the parsed URL host. |
| `hostname` | Host without the port. |
| `origin` | `protocol://host` — base for resolving relative URLs. |
| `protocol` | `http`/`https`, honoring `x-forwarded-proto` when trust proxy is enabled. |
| `ip` | First `x-forwarded-for` entry / `x-real-ip` when trust proxy is enabled, else `'unknown'`. |
| `body` | Lazy `Promise<unknown>` — parsed JSON, form data, or text (JSON.parse fallback). |
| `cookies` | `CookieStore` with `get`, `getAll`, `toString` and direct reads. |
| `params` / `locals` | Settable `Record<string, string>` / `Record<string, unknown>` stores. |
| `set(key, value)` / `get(key)` | Locals map accessors so `VeskRequest` doubles as the middleware context. |
| `resolveUrl(url)` | Resolves a possibly-relative URL against `origin`. Used by SSR fetches so `/api/...` works during render. |
| `static from(request, { params?, locals? })` | Wraps a platform `Request` (same method + headers, cookies parsed) with seeded params/locals. |
| `setCsp(policy | false)` | Records a CSP override applied to the response via `applyRequestSecurity`. |
| `setCsrf(enable)` | Records the CSRF flag override. |
| `setRateLimit({ windowMs?, max? } | false)` | Records a rate-limit override. |
| `setSecurityHeader(name, value | false)` | Records a custom header override. |
| `setTrustProxy(enable | string)` | Enables trusting `x-forwarded-*` headers for `ip`, `protocol` and `host`. |
| `getSecurityOverrides()` | Returns the recorded `_security` overrides object. |
import { VeskRequest, VeskResponse } from '@vesk/runtime/server';
export async function GET(req: VeskRequest) {
return VeskResponse.json({
url: req.url,
method: req.method,
pathname: req.parsedUrl.pathname,
query: req.query,
host: req.host,
hostname: req.hostname,
origin: req.origin,
protocol: req.protocol,
ip: req.ip,
absolute: req.resolveUrl('/api/self'),
});
}
Security helpers
`withValidation(request, schema, { jsonOnly? })` parses the body (JSON, form data, or text) and runs `schema.safeParse(data)`. On failure it returns a `ServerResponse.json` 400 with `{ error, issues: [{ path, message }] }`; on success it returns `result.data`. `applyRequestSecurity(request, response)` pushes CSP / custom-header overrides recorded on a `VeskRequest` onto the response.
import { withValidation } from '@vesk/runtime/server';
const schema = {
safeParse: (data: unknown) => {
const d = data as { email?: string };
return d.email?.includes('@')
? { success: true as const, data: d }
: { success: false as const, error: { issues: [{ path: ['email'], message: 'Invalid email' }] } };
},
};
export async function POST(request: Request) {
const result = await withValidation(request, schema);
if (result instanceof Response) return result;
return Response.json({ ok: true, email: (result as { email?: string }).email });
}
import { applyRequestSecurity, VeskRequest, VeskResponse } from '@vesk/runtime/server';
export async function GET(req: VeskRequest) {
req.setCsp("default-src 'self'");
req.setSecurityHeader('X-Custom', 'yes');
const res = VeskResponse.json({ ok: true });
applyRequestSecurity(req, res);
return res;
}
// Wrapping a platform Request with seeded params/locals
export async function wrap(platformRequest: Request, db: unknown) {
const vreq = VeskRequest.from(platformRequest, {
params: { id: '42' },
locals: { db },
});
vreq.setTrustProxy(true);
vreq.setCsrf(true);
vreq.setRateLimit({ windowMs: 60_000, max: 100 });
return vreq.getSecurityOverrides();
}
Signed cookies
Vesk signs cookies with HMAC-SHA256 (Web Crypto) using a per-host secret, producing `value.base64url-signature` over the `name=value` payload. Tampered cookies unsign to `null`. All four helpers are async; `signCookie`/`setSignedCookie`/`readSignedCookie`/`unsignCookie` require the compiler package to be present, which the built runtime bundles.
- `signCookie(name, value, host?)` → `Promise<string>` — signed `value.signature`.
- `unsignCookie(name, signedValue, host?)` → `Promise<string | null>` — the value, or `null` when the signature is invalid.
- `setSignedCookie(name, value, options?, host?)` → `Promise<string>` — a ready-to-use `Set-Cookie` string (signed value + `HttpOnly`, `Secure`, `SameSite`, `Path`, `Max-Age`, `Domain`).
- `readSignedCookie(name, cookieString, host?)` → `Promise<string | null>` — parses a `Cookie`/`Set-Cookie` string and unsigns one entry.
import {
readSignedCookie,
setSignedCookie,
signCookie,
unsignCookie,
} from '@vesk/runtime/server';
export async function GET() {
const signed = await signCookie('session', 'user-42', 'example.com');
const value = await unsignCookie('session', signed, 'example.com');
return Response.json({ signed, value });
}
export async function POST(request: Request) {
const cookie = await setSignedCookie('token', 'abc', { httpOnly: true, maxAge: 3600 }, 'example.com');
const token = await readSignedCookie('token', request.headers.get('cookie') ?? '', 'example.com');
return Response.json({ cookie, token });
}
CORS
`cors(options)` returns a middleware function you can call from an `OPTIONS` handler and use to decorate normal responses: an `OPTIONS` request gets a `204` with the `Access-Control-*` headers; any other request sets those headers as `_pending` so `applyCors(response)` can stamp them onto the final response.
- `origin` — default `'*'`.
- `methods` — default `'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS'`.
- `allowedHeaders` — default `'Content-Type, Authorization'`.
- `credentials` — default `true` (adds `Access-Control-Allow-Credentials: true`).
- `maxAge` — default `86400`.
- `exposeHeaders` — optional `string[]`.
import { cors, VeskResponse } from '@vesk/runtime/server';
const api = cors({
origin: 'https://app.example.com',
methods: 'GET,POST,PUT,DELETE,OPTIONS',
credentials: true,
});
export async function OPTIONS(request: Request) {
return api(request);
}
export async function GET(request: Request) {
const res = VeskResponse.json({ ok: true });
return api.applyCors(res);
}
Webhooks
`webhook<T>({ secret, handler, headerName?, signaturePrefix?, algorithm?, signedPayload? })` returns a POST-only request handler that verifies an HMAC signature before running your `handler(event, request)`. The signature is compared in constant time (length then char-by-char, with no early return). A missing or invalid signature responds `401` with `{ error }`. Requires Web Crypto, and `handler` must return a `Response`.
- `secret` — required; used as the HMAC key.
- `handler(event, request)` — `event` is the parsed JSON body (raw body text when it isn't JSON).
- `headerName` — default `'x-webhook-signature'`.
- `signaturePrefix` — default `'sha256='`; pass `''` for unprefixed hex.
- `algorithm` — default `'SHA-256'`; pass `'SHA-512'` for Paystack.
- `signedPayload(event)` — sign something other than the raw body. Receives the parsed event and returns the string to HMAC. Needed for providers that sign a field subset; a throw is treated as an invalid signature (`401`), not a `500`, and the handler does not run. It runs *before* verification — the payload must be derived before it can be checked — so it is handed unauthenticated data: keep it pure.
- The type parameter `T` defaults to `unknown`. Declare `webhook<YourEvent>({ ... })` and both `handler` and `signedPayload` receive `YourEvent` — that declaration is your assertion that the body has the shape you expect, and it is not checked against the incoming JSON.
import { webhook } from '@vesk/runtime/server';
export const POST = webhook({
secret: process.env.WEBHOOK_SECRET!,
headerName: 'x-webhook-signature',
signaturePrefix: 'sha256=',
handler(event, request) {
return Response.json({ received: event });
},
});
Not every provider signs the raw body with SHA-256. Paystack signs `${amount}${reference}` — the kobo amount followed by the transaction reference — with HMAC-SHA512 and sends unprefixed hex in `x-paystack-signature`, so an algorithm override alone would still reject every genuine event. Supply `signedPayload` to sign the same canonical string Paystack signs.
import { webhook } from '@vesk/runtime/server';
type PaystackEvent = { event: string; data: { amount: number; reference: string } };
export const POST = webhook<PaystackEvent>({
secret: process.env.PAYSTACK_SECRET_KEY!,
headerName: 'x-paystack-signature',
signaturePrefix: '',
algorithm: 'SHA-512',
signedPayload: (event) => `${event.data.amount}${event.data.reference}`,
handler(event, request) {
return Response.json({ received: event });
},
});
Hooks (defineHook / removeHook / runHooks)
`defineHook(name, fn)` registers a hook in a process-wide registry; `removeHook(name, fn)` unregisters it; `runHooks(name, ...args)` runs the registered hooks serially and short-circuits with the first `Response` a hook returns (otherwise `undefined`). The API-route runner already invokes live `beforeRequest` / `afterRequest` / `onError` hooks — the primitives are also useful for your own middleware-style logic.
import { defineHook, removeHook, runHooks } from '@vesk/runtime/server';
interface HookCtx {
params: Record<string, string>;
locals: Record<string, unknown>;
}
export function protect(roles: string[]) {
const fn = async (_request: Request, ctx: HookCtx) => {
const user = ctx.locals.user as { role?: string } | undefined;
if (!user || !roles.includes(user.role ?? '')) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
};
defineHook('beforeRequest', fn);
return () => removeHook('beforeRequest', fn);
}
// Register once at boot
const dispose = protect(['admin']);
// runHooks short-circuits: the first hook that returns a Response wins
export async function GET() {
const blocked = await runHooks('beforeRequest', new Request('/api/secure'), {
params: {},
locals: { user: { id: 1, role: 'viewer' } },
});
if (blocked) return blocked;
dispose();
return Response.json({ ok: true });
}
Server events (app/_events.ts)
`app/_events.ts` (or `.js`) is the private convention file for lifecycle handlers. The `_` prefix means it is never routed. Values `set()` in `onStart` land in the server-wide context (`serverLocals()` / `getServerContext`) and are pre-seeded into every request's `locals()`.
- `onStart(ctx)` — once at server boot (lazily once per isolate on serverless/edge targets).
- `onRequest(ctx)` — every app request, with a request-scoped context.
- `onStop(ctx)` — graceful shutdown on Node servers (also dev HMR reload).
import type { ServerEventContext } from '@vesk/types';
declare function createDb(): Promise<{ close(): Promise<void> }>;
export async function onStart(ctx: ServerEventContext) {
await ctx.set('db', await createDb());
ctx.set('bootedAt', Date.now());
}
export async function onRequest(ctx: ServerEventContext) {
const hits = (ctx.get('hits') as number) || 0;
ctx.set('hits', hits + 1);
}
export async function onStop(ctx: ServerEventContext) {
const db = ctx.get('db') as { close(): Promise<void> } | undefined;
await db?.close();
}
`ctx.set`/`ctx.get` mirror the explicit server-wide context functions from `@vesk/runtime/server`:
- `serverLocals(): Record<string, unknown>` — the process/isolate-wide store shared across every request.
- `getServerContext(key)` — read one value (`undefined` when unset).
- `setServerContext(key, value)` — write one value, visible to every subsequent request.
- `clearServerContext()` — wipe the entire store.
| Field | Type | Meaning |
|---|---|---|
| `server` | `unknown` | The running Node `http.Server`; `null` on serverless/edge targets and at build time. |
| `port` | `number` | The port the server listens on (`0` when no persistent server exists). |
| `host` | `string` | The host address the server binds. |
| `request` | `Request` | The current `Request` (present in `onRequest`). |
| `params` | `Record<string, string>` | Current route params. |
| `url` | `URL` | Parsed request URL. |
| `locals` | `Record<string, unknown>` | Per-request locals. |
| `cookies` | `Record<string, string>` | The request's cookies. |
| `serverLocals` | `Record<string, unknown>` | The process-wide store shared across every request. |
| `set(key, value)` | method | Writes to `serverLocals` (visible after the current request). |
| `get(key)` | method | Reads from `serverLocals`. |
`useBody()` throws when called outside a request context, and `useRequest()` returns `null` there. `getServerContext(key)` returns `undefined` for unset keys. The signed-cookie helpers throw if `@vesk/compiler` isn't available. The webhook handler you pass must return a `Response`.