controller(name, build) takes a name and a function from dependencies to
routes, and returns a function that takes the same dependencies. Declare
the dependencies as the function’s parameter, usually with an interface:
: 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: "/orders"
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: "/orders",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Order[]
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: () =>
orders: OrderService
orders.all() }),
get: 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: "/orders/:id"
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: "/orders/:id",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {
id:string;
};
}) => Order
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: {
id: string;
};
}
ctx) => {
const
constorder:Order|undefined
order=
orders: OrderService
orders.find(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx.
params: {
id: string;
}
params.
id: string
id);
if (!
constorder:Order|undefined
order) throwhttpError(404, "ORDER_NOT_FOUND");
return
constorder:Order
order;
},
}),
}));
Calling it with the dependencies gives a plain object whose fields are the
routes, tagged with the controller’s name. The application reads those
fields and nothing else. A controller without dependencies is called with
no arguments.
Because the result is plain data, you can unit-test a handler by calling
it: ordersController(fakes).get.handler(testCtx({ params: { id: "1" } })).
Testing covers testCtx().
@tetsujs/openapi builds every operationId
from the controller’s name and the route’s field: list in "Orders"
becomes ordersList. A generated client names its methods after these, so
changing the name changes the client. Renaming the variable does not. To
set an id by hand, use docs: { operationId } on the route.
The name is also what ctx.route.controller reports in logs and metrics.
Two controllers in one application cannot share a name, and createApp()
refuses it at startup. To serve two versions of an API from one function,
declare it twice under two names: controller("UsersV1", users) and
controller("UsersV2", users), each mounted under its own group.
Wire the application by hand, in one place. Build every service there once
and pass it to the controllers that need it. There is no container and no
registration.
buildApp takes the database instead of opening it, so main.ts can open
a file and the tests an in-memory one. The full example is
examples/app,
a small notes API on bun:sqlite.
To get a controller’s dependency type without importing the interface,
use Parameters<typeof notesController>[0].
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: [signedIn] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
user:User;
}) => Note[]
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) =>
notes: NoteStore
notes.list(
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: User
user.
id: string
id),
}),
};
},
);
A hook whose state several controllers must share is different. One rate
limit for the whole API is one rateLimit() instance; made inside each
controller, it would be a separate limit per controller. Make such a hook
once in the composition root and pass it in like a service:
: 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: readonly [RateLimitHook]
beforeParse: [
limit: RateLimitHook
limit] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => Note[]
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: () =>
notes: NoteStore
notes.list(),
}),
}));
constlimit=rateLimit({
limit: number
Hits allowed per window: an integer, 0 included — a limiter that
refuses everything.
limit: 100,
windowMs: number
Length of the window in milliseconds: a positive, finite number.
Anything else is refused when the limiter is made. NaN — what
Number(process.env.RATE_WINDOW) reads as when the variable is not
set — and 0 used to start a new window on every request, so nothing
was ever refused while the headers went on reporting a budget.
windowMs: 60_000,
key: (ctx) => 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.
A route reads its hooks and schemas when it is declared. A function has
its dependencies from its first line, so a hook built from one of them
gets the real service.
In a class, fields are initialized before the constructor’s parameters are
assigned. A route declared as a field that builds a hook from a
constructor argument gets undefined. TypeScript reports the direct case
as error TS2729. A class instance can still be mounted, and it is named
after its class, but controller() avoids the problem.
Services can stay classes. A service is called by other code; a controller
is a declaration made once at startup.
A controller that needs the built application, for example to list or
document its routes, cannot get it as a dependency: the application does
not exist yet. Give the controller a method under the onMount symbol.
createApp() calls it once with the application, after the route table is
built and before it returns:
: 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: "/routes"
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: "/routes",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => 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: () =>
let mounted:App<RouteMap> |undefined
mounted?.
entries: readonly RouteTableEntry[]
The compiled route table, for diagnostics and tooling.
The runtime half of what the type parameter carries: entries is what
the table found, Routes is what the types remember of the same
walk — the method, full path and schemas of every route, keyed by
"METHOD /full/path". A generated client reads the second; a route
listing reads the first.
entries.map((
entry: RouteTableEntry
entry) =>`${
entry: RouteTableEntry
entry.
method: "GET"|"POST"|"PUT"|"PATCH"|"DELETE"
method} ${
entry: RouteTableEntry
entry.
path: string
Full path: group prefixes joined with the route's own path.
path}`) ?? [],
}),
};
});
It runs at startup, so an error thrown there stops the process. This is
how docs() from @tetsujs/openapi builds its document.