# 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.

## Progressive shorthands

Method shorthands reduce ceremony without removing the contract. DaloyJS derives a stable operation id from the method and path, such as `getRoot` or `postBookItemsByItemId`. An explicit `operationId` still wins.

```ts
const app = new App()
  .get(
    "/",
    { responses: { 200: { body: z.object({ hello: z.string() }) } } },
    () => ({ status: 200, body: { hello: "world" } }),
  )
  .post(
    "/books",
    {
      request: { body: z.object({ title: z.string().min(1) }) },
      responses: {
        201: { body: z.object({ id: z.string() }) },
      },
    },
    ({ body }) => ({ status: 201, body: { id: "book_1" } }),
  );
```

Shorthands keep the same validation, OpenAPI, security, and typed-client behavior as `route()`. There is no two-argument form that silently skips a response contract. For an intentionally opaque body, pass `acknowledgeNoResponseBodySchema: true` explicitly. Successful raw responses returned by `preBody` or `beforeHandle` require the same acknowledgement; ordinary `4xx`/`5xx` hook denials do not. Response descriptions remain optional; omitted values become a stable `HTTP <status> response` description in OpenAPI.

## Defining routes

A route declaration is the source of truth for matching, request validation, response validation, OpenAPI output, and typed clients. Provide an `operationId` when using `route()` for a public route you want in the typed client or generated SDK. HTTP shorthands derive one automatically. DaloyJS rejects duplicate `operationId` values at registration.

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

const UserSchema = z.object({
  id: z.uuid(),
  email: z.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.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,
      }),
    };
  },
});
```

## Multi-file type inference

Export each route with `defineRoute()`, then compose the imported contracts through `registerRoutes()`. The literal tuple survives file and module boundaries, so the no-codegen client retains every operation id and schema.

```ts
// routes/list-books.ts
export const listBooksRoute = defineRoute({
  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" }] }),
});

// app.ts
import { listBooksRoute } from "./routes/list-books.js";
import { createBookRoute } from "./routes/create-book.js";

export const app = new App().registerRoutes([
  listBooksRoute,
  createBookRoute,
] as const);
```

Chained `route()` calls also accumulate correctly. Avoid a bare `const app: App` annotation on the final composed app, because that explicitly widens away the route tuple.

Callback-style `group()` and plugin `register()` still provide runtime scoping, but TypeScript cannot widen the parent variable from inside their callbacks. For a fully typed no-codegen client across large modules, export literal route tuples and make `registerRoutes()` the final composition boundary.

## 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.

Shorthand methods are available for `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `HEAD`. Register an explicit `OPTIONS` route with `route()`; otherwise DaloyJS supplies automatic preflight handling.

## 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. For parallel `/api/v1` and `/api/v2` contracts, migration policy, and separately generated SDKs, read the [API versioning guide](/docs/api-versioning).

## 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.
- `preBody`: after route matching, before schema validation or body I/O. Built-in header/JWT/basic/mTLS auth runs here.
- `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.

**Diagram: Request lifecycle**

1. **onRequest** (earliest) - before parsing
2. **preBody** (cheapest rejection) - route matched · body untouched
3. **validate** (framework) - params · query · body · headers
4. **beforeHandle** - return a Response to short-circuit
5. **handler** - your route logic
6. **afterHandle** - wrap / transform the result
7. **onSend** - mutate or replace the Response
8. **onResponse** (always) - observability only

Hooks fire at fixed points around your handler. Cheap authentication can reject in preBody before request-body I/O; body-aware middleware keeps beforeHandle after validation. If anything throws, control jumps to onError, then onSend and onResponse still run.

```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

```
static route lookup         25,810,420 ops/sec
dynamic 4-segment lookup     2,105,121 ops/sec
miss                         7,742,635 ops/sec
```

---

Source: https://daloyjs.dev/docs/routing