Search docs

Jump between documentation pages.

Browse docs

Routing

DaloyJS uses a trie/radix router with a static-route fast path. Static routes resolve via a single Map.get; dynamic routes walk a trie in O(path-segments) regardless of how many routes you have.

Defining routes

A route declaration is the source of truth for matching, request validation, response validation, OpenAPI output, and typed clients. Provide an operationId for every public route you want in the typed client or generated SDK; DaloyJS rejects duplicate operationId values at registration.

ts
import { App } from "@daloyjs/core";
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  tenantId: z.string(),
  included: z.enum(["profile", "settings"]).optional(),
});

async function loadUser(input: {
  id: string;
  tenantId: string;
  include?: "profile" | "settings";
}) {
  return {
    id: input.id,
    email: "dev@example.com",
    tenantId: input.tenantId,
    included: input.include,
  };
}

export const app = new App().route({
  method: "GET",
  path: "/users/:id",
  operationId: "getUser",
  tags: ["Users"],
  summary: "Get a user by id",
  request: {
    params: z.object({ id: z.string().uuid() }),
    query: z
      .object({ include: z.enum(["profile", "settings"]).optional() })
      .optional(),
    headers: z.object({ "x-tenant": z.string() }),
  },
  responses: {
    200: { description: "Found", body: UserSchema },
    404: { description: "Not found" },
  },
  handler: async ({ params, query, headers }) => {
    // params, query, and headers are inferred from the schemas above.
    return {
      status: 200,
      body: await loadUser({
        id: params.id,
        tenantId: headers["x-tenant"],
        include: query?.include,
      }),
    };
  },
});

Type inference and chaining

app.route() returns the same app instance with a widened route tuple type. Chain route registrations when you want createClient(app) to expose strongly typed methods for each operationId. Separate statements still register routes at runtime and in OpenAPI, but TypeScript cannot widen the already-created app variable.

ts
// Best for typed clients: app carries both operationIds in its type.
export const app = new App()
  .route({
    method: "GET",
    path: "/books",
    operationId: "listBooks",
    responses: {
      200: { description: "Books", body: z.array(z.object({ id: z.string() })) },
    },
    handler: async () => ({ status: 200, body: [{ id: "1" }] }),
  })
  .route({
    method: "POST",
    path: "/books",
    operationId: "createBook",
    request: { body: z.object({ title: z.string().min(1) }) },
    responses: {
      201: { description: "Created", body: z.object({ id: z.string() }) },
    },
    handler: async () => ({ status: 201, body: { id: "2" } }),
  });

HTTP methods

Supported methods include GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. Custom methods such as TRACE, CONNECT, and WebDAV verbs are rejected at registration.

HEAD falls back to the matching GET route when no explicit HEAD route exists, returning the same headers with an empty body. OPTIONS returns a 204 preflight with an Allow header when a path exists but no explicit OPTIONS route is registered.

Path parameters

ts
app.route({
  method: "GET",
  path: "/orgs/:org/repos/:repo",
  operationId: "getRepo",
  request: {
    params: z.object({ org: z.string(), repo: z.string() }),
  },
  responses: {
    200: {
      description: "Repository",
      body: z.object({ org: z.string(), repo: z.string() }),
    },
  },
  handler: async ({ params }) => ({ status: 200, body: params }),
});

Path values are decoded before validation. If you omit a request.params schema, ctx.params is inferred from the path as raw strings. Conflicting parameter names at the same trie position, such as /a/:x and /a/:y, throw at registration.

Wildcard captures

A trailing *name segment captures the rest of the path into one decoded string. Wildcards must be terminal.

ts
app.route({
  method: "GET",
  path: "/assets/*path",
  operationId: "getAsset",
  request: { params: z.object({ path: z.string() }) },
  responses: {
    200: { description: "Asset", body: z.object({ path: z.string() }) },
  },
  handler: async ({ params }) => ({ status: 200, body: params }),
});

// GET /assets/css/app.css -> params.path === "css/app.css"

Path traversal segments (..), empty segments //, and malformed percent escapes miss cleanly before your handler sees them.

