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.
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.
: 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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
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) =>
constnotes: {
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.
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.
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.
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.
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
constshipper: {
send(line:object):Promise<void>;
}
shipper.send(
constline: {
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";
exportconstconflicts= hook.onError((ctx) =>
ctx.
error: unknown
errorinstanceofAlreadyShipped
? 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.