Tetsu has few moving parts, and each is an ordinary value. This page goes
through them in the order a request meets them, with a link to the full
page for each.
: 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: "/livez"
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: "/livez",
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: () =>"ok" }),
}));
constapp=createApp({ routes: health() });
Bun.serve({ ...app,
port?: string | number |undefined
The port the server listens on
@default ― process.env.PORT || "3000"
port: 3000 });
There is no server object of the framework’s own. Routing is Bun’s native
router, ctx.server is Bun’s server, and starting and stopping it is up to
you. Graceful shutdown is a package:
@tetsujs/lifecycle.
: 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: (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.all() }),
}));
const
constnotes:NoteStore
notes=newNoteStore();
exportdefaultcreateApp({ routes: notesController(
constnotes:NoteStore
notes) });
There is no container, no decorator and no registry. The name matters: the
OpenAPI document builds each operationId from it.
Controllers and dependencies
route() takes a method, a path, schemas for the parts of the request, hooks
by slot and the handler. Its context is typed from what the route itself
declares; hooks of its groups and of the application run for it too, but add
nothing to its types. The compiler checks the path against what Bun’s router
matches.
Routes and handlers
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: [auth] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
user:User;
}) => User
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) =>
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
});
auth sits in beforeParse, so an anonymous request is refused before its
body is read. Lifecycle hooks
ctx is never annotated. A field is on it exactly when it exists at that
point: the parameters of the path, the body once it is validated, user
after the hook that returned it. A hook that needs a field declares it with
Requires, and mounting it where nothing provides the field is a compile
error:
importtype { Requires } from"@tetsujs/core";
import { hook, route } from"@tetsujs/core";
constaudit= hook.beforeHandle((
ctx: Requires<{
user:User;
}>
ctx:Requires<{
user: User
user:User }>) => {
console.log("request by",
ctx: Requires<{
user:User;
}>
ctx.
user: User
user.
id: string
id);
});
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: "/reports"
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: "/reports",
hooks: { beforeHandle: [audit] },
Error ts(2322) ― Hook requires context 'user' which is not provided by path, schema or preceding hooks
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) => never[]
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.
params, query, headers, cookies and body are each validated by the
schema you give them: Zod, Valibot, ArkType, or TypeBox through an adapter.
All parts are checked at once, and a failure is a 422 listing every issue.
A response schema checks what the route sends back.
Validation ·
Responses
An onError hook on the application sees every failure and can change the
format. An error that can no longer become a response, because the response
was already sent, goes to reportError. Errors
There is no plugin system. CORS, request ids, access logs, rate limits and
security headers are packages, each a function that returns a hook, mounted
in its slot like your own:
createApp({
hooks: {
beforeParse: [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.