Primate Logo Primate

Requests

Route handlers receive a single RequestFacade object that bundles everything you need to handle an incoming HTTP request: the parsed body, path parameters, query parameters, cookies, headers, the original WHATWG Request, and a URL helper. This page walks through each part and shows how to access and validate it.

Property Type Description
body RequestBody parsed request body
path RequestBag path parameters
query RequestBag query parameters
headers RequestBag request headers
cookies RequestBag request cookies (case-sensitive)
original Request original WHATWG Request object
url URL original request URL
forward (to: string) => Promise<Response> forward the request

Request context

RequestFacade includes a request context store: request-scoped values that hooks can attach for downstream hooks and route handlers to read.

Use request context for derived server values (e.g. authenticated user, locale, feature flags, request IDs). For client input, use body, path, query, headers, or cookies.

Context is most commonly set in hooks.

API

Method Description
request.set(key, value) Set a context value
request.set<T>(key, fn) Update a context value with a function
request.get<T>(key) Get a context value (throws if missing)
request.try<T>(key) Get a context value or undefined
request.has(key) Check if a context key exists
request.delete(key) Remove a context key

Context is not part of the underlying WHATWG Request. request.forward() forwards the original request, not your attached context.

Golden path: authenticate in a hook, use in a route

// routes/+hook.ts
import hook from "primate/route/hook";

hook(async (request, next) => {
  const user = await authenticate(request);
  return next(request.set("auth.user", user));
});

## Body

The parsed request body. Primate decodes the body according to the
`Content-Type` header sent with the request, and `request.body` offers different
methods to retrieve the body. `request.body` enforces the correct accessor for
the incoming `Content-Type` (e.g., `json()` for `application/json`) and throws
on a mismatch.

<div class="tabbed"><span class="captionline">
    <span class="captions"><span class='active'> TypeScript</span><span> Go</span></span><span class="filenames"><span class='active'>routes/identify.ts</span><span>routes/identify.go</span></span></span><span class="tabs"><div>

```ts
import p from "pema";
import route from "primate/route";

route.post(request => {
  const { name } = request.body.json(p({ name: p.string.min(1) }));

  return `Hello, ${name}`;
});

If a client sends a POST request to /identify using the content type application/json and {"name": "John"} as payload, this route responds with 200 OK and the body Hello, John.

Content type Method
text/plain text()
application/json json()
application/x-www-form-urlencoded form()
multipart/form-data form() — values are FormDataEntryValue (string or File)
application/octet-stream binary()
no body none()

RequestBody reference

import type JSONValue from "@rcompat/type/JSONValue";

interface RequestBody {
  type: "text" | "json" | "form" | "binary" | "none";

  text(): string;

  json(): JSONValue;
  json<S>(schema: { parse(x: unknown): S }): S;

  form(): Record<string, FormDataEntryValue>;
  form<S>(schema: { parse(x: unknown): S }): S;

  binary(): Blob;

  none(): null;

  files(): Record<string, File>;
}

Path

Path parameters extracted from bracketed segments in your route path (e.g. routes/users/[id], routes/blog/[year]/[slug]). Path parameters are exposed as a RequestBag, with get(), try(), has(), and schema-validated as().

TypeScriptroutes/user/[id].ts
import route from "primate/route";

route.get(request => {
  // throws if missing
  const id = request.path.get("id"); // "42" for /users/42
  return `User #${id}`;
});

If a parameter is optional in your route, prefer try() and handle undefined.

TypeScriptroutes/blog/[year]/[[slug]].ts
import route from "primate/route";

route.get(request => {
  const year = request.path.get("year");
  const slug = request.path.try("slug"); // string | undefined
  return slug ? `Post ${slug} from ${year}` : `All posts in ${year}`;
});

We're using [[slug]] to denote an optional path segment.

You can validate/coerce all parameters at once with a schema.

TypeScriptroutes/user/[id].ts
import p from "pema";
import route from "primate/route";

const PathSchema = p({ id: p.string.regex(/^\d+$/) });

route.get(request => {
  const { id } = request.path.parse(PathSchema); // id: string (digits only)
  return `User #${id}`;
});

Query

The query string is split into individual parameters and exposed as a RequestBag. Use it to read ?page=2&search=john-style parameters. Query string parameters are matched case-insensitively. If a query parameter appears multiple times, Primate keeps only the last.

TypeScript/search?page=2&search=john
import route from "primate/route";

route.get(request => {
  const page = Number(request.query.try("page") ?? "1"); // 1 if missing
  const term = request.query.get("search");
  return `Searching '${term}' (page ${page})`;
});

