ctx is the object a request carries through its hooks and its handler.
A field is on it only once it exists at that point of the request: the
body after it was read, ctx.user after the hook that returned it. Its
type is inferred at the route() call from the path, the schemas and the
hooks, so you never annotate it in a handler.
Always:req and server, Bun’s own request and server, not
wrappers; out, what the response will carry; startedAt, a
performance.now() reading taken as the request arrived.
Where a route matched:route, the matched route’s method,
path, controller and name; params, the path parameters.
With a schema:query, headers, cookies and body, validated,
from beforeHandle. A body the route reads is there as parsed from
beforeValidation, and so is rawBody with rawBody: true.
In their slots:res in beforeResponse and afterResponse,
error in onError.
After a hook: whatever that hook returned.
ctx.server.requestIP(ctx.req) gives the client’s address; behind a proxy
it is the proxy’s, see Behind a proxy.
Write to ctx.out, do not replace it: ctx.out.status = 201,
ctx.out.headers.set(…), ctx.out.cookies.set(…). Its headers are added
to every response, error responses included. See
Responses and
Cookies.
The context reference lists every field with
its type and the slot it exists from.
ctx.query, ctx.headers, ctx.cookies and ctx.body exist only on a
route that declares them. Without a query schema there is no
ctx.query at all, not even an unknown one:
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",
handler: any
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: {};
}
ctx) =>
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.query.page,
Error ts(2339) ― Property 'query' does not exist on type '{ readonly req: Request & { readonly cookies?: CookieMap | undefined; }; readonly server: Server<unknown>; readonly out: Outgoing; readonly route: RouteInfo; readonly startedAt: number; readonly params: {}; }'.
});
Unvalidated input has no type worth trusting, and an unknown field is
one cast away from being used as if it were checked. A part without a
schema is also not read at all. Declare a schema for the part you need,
and the field arrives typed from its output. For raw access, ctx.req is
always there.
A hook in beforeParse, beforeValidation or beforeHandle adds fields
by returning an object. Those fields are typed in the hooks after it and
in the handler:
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: "/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: [session] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
session:Session;
}) => {
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 req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
session: Session;
}
ctx) => ({
id: string
id:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
session: Session;
}
ctx.session.
userId: string
userId }),
session: Session
});
A later hook’s field replaces an earlier one’s of the same name. A hook
before validation may change ctx.query or ctx.body, but validation
runs after it, and the handler sees the schema’s output.
In beforeResponse, afterResponse and onError these fields are
optional. Those slots run on every outcome, including a request refused
before the hook that adds the field ran.
A hook reused across routes cannot know which route it will be mounted
on. It states what it needs instead, with Requires<{ … }> on its
parameter:
importtype { Requires } from"@tetsujs/core";
import { hook, HttpError } from"@tetsujs/core";
exportconstwithNote= hook.beforeHandle(
async (
ctx: Requires<{
params: {
id:number;
};
user:User;
}>
ctx:Requires<{
params: {
id: number;
}
params: {
id: number
id:number };
user: User
user:User }>) => {
const
constnote:Note|undefined
note=await
constnotes: {
find(id:number):Promise<Note|undefined>;
}
notes.find(
ctx: Requires<{
params: {
id:number;
};
user:User;
}>
ctx.
params: {
id: number;
}
params.
id: number
id);
if (!
constnote:Note|undefined
note||
constnote:Note
note.
owner: string
owner!==
ctx: Requires<{
params: {
id:number;
};
user:User;
}>
ctx.
user: User
user.
id: string
id) thrownewHttpError(404);
return {
note: Note
note };
},
);
Requires<T> is the base context (req, server, out, startedAt)
plus T. Where the hook is mounted, the compiler checks that the path,
the schemas and the earlier hooks provide those fields. Mounted on a route
without a params schema, where params.id is a string, or with no hook
providing user, it is a compile error that names the missing field.
Why a group hook’s field is not in the handler’s type
A hook mounted on a group runs for every route under it, but the fields it
returns are not in the handlers’ types. A controller is typed where it is
written, not where it is mounted, and the same controller could be
mounted under an authenticated group and outside one.
The field is still there at runtime, but to use it in a handler, mount the
hook on the route itself, on each route that reads the field:
hooks: { beforeParse: [auth] },
To reuse a whole set of hooks across routes, see
Mounting hooks.
Use group hooks for work that needs no typed field later: guards that
refuse, logs, metrics, CORS. See
Groups and mounting
for what group hooks can see of each other.
A hook cannot replace the framework’s own fields: req, server, out,
route, res, error, startedAt and rawBody. If a hook returns one
of them, the key is dropped at runtime and left out of the hook’s type.
Otherwise a hook could, for example, forge server and defeat every
address-based rate limit behind it. The keys __proto__, constructor
and prototype are dropped too.
params, query, body, headers and cookies are not protected;
hooks may normalize them.
Only the returned object’s own string keys are copied. Return a plain
object literal: the methods and getters of a class instance are not
copied.