Skip to content

Lifecycle hooks

A hook is a function bound to one slot of the request lifecycle. Hooks do authentication, logging, headers and error mapping around a handler.

A request passes through fixed stages. There is no next(): every hook runs at one point, named by its slot.

beforeParse → parse → beforeValidation → validate → beforeHandle
→ handler → beforeResponse → afterResponse
onError: when any stage up to beforeResponse throws

parse reads the body and validate checks the request against the route’s schemas. The other names are slots:

Slot Runs Typical use
beforeParse before the body is read authentication, rate limits, request ids
beforeValidation after the body is parsed, before it is checked normalizing raw input, checking a signature
beforeHandle after validation, right before the handler loading an entity by a validated id, ownership checks
beforeResponse once the response exists, on every outcome response headers, replacing the response
afterResponse as the response goes out, on every outcome access logs, metrics, audit
onError when any stage up to beforeResponse throws turning an error into a response

A request refused in beforeParse never pays for parsing the body, and a 401 from there always comes before a 422 from validation. The hook slots reference lists what each slot’s context holds.

Make a hook with its slot’s factory, hook.<slot>(fn). The function receives ctx and returns one of three things:

  • nothing: the request goes on;
  • an object: its fields are added to ctx, typed in later hooks and in the handler;
  • a Response: the request stops, and that response is sent.

To refuse a request, throw an HttpError or return a Response. Any other thrown error becomes a 500.

import { hook, HttpError, route } from "@tetsujs/core";
export const auth = hook.beforeParse(async (ctx) => {
const
const user: User | undefined
user
= await
const sessions: {
verify(token: string | null): Promise<User | undefined>;
}
sessions
.verify(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("authorization"));
if (!
const user: User | undefined
user
) throw new HttpError(401);
return {
user: User
user
};
});
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/notes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes",
hooks: { beforeParse: [auth] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}) => {
id: number;
}[]

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx
) =>
const notes: {
listFor(userId: string): {
id: number;
}[];
}
notes
.listFor(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx
.user.
id: string
id
),
user: User
});

A hook that sometimes returns nothing adds an optional field: return user ? { user } : undefined makes ctx.user a User | undefined. Context covers what a hook can and cannot add to ctx.

Build a returned Response on every call. Its body can be read only once, so a response kept in a module-level constant sends an empty body to every request after the first.

Hooks are mounted by slot, the same way on a route, a group and the application:

import { route } from "@tetsujs/core";
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/me"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/me",
hooks: { beforeParse: [auth], afterResponse: [log] },
handler: (ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: {
id: string;
};
}) => {
id: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: {
id: string;
};
}
ctx
) =>
ctx: {
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
user: {
id: string;
};
}
ctx
.
user: {
id: string;
}
user
,
});

Within a slot, hooks run in array order. The compiler checks that each hook sits under the slot it was made for, and a plain function without its factory is a compile error. Code the compiler did not check is checked at startup, including a misspelled slot such as beforParse. Groups and mounting covers the order of hooks across the application, groups and a route.

The three slots before the handler add to the context. Each hook sees what earlier hooks returned.

  • beforeParse sees the request, the raw path parameters and ctx.route. There is no body, query or validated headers yet.
  • beforeValidation also sees ctx.body, parsed but not yet checked: unknown for JSON. A hook here may normalize it, for example trim strings or rename a legacy field. What it returns under body is what gets validated. This is also where a webhook signature is checked; see Request bodies.
  • beforeHandle sees the validated request. A reusable hook here states what it needs with Requires; see Context.

A Response returned from any of them skips the rest, handler included, but still goes through beforeResponse and afterResponse.

A beforeResponse hook sees the response as ctx.res and may replace it by returning another Response. It runs for every response: a handler’s result, a hook’s early response, and error responses too.

A response a hook returns here is not checked against the route’s response map and is not in the OpenAPI document. If clients should know about it, annotate the hook with documented().

A beforeResponse hook that throws goes to onError like any stage. The hooks after it then run over the error response; the ones before it do not run again.

import { hook } from "@tetsujs/core";
export const noStore = hook.beforeResponse((ctx) => {
if (ctx.
res: Response
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
>= 400) return;
const
const res: Response
res
= new Response(ctx.
res: Response
res
.
body: ReadableStream<Uint8Array<ArrayBuffer>> | null
body
, ctx.
res: Response
res
);
const res: Response
res
.
headers: Headers

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

MDN Reference

headers
.set("cache-control", "no-store");
return
const res: Response
res
;
});

To add a header only, ctx.out.headers is simpler: it is applied to every response. Returning anything other than a Response from this slot does nothing.

