This is the full developer documentation for Tetsu # Why write an HTTP framework in TypeScript in 2026? > Go and Rust are stricter than TypeScript. Why I still wrote Tetsu in TypeScript, and what a framework has to do to close part of the gap. In September, Jarred Sumner, who builds Bun, [wrote](https://x.com/jarredsumner/status/2103092824363401586) that “JavaScript was never designed for the server”, and that unless the engine changes, Rust will replace it there. Many replies agreed and said to let server-side JavaScript die. A week later Ryan Dahl, who created Node.js and Deno and writes Rust as his main language, [answered](https://x.com/rough__sea/status/2106813105594593704) that application code belongs in JavaScript or TypeScript. I had just written Tetsu, an HTTP framework for Bun, in TypeScript, and I’d been asking myself the same question while building it. Here’s my answer. It starts with what Go and Rust do better, because they do a lot better. ## What Go and Rust do better | | TypeScript | Go | Rust | | -------------------------- | ------------------------------------------------------- | ---------------------------------------- | ---------------------------------------- | | Code with a type error | runs: Bun and Node don’t check types | doesn’t build | doesn’t build | | A value matches its type | not always: `any`, `as` and `JSON.parse` aren’t checked | yes, except with `unsafe` or a data race | yes, except with `unsafe` | | JSON with a missing field | passes, typed as whatever you claimed | the field quietly gets its zero value | `serde` refuses it, unless it’s optional | | Extra fields in a response | sent, unless something strips them | only the struct’s fields are sent | only the struct’s fields are sent | | Errors | anything can throw, and the type doesn’t say | that a call can fail is in the signature | `Result` in the signature | | Numbers | `number` is a float: 64-bit ids need `bigint` | sized integers | sized integers | | CPU-heavy work | one thread per process | all cores | all cores | | Dependencies | npm runs install scripts, Bun only for an allow list | fetching or building runs no code | build scripts run at build time | TypeScript’s types are erased before the code runs, so they’re claims, and Bun or Node will run code whose claims don’t hold: ```ts const order = (await res.json()) as Order; // compiles, and nothing checked it JSON.parse('{"id": 9007199254740993}').id; // 9007199254740992 ``` Rust is stricter almost everywhere. Its compiler refuses whole classes of bugs, and `sqlx` can even check each SQL query against a real database at build time. Go is simpler and has a careful [supply chain](https://go.dev/blog/supply-chain), while npm runs install scripts, which is how a worm spread through [more than 500 packages](https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem) in 2025. If your team writes Go or Rust, these are good reasons to stay there. ## Who reads the code Then why TypeScript? Because of who reviews the code. Agents write a lot of our code now, but someone still reviews it before it goes to production, and has to understand it. Tetsu was built that way: Claude wrote most of its code, and I reviewed every change. José Valim, who created Elixir, [wrote](https://x.com/josevalim/status/2108167477469327411) this week that because of coding agents he reviews significantly more code than before. And most of what that review is for isn’t a type error. In Stack Overflow’s [2025 survey](https://stackoverflow.co/company/press/archive/stack-overflow-2025-developer-survey/), the most common frustration with AI tools was output that is almost right, and 45% of developers said debugging AI-generated code takes them longer. Code that is almost right is exactly what a compiler can’t see: a refund that can exceed the order, a permission check on the wrong field, a status that lets a cancelled order ship. Rust’s compiler doesn’t know your business rules either. Cloudflare learned that in November 2025, when a large part of the web returned errors for about three hours. A database permissions change made a query return duplicate rows, the file that feeds Cloudflare’s bot detection doubled in size, and it went over a limit of 200 features in the new proxy, which is written in Rust. Rust had made the failure visible: it was an error value the code had to handle. The code called `unwrap()` on it, and the proxy [panicked](https://blog.cloudflare.com/18-november-2025-outage/). The compiler did its job. No compiler can tell you that a file which usually holds about 60 features will one day hold more than 200. Someone who knows the system can. Bun’s own team just showed what a strict compiler is best at: they [ported Bun from Zig to Rust](https://bun.com/blog/bun-in-rust) with Claude Code so that use-after-free and double-free bugs would become compile errors. Those are memory bugs, and application code on a garbage-collected runtime doesn’t have them. Its expensive bugs are in the business logic, and those are caught by tests and by a person who knows the business and reads the code fluently. You read fluently in the language you already write. If your team writes Go, that’s Go, and you should pick Go. If it’s a web team that writes TypeScript every day, and its front end too, it’s TypeScript. A model can write Rust for a team that doesn’t know Rust, but then nobody on the team can really review it. So for a TypeScript team, the useful question is how to make TypeScript code easy to review, and as safe as it can get. ## What makes code easy to review Reviewing Tetsu’s code made one thing clear to me: code is easy to review when everything that runs is visible where you read it. Every language has ways to hide it. Rust’s derive and attribute macros generate code you never see. The compiler checks it, but it isn’t the code you review, and reading it takes `cargo expand`. Go reads struct tags, which are plain strings, through reflection. TypeScript has decorators and DI containers, which decide at runtime what runs and what gets injected. How much of that a project uses is the framework’s choice more than the language’s. Tetsu uses none of it. It does use something TypeScript does better than Go, and without the macros Rust needs: types computed from your own code, such as a route’s parameters from its path, or a request body’s type from its schema. ## How Tetsu helps ### A route reads top to bottom A route in Tetsu is one object: the method, the path, schemas for each part of the request and response, the hooks that run around it, and the handler. ```ts const auth = hook.beforeParse(async (ctx) => { const user = await sessions.verify(ctx.req.headers.get("authorization")); if (!user) throw new HttpError(401); return { user }; }); const refund = route({ method: "POST", path: "/orders/:id/refund", schema: { params: z.object({ id: z.coerce.number() }), body: z.object({ amount: z.number().positive() }), response: { 200: RefundDto }, }, hooks: { beforeParse: [auth] }, handler: (ctx) => refunds.create(ctx.user, ctx.params.id, ctx.body.amount), }); ``` A reviewer sees who can call it, what it accepts, what it returns and what it does. That’s also what lets them see what the route doesn’t do: nothing here stops a refund larger than the order, unless `refunds.create` checks it. That’s the almost-right bug from earlier. Neither the compiler nor the schema can see it, but a reviewer with the whole route in front of them can ask. Hooks sit in named slots that always run in the same order, so there’s no `next()` chain to trace. Anything else that runs for this route sits on its group or the app, in the same form. There are no decorators, no container and no plugins: a controller gets its dependencies as function arguments, and the wiring is ordinary code in one file. ### Types show only what exists `ctx` is never annotated. A field is in its type exactly when it exists at that point: `ctx.params.id` because the path has `:id`, a number because the schema converts it, `ctx.body` because the route declares a body, `ctx.user` because `auth` returned it. Remove `auth`, and the handler stops compiling: ```ts route({ method: "GET", path: "/reports", handler: (ctx) => reportsFor(ctx.user), }); ``` A reviewer doesn’t have to check by eye whether auth ran before this handler. The compiler already did. ### Boundaries are parsed in one place The schemas are the checks. A request with a missing or malformed field gets a `422` that lists every issue, and the handler never runs. This is the old advice to [parse, don’t validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/): one schema at the edge instead of checks spread through the handler, and one place for a reviewer to look. Responses are parsed too, which closes one of the gaps from the table. TypeScript lets an object with extra fields pass for a type with fewer, so a stored user with a `passwordHash` passes for a public user. The response schema runs on the way out, and its output is what gets sent. With Zod or Valibot, that drops the hash. ArkType and TypeBox have to be told to: ```ts const PublicUser = z.object({ id: z.number(), name: z.string() }); route({ method: "GET", path: "/users/:id", schema: { params: z.object({ id: z.coerce.number() }), response: { 200: PublicUser } }, handler: (ctx) => users.find(ctx.params.id), // a stored user, passwordHash included }); // GET /users/1 → 200 {"id":1,"name":"Ada"} ``` ### Docs come from the code The OpenAPI document is built from the same routes and schemas, so a reviewer who approves a schema change has approved the docs too. Tetsu’s own documentation is written for agents as well as people: every page is also served as Markdown, all of it is in one file at [`/llms-full.txt`](/llms-full.txt), and the packages ship TSDoc that editors and agents read from `node_modules`. An agent that reads the right docs writes code that’s closer to right, which leaves less for the reviewer. ## Why not Hono or Elysia Both are good, and both infer types from the path and from schemas. Where they differ from Tetsu is how the code around a handler is put together, and that’s what a reviewer reads. In Hono, middleware wraps the handler with `next()`, and what runs for a route depends on the order of the `app.use()` calls. In Elysia, the app is a chain of method calls, each extending the type of the next, and a plugin’s scope decides how far its hooks reach. Both are more flexible than Tetsu. Tetsu trades that flexibility for one shape: hooks in fixed slots, listed on the route, its group or the app, and no plugins. It costs more typing, since each route lists its own hooks, and it gives up things the others do well. Hono runs on almost any runtime, and Elysia’s Eden gives the front end the server’s types directly. [Comparison](/docs/more/comparison/) has the details. ## What it doesn’t fix A framework can make TypeScript safer at the HTTP boundary, but not stricter everywhere. Errors still aren’t in any signature, numbers are still floats, CPU-heavy work still needs a worker, and a database row is still whatever the driver returned unless you parse it too. Tetsu has a limit of its own: hooks mounted on a group run for its routes but don’t add to their types, so a handler that reads `ctx.user` needs `auth` on its own route. ## Where TypeScript on the server is going The first row of the table is closing. Bun is adding a type checker of its own, [`bun check`](https://bun.com/docs/runtime/check), ported from typescript-go to Rust and already in canary builds. `bun --check server.ts` checks the code before running it, and `bun test --check` checks it before the tests, so code with a type error no longer has to run at all. I tried the canary build on Tetsu’s repository: on the same 1,588 files, it reported the same errors as `tsc`, with the same messages, in 0.22 seconds against 0.8 for TypeScript 7, on an M5 Pro. The TypeScript compiler itself was rewritten in Go, and TypeScript 7 came out in July [about ten times faster](https://www.infoworld.com/article/4196378/go-based-typescript-7-0-arrives.html). For a team that reviews TypeScript, that means the type check can run on every save and every test run, not only in CI. There’s money behind it too. Bun had no revenue when Anthropic bought it in December 2025. Claude Code ships to millions of users as a Bun executable, so Anthropic has a direct stake in Bun’s quality, and Bun’s [own post](https://bun.com/blog/bun-joins-anthropic) about the deal says its roadmap still includes replacing Node.js as the default runtime on the server. Since then, [Bun 1.4](https://bun.com/blog/bun-v1.4) fixed more than 2,900 issues, the runtime itself was rewritten in Rust, and the `bun` package on npm had [15 million downloads](https://api.npmjs.org/downloads/point/2026-09-01:2026-09-30/bun) in September 2026, ten times [more than a year before](https://api.npmjs.org/downloads/point/2025-09-01:2025-09-30/bun). I have no ties to Anthropic, for the record. Tetsu runs only on Bun, so if Bun stalls, so does Tetsu. All of this lowers that risk without removing it. ## When I’d pick which If your team writes Go or Rust, pick Go or Rust, for the same reason I picked TypeScript: you’ll review that code best. If your team writes TypeScript, especially on the front end, TypeScript on the server is a reasonable choice in 2026, if you do four things: * Run `tsc --noEmit` in CI, or `bun check` once it’s in a stable Bun release. * Turn on `strict` and `noUncheckedIndexedAccess`, and ban `any` in the linter, for example with Biome’s `noExplicitAny`. * Parse every boundary with a schema: requests, responses, database rows, other services’ replies and configuration. * Choose tools that keep the code readable: no hidden wiring, nothing that runs without being visible where you read. That’s why I wrote Tetsu in TypeScript. Agents can write the code, but someone still has to understand it, and the language you understand best is the one you already write. Tetsu is MIT licensed. The [quick start](/docs/quick-start/) builds a first app with a test, and the code is on [GitHub](https://github.com/tetsujs/tetsu). # Introduction > Tetsu is an HTTP framework for Bun with controllers, lifecycle hooks in fixed slots and types inferred from end to end. Tetsu is an HTTP framework for Bun. Controllers get their dependencies as function arguments, hooks run in fixed slots instead of a middleware chain, and the compiler infers every type from the path to the handler. There are no decorators, no DI container and no dependencies in the core. ```ts import { controller, createApp, httpError, route } from "@tetsujs/core"; import { z } from "zod"; const users = controller("Users", ({ repo }: { repo: UserRepository }) => ({ get: route({ method: "GET", path: "/users/:id", schema: { params: z.object({ id: z.coerce.number() }) }, handler: (ctx) => { const user = repo.find(ctx.params.id); if (!user) throw httpError(404, "USER_NOT_FOUND"); return user; }, }), })); Bun.serve({ ...createApp({ routes: users({ repo }) }) }); ``` Nothing here is annotated. `ctx.params.id` is a number because the schema converts the path segment, and without `:id` in the path it would not exist. A request with a bad id gets a `422` and never reaches the handler. ## What it is not Tetsu runs on Bun only. It has no plugin system: a package such as CORS or rate limiting is a hook, mounted like your own. It has no container, no file-based routing and no global registry: what runs for a route is declared in code, on the route, its groups or the application. ## Where to go next * [Installation](/docs/installation/) — requirements and TypeScript settings. * [Quick start](/docs/quick-start/) — a first application with a test. * [Key concepts](/docs/key-concepts/) — the whole model on one page. # Cancellation and timeouts > Stopping work when the client leaves with ctx.req.signal, adding a deadline with AbortSignal.timeout, and why the framework has no timeout of its own. Work started for a request can outlive the reason for it: the client disconnects, or an upstream call takes longer than anyone will wait. The platform’s `AbortSignal` handles both. ## The request’s signal `ctx.req.signal` aborts when the client disconnects. Pass it to the work the handler starts, and that work stops when nobody is left to receive the answer: ```ts route({ method: "GET", path: "/rates", handler: async (ctx) => { const res = await fetch("https://rates.example.com/latest", { signal: ctx.req.signal }); const rates: Rates = await res.json(); return rates; }, }); ``` `fetch`, Bun’s own APIs, `node:timers/promises` and most database drivers take a signal. `@tetsujs/sse` passes one to its generator for you; see [Streaming](/docs/concepts/streaming/). ## Deadlines `AbortSignal.timeout()` aborts after a delay, and `AbortSignal.any()` combines it with the request’s signal, so the work stops at whichever comes first: ```ts route({ method: "GET", path: "/quotes", handler: async (ctx) => { const signal = AbortSignal.any([ctx.req.signal, AbortSignal.timeout(5_000)]); return await upstream.fetch({ signal }); }, }); ``` An expired deadline rejects with a `DOMException` named `TimeoutError`. Left alone, it answers `500` and is reported as `unhandled`. An `onError` hook turns it into the answer you mean: ```ts const upstreamTimeout = hook.onError((ctx) => { if (ctx.error instanceof DOMException && ctx.error.name === "TimeoutError") { return Response.json(errorBody(504, "UPSTREAM_TIMEOUT"), { status: 504 }); } }); ``` ## A signal stops only the work it was passed to Aborting a signal does not stop the handler. It stops the calls that were given the signal; everything else runs on. A query started without one runs to completion, and a loop that never checks the signal keeps looping. So pass the signal to every call that may take long, and in a loop of your own call `signal.throwIfAborted()` between steps. ## Why there is no framework timeout A framework timeout would answer `503` once a request takes too long. The framework does not offer one, because JavaScript cannot interrupt a running function: the handler would keep running, mid-query or mid-payment, after the client was told the request failed. A client that retries on `503` would then run the operation twice. A deadline that really stops the work has to be passed to the work, and only the handler knows which calls are safe to abandon. `AbortSignal.any()` with `AbortSignal.timeout()` is that deadline, in one line. For a retry that must not repeat its effect, the client sends an idempotency key and the handler remembers what it answered. ## Connection timeouts Bun closes a connection that sends nothing for its `idleTimeout`, set where the application is served. `ctx.server.timeout(ctx.req, seconds)` changes it for one request, such as a long upload or a slow report: ```ts route({ method: "POST", path: "/reports", handler: async (ctx) => { ctx.server.timeout(ctx.req, 120); return await reports.build(ctx.req.signal); }, }); ``` An event stream from `@tetsujs/sse` sets its own, above its heartbeat; see [Idle connections](/docs/packages/sse/#idle-connections). On a unix socket Bun ignores `ctx.server.timeout()`, and only the server’s `idleTimeout` applies. # Context and its types > What is on ctx at each point of a request, why a field exists only when something provides it, and how a reusable hook states what it needs. `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. ## What is on it * **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](/docs/guides/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](/docs/concepts/responses/) and [Cookies](/docs/concepts/cookies/). The [context reference](/docs/reference/context/) lists every field with its type and the slot it exists from. ## No schema, no field `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: ```ts route({ method: "GET", path: "/notes", handler: (ctx) => ctx.query.page, }); ``` 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. `ctx.params` always exists, typed from the path. ## Fields from hooks 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: ```ts import { hook, HttpError, route } from "@tetsujs/core"; const session = hook.beforeParse((ctx) => { const found = sessions.get(ctx.req.headers.get("authorization") ?? ""); if (!found) throw new HttpError(401); return { session: found }; }); route({ method: "GET", path: "/me", hooks: { beforeParse: [session] }, handler: (ctx) => ({ id: ctx.session.userId }), }); ``` 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. ## Stating what a hook needs 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: ```ts import type { Requires } from "@tetsujs/core"; import { hook, HttpError } from "@tetsujs/core"; export const withNote = hook.beforeHandle( async (ctx: Requires<{ params: { id: number }; user: User }>) => { const note = await notes.find(ctx.params.id); if (!note || note.owner !== ctx.user.id) throw new HttpError(404); return { note }; }, ); ``` `Requires` 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: ```ts hooks: { beforeParse: [auth] }, ``` To reuse a whole set of hooks across routes, see [Mounting hooks](/docs/concepts/groups-and-mounting/#mounting-hooks). Use group hooks for work that needs no typed field later: guards that refuse, logs, metrics, CORS. See [Groups and mounting](/docs/concepts/groups-and-mounting/#what-a-level-sees) for what group hooks can see of each other. ## What a hook cannot put there 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. # Controllers and dependencies > A controller is a name and a function from its dependencies to its routes, wired by hand in one composition root. A controller groups the routes of one part of an API and gives them the services they depend on. ## Declaring a controller `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: ```ts import { controller, httpError, route } from "@tetsujs/core"; export interface OrdersDeps { readonly orders: OrderService; } export const ordersController = controller("Orders", ({ orders }: OrdersDeps) => ({ list: route({ method: "GET", path: "/orders", handler: () => orders.all() }), get: route({ method: "GET", path: "/orders/:id", handler: (ctx) => { const order = orders.find(ctx.params.id); if (!order) throw httpError(404, "ORDER_NOT_FOUND"); return 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](/docs/guides/testing/) covers `testCtx()`. ## The name is a contract [`@tetsujs/openapi`](/docs/packages/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. ## The composition root 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. ```ts import type { Database } from "bun:sqlite"; import { createApp, group } from "@tetsujs/core"; import { requestId } from "@tetsujs/request-id"; import { accessLog } from "@tetsujs/request-log"; export function buildApp(db: Database, tokens: ReadonlyMap) { const notes = new NoteStore(db); const sessions = new Sessions(tokens); return createApp({ hooks: { beforeParse: [requestId()], afterResponse: [accessLog()] }, routes: group("/api", { children: [notesController({ notes, sessions })] }), }); } ``` `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`](https://github.com/tetsujs/tetsu/blob/main/examples/app/app.ts), a small notes API on `bun:sqlite`. To get a controller’s dependency type without importing the interface, use `Parameters[0]`. ## Hooks and dependencies A hook that needs a service is built inside the controller, from the dependency it received: ```ts import { controller, hook, HttpError, route } from "@tetsujs/core"; const authenticate = (sessions: Sessions) => hook.beforeParse((ctx) => { const token = ctx.req.headers.get("authorization")?.replace(/^Bearer /, ""); const user = token ? sessions.find(token) : undefined; if (!user) throw new HttpError(401); return { user }; }); export const notesController = controller( "Notes", ({ notes, sessions }: { notes: NoteStore; sessions: Sessions }) => { const signedIn = authenticate(sessions); return { list: route({ method: "GET", path: "/notes", hooks: { beforeParse: [signedIn] }, handler: (ctx) => notes.list(ctx.user.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: ```ts import { controller, createApp, route } from "@tetsujs/core"; import type { RateLimitHook } from "@tetsujs/rate-limit"; import { rateLimit } from "@tetsujs/rate-limit"; const notesController = controller("Notes", ({ notes, limit }: { notes: NoteStore; limit: RateLimitHook }) => ({ list: route({ method: "GET", path: "/notes", hooks: { beforeParse: [limit] }, handler: () => notes.list(), }), })); const limit = rateLimit({ limit: 100, windowMs: 60_000, key: (ctx) => ctx.server.requestIP(ctx.req)?.address, }); createApp({ routes: notesController({ notes: store, limit }) }); ``` See [Groups and mounting](/docs/concepts/groups-and-mounting/#mounting-hooks) for more on where hooks and their state live. ## Why a function and not a class 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. ## Handing a controller the application 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: ```ts import type { App } from "@tetsujs/core"; import { controller, onMount, route } from "@tetsujs/core"; export const routesController = controller("Routes", () => { let mounted: App | undefined; return { [onMount]: (app: App) => { mounted = app; }, list: route({ method: "GET", path: "/routes", handler: () => mounted?.entries.map((entry) => `${entry.method} ${entry.path}`) ?? [], }), }; }); ``` It runs at startup, so an error thrown there stops the process. This is how `docs()` from `@tetsujs/openapi` builds its document. # Cookies > Reading cookies through schema.cookies, writing them on ctx.out.cookies, signing them with an application secret, reading a signed cookie early with signedCookie(), and cross-site sessions. Cookies arrive as a request part and leave on `ctx.out`. With a secret, the application signs the cookies it names on the way out and verifies them on the way in, without any call site mentioning it. ## Reading cookies `schema.cookies` validates incoming cookies and adds `ctx.cookies`, typed by the schema’s output: ```ts const Prefs = z.object({ theme: z.enum(["light", "dark"]).default("light") }); route({ method: "GET", path: "/prefs", schema: { cookies: Prefs }, handler: (ctx) => ({ theme: ctx.cookies.theme }), }); ``` Cookies are a record of strings and are validated like `query`: in the same phase, with the same `422`. A required cookie that is missing is reported at `["cookies", "theme"]`. A name sent twice reads as its first value, which is the host’s own cookie rather than one a sibling subdomain set. Without a schema there is no `ctx.cookies`. Bun’s `ctx.req.cookies` holds the values as the client sent them, unchecked. ## Writing cookies Outgoing cookies are written by name on `ctx.out.cookies`: ```ts route({ method: "POST", path: "/session", handler: async (ctx) => { ctx.out.cookies.set("session", await sessions.open(), { httpOnly: true, maxAge: 3600 }); ctx.out.cookies.delete("flash"); }, }); ``` The attributes are Bun’s `CookieInit` without the name and value: `path`, `domain`, `maxAge`, `expires`, `httpOnly`, `secure`, `sameSite`, `partitioned`. Left out, Bun’s defaults apply: `Path=/` and `SameSite=Lax`. * **Writing a name twice replaces it.** Different names accumulate. * **Deleting needs the same `path` and `domain`** the cookie was set with. The browser treats a different path as a different cookie. * **Cookies go out with every response**, including a redirect, an error and a hook’s short-circuit. Changes to Bun’s `ctx.req.cookies` are sent too, but only `ctx.out.cookies` signs, and only it works on every response, including the `404` fallback and unit tests. ## Signed cookies Give the application a secret, and the cookies it names are signed on the way out and verified on the way in: ```ts const app = createApp({ cookies: { secret: Bun.env.COOKIE_SECRET!, sign: ["session"] }, routes, }); ``` | `cookies` | | | --------- | ------------------------------------------------------------------------ | | `secret` | the key, 32 random bytes or more | | `sign` | a name, a list of names, or `true` for every cookie; `true` when omitted | A signed cookie is sent as `value.signature`, an HMAC-SHA256 of the value. `ctx.out.cookies.set("session", "42")` sends `session=42.DESNm6lp…`, and `ctx.cookies.session` reads `"42"` back. Generate the secret once and keep it with the application’s other secrets: ```bash openssl rand -base64 32 ``` An empty or missing secret is refused at startup with a `TypeError`. Prefer a list of names over `true`. With `true`, a cookie set by anything else, such as an analytics script or a proxy, fails verification and reads as absent. Signing proves the value came from the application. It does not hide it: the client can read a signed cookie, so it should hold an identifier, not a secret. ### A forged signature is treated as absent A cookie whose signature does not hold is left out. `schema.cookies` then reports it missing, so a forger cannot tell a bad signature from a cookie that was never sent. Cookies a hook returns are checked the same way. A hook that takes a mobile client’s session from a header returns it signed, exactly as the client sent it; a plain `session: "admin"` is dropped. Cookies passed on unchanged from `ctx.cookies` are already verified and stay. ## Reading a signed cookie early `ctx.cookies` is filled at validation, after the body is read. A hook that authenticates earlier, to refuse before the body is read or to give a rate limit a user to count, uses `signedCookie()`. It checks the signature and returns the value without it: ```ts const auth = hook.beforeParse((ctx) => { const userId = signedCookie(ctx, "session"); if (!userId) throw new HttpError(401); return { userId }; }); route({ method: "GET", path: "/me", hooks: { beforeParse: [auth] }, handler: (ctx) => ({ id: ctx.userId }), }); ``` * It reads the request’s `cookie` header, not what a hook put on the context. * It returns `undefined` when the cookie is missing or its signature does not hold. Of a name sent twice, it takes the first value that verifies. * It throws for a name the application does not sign: that is a bug in the code, not in the request. This is also how a missing session answers `401`. A session required by `schema.cookies` fails validation with `422` instead, after the body was read. ## Cross-site cookies and CORS A frontend on another site that sends the session with `cors({ credentials: true })` needs the cookie set with `secure: true` and `sameSite: "none"`: ```ts route({ method: "POST", path: "/session", handler: (ctx) => { ctx.out.cookies.set("session", token, { httpOnly: true, secure: true, sameSite: "none" }); }, }); ``` The default, `Lax`, keeps the cookie off cross-site requests, and browsers refuse `none` without `secure`. See [`@tetsujs/cors`](/docs/packages/cors/) for the other half. A cookie sent from any site also needs a check against cross-site requests; see [Authentication](/docs/guides/authentication/#cookies-across-sites-and-csrf). ## Testing code that signs A handler called with `testCtx()` has no application behind it, and so no secret: pass the application’s cookie options as its second argument. A client from `serve(app).client()` keeps a cookie jar that holds a signed cookie as it arrived. [Testing](/docs/guides/testing/#signed-cookies-in-a-unit-test) shows both. # Errors > How HttpError and httpError become responses, the error envelope, how onError hooks map errors at every level, and where failures that cannot become a response go. An error thrown anywhere in a request becomes a response. This page covers the errors you throw, the shape every error response has, `onError` hooks that answer errors your own way, and `reportError`, which receives the failures no response can carry. ## Throwing an error A handler or a hook refuses a request by throwing. `HttpError` carries a status, and `httpError()` builds one with an error code of your own: ```ts route({ method: "POST", path: "/orders/:id/ship", handler: (ctx) => { const order = orders.find(ctx.params.id); if (!order) throw new HttpError(404); // { "status": 404, "message": "Not Found", "error": "NOT_FOUND" } if (order.shipped) throw httpError(409, "ALREADY_SHIPPED", "Order already shipped"); // { "status": 409, "message": "Order already shipped", "error": "ALREADY_SHIPPED" } return order; }, }); ``` The status is always yours to choose. `httpError(status, error, message)` is the usual way to throw. With `new HttpError(status, body)`, the second argument decides the body: | `body` | The response body | | ------------- | ---------------------------------------------- | | none | the envelope for the status | | a string | the envelope, with the string as its `message` | | anything else | the value itself, as JSON | Anything thrown that is not an `HttpError`, such as a `TypeError` or a driver’s error, answers `500` with the envelope and nothing of the error itself, and goes to [`reportError`](#failures-that-cannot-become-a-response). ## The envelope Every error the framework produces, and every `HttpError` without a body of its own, has one shape: ```json { "status": 404, "message": "Not Found", "error": "NOT_FOUND" } ``` * `status` repeats the HTTP status, for a client that kept only the payload. * `error` is the machine-readable code in `UPPER_SNAKE_CASE`. Branch on it. * `message` is for people and may change. Never match on it. By default the message is the status’s reason phrase, and the code is that phrase in upper snake case: `Unprocessable Content` is `UNPROCESSABLE_CONTENT`. A validation failure adds `issues`, one per rejected value, with a `path` that starts at the request part; see [Validation errors](/docs/concepts/validation/#validation-errors). `errorBody(status, error, message)` builds the same envelope as a plain object, to add fields of your own or to answer with a `Response` of your own: ```ts const closed = hook.beforeParse(() => { if (maintenance.on) { throw new HttpError(503, { ...errorBody(503, "MAINTENANCE"), until: maintenance.until }); } }); ``` Thrown, it still reaches the `onError` hooks; a returned `Response` does not. ## The framework’s own failures The framework raises its own failures as `HttpError`s in the same envelope: `MALFORMED_JSON` or `MALFORMED_FORM` for a body that does not parse, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `BODY_TOO_LARGE`, `VALIDATION_FAILED`, and `INTERNAL_SERVER_ERROR` for anything unexpected. [Framework error codes](/docs/reference/error-codes/) lists every one, those of the packages included. All of them reach the application’s `onError` hooks, the `404` and `405` too, so one hook can decide the format of every error. ## `onError` hooks An `onError` hook runs when a stage throws. It sees the error as `ctx.error`, and answers with a `Response`, or returns nothing to pass the error on: ```ts const domainErrors = hook.onError((ctx) => { if (ctx.error instanceof OrderNotFound) { return Response.json(errorBody(404, "ORDER_NOT_FOUND"), { status: 404 }); } }); ``` This is how a domain error becomes a response without the domain knowing about HTTP: the service throws `OrderNotFound`, and the hook decides it is a `404`. `onError` hooks mount on a route, a group or the application, and run from the route outwards, the opposite of the other slots (see [Groups and mounting](/docs/concepts/groups-and-mounting/#application-hooks)). The first `Response` wins. When none answers, an `HttpError` gets its own status and body, and anything else a `500`. * **It returns a `Response` or nothing.** Returning an object is a compile error. * **Validated parts and fields added by hooks are optional.** The error may have come before validation or before the hook that adds a field ran, so narrow before reading them. * **A hook that throws is skipped.** Its error goes to `reportError` with `source: "onError"`, and the next hook, or the default mapping, answers the original error. * **Its response goes the rest of the way out.** It passes the `beforeResponse` hooks that have not run yet, gets `ctx.out.headers`, and is seen by `afterResponse`. * **Only the application’s hooks see `404`, `405` and preflights.** No route is behind them, so a group’s `onError` never runs for them. ## An error format of your own An `onError` hook on the application replaces the format for every failure: an `HttpError` you threw, a validation or body failure, an unmatched path or method, a rate limit’s refusal, and an unexpected error. ```ts import type { ErrorBody } from "@tetsujs/core"; import { hook, HttpError, reportFailure } from "@tetsujs/core"; export const inOurFormat = hook.onError((ctx) => { const { error } = ctx; if (error instanceof HttpError) { const { status, error: code, ...rest } = error.body as ErrorBody; return Response.json({ code, ...rest }, { status }); } reportFailure(ctx, "unhandled", error); return Response.json( { code: "INTERNAL_SERVER_ERROR", message: "Internal Server Error" }, { status: 500 }, ); }); ``` `reportError` only hears of a failure no `onError` hook answered. So a hook that answers unexpected errors reports them itself, with `reportFailure`. Without that line, a lost database connection answers in your format and never reaches the error tracker. The generated document does not read that hook. Tell [`@tetsujs/openapi`](/docs/packages/openapi/#an-error-format-of-your-own) the same format with its `errors` option, and test the two against each other with [`assertDescribed`](/docs/packages/openapi/#testing-against-the-document). ## Failures that cannot become a response Some failures have no response to become: an `afterResponse` observer that throws after the response has gone, a WebSocket handler, a stream whose generator breaks mid-body, an error no `onError` hook answered. These are the only things the framework logs, and by default they go to `console.error`. Pass `reportError` to `createApp` to receive them yourself: ```ts const logger = pino(); const app = createApp({ hooks: { beforeParse: [requestId()] }, reportError: ({ source, error, ctx }) => logger.error({ err: error, source, requestId: ctx?.requestId }, "tetsu"), routes, }); ``` A report carries: * `error`: what was thrown, untouched, so the logger’s redaction still applies. * `ctx`: the request’s context, with the fields your hooks add typed as optional. Absent where there was no request: a WebSocket event, a shutdown. * `source`: what failed. | `source` | What failed | | --------------- | ---------------------------------------------------------------------------------------------------------------------- | | `unhandled` | an error no `onError` hook answered, and not an `HttpError`; the request got a `500` | | `response` | a handler broke its response contract: an undeclared status, a body its schema rejects | | `onError` | an `onError` hook threw; the next one, or the default mapping, answered instead | | `errorResponse` | the error path kept failing, and the request got a plain `500` | | `afterResponse` | an `afterResponse` observer threw; the response had already gone | | `websocket` | a WebSocket handler threw, a message schema threw instead of reporting issues, or an endpoint’s `until` function threw | | `stream` | a streamed body’s generator threw, or its `onEnd` did (`@tetsujs/sse`) | | `shutdown` | a closer threw while the process was stopping (`@tetsujs/lifecycle`) | An `HttpError` no hook answered is not reported: it is an answer, not a failure. The receiver is called in place and never awaited, so the error path never waits on a log shipper. If it throws or its promise rejects, that error is printed to `console.error` with the original report. `reportFailure(ctx, source, error)` sends a report the way the framework does, for code that meets a failure it cannot answer: a hook package, a background task started from a handler, or the `onError` hook above. `source` can be any string. With a context from `testCtx()`, which has no application behind it, the report is printed. # Groups and mounting > Groups add a path prefix and hooks to the routes under them, application hooks run for every request, and a few habits keep hook mounting readable. A group mounts controllers under a path prefix and adds hooks to every route below it. The application has hooks of its own that run for every request. ## Groups `group(prefix, { hooks, children })` adds its prefix to every route of its children. Children are controllers, routes or other groups: ```ts import { createApp, group } from "@tetsujs/core"; const adminOnly = hook.beforeParse((ctx) => { if (ctx.req.headers.get("x-role") !== "admin") throw new HttpError(403); }); createApp({ routes: group("/api", { children: [ notesController(), group("/admin", { hooks: { beforeParse: [adminOnly] }, children: [statsController()], }), ], }), }); ``` The routes are `GET /api/notes` and `GET /api/admin/stats`, and only the second runs `adminOnly`. A route with the path `/` inside a group answers at the prefix itself. A prefix follows the path rules, and also cannot be `/` alone, contain a `*` or declare a `:param`. A controller is typed where it is written, so a parameter from the prefix would never appear in `ctx.params`. Groups nest only through `group()`; a controller never contains another controller. The whole shape of the API is written in one place. ## Application hooks `createApp({ hooks })` takes hooks keyed by slot, like a route and a group. They run for every request the application answers: ```ts import { createApp } from "@tetsujs/core"; import { cors } from "@tetsujs/cors"; import { requestId } from "@tetsujs/request-id"; import { accessLog } from "@tetsujs/request-log"; const id = requestId(); const browser = cors({ origin: "https://app.example.com" }); const log = accessLog(); createApp({ hooks: { beforeParse: [id, browser], afterResponse: [log], }, routes, }); ``` For a matched route, each slot runs the application’s hooks first, then each group’s from the outside in, then the route’s own. `onError` runs the other way, route first and application last, so the most specific hook maps an error first. ## Requests no route answered A `404`, a `405` and an `OPTIONS` preflight also go through the lifecycle, but only with the application’s hooks. Group hooks do not run for them, because no route in the group matched. This means application-wide logs and rate limits see requests nothing handled, and a CORS hook on the application can answer a preflight. It also keeps a group’s guard from refusing a preflight: an authentication hook on `/admin` would otherwise turn every preflight into a `401` the browser cannot read. That is why `cors()` belongs on the application. The `404` and `405` also reach the application’s `onError` hooks as an `HttpError`, so one hook sets the error format for everything. ## What a level sees Group and application hooks run for routes they do not know, so they cannot see a route’s schemas or its hooks’ fields. They can see what earlier hooks **at the same level** returned, in the same slot or an earlier one: ```ts import type { Requires } from "@tetsujs/core"; import { createApp, hook } from "@tetsujs/core"; import { requestId } from "@tetsujs/request-id"; const id = requestId(); const scope = hook.beforeParse((ctx: Requires<{ requestId: string }>) => ({ tenant: ctx.req.headers.get("x-tenant") ?? "public", })); createApp({ hooks: { beforeParse: [id, scope] }, routes, }); ``` `scope` declares what it reads with `Requires`, and compiles because `id` comes first. In the other order it is a compile error that names the missing field. In `beforeResponse`, `afterResponse` and `onError` such fields are optional, `Requires<{ requestId?: string }>`, since the hook that adds them may not have run. What a group hook returns does not reach the handlers’ types, for the reason [Context](/docs/concepts/context/#why-a-group-hooks-field-is-not-in-the-handlers-type) explains. ## Mounting hooks A few habits keep it clear what runs for each request. **Make a hook once, in a named constant.** Write `const log = accessLog()`, then `afterResponse: [log]`. The slot then reads as a list of names, and you do not create a new instance each time the surrounding code runs. **Share hooks, not `hooks` objects.** To reuse a set of hooks, declare it `as const` and spread each slot where it is mounted: ```ts import { createApp } from "@tetsujs/core"; const common = { beforeParse: [id, browser], afterResponse: [log] } as const; createApp({ hooks: { beforeParse: [...common.beforeParse, auth], afterResponse: [...common.afterResponse], }, routes, }); ``` Without `as const`, the array loses its element types, and mounting it is a compile error. Spreading whole `hooks` objects, or `Object.assign`, replaces a slot instead of joining it. For every route under a prefix, use a group’s `hooks`. **State lives in the instance.** One `rateLimit()` mounted on two groups shares one counter. For separate limits, make two instances. To share a hook’s state across controllers, make it in the composition root and pass it in; see [Controllers](/docs/concepts/controllers/#hooks-and-dependencies). **An instance runs once per request.** The same hook mounted twice in one route’s chain, for example on a group and on a route under it, is refused at startup. **You choose the order within a slot.** The compiler checks what a hook needs, not what should come first. Put `cors()` before every hook that can refuse a request, so the refusal carries the headers a browser needs to read it. **A package is one hook.** A hook package is a function that takes options and returns one hook; there is no plugin system. See [Writing a hook package](/docs/guides/writing-a-hook-package/). # Lifecycle hooks > Hooks run in fixed, named slots of a request, extend the context by returning an object, and refuse a request by throwing or returning a Response. A hook is a function bound to one slot of the request lifecycle. Hooks do authentication, logging, headers and error mapping around a handler. ## The slots A request passes through fixed stages. There is no `next()`: every hook runs at one point, named by its slot. ```text beforeParse → parse → beforeValidation → validate → beforeHandle → handler → beforeResponse → afterResponse 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](/docs/reference/hooks/) lists what each slot’s context holds. ## Making a hook Make a hook with its slot’s factory, `hook.(fn)`. The function receives `ctx` and returns one of three things: * **nothing**: the request goes on; * **an object**: its fields are added to `ctx`, typed in later hooks and in the handler; * **a `Response`**: the request stops, and that response is sent. To refuse a request, throw an `HttpError` or return a `Response`. Any other thrown error becomes a `500`. ```ts import { hook, HttpError, route } from "@tetsujs/core"; export const auth = hook.beforeParse(async (ctx) => { const user = await sessions.verify(ctx.req.headers.get("authorization")); if (!user) throw new HttpError(401); return { user }; }); route({ method: "GET", path: "/notes", hooks: { beforeParse: [auth] }, handler: (ctx) => notes.listFor(ctx.user.id), }); ``` A hook that sometimes returns nothing adds an optional field: `return user ? { user } : undefined` makes `ctx.user` a `User | undefined`. [Context](/docs/concepts/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. ## Mounting by slot Hooks are mounted by slot, the same way on a route, a group and the application: ```ts import { route } from "@tetsujs/core"; route({ method: "GET", path: "/me", hooks: { beforeParse: [auth], afterResponse: [log] }, handler: (ctx) => ctx.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](/docs/concepts/groups-and-mounting/) covers the order of hooks across the application, groups and a route. ## Before the handler 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](/docs/concepts/request-bodies/#raw-bytes-for-signatures). * **`beforeHandle`** sees the validated request. A reusable hook here states what it needs with `Requires`; see [Context](/docs/concepts/context/#stating-what-a-hook-needs). A `Response` returned from any of them skips the rest, handler included, but still goes through `beforeResponse` and `afterResponse`. ## `beforeResponse` 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()`](/docs/packages/openapi/#documenting-hooks). 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. ```ts import { hook } from "@tetsujs/core"; export const noStore = hook.beforeResponse((ctx) => { if (ctx.res.status >= 400) return; const res = new Response(ctx.res.body, ctx.res); res.headers.set("cache-control", "no-store"); return res; }); ``` 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` `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. ```ts import { hook } from "@tetsujs/core"; export const shipped = hook.afterResponse(async (ctx) => { const line = { status: ctx.res.status, route: ctx.route?.path, agent: ctx.req.headers.get("user-agent"), ip: ctx.server.requestIP(ctx.req)?.address, ms: performance.now() - ctx.startedAt, }; await shipper.send(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. ## `onError` 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. ```ts import { hook } from "@tetsujs/core"; export const conflicts = hook.onError((ctx) => ctx.error instanceof AlreadyShipped ? Response.json({ error: "ALREADY_SHIPPED" }, { status: 409 }) : undefined, ); ``` The error response then goes through `beforeResponse` and `afterResponse` like any other. [Errors](/docs/concepts/errors/#onerror-hooks) covers the order of `onError` hooks, what answers when none does, and an error format for the whole application. ## Synchronous hooks 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. # Request bodies > How a route declares its body with bodyType, how uploads arrive, how maxBodySize limits what is read, and how rawBody keeps the bytes a signature is over. A route declares how its body is read, and the framework reads it that way, within a size limit. ## Declaring the shape `bodyType` sets the body’s wire shape: | `bodyType` | `ctx.body` before validation | | | ---------- | ------------------------------- | ------------------------------------------------------------- | | `"json"` | `unknown` | the default | | `"form"` | a record of strings and `File`s | `multipart/form-data` and `application/x-www-form-urlencoded` | | `"text"` | `string` | the body as text | | `"stream"` | `ReadableStream` | the body unread, for the handler to consume | The route decides, not the `content-type` header, which many clients leave out: `fetch` sends none for a plain string body. A body that does not parse as the declared shape is a `400`, `MALFORMED_JSON` or `MALFORMED_FORM`. With a `body` schema, `ctx.body` is the schema’s output. Without one, it is the parsed shape from the table: ```ts route({ method: "POST", path: "/notes/import", bodyType: "text", handler: (ctx) => ({ lines: ctx.body.split("\n").length }), }); ``` A route reads its body only when it has a `body` schema, a `bodyType` or `rawBody: true`. Otherwise the body is never read and `ctx.body` does not exist. The body is read after the `beforeParse` hooks, so a hook there that refuses a request costs no reading at all. A handler can still read `ctx.req.body` itself, but `maxBodySize` does not apply to it. To read the body yourself, declare `"stream"` instead. ## Uploads A `"form"` body is parsed with Bun’s own parser. Files arrive as `File` values in `ctx.body`, next to the text fields, and are validated like any other field. With TypeBox, `file()` and `files()` from [`@tetsujs/typebox`](/docs/packages/typebox/#files) describe them; with Zod, `z.file()`: ```ts import { controller, route } from "@tetsujs/core"; import { file, Type, tb } from "@tetsujs/typebox"; const ImageUpload = tb( Type.Object({ title: Type.String({ minLength: 1 }), image: file({ maxSize: "2m", type: "image" }), }), ); export const imagesController = controller("Images", () => ({ upload: route({ method: "POST", path: "/images", bodyType: "form", maxBodySize: 3 * 1024 * 1024, schema: { body: ImageUpload }, handler: async (ctx) => { ctx.out.status = 201; return await images.save(ctx.body.title, ctx.body.image); }, }), })); ``` * A field sent once is a value, a repeated one an array, as in the query string. * A file input left empty is dropped, as if the field were not sent, so an optional file field works on an ordinary HTML form. * A file’s `type` is the MIME type the client declared, not what the bytes are. * `maxBodySize` counts the whole form, multipart framing included. Without a schema, `ctx.body` is the record of fields and files as they arrived. ## Size limits `maxBodySize` is the most a route reads, in bytes. It is 1 MiB by default, set for the application on `createApp`, and overridden per route, as the upload above does: ```ts import { createApp } from "@tetsujs/core"; createApp({ routes, maxBodySize: 256 * 1024 }); ``` Raise it on the route that needs more, not for the whole application. An oversized body is refused without being buffered: * A `content-length` above the limit is refused before any byte is read. * A chunked body is counted as it arrives and refused at the first chunk that crosses the limit. The rest is left unread, so that connection cannot carry another request. * A `"stream"` body is counted as the handler reads it. Each is the same `413`: ```json { "status": 413, "message": "Body exceeds the configured limit", "error": "BODY_TOO_LARGE" } ``` ### Streamed bodies A `"stream"` body reaches the handler unread, so a large upload is never held in memory: ```ts import { route } from "@tetsujs/core"; route({ method: "PUT", path: "/backups/:name", bodyType: "stream", maxBodySize: 5 * 1024 ** 3, handler: async (ctx) => { await storage.put(ctx.params.name, ctx.body); }, }); ``` The limit fails later here: the handler is already running when it is crossed. The stream errors with the `413`, and whatever reads it rethrows it. Cleaning up a partial write is the handler’s job, in a `finally`. A `"stream"` body cannot have a `body` schema, since nothing is read before the handler runs. Declaring both is a compile error. ### Bun’s own limit `Bun.serve` has its own `maxRequestBodySize`, 128 MiB by default, and refuses a larger body with a bare `413` before the application sees it. When any `maxBodySize` in the application is above that, `createApp()` puts a slightly higher `maxRequestBodySize` on the app, and `Bun.serve({ ...app })` picks it up. It is only ever raised, and a value written after the spread still wins. A handler that reads `ctx.req` itself is limited by this cap alone. ### Large uploads For files of many megabytes, the usual design keeps them out of the application: the route checks who is asking and returns a pre-signed URL, and the client uploads straight to object storage. ## Raw bytes for signatures A webhook is signed over the exact bytes it was sent as. `rawBody: true` keeps them in `ctx.rawBody`, next to the parsed and validated `ctx.body`. The bytes are there from `beforeValidation` on, so a hook in that slot can check the signature before anything is validated: ```ts import type { Requires } from "@tetsujs/core"; import { hook, httpError, route } from "@tetsujs/core"; import { z } from "zod"; const PaymentEvent = z.object({ id: z.string(), amount: z.number() }); const signed = hook.beforeValidation((ctx: Requires<{ rawBody: Uint8Array }>) => { if (!verify(ctx.rawBody, ctx.req.headers.get("x-signature"))) { throw httpError(401, "BAD_SIGNATURE"); } }); route({ method: "POST", path: "/webhooks/payments", rawBody: true, schema: { body: PaymentEvent }, hooks: { beforeValidation: [signed] }, handler: (ctx) => payments.record(ctx.body), }); ``` `ctx.rawBody` is typed only on a route that asks for it, so a hook that needs it cannot be mounted on one that does not. It works with `json` and `text` bodies; with `form` or `stream` it is a compile error. The body is held twice, as bytes and as what was parsed, so ask for it only where a signature needs it. The [Webhooks guide](/docs/guides/webhooks/) builds a complete receiver on this. # Responses > What a handler's return value becomes, what ctx.out carries, how a response map lists the statuses a route answers with, and how redirects and your own Response objects are sent. A handler answers by returning a value, and the framework turns that value into the response. ## What a handler’s return becomes | The handler returns | The response | | ----------------------- | ---------------- | | `undefined`, or nothing | `204`, no body | | a `Response` | sent as it is | | any other value | JSON, with `200` | `ctx.out.status` replaces the `200` or `204` of a serialized value. It has no effect on a `Response`, which states its own status: ```ts route({ method: "POST", path: "/orders", handler: (ctx) => { const order = orders.create(); ctx.out.status = 201; ctx.out.headers.set("location", `/orders/${order.id}`); return order; }, }); ``` A `ReadableStream` or a generator returned bare is a compile error, and a `500` at runtime: as JSON it would be `{}`. A stream goes out inside a `Response` that states its content type. See [Streaming](/docs/concepts/streaming/). ## `ctx.out` `ctx.out` is what the response carries besides its body. `ctx.headers` and `ctx.cookies` are what arrived; `ctx.out.headers` and `ctx.out.cookies` are what leaves. | Field | | | --------- | ----------------------------------------------------------------------------------------------------- | | `status` | the status of a serialized result | | `headers` | a standard `Headers` | | `cookies` | `set(name, value, attributes)` and `delete(name, attributes)`, see [Cookies](/docs/concepts/cookies/) | Every hook and the handler share the same `ctx.out`. `headers` is never replaced, only changed with `set()` and `append()`, so two hooks that write headers do not overwrite each other’s. `ctx.out.headers` is applied to every response that leaves: a serialized result, a `Response` the handler built, a hook’s short-circuit, and an error response. A request id or a rotated session cookie does not vanish on the `401` it goes with. Headers are merged by name: * `set-cookie` is appended; * `vary` is merged, each token once, so a handler’s `Vary: Cookie` survives a CORS hook adding `Origin`; * every other name overwrites the response’s own value. `ctx.out.status`, by contrast, applies only to a serialized result. An error’s status belongs to the error, and a `Response` carries its own. ## The response map `schema.response` describes what leaves. A single schema checks every serialized response, whatever its status. A map binds a schema to each status, and is also the list of statuses the route answers with: ```ts const UserId = z.object({ id: z.coerce.number().int().positive() }); const NewUser = z.object({ name: z.string().min(1) }); const PublicUser = z.object({ id: z.number(), name: z.string() }); export const usersController = controller("Users", () => ({ create: route({ method: "POST", path: "/users", schema: { body: NewUser, response: { 201: PublicUser } }, handler: (ctx) => { const user = { id: users.size + 1, name: ctx.body.name, passwordHash: "…" }; users.set(user.id, user); ctx.out.status = 201; return user; }, }), remove: route({ method: "DELETE", path: "/users/:id", schema: { params: UserId, response: { 204: null } }, handler: (ctx) => { if (!users.delete(ctx.params.id)) throw httpError(404, "USER_NOT_FOUND"); }, }), })); ``` * **Only a declared status leaves.** Setting an undeclared status on `ctx.out.status` is a compile error. A response that still leaves with one, such as the implicit `200` of a handler that forgot to set `201`, is a `500`. * **`null` declares a status without a body**, such as `204` or `304`. A value returned under it is a `500`. * **The status picks the schema.** The handler may return any of the declared shapes, and the status it leaves with decides which schema checks it. The wrong shape for the status compiles, and is a `500`. Entries for statuses the handler never returns, such as a `404` it throws, only document the error path. A thrown `HttpError` is answered by the [error mapping](/docs/concepts/errors/), and no response schema is checked there. ### What is serialized The value the schema returns is what becomes the JSON. In the example above, the stored user carries a `passwordHash` that `PublicUser` does not name, and Zod’s object schema drops unknown keys, so the hash never leaves. TypeScript alone would not catch this: a `StoredUser` satisfies `{ id: number; name: string }`, extra field and all. A response that fails its schema is the server’s fault. It answers `500` with the envelope and nothing of the value, and `reportError` receives a `ResponseContractError` with `source: "response"`. See [Errors](/docs/concepts/errors/#failures-that-cannot-become-a-response). `validateResponses: false` on `createApp` turns every response check off. The schemas still type the handler and document the route. The framework never reads `NODE_ENV` itself, so checking outside production only is your call: ```ts const app = createApp({ routes, validateResponses: Bun.env.NODE_ENV !== "production", }); ``` ### Headers and cookies of a status An entry can check headers and cookies too, with the keys `body`, `headers` and `cookies`, and name a body that is not JSON with `contentType` ([below](#a-body-that-is-not-json)). Any other key is refused at startup, so a misspelled part is not silently left unchecked: ```ts const Order = z.object({ id: z.number(), total: z.number() }); const Created = z.object({ location: z.string() }); route({ method: "POST", path: "/orders", schema: { response: { 201: { body: Order, headers: Created } } }, handler: (ctx) => { const order = orders.create(); ctx.out.status = 201; ctx.out.headers.set("location", `/orders/${order.id}`); return order; }, }); ``` * `headers` sees `ctx.out.headers` after the handler returns, names in lower case, without `set-cookie`. Hooks may have added headers too, so the schema should allow keys it does not name. * `cookies` sees each cookie the response sets, by name, with the value as the handler wrote it: opened when signed, `""` when deleted. * An entry without `body` has no body, like `null`, unless it has a `contentType`. A response that breaks them is a `500`. ### A body that is not JSON A value the handler returns always leaves as JSON. A CSV export, a file or an event stream is a `Response` the handler builds, and `contentType` on its entry says what it carries: ```ts route({ method: "GET", path: "/orders.csv", schema: { response: { 200: { contentType: "text/csv", body: z.string() } } }, handler: () => new Response(toCsv(orders.all()), { headers: { "content-type": "text/csv" } }), }); ``` * The [document](/docs/packages/openapi/) describes the status under `text/csv` instead of `application/json`, with `body` as its schema. Without `body`, by the type alone: `"application/pdf"`, `"image/*"`, or `"*/*"` for a file of any type. * Returning a value for such a status is a compile error, and so is returning nothing: a value would leave as JSON, nothing as an empty body. A type the compiler knows only as a `string`, in a map declared apart from the route, counts as not JSON; `as const` keeps the literal. Where the compiler does not see the key at all, as in an entry typed as `ResponseEntry`, the response is a `500` when responses are validated. * Its `headers` and `cookies` are documented, not checked: only a `Response` answers such a status, and a `Response` is not checked. * The type goes alone, `"text/csv"` and not `"text/csv; charset=utf-8"`: the charset belongs on the `Response`. `route()` throws on anything but a bare type or a range. ## Redirects A redirect is a response like any other. `Response.redirect` works from a handler or a hook, and cookies set on `ctx.out` go with it: ```ts route({ method: "POST", path: "/login", handler: (ctx) => { ctx.out.cookies.set("session", sessions.open(), { httpOnly: true }); return Response.redirect("/orders", 303); }, }); ``` That redirect is a `Response`, so it is not checked and not in the generated document. To have it checked and documented, declare it in the response map, set the status and `location` on `ctx.out`, and return nothing: ```ts const SeeOther = z.object({ location: z.string() }); route({ method: "POST", path: "/orders", schema: { response: { 303: { headers: SeeOther } } }, handler: (ctx) => { const order = orders.create(); ctx.out.status = 303; ctx.out.headers.set("location", `/orders/${order.id}`); }, }); ``` ## A `Response` of your own A `Response` the handler builds is sent as it is: no response schema checks it, the response map does not apply to its status, and the generated document does not describe it. `ctx.out.headers` is still applied, and `beforeResponse` and `afterResponse` hooks still see it. Use it for what JSON is not, such as a file, HTML or a stream. For JSON, return the value, so the contract stays checked and documented. ```ts route({ method: "GET", path: "/reports/:id", handler: async (ctx) => new Response(await reports.csv(ctx.params.id), { headers: { "content-type": "text/csv" }, }), }); ``` # Routes and handlers > Declaring a route with route(), what a handler returns, and the paths the compiler accepts. A route is one method on one path, and the handler that answers it. ## Declaring a route `route()` takes a configuration object and returns it as data. Nothing is registered when it runs; `createApp()` reads the routes later. ```ts import { controller, route } from "@tetsujs/core"; export const notesController = controller("Notes", () => ({ get: route({ method: "GET", path: "/notes/:id", handler: (ctx) => ({ id: ctx.params.id, title: "First note" }), }), })); ``` Never annotate `ctx`. Its type is inferred from the rest of the configuration: the path parameters, the validated parts, and what hooks add. The fields are: | Field | Required | | | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `method` | yes | `GET`, `POST`, `PUT`, `PATCH` or `DELETE` | | `path` | yes | the path, with `:param` segments | | `handler` | yes | the function that answers the request | | `schema` | no | schemas for `params`, `query`, `headers`, `cookies`, `body` and `response` — see [Validation](/docs/concepts/validation/) | | `hooks` | no | hooks keyed by slot — see [Lifecycle hooks](/docs/concepts/lifecycle-hooks/) | | `bodyType` | no | `"json"`, `"form"`, `"text"` or `"stream"` — see [Request bodies](/docs/concepts/request-bodies/) | | `maxBodySize` | no | this route’s body limit in bytes, in place of the application’s | | `rawBody` | no | keep the body’s bytes next to the parsed body, for signatures | | `docs` | no | `summary`, `description`, `tags`, `deprecated`, `hidden`, `operationId` — read by [`@tetsujs/openapi`](/docs/packages/openapi/) only | The [route reference](/docs/reference/route/) lists them with their types. ## What a handler returns The value a handler returns becomes the response: * a value is sent as JSON with status `200`; * `undefined`, or no `return` at all, is `204` with no body; * a `Response` is sent as it is. ```ts import { controller, route } from "@tetsujs/core"; import { z } from "zod"; export const notesController = controller("Notes", () => ({ create: route({ method: "POST", path: "/notes", schema: { body: z.object({ title: z.string().min(1) }) }, handler: (ctx) => { ctx.out.status = 201; return notes.add(ctx.body.title); }, }), remove: route({ method: "DELETE", path: "/notes/:id", handler: (ctx) => { notes.remove(Number(ctx.params.id)); }, }), export: route({ method: "GET", path: "/notes/export", handler: () => new Response("id,title\n", { headers: { "content-type": "text/csv" } }), }), })); ``` Set the status and headers of a JSON result on `ctx.out`; a `Response` carries its own. A handler may be `async`. [Responses](/docs/concepts/responses/) covers response schemas, redirects and streams, and [Errors](/docs/concepts/errors/) covers what a thrown error becomes. ## Path parameters Each `:name` segment becomes a field of `ctx.params`, typed from the path literal: ```ts route({ method: "GET", path: "/users/:userId/notes/:noteId", handler: (ctx) => ctx.params, }); ``` Parameters are strings. To get a number, a UUID or an enum, declare a `params` schema; see [Validation](/docs/concepts/validation/). A path without parameters has an empty `ctx.params`. ## Path rules A path starts with `/` and has no empty segments and no trailing slash. A parameter takes a whole segment. A `*` is allowed only as the whole last segment, where it matches the rest of the path: ```ts import { route } from "@tetsujs/core"; route({ method: "GET", path: "/users/:userId/notes/:noteId", handler: () => undefined }); route({ method: "GET", path: "/files/*", handler: () => undefined }); ``` These are the rules of Bun’s router, and a path that breaks them does not compile. That includes two spellings that look right but are not: `{id}` and the optional parameter `:id?`, which Bun’s router does not support: ```ts route({ method: "GET", path: "/users/{id}", handler: () => undefined }); route({ method: "GET", path: "/users/:id?", handler: () => undefined }); ``` A path the compiler cannot see, such as one built from a variable, is checked with the same rules when `route()` runs. `createApp()` also refuses two mistakes at startup: the same method and path declared twice, and two paths that differ only in parameter names, such as `/users/:id` and `/users/:userId`. Bun’s router treats those as one pattern. A `*` captures nothing: `ctx.params` has no field for it. Read the rest of the path from `ctx.req.url`. ## Methods A route declares `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. Every path answers `HEAD` and `OPTIONS` on its own: `HEAD` runs the path’s `GET` route and sends the headers without the body, and `OPTIONS` answers `204` with an `Allow` header. Any other method on a known path gets `405` with the same `Allow`. ## The matched route `ctx.route` describes the route that matched: ```ts import { controller, route } from "@tetsujs/core"; export const usersController = controller("Users", () => ({ get: route({ method: "GET", path: "/users/:id", handler: (ctx) => { ctx.route.path; // "/users/:id" ctx.route.method; // "GET" ctx.route.controller; // "Users" ctx.route.name; // "get" return { id: ctx.params.id }; }, }), })); ``` `path` is the template with group prefixes joined, never the URL. Use it to label logs and metrics: a label built from the URL creates a new series for every id. `controller` and `name` are absent for a route mounted on its own, outside a controller. In hooks that also run for a `404`, a `405` or a preflight, `ctx.route` is optional, because no route matched. ## How routes reach Bun’s router Routing is done by Bun’s native router. `createApp()` turns each declared path into one entry of the `routes` object `Bun.serve` takes, and that entry picks the route by method. There is no second matcher, so the precedence between overlapping paths is Bun’s. `app.fetch` only handles requests that matched no path: it answers `404`, or runs the `fallback` option of `createApp`. Calling `app.fetch` directly does not route, so integration tests go through a real server; see [Testing](/docs/guides/testing/). # Streaming > Responses that arrive over time — a Response carrying a stream, sse() and stream() from @tetsujs/sse, backpressure with a pull source, and why a generator must yield or wait on its signal. A streamed response sends its body as it is produced: server-sent events, a large export, a model’s tokens. Most streams are easiest to write with `@tetsujs/sse`; a stream written by hand has a few rules to follow. ## A `Response` carrying a stream A handler that streams returns a `Response` whose body is the stream. A bare `ReadableStream` or generator is a compile error and a `500` at runtime: as JSON it would be `{}`. The `Response` is also where the content type is stated. Like any `Response`, it is not checked by a response schema, and `ctx.out.headers` is applied to it. The status and headers leave with the first chunk of the body. A stream that has nothing to send for a while keeps the client waiting for the headers too, so write something the format allows as soon as you can. ## `sse()` and `stream()` [`@tetsujs/sse`](/docs/packages/sse/) builds the `Response` from an async generator, and handles what a hand-written stream tends to get wrong: backpressure, stopping when the client leaves, ending the generator, and reporting a failure. `sse()` formats each yielded event for `EventSource`, sends keep-alive comments so neither Bun nor a proxy closes an idle connection, and sends a first comment so the headers leave at once: ```ts route({ method: "GET", path: "/clock", handler: (ctx) => sse(ctx, async function* (signal) { while (!signal.aborted) { yield { event: "tick", data: { at: new Date().toISOString() } }; await Bun.sleep(1_000); } }), }); ``` `stream()` is the same without the event format, for NDJSON, CSV or anything else: ```ts route({ method: "GET", path: "/orders.ndjson", handler: (ctx) => stream( ctx, async function* () { for await (const order of orders.cursor()) yield `${JSON.stringify(order)}\n`; }, { contentType: "application/x-ndjson" }, ), }); ``` The package page covers event ids and `Last-Event-ID`, keep-alives, `onEnd` summaries and every option. On the other side, [`@tetsujs/client`](/docs/packages/client/#streams) reads a `text/event-stream` as `res.events`, each event’s `data` typed by the `body` of the route’s response entry: `200: { contentType: "text/event-stream", body: Price }`. ## A generator must yield or wait on the signal The generator behind `sse()` or `stream()` receives an `AbortSignal` that fires when the stream is over: the client left, the response was discarded, `until` fired, or the generator ended. A generator that keeps yielding stops on its own: at the next value the stream sees the abort and calls `return()`, which runs its `finally`. A generator stuck in an `await` is not woken by anything. If it waits on a source that has gone quiet, such as a queue with no traffic, it never resumes, and the subscription inside it lives as long as the process. So **a stream ends with the connection only if its generator keeps yielding, or waits on the signal.** A generator that can go quiet passes the signal to whatever it waits on: ```ts route({ method: "GET", path: "/prices", handler: (ctx) => sse(ctx, async function* (signal) { const queue = await broker.subscribe("prices", { signal }); try { for await (const price of queue) yield { data: price, id: price.at }; } finally { await queue.close(); } }), }); ``` A source that takes the signal, such as `fetch`, `events.on` or the timers of `node:timers/promises`, rejects with an `AbortError` when the client leaves. That is not reported as a failure. ## Failures and the end of a stream A generator that throws ends the stream where it stood. The headers have already gone, so the error goes to `reportError` with `source: "stream"`, and the client sees an ordinary end of stream. A client that has to know the stream finished, rather than broke off, needs a last event that says so, such as `done`. An access log in `afterResponse` sees a stream when it starts, so it records an hour-long feed as a fast `200`. `onEnd` on `sse()` and `stream()` reports how the stream really ended, with its size and duration. ## Ending streams on shutdown A stopping server waits for every response in flight, and a stream may never finish on its own. `until` on `sse()` and `stream()` ends it when a signal fires. With `@tetsujs/lifecycle` that signal is `draining`, and the client reconnects to a server that stays; see [Health checks and shutdown](/docs/guides/health-and-shutdown/#streams-and-sockets). ## A stream written by hand A client reads at its own pace. `enqueue` never blocks, so a stream fed from a loop in `start()` builds every chunk at once, whether the client is reading or gone. A `pull()` source applies backpressure: the platform calls it only while the stream wants more, so the source advances at the rate the client reads. ```ts route({ method: "GET", path: "/orders.ndjson", handler: () => { const rows = orders.cursor(); const encoder = new TextEncoder(); const body = new ReadableStream( { async pull(controller) { const next = await rows.next(); if (next.done) controller.close(); else controller.enqueue(encoder.encode(`${JSON.stringify(next.value)}\n`)); }, async cancel() { await rows.return(); }, }, { highWaterMark: 0 }, ); return new Response(body, { headers: { "content-type": "application/x-ndjson" } }); }, }); ``` * **`highWaterMark: 0`.** With the default of `1`, the platform calls `pull()` as soon as the stream is built. A response can be built and never sent (a hook replaced it, an error displaced it, a `HEAD` request has no body), and that first `pull()` would start work for nobody. * **`cancel()`.** A response the pipeline builds and does not send has its body cancelled, and so does a client that leaves mid-stream. `cancel()` is where a cursor is closed or a subscription dropped. * **An idle connection.** Bun closes a connection that sends nothing for its `idleTimeout`, 10 seconds unless set. A stream that can stay quiet longer writes something its format ignores on a timer, and sets its request’s timeout above that with `ctx.server.timeout()`, as `sse()` does. To end such a stream on shutdown too, give its source `AbortSignal.any([ctx.req.signal, shutdown.draining])`, as [Cancellation and timeouts](/docs/concepts/cancellation/) combines a deadline with the request’s signal. # Validation > Any Standard Schema validates any part of a request; a failure answers 422 with every issue, and the response is checked on the way out. A route validates a part of the request by declaring a schema for it. The handler then gets that part typed from the schema’s output. ## Declaring schemas Any [Standard Schema](https://standardschema.dev) works. Zod, Valibot and ArkType support it directly, and TypeBox does through `tb()` from [`@tetsujs/typebox`](/docs/packages/typebox/). ```ts import { controller, route } from "@tetsujs/core"; import { z } from "zod"; const NoteId = z.object({ id: z.coerce.number().int().positive() }); const NoteChange = z.object({ title: z.string().min(1).optional(), body: z.string().optional() }); export const notesController = controller("Notes", () => ({ change: route({ method: "PATCH", path: "/notes/:id", schema: { params: NoteId, body: NoteChange }, handler: (ctx) => ({ id: ctx.params.id, ...ctx.body }), }), })); ``` A value the schema converts arrives converted: `ctx.params.id` is a number here. ## The parts | Part | Validates | Adds | | ---------- | ---------------------------------------------------------------------- | ------------------------------ | | `params` | path parameters, as strings | `ctx.params`, narrowed | | `query` | the query string: a record of strings, a repeated key as an array | `ctx.query` | | `headers` | the request headers, names in lower case | `ctx.headers` | | `cookies` | the request’s cookies, signed ones already verified | `ctx.cookies` | | `body` | the parsed body — see [Request bodies](/docs/concepts/request-bodies/) | `ctx.body` | | `response` | what the handler returns | nothing; it checks what leaves | A part without a schema is neither read nor typed. Without a `query` schema the query string is not parsed and `ctx.query` does not exist. Without a `body` schema, `bodyType` or `rawBody`, the body is never read. [Context](/docs/concepts/context/#no-schema-no-field) explains why. If a `beforeValidation` hook changes `ctx.body` or `ctx.query`, the schema validates the hook’s value. ## Validation errors All parts are checked, in the order `params`, `query`, `headers`, `cookies`, `body`, and their issues are collected. The client sees everything wrong with a request in one answer. A failure answers `422` before the handler runs: ```json { "status": 422, "message": "Validation failed", "error": "VALIDATION_FAILED", "issues": [ { "message": "Invalid option: expected one of \"yes\"|\"no\"", "path": ["query", "draft"] }, { "message": "Too small: expected string to have >=1 characters", "path": ["body", "title"] } ] } ``` Each issue’s `path` starts with the part, then the keys inside it. The messages come from the validator and change with its version, so branch on `error` and `path`, and show `message` to people. To answer `400` instead, set `validation: { status: 400 }` on `createApp`. A body that is not valid JSON is always a `400`. To change the format of the answer, use an application `onError` hook; see [Errors](/docs/concepts/errors/). ## Converting strings Path parameters, query values, headers, cookies and form fields all arrive as strings. The framework does not guess types; the schema converts them. With Zod, use `z.coerce`: ```ts const Page = z.object({ page: z.coerce.number().int().min(1).default(1), size: z.coerce.number().int().min(1).max(100).default(20), }); ``` A boolean is the exception: `z.coerce.boolean()` makes any non-empty string `true`, `"false"` included. Use `z.stringbool()`, which reads `"true"` and `"false"` (and `"1"`, `"yes"`, `"on"`…) as they are meant. Arrays need the same care. A key sent once is a string, and only a repeated key becomes an array: `?tag=a` is `"a"`, and `?tag=a&tag=b` is `["a", "b"]`; an empty array sends no key at all. A plain `z.array()` refuses a single tag, so wrap a single value in an array first, and let the array be missing: ```ts const one = (value: unknown) => (typeof value === "string" ? [value] : value); const Filter = z.object({ tag: z.preprocess(one, z.array(z.string())).optional(), }); ``` Form fields work the same way. With TypeBox, `tb(…, { convert: true })` does both conversions: `"42"` to `42`, and `"a"` to `["a"]`. ## Validator libraries Only the schema changes between libraries; the route and the handler’s types follow it: ```ts import { route } from "@tetsujs/core"; import { type } from "arktype"; import * as v from "valibot"; import { z } from "zod"; import { Type, tb } from "@tetsujs/typebox"; const WithZod = z.object({ title: z.string().min(1), pinned: z.boolean() }); const WithValibot = v.object({ title: v.pipe(v.string(), v.minLength(1)), pinned: v.boolean(), }); const WithArkType = type({ title: "string > 0", pinned: "boolean" }); const WithTypeBox = tb(Type.Object({ title: Type.String({ minLength: 1 }), pinned: Type.Boolean() })); route({ method: "POST", path: "/notes", schema: { body: WithTypeBox }, handler: (ctx) => ctx.body, }); ``` `tb()` options such as `convert`, `clean` and `defaults` are on the [package page](/docs/packages/typebox/). For a schema to appear in the OpenAPI document it must also produce JSON Schema. Zod, ArkType and `tb()` do this themselves; Valibot needs `toStandardJsonSchema` from `@valibot/to-json-schema`. See [`@tetsujs/openapi`](/docs/packages/openapi/). Asynchronous validators work too. ## Checking the response `schema.response` checks what the handler returns, at compile time and at runtime, and the schema’s output is what gets sent: a schema that strips unknown keys keeps a field like `passwordHash` out of the response. A response that fails its schema is a `500`, reported to `reportError`. [Responses](/docs/concepts/responses/#the-response-map) covers a schema per status, what is serialized, and turning the runtime check off with `validateResponses: false`. # WebSockets > Declaring a WebSocket endpoint with ws(), a handshake that runs the ordinary hooks, typed socket data, validated messages, and closing sockets when the server stops. A WebSocket endpoint is declared with `ws()` and lives in the same controllers as routes. Its handshake runs the ordinary hooks, and what they establish becomes the socket’s typed data. ## Declaring an endpoint `ws()` takes the handshake’s path, its hooks and schemas, and Bun’s own socket handlers: ```ts const named = hook.beforeParse((ctx) => { const name = new URL(ctx.req.url).searchParams.get("name"); if (!name) throw new HttpError(401); return { name }; }); const ChatMessage = z.object({ text: z.string().min(1).max(500) }); export const chatController = controller("Chat", () => ({ room: ws({ path: "/chat/:room", hooks: { beforeParse: [named] }, schema: { message: ChatMessage }, open: (socket) => socket.subscribe(socket.data.params.room), message: (socket, message) => { socket.publish( socket.data.params.room, JSON.stringify({ from: socket.data.name, text: message.text }), ); }, close: (socket) => socket.unsubscribe(socket.data.params.room), }), })); ``` The socket is Bun’s own `ServerWebSocket`: `send`, `subscribe`, `publish` and the rest are the platform’s, unwrapped. Only `data` is typed by the framework. ## The handshake The handshake is an ordinary `GET` and runs the ordinary pipeline: `beforeParse` hooks, validation of `params`, `query` and `headers`, and `beforeHandle` hooks. Authentication and rate limiting are the same hooks as on any route, mounted on the endpoint, a group or the application. A refused handshake is a normal HTTP response. A hook that throws `HttpError(401)` answers `401` in the error envelope, through `onError` and the response hooks, and the client’s `WebSocket` reports a failed connection. A successful handshake has no response, so the response hooks do not run for it. A plain `GET` on the path that is not a handshake is answered `426 UPGRADE_REQUIRED`. ## `socket.data` `socket.data` is the handshake’s context without the parts that belong to the finished request: path parameters, the validated `query` and `headers`, and every field the handshake’s hooks added. An `auth` hook that adds `ctx.user` gives every handler a typed `socket.data.user`. `req`, `server`, `out`, `route` and `startedAt` are left out. A socket can live for hours, and holding the request would hold its headers and body. A hook that needs something from the request adds it as a field, as `named` does above. ## Handlers | Handler | Runs when | | ----------------------------- | ------------------------------------------------------------ | | `open(socket)` | the socket is open and has its data | | `message(socket, message)` | a frame arrived, validated when `schema.message` is declared | | `invalid(socket, issues)` | a frame failed `schema.message` | | `close(socket, code, reason)` | the socket closed, with the peer’s code and reason | | `drain(socket)` | backpressure eased and the socket is writable again | | `ping(socket, data)` | a ping frame arrived | | `pong(socket, data)` | a pong frame arrived | A handler may return anything; a returned promise is awaited only to catch its rejection. A handler that throws or rejects is reported to `reportError` with `source: "websocket"`, and the socket stays open. ## Messages Without `schema.message`, `message` receives the frame as Bun gives it: `string | Buffer`. With `schema.message`, the protocol is JSON. A text frame is parsed and validated, and `message` receives the schema’s output. A frame that is not valid JSON or fails the schema never reaches `message`, and neither does a binary frame: * without `invalid`, the socket is closed with `1007` and the first issue’s message as the reason; * with `invalid`, that handler receives the issues and decides what to do. A validator that throws or rejects, rather than reporting issues, is the server’s failure. The socket is closed with `1011` and the error is reported as `websocket`. Frames reach `message` in the order they arrived, even with an asynchronous schema, and none arrives after the socket has closed. ## Serving sockets Bun takes one WebSocket handler for the whole server. The application carries it as `app.websocket`, and `Bun.serve({ ...app })` picks it up. It is always present, even when nothing declares a socket. Bun’s server-level WebSocket options, such as `idleTimeout`, `maxPayloadLength` and `backpressureLimit`, are not proxied. Spread the ones you need over the handler: ```ts const app = createApp({ routes }); Bun.serve({ ...app, websocket: { ...app.websocket, idleTimeout: 30 } }); ``` ## Closing sockets on shutdown `server.stop()` waits for every open socket, and a socket stays open as long as its client wants, so one idle socket can hold a deploy for the whole grace period. `until` closes the endpoint’s sockets with `1001` (going away) when a signal fires, and closes any socket opened after that at once. The client reconnects to a server that stays, and `close` runs as usual. It takes a signal, or a function that returns one, called as each socket opens. The function is the usual form, because the endpoint is declared before the server and its signal exist: `until: () => shutdown.draining`. If the function returns nothing, or throws, that socket is not closed by it; a throw is reported as `websocket`. [Health checks and shutdown](/docs/guides/health-and-shutdown/#streams-and-sockets) wires it to the `draining` signal of `@tetsujs/lifecycle`. ## Not in the OpenAPI document Socket endpoints are absent from the OpenAPI document and from the typed client’s route map. `docs` on `ws()` takes only a `summary` and a `description`. The handshake still takes the `GET` of its path in `app.entries`, marked with a `ws` field. # Postgres with @tetsujs/sql > SQL migrations as the source of truth, Kysely types generated from the database they build, and SQL files typed by Postgres itself, on Bun's own client. `@tetsujs/sql` puts Postgres behind an application on Tetsu, through Bun’s own client. The schema is a directory of SQL migrations. The types are generated from the database those migrations build, as Postgres describes it, so a column the database can leave NULL is `| null` in the code, and a constraint is known by the name the database gives it. Queries are written with Kysely’s builder, on a driver of the package’s own, or as SQL files, whose functions Postgres types as well. ```bash bun add @tetsujs/sql kysely ``` The package needs Bun 1.4.4 or later and Postgres 14 or later. Kysely is a peer dependency: queries are written with it, and the package gives it a driver on Bun.SQL that keeps every value what the types say. ## From a migration to a route The command line, `tetsu-sql`, does the work outside the application. Three scripts cover it: package.json ```json "scripts": { "db:new": "tetsu-sql migrations:new", "db:migrate": "tetsu-sql migrations:run", "db:types": "tetsu-sql types:generate" } ``` `bun run db:new create_orders` creates an empty migration, named with the current time, for the schema to be written in: migrations/20261014120100_create_orders.sql ```sql create type order_status as enum ('new', 'paid', 'shipped'); create table orders ( id int8 generated always as identity primary key, customer_id int8 not null references customers (id), status order_status not null default 'new', total numeric(12, 2) not null check (total >= 0), tags text[] not null default '{}', note text, placed_at timestamptz not null default now() ); ``` `bun run db:migrate` applies it to the database `DATABASE_URL` names. `bun run db:types` builds the migrations on a scratch database of the same server, reads it back, and writes `src/db.generated.ts`: src/db.generated.ts ```ts export type OrderStatus = "new" | "paid" | "shipped"; export interface DB { orders: { id: GeneratedAlways; customer_id: bigint; status: Generated; total: string; tags: Generated>; note: string | null; placed_at: Generated; }; // the other tables, the constraints' names, and `schema` for the runtime } ``` `database()` opens a pool on the database, and a controller takes it as a dependency, as it would take anything else: src/orders.ts ```ts import { controller, createApp, route } from "@tetsujs/core"; import { database } from "@tetsujs/sql"; import type { Kysely } from "kysely"; import { type DB, schema } from "./db.generated"; const ordersController = controller("Orders", (db: Kysely) => ({ open: route({ method: "GET", path: "/orders/open", handler: async () => { const orders = await db .selectFrom("orders") .select(["id", "total", "placed_at"]) .where("status", "=", "new") .execute(); return orders.map((order) => ({ ...order, id: String(order.id) })); }, }), })); const db = database(schema, { url: config.DATABASE_URL }); Bun.serve({ ...createApp({ routes: ordersController(db) }) }); ``` Nothing is annotated: the row’s type is the table’s columns as the database reads them. `where("status", "=", "open")` would not compile, since `open` is not a label of the enum. An `int8` is read as a `bigint`, which JSON cannot hold, so the handler hands the id on as a string: [Recipes](/docs/database/recipes/#ids-in-json) has the ways to do it. The files the commands write are part of the code: commit them, and let CI check that they match the migrations, as [Deploy and CI](/docs/database/deploy/#ci) shows. ## The pages * [Migrations](/docs/database/migrations/) — writing them, applying them, and changing a schema the application is running on. * [Types from the database](/docs/database/types/) — what is generated, and how each Postgres type reads and is written. * [Queries](/docs/database/queries/) — `database()`, Kysely’s builder on it, the pool, sessions, and cancelling a query. * [Query files](/docs/database/query-files/) — SQL in files of its own, each a typed function, with its NULLs told by Postgres. * [Transactions and errors](/docs/database/transactions/) — `atomic()`, and telling a broken constraint by its name. * [Testing](/docs/database/testing/) — a database of its own for each test file, built from the migrations. * [Deploy and CI](/docs/database/deploy/) — migrations before the application starts, and the generated files checked in CI. * [Bun’s limits](/docs/database/bun-limits/) — what Bun.SQL does not do yet, and what the package does about it. * [Recipes](/docs/database/recipes/) — ids in JSON, checking rows, raw SQL. What a query costs over Bun.SQL alone, and next to Drizzle, is on [Performance](/docs/more/performance/#database-queries). ## What it is not * **Not an ORM.** There are no models and no relations: the schema is written in SQL, and the types follow the database, never the other way round. * **Not a generator of migrations.** A migration is written by hand, as the SQL Postgres runs. `types:generate` reads the migrations; it never writes one. * **Postgres only, on Bun.SQL.** There is no driver of Node’s, such as `pg`, and no other database. * **Columns keep their names.** A column is `placed_at` in the code as in the database. A plugin that renames keys on the way out renames the keys inside `jsonb` values too, and a query of the code would no longer read as the SQL it runs. A linter’s rule of names, such as Biome’s `useNamingConvention` or ESLint’s `camelcase`, then flags the keys of the rows the code writes, `{ placed_at }`: let it take snake_case for the properties of objects and of types. # Bun's limits > What Bun.SQL does not do yet, as of Bun 1.4.4, what the package does about each, and what is left to the application. The package runs on Bun’s own client, and leans on what it does. Where Bun falls short, the package works around it, or the types say what Bun reads. Where a row names an issue, the package’s tests pin what Bun does today, and fail once Bun fixes it, so that the workaround goes with the bug. ## Values | Bun | the package | left to you | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | reads an array of an enum, of a domain, of a composite type, of a range or a multirange of one’s own or of an extension’s type as its literal, such as `"{a,b}"` ([#41040](https://github.com/oven-sh/bun/issues/41040)) | types such a column `ArrayLiteral`: read as that string, written as an array | parse the string where the elements are needed | | binds a JavaScript array to an array of an enum, of a domain, of a composite type, of a range or a multirange of one’s own or of an extension’s type as its elements joined by commas, which Postgres refuses | writes an array to a column of arrays, and to a parameter of a query file, as the literal Postgres reads | in raw SQL, cast the array to one of a built-in type first: `${moods}::text[]::mood[]` | | binds a JavaScript array to a `json[]` or a `jsonb[]` with `null` as JSON’s `null`, and an array element as an array of more dimensions | writes the JSON of each element, and `null` as NULL | | | binds `-0` in a JavaScript array as `0` | writes it as `-0` | | | binds a `Date` of a year past 9999 or before 1 as `toISOString()` writes it, such as `+010000-01-01`, which a `date` and an array of dates or timestamps refuse | writes a `Date` as its ISO text, which a `date` reads of any year | | | binds an invalid `Date` as the text `Invalid Date`, which Postgres refuses | throws a `RangeError` before anything is sent | | | reads an `int4[]` or a `float4[]` as an `Int32Array` or a `Float32Array` when a query has parameters | reads it as an array | | | reads a `float4` with parameters as its 32-bit value, such as `1.100000023841858` for `1.1` | | store what is compared or shown as `float8` or `numeric` | | reads `'infinity'` and `'-infinity'` of a timestamp or a date as the numbers `Infinity` and `-Infinity` | says so in the generated types | check for a number where a column may hold them | | stores a string bound to `json` or `jsonb` as a JSON string ([#28819](https://github.com/oven-sh/bun/issues/28819)) | types the column to take an object or an array, so a value passed through `JSON.stringify` does not compile | | ## The client | Bun | the package | left to you | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | before 1.4.4, read an array of `uuid`, of a range and of more types as its literal; before 1.4.3, could hand a query the rows of another after a failure on the same connection | `database()` throws on a Bun older than 1.4.4 | | | takes `PGDATABASE`, when it is set, over the database a URL names ([#45276](https://github.com/oven-sh/bun/issues/45276)) | gives Bun the URL’s database apart from the URL | give a client of your own its `database` too | | connects without TLS when neither the URL nor `PGSSLMODE` names an `sslmode`, where Postgres’s own clients try TLS first | | name the mode: [TLS](/docs/database/deploy/#tls) | | has no cursor | `stream()` throws, saying so | read a long result in pages, by key | | cannot describe a query without running it ([#45277](https://github.com/oven-sh/bun/issues/45277)) | describes query files over a connection of its own, by host and port, with TLS as `sslmode` says | give `types:generate` a URL with a host and a port, not a [unix socket](/docs/database/deploy/#a-unix-socket), and an authority of your own in [`NODE_EXTRA_CA_CERTS`](/docs/database/deploy/#tls) | | has `query.cancel()` cancel a query already sent on the server, where Kysely’s default `ignore query` lets one run to its end | cancels on abort only a query Bun has not sent yet, and leaves the server’s cancel to `cancel query` | | | may wait forever to close a connection the server ended | supports `cancel query`, not `kill session` | | When Bun fixes one of these, a release of the package drops the workaround and says so in its notes. # Deploy and CI > Migrations as a step before the application starts, the database checked against the types at start, the generated files checked in CI, and the connection settings production needs. ## Migrations before the application Apply the migrations as a step of the deploy, before the new version starts: a release job, a Kubernetes `Job`, a step of the pipeline. ```bash bunx tetsu-sql migrations:run --json ``` It runs where the project’s dependencies are installed, as after `bun install`, so that `bunx` runs the command of the project’s own `@tetsujs/sql`: a binary of `bun build --compile` holds the application, not the command. * **A failure stops the deploy** before any instance runs code the database is not ready for. * **The migrations can run as a role of their own,** given by the step’s `DATABASE_URL`, with rights to change the schema that the application’s role does not need. * **One run applies them.** Instances started together would be safe as well, since a run waits for another to finish, but each would hold a connection while it waits. The version before keeps running while the migrations apply, and until the new one is out, so a migration must not break it: [Changing a schema the application runs on](/docs/database/migrations/#changing-a-schema-the-application-runs-on). A database built before its migrations takes a baseline once, before its first run: [Adopting a database](/docs/database/migrations/#adopting-a-database). The migrations are read from disk by the step that applies them: ship the directory with it. The application itself reads none of them, so a binary built with `bun build --compile` holds only the generated files, which are ordinary modules. ## Checking the database at start `verifySchema()` checks, before the application serves, that the database has applied the migration the types were built from: src/main.ts ```ts import { createApp } from "@tetsujs/core"; import { database, verifySchema } from "@tetsujs/sql"; import { schema } from "./db.generated"; import { ordersController } from "./orders"; const db = database(schema, { url: config.DATABASE_URL }); await verifySchema(db, schema); Bun.serve({ ...createApp({ routes: ordersController(db) }) }); ``` A deploy whose migrations did not run then fails as it starts, with a `MigrationError`, rather than on the first request that needs them: * `behind` — the database has not applied the migration, or has no journal; * `changed` — it applied another text of that migration; * `table` — the journal’s table is not a journal. Newer migrations may follow it: after a rollback, the code is older than the database, and runs on it as it did before. The journal is read with `db`’s own connection, so the application’s role needs to read it. A journal in a schema the connection does not search is named as `schema.table`, with `--table`, when the types are generated. ## CI CI checks that the generated files are what the migrations and the query files make, then runs the tests, on a Postgres of the job: .github/workflows/ci.yml ```yaml jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:18 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 2s --health-timeout 5s --health-retries 15 env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - run: bunx tetsu-sql types:generate --check - run: bun test ``` `types:generate --check` writes nothing. It fails when a file differs from what the migrations and the query files make, in anything but its formatting, and says which: `src/db.generated.ts is out of date: run tetsu-sql types:generate`. A migration changed without generating the types again, or a query file that no longer fits the schema, fails the build rather than a request in production. The image is the Postgres of production, so that the types and the tests see what production does. ## Connections ### TLS Without `sslmode` in the URL, or `PGSSLMODE` in the environment, Bun connects without TLS, where Postgres’s own clients would try TLS first. Name the mode in the URL: `sslmode=require` encrypts, and `sslmode=verify-full` also checks the server’s certificate and its name. A certificate signed by an authority of your own goes in Bun’s [`tls` option](https://bun.com/docs/runtime/sql), which `database()` passes on. `tetsu-sql` and `generate()` take only a URL: name the authority’s file in `NODE_EXTRA_CA_CERTS`, which Bun trusts for every connection, theirs included. ### The database a URL names Bun 1.4.4 takes `PGDATABASE`, when it is set, over the database a URL names. `database()`, `tetsu-sql` and `testDatabase()` give Bun the URL’s database apart from the URL, so the URL wins. A client of your own, such as one handed to `migrate()`, needs the same: `new SQL({ url, database: "shop" })`. ### A unix socket Bun reaches a server on the unix socket that `path` names in the URL: `postgres://shop@localhost/shop?path=/var/run/postgresql/.s.PGSQL.5432`. `tetsu-sql types:generate` and `generate()` build the types over it, but describe query files on a connection of their own, which reaches a server only by its host and port: with query files, they refuse a URL with `path`. ### The size of the pool `max` is per process, and a process that cancels a query opens one connection more, to send the cancel on. Postgres’s `max_connections`, 100 by default, is shared by every process of every instance. A query holds no connection of its own, so the pool’s size is a matter of the transactions that run at once: they take turns at `max − 1` connections, and the one left over runs everything else. Behind a pooler in transaction mode, such as PgBouncer, a session lasts one transaction: `db.connection()`, a session’s advisory lock and `set` do not hold across transactions there. Bun prepares statements on each connection; where the pooler does not keep prepared statements, pass `prepare: false`. ### Timeouts `statement_timeout` is the server’s limit on a query: [Cancelling a query](/docs/database/queries/#cancelling-a-query) sets it for the pool. Bun’s own options limit the pool: `connectionTimeout`, 30 seconds by default, `idleTimeout` and `maxLifetime`, in seconds. ### Shutdown `db.destroy()` closes the pool once the requests in flight are done, as a `close` of [`@tetsujs/lifecycle`](/docs/packages/lifecycle/): [Queries](/docs/database/queries/) shows it. # Migrations > SQL files applied in the order they were made, each in a transaction of its own and recorded in a journal, and what a run refuses before it changes anything. The schema is a directory of SQL files, `migrations/` by default. A file is named `YYYYMMDDHHMMSS_name.sql`: the UTC time it was made, then a name in lowercase letters, digits and underscores. Files run in the order of their names, which is the order they were made in, and the types are generated in that same order. ## Writing one ```bash bunx tetsu-sql migrations:new add_shipments ``` This creates `migrations/20261014120500_add_shipments.sql`, empty, and refuses to overwrite a file of that name. From code, `createMigration()` of `@tetsujs/sql/generate` does the same. A migration is the SQL Postgres runs: any number of statements, comments and `$$` bodies, but none of the commands of `psql` itself, such as `\set` or `\i`. A pending migration that holds only comments and blank lines is refused rather than applied: once recorded, it could not be written. ## Applying them ```bash bunx tetsu-sql migrations:run ``` The command applies the pending migrations to the database `DATABASE_URL` names, or `--url`, and prints a line for each step: `applying …`, then `applied … in 12 ms`, or `nothing to apply`. A failure is printed on stderr, and the command exits with 1. What Postgres adds to it follows, a line each, as psql prints it: the SQLSTATE, then the detail and the hint when there are any. ```text error: 20261014120500_unique_email.sql failed: could not create unique index "customers_email_key" sqlstate: 23505 detail: Key (email)=(ann@example.com) is duplicated. ``` The detail holds values of rows, as here. With `--json`, each line is a JSON object with its time and level, for a log collector, and so is a failure, on stdout with the rest, with `sqlstate`, `detail` and `hint` as fields of its own. The same run, from code, is `migrate()`, on a Bun.SQL client of its own: scripts/migrate.ts ```ts import { SQL } from "bun"; import { migrate } from "@tetsujs/sql"; const sql = new SQL({ url: Bun.env.DATABASE_URL, database: "shop" }); try { const { applied } = await migrate(sql, { dir: "./migrations", write: (record) => console.log(record), }); console.log(`${applied.length} applied`); } finally { await sql.close(); } ``` The run reserves one connection of the client and closes it at the end, so nothing a migration set for its session reaches anything else. Bun 1.4.4 takes `PGDATABASE`, when it is set, over the database a URL names: where it may be set, give the client the database as `database` too, as the command does. Each migration runs in a transaction of its own, and is recorded in the journal in that same transaction. A migration that fails leaves nothing of itself applied or recorded; the ones before it stay applied. Each starts from the session’s defaults, so a `search_path` or a role one of them sets does not reach the next. A run holds an advisory lock for its whole length. A second run started meanwhile waits for it — `waiting for another migration run to finish` — and then finds nothing left to apply. So every instance of an application can run the migrations as it starts without applying one twice, though a step of its own before the start is safer: see [Deploy and CI](/docs/database/deploy/#migrations-before-the-application). ## What a run refuses Before it applies anything, a run checks the files against the journal: every applied migration must still have its file, unchanged, and every pending one must be newer than the last one applied. A refusal is a `MigrationError`, whose `reason` says what was wrong and whose `migration` names the file to blame. | `reason` | when | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `directory` | the migrations directory is not there | | `name` | a `.sql` file is not named as a migration, or a new migration’s name is not lowercase letters, digits and underscores, or its file exists | | `encoding` | a migration file is not UTF-8 | | `empty` | a pending migration holds nothing to run, or one a baseline would record | | `table` | the journal’s table exists, and is not a journal | | `missing` | an applied migration’s file is gone, though newer files are there, or the directory holds no migration at all; for a baseline, the migration named is not there | | `changed` | an applied migration’s file has changed | | `out-of-order` | a pending migration is older than the last one applied | | `recorded` | a baseline found migrations recorded in the journal already: [Adopting a database](#adopting-a-database) | | `failed` | a migration failed, and was rolled back, unless it runs outside a transaction; the Postgres error is the `cause` | | `own-transaction` | a migration ended its transaction itself, with a `COMMIT` or a `ROLLBACK` | Every reason but the last two is found before anything is applied, so the database is as it was. After those two, `applied` lists what the run applied first. An applied migration newer than every file is not missing: the code was rolled back past it, while the database kept it. The run applies nothing, and names it in `ahead`, so that the old code can start on the newer database — as long as the migration left it working for the code before it, which is the next section’s subject. ## Changing a schema the application runs on The application keeps running while the migrations apply, and until its new version is out: a migration runs under the code before it. Renaming a column, changing its type or making it `not null` breaks that code at once. Such a change takes three releases: 1. **Expand.** Add the new column or table, nullable or with a default, and nothing the old code reads. 2. **Switch.** Release code that writes both and reads the new one; fill the new one from the old in a migration or a job. 3. **Contract.** Once no running code reads the old one, drop it, in a later release. There are no down migrations: a mistake is undone by a new migration, and a release rolled back runs on the database as it is, which the expand step keeps working for it. [squawk](https://squawkhq.com/) lints migrations for statements that hold long locks on a busy table, such as a column added with a volatile default, which rewrites the table, or an index built without `concurrently`. It reads the same files, as a step of CI. ## Locks and timeouts A statement of a migration waits at most 5 seconds for a lock, then fails, and the migration is rolled back. A statement waiting for a lock holds up every later query that needs the same lock, so an `ALTER TABLE` behind a long transaction would stall the application; failing early and running again later does not. `--lock-timeout-ms` changes the limit, or the `lockTimeoutMs` option of `migrate()`; `0` waits as long as it takes. A `statement_timeout` or a `transaction_timeout` that the role or the database sets holds for a migration in a transaction, as for any query of the role: a long backfill under a role with a limit fails, `canceling statement due to statement timeout`. Lift it in the migration, for its own transaction only: `set local statement_timeout = 0;`. ## Outside a transaction A migration whose first line is `-- no-transaction` runs outside one, as `create index concurrently` must, and is recorded once it has run: migrations/20261014121000_index_orders_by_status.sql ```sql -- no-transaction create index concurrently orders_status_idx on orders (status); ``` Postgres runs the statements of such a file together, so a `concurrently` statement must be the only one in it. It runs without a lock timeout, and without a statement or a transaction timeout the role or the database sets: `create index concurrently` waits for every older transaction to end, without holding up the application, and building the index may take long. One that fails leaves an invalid index behind: drop it, with `drop index concurrently`, before running the migration again. ## The journal The journal is a table, `migrations` by default, in the first schema of the `search_path`: a row for each applied migration, with its name, the SHA-256 of its text and when it was applied. `--table` names another, as `table` or `schema.table`. A table of that name that is not a journal is refused, never written to. `verifySchema()` reads the same journal when the application starts: [Deploy and CI](/docs/database/deploy/#checking-the-database-at-start). ## Adopting a database A database built before its migrations, by another tool or by hand, has the schema they create already, so its first run fails, and says what to do: ```text error: 20261015120000_schema.sql failed: relation "customers" already exists. If the database was built before its migrations, record the ones it has instead of running them: tetsu-sql migrations:baseline sqlstate: 42P07 ``` A baseline records migrations in the journal as applied, with their checksums, without running them: ```bash bunx tetsu-sql migrations:baseline 20261015120000_schema ``` It records the migration named and every one before it, and `migrations:run` then applies the ones after. The name goes with or without `.sql`, or as the file’s path, and `--dir`, `--url` and `--table` are the ones `migrations:run` is given. From code, it is `baseline(sql, { dir, migration })`, on a client of its own, as `migrate()` is. * **Name the last migration the database has.** Named too early, the next run fails on what the database has; too late, the run skips what it lacks. * **Only a journal that records nothing takes a baseline.** A missing one is created. Once a migration is recorded, the journal says what the database has, and a baseline is refused with `recorded`. A run whose first migration failed leaves the journal empty; one that applied migrations before failing, or a baseline named wrong, leaves rows to delete before the next run: `delete from migrations`, or from the table `--table` names. * **Each database built before the migrations takes one, once:** production, staging. A new database, for the tests or a new environment, runs every migration from the first, as the scratch database of `types:generate` does, so the first migration must build the schema on an empty database too. The first migration of such a database is usually its schema as it is, from `pg_dump --schema-only --no-owner --no-privileges`, trimmed to the statements you would have written. A recent `pg_dump` puts `\restrict` and `\unrestrict` lines around the dump: commands of `psql`, not SQL, so delete them. # Queries > database() and Kysely's builder on it, values as the types say, arrays, the pool and its reservations, a session of its own, and cancelling a query. src/db.ts ```ts import { database } from "@tetsujs/sql"; import { schema } from "./db.generated"; export const db = database(schema, { url: config.DATABASE_URL }); ``` `database()` returns Kysely on a pool of Bun.SQL, for the database the generated `schema` describes. The URL is a `postgres://` or `postgresql://` one, and the other options are Bun’s options of a pool, such as `max`, `idleTimeout` or `tls`, but for those the package sets itself. The package reads nothing from the environment: the URL is passed in, from the application’s configuration. Bun fills in what the URL leaves out — the host, the port, the user, the password and `sslmode` — from `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD` and `PGSSLMODE`, so name them all in the URL. Open it once, where the application is wired, and hand it to what needs it, as any other dependency. `db.destroy()` closes the pool, on shutdown: src/main.ts ```ts import { createApp } from "@tetsujs/core"; import { onShutdownSignals } from "@tetsujs/lifecycle"; import { database } from "@tetsujs/sql"; import { schema } from "./db.generated"; import { ordersController } from "./orders"; const db = database(schema, { url: config.DATABASE_URL }); const server = Bun.serve({ ...createApp({ routes: ordersController(db) }) }); onShutdownSignals(server, { close: [() => db.destroy()] }); ``` On a Bun older than 1.4.4, `database()` throws: before it, Bun read an array of `uuid`, of a range and of more types as its literal, where the generated types say it reads as an array. ## Queries are Kysely’s Every query is [Kysely’s](https://kysely.dev/docs/category/examples), typed by `DB`: ```ts const recent = await db .selectFrom("orders as o") .innerJoin("customers as c", "c.id", "o.customer_id") .select(["o.id", "o.total", "o.placed_at", "c.email"]) .where("o.customer_id", "=", customerId) .orderBy("o.placed_at", "desc") .limit(20) .execute(); const { id } = await db .insertInto("orders") .values({ customer_id: customerId, total: "12.50", tags: ["gift"] }) .returning("id") .executeTakeFirstOrThrow(); await db.updateTable("orders").set({ status: "paid" }).where("id", "=", id).execute(); ``` The package changes nothing of Kysely’s API but two things Bun.SQL cannot do yet: the `kill session` strategy of [cancelling a query](#cancelling-a-query), and `stream()`, which needs a cursor: it throws, so read a long result in pages instead, by key. A query of raw SQL, with Kysely’s `` sql`…` ``, binds its values as parameters too. One that binds no value may hold several statements, and resolves to the rows of the last; with a value, Postgres refuses more than one. ## Values Values go through as the [generated types](/docs/database/types/#how-each-type-reads) say: an `int8` is a `bigint` both ways, unless the types were generated with `--int8 string`, a `numeric` a string, a `json` or `jsonb` column takes an object or an array and returns what Postgres parsed. The driver mends what Bun does not do: * a `Date` is written to any timestamp and to a `date` as its instant, in ISO text, which a `date` takes of any year, where it refuses Bun’s own text of a year past 9999 or before 1; an invalid `Date` throws a `RangeError` before anything is sent, where Bun would send it for Postgres to refuse; * a number that is not whole, such as `2.5`, is sent as its text where the builder writes it to a column — a value of an insert, an upsert or a merge, a `set` — and to any parameter of a [query file](/docs/database/query-files/), which Postgres has typed. Postgres reads it as that type: an integer refuses it, `invalid input syntax for type integer: "2.5"`, where Bun would send a `float8` the column rounds to the even, `2`, without an error, and a `float8`, a `float4` or a `numeric` reads the number it is. Where the builder compares or computes with it, and in raw SQL, it is bound as Bun binds it, a `float8`: `where("rank", ">", 2.5)` means what it says, and a raw `insert` rounds; * an `int4[]` or a `float4[]`, which Bun reads as an `Int32Array` or a `Float32Array` when a query has parameters, is read as an array, as it is without them. ### Arrays `database()` writes a JavaScript array to a column of arrays as the literal Postgres reads, such as `{"gift","glass"}`. Bun binds an array as Postgres reads it only to an array of a type it knows: not of an enum, a domain, a composite type, a range of one’s own or an extension’s type, and not every array of json ([Bun’s limits](/docs/database/bun-limits/#values)). A literal is read as the column’s own type, whatever it is. `database()` writes one wherever a query says the array goes to such a column: * a value of an insert, an upsert or a merge, and a `set` of an update; * a value compared with a column of arrays of the same query, such as `where("tags", "@>", ["gift"])`; * the argument of `any()` or `all()`, which only an array can be. ```ts const gifts = await db .selectFrom("orders") .select("id") .where("tags", "@>", ["gift"]) .execute(); const some = await db .selectFrom("orders") .select(["id", "status"]) .where((eb) => eb("id", "=", eb.fn.any(eb.val([1n, 2n, 3n])))) .execute(); ``` Elements are written as their column takes them: by the delimiter of the type, which is `;` for a `box`, and as JSON for an array of `json` or `jsonb`. An array of arrays is written as one of more dimensions. Anywhere else an array is bound as Bun binds it: as JSON into a `jsonb` column, and as a Postgres array where raw SQL, or the argument of another function, casts it to an array of a built-in type: ```ts import { sql } from "kysely"; const labels = ["gift", "fragile"]; const { rows } = await sql<{ tagged: boolean }>` select ${labels}::text[] && array['gift'] as tagged `.execute(db); ``` An array of an enum, or of another type Bun does not know, takes a second cast from one it knows: `${moods}::text[]::mood[]`. ## Values and SQL A value never becomes text of a query. The builder, `` sql`…` `` and the functions of query files send every value as a parameter, apart from the SQL, and Postgres reads it as data: a name such as `'; drop table customers; --` is stored as it is. An array goes as one parameter too, the elements of its literal quoted and escaped. What a parameter cannot carry is left to the code: * **SQL made of input.** `sql.raw()` and `sql.lit()` write their argument into the query as it is: never hand them input. * **A name from input,** such as the column a list is sorted by. A name is not a value, so it cannot be a parameter: in a query file, `order by :column` sorts by a constant. Kysely quotes a name it is given, but the name can be any column, so map the input to the names it may be. * **`set`,** which takes no parameter: `` sql`set local app.user_id = ${id}` `` fails with `42601`. `set_config()` takes the value as one. * **A pattern of `like`,** where `%` and `_` from input match anything. Escape them. ```ts import { sql } from "kysely"; const sortable = { placed: "placed_at", total: "total" } as const; function openOrders(sort: keyof typeof sortable) { return db .selectFrom("orders") .select(["id", "total", "placed_at"]) .where("status", "=", "new") .orderBy(sortable[sort], "desc") .execute(); } await db.transaction().execute(async (trx) => { await sql`select set_config('app.user_id', ${userId}, true)`.execute(trx); // the transaction's queries, and its policies, read current_setting('app.user_id') }); const matching = await db .selectFrom("customers") .select("email") .where("email", "like", `${typed.replaceAll(/[\\%_]/g, "\\$&")}%`) .execute(); ``` `set_config()` with `true` holds for the transaction, as `set local` does. ## The pool A query runs on Bun’s pool, which sends the queries of many callers down one connection without waiting for each answer. A query holds no connection of its own, and `max`, 10 by default, limits connections, not queries: the [benchmark](/docs/more/performance/#database-queries) runs 32 queries at a time on 10 connections. Three things reserve a connection for themselves: * a transaction, from its `begin` to its end; * the work of `db.connection()`, below; * a query given a signal with Kysely’s `cancel query` strategy, to cancel it by. Reservations take turns, in the order they come, at one fewer connection than `max`, at least one. Bun hands a connection that comes free to what has waited longest, a query or a reservation, so were every connection theirs to take, a query that comes behind a burst of transactions — a health check included — would wait for each of them to start, and behind long ones for as long as they run. The connection left over is the pool’s. A reservation inside another’s work, such as a transaction opened on `db` inside the callback of `db.connection()`, needs a turn of its own, and waits for it while holding the first. With `max: 2` there is one turn, and it waits forever. Run the inner queries on what the work is handed — the `trx` of a transaction, the connection of `connection()` — and it needs no turn. ## A session of its own Queries of `db` share no session: two of them may run on different connections. Work that needs one — a `set` that later queries read, a temporary table, a session’s advisory lock — runs in `db.connection()`, whose queries share one connection, reserved for it: ```ts import { sql } from "kysely"; await db.connection().execute(async (session) => { await sql`select pg_advisory_lock(${42})`.execute(session); try { await closeTheDay(session); } finally { await sql`select pg_advisory_unlock(${42})`.execute(session); } }); ``` The connection goes back to the pool as the work left it: Bun resets nothing. Undo what the work set for the session before it ends, or set it with `set local` inside a transaction, which ends with it. `connection()` keeps its session on a Kysely made from `db` too, by `withSchema()` or `withPlugin()`. A query the work runs on `db` itself, rather than on what it is handed, runs on the pool. ## Cancelling a query A query is given up with an `AbortSignal`, as Kysely takes it: ```ts const totals = await db .selectFrom("orders") .select(["status", db.fn.sum("total").as("total")]) .groupBy("status") .execute({ signal: AbortSignal.timeout(5_000), inflightQueryAbortStrategy: "cancel query", }); ``` The promise then fails with the signal’s reason. What happens to the query depends on where it was: * **Waiting for a connection** none can take it on, as when transactions reserve every one, it is taken out of Bun’s queue, and never runs. On a pool busy with other queries, Bun sends a query at once, down a connection behind theirs, so it is sent, as below: a write given up there may still be made. Make a write one that can run twice, with a key of its own, before trying it again. * **Sent, with the default `ignore query`,** it runs to its end on the server, and its answer is dropped: a write is made, and a transaction it runs in goes on. * **Sent, with `cancel query`,** a connection of the database’s own sends Postgres the cancel for its backend: Postgres stops it, and inside a transaction the transaction then fails to commit. Kysely prints the query’s own failure, `` `query` failed in the background after abortion ``, on the console. `kill session` is not supported: Bun’s pool may wait forever to close a connection the server ended. A limit the server holds for every query of the pool is `statement_timeout`, a setting Bun sends as each connection opens: ```ts import { database } from "@tetsujs/sql"; import { schema } from "./db.generated"; export const db = database(schema, { url: config.DATABASE_URL, connection: { statement_timeout: "5s" }, }); ``` A query that runs longer fails with `57014`. The URL’s `options` parameter sets it as well, as `?options=-c%20statement_timeout%3D5s`. # Query files > SQL in files of its own, each written as a function that Postgres types — its parameters, its columns, and which of them may be NULL. Some queries read better as SQL: a report with CTEs, a window function, a query tuned against its plan. A query file holds one, in `queries/`. `tetsu-sql types:generate` has Postgres describe each, without running it, on the database the migrations build, and writes a function for it into `src/queries.generated.ts`, typed by Postgres’s answer. queries/customer-orders.sql ```sql -- A customer's orders, newest first, with the shipment of each that has -- one. select o.id, o.status, o.total, s.carrier, s.shipped_at from orders o left join shipments s on s.order_id = o.id where o.customer_id = :customerId order by o.placed_at desc ``` src/queries.generated.ts ```ts export interface CustomerOrdersRow { id: bigint; status: Database.OrderStatus; total: string; carrier: string | null; shipped_at: Date | null; } export interface CustomerOrdersParams { customerId: bigint; } /** * A customer's orders, newest first, with the shipment of each that has * one. * * `../queries/customer-orders.sql` */ export function customerOrders( db: Kysely, params: CustomerOrdersParams, ): Promise { return queryRows(db, CUSTOMER_ORDERS, params); } ``` `carrier` and `shipped_at` are `not null` in `shipments`, but the left join leaves them NULL for an order that has not shipped. Postgres’s plan of the query says so, and the type follows: code that reads `shipped_at` without checking it does not compile. ```ts import { customerOrders } from "./queries.generated"; for (const order of await customerOrders(db, { customerId: 1n })) { console.log(order.id, order.shipped_at.toISOString()); } ``` A function takes the database or a transaction, as anything written with Kysely does. A query that returns no rows — an `update` without `returning`, say — resolves to how many rows it changed, as a `bigint`. ## Writing a file * **The name** is lowercase words joined by hyphens, and the function is its camel case: `customer-orders.sql` is `customerOrders`. A name that would make a reserved word, such as `delete.sql`, is refused. * **The comment the file opens with** is the function’s documentation. * **A parameter** is a name after a colon, `:customerId`: the same name is the same parameter. Numbered ones, `$1`, are refused. * **One query a file:** Postgres prepares it as one statement. A colon after a letter, a digit, an underscore, a closing bracket or a quote is not a parameter, so `total::text` and `tags[1:3]` are left as they are, and so is anything in a string, a quoted name or a comment. Two cases read as a parameter where they are not: * **A slice from the start to a column,** `tags[:size]`, reads `:size` as a parameter: write `tags[1:size]`. * **jsonb’s `?` right after a parameter,** `:document?`, marks the parameter as maybe NULL: write `:document ? 'key'`, with a space. ## Parameters A parameter is typed as Postgres types it from where it stands, and written as a column of that type is: `:customerId` compared with an `int8` column is a `bigint`, `:since` compared with a `timestamptz` is a `Date | string`, and `any(:statuses)` against an enum column is a `readonly OrderStatus[]`. An array is written as the literal Postgres reads, as [`database()`](/docs/database/queries/#arrays) writes one. Postgres types a `limit` and an `offset` as `int8`, so `limit :count` takes a `bigint`, and `limit :count::int4` a `number`. A parameter is not NULL unless it says it may be, with `?` where it first appears: queries/orders-with-note.sql ```sql select id, note from orders where customer_id = :customerId and (note = :note? or :note? is null) ``` Where Postgres cannot tell a parameter’s type, the generator says so, and hints at a cast when nothing in the query types the parameter at all. A parameter that first appears in `:since is null` is one: nothing there says what it is. The cast goes where it first appears: queries/orders-since.sql ```sql select id, placed_at from orders where :since?::timestamptz is null or placed_at >= :since ``` A parameter compared with a `text` column whose check lists its values is a `string`, where the column reads as the union of the list: Postgres types the parameter `text`, and knows nothing of the check. An enum, made with `create type … as enum`, types the parameter as its union, and a column of one takes no other value. A [literal](#when-a-file-is-refused) outside a list is refused either way. ## Which columns may be NULL Postgres describes a query’s columns, and the table and column each is read from; the generated types give that column’s type. Whether it may be NULL comes from the plan of the query, made for any values of its parameters: * A query with no outer join and no grouping sets, whose statement returns only the rows it writes, makes no column NULL: a column is NULL as its table’s column is. * Otherwise, a column is not NULL only where the plan reads it from its table, and not on a side a join may leave NULL. A side a left, right, full or anti join leaves NULL is NULL, and so is a column a materialized CTE or a subquery gives on. * With grouping sets, every column may be NULL. * Any other column — an expression, an aggregate, a column of a union, or of a relation the generated file does not describe — may be NULL, and so may every column of a query Postgres cannot plan, such as `show`. A quoted name that ends in `!` or `?` overrides all that: `!` says the column is never NULL, and `?` that it may be. The marker is taken off the name: queries/revenue-by-day.sql ```sql -- The revenue of paid and shipped orders, by day, since a time. select date_trunc('day', placed_at) as "day!", sum(total) as "revenue!", count(*) as "orders!" from orders where status in ('paid', 'shipped') and placed_at >= :since group by 1 order by 1 ``` src/queries.generated.ts ```ts export interface RevenueByDayRow { day: Date; revenue: string; orders: bigint; } ``` Every column there is an expression or an aggregate, which Postgres says nothing about. `count(*)` is never NULL; `sum(total)` is not NULL over a group, whose rows are there and whose `total` is `not null`; and `date_trunc` of a `not null` column is not NULL. The marker says what the query knows and the plan does not. Three cases to know: * **A partitioned table under an outer join** reads as NULL on the side that is kept, too. Mark its columns `!`. * **An expression cast to a domain,** `:kind::kind`, is typed by the domain’s base type, as Postgres reports it. A column of a table of that domain keeps the domain’s type. * **A column of a view** may be NULL, as a view keeps no `not null`. ## When a file is refused A file that cannot be read as a query, or one Postgres refuses, fails the run. Every refusal is told at once, each at the file, line and column it is about, in Postgres’s words, with `:name` in place of the `$n` Postgres uses. A file whose `where` reads `o.customer` for `o.customer_id` is told, with Postgres’s own hint: ```text error: queries/customer-orders.sql:6:7: column o.customer does not exist (hint: Perhaps you meant to reference the column "o.customer_id".) ``` A literal the query compares a column with is refused when it is outside the list the column’s checks allow. Postgres runs such a comparison without a word, and it never holds, or always does: `state = 'opne'` finds nothing. A column of an enum refuses such a literal itself, in Postgres’s words. ```text error: queries/open-tickets.sql:2:15: tickets.state never holds 'opne', only 'open', 'closed' ``` The comparisons are read from the plan of the query: a column of a table it reads, `=` or `<>` a literal, `in` and `not in` a list of them. A literal the file writes is refused only when no column the plan compares it with may hold it: the planner copies a condition into each table a `union all` reads, and a join hands it to the column it makes equal, so `status in ('captured', 'settled')` over two tables of their own lists passes. In a database with a nondeterministic collation, lists of strings are not checked: the plan does not tell which collation compares. `types:generate --check` checks the file of functions as it checks the file of types: in CI, a query that no longer fits the schema fails the build, not the first request that runs it. # Recipes > Ids in JSON, rows checked against a schema of your own, raw SQL, and a long result read in pages. ## Ids in JSON An `int8` is read as a `bigint`, and `JSON.stringify` refuses a `bigint`: a handler that returns a row with one answers `500`. Hand the id on as a string, and say so in the response’s schema, which the OpenAPI document is built from: ```ts import { route } from "@tetsujs/core"; import { z } from "zod"; const Order = z.object({ id: z.string(), total: z.string() }); const open = route({ method: "GET", path: "/orders/open", schema: { response: { 200: z.array(Order) } }, handler: async () => { const orders = await db .selectFrom("orders") .select(["id", "total"]) .where("status", "=", "new") .execute(); return orders.map((order) => ({ ...order, id: String(order.id) })); }, }); ``` `Number(order.id)` will do as well, while the ids stay below 2⁵³; past that, a number rounds them. Types generated with `--int8 string` read an id as a string, which needs neither: [int8 as a string](/docs/database/types/#int8-as-a-string). An id that comes in — in a path, a query or a body — comes in as a string. A codec checks it, up to the largest `int8`, and hands the handler a `bigint`, while the document says the client sends a string: ```ts import { httpError, route } from "@tetsujs/core"; import { z } from "zod"; const Id = z.codec( z.string().regex(/^[1-9]\d{0,18}$/), z.bigint().max(9_223_372_036_854_775_807n), { decode: (text) => BigInt(text), encode: (id) => String(id) }, ); const cancel = route({ method: "POST", path: "/orders/:id/cancel", schema: { params: z.object({ id: Id }) }, handler: async (ctx) => { const { numUpdatedRows } = await db .updateTable("orders") .set({ note: "cancelled" }) .where("id", "=", ctx.params.id) .executeTakeFirst(); if (numUpdatedRows === 0n) { throw httpError(404, "ORDER_NOT_FOUND"); } return { cancelled: true }; }, }); ``` With [`@tetsujs/typebox`](/docs/packages/typebox/), the codec is TypeBox’s. A `Decode` that throws refuses the value with a `422`, as a pattern that does not match does: ```ts import { httpError, route } from "@tetsujs/core"; import { tb, Type } from "@tetsujs/typebox"; const Id = Type.Codec(Type.String({ pattern: "^[1-9][0-9]{0,18}$" })) .Decode((text) => { const id = BigInt(text); if (id > 9_223_372_036_854_775_807n) { throw new Error("is larger than the largest int8"); } return id; }) .Encode((id: bigint) => String(id)); const cancel = route({ method: "POST", path: "/orders/:id/cancel", schema: { params: tb(Type.Object({ id: Id })) }, handler: async (ctx) => { const { numUpdatedRows } = await db .updateTable("orders") .set({ note: "cancelled" }) .where("id", "=", ctx.params.id) .executeTakeFirst(); if (numUpdatedRows === 0n) { throw httpError(404, "ORDER_NOT_FOUND"); } return { cancelled: true }; }, }); ``` Either way, the OpenAPI document says the client sends a string, with the pattern, and the handler is handed a `bigint`. ## Rows checked against a schema A query of the builder or of a file is typed by the database, and needs no check of its rows. Raw SQL is typed by what it is told, as in `` sql`…` ``, and some values depend on the data more than on the schema: a `sum` over no rows is NULL, as is a `json_agg` over none. Where that matters, check the rows with a schema of your own: ```ts import { sql } from "kysely"; import { z } from "zod"; const Totals = z.object({ customers: z.number(), revenue: z.string() }); const { rows } = await sql` select count(distinct customer_id)::int4 as customers, sum(total) as revenue from orders where status = 'paid' `.execute(db); const totals = rows.map((row) => Totals.parse(row)); ``` A row that fails the check is the database disagreeing with the code: a bug, which the application answers with a `500`, not the client’s `422`. Here, a `revenue` of NULL fails when nothing is paid yet; a `coalesce(sum(total), 0)` in the query keeps it a string. A [query file](/docs/database/query-files/) is often the better answer: Postgres types it, and the generated file says which columns may be NULL. ## Raw SQL Kysely’s `` sql`…` `` binds every value as a parameter, never as text of the query. Within it: * `sql.ref("orders.status")` names a column, and `sql.id("orders")` a table, quoted as Postgres reads them; * `sql.join(values)` writes a list of parameters: `` sql`id in (${sql.join(ids)})` ``; * an array for an array type is built in the query, as `` sql`array[${sql.join(labels)}]::text[]` ``: [Arrays](/docs/database/queries/#arrays). A raw query that binds no value may hold several statements, and resolves to the rows of the last; with a value, Postgres refuses more than one. Anything long is clearer as a query file, which Postgres checks when the types are generated. ## A long result, in pages Bun.SQL has no cursor, so a long result is read in pages, each after the last key of the one before: ```ts let after = 0n; for (;;) { const page = await db .selectFrom("orders") .select(["id", "total"]) .where("id", ">", after) .orderBy("id") .limit(1_000) .execute(); for (const row of page) { write(row); } const last = page.at(-1); if (last === undefined) { break; } after = last.id; } ``` An index on the key keeps every page as quick as the first, which an `offset` does not. # Testing > A database of its own for each test file, built from the migrations with the types checked against them, and dropped once the file has run. Tests run against a real Postgres: the queries, the constraints and the types are what they check. `testDatabase()`, from `@tetsujs/sql/testing`, gives a test file a database of its own: src/orders.test.ts ```ts import { expect, test } from "bun:test"; import { testDatabase } from "@tetsujs/sql/testing"; import { schema } from "./db.generated"; import { customerOrders } from "./queries.generated"; const { db } = testDatabase(schema, { url: Bun.env.DATABASE_URL, dir: "migrations", }); test("lists a customer's orders, with the shipment of each", async () => { const customer = await db .insertInto("customers") .values({ email: "ada@example.com", name: "Ada" }) .returning("id") .executeTakeFirstOrThrow(); const order = await db .insertInto("orders") .values({ customer_id: customer.id, total: "12.50", status: "paid" }) .returning("id") .executeTakeFirstOrThrow(); await db .insertInto("shipments") .values({ order_id: order.id, carrier: "post" }) .execute(); expect(await customerOrders(db, { customerId: customer.id })).toEqual([ { id: order.id, status: "paid", total: "12.50", carrier: "post", shipped_at: expect.any(Date), }, ]); }); ``` `testDatabase()` registers two hooks of `bun:test` where it is called — at the top level of a file for the file, in a `describe` for that `describe` — and returns the database’s `db`, its `url` for code under test that opens connections of its own, and its `name`. Its options besides `url` and `dir` are `database()`’s, such as `max` and `connection`, so that the tests run on a pool set as the application’s; `password`, `tls` and `path` reach the server as the database is made, too. * **`beforeAll`** creates a database from `template0` on the server `url` names, and applies the migrations of `dir`. It fails when the newest migration is not the one the types of `schema` were built from: the tests would check code against types older or newer than the database. Run `types:generate` again. * **`afterAll`** closes the pool, then drops the database, whoever is still on it. The server is one where tests may create databases, such as a container. A `url` that is not set is refused, saying so, so `Bun.env.DATABASE_URL` can be passed as it is. ## What the tests share Every test of the file runs on the same database, one after another. A test inserts the rows it reads, as above, so that it runs the same alone, under `-t`, as with the others. Two files never share a database. Bun runs hooks in the order they were registered. Call `testDatabase()` before the hooks of your own that fill the database, and leave cleaning up to the drop: an `afterAll` of your own runs once the database is gone. When no test of the scope runs, as with a `-t` filter that matches none, Bun runs neither hook, and no database is made. A pool that does not close fails `afterAll`, once the database is dropped all the same: a test left a transaction open, or a query running. ## A run that stops early A run that was killed, or stopped by `--bail`, which skips `afterAll`, leaves its database behind. Each is named `tetsu_sql_` and a UUIDv7, which holds the time it was made, and the next run on the server drops it once it is an hour old and nobody is on it. ## The application over HTTP The same database serves an application under test, built as `main.ts` builds it, with [`serve()`](/docs/guides/testing/#testing-through-a-server): src/orders.http.test.ts ```ts import { expect, test } from "bun:test"; import { createApp } from "@tetsujs/core"; import { serve } from "@tetsujs/core/testing"; import { testDatabase } from "@tetsujs/sql/testing"; import { schema } from "./db.generated"; import { ordersController } from "./orders"; const { db } = testDatabase(schema, { url: Bun.env.DATABASE_URL, dir: "migrations", }); const request = serve(createApp({ routes: ordersController(db) })); test("refuses an order for a customer there is not", async () => { const response = await request("/customers/999/orders", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ total: "1.00" }), }); expect(response.status).toBe(404); }); ``` A Postgres for the tests in CI is a service of the job: [Deploy and CI](/docs/database/deploy/#ci) has one. # Transactions and errors > Kysely's transactions, units of work with atomic() that hold together inside another's transaction too, and a broken constraint told by its name. ## Transactions A transaction is Kysely’s: it commits when its callback resolves, and rolls back when the callback throws. Its queries run on `trx`, the connection it reserved; a query on `db` inside the callback runs outside it. ```ts const order = await db .transaction() .setIsolationLevel("serializable") .execute(async (trx) => { const { id } = await trx .insertInto("orders") .values({ customer_id: 1n, total: "12.50" }) .returning("id") .executeTakeFirstOrThrow(); await trx .insertInto("shipments") .values({ order_id: id, carrier: "post" }) .execute(); return id; }); ``` A query that fails aborts the transaction, even when the callback catches the failure and goes on: Postgres then turns the commit into a rollback. The commit fails, with Bun’s `ERR_POSTGRES_COMMIT_ROLLED_BACK`, rather than resolve with nothing written. To go on after a failure that is expected, run the part that may fail in a unit of its own, below: a savepoint takes the failure, and the transaction goes on. A transaction Kysely controls, from `db.startTransaction()`, holds its connection until it is committed or rolled back, or, dropped without either, until it is garbage-collected: Bun then closes the connection, which rolls the transaction back. That can take a second or more, and until then the transaction keeps its locks, so end every transaction you start. One whose commit failed still holds it, so roll back in the `catch`: ```ts const trx = await db.startTransaction().execute(); try { await trx.updateTable("orders").set({ status: "paid" }).where("id", "=", 7n).execute(); await trx.commit().execute(); } catch (error) { await trx.rollback().execute(); throw error; } ``` ## Units of work A method of a service that writes in two places has to hold together whether it is called on its own or inside a caller’s transaction. Kysely’s `transaction()` refuses to open inside another. `atomic()` does what each case needs: src/shipping.ts ```ts import { atomic } from "@tetsujs/sql"; import type { Kysely } from "kysely"; import type { DB } from "./db.generated"; import { shipOrder } from "./queries.generated"; export function ship(db: Kysely, id: bigint, carrier: string) { return atomic(db, async (trx) => { if ((await shipOrder(trx, { id })) === 0n) { throw new Error("only a paid order ships"); } await trx.insertInto("shipments").values({ order_id: id, carrier }).execute(); }); } ``` * **On its own,** `ship(db, …)` opens a transaction, which commits when the work resolves and rolls back when it throws. * **Inside a transaction,** `ship(trx, …)` marks a savepoint instead: the work throwing undoes what it wrote, and nothing the transaction wrote before it, which goes on. ```ts await ship(db, 7n, "post"); await db.transaction().execute(async (trx) => { await trx.updateTable("orders").set({ note: "rush" }).where("id", "=", 8n).execute(); await ship(trx, 8n, "courier"); }); ``` A query of the work that fails aborts what the work wrote, though the work catches the failure: on its own the commit fails, and inside a transaction the savepoint is rolled back and fails the same way, with `ERR_POSTGRES_COMMIT_ROLLED_BACK`. Units of one transaction run one after another, or one inside another, never side by side: rolling one back would undo what the other wrote. A unit opened beside one that is still open, as by `Promise.all`, is refused before it writes anything. A function of a [query file](/docs/database/query-files/) takes `trx` as it takes `db`, so a unit mixes them freely, as `ship` does. ## A broken constraint `isViolation(error, name)` tells whether Postgres refused a write for breaking the constraint `name`: a unique key or index, a foreign key, a check of a table or of a domain, or an exclusion constraint. ```ts import { httpError } from "@tetsujs/core"; import { isViolation } from "@tetsujs/sql"; try { await db.insertInto("customers").values({ email, name }).execute(); } catch (error) { if (isViolation(error, "customers_email_key")) { throw httpError(409, "EMAIL_TAKEN"); } throw error; } ``` The names it takes are those of the generated `Constraint`, so a name the database does not have does not compile: ```ts isViolation(error, "customers_emial_key"); ``` It reads the class of the error’s SQLSTATE and the constraint the error names, and nothing of its `detail`, which holds values of the row, such as the email that was taken: keep `detail` out of the response. When the name matches, `error` is narrowed to Bun’s `PostgresError`, with that `constraint`. A failure inside a transaction aborts it all the same. Answer it outside the transaction, or run the write in a unit of its own with `atomic()`, whose savepoint takes the failure while the transaction goes on. A key, a unique index or an exclusion constraint of a partitioned table is each partition’s own: a duplicate in `events` breaks `events_2026_pkey`, of the partition the row goes to, and never `events_pkey`. `Constraint` holds them under the names they have in the partitions the migrations create, not the table’s. A partition made later, as by a job that adds one each month, is in no generated file: tell its violation by `errno`, `23505` for a duplicate, and by `table`, the partition. A foreign key or a check keeps the table’s name in every partition. # Types from the database > What tetsu-sql types:generate writes, how each Postgres type reads and is written, and when a column is NULL. ```bash bunx tetsu-sql types:generate ``` The command builds the migrations on a scratch database of the server `DATABASE_URL` names, reads its catalog, and writes `src/db.generated.ts`: the database the code is written against, as Postgres describes it. Then it drops the scratch database, whether the migrations applied or failed. When there is a `queries/` directory, it writes the functions of the [query files](/docs/database/query-files/) as well, from the same database. | flag | what it is | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `--url` | the server, as a database of it: the scratch database is made next to it, and it is not touched. `DATABASE_URL` by default | | `--dir` | the migrations, `migrations` by default | | `--table` | the journal, `migrations` by default | | `--out` | the file of types, `src/db.generated.ts` by default | | `--queries`, `--queries-out` | the query files, `queries`, and their functions, `src/queries.generated.ts` | | `--int8` | what an `int8` reads as: `bigint` or `string`, as [int8 as a string](#int8-as-a-string) says; what the file of types says by default, or `bigint` | | `--check` | write nothing, and fail when a file differs from what would be written in anything but its formatting | | `--json` | print each line as a JSON object | The user of the URL needs the right to create databases. What a migration does to the whole server, such as a role it creates, stays on it after the scratch database is gone, so build the types on a server of their own, such as a container, and create roles only where they are missing: migrations/20261014122000_create_reader.sql ```sql do $$ begin if not exists (select from pg_roles where rolname = 'reader') then create role reader nologin; end if; end $$; ``` A scratch database is named `tetsu_sql_` and a UUIDv7. One that a killed run left behind is dropped by the next run on the server, once it is an hour old and nobody is on it. ## What the file holds * **`DB`**, with a member for each table, view, materialized view and foreign table. A relation of the `public` schema is keyed by its name, one of another schema by `schema.name`, such as `"billing.invoices"`. Partitions are left out: their parent describes them. * **A union for each enum,** such as `OrderStatus`, and for each list of values a check allows, such as `check (region in ('eu', 'us'))`. * **`Constraint`**, the names of the constraints, which [`isViolation()`](/docs/database/transactions/#a-broken-constraint) takes: a key of a partitioned table under the name it has in each partition, which is the one an error names. * **`schema`**, for the runtime: the journal, the migration the types were built from, which [`verifySchema()`](/docs/database/deploy/#checking-the-database-at-start) checks the database against, what an `int8` reads as, and the columns of arrays, which `database()` writes arrays to. A `comment on` a table or a column is its documentation in the file, and shows where the editor shows the type. The file is generated: change the migrations, never the file, and generate it again. Commit it, and check it in CI, as [Deploy and CI](/docs/database/deploy/#ci) shows. Leave the generated files out of the linter and the formatter: they are laid out as Prettier, and Biome set to spaces, lay out code by default, and `--check` compares their code, not their layout. ## Columns A column is typed as Bun reads its values, with `bigint: true`, or with `bigint: false` for [`int8` read as a string](#int8-as-a-string), which is how `database()` reads them. Three things can change what a type is written as: * **`Generated`** — a column with a default, an identity `by default` or a serial: an insert may leave it out. * **`GeneratedAlways`** — an identity `always`, a generated column, or a column of a materialized view: it is never written. * **`| null`** — a column without `not null`. ```ts await db .insertInto("orders") .values({ customer_id: 1n, total: "12.50" }) .execute(); await db .insertInto("orders") .values({ id: 7n, customer_id: 1n, total: "12.50" }) .execute(); ``` The first insert leaves `status`, `tags` and `placed_at` to their defaults, and `note` NULL. The second writes `id`, which an identity `always` makes Postgres’s alone. A column of a domain declared `not null` is not NULL, and one of a domain with a default is `Generated`. Postgres does not hold a domain’s `not null` everywhere: the notes of [`create domain`](https://www.postgresql.org/docs/current/sql-createdomain.html) show how an outer join or a subquery can put a NULL in such a column. Postgres cannot tell whether a column of a view or of a materialized view holds NULL, and keeps no `not null` of a foreign table, so their columns are `| null`. A query of a view reads what it reads; a [query file](/docs/database/query-files/) that reads a view types its columns the same way. ## How each type reads | Postgres | read as | written as | | ---------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | | `bool` | `boolean` | | | `int2`, `int4`, `oid`, `float4`, `float8` | `number` | | | `int8` | `bigint`, or `string` with `--int8 string` | with `--int8 string`, a string, a number or a bigint | | `numeric`, `money` | `string`, exact | | | `text`, `varchar`, `char(n)`, `name`, `citext` | `string` | | | `uuid` | `string` | | | `json`, `jsonb` | `unknown`, as Postgres parsed it | an object or an array | | `timestamp`, `timestamptz` | `Date` | a `Date` or a string Postgres reads | | `date` | `Date`, at midnight UTC | a `Date`, its day in UTC, or a string such as `"2026-10-12"` | | `time`, `timetz`, `interval` | `string` | | | `bytea` | `Buffer` | any `Uint8Array` | | an enum | the union of its labels | | | a domain | its base type, narrowed by a list its checks allow | | | `inet`, `cidr`, `macaddr`, geometric types, `bit`, `xml`, ranges, `tsvector`, a composite type | `string`, as Postgres writes it | | | a type the generator does not know | `unknown` | | A few values read as more than their type says: * A `timestamp` is read as its time in UTC: Postgres keeps no zone with it. * `'infinity'` and `'-infinity'` of a timestamp or a date are read as the numbers `Infinity` and `-Infinity`. * A `float4` with parameters is read as its 32-bit value, such as `1.100000023841858` for `1.1`: [Bun’s limits](/docs/database/bun-limits/). * A string written to a `json` or `jsonb` column would be stored as a JSON string, so the column’s type takes an object or an array: a value already passed through `JSON.stringify` does not compile. ## int8 as a string An `int8` is read as a `bigint` by default: exact, and refused by `JSON.stringify`, so an id goes out of a handler as a string, as [Ids in JSON](/docs/database/recipes/#ids-in-json) shows. A project whose `int8` columns are ids, and none of them amounts, can read them as strings instead: ```bash bunx tetsu-sql types:generate --int8 string ``` * **Every `int8` reads as a string:** a column, an element of an `int8[]`, a `count(*)` of a query file. JSON takes it as it is. * **It is written as a string, a number or a bigint.** A number that is not whole is refused, as by any integer column. Kysely compares a column as it reads it, so `where("id", "=", "42")` takes a string. * **`schema` records it,** and `database()` and `testDatabase()` read `int8` so: the types and the client cannot disagree. The next `types:generate`, and `--check`, keep what the file says; `--int8 bigint` goes back. * **Arithmetic on a string compiles:** `count + 1` is `"11"`. Cast in SQL, as `count(*)::int4`, or keep `bigint` where an `int8` holds amounts. The choice is the project’s, not a column’s: Bun reads every `int8` one way. ## Arrays An array of a type Bun parses is `ArrayOfNullable`: an array whose elements may be NULL, as those of any Postgres array may. `check (array_position(tags, null) is null)` keeps them out, and makes the column `ArrayOf`. Bun reads an array of an enum, of a domain, of a composite type, of a range or a multirange of one’s own or of an extension’s type as its Postgres literal, such as `"{a,b}"`: such a column is `ArrayLiteral`, read as that string and written as an array. An array of more dimensions is typed as an array of one: Postgres keeps no number of dimensions with a column. Bun reads it as nested arrays, so a query that reads one says what it holds itself, as `` sql`cells` ``, and the package writes nested arrays back as more dimensions. An array is written to a column of arrays as the literal Postgres reads: [Queries](/docs/database/queries/#arrays) has where that happens, and where it does not. ## More than one database Each database has a file of its own, through `--out`, and its own `database()`. The names of the constraints of every generated file in a program are the names `isViolation()` takes, without clashing. # Authentication > A session in a signed cookie, a hook that says who is asking before the body is read, sign-in and sign-out routes, bearer tokens and API keys. This page builds sign-in for an API: a session in a signed cookie, a hook that refuses an unknown caller and gives the routes behind it a typed `ctx.user`, and the routes that start and end a session. Bearer tokens and API keys follow the same pattern. Tetsu has no authentication module and no session store. It provides the pieces: signed cookies, hooks that add to the context, and `Requires`. The session store is a service of your own. The examples assume one with three methods: * `start(userId)` makes a new random session id and records its owner; * `find(sessionId)` returns the user, or `undefined` when the id is unknown or expired; * `end(sessionId)` forgets it. A table, a Redis hash or a map in memory all fit. ## A signed session cookie With a secret, the cookies named in `sign` are signed on the way out and verified on the way in: ```ts createApp({ cookies: { secret: env.COOKIE_SECRET, sign: ["session"] }, routes }); ``` Use 32 random bytes or more (`openssl rand -base64 32`), read from the environment in `main.ts`. An empty or missing secret is refused at startup. A signature proves the value came from this application, so a forged id is refused without a lookup. It does not hide the value: the client can read it, so the cookie carries a session id and nothing else. See [Cookies](/docs/concepts/cookies/#signed-cookies). ## The hook that says who is asking A hook in `beforeParse` reads the session, asks the store, and either refuses with `401` or returns the user. What it returns joins the context, typed, for everything that runs after it: ```ts import { HttpError, hook, route, signedCookie } from "@tetsujs/core"; export const authenticate = (sessions: Sessions) => hook.beforeParse(async (ctx) => { const id = signedCookie(ctx, "session"); const user = id === undefined ? undefined : await sessions.find(id); if (!user) throw new HttpError(401); return { user }; }); const signedIn = authenticate(sessions); route({ method: "GET", path: "/notes", hooks: { beforeParse: [signedIn] }, handler: (ctx) => notes.list(ctx.user.id), }); ``` * **`beforeParse`** refuses before the body is read, so an upload from a stranger costs nothing, and a rate limit mounted after the hook can count by user. * **`signedCookie()`**, not `ctx.cookies`, because `ctx.cookies` is filled only after the body is read. `signedCookie()` reads the `cookie` header, checks the signature, and returns the value, or `undefined` when the cookie is missing or forged. The hook is a factory over the store. A controller builds it from the `sessions` it is given — see [Hooks and dependencies](/docs/concepts/controllers/#hooks-and-dependencies). ## Hooks that need the user A hook that reads the user declares it with `Requires`. Mounting it where nothing provides `user` is a compile error that names the missing field: ```ts import type { Requires } from "@tetsujs/core"; import { HttpError, hook, httpError, route } from "@tetsujs/core"; import { z } from "zod"; const adminOnly = hook.beforeParse((ctx: Requires<{ user: User }>) => { if (!ctx.user.admin) throw new HttpError(403); }); const ownNote = hook.beforeHandle( async (ctx: Requires<{ user: User; params: { id: number } }>) => { const note = await notes.find(ctx.user.id, ctx.params.id); if (!note) throw httpError(404, "NOTE_NOT_FOUND"); return { note }; }, ); route({ method: "DELETE", path: "/notes/:id", schema: { params: z.object({ id: z.coerce.number() }) }, hooks: { beforeParse: [signedIn, adminOnly], beforeHandle: [ownNote] }, handler: async (ctx) => { await notes.remove(ctx.note.id); }, }); ``` `adminOnly` must come after `signedIn`; the other order does not compile. `ownNote` reads the validated `params`, so it goes in `beforeHandle`. Mounted on a group, `authenticate` still refuses everyone under it, but `ctx.user` is not typed in the handlers, so a route that reads it mounts the hook itself; see [Context and its types](/docs/concepts/context/#why-a-group-hooks-field-is-not-in-the-handlers-type). ## Signing in and out Signing in checks the credentials, starts a session and sets the cookie. Signing out ends the session and deletes the cookie: ```ts import { controller, httpError, route, signedCookie } from "@tetsujs/core"; import { z } from "zod"; const Credentials = z.object({ email: z.email(), password: z.string().min(1) }); const cookie = { httpOnly: true, secure: true, sameSite: "lax", path: "/" } as const; export const sessionController = controller( "Session", ({ accounts, sessions }: { accounts: Accounts; sessions: Sessions }) => ({ signIn: route({ method: "POST", path: "/session", schema: { body: Credentials, response: { 204: null } }, handler: async (ctx) => { const user = await accounts.verify(ctx.body.email, ctx.body.password); if (!user) throw httpError(401, "BAD_CREDENTIALS", "Wrong email or password"); const id = await sessions.start(user.id); ctx.out.cookies.set("session", id, { ...cookie, maxAge: 7 * 24 * 3600 }); ctx.out.status = 204; }, }), signOut: route({ method: "DELETE", path: "/session", schema: { response: { 204: null } }, handler: async (ctx) => { const id = signedCookie(ctx, "session"); if (id !== undefined) await sessions.end(id); ctx.out.cookies.delete("session", cookie); ctx.out.status = 204; }, }), me: route({ method: "GET", path: "/me", hooks: { beforeParse: [authenticate(sessions)] }, handler: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }), }), }), ); ``` * **A new session id at every sign-in**, so an id planted before sign-in is worth nothing after it. * **`httpOnly`** keeps the cookie from the page’s scripts; **`secure`** keeps it off plain HTTP. The value is signed without the route doing anything, because `session` is in `sign`. * **Deleting** a cookie needs the `path` and `domain` it was set with. Sharing `cookie` between the two routes keeps them the same. * **Sign-out answers `204` either way**, so signing out twice is not an error. A wrong password answers `401` with its own code, `BAD_CREDENTIALS`, which a client can tell apart from an expired session’s `UNAUTHORIZED`. To test the whole flow, use a client with a cookie jar — see [Testing](/docs/guides/testing/#a-client-with-a-session). ## Bearer tokens and API keys A token in the `authorization` header is the same hook with another source: ```ts import { HttpError, hook } from "@tetsujs/core"; export const apiKey = (keys: ApiKeys) => hook.beforeParse(async (ctx) => { const header = ctx.req.headers.get("authorization") ?? ""; const key = header.startsWith("Bearer ") ? header.slice(7) : ""; const client = key === "" ? undefined : await keys.find(key); if (!client) throw new HttpError(401); return { client }; }); ``` How the token is checked — a JWT library, a key looked up by its hash — is the service’s business; the route sees only `ctx.client`. To accept either a session or a token, write one hook that tries both and returns the same field. Wrap the hook in `secured()` from `@tetsujs/openapi` to put its scheme and its `401` in the OpenAPI document. See [documenting hooks](/docs/packages/openapi/#documenting-hooks). ## Cookies across sites and CSRF With the frontend on the same site, the `lax` cookie above needs nothing more. Browsers send it on the site’s own requests and on navigation to it, but not on a form post or a `fetch` from another site’s page, and that is what stops those pages from acting as the user. A frontend on another site needs the cookie set with `secure: true` and `sameSite: "none"`, and `cors({ credentials: true })`; see [Cookies](/docs/concepts/cookies/#cross-site-cookies-and-cors). The browser then sends the cookie on requests from any site. CORS stops another page from reading the answer, not from sending the request: a form on another site can still post to the API with the user’s cookie. The frontend asks for the cookie to be sent: `credentials: "include"` on `fetch`, or once for the [typed client](/docs/packages/client/#options). So with `sameSite: "none"`, check the `Origin` of every request that changes something. A hook after `cors()` refuses the ones not on the list: ```ts const trusted = new Set(["https://app.example.com"]); const safe = new Set(["GET", "HEAD", "OPTIONS"]); const sameOrigin = hook.beforeParse((ctx) => { if (safe.has(ctx.req.method)) return; const origin = ctx.req.headers.get("origin"); if (!origin || !trusted.has(origin)) throw httpError(403, "ORIGIN_NOT_ALLOWED"); }); ``` A client that is not a browser sends no `Origin` and is refused here; it should authenticate with a token instead of a cookie. ## Rate limiting sign-in Sign-in is where passwords are guessed. Limit it twice: by the client’s address, before the body is read, and by the account being tried, after validation, which stops guessing spread across many addresses. See [`@tetsujs/rate-limit`](/docs/packages/rate-limit/#limiting-by-account). Behind `authenticate`, a limiter can count by user instead: `key: (ctx: Requires<{ user: User }>) => ctx.user.id`, mounted after it in `beforeParse`. Do not key by the raw cookie: a client that sends a new value each time gets a new budget each time. Behind a proxy, the address is the proxy’s until you say otherwise — see [Behind a proxy](/docs/guides/behind-a-proxy/). ## WebSockets A WebSocket handshake is a request and goes through the same hooks. `authenticate` refuses it with an ordinary `401`, and the user it returns is `socket.data.user`. See [WebSockets](/docs/concepts/websockets/). # Background jobs > Scheduled work that starts and stops with the server, never overlaps itself, reports its failures and runs once per fleet when it has to. This guide runs scheduled work inside the server’s process — a sweep every few minutes, a cache refreshed every few seconds — and stops it with the server through [`@tetsujs/lifecycle`](/docs/packages/lifecycle/). There is no jobs package: `Bun.cron` and `setInterval` do the scheduling, and the rest is a few lines you own. ## A scheduled job This job deletes expired sessions every five minutes. On shutdown, the pool is closed only after a run in progress ends: ```ts import { onShutdownSignals } from "@tetsujs/lifecycle"; const reportError: ReportError = ({ source, error }) => logger.error({ err: error, source }, "failed"); async function sweepExpired(signal: AbortSignal): Promise { while (!signal.aborted) { const deleted = await sessions.deleteExpired({ limit: 500 }); if (deleted < 500) return; } } const server = Bun.serve({ ...createApp({ reportError, routes }), port: 3000 }); let running: Promise | undefined; const shutdown = onShutdownSignals(server, { reportError, close: [() => running, () => pool.end()], }); const job = Bun.cron("*/5 * * * *", async () => { running = sweepExpired(shutdown.stopping) .catch((error) => { reportError({ source: "job", error }); }) .finally(() => { running = undefined; }); await running; }); shutdown.stopping.addEventListener("abort", () => job.stop(), { once: true }); ``` * **No new run starts once a shutdown begins.** `stopping` is an `AbortSignal` that aborts as soon as a shutdown begins, and it stops the job. * **A run in progress is waited for.** Closers run in order after the server has stopped. The first returns the run’s promise, so the pool closes only once the run settles. Closers have no deadline, so a long run checks the signal between batches and returns early. * **A failed run does not end the process.** `Bun.cron` treats a rejected promise as `setTimeout` does: an unhandled rejection, which ends the process when nothing listens for it. The `catch` hands it to the same `reportError` that `createApp` was given, and the schedule goes on. See [Errors](/docs/concepts/errors/). * **Runs do not overlap.** `Bun.cron` schedules the next fire only after the callback’s promise settles, so a slow run delays the next one. `Bun.cron` reads the expression in the system’s local time unless it is given `{ tz: "UTC" }` as a third argument. It also accepts nicknames such as `@hourly` and `@daily`. ## An interval A cron expression’s smallest step is a minute. For work every few seconds, use `setInterval`. It fires whether the last run finished or not, so `running` doubles as a guard: ```ts let running: Promise | undefined; const shutdown = onShutdownSignals(server, { close: [() => running] }); const timer = setInterval(() => { if (running) return; running = refreshRates() .catch((error) => { reportError({ source: "rates", error }); }) .finally(() => { running = undefined; }); }, 10_000); shutdown.stopping.addEventListener("abort", () => clearInterval(timer), { once: true }); ``` A tick that finds a run still going is skipped, so a slow upstream slows the refresh down instead of piling up runs. ## Once per fleet Every instance runs the schedule, so a daily report is sent once per instance. Work that must run once needs a lock that every instance sees, kept where the fleet’s shared state lives. Put it behind a one-method interface, so a test can pass a lock that always answers `true`: ```ts import { redis } from "bun"; export interface JobLock { acquire(name: string, ttlMs: number): Promise; } export const redisLock: JobLock = { acquire: async (name, ttlMs) => (await redis.set(`lock:${name}`, crypto.randomUUID(), "PX", String(ttlMs), "NX")) === "OK", }; const job = Bun.cron("0 6 * * *", async () => { if (!(await redisLock.acquire("daily-report", 10 * 60_000))) return; await sendDailyReport(); }); ``` The first instance to set the key runs the report; the others find it taken and return. The lock is never released: it expires. Keep its time to live longer than a run and shorter than the period — ten minutes against a day here. An instance that dies mid-run leaves the lock to expire, and the job waits for its next fire. A Postgres advisory lock or a row with a unique key fits the same interface. None of them guarantees the run happens: one that fails after taking the lock is not retried until the next fire. Work that must not be lost belongs in a queue. ## Where else a job can live A job inside the server shares its process, its memory and its deploys. That makes it cheap, and sometimes wrong: * A job that must run when no server does — a nightly export on a service that scales to zero — belongs to the orchestrator: a Kubernetes `CronJob`, or `Bun.cron` with a module path and a title, which registers it with the operating system’s scheduler. * A job heavy enough to slow requests down — a large import, image processing — belongs in a worker process of its own. * A queue consumer is a loop over a transport — Redis streams, SQS, a table polled with `for update skip locked` — and the framework has no opinion on which. It stops on the same `stopping` signal. # Behind a proxy > The client's address behind a proxy or load balancer, rate limiting by it, HTTPS that ends at the proxy, secure cookies and HSTS. This page covers what changes when the application runs behind a reverse proxy or a load balancer: where the client’s address comes from, how to rate limit by it, and what HTTPS that ends at the proxy means for cookies and security headers. ## The client’s address `ctx.server.requestIP(ctx.req)` is the address at the other end of the connection. Behind a proxy, that is the proxy, for every request. The proxy passes the client’s address in `x-forwarded-for`. Each proxy on the way appends the address it received the request from: ```text x-forwarded-for: , , ``` The client can put anything at the start of the header, so only the entries your own proxies appended can be trusted. They are at the end: the client’s address is the Nth entry from the end, where N is the number of your proxies in front of the server. Tetsu does not parse this header, because N is a fact about your deployment, and a wrong default fails silently: count one proxy too few and every client shares a proxy’s address; take the first entry and every client picks its own. ## A hook for the client’s address Write N once, in a hook that adds the address to the context: ```ts const trustedHops = 1; export const clientIp = hook.beforeParse((ctx) => { const chain = ctx.req.headers.get("x-forwarded-for"); if (!chain) { return { clientIp: ctx.server.requestIP(ctx.req)?.address }; } return { clientIp: chain.split(",").at(-trustedHops)?.trim() }; }); ``` With one load balancer, `trustedHops` is `1`. With a CDN in front of the load balancer, it is `2`. Without the header, as on a developer’s machine, the connection’s address is the client’s. This holds only when every request passes through all your proxies. Keep the server reachable only from the last one, with a private network or a firewall rule. If a proxy overwrites a header of its own, such as `x-real-ip` in a common nginx setup, you can read that instead, on the same condition. Mount the hook first in the application’s `beforeParse`: ```ts createApp({ hooks: { beforeParse: [clientIp] }, reportError: ({ source, error, ctx }) => logger.error({ err: error, source, clientIp: ctx?.clientIp }, "tetsu"), routes, }); ``` The application’s later hooks see `ctx.clientIp` typed, and so does `reportError`, where it is optional because a failure may come before the hook ran. A route handler that reads it mounts the hook on the route, or declares `Requires<{ clientIp: string | undefined }>` — see [Context and its types](/docs/concepts/context/). `requestId()` ignores an incoming `x-request-id` by default, since a client could stamp another client’s log lines. If your proxy sets the header on every request, `requestId({ trustIncoming: true })` keeps the proxy’s id, so its logs and the application’s share it. See [`@tetsujs/request-id`](/docs/packages/request-id/). ## The IPv4-mapped form `Bun.serve` without a `hostname` listens on IPv4 and IPv6 at once, and reports an IPv4 client as `::ffff:203.0.113.7`, not `203.0.113.7`. As a rate limit key, that is harmless. Compared with an address written the usual way, such as a proxy’s address in a list, it never matches, and nothing reports it. Strip the prefix before comparing. A stricter address hook needs this. It trusts `x-forwarded-for` only on a connection from one of your proxies, and takes the connection’s address otherwise, which also covers a request that bypassed them: ```ts const proxies = new Set(["10.0.0.2", "10.0.0.3"]); export const clientIp = hook.beforeParse((ctx) => { const peer = ctx.server.requestIP(ctx.req)?.address.replace(/^::ffff:/, ""); const chain = ctx.req.headers.get("x-forwarded-for"); if (!chain || peer === undefined || !proxies.has(peer)) { return { clientIp: peer }; } return { clientIp: chain.split(",").at(-1)?.trim() }; }); ``` Without the `replace`, `proxies.has(peer)` is `false` for every IPv4 request, and the hook reports the proxy as the client. `at(-1)` assumes one proxy, as `trustedHops = 1` does above. The other way out is `hostname: "0.0.0.0"`, which listens on IPv4 only and turns IPv6 clients away. Tests have the same choice — see [Testing](/docs/guides/testing/#ipv4-and-ipv6). ## Rate limiting behind a load balancer `rateLimit()` has no default `key`, because behind a balancer the connection’s address is the balancer’s, and every client would share one bucket; see [Choosing a key](/docs/packages/rate-limit/#choosing-a-key). Count by the address the hook worked out. The limiter declares the field with `Requires`, and the compiler checks that it is mounted after the hook: ```ts import type { Requires } from "@tetsujs/core"; import { rateLimit } from "@tetsujs/rate-limit"; const limit = rateLimit({ limit: 100, windowMs: 60_000, key: (ctx: Requires<{ clientIp: string | undefined }>) => ctx.clientIp, }); createApp({ hooks: { beforeParse: [clientIp, limit] }, routes }); ``` A key of `undefined` skips the limit for that request. With the first hook, that happens when the chain is shorter than `trustedHops`, which means a request bypassed a proxy. Several instances each count in their own memory by default, so a client gets the limit once per instance. A [shared store](/docs/packages/rate-limit/#a-shared-store), such as Redis, makes it one budget. ## HTTPS that ends at the proxy A proxy usually terminates TLS: the client talks HTTPS to it, and it talks plain HTTP to the application. Two things follow. **`ctx.req.url` says `http:`**, and its host is whatever the proxy put in `host`. Build absolute URLs the application sends out, such as links in emails, from a public origin in your configuration, not from the request. `x-forwarded-proto` reports the client’s scheme, if your proxy sets it. **`secure` cookies still work.** `secure: true` tells the browser to send the cookie over HTTPS only, and the browser’s connection to the proxy is HTTPS. Set it the same way with or without a proxy. Bun can also terminate TLS itself with the `tls` option of `Bun.serve`. ## HSTS `secureHeaders()` from `@tetsujs/secure-headers` sends `strict-transport-security` on every response, which tells browsers to use HTTPS for the site from then on, for 180 days by default. Browsers ignore it on plain HTTP responses, so the application does not need to know whether TLS ended in front of it. ```ts import { secureHeaders } from "@tetsujs/secure-headers"; const secure = secureHeaders({ hsts: { maxAge: 63_072_000, includeSubDomains: true } }); createApp({ hooks: { beforeResponse: [secure] }, routes }); ``` `includeSubDomains` and `preload` are off by default: the first breaks any subdomain still on plain HTTP, and the second takes months to undo. See [`@tetsujs/secure-headers`](/docs/packages/secure-headers/#hsts). # Deploy to production > Serving the application with Bun.serve, configuration from the environment, graceful shutdown, a container image and a single-file binary. This page takes an application from `bun src/main.ts` on a laptop to production: serving, configuration, graceful shutdown, and two ways to ship it — a container image and a single-file binary. There is no production mode. The application runs the same code everywhere, and `main.ts` passes in what differs. There is no build step either: Bun runs TypeScript as it loads it, so the container below runs `src/main.ts` as it is. A [single-file binary](#a-single-file-binary) is the option for when you want one. ## Serving `createApp` returns what `Bun.serve` takes, so Bun’s own options go next to it: ```ts const server = Bun.serve({ ...app, port: Number(Bun.env.PORT ?? 3000), }); console.log(`listening on ${server.url}`); ``` The port, hostname, TLS, idle timeout and Bun’s body size cap are all `Bun.serve` options. An option written after the spread wins. The idle timeout, 10 seconds unless set, closes a connection that sends nothing for that long. An event stream from `sse()` sets its own request’s timeout above its heartbeat, except on a unix socket, such as one nginx on the same machine connects to: Bun ignores a request’s timeout there, and the heartbeat has to stay under 8 seconds. See [Idle connections](/docs/packages/sse/#idle-connections). Without a `hostname`, Bun listens on every interface, which is what a container needs. A server on `localhost` only is unreachable from outside the container. Bun compresses no response. A proxy or a CDN in front can, on its own processor rather than the application’s. nginx does with `gzip on`, and compresses only `text/html` until `gzip_types` names more types, such as `application/json`. Files can go out compressed without a proxy: [`@tetsujs/static`](/docs/packages/static/#compressed-copies) sends the copies a build compressed once. ## Configuration The core reads no environment variables. `main.ts` reads the environment once, checks it, and passes values in: the port to `Bun.serve`, the cookie secret to `createApp`, a limit to `rateLimit()`. A missing secret then stops the process at startup, not on the first request that needs it. `NODE_ENV` changes nothing in the framework. A difference between environments is a value `main.ts` passes, such as `validateResponses: Bun.env.NODE_ENV !== "production"`. Keep response checks on unless a measurement says otherwise: a response schema that strips unknown keys is also what keeps a field like `passwordHash` out of the JSON; see [Responses](/docs/concepts/responses/#what-is-serialized). ## Stopping without cutting requests A deploy, a scale-down or a restart sends the process `SIGTERM`. `onShutdownSignals()` from `@tetsujs/lifecycle` stops accepting connections, lets requests in flight finish within a grace period, then runs the closers and exits: ```ts import { onShutdownSignals } from "@tetsujs/lifecycle"; const server = Bun.serve({ ...app, port: Number(Bun.env.PORT ?? 3000) }); onShutdownSignals(server, { close: [() => db.close()] }); ``` Closers run after the server has stopped, so no request loses the database it is using. The process exits with `0`, or `1` when connections had to be cut or a closer failed. Behind a load balancer, add a pre-stop delay, `preStopDelayMs`, and fail the readiness check during it, so traffic stops arriving before the server stops. Event streams and WebSockets never finish on their own; close them on the `draining` signal. Both are in [Health checks and shutdown](/docs/guides/health-and-shutdown/). The platform kills a stopping process after a limit: ten seconds for `docker stop` by default, thirty for a Kubernetes pod. The shutdown must fit in it: the pre-stop delay, `graceMs` (ten seconds by default), `forceMs` and the closers together. Lower `graceMs` or raise the platform’s limit. ## A container The official `oven/bun` image has everything the application needs. Dependencies are installed in their own layer, so a code change does not reinstall them: ```dockerfile FROM oven/bun:1.4 WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production COPY . . USER bun EXPOSE 3000 CMD ["bun", "src/main.ts"] ``` .dockerignore ```text node_modules .env .git ``` * **`CMD` in exec form**, a JSON array, makes Bun the main process, so `SIGTERM` reaches it. The shell form, `CMD bun src/main.ts`, starts a shell that does not pass the signal on, and the platform kills the process with its requests in flight. * **`--production`** skips development dependencies. Bun runs TypeScript directly, so `typescript` is not needed at runtime. * **`--frozen-lockfile`** installs exactly what `bun.lock` records. * **`USER bun`** runs the process as the image’s unprivileged user. * **`.env` stays out of the image.** Configuration comes from the platform’s environment. Pin the Bun version you develop and test with. Tetsu requires Bun 1.4 or later. ## A single-file binary `bun build --compile` bundles the application, its dependencies and Bun itself into one executable of about 60 MB: ```bash NODE_ENV=production bun build --compile src/main.ts --outfile server PORT=8080 ./server ``` The binary needs no `node_modules` and no Bun on the machine. Environment variables are read when it runs, and `SIGTERM` stops it gracefully. Some things differ from running under `bun`: * **`process.env.NODE_ENV` is fixed at build time**, to the value the build ran with, or `development` when unset. That is why the build above sets it. `Bun.env.NODE_ENV` is still read at runtime. * **A `.env` file in the working directory is loaded.** Build with `--no-compile-autoload-dotenv` so a stray file cannot change production configuration. * **Do not minify names.** `--minify` and `--production` rename classes, so an error’s `constructor.name` in a failure report becomes a letter or two. Build without them, or with `--minify-whitespace --minify-syntax`. A binary runs on the operating system and processor it was built for. To build for another, use `--target`; see Bun’s documentation on single-file executables. ## Before the first deploy * The cookie secret comes from the environment, 32 random bytes or more — see [Cookies](/docs/concepts/cookies/). * `secureHeaders()` on the application — see [`@tetsujs/secure-headers`](/docs/packages/secure-headers/). * A `reportError` receiver, so failures reach your logger instead of the console — see [Logging](/docs/guides/logging/). * Behind a load balancer or a proxy, the client’s address and HTTPS come from the proxy — see [Behind a proxy](/docs/guides/behind-a-proxy/). * Behind nginx, which buffers a proxied response by default, a live `stream()` sends `x-accel-buffering: no`, as `sse()` does on its own — see [`@tetsujs/sse`](/docs/packages/sse/). * With `@tetsujs/sql`, the migrations run as a step before the application starts, and `verifySchema()` checks the database as it starts — see [Deploy and CI](/docs/database/deploy/). * A body limit that fits the API: `maxBodySize` is 1 MiB by default — see [Request bodies](/docs/concepts/request-bodies/#size-limits). A proxy in front has a limit of its own, and the lower one wins: nginx’s `client_max_body_size` is 1 MB by default. # Health checks and shutdown > Liveness and readiness routes, and a shutdown that fails readiness first, drains the requests in flight and closes streams and sockets on time. This guide gives an instance the two probes an orchestrator asks, and a shutdown that lets the load balancer move traffic away before anything is cut. The shutdown is [`@tetsujs/lifecycle`](/docs/packages/lifecycle/); its package page lists every option. ## Liveness and readiness * **Liveness** asks whether the process works at all. A failure restarts it, so it checks nothing but the process: a liveness check on the database would restart every instance when the database goes down. * **Readiness** asks whether this instance can serve traffic now. A failure takes it out of rotation until it passes again. It checks the dependencies the instance cannot answer without, and it fails while the instance is shutting down. ```ts import { controller, httpError, route } from "@tetsujs/core"; async function passes(check: () => Promise, timeoutMs: number): Promise { try { await Promise.race([check(), Bun.sleep(timeoutMs).then(() => Promise.reject())]); return true; } catch { return false; } } interface HealthDeps { readonly stopping: () => boolean; readonly checks: Readonly Promise>>; } export const healthController = controller("Health", ({ stopping, checks }: HealthDeps) => ({ live: route({ method: "GET", path: "/livez", docs: { hidden: true }, handler: () => "ok" }), ready: route({ method: "GET", path: "/readyz", docs: { hidden: true }, handler: async () => { if (stopping()) throw httpError(503, "STOPPING", "Shutting down"); const results = await Promise.all( Object.entries(checks).map(async ([name, check]) => ({ name, ok: await passes(check, 1_000) })), ); const failing = results.filter((result) => !result.ok).map((result) => result.name); if (failing.length > 0) throw httpError(503, "NOT_READY", `Not ready: ${failing.join(", ")}`); return "ok"; }, }), })); ``` The checks run side by side, each with its own deadline, so one that hangs fails alone and the probe still answers in time. A refusal names the checks that failed: ```json { "status": 503, "message": "Not ready: database", "error": "NOT_READY" } ``` `stopping` is a function because the shutdown signal exists only once the server does, and the server is built from this controller. The next section wires it. Keep each check cheap — `select 1` on the pool, not a query over a table — since every balancer asks every few seconds. Leave out dependencies the instance can serve without: a shared dependency fails every instance’s check at once, and the balancer is left with nowhere to send traffic. `docs: { hidden: true }` keeps the probes out of the [OpenAPI document](/docs/packages/openapi/). To keep them out of [request logs and metrics](/docs/guides/metrics/#feeding-a-histogram), skip them by `record.route` in `accessLog()`’s `write`. Mount a rate limit on the groups it protects rather than on the application, so the probes are not counted against it. ## Stopping On `SIGTERM` or `SIGINT`, `onShutdownSignals()` aborts `stopping`, so readiness starts failing, keeps serving for `preStopDelayMs`, then aborts `draining` and stops the server, giving the requests in flight `graceMs` (10 seconds) to finish. It then cuts what is left within `forceMs` (1 second), runs the `close` functions and exits. The [package page](/docs/packages/lifecycle/#usage) lists every step and option. ```ts import { createApp } from "@tetsujs/core"; import { onShutdownSignals } from "@tetsujs/lifecycle"; const checks = { database: () => pool.query("select 1") }; const server = Bun.serve({ ...createApp({ routes: [ healthController({ stopping: () => shutdown.stopping.aborted, checks }), ordersController({ orders }), ], }), port: 3000, }); const shutdown = onShutdownSignals(server, { preStopDelayMs: 5_000, close: [() => pool.end()], }); ``` The closers run after the server has stopped, so a request in flight does not lose the pool it is using. ### Why the delay In Kubernetes, `SIGTERM` and the pod’s removal from the Service happen in parallel, and the removal takes time to reach every proxy. A server that stops the moment the signal lands cuts the requests still being routed to it. So readiness fails first, and the server keeps serving for `preStopDelayMs` while the balancers catch up. Use the delay together with a failing readiness check. A balancer that polls each instance learns about the shutdown only from the failing answer; without it, the delay only postpones the same cut. Where nothing has to be told — a test, a CLI — leave the delay at `0`. ### How long it takes A shutdown takes at most `preStopDelayMs + graceMs + forceMs` — 16 seconds with the settings above — plus the closers, which have no deadline. The platform must wait at least that long before it kills the process; see [Kubernetes](#kubernetes) below. A second signal skips the delay and the grace period but still cuts what is left and runs the closers; a third ends the process at once. ## Streams and sockets An event stream or a long poll never finishes on its own. Left open, it holds the stop for the whole of `graceMs`, and the process then exits with `1`. Close it on `draining`, which aborts after the delay, when the server starts to stop. An [event stream](/docs/packages/sse/) takes it as `until`. The signal exists only once the server does, and the server is built from the controllers, so a controller takes it as a function, as the health controller takes `stopping`, and calls it when a request comes in: ```ts import { controller, createApp, route } from "@tetsujs/core"; import { onShutdownSignals } from "@tetsujs/lifecycle"; import { sse } from "@tetsujs/sse"; const feedController = controller( "Feed", ({ draining }: { draining: () => AbortSignal }) => ({ live: route({ method: "GET", path: "/feed", handler: (ctx) => sse(ctx, feed, { until: draining() }), }), }), ); const server = Bun.serve({ ...createApp({ routes: feedController({ draining: () => shutdown.draining }), }), port: 3000, }); const shutdown = onShutdownSignals(server, { preStopDelayMs: 5_000 }); ``` By the time a request comes in, `shutdown` exists. A route written in the same file as the server can read `shutdown.draining` in its handler directly. Use `draining`, not `stopping`: during the delay the balancer still sends traffic here, and a client that reconnects at once would land on this server again. A [WebSocket](/docs/concepts/websockets/) endpoint takes `until` too, and closes its sockets with `1001`, going away. The endpoint is declared before the server exists, so `until` is a function, called as each socket opens: ```ts import { ws } from "@tetsujs/core"; const chat = ws({ path: "/chat/:room", until: () => shutdown.draining, open: (socket) => socket.subscribe(socket.data.params.room), }); const server = Bun.serve({ ...createApp({ routes: { chat } }), port: 3000 }); const shutdown = onShutdownSignals(server, { preStopDelayMs: 5_000 }); ``` Without `until`, an open socket holds every stop for `graceMs` and is then cut by the forced stop. ## Kubernetes ```yaml spec: terminationGracePeriodSeconds: 30 containers: - name: api readinessProbe: httpGet: { path: /readyz, port: 3000 } periodSeconds: 2 failureThreshold: 1 livenessProbe: httpGet: { path: /livez, port: 3000 } periodSeconds: 10 failureThreshold: 3 ``` * **`terminationGracePeriodSeconds`** is how long Kubernetes waits before it sends `SIGKILL`. Keep it above `preStopDelayMs + graceMs + forceMs` plus the time the closers take. * **The readiness probe** has to fail within `preStopDelayMs`: a short `periodSeconds` and a `failureThreshold` of one or two. * **The liveness probe** is slower and more forgiving: a restart is expensive, and a busy instance that misses one probe is not dead. * **A `preStop` hook** that sleeps does the same job as `preStopDelayMs`, and the two add up, so use one or the other. `preStopDelayMs` needs nothing in the image; a `sleep` command needs a binary that a minimal image or a [compiled executable](/docs/guides/deploy/) may not have. Startup needs nothing from the package: open the pool and run migrations with `await` before `Bun.serve`, and readiness fails until the server listens. ## Several servers A process with more than one server — the API and a [metrics port](/docs/guides/metrics/#serving-the-registry), or a public API and an admin one — passes them all to one `onShutdownSignals` call; see [Several servers](/docs/packages/lifecycle/#several-servers). Scheduled work stops with the server the same way; see [Background jobs](/docs/guides/background-jobs/). # Logging > Failures the framework cannot answer through reportError, request logs with accessLog and arrivalLog, request ids that join them, and redaction by the application's logger. This page wires an application’s logs: the failures the framework cannot answer to a client, a line for every request, and the request id that joins them. The framework has no logger and takes none; where a line goes and what it contains is up to the application. The examples use [pino](https://getpino.io), and any logger that takes an object works the same way. ## Failures nobody can answer Most failures become a response: a thrown `HttpError` is a `404` or a `409`, a validation failure is a `422`, and the access log shows them with their status. Some failures have no response to become — an error no `onError` hook mapped, an `afterResponse` hook that threw after the response was sent, a broken stream. By default they are printed with `console.error`. Given `reportError`, `createApp` sends them there instead: ```ts import type { FailureReport } from "@tetsujs/core"; import { createApp } from "@tetsujs/core"; import { requestId } from "@tetsujs/request-id"; import pino from "pino"; const logger = pino(); const reportError = ({ source, error, ctx }: FailureReport<{ requestId?: string }>) => logger.error({ err: error, source, requestId: ctx?.requestId }, "tetsu"); createApp({ hooks: { beforeParse: [requestId()] }, reportError, routes, }); ``` `error` is what was thrown, exactly as it was thrown; see [Redaction](#redaction) for why that matters. `source` says what failed. `ctx` is the request’s context, typed from the application’s hooks, each field optional, and absent where there was no request. [Errors](/docs/concepts/errors/#failures-that-cannot-become-a-response) lists every source. The receiver is called and never awaited, so a slow log shipper does not slow down a response. The same function serves the rest of the process: `onShutdownSignals` takes it as its `reportError` option, and a [background job](/docs/guides/background-jobs/) calls it with a source of its own, such as `{ source: "job", error }`. ## A line for every request `@tetsujs/request-log` writes request logs. `accessLog()` is an `afterResponse` hook that writes one record as each response goes out, `404`s and failures included. `arrivalLog()` is a `beforeParse` hook that writes one as each request comes in: ```ts import { createApp } from "@tetsujs/core"; import { requestId } from "@tetsujs/request-id"; import { accessLog, arrivalLog } from "@tetsujs/request-log"; const id = requestId(); const arrived = arrivalLog({ write: (record) => logger.info(record, "request received") }); const finished = accessLog({ write: (record) => logger.info(record, "request finished") }); createApp({ hooks: { beforeParse: [id, arrived], afterResponse: [finished] }, routes, }); ``` An access record looks like this: ```json { "method": "GET", "path": "/notes/42", "route": "/notes/:id", "status": 200, "durationMs": 12.418, "requestId": "0b7c6f0e-…" } ``` `route` is the route that answered, and is absent on a `404`. When something was thrown, the record adds `thrown` with the error’s name, never its message; the full error goes to `reportError`, joined by the request id. When the client left before the response was ready, it adds `aborted: true`. The arrival line is for requests that never finish — a handler that hangs, a process that dies half-way — and leave no access record. It doubles the lines, so skip it behind a proxy that already logs every arrival. Neither record holds headers, bodies or query strings; `path` is the one field that carries what the client sent, so keep secrets out of URLs. A route that needs more in its line — a user id, a tenant — logs it itself. [`@tetsujs/request-log`](/docs/packages/request-log/) lists every field. Both hooks write on the request’s own path, so a `write` that blocks delays the response. Use a logger that does not wait on its output, such as `pino(pino.destination({ sync: false }))`, and call `logger.flush()` among the closers when the process stops. The same records feed [request metrics](/docs/guides/metrics/). ## Joining the lines of one request `requestId()` gives every request an id: `ctx.requestId` in the context, and an `x-request-id` header on the response. Mount it before the request logs, and they pick it up; `reportError` reads it from `ctx?.requestId`. The arrival line, the access line and a failure report of one request then share one id, and a client reporting a problem can quote it from the response header. Order matters: a hook sees only what the hooks before it contributed. See [`@tetsujs/request-id`](/docs/packages/request-id/). ### The id deeper than the handler Code that never receives `ctx` — a repository several calls down — can still log with the id, through `AsyncLocalStorage`: ```ts import { AsyncLocalStorage } from "node:async_hooks"; import { hook, type Requires } from "@tetsujs/core"; const store = new AsyncLocalStorage<{ requestId: string }>(); export const scope = hook.beforeParse((ctx: Requires<{ requestId: string }>) => { store.enterWith({ requestId: ctx.requestId }); }); export const current = () => store.getStore(); ``` Mount `scope` on the application, after `requestId()`, so that every request has it, a `404` included: `createApp({ hooks: { beforeParse: [id, scope] }, routes })`. Placed before `id`, it does not compile. It uses `enterWith` rather than `run` because a hook is not handed the rest of the request as a callback. With pino, a `mixin` puts the id on every line the application writes: ```ts import pino from "pino"; const logger = pino({ mixin: () => ({ ...current() }) }); ``` Return a copy, as above: pino merges each line’s fields into the object `mixin` returns, so the stored object itself would carry one line’s fields into the next. Keep the store to ids and trace labels. What a decision depends on — a user, a role — belongs in `ctx`, where the compiler checks that it is there. ## Redaction `reportError` receives the error as it was thrown. A database error may carry the query parameters; a broken response contract carries the validator’s `issues`, and some validators quote the rejected value. The framework formats none of it into a string, so the logger’s own redaction still sees those fields: ```ts const logger = pino({ redact: ["err.issues", "err.parameters"], }); ``` pino’s error serializer keeps an error’s own fields, such as `issues` on a `ResponseContractError` or a `parameters` field a database driver adds, and `redact` replaces them with `[Redacted]`. The request logs need none of this: their records hold no headers, bodies or query strings, and `thrown` is only a name. # Metrics > Request metrics for Prometheus, fed from the access log's record with prom-client and served on a port of their own. This guide counts and times every request for Prometheus with [`prom-client`](https://github.com/siimon/prom-client), and serves the registry on a port the API’s clients cannot reach. There is no metrics package: `accessLog()` from [`@tetsujs/request-log`](/docs/packages/request-log/) already produces a record for every request, and which registry and buckets suit a service is the application’s choice. ## Feeding a histogram `accessLog()` takes a `write` function, and the record it is handed can go to a logger, a registry, or both: ```ts import { createApp } from "@tetsujs/core"; import { accessLog } from "@tetsujs/request-log"; import { collectDefaultMetrics, Histogram } from "prom-client"; collectDefaultMetrics(); const duration = new Histogram({ name: "http_request_duration_seconds", help: "How long a request took, from arrival to response", labelNames: ["method", "route", "status"], }); const probes = new Set(["/livez", "/readyz"]); const measured = accessLog({ write: (record) => { if (record.route !== undefined && probes.has(record.route)) return; duration.observe( { method: record.method, route: record.route ?? "unmatched", status: record.status }, record.durationMs / 1000, ); }, }); const app = createApp({ hooks: { afterResponse: [measured] }, routes: usersController(), }); ``` The histogram’s `_count` series is the number of requests, so no separate counter is needed: the request rate is `rate(http_request_duration_seconds_count[5m])`, and the error rate is the same filtered by `status`. The [health probes](/docs/guides/health-and-shutdown/) are skipped: they arrive every few seconds from every balancer and would outweigh real traffic. To write the access log too, call the logger in the same `write`. ## Label by the route, not the path `record.route` is the route as declared, `/users/:id`. `record.path` is what the client asked for, `/users/42`, and as a label it creates a new series for every id and every path a scanner tries. Prometheus pays for every series, so a label built from the path grows without bound. A request no route answered has no `route`: a `404`, a `405`, or a CORS preflight that `cors()` answered. The recipe counts them all under `unmatched`, so a spike stays visible without a series per path. Your own hooks and handlers can read the same value as `ctx.route.path`. See [Context fields](/docs/reference/context/). ## Buckets Every bucket is a series for every combination of labels. `prom-client`’s defaults run from 5 ms to 10 s in eleven steps. Pick buckets around the latencies you want to tell apart, and few enough to keep the count down: ```ts const duration = new Histogram({ name: "http_request_duration_seconds", help: "How long a request took, from arrival to response", labelNames: ["method", "route", "status"], buckets: [0.005, 0.025, 0.1, 0.25, 0.5, 1, 2.5], }); ``` Thirty routes with four statuses each and seven buckets is already close to a thousand series per instance. `status` is fine as a label, since a route answers with few statuses; think twice before adding a label that comes from the request. ## What the duration measures `durationMs` runs from `ctx.startedAt`, read by the core before any hook, to the moment the response is handed to Bun. It covers hooks, validation, the handler and serialization. It does not cover writing the response to the socket, TLS, or time spent in a proxy; the balancer’s metrics cover those. For a streamed response, it ends when the stream is handed over, not at the last event. A request whose client left early is recorded with `aborted: true` and the status the server answered. To count those as nginx does, label with `record.aborted ? 499 : record.status`. ## Serving the registry Serve the registry from an application of its own, on a port the balancer does not route to. Prometheus can read it, the API’s clients cannot, and it stays out of the API’s OpenAPI document, access log and rate limits: ```ts import { controller, createApp, route } from "@tetsujs/core"; import { onShutdownSignals } from "@tetsujs/lifecycle"; import { register } from "prom-client"; const metricsController = controller("Metrics", () => ({ metrics: route({ method: "GET", path: "/metrics", handler: async () => new Response(await register.metrics(), { headers: { "content-type": register.contentType }, }), }), })); const api = Bun.serve({ ...createApp({ hooks: { afterResponse: [measured] }, routes }), port: 3000, }); const internal = Bun.serve({ ...createApp({ routes: metricsController() }), port: 9464, }); onShutdownSignals([api, internal], { close: [() => pool.end()] }); ``` The metrics application has no `accessLog()`, so a scrape is neither logged nor measured. One [`onShutdownSignals`](/docs/packages/lifecycle/#several-servers) stops both servers. `collectDefaultMetrics()` adds the process’s memory, CPU, event loop lag and garbage collection. The series are named `nodejs_*` even under Bun, because `prom-client` reads them through the Node APIs Bun implements. ### On the API’s own port Where a second port is not an option, mount the same route on the API with `docs: { hidden: true }` to keep it out of the [OpenAPI document](/docs/packages/openapi/), and add `/metrics` to the `probes` set the `write` function skips. Mount a rate limit on the groups it protects rather than on the application, so the scraper is not counted against it. Hiding the route from the document does not restrict access: anyone who can reach the port can read the metrics. Block the path at the proxy, or guard the route with a hook that checks a token Prometheus sends. ## Metrics of your own Count a business event — an order placed, a payment refused — where it happens, with a `Counter` from the same registry. It shows up on the same `/metrics` page. The same rule applies to its labels: a small, closed set of values, never an id. # Structuring an application > A simple layout to start from, and two habits worth keeping whatever the layout. Tetsu reads the routes it is given, not files or folders, so any layout works. Here is a simple one to start from: ```text src/ main.ts starts the server app.ts builds the application notes/ controller.ts the routes service.ts the logic and the queries schemas.ts what comes in and what goes out controller.test.ts ``` Two habits are worth keeping whatever the layout. ## Build the application in a function `app.ts` makes the services and returns the application: src/app.ts ```ts import type { Database } from "bun:sqlite"; import { createApp } from "@tetsujs/core"; import { notesController } from "./notes/controller"; import { NoteService } from "./notes/service"; export function buildApp(db: Database) { const notes = new NoteService(db); return createApp({ routes: notesController(notes) }); } ``` `main.ts` serves it, and a test builds the same application on a database in memory, so the test runs what the server runs: src/main.ts ```ts import { Database } from "bun:sqlite"; import { buildApp } from "./app"; Bun.serve({ ...buildApp(new Database("notes.sqlite")), port: 3000 }); ``` src/notes/controller.test.ts ```ts import { Database } from "bun:sqlite"; import { serve } from "@tetsujs/core/testing"; import { expect, test } from "bun:test"; import { buildApp } from "../app"; const request = serve(buildApp(new Database(":memory:"))); test("lists notes", async () => { expect((await request("/notes")).status).toBe(200); }); ``` ## Keep HTTP out of services A service takes values, returns values and throws its own errors; routes and hooks deal with requests. Then a service is tested without a server, and a scheduled job or a script can use it too. A small runnable application laid out much this way is [`examples/app`](https://github.com/tetsujs/tetsu/tree/main/examples/app). # Testing > Call a handler directly with testCtx(), or send requests to a real server with serve(), a client with a cookie jar, and a check against the OpenAPI document. Tests use two helpers from `@tetsujs/core/testing`, both under `bun test`. `testCtx()` builds a context to call a handler directly, as a function. `serve()` starts the application on a real server for everything around the handler — routing, hooks, validation, the error format — since Bun’s router is only reachable through a socket. ## Calling a handler A controller is a function of its dependencies, so the test hands in its own, and `testCtx()` builds the context from the parts the handler reads: ```ts import { expect, test } from "bun:test"; import { HttpError } from "@tetsujs/core"; import { testCtx } from "@tetsujs/core/testing"; const note = { id: 7, title: "first" }; const routes = notesController({ notes: { find: (owner, id) => (owner === "ada" && id === 7 ? note : undefined) }, }); test("returns the note it finds", () => { const ctx = testCtx({ params: { id: 7 }, user: { id: "ada" } }); expect(routes.get.handler(ctx)).toEqual(note); }); test("refuses a note that is someone else's", () => { const ctx = testCtx({ params: { id: 7 }, user: { id: "grace" } }); expect(() => routes.get.handler(ctx)).toThrow(HttpError); }); ``` The parts are what the handler would have received: `params` after validation (a number, since the schema converts it) and `user` from the route’s hook. Leave one out and the call does not compile. The rest is filled in. `ctx.req` is a request to `http://test/`; pass your own as `req` when the handler reads it. `ctx.out` collects the status, headers and cookies the handler sets. `ctx.server` throws when touched: code that needs it, such as `ctx.server.requestIP()`, is tested through `serve()`. Only `ctx.server.timeout()` does nothing, since a unit test has no connection to time out. ### Signed cookies in a unit test A handler that sets a signed cookie, or reads one with `signedCookie()`, needs the application’s cookie options. Pass them as the second argument: ```ts import { expect, test } from "bun:test"; import { testCtx } from "@tetsujs/core/testing"; const cookies = { secret: "a test secret", sign: ["session"] } as const; test("signing in sets a signed session", () => { const ctx = testCtx({ params: {} }, { cookies }); signIn.handler(ctx); expect(ctx.out.headers.get("set-cookie")).toMatch(/^session=s1\.[^;]+/); }); ``` A route without path parameters still gets `params: {}`. With the options, `signedCookie()` opens a cookie sent in the `cookie` header of the `req` you pass; without them, it throws and says what is missing. ## Testing through a server `serve()` starts the application on a free port and returns a function that sends requests to it. It takes a path and `fetch`’s options: ```ts import { describe, expect, test } from "bun:test"; import { serve } from "@tetsujs/core/testing"; const request = serve(createApp({ routes: notesController() })); describe("notes", () => { test("a note is found by its id", async () => { const res = await request("/notes/1"); expect(res.status).toBe(200); }); test("a note is created from JSON", async () => { const res = await request("/notes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: "first" }), }); expect(res.status).toBe(201); }); }); ``` The server stops by itself: `serve()` registers an `afterAll` where it is called, and Bun runs it when that scope ends: the file, the `describe`, or the test. Outside `bun test`, call `request.stop()` or `stopServers()`. Bun runs `afterAll` hooks in the order they were registered, so an `afterAll` of yours registered after `serve()` runs once the server has stopped. A teardown that still needs the server uses `{ stop: false }`, shown below, and calls `request.stop()` last. ### Setting up before the server Not in `beforeAll`: Bun runs an `afterAll` registered inside a hook as soon as the hook returns, so the server would stop before the first test. A request to a server that has stopped throws, saying what stopped it. Await the setup at the top level of the file instead, and call `serve()` after it: ```ts import { serve } from "@tetsujs/core/testing"; const db = await openDatabase(); const request = serve(createApp({ routes: notesController({ db }) })); ``` Or keep the hooks and stop the server yourself: with `{ stop: false }`, it runs until `request.stop()`. ```ts import { afterAll, beforeAll } from "bun:test"; import type { RequestFn } from "@tetsujs/core/testing"; import { serve } from "@tetsujs/core/testing"; let request: RequestFn; beforeAll(async () => { const db = await openDatabase(); request = serve(createApp({ routes: notesController({ db }) }), { stop: false, }); }); afterAll(() => request.stop()); ``` A server starts in under a millisecond, so building the application per file or per test, on a database in memory, is cheap. See [Structuring an application](/docs/guides/structuring/). ## A client with a session A test that signs in and then acts as that user needs the session carried from one request to the next. `request.client()` returns a client with a cookie jar that does what a browser would: ```ts import { expect, test } from "bun:test"; test("a session lasts until it is ended", async () => { const client = request.client(); await client("/session", { method: "POST", json: { email: "ada@example.com", password: "correct horse" }, }); expect(client.cookies.get("session")).toBeDefined(); expect((await client("/me")).status).toBe(200); await client("/session", { method: "DELETE" }); expect(client.cookies.get("session")).toBeUndefined(); expect((await client("/me")).status).toBe(401); }); ``` * The jar keeps the cookies responses set, sends each where its `Path` matches, and forgets one that is deleted or expires. `Domain` is ignored and `Secure` cookies go over plain `http`. * A signed cookie is held as it arrived, signature included. `client.cookies.set()` plants a value, to send a forged or stale session. * `json` sends a value as JSON with its `content-type`; `body` is sent as given, a malformed body included. * Headers are a record. A header set to `null` is not sent, so `{ cookie: null }` is a request without the session. Headers passed to `request.client({ headers })` go with every request. * A redirect is returned, not followed, so the test sees the `303` and the cookie it set. `redirect: "follow"` follows it, and loses the cookies set along the way. Clients do not share a jar. Make one per test. ## Testing a hook A hook runs inside a request, so test it through one: mount it on a small route that returns what the hook added, and serve that. ```ts import { expect, test } from "bun:test"; import { controller, createApp, route } from "@tetsujs/core"; import { serve } from "@tetsujs/core/testing"; const sessions = { find: (token: string) => (token === "ada-token" ? { id: "ada" } : undefined) }; const probe = controller("Probe", () => ({ me: route({ method: "GET", path: "/me", hooks: { beforeParse: [authenticate(sessions)] }, handler: (ctx) => ctx.user, }), })); const request = serve(createApp({ routes: probe() })); test("a known token becomes the user", async () => { const res = await request("/me", { headers: { authorization: "Bearer ada-token" } }); expect(await res.json()).toEqual({ id: "ada" }); }); test("an unknown one is refused", async () => { expect((await request("/me")).status).toBe(401); }); ``` Logic worth testing on its own, such as parsing a token, belongs in a plain function the hook calls, tested by calling it. ## Expected failures Errors no `onError` hook mapped, and handlers that break their response contract, go to the application’s `reportError`, or to `console.error` without one. A test that provokes one on purpose would print it. `captureErrors()` collects those lines for each test of its `describe` and restores the console afterwards. Call it in the `describe` body, not inside a test: ```ts import { describe, expect, test } from "bun:test"; import { captureErrors } from "@tetsujs/core/testing"; describe("a failing store", () => { const errors = captureErrors(); test("answers 500 and reports what failed", async () => { expect((await request("/notes/1")).status).toBe(500); expect(errors.lines.join("\n")).toContain("[tetsu]"); }); }); ``` An application with its own `reportError` sends reports there instead; the test passes one that collects them. ## Checking responses against the OpenAPI document `assertDescribed()` from `@tetsujs/openapi/testing` checks that a response a test provoked is one the OpenAPI document declares for its operation: the status, and a body that fits it. See [`@tetsujs/openapi`](/docs/packages/openapi/#testing-against-the-document). ## A database `testDatabase()`, from `@tetsujs/sql/testing`, gives a test file a Postgres database of its own, built from the migrations and dropped once the file has run. The application under test is built on its `db`, as `main.ts` builds it on the real one. See [Testing](/docs/database/testing/) in the Database section. ## IPv4 and IPv6 `serve()` listens on IPv4 and IPv6 together, as `Bun.serve` does by default, so an IPv4 client is reported as `::ffff:127.0.0.1`. To test code that compares addresses as a plain IPv4 client, listen on IPv4: ```ts import { serve } from "@tetsujs/core/testing"; const request = serve(app, { hostname: "127.0.0.1" }); ``` [Behind a proxy](/docs/guides/behind-a-proxy/#the-ipv4-mapped-form) explains the mapped form. Every request in a test comes from the same address, so a rate limit keyed by the address counts them all in one bucket. Build the application per test, or pass the limiter in as a dependency the test controls. # Typed client > Call a Tetsu API from a frontend with typed paths, bodies and errors — with @tetsujs/client and a generated contract, in Next.js and SvelteKit with TanStack Query, or from the OpenAPI document. This guide gives a frontend or another service a client with typed paths, bodies and errors. ## Choosing * **[`@tetsujs/client`](/docs/packages/client/)**, for your own frontends in TypeScript. Its types are the ones your handlers see, taken from the compiler. * **[The OpenAPI document](#from-the-openapi-document)**, for other languages, other teams and public APIs. ## The contract A frontend does not import the server’s code. It imports a contract: a `.d.ts` file of plain types that `tetsu-client contract` generates from the application. Add a script to the server: package.json ```json "contract": "tetsu-client contract src/app.ts --out ../web/src/api.contract.d.ts" ``` The frontend creates its client with it: ```ts import { createClient } from "@tetsujs/client"; import type { Api } from "./api.contract"; export const api = createClient({ baseUrl: "https://api.example.com" }); ``` Commit the contract, so every API change shows up as a diff, and check it in CI: ```bash bunx tetsu-client contract src/app.ts --out ../web/src/api.contract.d.ts --check ``` In a monorepo, run the same command with `--watch` while you work, and the frontend sees a new route as soon as it is saved. Importing `typeof app` into the frontend also works, but it makes the frontend compile the server’s code with Bun’s types; keep that for tests and Bun projects. ## TanStack Query `@tetsujs/client/tanstack` turns the client into [TanStack Query](https://tanstack.com/query) options: ```ts import { useQuery } from "@tanstack/react-query"; import { queryUtils } from "@tetsujs/client/tanstack"; import { api } from "./api"; const q = queryUtils(api); const user = useQuery(q.get("/users/:id").queryOptions({ params: { id } })); if (user.error?.status === 404) { console.log(user.error.body.error); } ``` The same options work with Svelte, Vue and Solid; those adapters take a function that returns them, such as `createQuery(() => q.get(…).queryOptions(…))`. A mutation is `useMutation(q.post("/users").mutationOptions())`, and `invalidateQueries({ queryKey: q.get("/users").key() })` refreshes every query of a route. Two things are worth setting once. Send authorization from the client, not from each call: a call’s headers are part of its query key, and would end up in the cache. And retry only server errors and network failures, since TanStack Query retries everything three times by default in the browser, and a `404` would show only after them: ```ts import { QueryClient } from "@tanstack/react-query"; import { ClientError, createClient } from "@tetsujs/client"; export const api = createClient({ headers: () => ({ authorization: `Bearer ${currentToken()}` }), }); export const retry = (count: number, error: Error) => !(error instanceof ClientError && error.status < 500) && count < 3; export const queryClient = new QueryClient({ defaultOptions: { queries: { retry } }, }); ``` ### A stream A query of a stream is TanStack Query’s `streamedQuery`, fed by the call’s [`events`](/docs/packages/client/#streams). The options of `queryUtils` read one body and are done, so on a stream they fail the query with a `TypeError` rather than leave the connection open. TanStack Query retries that error like any other, so it shows after the retries. ```ts import { experimental_streamedQuery as streamedQuery, queryOptions, useQuery, } from "@tanstack/react-query"; import { ClientError } from "@tetsujs/client"; import { api } from "./api"; const reply = (input: string) => queryOptions({ queryKey: ["reply", input], queryFn: streamedQuery({ streamFn: async function* ({ signal }) { const res = await api.post("/agents/:name/stream", { params: { name: "chat" }, body: { input }, init: { signal }, }); if (!res.ok) throw new ClientError(res); for await (const event of res.events) { if (event.data.type === "delta") yield event.data.delta; } }, reducer: (text, delta) => text + delta, initialValue: "", }), staleTime: Number.POSITIVE_INFINITY, retry: false, }); const { data } = useQuery(reply("Hello")); ``` The query shows the text as it grows, and stays fetching until the stream ends. Its signal goes to the call, so a query that is cancelled or loses its last observer closes the stream. A stream that starts work on the server should not start it again by itself: `staleTime` keeps the query from refetching when the window regains focus or a component mounts, and `retry: false` from running the agent again after a failure. `streamedQuery` is still experimental in TanStack Query. ## Next.js On the server, a component renders for a request whose cookies the client has to pass on itself. A client is cheap to create, so make one per request: src/api.ts ```ts import { createClient } from "@tetsujs/client"; import type { Api } from "./api.contract"; export function api(cookie?: string) { return createClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL, headers: cookie === undefined ? undefined : { cookie }, }); } ``` A server component prefetches the query and hands the cache to the browser: app/users/\[id]/page.tsx ```tsx import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"; import { queryUtils } from "@tetsujs/client/tanstack"; import { cookies } from "next/headers"; import { api } from "./api"; import { User } from "./user"; export default async function Page({ params }: { params: Promise<{ id: string }> }) { const id = Number((await params).id); const q = queryUtils(api((await cookies()).toString())); const queryClient = new QueryClient(); // A failure is left to the browser, which fetches again and shows it. await queryClient.query(q.get("/users/:id").queryOptions({ params: { id } })).catch(() => undefined); return ( ); } ``` `User` is a client component that calls `useQuery` with the same options, over `queryUtils(api())`: in the browser, the browser sends the cookies. Its query client comes from a provider in the root layout, with the `retry` above: app/providers.tsx ```tsx "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; import { retry } from "./retry"; export function Providers({ children }: { children: ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { retry } } })); return {children}; } ``` For an API on another origin, pass `init: { credentials: "include" }` to the client. In a monorepo on Bun, set `"types": ["node"]` in the Next.js `tsconfig.json`, or it picks up Bun’s types from the root. ## SvelteKit A `load` function gets SvelteKit’s `fetch`, which carries the request’s cookies on the server. Pass it to the client: src/lib/api.ts ```ts import { createClient } from "@tetsujs/client"; import type { Api } from "./api.contract"; export function api(fetch = globalThis.fetch) { return createClient({ baseUrl: "https://api.example.com", fetch }); } ``` While it renders, SvelteKit’s `fetch` hides the headers of a response unless a hook lets them through, and the client reads `content-type`. Without the hook, every `load` that calls the client fails with a `500`: src/hooks.server.ts ```ts import type { Handle } from "@sveltejs/kit/hooks"; export const handle: Handle = ({ event, resolve }) => resolve(event, { filterSerializedResponseHeaders: (name) => name === "content-type" }); ``` In SvelteKit 2, `Handle` comes from `@sveltejs/kit`. For an API on another origin, the server’s `fetch` asks for CORS headers as a browser does, so the application needs [`cors`](/docs/packages/cors/) for the frontend’s origin. With TanStack Query, the root layout creates the query client, with queries off on the server, where `load` functions prefetch instead, and the `retry` above: a `load` runs in the browser too, and waits for every retry. src/routes/+layout.ts ```ts import { QueryClient } from "@tanstack/svelte-query"; import { browser } from "$app/env"; import { retry } from "#lib/api.ts"; import type { LayoutLoad } from "./$types"; export const load: LayoutLoad = () => ({ queryClient: new QueryClient({ defaultOptions: { queries: { enabled: browser, retry } } }), }); ``` src/routes/users/\[id]/+page.ts ```ts import { queryUtils } from "@tetsujs/client/tanstack"; import { api } from "#lib/api.ts"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params, parent }) => { const { queryClient } = await parent(); const id = Number(params.id); const q = queryUtils(api(fetch)); await queryClient.query(q.get("/users/:id").queryOptions({ params: { id } })).catch(() => undefined); return { id }; }; ``` The page calls `createQuery(() => q.get("/users/:id").queryOptions({ params: { id: data.id } }))` over `queryUtils(api())` and finds the user in the cache. Wrap the layout in `QueryClientProvider` with `data.queryClient`, as the [Svelte Query docs](https://tanstack.com/query/latest/docs/framework/svelte/ssr) show. Nuxt, SolidStart and other frameworks follow the same idea: a client per request that passes its cookies on, and prefetching as TanStack Query’s guide for that framework describes. ## From the OpenAPI document [`@tetsujs/openapi`](/docs/packages/openapi/) serves the document at `/openapi.json`. [`openapi-typescript`](https://openapi-ts.dev) turns it into types: ```bash bunx openapi-typescript http://localhost:3000/openapi.json -o src/openapi.d.ts ``` and [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/) calls the API with them: ```ts import createClient from "openapi-fetch"; import type { paths } from "./openapi"; const api = createClient({ baseUrl: "https://api.example.com" }); const { data, error } = await api.GET("/users/{id}", { params: { path: { id: 42 } } }); if (error) { console.log(error.error); } else { console.log(data.name); } ``` `error` is the body of any other status the document lists, including the `422` and `500` the framework answers. Branch on its `error` code. A refusal from a hook is in the document only when the hook is [documented](/docs/packages/openapi/#documenting-hooks). To commit the document and check it in CI, as with the contract, see [Using the document directly](/docs/packages/openapi/#using-the-document-directly). # Webhooks > Receiving signed webhooks — the signature checked over the raw bytes before validation, each event handled once, and the answer sent before the work is done. This guide receives webhooks from a payment provider. It checks the signature over the bytes as they were sent, records each event once however often it is delivered, and answers before the work the event asks for is done. ## Checking the signature A signature holds only over the exact bytes the sender signed: parsed and serialized again, the same JSON may come out with its keys in another order, and the signature no longer matches. `rawBody: true` on a route keeps the bytes in `ctx.rawBody` while the body is still parsed and validated into `ctx.body`. A `beforeValidation` hook runs between the two. See [Request bodies](/docs/concepts/request-bodies/). The provider here signs in the style Stripe uses: a header `t=1767225600,v1=5257a869…` with a timestamp and a hex HMAC-SHA256 of the timestamp and the body joined by a dot. Other providers differ in the details; the shape of the check stays the same. ```ts import { timingSafeEqual } from "node:crypto"; const toleranceSeconds = 300; export function signatureHolds(body: Uint8Array, header: string | null, secret: string): boolean { if (!header) return false; const fields = header.split(",").map((field) => field.trim().split("=")); const timestamp = Number(fields.find(([key]) => key === "t")?.[1]); const signatures = fields.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp)) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = new Bun.CryptoHasher("sha256", secret).update(`${timestamp}.`).update(body).digest(); return signatures.some((signature) => { const given = Buffer.from(signature, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); }); } ``` * **`timingSafeEqual`, not `===`.** A string comparison stops at the first difference, and its timing tells an attacker how much of a forged signature was right. `timingSafeEqual` throws on buffers of different lengths, so the length is checked first. * **The timestamp is signed too.** One older than five minutes is refused, so a recorded request cannot be replayed later. * **Every `v1` counts.** A provider rotating its secret sends signatures made with both the old and the new one. The hook that runs the check is built once from the secret: ```ts import type { Requires } from "@tetsujs/core"; import { hook, httpError } from "@tetsujs/core"; export function signedWebhook(secret: string | undefined) { if (!secret) throw new Error("signedWebhook: the secret is empty"); return hook.beforeValidation((ctx: Requires<{ rawBody: Uint8Array }>) => { if (!signatureHolds(ctx.rawBody, ctx.req.headers.get("x-signature"), secret)) { throw httpError(401, "BAD_SIGNATURE", "The signature does not match the body"); } }); } ``` An empty secret — a variable nobody set — fails at startup, since anyone can sign with an empty key. The hook declares that it needs `ctx.rawBody` with [`Requires`](/docs/concepts/context/), so mounting it on a route without `rawBody: true` does not compile. Checking in `beforeValidation` refuses an unsigned request with `401` before the schema sees it, so a stranger gets no `422` describing what the schema expects. A body that is not JSON is refused with `400` while it is parsed, before the hook runs. ## The route ```ts import { controller, route } from "@tetsujs/core"; import { z } from "zod"; const PaymentEvent = z.object({ id: z.string(), type: z.string(), data: z.object({ object: z.record(z.string(), z.unknown()) }), }); interface WebhooksDeps { readonly inbox: ReturnType; readonly secret: string | undefined; } export const webhooksController = controller("Webhooks", ({ inbox, secret }: WebhooksDeps) => { const signed = signedWebhook(secret); return { payments: route({ method: "POST", path: "/webhooks/payments", rawBody: true, schema: { body: PaymentEvent }, hooks: { beforeValidation: [signed] }, docs: { hidden: true }, handler: (ctx) => { inbox.receive(ctx.body.id, ctx.body.type, new TextDecoder().decode(ctx.rawBody)); }, }), }; }); ``` The handler returns nothing, so the answer is `204`. * **The schema accepts every event type.** Providers add new types without notice. A schema that refused them would answer `422`, and the sender would retry each one for days. Check the type where the event is handled. * **The raw body is stored**, not `ctx.body`: the Zod object strips fields it does not declare, and the raw text keeps everything the sender signed. * **`docs: { hidden: true }`** leaves the route out of the [OpenAPI document](/docs/packages/openapi/). * **The body limit** is the application’s `maxBodySize`, 1 MiB by default. Set `maxBodySize` on this route if the provider’s events are larger. ## Receiving an event twice Webhooks are delivered at least once. A sender that did not hear the answer in time sends the same event again. Every event carries an id, so a table with that id as its primary key turns a second delivery into a no-op: ```ts import type { Database } from "bun:sqlite"; export function eventInbox(db: Database) { db.run(`create table if not exists webhook_events ( id text primary key, type text not null, payload text not null, received_at integer not null, processed_at integer )`); const insert = db.prepare( "insert into webhook_events (id, type, payload, received_at) values (?, ?, ?, ?) on conflict (id) do nothing", ); return { receive: (id: string, type: string, payload: string) => insert.run(id, type, payload, Date.now()).changes === 1, }; } ``` `receive` answers whether the event was new. A duplicate is still answered `204`, so the sender stops sending it. The check and the write are one atomic statement, so two deliveries arriving at once cannot both be taken as new; a separate check followed by a write could let both through. ## Answering fast, working after A sender waits a few seconds and then counts the delivery as failed. The work an event asks for — updating an order, sending an email — can take longer, and a failure half-way must not lose the event. The table above is already a queue: the handler records the event and answers, and a job processes what is still pending: ```ts const pending = db.query<{ id: string; type: string; payload: string }, []>( "select id, type, payload from webhook_events where processed_at is null order by received_at limit 100", ); const processed = db.prepare("update webhook_events set processed_at = ? where id = ?"); export async function processEvents(): Promise { for (const event of pending.all()) { await payments.apply(event.type, JSON.parse(event.payload)); processed.run(Date.now(), event.id); } } ``` Run it every few seconds as in [Background jobs](/docs/guides/background-jobs/#an-interval). An event whose processing throws stays pending and is tried again on the next run. Count the attempts in the table and set aside an event past a limit, so one that can never succeed does not block the rest. Starting the work after the response without waiting for it — in an `afterResponse` hook, or a promise the handler does not return — needs no table, but the work lives only in memory. The sender has been told the event arrived and will not send it again, so a restart or deploy half-way loses it for good. That is fine for a cache to warm; for anything the event is the only record of, keep the table. ## Testing A test signs the body the way the sender does and sends it to a served application, with the inbox on an in-memory database: ```ts import { expect, test } from "bun:test"; import { Database } from "bun:sqlite"; import { createApp } from "@tetsujs/core"; import { serve } from "@tetsujs/core/testing"; import { eventInbox, webhooksController } from "./webhooks"; const secret = "test-secret"; const inbox = eventInbox(new Database(":memory:")); const request = serve(createApp({ routes: webhooksController({ inbox, secret }) })); function signed(body: string, at = Math.floor(Date.now() / 1000)) { const signature = new Bun.CryptoHasher("sha256", secret).update(`${at}.${body}`).digest("hex"); return { "x-signature": `t=${at},v1=${signature}` }; } const event = JSON.stringify({ id: "evt_1", type: "payment.succeeded", data: { object: {} } }); test("a signed event is accepted", async () => { const res = await request("/webhooks/payments", { method: "POST", body: event, headers: signed(event) }); expect(res.status).toBe(204); }); test("an event signed an hour ago is refused", async () => { const hourAgo = Math.floor(Date.now() / 1000) - 3_600; const res = await request("/webhooks/payments", { method: "POST", body: event, headers: signed(event, hourAgo) }); expect(res.status).toBe(401); }); ``` A test that sends the same event twice and counts the rows checks the inbox. More on testing is in [Testing](/docs/guides/testing/). # Writing a hook package > How to write a reusable hook — one function from options to one hook, typed by inference, refusing with HttpError, documented in OpenAPI and tested over HTTP. This guide writes a reusable hook the way the framework’s own packages are written, using an API-key check as the example: the package’s shape, the rules that keep its types and failures honest, and a test. ## A package is one hook Tetsu has no plugin system. A package is a function that takes options and returns one hook, which the application mounts like any hook of its own — `cors()`, `requestId()` and `rateLimit()` all have this shape. The compiler checks the package’s hook as it checks any other: its slot, what it needs from the context and what it adds. The function runs once, where the application is wired, and reads and checks the options. The hook it returns runs for every request. A package that seems to need two slots usually does not: `accessLog()` reads the start time from `ctx.startedAt` instead of a `beforeParse` hook, and `cors()` sets its headers on `ctx.out` instead of a late hook. If yours still needs two, [open an issue](https://github.com/tetsujs/tetsu/issues). ## An API-key check The hook reads a key from a header, looks it up, refuses the request when the key is unknown, and adds the key’s client to the context: ```ts import { hook, httpError, reportFailure } from "@tetsujs/core"; import { secured } from "@tetsujs/openapi"; export interface ApiClient { readonly id: string; readonly name: string; } export interface ApiKeyStore { find(keyHash: string): ApiClient | undefined | Promise; used?(clientId: string, at: Date): Promise; } export interface ApiKeyOptions { readonly store: ApiKeyStore; readonly header?: string; } export function apiKey(options: ApiKeyOptions) { const header = options.header ?? "x-api-key"; const check = hook.beforeParse(async (ctx) => { const key = ctx.req.headers.get(header); const client = key ? await options.store.find(hashOf(key)) : undefined; if (!client) throw httpError(401, "INVALID_API_KEY", "A valid API key is required"); void options.store.used?.(client.id, new Date()).catch((error) => { reportFailure(ctx, "apiKey", error); }); return { client }; }); return secured(check, { name: "apiKey", scheme: { type: "apiKey", in: "header", name: header }, error: "INVALID_API_KEY", description: "The API key is missing or unknown", }); } export type ApiKeyHook = ReturnType; function hashOf(key: string): string { return new Bun.CryptoHasher("sha256").update(key).digest("hex"); } ``` Mounted on a route, it gives the handler a typed `ctx.client`: ```ts import { controller, route } from "@tetsujs/core"; const guard = apiKey({ store: { find: (hash) => clients.get(hash) } }); const reportsController = controller("Reports", () => ({ list: route({ method: "GET", path: "/reports", hooks: { beforeParse: [guard] }, handler: (ctx) => ({ requestedBy: ctx.client.name }), }), })); ``` The sections below explain each part. ## Do not annotate the return type `apiKey` has no return type on purpose. The hook’s type carries its slot, the context it needs and the context it adds, all inferred from the `hook.beforeParse` call. An annotation such as `AnyHook` erases them, and even a precise slot loses what the hook adds: ```ts import type { BaseCtx, Hook } from "@tetsujs/core"; import { hook, httpError, route } from "@tetsujs/core"; function apiKey(): Hook<"beforeParse", BaseCtx, unknown> { return hook.beforeParse((ctx) => { if (!ctx.req.headers.has("x-api-key")) throw httpError(401, "INVALID_API_KEY"); return { client: { id: "c1", name: "Reports" } }; }); } route({ method: "GET", path: "/reports", hooks: { beforeParse: [apiKey()] }, handler: (ctx) => ctx.client.name, }); ``` To give the type a public name, read it off the function: `export type ApiKeyHook = ReturnType`. The one exception is a package whose slot is chosen by an option, as `rateLimit()`’s `slot` is. It writes one overload per slot, each returning that slot’s precise hook. See [the rate limiter’s source](https://github.com/tetsujs/tetsu/blob/main/packages/rate-limit/src/index.ts). ## Refuse by throwing, set headers on `ctx.out` A refusal is a thrown `HttpError` made with `httpError(status, code, message)`, never a `Response` the hook builds. A thrown error goes through the application’s `onError` hooks, so an application with its own error format formats the package’s refusal too. Name the code, such as `INVALID_API_KEY`, after what happened; it is what clients branch on. See [Errors](/docs/concepts/errors/). A header the package adds goes on `ctx.out.headers`. The core puts those on every response that leaves — the handler’s, an error, a `404` — so a header set before a refusal is on the refusal too. A maintenance switch shows both rules, and how to document a refusal that carries a header: ```ts import { hook, httpError } from "@tetsujs/core"; import { documented } from "@tetsujs/openapi"; export function maintenance(options: { readonly until: () => Date | undefined }) { const check = hook.beforeParse((ctx) => { const end = options.until(); if (!end) return; ctx.out.headers.set("retry-after", String(Math.max(1, Math.ceil((end.getTime() - Date.now()) / 1000)))); throw httpError(503, "MAINTENANCE", "Down for maintenance"); }); return documented(check, { responses: [ { status: 503, description: "The API is down for maintenance", error: "MAINTENANCE", headers: { "retry-after": { schema: { type: "integer", minimum: 1 } } }, }, ], }); } ``` ## Contribute to the document A hook that answers by itself — a `401`, a `429`, a `503` — changes what the routes it guards can answer. The package annotates its hook with `@tetsujs/openapi` so the [OpenAPI document](/docs/packages/openapi/) says so on every route it runs for: * **`secured(hook, requirement)`** adds a security scheme and its refusal, `401` unless the requirement sets `status`. * **`documented(hook, { responses })`** adds responses: a status, its `error` code, and the fields and headers the hook adds. Both return a copy of the hook with the same type, so the annotated hook mounts exactly like the plain one. See [Documenting hooks](/docs/packages/openapi/#documenting-hooks). ## State lives in the instance Anything the hook keeps between requests belongs to the instance `apiKey()` returned: two calls make two independent hooks. Keep nothing in module scope, where every application and every test in the process would share it. State shared across processes — counters, locks, the keys themselves — goes behind an interface the application implements, as `ApiKeyStore` does here and `RateLimitStore` does for [`@tetsujs/rate-limit`](/docs/packages/rate-limit/#a-shared-store). Keep the interface to the methods the hook calls, and let a method return a value or a promise, so an in-memory test store can stay synchronous. The store is given the key’s hash, not the key, so a database dump holds no working keys. ## Report what cannot be answered Recording when a key was last used should not make the request wait, so the hook starts the write and does not await it. If the write fails, the response may already be gone, and there is nobody to answer with an error. `reportFailure(ctx, source, error)` hands the failure to the application’s `reportError` with the request’s context, so the report carries the request id. `source` names the package. A failure the hook can answer is thrown instead: a store that throws in `find` becomes a `500` and is reported like any unhandled error. ## No timers without an owner A package does not start an interval or a timeout that outlives the request: the timer keeps the process alive, nobody can stop it, and tests hang. To expire entries, sweep as the hook is used, the way the rate limiter’s memory store drops old windows as its map grows. For work in the background, take a signal from the application as an option, such as the `stopping` signal of [`@tetsujs/lifecycle`](/docs/packages/lifecycle/). ## Testing Test a package the way you test an application: mount it on a small application, serve it with `serve()`, and send it requests. The same test can check that the document describes the refusal the hook really sends: ```ts import { expect, test } from "bun:test"; import { controller, createApp, route } from "@tetsujs/core"; import { serve } from "@tetsujs/core/testing"; import { openapi } from "@tetsujs/openapi"; import { assertDescribed } from "@tetsujs/openapi/testing"; import { apiKey } from "./api-key"; const hashOf = (key: string) => new Bun.CryptoHasher("sha256").update(key).digest("hex"); const clients = new Map([[hashOf("key-1"), { id: "c1", name: "Reports" }]]); const guard = apiKey({ store: { find: (hash) => clients.get(hash) } }); const reports = controller("Reports", () => ({ list: route({ method: "GET", path: "/reports", hooks: { beforeParse: [guard] }, handler: (ctx) => ({ requestedBy: ctx.client.name }), }), })); const app = createApp({ routes: reports() }); const request = serve(app); const { document } = openapi(app, { info: { title: "Test", version: "1" } }); test("a known key passes, with its client", async () => { const res = await request("/reports", { headers: { "x-api-key": "key-1" } }); expect(await res.json()).toEqual({ requestedBy: "Reports" }); }); test("an unknown key is refused as the document says", async () => { const res = await request("/reports", { headers: { "x-api-key": "key-2" } }); expect(res.status).toBe(401); await assertDescribed(document, "GET /reports", res); }); test("the route is documented as requiring a key", () => { expect(document.paths["/reports"]?.get?.security).toEqual([{ apiKey: [] }]); }); ``` The store is a `Map` in the test. To test the failure report, pass a store whose `used` rejects and a `reportError` that collects what it receives. More on testing is in [Testing](/docs/guides/testing/). ## Publishing * **`@tetsujs/core` is a peer dependency.** The application and the package must share one copy of the core: an `HttpError` from a second copy is not an instance of the application’s, and the refusal would become a `500`. * **`@tetsujs/openapi` is a dependency** when the package annotates its hook, as in `@tetsujs/rate-limit`. * **Export the options and the hook’s type** — `ApiKeyOptions`, `ApiKeyHook` — so an application can pass the hook around. * **Say which slot it goes in**, and where in that slot: before or after `cors()`, after the hooks that provide what it `Requires`. The compiler checks what a hook needs, not what should come first. See [Groups and mounting](/docs/concepts/groups-and-mounting/). # Installation > What Tetsu needs — Bun, TypeScript with strict on, Bun's types and a module resolution that reads package exports — and how to add it to a project. The core is the only package an application needs: ```bash bun add @tetsujs/core ``` ## Requirements | | | | ------------------ | ------------------------------------------- | | Bun | 1.4 or later | | TypeScript | 5.7 or later, including 7, with `strict` on | | Bun’s types | `@types/bun` | | `moduleResolution` | `bundler`, `node16` or `nodenext` | A project made with `bun init` already has Bun’s types and a `tsconfig.json` that works. For an existing project, add Bun’s types: ```bash bun add -d @types/bun ``` and check `tsconfig.json` against this one: ```json { "compilerOptions": { "lib": ["ESNext"], "target": "ESNext", "module": "Preserve", "moduleResolution": "bundler", "types": ["bun"], "strict": true, "noEmit": true, "skipLibCheck": true } } ``` * `strict` is required. Without `strictNullChecks` the types cannot tell a field that exists from one that might not. * The old `node` resolution (also called `node10`) does not read package `exports` and reports `@tetsujs/core` as not found. * Stricter flags such as `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` work: the packages are checked under `@tsconfig/strictest`. * Bun runs TypeScript without checking its types. The mistakes Tetsu’s types catch show up only when the type checker runs, so run `tsc --noEmit` in CI. Bun’s types also describe what Bun adds to the platform’s globals. Their `fetch` has `preconnect`, so `typeof fetch` requires it too, and a stand-in typed with it, such as a fake in a test, fails to compile with “Property ‘preconnect’ is missing”. Type a dependency on `fetch` by its call instead. `fetch` itself still fits: ```ts type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise; const weatherController = controller("Weather", ({ fetch }: { fetch: Fetch }) => ({ today: route({ method: "GET", path: "/weather", handler: async () => (await fetch("https://api.example.com/today")).json(), }), })); weatherController({ fetch }); // the real one weatherController({ fetch: async () => Response.json({ sunny: true }) }); // a fake, in a test ``` ## A validation library Tetsu validates through [Standard Schema](https://standardschema.dev), which Zod, Valibot and ArkType implement. The core depends on none of them; install the one you use: ```bash bun add zod ``` TypeBox needs the adapter [`@tetsujs/typebox`](/docs/packages/typebox/). ## Other packages Everything else is optional. All packages share one version number and are released together. | Package | What it adds | | ----------------------------------------------------------- | ------------------------------------------------------------------ | | [`@tetsujs/openapi`](/docs/packages/openapi/) | an OpenAPI 3.1 document and a docs page, generated from the routes | | [`@tetsujs/typebox`](/docs/packages/typebox/) | TypeBox schemas as DTOs, file uploads included | | [`@tetsujs/cors`](/docs/packages/cors/) | CORS | | [`@tetsujs/rate-limit`](/docs/packages/rate-limit/) | rate limiting with a replaceable store | | [`@tetsujs/request-id`](/docs/packages/request-id/) | request ids | | [`@tetsujs/request-log`](/docs/packages/request-log/) | access and arrival logs | | [`@tetsujs/secure-headers`](/docs/packages/secure-headers/) | security headers | | [`@tetsujs/sse`](/docs/packages/sse/) | server-sent events and streamed responses | | [`@tetsujs/static`](/docs/packages/static/) | static files: a built site, a single-page app or assets | | [`@tetsujs/lifecycle`](/docs/packages/lifecycle/) | graceful shutdown | | [`@tetsujs/sql`](/docs/database/) | Postgres: SQL migrations, types from the database, Kysely | Next: [Quick start](/docs/quick-start/). # Key concepts > The whole model of Tetsu on one page — the application, controllers, routes, hooks in slots, the context and errors. 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 application is data `createApp` returns plain data — the routes, a fallback for unmatched paths and a WebSocket handler — and `Bun.serve` takes it as it is: ```ts import { controller, createApp, route } from "@tetsujs/core"; const health = controller("Health", () => ({ live: route({ method: "GET", path: "/livez", handler: () => "ok" }), })); const app = createApp({ routes: health() }); Bun.serve({ ...app, 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`](/docs/packages/lifecycle/). ## 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: ```ts import { controller, createApp, route } from "@tetsujs/core"; const notesController = controller("Notes", (notes: NoteStore) => ({ list: route({ method: "GET", path: "/notes", handler: () => notes.all() }), })); const notes = new NoteStore(); export default createApp({ routes: notesController(notes) }); ``` There is no container, no decorator and no registry. The name matters: the OpenAPI document builds each `operationId` from it. [Controllers and dependencies](/docs/concepts/controllers/) ## A route declares what it needs `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](/docs/concepts/routes-and-handlers/) ## Hooks sit in fixed slots A request passes through named stages. There is no `next()`: each hook is made for a slot, and the slot says when it runs. ```plaintext 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. ```ts import { hook, HttpError, route } from "@tetsujs/core"; const auth = hook.beforeParse(async (ctx) => { const user = await sessions.verify(ctx.req.headers.get("authorization")); if (!user) throw new HttpError(401); return { user }; }); route({ method: "GET", path: "/me", hooks: { beforeParse: [auth] }, handler: (ctx) => ctx.user, }); ``` `auth` sits in `beforeParse`, so an anonymous request is refused before its body is read. [Lifecycle hooks](/docs/concepts/lifecycle-hooks/) ## The context has only what exists `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: ```ts import type { Requires } from "@tetsujs/core"; import { hook, route } from "@tetsujs/core"; const audit = hook.beforeHandle((ctx: Requires<{ user: User }>) => { console.log("request by", ctx.user.id); }); route({ method: "GET", path: "/reports", hooks: { beforeHandle: [audit] }, handler: () => [], }); ``` [Context and its types](/docs/concepts/context/) ## Validation is Standard Schema `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](/docs/concepts/validation/) · [Responses](/docs/concepts/responses/) ## One shape for every error A thrown `HttpError`, a failed validation, an unmatched path, a body over the limit — every failure is answered in one shape: ```json { "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](/docs/concepts/errors/) ## Packages are hooks 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: ```ts createApp({ hooks: { beforeParse: [cors({ origin: "https://app.example.com" }), requestId()], afterResponse: [accessLog()], }, routes, }); ``` [Groups and mounting](/docs/concepts/groups-and-mounting/) · [Packages](/docs/packages/core/) # Comparison > How Tetsu relates to Elysia, Hono, NestJS and Fastify — what it borrows, where it differs, and when another framework is the better choice. Tetsu combines ideas from other frameworks: a request lifecycle like Elysia’s without the method chain, hooks in named stages like Fastify’s with inferred types, and Nest’s controllers without decorators or a container. This page says where it differs, what that costs, and when another framework fits better. The notes on other frameworks are brief and may lag behind their latest releases. ## At a glance | | Tetsu | Elysia | Hono | NestJS | | ---------------- | ----------------------------------------- | --------------------------------- | --------------------------------- | ------------------------------------ | | Runtime | Bun | Bun first, others via adapters | many: Workers, Deno, Bun, Node | Node (Express or Fastify underneath) | | Structure | named controllers, wired by hand | a chain of methods and plugins | an app with middleware and routes | modules, classes and decorators | | Around a handler | hooks in fixed slots | lifecycle hooks, scoped by plugin | middleware with `next()` | guards, pipes, interceptors, filters | | Request types | inferred from path, schemas and hooks | inferred through the chain | inferred from validators | declared on DTO classes | | Dependencies | function arguments | decorate / derive / plugins | context variables | a DI container | | Validation | Standard Schema | TypeBox built in, Standard Schema | validator middleware | pipes, usually class-validator | | Typed client | `@tetsujs/client` or the OpenAPI document | Eden | `hc` (RPC) | via the OpenAPI document | ## Elysia Elysia is the closest relative: Bun first, a request lifecycle with named events, types inferred end to end. The difference is in shape. An Elysia application is a chain, where each `.use()`, `.derive()` or `.get()` extends the type of what follows, and plugins decide how far their hooks reach. A Tetsu application is a tree of plain objects: a route lists its own hooks by slot, and a hook reaches as far as the place it is mounted. The chain gives Elysia Eden, a client typed straight from the server’s type. Tetsu’s [client](/docs/packages/client/) reads the same route types, but a frontend gets them from a generated contract of plain types rather than from the server’s code, so it needs neither Bun’s types nor the server’s sources. For consumers in other languages, there is the OpenAPI document. In [the benchmark](/docs/more/performance/), Elysia 1.4 is level with Tetsu on the `GET` routes and up to 0.3 µs a request faster on the `404` and the validated `POST`. ## Hono Hono runs almost anywhere — Workers, Deno, Bun, Node — and is small and quick to start. Its middleware around `next()` is flexible and familiar, but what runs and in which order is decided at runtime, where a type cannot check it. Tetsu gives up every runtime but Bun to use Bun’s router, server and cookies directly, and gives up `next()` for fixed slots, so the compiler checks the order of hooks and what each needs. If the application must run on an edge platform or on more than one runtime, Hono is the better choice. ## NestJS Nest brought controllers, services and modules to Node, with a large ecosystem. It is built on decorators, `reflect-metadata` and a DI container: request types are declared on DTO classes, and the container resolves what each class needs. Tetsu keeps controllers and services and drops the rest: a controller gets its dependencies as function arguments, wired in one place, and the types of `ctx` are inferred. If the team relies on Nest’s ecosystem — queues, microservices, GraphQL — Nest remains the practical choice. ## Fastify Fastify’s hooks — `onRequest`, `preParsing`, `preValidation`, `preHandler`, `onSend`, `onResponse`, `onError` — are what Tetsu’s slots are closest to. Fastify runs on Node, has a mature plugin system with encapsulation, and types its routes through type providers. Tetsu has no plugins: a package is a hook in a slot, and what it adds to `ctx` is inferred. ## What Tetsu costs * **Bun only.** No Node, no Deno, no edge runtimes. * **Explicit over DRY.** Each route lists its hooks. A set several routes share is a constant you spread into their slots. * **A young ecosystem.** Thirteen official packages; beyond them, you write the hook yourself. * **`0.x`.** The API may change between minor versions until `1.0`, and every such change comes with how to move. See [Stability and versioning](/docs/more/stability/). # FAQ > Short answers to the questions people ask about Tetsu first — decorators, classes, start and shutdown, Bun only, plugins, typed clients and the version. ## Why no decorators or DI container? They add a hidden layer — metadata, registration, resolution order — that the compiler cannot check and a reader cannot follow. Here a controller gets its dependencies as function arguments, and the wiring is ordinary code in one file. Decorators also cannot carry the inferred type of `ctx` into a method, so every handler would need a hand-written annotation. ## Can a controller be a class? It can: the framework reads routes from any object and names it after its class. But class fields are initialized before the constructor assigns its parameters, so a hook built from a constructor argument in a field is built from `undefined` (TypeScript reports TS2729). `controller()` avoids the problem. Services stay classes — see [Controllers and dependencies](/docs/concepts/controllers/). ## Where are `onStart` and `onShutdown`? There are none. A container needs them because it creates your objects in an order of its own; here you create them in `main.ts`, so the order is the order of the lines. Whatever must happen before the first request, such as connecting to the database or running migrations, goes before `Bun.serve()`. For stopping, [`@tetsujs/lifecycle`](/docs/packages/lifecycle/) handles `SIGTERM`: it drains the server and then runs the `close` functions you give it, in order. See [Health checks and shutdown](/docs/guides/health-and-shutdown/). ## Why only Bun? Tetsu uses Bun directly: its native router, `CookieMap`, `Bun.serve` and `Bun.Server`. Other runtimes would need a second router and a wrapper around everything else, and a second router is a second answer to which route a request matches. ## Is there a plugin system? No. Packages such as CORS or rate limiting are hooks, mounted in their slot like your own, so everything that runs for a route is visible where it is mounted. See [Writing a hook package](/docs/guides/writing-a-hook-package/). ## Why is `ctx.user` from a group hook missing from the handler’s type? A controller is typed where it is written, not where it is mounted, so a group’s hooks do not reach the handler’s type. The hook still runs. To use its field in a handler, mount the hook on the route. See [Context and its types](/docs/concepts/context/). ## Why is a path parameter a string? A path segment is text. `ctx.params.id` is a `string` until a `params` schema converts it, for example with `z.coerce.number()`. The same holds for query and form fields. ## Can I call the application without starting a server? Not through routing: routing is Bun’s, and only a socket reaches it. Call a handler directly with `testCtx()`, or start a real server on a free port with `serve()` from `@tetsujs/core/testing`. See [Testing](/docs/guides/testing/). ## Is there a typed client, like Eden or Hono RPC? Yes: [`@tetsujs/client`](/docs/packages/client/). It is typed by the application’s routes, through a contract that `tetsu-client contract` generates from them, so a Next.js or SvelteKit frontend imports plain types and never the server’s code. It comes with TanStack Query options. For other languages, `@tetsujs/openapi` builds an OpenAPI document that any generator turns into a client. See [Typed client](/docs/guides/typed-client/). ## Can I use Express or Hono middleware? No. Middleware wraps `next()`; a hook has a slot and no `next()`. Most middleware maps onto one hook in one slot, and the official packages cover the common cases: CORS, request ids, logs, rate limits, security headers. ## Does the framework read environment variables? No, neither the core nor any package. Read your configuration where the server starts and pass it in; in a test, configuration is then a plain argument. ## Is it ready for production? Tetsu is at `0.x`: usable, but the API may change between minor versions until `1.0`. Every breaking change is listed in the [release notes](https://github.com/tetsujs/tetsu/releases) with how to move. See [Stability and versioning](/docs/more/stability/). ## How is it different from Nest, Hono or Elysia? Controllers and explicit wiring like Nest, without decorators or a container; fixed slots instead of Hono’s middleware; controllers instead of Elysia’s method chain. See [Comparison](/docs/more/comparison/). # Performance > What Tetsu costs over raw Bun, compared with Elysia and Hono — processor time per request, startup, memory and the cost of the types — what a database query costs through @tetsujs/sql, and how it is measured. Tetsu adds a fraction of a microsecond of processor time per request over a handler written for `Bun.serve` directly. This page shows the numbers and what they do not cover. The full method and every reading are in [`bench/README.md`](https://github.com/tetsujs/tetsu/blob/main/bench/README.md). ## Processor time per request Four routes, the same in every target: `GET /ping`; `GET /users/:id` behind a hook that adds `user` and an observer after the response; `POST /items` with a validated JSON body; and a `404`. Microseconds per request, lower is better — Bun 1.4.2 on an Apple M5 Pro, medians of three interleaved rounds (2026-09-27): | | ping | users + 2 hooks | POST, validated | 404 | | -------------------- | -------- | --------------- | --------------- | -------- | | raw Bun | 4.78 | 4.81 | 5.85 | 4.82 | | **Tetsu** | **4.95** | **5.09** | **6.60** | **5.13** | | Elysia 1.4.30 | 4.89 | 5.13 | 6.31 | 4.91 | | Elysia 2.0.0-beta.16 | 5.07 | 5.47 | 6.29 | 5.10 | | Hono 4.13.8 | 5.16 | 5.87 | 6.88 | 5.41 | Tetsu adds 0.17–0.75 µs a request over raw Bun and keeps 90–97% of its throughput. It is ahead of Hono on every route. Against Elysia 1.4, which compiles a function per route ahead of time, it is level on the `GET`s and 0.22–0.29 µs behind on the `404` and the validated `POST`. Processor time is the number to compare, because it does not depend on whether the load generators kept up. Requests per second and latency are in the full report. ## Startup and memory | | startup | slowest first request | idle RSS | peak RSS under load | | ------------- | -------- | --------------------- | ----------- | ------------------- | | raw Bun | 3 ms | 0.28 ms | 14.3 MB | 38–53 MB | | **Tetsu** | **5 ms** | **0.51 ms** | **21.7 MB** | **47–59 MB** | | Hono | 6 ms | 1.17 ms | 22.1 MB | 51–60 MB | | Elysia 1.4.30 | 24 ms | 2.15 ms | 38.3 MB | 59–72 MB | | Elysia 2 beta | 13 ms | 2.24 ms | 41.2 MB | 65–77 MB | The benchmark loads Tetsu from its sources. The published packages are bundled and idle at 14.1 MB. No target grows its heap after load. ## The cost of the types On every change, CI measures the compiler’s work for a generated application of 200 routes and fails if it goes over budget: | routes | types | instantiations | memory | check | | ------ | ------ | -------------- | ------ | ------ | | 2 | 10 648 | 27 218 | 56 MB | 0.03 s | | 200 | 30 857 | 235 422 | 83 MB | 0.12 s | | 800 | 92 057 | 866 322 | 166 MB | 0.45 s | TypeScript 7.0.2. The cost grows linearly, about 800 instantiations a route. The comparison with other frameworks uses an earlier version of these routes, without an `afterResponse` observer and an `onError` mapper. Written as one chain, the shape Elysia’s and Hono’s typed clients need, they cost 330–408 thousand instantiations at 200 routes and 2.75–3.04 million at 800, against 196 and 733 thousand in Tetsu. ## Validation Validation goes through Standard Schema, so its cost is the library’s. From `bun run --cwd bench validators`, in nanoseconds per check: | | Zod 4.6 | ArkType 2.2 | Valibot 1.5 | TypeBox via [`tb()`](/docs/packages/typebox/) | | ------------------------------ | ------- | ----------- | ----------- | --------------------------------------------- | | small body, valid | 23 | 24 | 21 | **6.7** | | 20-item body, valid | 922 | 168 | 784 | **52** | | 20-item body, one item invalid | 1,020 | 3,260 | 936 | 21,090 | | memory to import | +21 MB | +57 MB | +3 MB | +36 MB | TypeBox’s error path is the slowest of them: with `issues: "summary"` the invalid body above takes 76 ns. ## Database queries What a query costs through [`@tetsujs/sql`](/docs/database/), next to Bun.SQL alone and to Drizzle on the same client. Processor microseconds per operation, with 32 operations in flight on a pool of 10 connections — Bun 1.4.3 and Postgres 18.6 in Docker on the same Apple M5 Pro, medians of five interleaved rounds (2026-10-14): | | one row | 100 rows | insert | transaction | | ------------------------------- | --------- | --------- | --------- | ----------- | | Bun.SQL | 7.04 | 26.20 | 8.82 | 44.74 | | **`@tetsujs/sql`, query files** | **8.86** | **29.58** | **10.52** | **52.02** | | **`@tetsujs/sql`, builder** | **10.37** | **33.77** | **11.96** | **55.27** | | Drizzle 0.45.3 | 15.71 | 62.57 | 15.22 | 59.73 | | Drizzle, prepared | 8.16 | 47.84 | 9.21 | 59.47 | Query files add 1.8 µs to a one-row read over Bun.SQL alone, and keep 93% of its throughput. The builder adds 3.3 µs, of which 1.5 is Kysely building and compiling the query, and costs a third less than Drizzle’s. A query runs on Bun’s pool, which sends the queries of many callers down one connection, so one-row reads reach 112,000 a second through query files on 10 connections, and 105,500 through the builder. One at a time, a one-row read takes 71–87 µs for every target: most of it is the round trip to the server. ## Reproducing ```bash git clone https://github.com/tetsujs/tetsu && cd tetsu bun install bun run --cwd bench http ``` Every target and every load generator runs in its own process. `ROUNDS=3` interleaves rounds and reports medians; `ONLY=` picks targets. `bun run --cwd bench types`, `validators` and `cost` run the other measurements, and `DATABASE_URL=postgres://… bun run --cwd bench sql` the database’s, on a server where it may create a database of its own. ## What these numbers do not say * They come from one machine and one level of concurrency. * The load generator counts responses but does not check their bodies. * The routes are small. In an application that talks to a database, the framework’s share of each request is smaller still. * Two runs back to back behave like two different machines. Compare targets within one run, with the baseline in it. # Stability and versioning > How Tetsu is versioned, what may change before 1.0, what will not change after it, and which Bun and TypeScript versions are supported. All `@tetsujs/*` packages share one version number and are released together: `@tetsujs/core@0.6.0` and `@tetsujs/cors@0.6.0` were built and tested as one. This page says what a version number promises. ## Before 1.0 * A **minor** version (`0.6.0` → `0.7.0`) may break something. * A **patch** (`0.6.0` → `0.6.1`) never does: it is a fix, or an addition that changes nothing already there. So a project on `^0.6.0` gets every patch automatically and a new minor version only when you choose it. A release that breaks something has a **Moving from** section in its [release notes](https://github.com/tetsujs/tetsu/releases): what changed, why, and how to update an application — usually a few lines the compiler points at. ## What is settled These parts of the design are expected to reach `1.0` as they are, and after `1.0` change only in a major version: * the hook slots and their order — `beforeParse`, `parse`, `beforeValidation`, `validate`, `beforeHandle`, the handler, `beforeResponse`, `afterResponse`, and `onError`; * `hooks` as an object keyed by slot, the same on a route, a group and the application; * `controller(name, build)`, with the name as the source of `operationId`s; * `Requires<{ … }>` for what a hook needs; * the error body `{ status, message, error }`, `onError` as the place to change it, and `reportError` for errors that cannot become a response; * `createApp` returning data and `Bun.serve({ ...app })` serving it; * `route()` and its fields. Still moving: the exact shape of the generated OpenAPI document, and the packages outside the core. ## After 1.0 Strict semantic versioning. A breaking change comes only in a major version, and only after a minor version has shipped the replacement and marked the old way as deprecated: in the types, where the editor shows it, and in the release notes. ## Supported platforms | | Supported | | ---------- | -------------------------------------------------------------------------- | | Bun | 1.4 and later — tested on the pinned version and on the latest | | TypeScript | 5.7 and later, TypeScript 7 included — tested on 5.7, the newest 5.x and 7 | Raising the minimum Bun or TypeScript version counts as a breaking change: a minor version before `1.0`, a major one after it. ## Security fixes Security fixes go into the latest release only. Report a vulnerability privately through GitHub’s [Report a vulnerability](https://github.com/tetsujs/tetsu/security/advisories/new) form, not in a public issue. The full policy is in [`SECURITY.md`](https://github.com/tetsujs/tetsu/blob/main/SECURITY.md). # @tetsujs/client > A typed fetch client for a Tetsu application, typed by a contract generated from its routes, with TanStack Query options. `@tetsujs/client` calls a Tetsu application with the types its routes already declare. It is a small `fetch` wrapper with no dependencies. A frontend gets the types from a contract, a `.d.ts` file generated from the application. Setting it up in Next.js or SvelteKit is in the [guide](/docs/guides/typed-client/). ```bash bun add @tetsujs/client ``` ## Usage ```ts import { createClient } from "@tetsujs/client"; import type { Api } from "./api.contract"; const api = createClient({ baseUrl: "https://api.example.com" }); const res = await api.get("/users/:id", { params: { id: 42 } }); if (res.ok) { console.log(res.data.createdAt); } else { console.log(res.status); } ``` `api.get` suggests the paths that answer `GET`. A wrong path, a missing parameter or a wrong body is a compile error. `data` is what JSON delivers, so a `Date` is a string. `Api` comes from the [contract](#the-contract). In tests and projects on Bun, which have Bun’s types anyway, `createClient()` reads the application’s type directly. ## A call A call takes the path, then the parts the route declares: | Option | | | --------- | ------------------------------------------------------------------------------------------- | | `params` | the path’s `:name` segments | | `query` | the query string; an array repeats its key | | `body` | the body: an object goes as JSON; required with a schema, `{}` when every field is optional | | `form` | the body of a route with `bodyType: "form"`, sent as `FormData` | | `headers` | the headers the route declares, and any other | | `init` | the rest of `RequestInit`: `signal`, `credentials`… | Parameters, the query and headers travel as text; a `Date` goes as its ISO string. A part the route does not declare is a compile error. `api.url(path, { params, query })` returns the address without sending, for an image or an `EventSource`. ## The result A call does not throw on an error status. It resolves to one member per status the route declares, told apart by `ok` and `status`: ```ts if (res.status === 404) { console.log(res.error.error); } ``` A status the route does not declare comes with the core’s [error envelope](/docs/concepts/errors/#the-envelope). Only what is not a response throws: a network failure, an aborted request, JSON that does not parse; and, before anything is sent, a `TypeError` for an address that would go elsewhere: a parameter that is missing, empty, `.` or `..`, or a path that does not start with a single `/`. `unwrap()` returns the data, or throws a `ClientError` with `status`, `body` and `response`: ```ts const user = unwrap(await api.get("/users/:id", { params: { id } })); ``` ## Streams A success that comes with `content-type: text/event-stream` has `events`: the events of the stream, read as the loop asks for them. The route’s `body` declares the `data` of one event: ```ts const res = await api.post("/agents/:name/stream", { params: { name: "chat" }, body: { input: "Hello" }, }); if (res.ok) { for await (const event of res.events) { if (event.data.type === "delta") { console.log(event.data.delta); } } } ``` An event is `{ data, text, event?, id?, retry? }`. `text` is the data as it came; `data` is that text parsed as JSON, and `undefined` when it is not JSON. `sse()` sends a string as it is and anything else as JSON, so a `body` that may be a string, such as `z.string()`, types `data` as `unknown`, as no `body` does. A stream of text reads `text`: a token such as `" 42"` would be the number `42` as `data`. `event` is the name the server gave the event, there only when it gave one; `id` and `retry` are the event’s own. An `id` or a `retry` sent without `data` is no event, and is lost. A failure that comes before the stream, such as the `404` above, is an ordinary failure without `events`. On a route that declares no response, `events` is optional: there when the response is a stream. The body is one stream, read once, and the connection stays open while it is: * Leaving the loop, by `break`, `return` or a throw, closes it. * A stream that is never read keeps it open: read it, or abort the call. `unwrap()` throws a `TypeError` for a stream, and closes it. * The call’s `init.signal` closes it from outside, for a timeout or when the user leaves. The loop then throws the signal’s reason: an `AbortError` for `abort()`, a `TimeoutError` for `AbortSignal.timeout()`. * A network failure throws. A connection that dies without closing is not noticed: the loop waits until the signal aborts it. * A handler that fails mid-stream ends it like a stream that is done. Send an event that says so, such as `done` or `error`, and check for it. There is no reconnecting: to resume, send `last-event-id` in the call’s `headers` with the last `id` read. For a `GET` without headers, `new EventSource(api.url(path))` reconnects by itself, untyped. For TanStack Query, see [the recipe](/docs/guides/typed-client/#a-stream). ## Options | Option | Default | | | --------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `baseUrl` | `""` | put before every path | | `headers` | none | sent with every request; a function is called for each one | | `fetch` | the global `fetch` | what sends the request: SvelteKit’s `event.fetch` (with [a hook](/docs/guides/typed-client/#sveltekit) that lets `content-type` through), a test client, a wrapper | | `init` | none | the rest of `RequestInit` for every request: `credentials`, `cache`, `signal` | A client is a plain object, cheap enough to create one per request. A frontend on another site than the API sends its cookies with `credentials: "include"`, set once for the client: ```ts import { createClient } from "@tetsujs/client"; const api = createClient({ baseUrl: "https://api.example.com", init: { credentials: "include" }, }); ``` A call’s own `init` wins over the client’s key by key. A signal is the exception: the client’s and the call’s both abort the request. A second type argument names the headers the client sends itself, so a route that requires one does not require it of each call: ```ts import { createClient } from "@tetsujs/client"; const api = createClient({ headers: () => ({ authorization: `Bearer ${token}` }), }); ``` For an application with [its own error format](/docs/concepts/errors/#an-error-format-of-your-own), add `error: AppError` next to `headers`. In tests, the core’s [test client](/docs/guides/testing/#a-client-with-a-session) is a `fetch` with a cookie jar: `createClient({ fetch: request.client() })`. ## The contract `tetsu-client contract` writes the contract of the application a module exports: ```bash bunx tetsu-client contract src/app.ts --out ../web/src/api.contract.d.ts ``` | Option | | | ----------------- | ------------------------------------------------------------------------------- | | `--out ` | where to write; printed without it | | `--export ` | the export with the application, or a function that builds it; `app` by default | | `--check` | write nothing, fail when the file is out of date | | `--watch` | write again whenever the application changes | `--name` and `--tsconfig` change the interface’s name and the `tsconfig.json` used. The contract holds plain types only: no server code, no validators, no Bun. A brand is dropped and an `enum` becomes a union of its values. A type it cannot hold, such as a function, is an error that says where. The command runs on Bun. On first run, Bun downloads TypeScript 6.0.3 into its own cache to work the types out. From a script, `generateContract()` takes the same options and returns the contract’s text: ```ts import { generateContract } from "@tetsujs/client/contract"; const text = await generateContract({ entry: "src/app.ts", export: "app" }); ``` ## TanStack Query `@tetsujs/client/tanstack` turns a client into [TanStack Query](https://tanstack.com/query) options for any adapter: ```ts import { queryUtils } from "@tetsujs/client/tanstack"; const q = queryUtils(api); ``` | | | | ------------------------------------------- | ----------------------------------------------------------- | | `q.get(path).queryOptions(call)` | a query | | `q.get(path).infiniteOptions({ input, … })` | an infinite query; `input` turns a page param into the call | | `q.post(path).mutationOptions()` | a mutation; its variables are the call | | `q.get(path).key()` | the start of the route’s keys, for `invalidateQueries` | A query’s error is a `ClientError`: `error.status === 404` narrows `error.body` to that status’s body. Its key is `[method, path, call]`, the call without its `init` and without what is `undefined`, and `{ infinite: true }` after it for an infinite query. It is typed, so `getQueryData` knows what it holds. The types need `@tanstack/query-core`, which every adapter installs. Usage is in the [guide](/docs/guides/typed-client/#tanstack-query). ## What the types don’t know * A declared `404` or `422` also covers the router’s `404` and the validation `422`. * `fetch` follows redirects, so a `3xx` arrives as the target’s response. * A string body goes as text: for a JSON route that takes a string, send `JSON.stringify(text)`. * A route with only `rawBody: true` takes no body from the client: give it a `bodyType`. * A query or form array of one item reaches the server as a string, and an empty one is not sent at all; see [Converting strings](/docs/concepts/validation/#converting-strings). * `z.coerce.boolean()` reads the text `"false"` as `true`: the client sends `false` as `"false"`. Use `z.stringbool()` in a query, a form or headers. * `const { get } = api` loses the types: call `api.get()`. * An undeclared `5xx` from a proxy may have no body, so its `error` can be `undefined`. * In TanStack Query, a network failure is a `TypeError`, not a `ClientError`; `error?.status` is safe either way. * With pnpm, keep one version of `@tanstack/query-core`, or the keys lose their types. * On TypeScript 5.7, type the client with an interface: `interface Api extends ContractOf {}`. # @tetsujs/core > The Tetsu framework itself — install, what the package exports, and the testing entry point. `@tetsujs/core` is the framework: controllers, routes, lifecycle hooks, validation, cookies, WebSockets and the request pipeline. It has no runtime dependencies. This page lists what the package exports and where each part is explained. The model itself is in [Key concepts](/docs/key-concepts/). ## Install ```bash bun add @tetsujs/core ``` It needs Bun 1.4 or later, TypeScript 5.7 or later with `strict` on, `@types/bun`, and `moduleResolution` set to `bundler`, `node16` or `nodenext`. [Installation](/docs/installation/) explains each. The package has two entry points: `@tetsujs/core` for the framework and [`@tetsujs/core/testing`](#testing) for test helpers. ## What it exports ### Building an application | Export | What it is | Explained in | | ------------ | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `createApp` | Builds the application from routes, hooks and options: the object you spread into `Bun.serve`. | [createApp](/docs/reference/create-app/) | | `route` | Declares one route: method, path, `schema`, hooks and handler. | [Routes and handlers](/docs/concepts/routes-and-handlers/) | | `controller` | Declares a controller: a named function from its dependencies to its routes. | [Controllers and dependencies](/docs/concepts/controllers/) | | `group` | Puts routes and groups under a path prefix, with hooks of their own. | [Groups and mounting](/docs/concepts/groups-and-mounting/) | | `hook` | One factory per slot: `hook.beforeParse`, `hook.beforeHandle`, `hook.onError` and the rest. | [Lifecycle hooks](/docs/concepts/lifecycle-hooks/) | | `ws` | Declares a WebSocket endpoint. | [WebSockets](/docs/concepts/websockets/) | | `onMount` | A symbol. A controller method under this name receives the built application, once, before `createApp` returns. | [Controllers and dependencies](/docs/concepts/controllers/) | ### Errors and failures | Export | What it is | Explained in | | ----------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | `HttpError` | An error with a status and a body. The pipeline answers with both. | [Errors](/docs/concepts/errors/) | | `httpError` | Builds an `HttpError` with the standard envelope and an error code of your own. | [Errors](/docs/concepts/errors/) | | `errorBody` | Builds the envelope `{ status, message, error }`, for a hook that returns its own `Response`. | [Errors](/docs/concepts/errors/) | | `ValidationError` | The `HttpError` thrown when a request part fails its schema. It carries the `issues`. | [Validation](/docs/concepts/validation/) | | `ResponseContractError` | Reported when a handler returns a status or body its response map does not allow. The client gets a `500`. | [Responses](/docs/concepts/responses/) | | `reportFailure` | Passes a failure to the application’s `reportError`, the way the framework does. | [Errors](/docs/concepts/errors/) | ### Schemas and cookies | Export | What it is | Explained in | | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | `toJsonSchema` | Returns the JSON Schema of a Standard Schema value, or `undefined` if it has no JSON Schema support. Used by documentation tools. | [Validation](/docs/concepts/validation/) | | `signedCookie` | Reads a signed cookie from the request before it is parsed, when `ctx.cookies` is not filled yet. | [Cookies](/docs/concepts/cookies/#signed-cookies) | ### Type guards `isRoute`, `isGroup` and `isWs` tell whether a value was declared by `route()`, `group()` or `ws()`. They are for code that walks a route tree, such as a documentation generator. ### Types Everything else is a type: the application, the context at each stage and `Requires`, hooks and slots, routes, errors and cookies. The [`createApp` reference](/docs/reference/create-app/#types) lists them by area. ## Testing `@tetsujs/core/testing` imports `bun:test`, so use it in test files only. [Testing](/docs/guides/testing/) shows each helper in use. | Helper | What it does | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `serve(app, options?)` | Starts the application on a free port and returns a request function for it, with the server’s address as `request.url`. Stopped when the tests around the call finish, so not to be called in `beforeAll`; `{ stop: false }` leaves the stop to `request.stop()`. | | `request.client(options?)` | A client with default headers and a cookie jar, for a test that signs in and acts as that user. | | `request.stop()` | Stops the server now. A request to it afterwards throws, saying what stopped it. | | `testCtx(parts, options?)` | Builds a context for calling a handler directly, with no server. | | `captureErrors()` | Collects what the framework logs on `console.error`, so a test can assert on it. Call it in a `describe` body, not inside a test. | | `stopServers()` | Stops every server `serve` started that is still running. Needed only outside `bun test`. | ```ts const users = controller("Users", () => ({ list: route({ method: "GET", path: "/users", handler: () => [] }), })); const request = serve(createApp({ routes: users() })); test("lists users", async () => { const res = await request("/users"); expect(res.status).toBe(200); }); ``` ## Links * [Introduction](/docs/) and [Quick start](/docs/quick-start/) * [Reference: createApp](/docs/reference/create-app/) * [Framework error codes](/docs/reference/error-codes/) * [Stability](/docs/more/stability/): the version policy of the `0.x` releases * [Changelog](https://github.com/tetsujs/tetsu/blob/main/CHANGELOG.md) * [Source of the package](https://github.com/tetsujs/tetsu/tree/main/packages/core) # @tetsujs/cors > CORS headers and preflight responses as one beforeParse hook. `@tetsujs/cors` lets a page on another origin call the API. It is one `beforeParse` hook: it answers the browser’s preflight request and adds the CORS headers to every other response. ```bash bun add @tetsujs/cors ``` ## Usage ```ts import { cors } from "@tetsujs/cors"; const browser = cors({ origin: "https://app.example.com" }); createApp({ hooks: { beforeParse: [browser] }, routes }); ``` On a request from an allowed origin, the hook puts its headers in `ctx.out`, which the core applies to whatever response leaves, errors and `404`s included. A request from any other origin gets no CORS headers, and the browser keeps the answer from the page. ## Where to mount it Mount it on the application, not on a group. Group hooks do not run for `404`, `405` and `OPTIONS` preflights (see [Groups and mounting](/docs/concepts/groups-and-mounting/)), so CORS on a group would miss the preflight it is for. Mount it before every hook that can refuse, such as an authentication check or a rate limit. A refusal from a hook placed before `cors()` goes out without CORS headers, and the browser cannot read it. A preflight also carries no credentials, so an authentication check placed first would refuse it. ```ts createApp({ hooks: { beforeParse: [browser, auth, limit] }, routes }); ``` Hooks that never refuse, such as `requestId()` and `arrivalLog()`, can go before it. Preflights then get a request id and an arrival line too; placed after it, they get neither. ## Options | Option | Default | | | --------------- | ------------------------------- | --------------------------------------------------------------------------- | | `origin` | required | `"*"`, one origin, or a list of origins | | `methods` | `GET, POST, PUT, PATCH, DELETE` | methods allowed in a preflight | | `headers` | `content-type, authorization` | request headers a browser may send | | `exposeHeaders` | none | response headers a browser may read | | `credentials` | `false` | allow cookies and credentials; refused with `origin: "*"` and with `"null"` | | `maxAge` | `86400` | how long a browser may cache a preflight, in seconds | ```ts const browser = cors({ origin: ["https://app.example.com", "https://admin.example.com"], credentials: true, exposeHeaders: ["x-request-id"], }); ``` Write each origin as a browser sends it: scheme, host and port, in lowercase, with nothing after. Origins are compared exactly, so `https://app.example.com/` or `https://App.example.com` would never match. `cors()` throws at startup instead and names the origin you meant. Patterns are not supported: an origin containing `*` is refused. Custom schemes such as `capacitor://localhost` or `chrome-extension://…` work as long as they are written in lowercase. `"null"`, which sandboxed frames and `file:` pages send, is accepted, but not together with `credentials`, since any site can send it. ## What it sends On a request from an allowed origin: * `access-control-allow-origin`: the request’s origin, or `*`; * `access-control-allow-credentials: true`, with `credentials`; * `access-control-expose-headers`, with `exposeHeaders`. An `OPTIONS` request from an allowed origin is answered by the hook itself with `204`, plus `access-control-allow-methods`, `access-control-allow-headers` and `access-control-max-age`. It does not reach later hooks or a handler. Unless `origin` is `"*"`, every response carries `vary: origin`, including those without CORS headers. Without it, a shared cache could serve one origin’s answer to another. A `Vary` header the handler set is kept and merged. ## Notes * The package also exports the types `CorsOptions` and `CorsHook`. Type a hook with `ReturnType` (which is `CorsHook`) rather than `AnyHook`, which erases the slot the hook belongs to. * [Writing a hook package](/docs/guides/writing-a-hook-package/) shows how a package like this one is built. # @tetsujs/lifecycle > Graceful shutdown for a Bun server — stop accepting requests, let the ones in flight finish, then close what the server was using. `@tetsujs/lifecycle` stops a server without cutting the requests it is still serving, then closes what the server was using. It works with any `Bun.serve` server, not only a Tetsu application. The [Health checks and shutdown](/docs/guides/health-and-shutdown/) guide puts it in context. ```bash bun add @tetsujs/lifecycle ``` ## Usage ```ts import { onShutdownSignals } from "@tetsujs/lifecycle"; const server = Bun.serve({ ...app }); onShutdownSignals(server, { close: [() => pool.end()] }); ``` On `SIGTERM` or `SIGINT` this: 1. aborts the `stopping` signal; 2. keeps serving for `preStopDelayMs` (`0` by default; see [Behind a load balancer](#behind-a-load-balancer)); 3. aborts the `draining` signal, stops accepting connections and waits up to `graceMs` for the requests in flight; 4. cuts whatever is left, waiting up to `forceMs`; 5. runs the `close` functions in order; 6. exits with `0` if all went cleanly, or `1` if connections had to be cut or a closer or a server’s `stop` threw. Closers run after the server has stopped, so a request in flight never loses the pool it is using. A closer that throws is reported, and the rest still run. A second signal skips the pre-stop delay and the grace period, but still cuts what is left and runs the closers. A third ends the process at once, even with `exit: false`: it is the way out of a shutdown that hangs, such as a closer that never returns. To run the sequence without signal handling, call `shutdown()`. It never rejects: failures are collected into the result. ```ts import { shutdown } from "@tetsujs/lifecycle"; const { forced, failures } = await shutdown(server, { close: [() => pool.end()] }); ``` ### Several servers A process with more than one server, such as a public API and an admin API on their own ports, passes them all at once: ```ts const api = Bun.serve({ ...publicApp, port: 3000 }); const admin = Bun.serve({ ...adminApp, port: 3001 }); onShutdownSignals([api, admin], { close: [() => pool.end()] }); ``` They drain side by side within one `graceMs`, and the closers run once, after the last server has stopped. Do not call `onShutdownSignals` once per server: the closers would run twice, and the process would exit when the first server is done. ## Behind a load balancer A balancer keeps sending requests for a few seconds after the signal, and stopping at once cuts exactly those requests. Set `preStopDelayMs` and fail the readiness check while `stopping` is aborted, so traffic moves away before the server stops. Use both or neither: a delay without a failing readiness check only postpones the cut. [Health checks and shutdown](/docs/guides/health-and-shutdown/) wires the two together. ### Streams and long polls An event stream, a long poll or a WebSocket never finishes on its own, and holds every stop for the whole of `graceMs`. End it on `draining`: `sse()`, `stream()` and `ws()` take it as `until`, and a long poll passes it to what it waits on. Use `draining`, not `stopping`: during the pre-stop delay a client that reconnects at once would land on this server again. See [Streams and sockets](/docs/guides/health-and-shutdown/#streams-and-sockets). ### Health checks A readiness route fails while `stopping` is aborted, and while something the instance needs is down. The guide has a [health controller](/docs/guides/health-and-shutdown/#liveness-and-readiness) to copy. ## Background jobs `stopping` is a standard `AbortSignal`, so scheduled work can stop with the server, and a closer can wait for a run in progress before the pool it uses goes away. [Background jobs](/docs/guides/background-jobs/) shows both. ## Options | Option | Default | | | ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `close` | `[]` | functions run in order after the server has stopped | | `preStopDelayMs` | `0` | how long to keep serving after the signal | | `graceMs` | `10000` | how long in-flight requests get to finish | | `forceMs` | `1000` | how long to wait for the forced close | | `signals` | `["SIGTERM", "SIGINT"]` | `onShutdownSignals` only: which signals start it | | `exit` | `true` | `onShutdownSignals` only: whether to end the process when done | | `reportError` | `console.error` | `onShutdownSignals` only: receives each failure as `{ source: "shutdown", error }`, like the `reportError` of `createApp` | `onShutdownSignals` returns `{ stopping, draining, detach }`: * `stopping` aborts as soon as a stop is asked for. A readiness check reads it, and anything that takes an `AbortSignal` can use it. * `draining` aborts when the server starts to stop: after the pre-stop delay, or together with `stopping` when there is none. * `detach` removes the signal handlers, for a process that outlives the server, such as a test suite. `shutdown` returns `{ forced, failures }`: whether connections had to be cut on any server, and what the servers’ `stop` and the closers threw, in order. The package also exports the types `ShutdownOptions`, `SignalOptions`, `ShutdownHandle`, `ShutdownResult`, `ShutdownFailure`, `Closer`, `Stoppable` (anything with a `stop` method) and `Servers`. ## Notes * **Why not plain `server.stop()`.** Bun’s `stop()` waits as long as a client holds a WebSocket open. Every step here runs against a deadline instead. * **Startup needs nothing from this package.** Open pools and run migrations with an `await` before `Bun.serve`. # @tetsujs/openapi > An OpenAPI 3.1 document and a docs page, generated from the routes you already declared. `@tetsujs/openapi` reads an application’s routes and produces an OpenAPI 3.1 document and a page that renders it. Paths, parameters, bodies and responses come from the schemas and response maps the routes already have, so nothing is written twice. To turn the document into a typed client, see [Typed client](/docs/guides/typed-client/#from-the-openapi-document). ```bash bun add @tetsujs/openapi ``` ## Usage Mount the `docs()` controller next to your own: ```ts import { docs } from "@tetsujs/openapi"; createApp({ routes: [ group("/api", { children: [usersController()] }), docs({ info: { title: "Users API", version: "1.0.0" } }), ], }); ``` `/openapi.json` serves the document and `/docs` renders it. The document covers the whole application `docs()` is mounted in, except its own two routes. It is built once, at startup, so anything that cannot be described is reported before the first request. `docs()` is an ordinary controller: put it in a group to move it under a prefix, guard it with hooks, or leave it out in production. A group does not narrow the document, so two documents, such as a public API and an admin one, need two applications, each with its own `docs()`. A route adds what the schemas cannot say in `docs`: ```ts const list = route({ method: "GET", path: "/users", docs: { summary: "List users", tags: ["users"], operationId: "listUsers" }, handler: () => [], }); ``` `docs` also takes `description`, `deprecated`, and `hidden: true`, which leaves the route out of the document while it is still served. `hidden: false` keeps a route in when its handler would hide it, see [Documenting a handler](#documenting-a-handler). WebSocket endpoints are never in the document: OpenAPI cannot describe what happens after the handshake. ## Options | Option | Default | | | -------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `info` | required | the document’s `info`: title, version, description | | `servers` | none | where the API is reachable | | `path` | `/openapi.json` | where the document is served | | `uiPath` | `/docs` | where the page is served | | `ui` | `"scalar"` | `"scalar"`, `"swagger-ui"`, `"redoc"`, or `false` for no page | | `title` | the document’s title | the page’s title | | `assets` | the renderer on jsDelivr, pinned | your own renderer URLs, with `integrity` hashes, see [The page and your origin](#the-page-and-your-origin) | | `documentSelf` | `false` | include the two docs routes in the document, under the tag `docs` | | `onWarning` | `console.warn` | receives what could not be described, see [Warnings](#warnings) | | `errors` | the framework’s envelope | an error format of your own, see [below](#an-error-format-of-your-own) | | `tags` | none | a description for each tag, in sidebar order, see [Tags](#tags) | `path` and `uiPath` go under the prefix of any group `docs()` is mounted in, and the page finds the document there. ## How a route becomes an operation | In the document | Comes from | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | the path | the route’s `path` under its groups’ prefixes: `/users/:id` becomes `/users/{id}` | | `operationId` | `docs.operationId`, or the controller and field name, see [Operation ids](#operation-ids) | | `summary`, `description`, `tags`, `deprecated` | the route’s `docs` | | parameters | `schema.params`, `query`, `headers` and `cookies`, required as the schema says | | the request body | `schema.body` and `bodyType`; required unless `bodyType` is `text` or `stream` | | responses | `schema.response`, hooks annotated with [`documented()` and `secured()`](#documenting-hooks), a handler annotated with [`documented()`](#documenting-a-handler), and the [failures the framework adds](#responses-the-framework-adds) | | `security` | `secured()` hooks on the route, its groups and the application | Schemas are converted through Standard Schema’s JSON Schema support, which Zod, ArkType and [`@tetsujs/typebox`](/docs/packages/typebox/) provide; Valibot needs `toStandardJsonSchema` from `@valibot/to-json-schema`. Responses follow the route’s response map (see [Responses](/docs/concepts/responses/)): * One schema is a `200`. A map gives its statuses. `null` is a status without a body, and so is an entry with neither `body` nor a `contentType` of its own. * A route with no `schema.response` is documented as `200`. If its handler can answer `204`, declare `204: null`. * An entry `{ body, headers, cookies }` documents its headers, required as its schema says, and its cookies as one `set-cookie` header. * An entry with `contentType` documents its body under that media type instead of `application/json`, with `body` as its schema, or by the type alone without one: a CSV export, a file, `"text/event-stream"` for [`sse()`](/docs/packages/sse/#in-the-openapi-document). See [Responses](/docs/concepts/responses/#a-body-that-is-not-json). For a stream, `body` is the `data` of one event, written as the schema of the stream: OpenAPI 3.1 cannot describe the events one by one. * When several sources answer with one status, the JSON body is one flat `anyOf`, the route’s own schema first. A body of another type is listed next to it under its own media type. ## Operation ids A generated client names its methods after the `operationId`s, so every one comes from a name you wrote: | Route | `operationId` | | ------------------------------------------- | ---------------- | | with `docs: { operationId }` | as written | | in `controller("Users", …)` as `setAvatar` | `usersSetAvatar` | | in a class `UsersController` as `setAvatar` | `usersSetAvatar` | | in an object literal as `setAvatar` | `setAvatar` | | mounted on its own, `POST /auth/code` | `postAuthCode` | Two routes with the same id are refused when the document is built, naming both: `docs()` stops the application at startup, and `openapi()` throws. Nothing is renamed for you, since that would change a method in a generated client the day a route is added. Give one of them `docs.operationId`, or its controller another name. On a public API, write the id on every route, so a change to it shows up in review. ## Tags A route names its tags in `docs: { tags }`. The `tags` option says what each one is: ```ts const documentation = docs({ info, tags: { "sign-in": "Signing in with a code sent by email, and signing out", me: "The signed-in user", }, }); ``` Renderers list the sections in the order of the keys. A tag the routes use but `tags` leaves out comes after them and is reported as a warning. It is usually one tag spelled two ways, `sign_in` next to `sign-in`. A tag described but never used is reported too. Without `tags`, the document lists none and nothing is reported. ## Responses the framework adds The framework answers some failures by itself. They are documented on every route where they can happen, in the envelope the server sends: | Status | `error` | When | | -------------------------------------------- | ----------------------------------- | --------------------------------------------------------------- | | `422`, or `validation.status` of `createApp` | `VALIDATION_FAILED` | the route has a request schema, and a part failed it | | `400` | `MALFORMED_JSON` / `MALFORMED_FORM` | the route reads a JSON or form body, and it could not be parsed | | `413` | `BODY_TOO_LARGE` | the route reads a body, and it exceeded `maxBodySize` | | `500` | `INTERNAL_SERVER_ERROR` | every route: an unhandled failure | See [Framework error codes](/docs/reference/error-codes/). A status the route declares itself is kept, and the framework’s failures for that status are listed next to it. Every envelope, whether from the framework, a hook or the route, becomes one definition in `components`, named after its code: `ITEM_NOT_FOUND` becomes `ItemNotFound`. A generated client gets one type per failure. A schema counts as an envelope when its `error` is required and a single string. When every alternative of a status is an envelope, the status gets a `discriminator` on `error`, so a client can narrow on the code. A status’s description comes from what answers with it: the `description` of the route’s schema (`.describe()` in Zod and ArkType, `v.description()` in Valibot, `{ description }` in TypeBox), a hook’s or a handler’s `documented()` description, or the framework’s own. Several descriptions become a list led by each code: ```md - `ACCOUNT_DISABLED`: this account is disabled - `CAPTCHA_FAILED`: the captcha token is missing or did not pass ``` A status with no description keeps its reason phrase, such as `Not Found`. ## Documenting hooks A hook that answers by itself, such as an auth check or a limiter, can say so. Every route it guards is then documented with it. `secured()` adds a security scheme, `documented()` adds responses: ```ts import { documented, secured } from "@tetsujs/openapi"; export const auth = secured( hook.beforeParse((ctx) => { const user = verify(ctx.req.headers.get("authorization")); if (!user) throw new HttpError(401); return { user }; }), { name: "bearerAuth", scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" } }, ); export const guard = documented(hook.beforeParse((ctx) => check(ctx)), { responses: [ { status: 429, description: "Rate limit exceeded", error: "RATE_LIMITED", fields: { retryAfter: { type: "integer", minimum: 0 } }, headers: { "retry-after": { schema: { type: "integer", minimum: 0 } } }, }, ], }); ``` Both return a copy of the hook with the same type, so it goes into a stack like any other. A hook mounted on a group or the application documents every route under it. Hooks from [`@tetsujs/rate-limit`](/docs/packages/rate-limit/) come documented already. A response in `documented()` takes: * `contentType`: the media type of a body that is not JSON, such as `"text/html"`, without parameters. `documented()` throws on anything but a bare type or a range. * `error`: the envelope’s code, documented as a `const`. * `fields`: what the hook adds next to `status`, `message` and `error`, always present. * `headers`: what it sets on the response, documented as possible, not required. * `message`: an example of the envelope’s message. * `schema`: the body, for a hook that does not answer with the envelope. Without `schema` or `contentType`, an error status is the envelope and any other status, such as a redirect, has no body. `fields` and `headers` are JSON Schema typed keyword by keyword (`JsonSchema`), so a misspelled keyword does not compile. `secured()` takes: | Field | Default | | | ------------- | -------- | ----------------------------------------------------------------------------------------------------- | | `name` | required | the name the scheme is registered under in `components` | | `scheme` | required | the OpenAPI security scheme; its `type` is `http`, `apiKey`, `oauth2`, `openIdConnect` or `mutualTLS` | | `scopes` | none | OAuth2 scopes | | `status` | `401` | the status a refused request gets | | `description` | none | how the refusal is described | | `error` | none | the refusal’s `error` code | | `message` | none | an example of the refusal’s message | Every hook of a route runs, so the schemes of all its hooks are required together: a route behind a CSRF check and a captcha needs both. To accept one credential or another, such as a session cookie or a bearer token, check both in one hook and pass `anyOf`: ```ts import { secured } from "@tetsujs/openapi"; export const caller = secured(hook.beforeParse((ctx) => ({ user: sessionOrToken(ctx) })), { anyOf: [cookieSession, bearerToken], }); ``` With a CSRF check on the same route, the document says `security: [{ session: [], csrf: [] }, { bearer: [], csrf: [] }]`. ## Documenting a handler A handler a package hands out, such as one serving a directory of files, can describe every route it is mounted on, the way a hook describes the routes it guards. `documented()` takes the handler and returns a copy that answers the same way: ```ts import { documented } from "@tetsujs/openapi"; export const files = documented(readFile, { hidden: true, responses: [ { status: 200, description: "The file", contentType: "*/*", headers: { etag: { schema: { type: "string" } } }, }, { status: 304, description: "Not modified" }, { status: 404, description: "No such file", error: "NOT_FOUND" }, ], }); ``` * Its responses are the route’s own: a route that mounts it needs no `schema.response`, and gets no placeholder `200`. * `hidden: true` keeps every route that mounts it out of the document, unless the route says `docs: { hidden: false }`. The route’s own word wins either way. * An arrow that wraps the handler is what the route then mounts, and it says nothing. Hide such a route, or describe it, on the route itself. Annotate a handler whose type is already settled, such as a package’s. An arrow written inside `documented()` on a route does not get the route’s context and does not compile; a route of your own describes its statuses in its response map. ## An error format of your own An application can answer every failure in a format of its own with one `onError` hook, as [Errors](/docs/concepts/errors/#an-error-format-of-your-own) shows. The document cannot read that hook, so describe the same format with `errors`. Here the format is `{ code, message }`: ```ts import { docs, type ErrorFormat } from "@tetsujs/openapi"; const errors: ErrorFormat = { schema: ({ error, message, fields }) => ({ type: "object", required: ["code", "message", ...Object.keys(fields)], properties: { code: error ? { type: "string", const: error } : { type: "string" }, message: { type: "string", ...(message ? { examples: [message] } : {}) }, ...fields, }, }), discriminator: "code", }; createApp({ hooks: { onError: [inOurFormat] }, routes: [api, docs({ info, errors })], }); ``` | `errors` field | | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `schema` | builds the JSON Schema of one failure from `{ status, error, message, fields }`. `error` is the code when known; `fields` holds what the failure carries besides, such as `issues` or `retryAfter` | | `discriminator` | the top-level field that holds the code. Statuses are discriminated on it, and a route’s own envelope is recognized by it. None unless named | | `code` | reads the code from a schema a route or hook declared, for a format that nests its code. By default, the `const` of the discriminator’s field | The hook and `errors` describe one format in two places. Keep a test that provokes each kind of failure, the unexpected one included, and checks it with [`assertDescribed`](#testing-against-the-document). ## Testing against the document `@tetsujs/openapi/testing` checks a response a test provoked against what the document says: ```ts import { openapi } from "@tetsujs/openapi"; import { assertDescribed } from "@tetsujs/openapi/testing"; const { document } = openapi(app, { info }); test("a wrong code is what the document says", async () => { const res = await request("/session", { method: "POST", body }); await assertDescribed(document, "POST /session", res); }); ``` It throws unless the operation declares the status and the body fits one of that status’s alternatives, and lists every problem: ```text POST /session answered 403, which the document does not describe: - error "CAPTCHA_FAILED", which its 403 does not list: ACCOUNT_DISABLED, CSRF_HEADER_REQUIRED ``` The second argument is the method and the path the test requested, such as `"GET /users/42"`; it is matched against the document’s path templates. The body is read from a clone, so the test can still read the response. Its media type is compared without regard to case, and a range in the document, such as `image/*` or `*/*`, takes every type in it. The body is parsed and checked only under `application/json` or a `+json` type; under any other type, an empty body passes, as an export with no rows does. | Option | Default | | | ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `validate` | none | `(schema, body) => true \| string`: checks the whole body with a JSON Schema validator of your choice, such as `(schema, body) => ajv.validate(schema, body) \|\| ajv.errorsText()`. Without it, only the top level is compared: required fields and `const` fields | | `headers` | none | headers the status must declare when the response carries them, such as `["retry-after"]` | A header the status declares as required, such as `location` or `set-cookie`, must be on the response even without `headers`. See [Testing](/docs/guides/testing/). ## Using the document directly To write the document to a file in CI or feed it to a client generator, call `openapi()`. It takes `info`, `servers`, `errors` and `tags`, and mounts nothing: ```ts import { openapi } from "@tetsujs/openapi"; const { document, warnings } = openapi(app, { info: { title: "Users API", version: "1.0.0" }, }); await Bun.write("openapi.json", JSON.stringify(document, null, 2)); ``` Each warning has a `route`, such as `GET /path` (empty for the document as a whole), and a `message`. A committed document makes every API change a visible diff. With the code above in `scripts/openapi.ts`, CI writes the document again and fails when it differs, so a pull request that changes the API also shows the change to the document: ```bash bun scripts/openapi.ts git diff --exit-code openapi.json ``` `docsPage({ ui, documentUrl, title?, assets? })` returns the page’s HTML, to serve it from a route of your own. `documentUrl` is where the page fetches the document from, and `title` defaults to `"API documentation"`. ## Warnings A route that cannot be fully described is still documented, and the gap is reported instead of failing startup: ```text [openapi] POST /api/users: the body schema does not emit JSON Schema ``` Warnings are raised for: * a validator that does not emit JSON Schema, or a conversion that throws; * two routes that map to the same OpenAPI path, such as `/files/*` and `/files/:wildcard`; the second replaces the first in the document; * a reference that leads nowhere. Recursive and named schemas emit references (`#`, `#/$defs/…`) that no longer resolve once embedded in the document: Zod’s recursive getters and `.meta({ id })`, ArkType’s scopes, Valibot’s `lazy`. TypeBox’s `Type.Cyclic` is described correctly; * a tag used but not described in `tags`, or described and not used; * two different security schemes under one `name`. The first is kept. `docs()` prints each warning with `console.warn`, or passes it to `onWarning`. ## The page and your origin The page loads its renderer (Scalar, Swagger UI or Redoc) from jsDelivr and runs it on the application’s origin, with that origin’s cookies. The default files are pinned to an exact version with a Subresource Integrity hash, so the browser refuses a file the CDN changed. That still makes it someone else’s code running next to your users’ sessions. Where the origin carries a session, serve the document alone: ```ts const documentOnly = docs({ info, ui: false }); ``` Or host the renderer yourself, or pin your own copy with its hash: ```ts const pinned = docs({ info, assets: { script: "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.72.1/dist/browser/standalone.js", integrity: { script: "sha384-…" }, }, }); ``` `curl -s | openssl dgst -sha384 -binary | openssl base64 -A` prints the hash; prefix it with `sha384-`. Swagger UI also needs `style` and `integrity.style`. A `content-security-policy` that denies scripts from other origins, such as `apiPolicy` of [`@tetsujs/secure-headers`](/docs/packages/secure-headers/#content-security-policy), leaves the page blank. That page shows how to lift the policy for this route. ## Exports * `docs` and `DocsController`, with `DocsOptions`. * `openapi`, with `OpenApiOptions`, `GeneratorResult` and `GeneratorWarning`. * `docsPage`, with `DocsPageOptions`, `DocsUi` and `DocsAssets`. * `documented` and `secured`, with `HookDocs`, `HandlerDocs`, `DocumentedResponse`, `DocumentedHeader`, `SecurityRequirement`, `SecurityAlternatives`, `SecurityScheme` and `HookContributions`. * `ErrorFormat` and `DocumentedFailure`, for an error format of your own. * `JsonSchema`, `JsonSchemaKeywords` and `JsonSchemaType`, for JSON Schema written by hand. * The document’s types: `OpenApiDocument`, `DocumentInfo`, `DocumentServer`, `PathItemObject`, `OperationObject`, `ParameterObject`, `ResponseObject`, `HeaderObject`, `ContentMap` and `TagObject`. * From `@tetsujs/openapi/testing`: `assertDescribed`, with `AssertDescribedOptions`. # @tetsujs/rate-limit > Fixed-window rate limiting as a hook, with a replaceable counter store. `@tetsujs/rate-limit` refuses requests from a client that goes over its budget for a window of time. It is a hook you mount where the limit applies, and the counters live in a store you can replace. ```bash bun add @tetsujs/rate-limit ``` It depends on [`@tetsujs/openapi`](/docs/packages/openapi/), which it uses to document the refusal. ## Usage ```ts import { rateLimit } from "@tetsujs/rate-limit"; const limit = rateLimit({ limit: 60, windowMs: 60_000, key: (ctx) => ctx.server.requestIP(ctx.req)?.address, }); createApp({ hooks: { beforeParse: [limit] }, routes }); ``` This allows 60 requests a minute per client address. Behind a proxy that address is the proxy’s: see [Behind a proxy](#behind-a-proxy). A request over the limit gets `429` with the standard error body (code `RATE_LIMITED`, plus `retryAfter` in seconds) and a `retry-after` header. The refusal is a thrown `HttpError`, so `onError` hooks and a custom error format apply to it. Every counted response carries `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset` (seconds until the window ends). `rateLimit()` is one `beforeParse` hook. To limit one route, mount it on the route: ```ts const feedback = route({ method: "POST", path: "/feedback", hooks: { beforeParse: [limit] }, handler: () => ({ received: true }), }); ``` ### Budgets One limiter is one budget: mounted on two routes, it counts both together. For separate budgets, make two limiters. Two limiters on one route work too, such as a short window against bursts and a long one. A request passes when it is within both. Give one of them `headers: false`, or the two overwrite each other’s `x-ratelimit-*` headers: ```ts const burst = rateLimit({ limit: 3, windowMs: 1_000, key }); const hourly = rateLimit({ limit: 20, windowMs: 3_600_000, key, headers: false }); const login = route({ method: "POST", path: "/login", hooks: { beforeParse: [burst, hourly] }, handler: () => ({ ok: true }), }); ``` `perRoute: true` gives each route its own budget from one limiter mounted on the application, for “20 a minute on every endpoint”: ```ts import { rateLimit } from "@tetsujs/rate-limit"; const each = rateLimit({ perRoute: true, limit: 20, windowMs: 60_000, key: (ctx) => ctx.server.requestIP(ctx.req)?.address, }); createApp({ hooks: { beforeParse: [each] }, routes }); ``` A route is its template, so `/orders/1` and `/orders/2` share `GET /orders/:id`. Requests no route answers (a `404`, a `405`, an `OPTIONS` preflight) share one budget, so probing for paths is counted too. With [`cors()`](/docs/packages/cors/) mounted before the limiter, a preflight is answered before it is counted. ## Choosing a key `key` decides what is counted, and there is no default. The right key depends on the deployment: behind a balancer, a default by address would put every client in one bucket while the limiter looked fine. Return a string, or `undefined` to skip the limit for that request. **A key must be something the client cannot choose.** An unverified cookie, token or header is whatever the client sends, and a new value each time means a new, empty budget each time. Count by the connection’s address, or by what a hook has verified. By default the key runs before the body is parsed, so it reads the request itself and what earlier `beforeParse` hooks returned. To read a field another hook provides, declare it with `Requires`: ```ts import { rateLimit } from "@tetsujs/rate-limit"; // createApp({ cookies: { secret, sign: ["session"] } }) signs the session const auth = hook.beforeParse((ctx) => { const userId = signedCookie(ctx, "session"); if (!userId) throw new HttpError(401); return { userId }; }); const perUser = rateLimit({ limit: 100, windowMs: 60_000, key: (ctx: Requires<{ userId: string }>) => ctx.userId, }); const createOrder = route({ method: "POST", path: "/orders", hooks: { beforeParse: [auth, perUser] }, handler: (ctx) => ({ owner: ctx.userId }), }); ``` The limiter then requires `userId` wherever it is mounted: placed before `auth`, or where nothing provides it, it does not compile. See [Context and its types](/docs/concepts/context/). `signedCookie()` reads and verifies a signed cookie in `beforeParse`, where `ctx.cookies` is not filled in yet. Do not key by `ctx.req.cookies`: it holds the cookie as the client sent it, unverified. ### Behind a proxy Behind a proxy, the connection’s address is the proxy’s, and the client’s is in `x-forwarded-for`. Count it from the end of that header, by the number of your own proxies in front: the first entry is whatever the client sent, and would let it pick its own bucket. [Behind a proxy](/docs/guides/behind-a-proxy/#rate-limiting-behind-a-load-balancer) shows a hook that works the address out and a limiter keyed by it. A server that listens on IPv4 and IPv6, `Bun.serve`’s default, reports an IPv4 client as `::ffff:203.0.113.7`. As a key that is fine. To compare it with a list of addresses, strip the `::ffff:` prefix first, as the next example does. ### Skipping the limit Returning `undefined` skips the limit. Base an exemption on the connection’s address, not on a header such as `x-internal`, which any client can send: ```ts const internal = new Set(["10.0.0.5", "10.0.0.6"]); const limit = rateLimit({ limit: 60, windowMs: 60_000, key: (ctx) => { const address = ctx.server.requestIP(ctx.req)?.address.replace(/^::ffff:/, ""); return address && internal.has(address) ? undefined : address; }, }); ``` Likewise, a key such as `header ?? undefined` skips the limit for every client that leaves the header out. A health check needs no exemption: mount the limiter on the routes or groups it protects, and leave the probes outside. ## Limiting by account A limit by address barely slows password guessing: an attacker with many addresses gets a full budget on each. What works is counting by the account being tried, and the account is in the body. `slot: "beforeHandle"` runs the limiter after the body is validated, so the key can read it: ```ts import { rateLimit } from "@tetsujs/rate-limit"; import type { Requires } from "@tetsujs/core"; const perAccount = rateLimit({ slot: "beforeHandle", limit: 5, windowMs: 15 * 60_000, key: (ctx: Requires<{ body: { email: string } }>) => ctx.body.email, }); const perAddress = rateLimit({ limit: 60, windowMs: 60_000, key: (ctx) => ctx.server.requestIP(ctx.req)?.address, }); const login = route({ method: "POST", path: "/login", schema: { body: Login }, hooks: { beforeParse: [perAddress], beforeHandle: [perAccount] }, handler: () => ({ ok: true }), }); ``` The address limit still refuses a flood before reading its bodies, and the account limit stops guessing across addresses. A limiter can be made for `beforeParse` (the default), `beforeValidation` or `beforeHandle`, and the compiler refuses it in any other slot. ## Options | Option | Default | | | ---------- | --------------- | --------------------------------------------------------------------------------- | | `limit` | required | requests allowed per window: a whole number, `0` or more (`0` refuses everything) | | `windowMs` | required | window length in milliseconds: positive and finite | | `key` | required | what is counted; `undefined` skips the limit | | `perRoute` | `false` | a budget per route rather than one for the limiter | | `slot` | `"beforeParse"` | `"beforeValidation"` or `"beforeHandle"` to count by what the body holds | | `store` | `memoryStore()` | where the counters live | | `name` | none | required with `store`, and only with it | | `status` | `429` | status of a refusal | | `headers` | `true` | send the `x-ratelimit-*` headers | Bad options throw when the limiter is made. For example, a `windowMs` of `NaN` (what `Number()` of an unset environment variable gives) would otherwise refuse nothing. ## A shared store The default `memoryStore()` keeps counters in the process: fine for one server and for tests, not for several behind a load balancer. A store is one method, `hit(key, windowMs)`, which counts a hit and returns `{ count, resetAt }`, with `resetAt` in epoch milliseconds. It may be async: ```ts import { rateLimit, type RateLimitStore } from "@tetsujs/rate-limit"; const redisStore: RateLimitStore = { hit: async (key, windowMs) => { const count = await redis.incr(key); await redis.pexpire(key, windowMs, "NX"); return { count, resetAt: Date.now() + (await redis.pttl(key)) }; }, }; const limit = rateLimit({ name: "shop-login", store: redisStore, limit: 5, windowMs: 60_000, key: (ctx) => ctx.server.requestIP(ctx.req)?.address, }); ``` The expiry is set on every hit, only if the key has none (`NX`, Redis 7 and later). Set only on the first hit, a timeout between the two commands would leave a counter that never expires, and its client refused for good. On older Redis, send both commands in one `MULTI`. A limiter with a store needs a `name`, which starts every key it counts under: `shop-login:203.0.113.7`, or `shop-login:POST:/login:203.0.113.7` with `perRoute`. So the name is the budget: * Every server of a fleet whose limiter has that name shares one budget, which is what a shared store is for. * A name must be unique across everything that writes to the store. Two services on one Redis need different names, or a store that prefixes every key with the service. * Two limiters with one name on one store but different settings are refused. The same settings under one name are fine, since every test run and every server makes its limiters again. ## Notes * **In tests, every request comes from one address.** `serve()` and its `client()` connect from the test process, so a limiter keyed by address counts all requests of a test file in one bucket. Build the application per test, or pass it a `key` the test controls. See [Testing](/docs/guides/testing/). * With [`@tetsujs/openapi`](/docs/packages/openapi/), every operation the limiter guards is documented with a `429`, its `retryAfter` and its `retry-after` header. * The package also exports `memoryStore` and the types `RateLimitOptions`, `RateLimitHook`, `RateLimitStore`, `WindowState`, `LimitSlot`, `OwnCounters` and `SharedCounters`. `RateLimitHook` is a `beforeParse` limiter whose key reads only the request; type any other limiter with `ReturnType` of its own `rateLimit()` call. # @tetsujs/request-id > A request id on every request and response, shared by the log lines and failure reports of one request. `@tetsujs/request-id` gives every request an id, in the context and on the response, so the log lines and failure reports of one request can be joined. It is one `beforeParse` hook. ```bash bun add @tetsujs/request-id ``` ## Usage ```ts import { requestId } from "@tetsujs/request-id"; const id = requestId(); createApp({ hooks: { beforeParse: [id] }, routes }); ``` Every response gets an `x-request-id` header, and `ctx.requestId` holds the id for the rest of the request. The records of [`@tetsujs/request-log`](/docs/packages/request-log/) carry it when this hook runs before them. ## Reading the id in a handler Mounted on the application, `ctx.requestId` is there at runtime but not in a route’s types: the application cannot type the routes it holds (see [Context and its types](/docs/concepts/context/)). To have it typed in a handler, mount the hook on the route: ```ts const id = requestId(); const list = route({ method: "GET", path: "/orders", hooks: { beforeParse: [id] }, handler: (ctx) => logger.info({ requestId: ctx.requestId }, "listing"), }); ``` Or declare it where it is read, with `Requires<{ requestId: string }>`, and the compiler checks that something provides it. An application hook mounted after `requestId()` sees it typed too. ## Options | Option | Default | | | --------------- | ------------------- | ----------------------------------------------------------- | | `header` | `"x-request-id"` | the header the id is written to, and read from when trusted | | `trustIncoming` | `false` | use the id the client sent; an empty header counts as none | | `generate` | `crypto.randomUUID` | how a new id is made | ```ts const id = requestId({ header: "x-correlation-id", trustIncoming: true }); ``` Turn on `trustIncoming` only behind a proxy that overwrites the header. An incoming id is chosen by the client, and trusting it lets one client stamp another’s log lines. ## Notes * **Failures share the id** when you pass `reportError` to `createApp` and log `ctx?.requestId` with the error. See [Logging](/docs/guides/logging/). * The package also exports the types `RequestIdOptions` and `RequestIdHook`. Type a hook with `ReturnType` rather than `AnyHook`, which erases both the slot and the `requestId` it adds to the context. ## The id deeper than the handler Code that never receives `ctx`, such as a repository several calls down, can read the id through `AsyncLocalStorage`, filled by a hook mounted after `requestId()`. [Logging](/docs/guides/logging/#the-id-deeper-than-the-handler) shows the hook and a pino `mixin` that puts the id on every line. # @tetsujs/request-log > Request logs as hooks — a record when a request is done, and a record when it arrives. `@tetsujs/request-log` writes a record for every request as its response goes out, and optionally another as it arrives. Each is a hook that hands the record to a `write` function you give it, so it fits any logger. ```bash bun add @tetsujs/request-log ``` ## Usage ```ts import { requestId } from "@tetsujs/request-id"; import { accessLog } from "@tetsujs/request-log"; const id = requestId(); const finished = accessLog({ write: (record) => logger.info(record, "request finished") }); createApp({ hooks: { beforeParse: [id], afterResponse: [finished] }, routes, }); ``` `accessLog()` is one `afterResponse` hook. Every request, a `404` and a failure included, produces one record: ```ts { method: "GET", path: "/items/42", route: "/items/:id", status: 200, durationMs: 12.418, requestId: "…" } ``` | Field | | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `method` | the request method | | `path` | the pathname the client asked for; never the query | | `route` | the route as declared, `/items/:id` where `path` is `/items/42`; absent when no route matched | | `status` | the status of the response | | `durationMs` | milliseconds from `ctx.startedAt` to the response, rounded to the microsecond | | `thrown` | what was thrown, if anything was: an error’s own `name`, its class when it sets none, or the `typeof` of a value that is not an `Error` | | `aborted` | `true` when the connection closed before the response was ready; absent otherwise | | `requestId` | the id from [`requestId()`](/docs/packages/request-id/), when it ran before this hook | Group a dashboard by `route`: it stays one value however many ids are requested. `path` is what to read on a `404`, where there is no route. ## Arrival records A request whose handler hangs, or whose process dies, never gets an access record. `arrivalLog()` writes one as the request comes in, so there is a trace that it came at all: ```ts import { arrivalLog } from "@tetsujs/request-log"; const arrived = arrivalLog({ write: (record) => logger.info(record, "request received") }); createApp({ hooks: { beforeParse: [id, arrived], afterResponse: [finished] }, routes, }); ``` ```ts { method: "GET", path: "/items/42", requestId: "…" } ``` It is one `beforeParse` hook and doubles the log lines, so mount it only where that trace is wanted. Its place in `beforeParse` matters: after `requestId()`, the record carries the id; after `cors()`, preflights get no record. ## Options `accessLog()` and `arrivalLog()` take one option: | Option | Default | | | ------- | ------------- | -------------------- | | `write` | `console.log` | receives each record | `write` receives the record object, not a string, so a structured logger gets the fields as they are. The package exports the types `AccessRecord`, `ArrivalRecord`, `AccessLogOptions`, `ArrivalLogOptions`, `LogOptions`, `AccessLogHook` and `ArrivalLogHook`. ## Notes * **No headers, bodies or query strings** go into a record, and there is no option to add them. `thrown` names the error, never quotes its message; a minified build renames classes, and `thrown` with them. `path` carries what the client sent, so keep secrets out of URLs. * **For the full error,** pass `reportError` to `createApp` and join its reports to the records by `ctx?.requestId`. See [Logging](/docs/guides/logging/). * **Records are written on the request’s path,** so a `write` that blocks delays the response. Use a logger that buffers; see [Logging](/docs/guides/logging/#a-line-for-every-request). * **`durationMs` does not include writing the response to the socket.** For a stream, the record is written when the stream starts, so a long feed shows as a fast `200`. `onEnd` of [`@tetsujs/sse`](/docs/packages/sse/#knowing-what-a-stream-did) reports how a stream actually ended. * **`aborted: true`** means the client left, or a forced stop cut the connection. The record keeps the status the server answered. For nginx’s view, use `record.aborted ? 499 : record.status`. ## Metrics A record has what request metrics need (method, route, status and duration), so a metrics registry can be fed from the same `write`. Label by `route`, never by `path`: `path` creates a new series for every id, and for every path a scanner tries. The [Metrics guide](/docs/guides/metrics/) feeds a Prometheus histogram this way and serves it on a port of its own. # @tetsujs/secure-headers > Security headers on every response, as one beforeResponse hook. `@tetsujs/secure-headers` sets the response headers that tell a browser to turn off content sniffing, framing and referrer leaks, and to use HTTPS only. It is one `beforeResponse` hook, and its defaults are safe for a JSON API. ```bash bun add @tetsujs/secure-headers ``` ## Usage ```ts import { secureHeaders } from "@tetsujs/secure-headers"; const secure = secureHeaders(); createApp({ hooks: { beforeResponse: [secure] }, routes }); ``` Mount it on the application, so it covers every response, errors and `404`s included. Sent by default: | Header | Value | | | --------------------------- | ------------------ | ------------------------------------------------ | | `x-content-type-options` | `nosniff` | the browser does not guess a content type | | `x-frame-options` | `DENY` | the response cannot be framed | | `referrer-policy` | `no-referrer` | URLs with identifiers do not leak to other sites | | `strict-transport-security` | `max-age=15552000` | HTTPS only, for 180 days | The headers are set with `set`, so they replace a header of the same name that the handler set. ## Options | Option | Default | | | ----------------------- | ---------------------- | -------------------------------------------------------------- | | `hsts` | `{ maxAge: 15552000 }` | `maxAge`, `includeSubDomains`, `preload`; `false` turns it off | | `frameOptions` | `"DENY"` | `"SAMEORIGIN"`, or `false` | | `referrerPolicy` | `"no-referrer"` | any policy, or `false` | | `noSniff` | `true` | `false` turns it off | | `contentSecurityPolicy` | not sent | a policy string; `apiPolicy` is ready-made | ## HSTS HSTS is sent on every response. Browsers ignore it over plain HTTP, so it works the same behind any proxy. `includeSubDomains` and `preload` are off by default. The first breaks any subdomain still served over plain HTTP, for as long as `maxAge` says. The second takes months and a browser release to undo. Turn them on when you know they are safe: ```ts const secure = secureHeaders({ hsts: { maxAge: 63_072_000, includeSubDomains: true } }); ``` `preload` without `includeSubDomains`, or with a `maxAge` under one year (31,536,000 seconds), makes `secureHeaders()` throw, because the browsers’ preload list would reject it. ## Content Security Policy No policy is sent by default: a wrong policy breaks pages, and the failure only shows in the browser of whoever loads them. For an API that only answers JSON, `apiPolicy` denies everything: ```ts const secure = secureHeaders({ contentSecurityPolicy: apiPolicy }); // default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none' ``` If the same application also serves a page, such as the docs page of [`@tetsujs/openapi`](/docs/packages/openapi/), remove the policy for that route: ```ts const docsPage = "/docs"; const allowDocs = hook.beforeResponse((ctx) => { if (ctx.route?.path === docsPage) ctx.out.headers.delete("content-security-policy"); }); const secure = secureHeaders({ contentSecurityPolicy: apiPolicy }); createApp({ hooks: { beforeResponse: [secure, allowDocs] }, routes: [docs({ info, uiPath: docsPage }), api()], }); ``` `ctx.route.path` is the route’s full path: mounted in a group under `/api`, the page is `/api/docs`. ## An exception for one route A route’s own `beforeResponse` hook runs after the application’s, so it can change a header for that route. Setting the header in the handler does not work: the handler runs first, and the application’s hook overwrites it. ```ts const allowFraming = hook.beforeResponse((ctx) => { ctx.out.headers.set("x-frame-options", "SAMEORIGIN"); const policy = ctx.out.headers.get("content-security-policy"); if (policy) { ctx.out.headers.set( "content-security-policy", policy.replace(/frame-ancestors [^;]*/, "frame-ancestors 'self'"), ); } }); const embeddable = route({ method: "GET", path: "/embeddable", hooks: { beforeResponse: [allowFraming] }, handler: () => page(), }); ``` When the policy has `frame-ancestors`, as `apiPolicy` does, browsers follow it and ignore `x-frame-options`, so allowing a frame means changing both. ## Notes * The package also exports the types `SecureHeadersOptions`, `HstsOptions` and `SecureHeadersHook`. Type a hook with `ReturnType` rather than `AnyHook`, which erases the slot the hook belongs to. # @tetsujs/sql > Postgres on Bun's own client — SQL migrations, Kysely types generated from the database they build, and SQL files typed by Postgres itself. `@tetsujs/sql` puts Postgres behind an application on Tetsu. SQL migrations are the schema, the types are generated from the database they build, and queries are Kysely’s, or SQL files typed by Postgres itself. It has a section of its own: [Database](/docs/database/). ```bash bun add @tetsujs/sql kysely ``` It needs Bun 1.4.4 or later and Postgres 14 or later. `kysely` is a peer dependency, 0.29.6 or a later 0.29. ## Usage src/db.ts ```ts import { database, verifySchema } from "@tetsujs/sql"; import { schema } from "./db.generated"; export const db = database(schema, { url: config.DATABASE_URL }); await verifySchema(db, schema); const paid = await db .selectFrom("orders") .select(["id", "total", "placed_at"]) .where("status", "=", "paid") .execute(); ``` `src/db.generated.ts` is what `tetsu-sql types:generate` writes from the migrations: [Overview](/docs/database/) walks from the first migration to a route, and [Types from the database](/docs/database/types/) says what each column becomes. ## The command line `tetsu-sql` runs the package’s functions from `package.json` scripts, with the database in `DATABASE_URL` or `--url`: | command | what it does | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `migrations:new ` | creates an empty migration, named with the current UTC time | | `migrations:run` | applies the pending migrations: [Migrations](/docs/database/migrations/) | | `migrations:baseline ` | records the migrations up to this one as applied, without running them, in a database built before them: [Adopting a database](/docs/database/migrations/#adopting-a-database) | | `types:generate` | writes the types of the database the migrations build, and the functions of the query files; `--check` writes nothing and fails when the files are out of date, and `--int8 string` reads an `int8` as a string: [Types from the database](/docs/database/types/) | ## Exports * `database`, with `DatabaseOptions`. * `migrate`, with `MigrateOptions`, `MigrateResult` and `MigrationRecord`; `baseline`, with `BaselineOptions`, `BaselineResult` and `BaselineRecord`; `MigrationError`, with `MigrationErrorReason`. * `verifySchema`. * `atomic`. * `isViolation`, with `ConstraintName` and `Constraints`, which the generated files add the names of their constraints to. * `queryRows` and `queryCount`, with `FileQuery`: what the functions of query files call. * `Schema`, the type of a generated `schema`. * From `@tetsujs/sql/generate`: `generate`, with `GenerateOptions`, `QueriesOptions` and `GeneratedFiles`; `createMigration`, with `CreateMigrationOptions`; and `QueryFileError`. * From `@tetsujs/sql/testing`: `testDatabase`, with `TestDatabaseOptions` and `TestDatabase`. # @tetsujs/sse > Server-sent events from an async generator, and the same machinery for any other streamed format. `@tetsujs/sse` turns an async generator into a server-sent events response. It handles the framing, the headers, the keep-alive and the backpressure, which are easy to get quietly wrong by hand. `stream()` does the same for any other streamed format. For how streaming fits into a handler, see [Streaming](/docs/concepts/streaming/). ```bash bun add @tetsujs/sse ``` ## Usage ```ts import { sse } from "@tetsujs/sse"; const feed = route({ method: "GET", path: "/prices", handler: (ctx) => sse(ctx, async function* (signal) { for await (const price of prices.watch({ signal })) { yield { data: price, id: price.at }; } }), }); ``` `sse()` returns a `Response` that: * sets `content-type: text/event-stream` and `cache-control: no-cache`, and `x-accel-buffering: no`, so nginx, which buffers a proxied response by default, passes each event on as it is written; * opens with a comment, `: open`, so the headers go out at once rather than with the first event; * sends a `: ping` comment every 15 seconds, and sets the request’s idle timeout above that, so neither Bun nor a proxy closes an idle connection (see [Idle connections](#idle-connections)); * asks the generator for one event at a time, as the client reads. A client that stops reading pauses the generator instead of filling memory. A header some other proxy needs, such as `no-transform`, which asks a proxy not to change the body, goes on `ctx.out.headers`. The framework lays it over the response `sse()` returns, and set from a hook, it covers a whole group or application: ```ts const noTransform = hook.beforeHandle((ctx) => { ctx.out.headers.set("cache-control", "no-cache, no-transform"); }); ``` Each yielded event has these fields: | Field | | | ------- | ------------------------------------------------------------------------------------------------------------------ | | `data` | the payload: a string is sent as is, anything else as JSON. A value with no JSON form, such as `undefined`, throws | | `event` | the name, read by `addEventListener(name)` instead of `onmessage` | | `id` | a string or a number; the browser sends the last one back as `Last-Event-ID` when it reconnects | | `retry` | milliseconds the browser waits before reconnecting; a whole number, 0 or more | A multi-line `data` is framed correctly. `event` and `id` must fit on one line: a value with a line break or a NUL throws a `TypeError`, because sending it would add a field the stream never meant to send. ## Resuming A browser that lost the connection reconnects with the last id it saw. `lastEventId(ctx)` reads it, so the stream can continue from there: ```ts import { lastEventId, sse } from "@tetsujs/sse"; const resume = route({ method: "GET", path: "/history", handler: (ctx) => sse(ctx, async function* () { for await (const item of history.since(lastEventId(ctx))) { yield { data: item, id: item.id }; } }), }); ``` ## Cleaning up The generator receives an `AbortSignal` that fires when the stream is over: the client left, the response was discarded, `until` fired, or the generator ended. A generator that yields regularly needs nothing more: when the client leaves, its loop ends and its `finally` runs. **A generator that can go quiet must pass the signal on** to whatever it waits for, such as a queue with no traffic, or it waits inside an `await` forever. [Streaming](/docs/concepts/streaming/#a-generator-must-yield-or-wait-on-the-signal) shows how. A generator that throws ends the stream where it stood. The response has already gone, so the error goes to the application’s `reportError` with `source: "stream"` (or is printed as `[tetsu] stream failed:` without one). A `finally` or an `onEnd` that throws is reported the same way. ### Stopping the server A stopping server waits for every response in flight, and a stream never finishes on its own. `until` ends it from outside: pass the `draining` signal of `@tetsujs/lifecycle`, and clients reconnect to another server. `onShutdownSignals()` takes the server, and the server is built from the routes, so the handler reads the signal when a request comes in, after the server and the signal exist: ```ts import { createApp, route } from "@tetsujs/core"; import { onShutdownSignals } from "@tetsujs/lifecycle"; import { sse } from "@tetsujs/sse"; const live = route({ method: "GET", path: "/feed", handler: (ctx) => sse(ctx, feed, { until: shutdown.draining }), }); const server = Bun.serve({ ...createApp({ routes: { live } }), port: 3000 }); const shutdown = onShutdownSignals(server, { preStopDelayMs: 5_000 }); ``` A controller built from its dependencies takes the signal as a function; see [Streams and sockets](/docs/guides/health-and-shutdown/#streams-and-sockets). ## Idle connections Bun closes a connection that sends nothing for its `idleTimeout`, 10 seconds unless the server sets another, and the first heartbeat of a quiet feed comes after 15. So when the stream starts, `sse()` sets its own request’s timeout to the heartbeat and ten seconds more, with `ctx.server.timeout()`. Every other request keeps the server’s setting. It sets the timeout rather than raising it: Bun does not say what the server’s `idleTimeout` is, so a longer one, or `0`, is replaced for these requests too. A client that stops reading is then let go after the stream’s timeout, and a heartbeat over four minutes is cut at 255 seconds. The timeout is set as the stream is read, after the handler has returned, so it also replaces one the handler set itself, such as the `server.timeout(req, 0)` of Bun’s own guide. A generator that wants another sets it, since it starts later. Where the stream cannot set it, the server’s `idleTimeout` decides. A feed with `heartbeatMs: 0` is closed once it stays quiet longer. Over HTTP/3, Bun ignores a request’s own timeout: set `idleTimeout` above the heartbeat. On a unix socket it ignores it too, and its types do not take `idleTimeout` with `unix`: keep the heartbeat under 8 seconds there, since Bun checks idleness every 4 and its default of 10 can end a connection after 8. Bun waits 255 seconds at most, so a heartbeat over four minutes keeps no connection open. ## Knowing what a stream did An access log records a stream when it starts, so a long feed shows as a fast `200`. `onEnd` reports how the stream actually ended: ```ts const live = route({ method: "GET", path: "/feed", hooks: { beforeParse: [requestId()] }, handler: (ctx) => sse(ctx, feed, { onEnd: (summary) => logger.info({ ...summary, requestId: ctx.requestId }, "stream closed"), }), }); // { events: 412, bytes: 38104, durationMs: 2401882.6, reason: "cancelled" } ``` `reason` is `"ended"` (the generator finished), `"cancelled"` (the client left, the response was discarded, or `until` fired) or `"failed"` (the generator threw). `events` counts the events, not the keep-alives or the opening; `bytes` counts everything sent. The summary has no request id: add what identifies the request yourself, from `ctx`. A stream that was never sent reports nothing. ## Other formats: stream() `sse()` is `stream()` with SSE framing on top. For anything else, such as NDJSON, CSV or a model’s tokens, `stream()` sends the chunks as they are, with the same backpressure, signal and summary: ```ts import { stream } from "@tetsujs/sse"; const exportRows = route({ method: "GET", path: "/rows", handler: (ctx) => stream( ctx, async function* (signal) { for await (const row of rows.watch({ signal })) { yield `${JSON.stringify(row)}\n`; } }, { contentType: "application/x-ndjson" }, ), }); ``` `stream()` sends no opening and no keep-alive unless asked, because not every format has a line a client will ignore. The headers go out with the first chunk, so a stream that is slow to produce one keeps its client waiting for the headers too. Yield early, or set a keep-alive the consumer’s parser ignores, such as a blank line: ```ts const response = stream(ctx, chunks, { keepAlive: { everyMs: 15_000, chunk: "\n" } }); ``` A keep-alive sets the request’s idle timeout, as the heartbeat of `sse()` does. Without one, Bun closes a stream that writes nothing for the server’s `idleTimeout`. A stream that should arrive as it is written, behind nginx, sends `x-accel-buffering: no` in `headers`, as `sse()` does on its own. ## In the OpenAPI document Without a response map, the [generated document](/docs/packages/openapi/) says only that the route answers `200`, and nothing of what it sends. Name the stream’s type in the map, and the same for `stream()`, with its own type. `body` is the `data` of one event: ```ts const live = route({ method: "GET", path: "/feed", schema: { response: { 200: { contentType: "text/event-stream", body: Price } }, }, handler: (ctx) => sse(ctx, feed), }); ``` The [client](/docs/packages/client/#streams) types each event’s `data` by `body`. The document gives it as the schema of the stream: OpenAPI 3.1 cannot describe the events one by one, and a tool that reads it to the letter takes it for the schema of the whole body. Nothing checks that the generator yields what `body` says: it is documentation, as any `Response` a handler builds. ## Options | `sse()` | Default | | | ------------- | ------- | -------------------------------------------------------------------------------------------------------------- | | `heartbeatMs` | `15000` | keep-alive interval, up to 2³¹ − 1; `0` turns it off, and `NaN`, `Infinity` or a negative throws a `TypeError` | | `status` | `200` | | | `until` | none | a signal that ends the stream, such as `draining` | | `onEnd` | none | receives the summary when the stream ends | | `stream()` | Default | | | ------------- | ------- | ------------------------------------------------------------------ | | `contentType` | none | the `content-type` header | | `status` | `200` | | | `headers` | none | more response headers | | `keepAlive` | off | `{ everyMs, chunk }`; `everyMs` follows the rules of `heartbeatMs` | | `until` | none | a signal that ends the stream | | `onEnd` | none | receives the summary; it counts `chunks` instead of `events` | The package also exports `frame(event)`, which formats one event as it goes on the wire, and the types `ServerSentEvent`, `SseOptions`, `SseSummary`, `SseReason`, `StreamOptions`, `StreamSummary`, `StreamReason` and `KeepAlive`. # @tetsujs/static > Files from a directory — a built site, a single-page app or assets — with caching headers, precompressed copies and the application's hooks. `@tetsujs/static` serves the files of a directory: a built site next to the API, a single-page app, or a directory of assets. It is a handler, so it runs inside the pipeline, and the application’s hooks apply to files as to everything else: security headers, CORS, a request log. ```bash bun add @tetsujs/static ``` ## Usage ```ts import { staticFiles } from "@tetsujs/static"; const app = createApp({ routes, fallback: staticFiles({ root: "./dist", notFound: "404.html" }), }); ``` In `fallback`, it answers every path no route matched: `/css/site.css` is `dist/css/site.css`, and `/` is `dist/index.html`. A path with no file gets the application’s `404`, the same JSON as any other missing path, and a browser gets `404.html` ([below](#a-page-for-a-missing-path)). On a route, it answers what follows the route’s `*`: ```ts const assets = route({ method: "GET", path: "/assets/*", handler: staticFiles({ root: "./public" }), }); ``` `/assets/app.css` is `public/app.css`. A route without `*`, such as `/robots.txt`, serves its own path from the root. The route’s group, hooks and `docs` apply as to any other route, so files behind a sign-in are a group with the sign-in hook and a route `/*`, with a root of their own ([below](#files-behind-a-guard)). A route `/assets/*` does not answer `/assets` itself: Bun’s router wants the slash, and the bare address goes to the fallback. `root` is resolved against the working directory, as `Bun.file` resolves a path, not against the module that calls `staticFiles()`. For the module’s own directory, use `join(import.meta.dir, "public")`. `staticFiles()` checks the root when it is called, so a wrong one stops the application at startup. Every file goes out with: * its `content-type`, on `HEAD` too; * `ETag` and `Last-Modified`, and a `304` when the client’s copy is still the file; * `Cache-Control`, `no-cache` unless [`cacheControl`](#caching) says otherwise; * `Accept-Ranges: bytes`: Bun answers a `Range` with `206`, or `416` for one outside the file. ## Paths Bun resolves `.` and `..` before the handler reads the path, so `/css/../app.js` is `/app.js`, and no `..` climbs above the root. What is left is checked before the disk is touched, and refused rather than repaired. These get a `404`: * a path that leaves a route’s prefix once resolved, such as `/assets/../config.json` under `/assets/*`; * an empty segment, as in `//`; * an encoded `/` or `\`, so `..%2f` is no way out either, and a NUL; * a dotfile or a dot-directory, so `.env` and `.git` are never served. `.well-known` is the one exception. A directory’s address without its trailing slash is redirected to it with `301`, the query kept, so the relative links in its `index.html` work: `/docs` to `/docs/`. The `Location` always starts with exactly one `/`, so a path such as `//evil.example/docs` cannot send a browser to another site. `index: false` turns directories off. Symbolic links are followed, as nginx, Caddy and Express follow them: what the root links to is served as part of it. What the root holds is yours to decide. Serve a build’s output, not a project’s directory. ## Files behind a guard Everything below a root is public, however its path is written. Bun’s router matches a path as it arrives, while the handler reads it resolved and decoded, and the disks of macOS and Windows ignore case. So `/x/../reports/q3`, `/%72eports/q3` and, on such a disk, `/REPORTS/q3` all miss a `/reports/*` route that requires a sign-in. They reach the fallback, or a broader route such as `/*`, and if that root holds `reports/q3`, the file goes out. Files that need a guard live in a directory of their own, served by a route with the guard, never below a root that is served without it: ```ts const signedIn = hook.beforeParse((ctx) => { if (!isSignedIn(ctx.req)) throw httpError(401); }); const app = createApp({ routes: [ group("/reports", { hooks: { beforeParse: [signedIn] }, children: [ route({ method: "GET", path: "/*", handler: staticFiles({ root: "./reports" }) }), ], }), ], fallback: staticFiles({ root: "./public" }), }); ``` `./reports` is not inside `./public`, so no way of writing a path reaches a report through the fallback. ## Methods In `fallback`, `GET` and `HEAD` get the file, and `OPTIONS` gets `204` with `Allow: GET, HEAD, OPTIONS`, as a route answers it. Another method gets `405` with the same `Allow` where a file exists, and every method gets `404` where none does, so a `POST` to a mistyped API address is the usual `404`. On a route, the core answers the other methods, as for any route. ## Caching `cacheControl` sets `Cache-Control`. The default, `no-cache`, lets a browser keep a file but makes it ask whether the file changed before using it, and the answer is a small `304` when it has not. Without the header, a browser guesses how long to keep a file from its age, and can go on running an old script after a deploy. A file whose name changes with its content, as a bundler names what it puts in `assets/`, can be kept for good: ```ts staticFiles({ root: "./dist", cacheControl: (path) => path.startsWith("assets/") ? "public, max-age=31536000, immutable" : "no-cache", }); ``` The function gets the file’s path below the root, not the address. The shell of a single-page app is `index.html` at every address it answers, so a rule for `.html` covers it. ## A page for a missing path `notFound` names a file below the root, sent with `404` to a browser that asked for a path with no file. Only a request whose `Accept` names `text/html`, as a browser’s navigation does, gets the page. `fetch`, curl and every API client get the application’s `404`, so a mistyped API address is not answered with a page. Both carry `Vary: Accept`, so a cache keeps them apart. ## Single-page apps An app with a router of its own, such as React Router or Vue Router, reads the address in the browser. A reload on `/orders/42` must load the app, though no file has that path. `spa: true` answers a browser’s request for a path with no file with the root’s `index.html` and `200`: ```ts const app = createApp({ routes, fallback: staticFiles({ root: "./dist", spa: true }) }); ``` A request that does not ask for HTML still gets the application’s `404`: a script missing after a deploy, a mistyped API address. The app’s router shows its own page for an address it does not know. `spa` and `notFound` exclude each other. The app must load its assets from absolute paths, `/assets/app.js`, which is what bundlers such as Vite produce by default. A relative `assets/app.js` on the page `/orders/42` is fetched from `/orders/assets/app.js`. ## Compressed copies Bun compresses nothing on its own, and a bundle is the largest response a site sends. With `precompressed: true`, a client whose `Accept-Encoding` takes it gets the copy beside a file, `app.js.br`, then `app.js.gz`, with `Content-Encoding` and the original’s type. The build makes the copies once, with a plugin of the bundler or a few lines of Bun after it: ```ts import { brotliCompressSync } from "node:zlib"; for await (const path of new Bun.Glob("dist/**/*.{html,js,css,svg,json}").scan()) { const bytes = await Bun.file(path).bytes(); await Bun.write(`${path}.br`, brotliCompressSync(bytes)); await Bun.write(`${path}.gz`, Bun.gzipSync(bytes)); } ``` A copy has its own `ETag`, `Last-Modified` and length, and a `Range` is cut from its bytes. A copy older than its original is left over from an earlier build and is not sent, so a build that forgot to compress again does not serve the old bundle. Every file then carries `Vary: Accept-Encoding`, so a cache keeps the copies apart. The page of `notFound` and the shell of `spa` have copies too. ## Wrapping the handler The handler returns a `Response`, so headers of one route go on `ctx.out.headers` before it runs: ```ts const files = staticFiles({ root: "./uploads" }); const downloads = route({ method: "GET", path: "/downloads/*", docs: { hidden: true }, handler: (ctx) => { ctx.out.headers.set("content-disposition", "attachment"); return files(ctx); }, }); ``` The route mounts the arrow, not the handler, and the arrow tells the OpenAPI document nothing: without `docs: { hidden: true }`, the route would be in it, answering a `200`. Files your users uploaded are not the site’s own. An HTML page or an SVG served from the site’s domain runs its scripts as the site. Serve uploads from a domain of their own, or as attachments, as above. ## In the OpenAPI document A route of files is left out of the [generated document](/docs/packages/openapi/). Nearly every one serves a site’s assets, and a generated client would get a method that cannot fetch a nested file: OpenAPI cannot say that the parameter of a `*` holds slashes, so a client encodes them. `docs: { hidden: false }` on the route shows it, and the handler describes what it answers: a file of any type with its headers, `206`, `301`, `304`, `404` and `416`. Show a flat directory, such as `/downloads/*`. `fallback` is not in the document at all, since it has no path. ## Next to Bun’s own static routes Bun’s own `{ dir }` routes serve a directory a few microseconds faster per request, as they skip the pipeline, but they skip what it does too. No hook runs for them, so security headers, CORS and logs do not apply, and they serve `.env` and `.git` like any other file, answer `POST` with the file, and send no `Cache-Control`. They suit a directory with nothing secret in it that needs none of that. ## Options | Option | Default | | | --------------- | -------------- | ------------------------------------------------------------------------------------- | | `root` | required | the directory served, from the working directory | | `index` | `"index.html"` | the file a directory is answered with; `false` for none | | `notFound` | none | a file below the root, sent with `404` to a browser | | `spa` | `false` | `true` sends the root’s index file with `200` to a browser at an address with no file | | `cacheControl` | `"no-cache"` | a `Cache-Control` value, or a function of the file’s path below the root | | `precompressed` | `false` | `true` sends the `.br` or `.gz` copy to a client that takes it | `spa` with `notFound`, and `spa` with `index: false`, are compile errors. `staticFiles()` throws on them too, on a `root` that is not a directory, and on a `notFound` file, or with `spa` the index file, that is missing, so a mistake stops the application at startup rather than on a request. The package also exports the types `StaticOptions`, `StaticHandler`, `NotFoundPage` and `SinglePageApp`. # @tetsujs/typebox > TypeBox schemas as DTOs — compiled validation, full type inference, file uploads, and the same schema in the OpenAPI document. `@tetsujs/typebox` turns a [TypeBox](https://github.com/sinclairzx81/typebox) schema into a DTO you can use in any part of a route’s `schema`. Validation is compiled, types are inferred, and the same schema goes into the OpenAPI document. See [Validation](/docs/concepts/validation/) for how schemas are used in a route. ```bash bun add typebox @tetsujs/typebox ``` `typebox` is a peer dependency, version 1.3.34 or later, so the application picks the version and has one copy of it. ## Usage Wrap a TypeBox schema with `tb()` and use it in a route’s `schema`: ```ts import { tb, Type } from "@tetsujs/typebox"; export const CreateUser = tb( Type.Object({ name: Type.String({ minLength: 1 }), email: Type.String({ format: "email" }), }), ); export const UserParams = tb(Type.Object({ id: Type.Integer() }), { convert: true }); export const usersController = controller("Users", () => ({ update: route({ method: "PUT", path: "/users/:id", schema: { params: UserParams, body: CreateUser }, handler: (ctx) => users.update(ctx.params.id, ctx.body), }), })); ``` `tb()` compiles the schema once, and every request runs the compiled check. `Type` is TypeBox’s own, re-exported, so TypeBox’s documentation applies. The types `Static`, `StaticDecode` and `TSchema` are re-exported too. What `tb()` returns is still a plain JSON Schema object: it serializes cleanly and nests into other TypeBox schemas. ## Options | Option | Effect | Use for | | ---------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | `convert` | converts before checking: `"42"` becomes `42`, a single `"a"` becomes `["a"]` | `params`, `query`, `headers` and form fields, which arrive as strings | | `clean` | drops properties the schema does not declare | `response` DTOs, so undeclared fields do not leak | | `defaults` | fills in a declared `default` when a value is missing | optional query parameters, configuration | | `issues` | `"detailed"` (default) or `"summary"` | `"summary"` reports a single issue with no path, much cheaper on large bodies | | `vendor` | the vendor name reported through Standard Schema | custom tooling | All are off by default. None of them changes the value passed in: they work on a copy, so `clean` on a response never deletes fields from the object the handler returned. `convert` is never implicit. A JSON body that sends `"42"` for a number is refused. `defaults` runs before the check, so a filled-in default is checked like any other value. In the OpenAPI document, a property with a `default` is optional on input and required on output. `issues: "summary"` returns one issue, `does not match the schema`, with no path. Finding where a value failed is TypeBox’s slow path, and its cost grows with the valid data before the failure. Use `"summary"` on large bodies on public endpoints, where a bad payload only needs a `422`. Wrapping a DTO again replaces its options. `tb(CreateOrder, { issues: "summary" })` has no `convert`, even if `CreateOrder` had it. ### Nested DTOs A DTO nested in another is checked with the outer DTO’s options, not its own. If the nested one has an option the outer one lacks, `tb()` throws, because a `clean` that no longer strips would leak fields: ```ts const PublicUser = tb(Type.Object({ id: Type.String() }), { clean: true }); tb(Type.Object({ users: Type.Array(PublicUser) })); // throws: the nested DTO asks for clean, which the outer one does not have const Users = tb(Type.Object({ users: Type.Array(PublicUser) }), { clean: true }); // strips every user ``` A schema derived from a DTO, with `Type.Pick`, `Type.Omit` or `Type.Partial`, is a new schema with none of the DTO’s options. Pass the options it needs to its own `tb()`. ## Files `file()` and `files()` validate uploads in a `bodyType: "form"` body. See [Request bodies](/docs/concepts/request-bodies/) for how uploads are read. ```ts import { file, files, tb, Type } from "@tetsujs/typebox"; const Upload = tb( Type.Object({ title: Type.String({ minLength: 1 }), avatar: file({ maxSize: "5m", type: "image" }), gallery: files({ maxSize: "1m" }), }), ); export const uploads = controller("Uploads", () => ({ create: route({ method: "POST", path: "/uploads", bodyType: "form", schema: { body: Upload }, handler: (ctx) => store(ctx.body.title, ctx.body.avatar, ctx.body.gallery), }), })); ``` | Option | Accepts | Checks | | --------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `maxSize` | `5242880`, `"512k"`, `"5m"` | the largest file size | | `minSize` | the same | the smallest; `1` rejects an empty file | | `type` | `"image"`, `"image/png"`, `["image", "application/pdf"]` | the MIME type the client declared, not the bytes; `"image"` matches every image type | A size is a number of bytes, or a number with `k` (KiB) or `m` (MiB). `files()` gives an array whenever the field is there, even for a single file. A file input left empty is absent from the body, so wrap an optional one in `Type.Optional`: an absent `files()` field is `undefined`, not `[]`, and the handler reads `ctx.body.gallery ?? []`. These checks run after the body is read. The limit on how much is read at all is `maxBodySize` on the application, and it counts the multipart framing too. In the OpenAPI document a file is a binary string with its size limits and media type. ## Error messages Set your own message with `errorMessage`: one string for any failure, or one per keyword: ```ts const CreateUser = tb( Type.Object({ email: Type.String({ format: "email", errorMessage: { format: "Not an email address", required: "Email is required" }, }), password: Type.String({ minLength: 8, errorMessage: "At least 8 characters" }), }), ); ``` `errorMessage` is left out of the JSON Schema and the OpenAPI document. For several languages, keep schemas without messages and translate in an `onError` hook, by each issue’s path. See [Errors](/docs/concepts/errors/). Each issue points at the field itself: a missing `password` is reported at `["body", "password"]`, not at `body`. A union of literals fails with one issue that lists the allowed values. ## Codecs A `Type.Codec` is validated as it arrives and handed to the handler decoded. The validated type is the decoded one: ```ts const Instant = Type.Codec(Type.String({ format: "date-time" })) .Decode((value) => new Date(value)) .Encode((value: Date) => value.toISOString()); const Stored = tb(Type.Object({ code: Type.String(), expiresAt: Instant })); const session = parse(Stored, await redis.hgetall(key)); ``` If `Decode` throws, the value fails with a `422` carrying the error’s message, not a `500`. Throw a message meant for the client. ## Validating outside a request `parse()` validates a value and returns it, or throws a `ValidationError` with every issue. It is synchronous, so it works at module level, for example for the environment: ```ts const Env = tb( Type.Object({ PORT: Type.Integer({ minimum: 1, maximum: 65_535, default: 3000 }), DATABASE_URL: Type.String({ format: "uri" }), }), { convert: true, defaults: true, clean: true }, ); export const env = parse(Env, Bun.env); ``` Inside a handler, a thrown `ValidationError` becomes the same `422` a rejected request gets. At startup, catch it, print `error.issues` and exit. ## OpenAPI A TypeBox schema is JSON Schema 2020-12, which OpenAPI 3.1 uses as is, so [`@tetsujs/openapi`](/docs/packages/openapi/) needs no converter. Only the targets `draft-2020-12` and `openapi-3.1` are supported, exported as `supportedTargets`. Asking for an older dialect throws. ## Performance TypeBox compiles each schema into a checking function, so valid bodies are checked several times faster than with other Standard Schema libraries, and the gap grows with the body. Describing a failure in detail is its slow path, which `issues: "summary"` avoids, and importing it costs more memory than Zod or Valibot. TypeBox suits large bodies, mostly valid traffic and schemas that double as documentation; a lighter library suits cases where memory and startup matter more. [Performance](/docs/more/performance/#validation) has the numbers. # Quick start > A first Tetsu application — a controller with three routes, validation, errors and a test — from an empty folder. This page builds a small users API from an empty folder: three routes, a validated body, an error of your own and a test. It needs only Bun. ## Create the project ```bash mkdir users-api && cd users-api bun init -y bun add @tetsujs/core zod ``` `bun init` already sets up TypeScript the way Tetsu needs. To add Tetsu to an existing project, see [Installation](/docs/installation/). ## Declare the routes A controller is a name and a function from its dependencies to its routes. Here a `Map` stands in for a database: src/users.ts ```ts import { controller, httpError, route } from "@tetsujs/core"; import { z } from "zod"; export interface User { id: number; name: string; email: string; } const NewUser = z.object({ name: z.string().min(1), email: z.email() }); const UserId = z.object({ id: z.coerce.number().int().positive() }); export const usersController = controller("Users", (users: Map) => ({ list: route({ method: "GET", path: "/users", handler: () => [...users.values()], }), get: route({ method: "GET", path: "/users/:id", schema: { params: UserId }, handler: (ctx) => { const user = users.get(ctx.params.id); if (!user) throw httpError(404, "USER_NOT_FOUND", "No such user"); return user; }, }), create: route({ method: "POST", path: "/users", schema: { body: NewUser }, handler: (ctx) => { const user = { id: users.size + 1, ...ctx.body }; users.set(user.id, user); ctx.out.status = 201; return user; }, }), })); ``` * `ctx.params.id` is a number: the schema converts the `:id` segment before the handler runs, and `/users/abc` never reaches it. * `ctx.body` exists only on `create`, the route with a body schema, and has the schema’s type. * `httpError(404, "USER_NOT_FOUND", …)` is your own error, in the same shape as the framework’s. ## Serve it `createApp` turns the routes into plain data that `Bun.serve` takes as it is: index.ts ```ts import { createApp } from "@tetsujs/core"; import type { User } from "./src/users"; import { usersController } from "./src/users"; const users = new Map(); const app = createApp({ routes: usersController(users) }); Bun.serve({ ...app, port: 3000 }); console.log("Listening on http://localhost:3000"); ``` ```bash bun --watch index.ts ``` ## Try it ```bash curl -X POST localhost:3000/users -d '{"name":"Ada","email":"ada@example.com"}' ``` ```json { "id": 1, "name": "Ada", "email": "ada@example.com" } ``` The body is parsed as JSON, the route’s default, whatever `content-type` the client sends (curl’s `-d` sends a form type). A request the schema refuses gets a `422` with every issue at once: ```bash curl -X POST localhost:3000/users -d '{"name":"","email":"nope"}' ``` ```json { "status": 422, "message": "Validation failed", "error": "VALIDATION_FAILED", "issues": [ { "message": "Too small: expected string to have >=1 characters", "path": ["body", "name"] }, { "message": "Invalid email address", "path": ["body", "email"] } ] } ``` Your own error has the same shape: ```bash curl localhost:3000/users/7 ``` ```json { "status": 404, "message": "No such user", "error": "USER_NOT_FOUND" } ``` ## Test it Routing is Bun’s, and only a real socket reaches it. `serve()` from `@tetsujs/core/testing` starts the application on a free port and stops it when the test file finishes: src/users.test.ts ```ts import { createApp } from "@tetsujs/core"; import { serve } from "@tetsujs/core/testing"; import { expect, test } from "bun:test"; import { usersController } from "./users"; const request = serve(createApp({ routes: usersController(new Map()) })); test("creates a user and reads it back", async () => { const created = await request("/users", { method: "POST", body: JSON.stringify({ name: "Ada", email: "ada@example.com" }), }); expect(created.status).toBe(201); expect(await (await request("/users/1")).json()).toEqual({ id: 1, name: "Ada", email: "ada@example.com", }); }); ``` ```bash bun test ``` ## Where to go next * [Key concepts](/docs/key-concepts/) — the whole model on one page. * [Lifecycle hooks](/docs/concepts/lifecycle-hooks/) — authentication, logging and whatever else runs around a handler. * [Structuring an application](/docs/guides/structuring/) — where things go once there is more than one controller. # Context fields > Every field of ctx, its type and the slot it exists from, the details of ctx.out, ctx.res and ctx.error, signedCookie, and the context types. `ctx` is one object per request, handed to every hook and to the handler. A field is typed from the slot where it exists, and not before. [Context and its types](/docs/concepts/context/) explains the rule. ## Fields | Field | Type | Exists from | | | --------------- | -------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------- | | `req` | `Request & { cookies?: CookieMap }` | every slot | the request as Bun delivered it | | `server` | `Bun.Server` | every slot | the server handling the request | | `out` | `Outgoing` | every slot | what the response will carry, [below](#ctxout) | | `route` | `RouteInfo` | every slot | the route that matched; absent where none did | | `startedAt` | `number` | every slot | `performance.now()` when the request entered the framework | | `params` | `{ [name]: string }`, or the schema’s output | `beforeParse` | raw strings until validation, the schema’s output from `beforeHandle` | | `body` | `unknown`, or by `bodyType` | `beforeValidation` | only when the route reads a body; as parsed, then the schema’s output from `beforeHandle` | | `rawBody` | `Uint8Array` | `beforeValidation` | only with `rawBody: true` | | `query` | the schema’s output | `beforeHandle` | only with `schema.query` | | `headers` | the schema’s output | `beforeHandle` | only with `schema.headers` | | `cookies` | the schema’s output | `beforeHandle` | only with `schema.cookies`; signed cookies opened | | `res` | `Response` / `SentResponse` | `beforeResponse` / `afterResponse` | [below](#ctxres) | | `error` | `unknown` | `onError` | [below](#ctxerror) | | a hook’s fields | what it returned | the hook after it | typed in the handler for the route’s own hooks only | In `beforeResponse`, `afterResponse` and `onError`, the validated parts and the hooks’ fields are optional, since the request may have failed before they existed. `params` there is either the raw strings or the schema’s output. Without `schema.body`, `ctx.body` is typed by `bodyType`: `unknown` for `"json"`, `FormBody` (`Record`) for `"form"`, `string` for `"text"`, and `ReadableStream` for `"stream"`. ### `req` On a request that matched a route, `req.cookies` is Bun’s `CookieMap`: the cookies as the client sent them, unchecked. Bun sends changes to it as `Set-Cookie` with its defaults (`Path=/; SameSite=Lax`). It is absent on the `404` fallback and in unit tests. `req.signal` aborts when the client disconnects. ### `server` The `Bun.Server` itself: `ctx.server.requestIP(ctx.req)` gives the client’s address, `ctx.server.timeout(ctx.req, seconds)` sets a per-request idle timeout. A server listening on both IPv4 and IPv6, which is Bun’s default, reports an IPv4 client as `::ffff:203.0.113.7`. ### `route` | Field | Type | | | ------------ | ------------------ | ------------------------------------------------------------------------------- | | `method` | `string` | the method the route answers | | `path` | `string` | the declared path with prefixes joined: `/api/users/:id`, never `/api/users/42` | | `controller` | `string`, optional | the controller’s name; absent for an object literal or a standalone route | | `name` | `string`, optional | the field the route was declared as | Built once per route at startup. It is absent on a `404`, a `405` and an `OPTIONS` request, which have no route. Label logs and metrics with `route.path`, not the URL: a label per id creates a new series per user. ### `startedAt` A monotonic reading in milliseconds, taken before any hook runs. `performance.now() - ctx.startedAt` is how long the request has taken so far. For wall-clock time, use `Date.now()`. ## `ctx.out` | Member | Type | | | ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- | | `status` | `number \| undefined` | the status of a serialized result; ignored for a `Response` and for errors | | `headers` | `Headers` | added to every response that leaves | | `cookies.set` | `(name, value, attributes?) => void` | adds a `set-cookie`, signed when the name is covered; a second `set` of a name replaces the first | | `cookies.delete` | `(name, { path?, domain? }?) => void` | expires the cookie; `path` and `domain` must match the ones it was set with | The attributes of `set` are Bun’s `CookieInit` without `name` and `value`: `domain`, `path`, `expires`, `maxAge`, `secure`, `httpOnly`, `sameSite`, `partitioned`. `ctx.out.headers` applies to serialized results, a handler’s `Response`, a hook’s short-circuit and error responses. `set-cookie` is appended, `vary` is merged token by token, and every other header overwrites. On a route with a response map, the handler’s `ctx.out` is a `DeclaredOutgoing`: `status` accepts only a declared status. ## `ctx.res` In `beforeResponse`, `ctx.res` is the `Response` about to leave. Return a new `Response` to replace it. Reading its body consumes it, so read `ctx.res.clone()` instead. In `afterResponse`, it is a `SentResponse`: `status`, `statusText`, `headers`, `ok`, `redirected`, `type` and `url`, with no body. Read what you need before the first `await`. ## `ctx.error` Whatever was thrown, typed `unknown`: | Value | From | | ----------------------- | ------------------------------------------------------------------------------------------------ | | `HttpError` | a hook or handler that threw one; a `404` or `405`; a body that failed to parse or was too large | | `ValidationError` | a request part failed its schema; `issues` lists the failures | | `ResponseContractError` | the handler broke its response contract | | anything else | an unexpected failure, such as a `TypeError` or a driver’s error | ## Cookies ```ts function signedCookie(ctx: BaseCtx, name: string): string | undefined ``` Reads a signed cookie from the request’s `cookie` header and checks its signature, for a hook that runs before `ctx.cookies` exists. Returns the first value whose signature holds, or `undefined`. Throws when the application signs no cookies or does not sign `name`. See [Cookies](/docs/concepts/cookies/#reading-a-signed-cookie-early). ## Types | Type | | | ---------------------------------------------------- | ------------------------------------------------------------------------ | | `BaseCtx` | the fields every slot has: `req`, `server`, `out`, `route?`, `startedAt` | | `Requires` | `BaseCtx & T`, what a reusable hook declares | | `EarlyCtx` | `BaseCtx` with raw `params` and `route`, as in `beforeParse` | | `ValidatedCtx` | the context after validation, from the path and the schemas | | `HandlerCtx`, `ResponseCtx`, `ErrorCtx` | the context of a handler, a response-slot hook, an `onError` hook | | `Outgoing`, `DeclaredOutgoing` | `ctx.out` | | `DeclaredStatus` | the statuses a response map declares | | `ResponseCookies`, `CookieAttributes` | `ctx.out.cookies` and its attributes | | `RouteInfo` | `ctx.route` | | `BodyType`, `ParsedBody`, `FormBody`, `FormValue` | the body before validation | Every other type export is listed in [createApp](/docs/reference/create-app/#types). # createApp > The options of createApp, what the returned application carries, what is refused at startup, and every export of @tetsujs/core. `createApp()` compiles the routes, groups and hooks into a route table and returns an application in the shape `Bun.serve` takes. ```ts const app = createApp({ routes, hooks: { afterResponse: [log] }, cookies: { secret: Bun.env.COOKIE_SECRET!, sign: ["session"] }, maxBodySize: 2 * 1024 * 1024, reportError: ({ source, error }) => console.error(source, error), }); Bun.serve({ ...app, port: 3000 }); ``` ## Options | Option | Type | Default | | | ------------------- | ------------------------------------ | ------------------------ | ---------------------------------------------------------------------- | | `routes` | `object \| readonly object[]` | required | a controller, a group, a route, a `ws()` endpoint, or an array of them | | `hooks` | hooks keyed by slot | none | application hooks, run for every request | | `cookies` | `CookieOptions` | none | the secret and the names of signed cookies | | `maxBodySize` | `number` | `1048576` (1 MiB) | the largest request body, in bytes | | `validateResponses` | `boolean` | `true` | check results against `schema.response` | | `validation` | `{ status?: 400 \| 422 }` | `{ status: 422 }` | the status of a validation failure | | `fallback` | `(ctx: BaseCtx) => unknown` | the `404` envelope | answers a path no route matches | | `reportError` | `(report: FailureReport) => unknown` | print to `console.error` | receives failures no response can carry | ### `routes` Arrays nest, and a controller is read by its own string-keyed fields. See [Groups and mounting](/docs/concepts/groups-and-mounting/). ### `hooks` An object keyed by slot: `{ beforeParse: [a, b], afterResponse: [c] }`. Application hooks run before group and route hooks in every slot, except `onError`, where they run last. They are the only hooks that run for a `404`, a `405` and an `OPTIONS` request. See [Hook slots](/docs/reference/hooks/). ### `cookies` | Field | Type | Default | | | -------- | ------------------------------------- | -------- | -------------------------------------------- | | `secret` | `string` | required | the HMAC-SHA256 key, 32 random bytes or more | | `sign` | `true \| string \| readonly string[]` | `true` | the names to sign; `true` signs every cookie | An empty or missing `secret` throws a `TypeError` at startup. See [Cookies](/docs/concepts/cookies/#signed-cookies). ### `maxBodySize` A `content-length` above the limit is a `413` before any byte is read, and a body without one is cut at the chunk that crosses the limit. A `"stream"` body is counted as the handler reads it: crossing the limit errors the stream with the same `413`, with the handler already running, and a handler that stops reading first gets none. A route’s own `maxBodySize` overrides this one. A handler that reads `ctx.req` itself is not capped. ### `validateResponses` When `true`, a serialized result is checked against the schema of its status. A status the response map does not declare, a body under a bodiless status, a value or nothing under a status whose `contentType` is not JSON, and response `headers` or `cookies` that fail their schema are refused too. Each failure is a `500` and a report with `source: "response"`. A `Response` built by the handler is never checked. `false` turns every response check off. The framework never reads `NODE_ENV`: to check outside production only, pass `Bun.env.NODE_ENV !== "production"`. ### `validation` `422` by default. Set `400` if your API uses `400` for every broken contract. A malformed JSON or form body is a `400` either way. ### `fallback` Replaces the `404` for unmatched paths, and runs with the application’s hooks. Its result is serialized like a handler’s: `200`, or `204` for `undefined`, unless it sets `ctx.out.status` or returns a `Response`. An SPA index wants the `200`; a custom `404` must set its status. ### `reportError` Receives `{ source, error, ctx }` for each failure no response can carry: an error no `onError` hook answered, a broken response contract, a failing `onError` or `afterResponse` hook, a WebSocket handler, a stream, a shutdown step. `ctx` is typed from the application’s own hooks, each field optional, and is absent when there was no request. It is called in place and never awaited. See [Errors](/docs/concepts/errors/#failures-that-cannot-become-a-response). ## The application | Field | Type | | | -------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `routes` | `Record` | one native Bun route per declared path | | `fetch` | `(req, server) => Promise` | the no-match handler: the `404` or the `fallback` | | `websocket` | `WebSocketHandler` | the handler for every socket; always present | | `maxRequestBodySize` | `number`, optional | Bun’s own body cap, raised above the largest `maxBodySize`; set only when that exceeds Bun’s default of 128 MiB | | `entries` | `readonly RouteTableEntry[]` | the compiled route table, for tooling | | `options` | `AppOptions` | the options in force: `validationStatus`, `validateResponses`, `maxBodySize`, and `cookieSealer` when `cookies` is set | | `printRoutes` | `() => void` | prints each route’s method, full path and controller | `Bun.serve({ ...app })` takes `routes`, `fetch`, `websocket` and `maxRequestBodySize` and ignores the rest. Bun’s own options go after the spread: ```ts const app = createApp({ routes }); Bun.serve({ ...app, port: 3000, idleTimeout: 30, websocket: { ...app.websocket, idleTimeout: 60 }, }); ``` `app.fetch` does not route: Bun’s router does, and `fetch` runs only when no path matched. Calling it directly always takes the fallback, so tests go through a real server with `serve()` from `@tetsujs/core/testing`. Each path answers `HEAD` from its `GET` route, `OPTIONS` with `204` and an `Allow` header, and any other undeclared method with `405` and the same `Allow`. `app.entries[n]` has `method`, `path` (prefixes joined), `def`, `hooks` (the merged chains), `route` (what `ctx.route` is), and `controller`, `name` and `ws` when they apply. `AppRoutes` is the type of every HTTP route, keyed by `"METHOD /full/path"`, with its schemas. A typed client is built from it. Socket endpoints are not in it. ## Checked at startup `createApp` throws when: * two routes have the same method and full path; * two paths differ only in parameter names (`/users/:id`, `/users/:userId`); * two controllers share a name; * the same hook instance is mounted twice in one route’s chain; * a class is mounted instead of an instance, or an application as a child; * a controller declares a route under a symbol key or as a class getter; * `hooks` is a list, a key is not a slot, a slot is not a list, an element is not a hook, or a hook sits in a slot other than its own; * `cookies.secret` is empty or missing. A controller without routes is a warning on `console.warn`. Once the table is built, each mounted controller with an `[onMount](app)` method receives the application, once, before `createApp` returns. ## Exports | Export | Kind | Reference | | -------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `createApp` | function | this page | | `route` | function | [route](/docs/reference/route/#route) | | `controller` | function | [route](/docs/reference/route/#controller) | | `group` | function | [route](/docs/reference/route/#group) | | `ws` | function | [route](/docs/reference/route/#ws) | | `hook` | object of factories | [Hook slots](/docs/reference/hooks/#factories) | | `onMount` | symbol | the key of a controller method `[onMount](app)`, called with the built application | | `isRoute`, `isGroup`, `isWs` | functions | type guards for a `RouteDef`, a `GroupNode`, a `WsDef` | | `HttpError`, `httpError`, `errorBody`, `ValidationError` | classes and functions | [Framework error codes](/docs/reference/error-codes/#throwing) | | `ResponseContractError`, `reportFailure` | class, function | [Framework error codes](/docs/reference/error-codes/#reported-failures) | | `signedCookie` | function | [Context fields](/docs/reference/context/#cookies) | | `toJsonSchema` | function | `toJsonSchema(schema, { target }, direction?)`: a schema’s JSON Schema, or `undefined` when the validator cannot produce one | `@tetsujs/core/testing` exports `testCtx(parts, { cookies })`, `serve(app, { hostname, stop })`, `stopServers()` and `captureErrors()`. See [Testing](/docs/guides/testing/). ### Types | Area | Types | Reference | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | Application | `AppConfig`, `App`, `AppOptions`, `AppContext`, `AppRoutes`, `FallbackHandler`, `PathHandler`, `RoutedRequest` | this page | | Route table | `RouteMap`, `RouteSignature`, `RoutesOf`, `RouteTableEntry`, `Mountable` | this page | | Routes | `RouteConfig`, `RouteDef`, `RouteDocs`, `Method`, `SchemaConfig`, `ResponseEntry`, `HandlerResult`, `HandlerMustReturn`, `HandlerReturnMarker`, `ValidateResult`, `ResultError` | [route](/docs/reference/route/) | | Paths | `ExtractParams`, `ValidatePath`, `ValidatePrefix`, `PathError` | [route](/docs/reference/route/#path) | | Groups | `GroupNode`, `GroupOptions`, `GroupConfig`, `GroupHooks` | [route](/docs/reference/route/#group) | | WebSockets | `WsConfig`, `WsDef`, `WsSchemaConfig`, `Socket`, `SocketData`, `SocketState`, `MessageOf` | [route](/docs/reference/route/#ws) | | Hooks | `Hook`, `AnyHook`, `SlotName`, `SlotBases`, `SentResponse`, `HooksConfig`, `MergedHooks`, `HookSlotError`, `HookRequirementError`, `HookStackError` | [Hook slots](/docs/reference/hooks/) | | Context | `BaseCtx`, `EarlyCtx`, `ValidatedCtx`, `HandlerCtx`, `ResponseCtx`, `ErrorCtx`, `Requires`, `RouteInfo`, `Outgoing`, `DeclaredOutgoing`, `DeclaredStatus`, `BodyType`, `ParsedBody`, `FormBody`, `FormValue` | [Context fields](/docs/reference/context/#types) | | Cookies | `CookieOptions`, `CookieAttributes`, `ResponseCookies` | [`cookies`](#cookies) above, [Context fields](/docs/reference/context/#ctxout) | | Errors | `ErrorBody`, `ValidationIssue`, `FailureReport`, `FailureSource`, `ReportError` | [Framework error codes](/docs/reference/error-codes/) | | Schemas | `AnySchema`, `InferInput`, `InferOutput`, `JsonSchemaDirection`, `Standard*` | the [Standard Schema](https://standardschema.dev) interfaces | # Framework error codes > Every status and error code the framework produces by itself, the envelope they share, the default code of each status, and the error classes and functions. Every error the framework answers by itself uses one envelope. This page lists those errors, the default code of each status, and the classes and functions behind them. [Errors](/docs/concepts/errors/) explains the model. ## The envelope ```json { "status": 413, "message": "Body exceeds the configured limit", "error": "BODY_TOO_LARGE" } ``` | Field | Type | | | --------- | --------------------------------------------------- | ------------------------------------------------------------- | | `status` | `number` | the HTTP status, repeated | | `message` | `string` | for people; it may change, so never match on it | | `error` | `string` | the code, in `UPPER_SNAKE_CASE`; branch on this field | | `issues` | `{ message: string; path: (string \| number)[] }[]` | validation failures only; `path` starts with the request part | ## Codes the framework produces | Status | `error` | `message` | When | | ------ | ----------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `400` | `MALFORMED_JSON` | Body is not valid JSON | a `json` body does not parse | | `400` | `MALFORMED_FORM` | Body is not a valid form | a `form` body does not parse | | `404` | `NOT_FOUND` | Not Found | no route matches the path and there is no `fallback` | | `405` | `METHOD_NOT_ALLOWED` | Method Not Allowed | the path exists but not the method; `Allow` lists the path’s methods | | `413` | `BODY_TOO_LARGE` | Body exceeds the configured limit | the body is over `maxBodySize` | | `422` | `VALIDATION_FAILED` | Validation failed | a request part failed its schema; carries `issues`; `400` with `validation: { status: 400 }` | | `426` | `UPGRADE_REQUIRED` | Upgrade Required | a WebSocket endpoint’s path was requested without a handshake | | `500` | `INTERNAL_SERVER_ERROR` | Internal Server Error | an error that is not an `HttpError` and no `onError` hook answered; a broken response contract; a stream returned bare | [`@tetsujs/rate-limit`](/docs/packages/rate-limit/) adds `RATE_LIMITED`, `429` unless its `status` option says otherwise, with `retryAfter` in seconds in the body and a `retry-after` header. Every failure above reaches the application’s `onError` hooks before it is answered: as an `HttpError`, or, for a `500`, as the error that caused it. Only the application’s hooks run for a `404` and a `405`. Two answers reach no hook: * the last-resort `500`, sent when the error path itself kept failing; * a body larger than Bun’s own `maxRequestBodySize`, which Bun refuses with a bare `413` and no envelope. `createApp` raises that cap above the largest `maxBodySize` when it needs to. ### WebSocket close codes | Code | When | | ------ | ------------------------------------------------------------------------------------------------------------------------------- | | `1001` | the endpoint’s `until` signal fired (going away) | | `1007` | on an endpoint with `schema.message`, a frame is binary, not valid JSON, or fails the schema, and there is no `invalid` handler | | `1011` | the message schema threw or rejected instead of reporting issues | ## Default codes An `HttpError` without a body, and `errorBody()` or `httpError()` without an `error`, take the code and message from the status. The message is the reason phrase. The code is that phrase in upper snake case, apostrophes dropped. A status not in this list gets `HTTP ` and `HTTP_`. | Status | `error` | Status | `error` | | ------ | ------------------------------- | ------ | --------------------------------- | | `400` | `BAD_REQUEST` | `421` | `MISDIRECTED_REQUEST` | | `401` | `UNAUTHORIZED` | `422` | `UNPROCESSABLE_CONTENT` | | `402` | `PAYMENT_REQUIRED` | `423` | `LOCKED` | | `403` | `FORBIDDEN` | `424` | `FAILED_DEPENDENCY` | | `404` | `NOT_FOUND` | `425` | `TOO_EARLY` | | `405` | `METHOD_NOT_ALLOWED` | `426` | `UPGRADE_REQUIRED` | | `406` | `NOT_ACCEPTABLE` | `428` | `PRECONDITION_REQUIRED` | | `407` | `PROXY_AUTHENTICATION_REQUIRED` | `429` | `TOO_MANY_REQUESTS` | | `408` | `REQUEST_TIMEOUT` | `431` | `REQUEST_HEADER_FIELDS_TOO_LARGE` | | `409` | `CONFLICT` | `451` | `UNAVAILABLE_FOR_LEGAL_REASONS` | | `410` | `GONE` | `500` | `INTERNAL_SERVER_ERROR` | | `411` | `LENGTH_REQUIRED` | `501` | `NOT_IMPLEMENTED` | | `412` | `PRECONDITION_FAILED` | `502` | `BAD_GATEWAY` | | `413` | `CONTENT_TOO_LARGE` | `503` | `SERVICE_UNAVAILABLE` | | `414` | `URI_TOO_LONG` | `504` | `GATEWAY_TIMEOUT` | | `415` | `UNSUPPORTED_MEDIA_TYPE` | `505` | `HTTP_VERSION_NOT_SUPPORTED` | | `416` | `RANGE_NOT_SATISFIABLE` | `506` | `VARIANT_ALSO_NEGOTIATES` | | `417` | `EXPECTATION_FAILED` | `507` | `INSUFFICIENT_STORAGE` | | `418` | `IM_A_TEAPOT` | `508` | `LOOP_DETECTED` | | | | `510` | `NOT_EXTENDED` | | | | `511` | `NETWORK_AUTHENTICATION_REQUIRED` | The framework’s own `413` uses `BODY_TOO_LARGE`, not the default `CONTENT_TOO_LARGE`. ## Throwing | Export | Signature | | | ----------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `HttpError` | `new HttpError(status: number, body?: unknown)` | answered with `status`; no body gives the envelope, a string replaces the envelope’s `message`, any other value is sent as it is | | `httpError` | `httpError(status: number, error?: string, message?: string): HttpError` | an `HttpError` whose body is the envelope | | `errorBody` | `errorBody(status: number, error?: string, message?: string): ErrorBody` | the envelope as a plain object, for a `Response` of your own | | `ValidationError` | `new ValidationError(status: number, issues: readonly ValidationIssue[])` | an `HttpError` with `error: "VALIDATION_FAILED"` and `issues` | ```ts new HttpError(404); // { "status": 404, "message": "Not Found", "error": "NOT_FOUND" } new HttpError(403, "Not your order"); // { "status": 403, "message": "Not your order", "error": "FORBIDDEN" } httpError(409, "ALREADY_SHIPPED", "Order already shipped"); // { "status": 409, "message": "Order already shipped", "error": "ALREADY_SHIPPED" } Response.json({ ...errorBody(429, "RATE_LIMITED"), retryAfter: 30 }, { status: 429 }); // { "status": 429, "message": "Too Many Requests", "error": "RATE_LIMITED", "retryAfter": 30 } ``` An `HttpError` has `status`, `body`, and a `message` for logs. A `ValidationError` adds `issues`. ## Reported failures | Export | Signature | | | ----------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `ResponseContractError` | `new ResponseContractError(message: string, issues?: readonly unknown[])` | what `reportError` receives with `source: "response"` when a handler breaks its response contract | | `reportFailure` | `reportFailure(ctx: BaseCtx, source: FailureSource, error: unknown): void` | sends a report to the application’s `reportError`, or prints it when there is none | A report is `{ source, error, ctx? }`. The framework’s sources are `unhandled`, `response`, `onError`, `errorResponse`, `afterResponse`, `websocket`, `stream` and `shutdown`, and a package may add its own. See [Errors](/docs/concepts/errors/#failures-that-cannot-become-a-response). Without a `reportError`, each report is printed on `console.error` with a `[tetsu]` prefix. # Hook slots > The lifecycle slots in order, what ctx holds in each, what a return value and a throw do, the hook factories, Requires, and the checks. A hook is a function bound to one slot of the request lifecycle by a `hook.()` factory. This page lists the slots, the factories and the checks. [Lifecycle hooks](/docs/concepts/lifecycle-hooks/) explains the model. ```plaintext beforeParse → parse → beforeValidation → validate → beforeHandle → handler → beforeResponse → afterResponse (onError on failure) ``` ## Slots | Slot | Runs | `ctx` has, on a route | A return value | A throw | | ------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- | | `beforeParse` | first, before the body is read | `req`, `server`, `out`, `route`, `startedAt`, `params` as strings | an object joins `ctx`; a `Response` ends the request; nothing goes on | goes to `onError` | | `beforeValidation` | after the body is parsed, before any schema runs | the above, `body` as parsed, `rawBody` when the route asks | as `beforeParse` | goes to `onError` | | `beforeHandle` | after validation, right before the handler | the above, with `params`, `query`, `headers`, `cookies` and `body` validated | as `beforeParse` | goes to `onError` | | `beforeResponse` | once the response exists: after the handler, a short-circuit or an error | `res: Response`; validated parts and hook fields optional | a `Response` replaces `ctx.res`; anything else is ignored | goes to `onError`; the remaining hooks run on the error response | | `afterResponse` | as the response goes to Bun, on every outcome | `res: SentResponse`, without the body; validated parts and hook fields optional | ignored; a promise is not awaited | reported, `source: "afterResponse"` | | `onError` | when a stage throws | `error: unknown`; validated parts and hook fields optional | a `Response` answers; nothing passes the error on | reported, `source: "onError"`; the next hook tries | `parse` reads the body when the route declares `schema.body`, `bodyType` or `rawBody`, and fails with `400` or `413`. `validate` checks every declared part at once and fails with `422`. * **Order.** Slots run in lifecycle order, whatever order they are written in. Inside a slot, the array order is the run order. Application hooks run first, then each group’s from the outermost in, then the route’s. `onError` runs the other way, from the route outwards. * **Short-circuit.** A `Response` returned before the handler skips the remaining stages and the handler. It still goes through `beforeResponse`, `ctx.out.headers` and `afterResponse`. Build a new one on every call: a body can be read only once. * **`beforeResponse`** sees every response, error responses included. Each hook runs at most once per request: if one throws, the error response continues from the next hook. * **`afterResponse`** hooks start in order without waiting for each other. Their synchronous part adds to the response’s latency. Read what you need from `ctx.req` and `ctx.res` before the first `await`. * **`404`, `405` and `OPTIONS`** run the application’s hooks only. * **A successful WebSocket handshake** runs no response hooks: there is no response. ## What a slot guarantees `SlotBases[slot]` is what an unannotated hook’s `ctx` is typed as. It is also the most a group or application hook may require, besides what the hooks before it at the same level contribute. | Slot | `SlotBases[slot]` | | ------------------ | ------------------------------------------------------------- | | `beforeParse` | `BaseCtx & { params: Record }` | | `beforeValidation` | `BaseCtx & { params: Record; body: unknown }` | | `beforeHandle` | `BaseCtx` | | `beforeResponse` | `BaseCtx & { res: Response }` | | `afterResponse` | `BaseCtx & { res: SentResponse }` | | `onError` | `BaseCtx & { error: unknown }` | `BaseCtx` is described in [Context fields](/docs/reference/context/). ## Factories `hook` has one factory per slot: `hook.beforeParse`, `hook.beforeValidation`, `hook.beforeHandle`, `hook.beforeResponse`, `hook.afterResponse` and `hook.onError`. Each takes a function of `ctx` and returns a hook typed with what it requires (its `ctx` parameter, or the slot’s base when unannotated) and what it contributes (its return type). Only a hook made by a factory can be mounted. ```ts const auth = hook.beforeParse(async (ctx) => { const user = await sessions.verify(ctx.req.headers.get("authorization")); if (!user) throw new HttpError(401); return { user }; }); route({ method: "GET", path: "/me", hooks: { beforeParse: [auth] }, handler: (ctx) => ctx.user, }); ``` What a returned object contributes: * its own enumerable fields, typed in every later hook and in the handler. Return a plain object literal: a class instance’s methods and getters live on its prototype and are not copied; * optional fields when the hook may also return nothing (`return user ? { user } : undefined`); * never `req`, `server`, `out`, `route`, `res`, `error`, `startedAt` or `rawBody`: the pipeline owns them and drops them from a returned object. A `Response` contributes nothing; it short-circuits instead. An `onError` hook returns a `Response` or nothing; returning an object is a compile error. A returned `cookies` object is checked like the request’s cookies: under a signed name, a value whose signature does not hold is dropped. ## `Requires` ```ts type Requires = BaseCtx & T; ``` A reusable hook declares what it needs instead of where it sits. Mounting it where nothing provides that is a compile error naming the missing field: ```ts const withOrder = hook.beforeHandle( async (ctx: Requires<{ params: { id: string }; user: User }>) => { const order = await orders.find(ctx.params.id); if (!order || order.ownerId !== ctx.user.id) throw new HttpError(404); return { order }; }, ); route({ method: "POST", path: "/orders/:id/cancel", hooks: { beforeParse: [auth], beforeHandle: [withOrder] }, handler: (ctx) => ({ cancelled: ctx.order.id }), }); ``` ## Checks At compile time, where the hooks are mounted: | Error | When | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `HookSlotError` | a hook sits in a slot other than its own, its type was widened to `AnyHook`, or a function is not wrapped by a `hook.*` factory | | `HookRequirementError` | a hook requires a field nothing before it provides; the message names it | | `HookStackError` | a slot holds a widened array instead of a tuple, such as `const shared = [auth]` without `as const` | | `HooksIndexError` | `hooks` is typed with an index signature, so its slots cannot be checked | What `createApp` refuses at startup is listed in [`createApp`](/docs/reference/create-app/#checked-at-startup). ## Types | Type | | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `Hook` | a hook: its slot, what it requires, what it contributes | | `AnyHook` | any hook | | `SlotName` | `"beforeParse" \| "beforeValidation" \| "beforeHandle" \| "beforeResponse" \| "afterResponse" \| "onError"` | | `SlotBases` | what each slot guarantees on its own | | `SentResponse` | `Response` without its body | | `HooksConfig`, `GroupHooks` | hooks keyed by slot | | `MergedHooks` | the chains of a route table entry, every slot present | | `HandlerCtx`, `ResponseCtx`, `ErrorCtx` | the context of a route’s handler, response-slot hook, `onError` hook | # route > The fields of route() and ws(), the schema parts and the response map, what is refused, and the signatures of controller() and group(). The functions that declare what an application serves: `route()` for an HTTP endpoint, `ws()` for a WebSocket endpoint, `controller()` to name a set of them and give them dependencies, and `group()` to mount them under a prefix with hooks. ## `route` ```ts function route(config: RouteConfig): RouteDef ``` ```ts const Order = z.object({ id: z.number(), sku: z.string(), qty: z.number() }); route({ method: "POST", path: "/orders", schema: { body: z.object({ sku: z.string(), qty: z.number().int().min(1) }), response: { 201: Order }, }, maxBodySize: 64 * 1024, docs: { summary: "Place an order", tags: ["orders"] }, handler: (ctx) => { ctx.out.status = 201; return orders.create(ctx.body); }, }); ``` | Field | Type | Default | | | ------------- | ------------------------------------------------- | ----------------- | --------------------------------------------------------------- | | `method` | `"GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE"` | required | `HEAD` and `OPTIONS` are answered by every path itself | | `path` | string literal | required | the path, `:param` segments included | | `handler` | `(ctx) => result` | required | `ctx` is inferred; never annotate it | | `schema` | `SchemaConfig` | none | request parts and the response, [below](#schema) | | `hooks` | hooks keyed by slot | none | the route’s own hooks, see [Hook slots](/docs/reference/hooks/) | | `bodyType` | `"json" \| "form" \| "text" \| "stream"` | `"json"` | how the body is read | | `maxBodySize` | `number` | the application’s | this route’s body limit in bytes | | `rawBody` | `boolean` | `false` | keep the body’s bytes as `ctx.rawBody` | | `docs` | `RouteDocs` | none | documentation only, [below](#docs) | `route()` returns a `RouteDef`: the configuration, branded. Its `handler` keeps its inferred type, so a unit test can call it with `testCtx()`. `isRoute(value)` tells a `RouteDef` from anything else. ### `path` * starts with `/`; no empty segment (`//`); no trailing `/`, except the root path `/`; * a parameter is `:name` and fills a whole segment; * `*` only as the whole last segment; * `{id}`, `?` and a bare `:` are refused, not matched literally. A literal path is checked at compile time, and every path is checked again when `route()` runs. ### `schema` Every part is optional. A part without a schema is not validated. Without one, `params` is still on `ctx` as strings and a body the route reads as parsed; the other parts are not on `ctx`. | Part | Validates | Adds to `ctx` | | ---------- | -------------------------------------------- | ----------------------------------------------------------------------------- | | `params` | path parameters, as strings | `params` as the schema’s output; without a schema, `params` holds the strings | | `query` | the query string; a repeated key is an array | `query` | | `headers` | request headers, with lower-case names | `headers` | | `cookies` | the `cookie` header, signed cookies opened | `cookies` | | `body` | the parsed body | `body` | | `response` | the result | nothing; it limits what the handler may return and `ctx.out.status` | Parts are validated in the order `params`, `query`, `headers`, `cookies`, `body`. All their issues are collected into one `422`, or the configured status. Any [Standard Schema](https://standardschema.dev) validator works. `response` is a single schema, or a map by status: | Map value | Means | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | a schema | the body of that status | | `null` | the status has no body | | `{ body?, headers?, cookies?, contentType? }` | the body, the headers and cookies the response carries, and the media type of a body that is not JSON; without `body` or `contentType`, no body | With a map, the handler may set only a declared status. With `validateResponses` on, a response that leaves with an undeclared status is a `500`. `default` and a range such as `4XX`, OpenAPI’s own keys, are taken for the document and declare no status. Any other key that is not a status, an entry key other than `body`, `headers`, `cookies` and `contentType`, or a `contentType` other than a bare type or range makes `route()` throw. See [Responses](/docs/concepts/responses/#the-response-map). ### What the handler may return | Return | Result | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `undefined` | `204`, or `ctx.out.status`. Under a status whose `contentType` is not JSON, a compile error, or a `500` when responses are validated | | a `Response` | sent unchecked; `ctx.out.status` is ignored | | another value | JSON, checked by the response schema of its status; `200`, or `ctx.out.status`. Under a status whose `contentType` is not JSON, a compile error, or a `500` when responses are validated | | a `ReadableStream`, generator or async iterable | compile error; `500` at runtime | With `response`, the value must be the schema’s output or a `Response`. With a map, it may be any declared JSON body, or `undefined` when a status has no body. A status whose `contentType` is not JSON takes a `Response` only. ### Body The body is read only when the route declares `schema.body`, `bodyType` or `rawBody`. The route decides how it is parsed; the `content-type` header is ignored. | `bodyType` | `ctx.body` before validation | Unparsable | | ---------- | ------------------------------------------------------- | -------------------- | | `"json"` | `unknown` | `400 MALFORMED_JSON` | | `"form"` | `Record` | `400 MALFORMED_FORM` | | `"text"` | `string` | never | | `"stream"` | `ReadableStream`, counted against the limit | never | Two combinations are compile errors: * `bodyType: "stream"` with `schema.body`: a stream cannot be validated; * `rawBody: true` with `"form"` or `"stream"`: a form’s bytes are not kept, and a stream is already the raw body. `route()` also throws on this one at runtime. ### `docs` `docs` takes `summary`, `description` and `tags`, plus: * `deprecated`: the route stays in the document, marked deprecated; * `hidden`: `true` leaves the route out of the document, still served; `false` keeps it in when its handler was [annotated](/docs/packages/openapi/#documenting-a-handler) to hide it; * `operationId`: replaces the default, the controller’s name joined with the field’s. Only [`@tetsujs/openapi`](/docs/packages/openapi/) reads `docs`. It has no runtime effect. ## `ws` ```ts function ws(config: WsConfig): WsDef ``` | Field | Type | | | --------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `path` | string literal | the handshake’s path, checked by the same rules as a route’s: a literal at compile time, every path when `ws()` runs | | `schema` | `{ params?, query?, headers?, message? }` | the handshake’s parts, and every text frame | | `hooks` | hooks keyed by slot | run for the handshake | | `docs` | `{ summary?, description? }` | for readers only; socket endpoints are not in OpenAPI | | `open` | `(socket) => unknown` | the socket is open | | `message` | `(socket, message) => unknown` | a frame arrived: the schema’s output, or `string \| Buffer` without `schema.message` | | `invalid` | `(socket, issues) => unknown` | a frame failed `schema.message`; without this handler the socket closes with `1007` | | `close` | `(socket, code, reason) => unknown` | the socket closed | | `drain` | `(socket) => unknown` | the socket is writable again after backpressure | | `ping` | `(socket, data) => unknown` | a ping frame arrived | | `pong` | `(socket, data) => unknown` | a pong frame arrived | | `until` | `AbortSignal \| (() => AbortSignal \| undefined)` | closes the endpoint’s sockets with `1001` when it fires | `socket` is Bun’s `ServerWebSocket`. Its `data` is the handshake’s context without `req`, `server`, `out`, `route` and `startedAt`. A handler’s failure is reported with `source: "websocket"`. A plain `GET` on the path is a `426`. `isWs(value)` tells a `WsDef` from anything else. See [WebSockets](/docs/concepts/websockets/). ## `controller` ```ts function controller(name: string, build: (...deps) => routes): (...deps) => routes ``` Returns `build`, named. Calling it with the dependencies gives an object whose route and `ws()` fields are the controller’s endpoints. ```ts export const notesController = controller("Notes", ({ notes }: { notes: NotesRepo }) => ({ list: route({ method: "GET", path: "/notes", handler: () => notes.all() }), })); type NotesDeps = Parameters[0]; ``` * An empty `name` throws. * Two controllers of one application may not share a name: their `operationId`s would collide. `createApp` refuses it. * `Parameters[0]` is the dependencies’ type. * An object not built by `controller()` is mounted too. It is named after its class, or unnamed if it is an object literal. ## `group` ```ts function group( prefix: string, config: { hooks?: GroupHooks; children: readonly object[] }, ): GroupNode ``` Mounts `children` (controllers, routes, `ws()` endpoints, groups) under `prefix`, and runs `hooks` for every route below. * `prefix` starts with `/`, is not `/` itself, and has no trailing `/`, no `//`, no `:param`, no `*`, and none of `{`, `}`, `?`. A literal prefix is checked at compile time, and every prefix when `group()` runs. * A group hook may require only what its slot guarantees and what the group’s earlier hooks contribute. What it contributes is not typed in handlers. * A group’s hooks do not run for a `404`, a `405` or an `OPTIONS` request. * `isGroup(value)` tells a `GroupNode` from anything else. See [Groups and mounting](/docs/concepts/groups-and-mounting/).