Groups

ts
app.group("/api/v1", { tags: ["v1"] }, (v1) => {
  v1.route({
    method: "GET",
    path: "/health",
    operationId: "health",
    responses: {
      200: { description: "ok", body: z.object({ ok: z.boolean() }) },
    },
    handler: async () => ({ status: 200, body: { ok: true } }),
  });
});
// final path: /api/v1/health

Groups merge prefixes, tags, hooks, and auth defaults into the routes registered inside the callback. The child app is encapsulated: middleware added inside a group does not leak to routes outside that group. Grouped routes are visible to runtime routing and OpenAPI.

Route options

  • request: schemas for params, query, headers, and body.
  • responses: declared status codes and optional response body/header schemas.
  • accepts: per-route Content-Type allowlist for routes with request body schemas.
  • auth: OpenAPI security requirement for the route; pair it with an auth hook such as bearerAuth().
  • internal: hides a route from public adapters while still allowing in-process app.inject() calls.
  • deprecated and sunset: mark an endpoint as deprecated and emit the matching response headers.
  • callbacks and meta: add OpenAPI callbacks, examples, and AI-friendly route metadata.

Hooks

Hooks attach behavior at fixed lifecycle points:

  • onRequest: earliest, before parsing.
  • beforeHandle: after validation, before your handler. Return a Response to short-circuit.
  • afterHandle: wrap or transform the handler result before response serialization.
  • onError: observe or replace the error response.
  • onSend: mutate outgoing headers in place or return a new Response. Runs on success, error, and OPTIONS preflight paths.
  • onResponse: final observer. Use it for logging and metrics, not response mutation.
Request lifecycle
  1. 01earliestonRequestbefore parsing
  2. 02frameworkvalidateparams · query · body · headers
  3. 03beforeHandlereturn a Response to short-circuit
  4. 04handleryour route logic
  5. 05afterHandlewrap / transform the result
  6. 06onSendmutate or replace the Response
  7. 07alwaysonResponseobservability only
Hooks fire at fixed points around your handler. Validation runs before beforeHandle, so an invalid request never reaches your code. If anything throws, control jumps to onError, then onSend and onResponse still run so the error response is shaped and observed like any other.
ts
app.route({
  method: "POST",
  path: "/admin/purge",
  operationId: "adminPurge",
  hooks: bearerAuth({ validate: t => t === process.env.ADMIN_TOKEN }),
  responses: {
    200: { description: "ok", body: z.object({ purged: z.boolean() }) },
    401: { description: "denied" },
  },
  handler: async () => ({ status: 200, body: { purged: true } }),
});

Transforming responses with onSend

Use onSend when you need to rewrite the outgoing response, for example, to attach a header, strip an internal header, or replace the response entirely. Returning void keeps the current response. Multiple onSend hooks compose pipeline-style (global → group → route).

ts
const app = new App({
  hooks: {
    onSend(res) {
      // Always advertise the API version on every outgoing response,
      // including error responses and OPTIONS preflights.
      res.headers.set("x-api-version", "2026-05-15");
    },
  },
});

app.route({
  method: "GET",
  path: "/users/me",
  operationId: "me",
  hooks: {
    onSend(res) {
      // Strip internal implementation detail before the response leaves.
      res.headers.delete("x-internal-cache-key");
    },
  },
  responses: {
    200: { description: "ok", body: z.object({ id: z.string() }) },
  },
  handler: async ({ set }) => {
    set.headers.set("x-internal-cache-key", "shard-a");
    return { status: 200, body: { id: "u_1" } };
  },
});

onSend runs after response validation and after request-scoped headers, including x-request-id, have been merged. It runs before onResponse, which remains the right place for logging and metrics.

405 Method Not Allowed

If a path is registered for one method but called with another, the router returns 405 with a correct Allow header, never a misleading 404. Routes marked internal: true are filtered from public 405 and Allow responses so hidden admin or cron endpoints do not leak through method probing.

Performance

text
static route lookup        12,363,799 ops/sec
dynamic 4-segment lookup    1,513,983 ops/sec
miss                        4,763,878 ops/sec