This is the last place where you can read the body. To audit it, clone the response: void ctx.res.clone().text().then(…). A clone of a streamed body holds every chunk until it is read, so audit a stream where it is produced.

afterResponse hooks observe. They run on every outcome, errors included, and nothing they return changes the response. Use them for logs and metrics.

An observer runs as the response goes to Bun. Its synchronous part adds to the response’s latency; the promise it returns is not awaited. Two rules apply:

  • ctx.res has the status and headers but no way to read the body. Reading it is a compile error.
  • Read what you need from the request and the response before the first await. After it, the response may be sent, and a header or the client address nobody read before is gone.
import { hook } from "@tetsujs/core";
export const shipped = hook.afterResponse(async (ctx) => {
const
const line: {
status: number;
route: string | undefined;
agent: string | null;
ip: string | undefined;
ms: number;
}
line
= {
status: number
status
: ctx.
res: SentResponse
res
.
status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
,
route: string | undefined
route
: ctx.
route?: RouteInfo | undefined

The route this request matched, or nothing when none did.

Optional because a 404, a 405 and a preflight run the pipeline too, and there is no route behind them — the same reason req.cookies is optional. In a route's own context the field is not optional: a hook mounted on a route, and its handler, always have one.

It is the field that makes an observer able to name the endpoint rather than the URL: ctx.route.path is /users/:id, where new URL(ctx.req.url).pathname is /users/42. The difference is cosmetic in a log line and structural in a metric, where a label built from the second one grows a new series per identifier.

route
?.
path: string | undefined

The path as the route declared it, with group prefixes joined and :params left as they are — /api/users/:id, never /api/users/42.

path
,
agent: string | null
agent
: 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("user-agent"),
ip: string | undefined
ip
: ctx.
server: Bun.Server<unknown>

The server handling this request — the real Bun.Server, whole and unguarded, like req an escape hatch to the platform.

The way to reach connection- and server-level facts a Request does not carry: ctx.server.requestIP(ctx.req) for rate limiting by address, ctx.server.timeout(ctx.req, seconds) for a per-request idle timeout.

An address is in the form the socket reports it: a server listening on both stacks — Bun's default — reports an IPv4 client as ::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.

Deliberately not a facade: every hook is code the application author wrote or vetted, and hiding stop/reload from in-process code protects nothing. They are still process-level operations with no business inside a request — calling ctx.server.stop() from a hook takes the whole listener down.

upgrade is for the endpoints ws() declares, which the application upgrades itself. A socket upgraded by hand reaches the application's websocket handler with no endpoint on it: the connection drops, and each of its events is reported as a failure.

server
.requestIP(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
)?.
address: string | undefined

The IP address of the client.

address
,
ms: number
ms
: performance.now() - ctx.
startedAt: number

When the pipeline took the request: a performance.now() reading, in milliseconds on the monotonic clock.

performance.now() - ctx.startedAt is how long the request has been in the framework — what an access log reports, and what a failure report can say about the request it belongs to. Read once per request by the core rather than by a hook in beforeParse: an observer needs only this, so a package measuring requests is one hook after the response instead of two, and the clock starts before any hook, not after the ones mounted ahead of the one that reads it. It costs 7–9 ns a request in the pipeline (bench/src/cost.ts), below the noise of the HTTP benchmark.

Wall-clock time is Date.now(), and not this: a monotonic reading does not jump when the system clock is adjusted, which is what makes a difference of two of them a duration.

startedAt
,
};
await
const shipper: {
send(line: object): Promise<void>;
}
shipper
.send(
const line: {
status: number;
route: string | undefined;
agent: string | null;
ip: string | undefined;
ms: number;
}
line
);
});

performance.now() - ctx.startedAt is how long the request has been in the framework so far.

The observers of one request all start in order, without waiting for each other. One that throws or rejects is reported to reportError, or to the console, and the others still run. It does not reach onError: the response has already gone, so there is nothing left to answer. Work whose failure must fail the request, such as a required audit record, belongs in beforeResponse or the handler.

An onError hook receives ctx.error and may answer it with a Response. Returning nothing passes the error on; returning a plain object is a compile error.

import { hook } from "@tetsujs/core";
export const conflicts = hook.onError((ctx) =>
ctx.
error: unknown
error
instanceof AlreadyShipped
? Response.json({
error: string
error
: "ALREADY_SHIPPED" }, {
status?: number | undefined
status
: 409 })
: undefined,
);

The error response then goes through beforeResponse and afterResponse like any other. Errors covers the order of onError hooks, what answers when none does, and an error format for the whole application.

A request runs through the stages as plain function calls and becomes asynchronous only at the first stage that returns a promise. A hook that only reads a header is best written without async: it then costs a function call and nothing more.