Tibi

Server routes and responses

tibi serve runs request-time JavaScript: API routes under pages/api/, and page frontmatter that can redirect, set the status, add headers and set cookies. Both run in a pool of long-lived node workers instead of a fresh process per evaluation.

API routes

Any .ts, .mts, .js or .mjs file under pages/api/ is an endpoint, mapped like pages (pages/api/notes/[id].ts serves /api/notes/:id). Files starting with _ and .d.ts files are helpers, not routes. Export a handler per method:

// pages/api/notes/index.ts import { load, save } from '../../../lib/notes.ts'; export async function GET() { return load(); // 200, JSON } export async function POST(ctx) { if (!ctx.user) ctx.error(401, 'Sign in first'); const text = String(ctx.body?.text ?? ''); if (!text) ctx.error(422, 'Text is required', { field: 'text' }); ctx.setStatus(201); return save(text); }

Handlers: GET, POST, PUT, PATCH, DELETE, then ALL or default as a catch-all. HEAD falls back to GET. A method with no handler answers 405 with an Allow header.

The ctx argument

  • method, url, path, origin, params, query, headers (lower-cased), cookies, user (see Auth).
  • body: parsed JSON (application/json), an object for form posts (application/x-www-form-urlencoded), else the raw text. text always holds the raw body. Bodies are capped at 10 MiB.
  • setStatus(n), setHeader(name, value), setCookie(name, value, opts), deleteCookie(name), redirect(to, status = 302).
  • error(status, message, data?) throws; the response is { "error": message, "data": data } with that status.
  • json(data, init) is Response.json.
  • tether: a server-side Tether client when the TIBI_TETHER_* variables are set (see Tether); also on frontmatter's ctx.

Return values

  • An object or array: JSON. A string: text/plain. undefined: 204 (or the redirect).
  • A web Response: used as-is (status, headers, multiple Set-Cookie, binary bodies).
  • Any other thrown error: 500 {"error":"Internal Server Error"}, with the stack logged to the server's stderr.

Cookie options: path (default /), domain, maxAge (seconds), expires, httpOnly (default true), secure (default true on https requests), sameSite (lax default, strict, none).

With esbuild in the project (npm i -D esbuild), each module is bundled for node into .tibi/server/, which handles TypeScript and relative imports; npm packages stay external and load from node_modules. Without it, the file is imported directly (node strips TypeScript types itself from 22.18). In dev mode any file change re-bundles on the next request. Several workers serve requests, so keep shared state in a database (or on disk), not in module variables. API routes aren't part of tibi build.

Response controls from frontmatter

Page and layout frontmatter (JS / TS) get the same helpers on ctx:

---js const session = ctx.route?.cookies?.session; if (!session) { ctx.redirect('/login'); // 302, page body never renders return {}; } ctx.setHeader('x-robots-tag', 'noindex'); ctx.setCookie('last_seen', new Date().toISOString(), { maxAge: 86400 }); result = { title: 'Dashboard' } ---

Other languages set the reserved keys directly: __status (number), __redirect ("/path" or { to, status }), __headers (object) and __cookies ({ name: "value" } or { name: { value, ...options } }). They never become template tokens.

  • A page that redirects skips its layouts entirely. A layout can redirect too (an auth guard in _layout.tibi); it runs after the page's frontmatter.
  • The page's status and redirect win over a layout's; headers and cookies from every layer add up.
  • Responses that set cookies get Cache-Control: private, no-store unless a header says otherwise.
  • In tibi build a redirecting page becomes a meta-refresh stub.
  • ctx.route is only set while rendering a request. Boot-time scans and builds evaluate frontmatter without it, so use ctx.route?.cookies.

The worker pool

JS and TS frontmatter, and every API route, run in a small pool of persistent node processes (default min(4, cores)). Each worker handles many requests concurrently, so a slow await fetch() in one render doesn't block others. Rendering itself runs on a blocking thread pool, off the async server threads. Within one render, a component's frontmatter runs once per distinct context, however many times the component appears. console.log in user code goes to the server's stderr. Compiled Rust frontmatter binaries are cached by source, so rustc runs once per change rather than per request.

Environment variables:

  • TIBI_JS_WORKERS: pool size.
  • TIBI_JS_TIMEOUT_MS: per-call timeout (default 15000). A worker that misses it is killed and replaced, so an infinite loop costs one request, not the server. API routes answer 504.
  • TIBI_JS_POOL=0: go back to one node process per evaluation.