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

app/components/ParamsPage.vsk
										
											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`
app/components/ParamsPage.vsk
										
											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.

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

app/components/ParamsPage.vsk
										
											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.

app/api/posts/[id]/comments/route.ts
									
										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`.

app/api/flow/route.ts
									
										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.

app/api/hello/route.ts
									
										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.
app/api/request/route.ts
									
										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.

app/api/signup/route.ts
									
										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 });
}
									
								
app/api/secure/route.ts
									
										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.
app/api/session/route.ts
									
										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[]`.
app/api/cors/route.ts
									
										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.
app/api/hooks/route.ts
									
										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.

app/api/hooks/paystack/route.ts
									
										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.

app/lib/hooks.ts
									
										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).
app/_events.ts
									
										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`.