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
app.route({
method: "GET",
path: "/users/:id",
operationId: "getUser", // required and unique across the app
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() }).optional(),
},
responses: {
200: { description: "Found", body: UserSchema },
404: { description: "Not found" },
},
handler: async ({ params, query, headers }) => {
// params.id is string, query.include is "profile" | "settings" | undefined, headers["x-tenant"] is string
return { status: 200, body: await loadUser(params.id) };
},
});HTTP methods
Supported: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. HEAD is auto-derived from GET when not declared explicitly.
Path parameters
app.route({
method: "GET",
path: "/orgs/:org/repos/:repo",
// params is { org: string, repo: string } — inferred from the path
});Conflicting parameter names (e.g. /a/:x and /a/:y) throw at registration. Path traversal segments (..) and empty segments // are rejected by the router before your handler sees them.
Groups
app.group("/api/v1", { tags: ["v1"] }, (v1) => {
v1.route({
method: "GET",
path: "/health",
operationId: "health",
responses: { 200: { description: "ok" } },
handler: async () => ({ status: 200, body: { ok: true } }),
});
});
// final path: /api/v1/healthGroups merge prefixes, tags, and hooks. They are encapsulated — middleware added inside a group does not leak out.
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.onError— observe or replace the error response.onSend— symmetric tobeforeHandle, but for outgoing responses. Mutate headers in place or return a brand-newResponseto replace it. Runs on success, error, and OPTIONS preflight paths.onResponse— final hook, always runs. Use for observability; do not mutate the response here.
app.route({
method: "POST",
path: "/admin/purge",
operationId: "adminPurge",
hooks: bearerAuth({ validate: t => t === process.env.ADMIN_TOKEN }),
responses: { 200: { description: "ok" }, 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 an envelope, strip a sensitive header, or compress the body. Mutate res.headers in place, or return a brand-new Response to replace the current one entirely. Returning void keeps the existing response. Multiple onSend hooks compose pipeline-style (global → group → route).
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) {
// Replace the response with a freshly-wrapped envelope.
if (res.headers.get("content-type")?.includes("application/json")) {
return res.clone();
}
},
},
responses: { 200: { description: "ok" } },
handler: async () => ({ status: 200, body: { id: "u_1" } }),
});onSend runs after response validation and after request-scoped headers (including x-request-id) have been merged, so you can read the final shape of the response. 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.
Performance
static route lookup 12,363,799 ops/sec
dynamic 4-segment lookup 1,513,983 ops/sec
miss 4,763,878 ops/sec