Skip to content

Key concepts

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.

createApp returns plain data — the routes, a fallback for unmatched paths and a WebSocket handler — and Bun.serve takes it as it is:

import { controller, createApp, route } from "@tetsujs/core";
const health = controller("Health", () => ({
live: 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: "/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: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => 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" }),
}));
const app = 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.

Controllers take their dependencies as arguments

Section titled “Controllers take their dependencies as arguments”

A controller is a name and a function from its dependencies to its routes. You call it once, where the application is wired:

import { controller, createApp, route } from "@tetsujs/core";
const notesController = controller("Notes", (
notes: NoteStore
notes
: NoteStore) => ({
list: 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: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => 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
const notes: NoteStore
notes
= new NoteStore();
export default createApp({ routes: notesController(
const notes: 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

A request passes through named stages. There is no next(): each hook is made for a slot, and the slot says when it runs.

beforeParse → parse → beforeValidation → validate → beforeHandle
→ handler → beforeResponse → afterResponse (onError on failure)

What a hook returns is added to ctx. To refuse the request, it throws.

import { hook, HttpError, route } from "@tetsujs/core";
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: "/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: {
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;
}) => 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:

import type { Requires } from "@tetsujs/core";
import { hook, route } from "@tetsujs/core";
const audit = 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: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => 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.

handler
: () => [],
});

Context and its types

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

A thrown HttpError, a failed validation, an unmatched path, a body over the limit — every failure is answered in one shape:

{ "status": 404, "message": "Not Found", "error": "NOT_FOUND" }

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.

origin
: "https://app.example.com" }), requestId()],
afterResponse: [accessLog()],
},
routes: object | readonly object[]

The topology: a group, a controller, or an array of either.

routes
,
});

Groups and mounting · Packages