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)
context Record<string, unknown> initial context for the client
original Request original WHATWG Request object
url URL original request URL
forward (to: string) => Promise<Response> forward the request

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.

TypeScript Goroutes/identify.tsroutes/identify.go
import pema from "pema";
import string from "pema/string";
import route from "primate/route";

route.post(request => {
  const { name } = request.body.json(pema({ name: 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 fields()
multipart/form-data fields() — 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" | "fields" | "binary" | "none";

  text(): string;

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

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

  binary(): Blob;

  none(): null;
}

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 pema from "pema";
import string from "pema/string";
import route from "primate/route";

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

route.get(request => {
  const { id } = request.path.parse(Path); // 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 pema from "pema";
import int from "pema/int";
import string from "pema/string";
import route from "primate/route";

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

route.get(request => {
  const { page, search } = request.query.parse(Query);
  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 pema from "pema";
import string from "pema/string";
import Status from "primate/response/Status";
import route from "primate/route";

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

route.get(request => {
  const headers = request.headers.parse(Header);
  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 response from "primate/response";
import Status from "primate/response/Status";
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 pema from "pema";
import string from "pema/string";
import response from "primate/response";
import Status from "primate/response/Status";
import route from "primate/route";

const Cookie = pema({
  session: string.uuid().optional(),
});

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

  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>;
}

Context

Initial context for the client. This is a plain dictionary available during route execution and intended for values you want to expose to the client on initial load.

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

// Add data to the initial client context
route.get(request => {
  request.context.greeting = "Welcome!";
  request.context.env = "production";

  return response.view("Hello.jsx");
});

Use context as a small, serializable key–value store for data you want on initial render.

The client sees the context under props.request.context. To avoid polluting the props object, this API may change in the future.

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