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

RequestFacade also supports a request context store for sharing derived values between hooks and route handlers (see Request context).

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

export default route({
  async post(request) {
    const { name } = p({ name: p.string.min(1) }).parse(await request.body.json());

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

export default 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";

export default 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 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+$/) });

export default route({
  get(request) {
    const { id } = PathSchema.parse(request.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";

export default 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.loose.min(1).default(1),
  search: p.string.min(1),
});

export default route({
  get(request) {
    const { page, search } = QuerySchema.parse(request.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";

export default 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 http from "@rcompat/http";
import p from "pema";
import route from "primate/route";

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

export default route({
  get(request) {
    const headers = HeadersSchema.parse(request.headers);
    const token = headers.authorization?.slice("Bearer ".length);
    const status = token ? http.Status.NO_CONTENT : http.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 http from "@rcompat/http";
import response from "primate/response";
import route from "primate/route";

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

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

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

import http from "@rcompat/http";
import p from "pema";
import response from "primate/response";
import route from "primate/route";

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

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

    return response.text(session ? "OK" : "No session", {
      status: session ? http.Status.OK : http.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";

export default 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";

export default 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";

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

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

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.

You can use hooks to authenticate in a hook, and then use in a route.

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

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

RequestFacade reference

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

type Dict<V = unknown> = Record<string, V>;

interface RequestView {
  context: Dict;
  cookies: Dict<string>;
  headers: Dict<string>;
  path: Dict<string>;
  query: Dict<string>;
  url: URL;
}

interface RequestFacade {
  body: RequestBody;
  path: RequestBag;
  query: RequestBag;
  headers: RequestBag;
  cookies: RequestBag;
  original: Request;
  target: string; // pathname + querystring
  url: URL;
  forward(to: string, headers?: Dict): Promise<Response>;

  has(key: string): boolean;
  try<T>(key: string): T | undefined;
  get<T>(key: string): T;
  set<T>(key: string, value: T | ((prev: T | undefined) => T)): RequestFacade;
  delete(key: string): RequestFacade;

  toJSON(): RequestView;
}
Previous
Routing
Next
Responses