Routing
DaloyJS uses a segment trie with a static-route fast path. After path safety checks, exact static routes resolve via a single Map.get. Dynamic lookups prefer literal segments, then parameters, then wildcards. A lookup without backtracking is linear in path length; overlapping routes can require visiting additional trie branches.
Only complete routes count as matches. For example, registering /users/me/details/:field does not prevent /users/:id from matching /users/me. Within the dynamic trie, a complete higher-priority path wins even when it has no handler for the requested method. An exact static route wins for its registered methods; other methods may still match the dynamic trie.
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.
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.
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.
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
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.
Capture names must be nonempty and unique within a route. The names __proto__, constructor, and prototype are rejected to keep parameter objects safe for downstream use. A rejected registration does not reserve its operationId.
Wildcard captures
A trailing *name segment captures the rest of the path into one decoded string and requires at least one remaining segment. A bare * uses the name wildcard. Wildcards must be terminal: /assets/*path/private throws during registration instead of silently matching a broader path. Multiple methods can share a wildcard route when the capture name agrees. Duplicate methods and conflicting wildcard names throw.
Raw path traversal segments (..), empty segments //, and malformed percent escapes in captures miss cleanly. Method discovery applies the same checks, so rejected paths do not advertise an Allow header. Since 1.3.7, a param or wildcard segment never binds . or .., including the encoded spellings %2e and %2e%2e. A URL parser would resolve those as directory steps, so letting them bind would give the handler a different path from the one that middleware saw. Decoded parameters remain untrusted data: encoded slashes and other bytes are not sanitized filesystem paths. Validate filesystem access separately.
The adapters also keep the router and middleware on one path view (since 1.3.7). The Node adapter canonicalizes request-targets containing \, dot segments, or their %2e forms the way new URL() would before routing. The Node and AWS Lambda adapters both refuse a Host header that is not a plain host[:port] (for example one containing \ / ? # @ % or whitespace) with 400. Path-based guards such as except() therefore match exactly the route that runs. See the Node adapter for details.
Groups
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.
Route options
request: schemas forparams,query,headers, andbody.responses: declared status codes and optional response body/header schemas.accepts: per-routeContent-Typeallowlist for routes with request body schemas.auth: OpenAPI security requirement for the route. Pair it with an auth hook such asbearerAuth().internal: hides a route from public adapters while still allowing in-processapp.inject()calls.deprecatedandsunset: mark an endpoint as deprecated and emit the matching response headers.callbacksandmeta: 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 aResponseto 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 newResponse. Runs on success, error, andOPTIONSpreflight paths.onResponse: final observer. Use it for logging and metrics. Do not mutate the response here.
- 01earliestonRequestbefore parsing
- 02cheapest rejectionpreBodyroute matched · body untouched
- 03frameworkvalidateparams · query · body · headers
- 04beforeHandlereturn a Response to short-circuit
- 05handleryour route logic
- 06afterHandlewrap / transform the result
- 07onSendmutate or replace the Response
- 08alwaysonResponseobservability only
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).
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.
Method discovery includes both an exact static route and the selected dynamic route. With GET /users/me and POST /users/:id, the path /users/me advertises both registered methods. The header lists explicit registrations; synthesized HEAD and OPTIONS handling does not add entries.