Skip to content

Groups and mounting

A group mounts controllers under a path prefix and adds hooks to every route below it. The application has hooks of its own that run for every request.

group(prefix, { hooks, children }) adds its prefix to every route of its children. Children are controllers, routes or other groups:

import { createApp, group } from "@tetsujs/core";
const adminOnly = hook.beforeParse((ctx) => {
if (ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("x-role") !== "admin") throw new HttpError(403);
});
createApp({
routes: group("/api", {
children: [
notesController(),
group("/admin", {
hooks: { beforeParse: [adminOnly] },
children: [statsController()],
}),
],
}),
});

The routes are GET /api/notes and GET /api/admin/stats, and only the second runs adminOnly. A route with the path / inside a group answers at the prefix itself.

A prefix follows the path rules, and also cannot be / alone, contain a * or declare a :param. A controller is typed where it is written, so a parameter from the prefix would never appear in ctx.params.

Groups nest only through group(); a controller never contains another controller. The whole shape of the API is written in one place.

createApp({ hooks }) takes hooks keyed by slot, like a route and a group. They run for every request the application answers:

import { createApp } from "@tetsujs/core";
import { cors } from "@tetsujs/cors";
import { requestId } from "@tetsujs/request-id";
import { accessLog } from "@tetsujs/request-log";
const id = requestId();
const browser = cors({
origin: string | readonly string[]

Which origins are allowed.

A list is matched exactly and echoed back — echoing rather than returning the list is what the header format requires. "*" allows every origin, and is refused together with credentials, which the specification does not permit.

An origin is written as a browser sends it — scheme, host and port, nothing after: https://app.example.com. Anything else never matches, so it is refused where it is written, naming the origin it means: a trailing slash, capitals, a path, a default port. "null" — what a sandboxed frame or a file: page sends — is taken as it is, and refused with credentials: any site can send it.

origin
: "https://app.example.com" });
const log = accessLog();
createApp({
hooks: {
beforeParse: [id, browser],
afterResponse: [log],
},
routes: object

The topology: a group, a controller, or an array of either.

routes
,
});

For a matched route, each slot runs the application’s hooks first, then each group’s from the outside in, then the route’s own. onError runs the other way, route first and application last, so the most specific hook maps an error first.

A 404, a 405 and an OPTIONS preflight also go through the lifecycle, but only with the application’s hooks. Group hooks do not run for them, because no route in the group matched.

This means application-wide logs and rate limits see requests nothing handled, and a CORS hook on the application can answer a preflight. It also keeps a group’s guard from refusing a preflight: an authentication hook on /admin would otherwise turn every preflight into a 401 the browser cannot read. That is why cors() belongs on the application.

The 404 and 405 also reach the application’s onError hooks as an HttpError, so one hook sets the error format for everything.

Group and application hooks run for routes they do not know, so they cannot see a route’s schemas or its hooks’ fields. They can see what earlier hooks at the same level returned, in the same slot or an earlier one:

import type { Requires } from "@tetsujs/core";
import { createApp, hook } from "@tetsujs/core";
import { requestId } from "@tetsujs/request-id";
const id = requestId();
const scope = hook.beforeParse((
ctx: Requires<{
requestId: string;
}>
ctx
: Requires<{
requestId: string
requestId
: string }>) => ({
tenant: string
tenant
:
ctx: Requires<{
requestId: string;
}>
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("x-tenant") ?? "public",
}));
createApp({
hooks: { beforeParse: [id, scope] },
routes: object

The topology: a group, a controller, or an array of either.

routes
,
});

scope declares what it reads with Requires, and compiles because id comes first. In the other order it is a compile error that names the missing field. In beforeResponse, afterResponse and onError such fields are optional, Requires<{ requestId?: string }>, since the hook that adds them may not have run.

What a group hook returns does not reach the handlers’ types, for the reason Context explains.

A few habits keep it clear what runs for each request.

Make a hook once, in a named constant. Write const log = accessLog(), then afterResponse: [log]. The slot then reads as a list of names, and you do not create a new instance each time the surrounding code runs.

Share hooks, not hooks objects. To reuse a set of hooks, declare it as const and spread each slot where it is mounted:

import { createApp } from "@tetsujs/core";
const common = { beforeParse: [id, browser], afterResponse: [log] } as const;
createApp({
hooks: {
beforeParse: [...common.beforeParse, auth],
afterResponse: [...common.afterResponse],
},
routes: object

The topology: a group, a controller, or an array of either.

routes
,
});

Without as const, the array loses its element types, and mounting it is a compile error. Spreading whole hooks objects, or Object.assign, replaces a slot instead of joining it. For every route under a prefix, use a group’s hooks.

State lives in the instance. One rateLimit() mounted on two groups shares one counter. For separate limits, make two instances. To share a hook’s state across controllers, make it in the composition root and pass it in; see Controllers.

An instance runs once per request. The same hook mounted twice in one route’s chain, for example on a group and on a route under it, is refused at startup.

You choose the order within a slot. The compiler checks what a hook needs, not what should come first. Put cors() before every hook that can refuse a request, so the refusal carries the headers a browser needs to read it.

A package is one hook. A hook package is a function that takes options and returns one hook; there is no plugin system. See Writing a hook package.