Schema-validate the entire query string:

import p from "pema";
import route from "primate/route";

const QuerySchema = p({
  page: p.int.coerce.min(1).default(1),
  search: p.string.min(1),
});

route.get(request => {
  const { page, search } = request.query.parse(QuerySchema);
  return `Searching '${search}' (page ${page})`;
});

request.query normalizes keys, request.url.searchParams does not.

Headers

Request headers as a RequestBag. Header keys are matched case-insensitively. If a header appears multiple times, the last value is kept.

import route from "primate/route";

route.get(request => {
  const ua = request.headers.try("user-agent"); // may be undefined
  const contentType = request.headers.try("content-type");

  // returned as JSON
  return { ua, contentType };
});

Validate or transform headers with a schema.

import p from "pema";
import Status from "primate/http/Status";
import route from "primate/route";

const HeadersSchema = p({
  "content-type": p.string.optional(),
  authorization: p.string.startsWith("Bearer ").optional(),
});

route.get(request => {
  const headers = request.headers.parse(HeadersSchema);
  const token = headers.authorization?.slice("Bearer ".length);
  const status = token ? Status.NO_CONTENT : Status.UNAUTHORIZED;

  return new Response(null, { status });
});

Cookies

Request cookies as a RequestBag. Cookie names are case-sensitive (unlike other request bags). If a cookie repeats, Primate keeps the last value.

import Status from "primate/http/Status";
import response from "primate/response";
import route from "primate/route";

route.get(request => {
  const session = request.cookies.try("session"); // string | undefined
  if (!session) {
    return response.error({
      body: "Unauthorized",
      status: Status.UNAUTHORIZED,
    });
  }

  return `Hello (session ${session.slice(0, 8)}...)`;
});

As with other bags, you can parse/validate the whole set.

import p from "pema";
import Status from "primate/http/Status";
import response from "primate/response";
import route from "primate/route";

const CookieSchema = p({
  session: p.string.uuid().optional(),
});

route.get(request => {
  const { session } = request.cookies.parse(CookieSchema);

  return response.text(session ? "OK" : "No session", {
    status: session ? Status.OK : Status.FORBIDDEN,
  });
});

RequestBag reference

interface RequestBag {
  get(key: string): string;
  try(key: string): string | undefined;
  has(key: string): boolean;
  as<T>(schema: { parse(x: unknown): T }): T;
  toJSON(): Record<string, string>;
}

Original

The original WHATWG Request object as received from the runtime. Use this for low-level capabilities like clone(), signal, or direct header access when needed.

import route from "primate/route";

route.get(request => {
  // Abort handling if the client disconnects
  request.original.signal.addEventListener("abort", () => {
    console.log("client disconnected");
  });

  // Access a raw header
  const lang = request.original.headers.get("Accept-Language");
  return new Response(lang ?? "en-US");
});

URL

A URL instance representing the request URL. Handy for composing absolute or relative URLs, accessing the searchParams directly, etc.

import route from "primate/route";

route.get(request => {
  const url = request.url;

  // prefer request.query over url.searchParams
  const page = url.searchParams.get("page") ?? "1";

  // build a new URL relative to the request
  const cdn = new URL("/assets/logo.svg", url);

  // returned as JSON
  return { page, cdn: cdn.href };
});

Forward

Forwards the original WHATWG Request. The method lives on RequestFacade, but what's forwarded is the underlying original request.

If your handler will pass the request upstream and you don't need to read the body, disable body parsing on the route. Primate can't know whether you'll call request.forward() inside the handler, so you must explicitly opt out of body parsing on the route.

Most other aspects of the original request are preserved. By default only the Content-Type header is forwarded to match the body — you can specify additional headers to forward.

import route from "primate/route";

route.get(request => {
  return request.forward("https://upstream.internal/service", {
    Authorization: request.headers.try("authorization") ?? "",
    Accept: request.headers.try("accept") ?? "",
  });
}, { parseBody: false });

Use this to implement simple reverse proxies, edge routing, or fan-out/fan-in patterns.

RequestFacade reference

import type RequestBag from "@primate/core/request/RequestBag";
import type RequestBody from "@primate/core/request/RequestBody";

interface RequestFacade {
  body: RequestBody;
  path: RequestBag;
  query: RequestBag;
  headers: RequestBag;
  cookies: RequestBag;
  context: Record<string, unknown>;
  original: Request;
  url: URL;
  forward(to: string, headers?: Record<string, string>): Promise<Response>;
}
Previous
Routing
Next
Responses