# chttp Documentation - Full Context > Generated by docmd --- ## [Coding agents](https://chttpjs.pages.dev/architecture/) # Coding agents Read this before writing or changing chttp code. The core is authored in the scriptc compilable subset, so some normal TypeScript is banned. ## Give an agent the docs These docs are published for agents too: [llms.txt](https://chttpjs.pages.dev/llms.txt) is the page index, [llms-full.txt](https://chttpjs.pages.dev/llms-full.txt) is every page in one file. Point your agent at either. ## Subset rules for `packages/router/src` - `| null` unions only. Never `undefined`. - No `Object.create(null)`, `Object.freeze`. Plain `({})` records only. - Classes only for the router's entity types (`CtxCore`/`Ctx`, `ReqViewCore`/`ReqView`, the response sinks, `HttpError`). - Indexed `for` loops or `for...of` over arrays. Never `for...in`. - No `new Function`, `eval`, non-program `instanceof`, object or method shorthand. - Concrete types on route and middleware tables. No `any[]` or `unknown[]`. - Never detect promises with `typeof r.then` or `instanceof Promise`. Dispatch settles non-settled results by `await` inside `settleChain`. ```ts // ok for (let i = 0; i < routes.length; i++) {} const rec: { [k: string]: string } = {}; // banned for (const k in rec) {} const fns = { run: function (): void {} }; ``` ## Shape of the code - One core file, `packages/router/src/index.ts`: router, context, dispatch, the Node-shaped `app.handler()` adapter, and the Web Standards-shaped `app.fetch()`. - A second core file, `packages/router/src/sink.ts`: the `ResponseSink` abstraction plus `NodeResponseSink` (writes to `ServerResponse`) and `FetchResponseSink` (captures into a Web `Response`). `Ctx` holds a `ResponseSink`; both surfaces route through the same chain. - Route data is plain records. Handlers live in `App._fns`, nodes carry indices. - Matching uses an explicit frame stack, no recursion. - Response helpers call the sink at statement level. `app.fetch()` finalizes the sink to a `Response` in the chained promise; `app.handler()` finalizes by writing to `res.end()`. ## Where to read more - [ARCHITECTURE.md](https://github.com/sigilco/chttp/blob/main/ARCHITECTURE.md): request flow, compose model, error path, scriptc compilation. - [AGENTS.md](https://github.com/sigilco/chttp/blob/main/AGENTS.md): commands, release flow, full ban list. - Repo skills: [`chttp-app-dev`](https://github.com/sigilco/chttp/blob/main/.agents/skills/chttp-app-dev/SKILL.md) for writing subset-compatible apps, [`chttp-integration`](https://github.com/sigilco/chttp/blob/main/.agents/skills/chttp-integration/SKILL.md) for wiring chttp and the plugin into an existing project. - Build-time lowering: [Route lowering plugin](unplugin.md). --- ## ['@chttp/auth](https://chttpjs.pages.dev/auth/) # @chttp/auth Auth primitives for chttp apps — sessions, cookies, scrypt passwords, CSRF, JWT, OAuth2+PKCE and TOTP — as plain async functions over `node:crypto`, authored in the scriptc-compilable subset. The same source builds to a JS package with `tsc` and lowers into a native ELF with `scriptc build`. Zero runtime dependencies. ```ts import { MemorySessionStore, buildSessionCookie, createSession, generateSessionToken, readCookie, validateSessionToken, } from "@chttp/auth"; ``` ## What it does - **Sessions (Lucia pattern)** — a 384-bit `id.secret` bearer token, `hashToken` SHA-256 store key (the only thing persisted), `createSession`, `validateSessionToken` with sliding expiry, `invalidateSession`/`invalidateAllUserSessions`. The `SessionStore` contract is sync-or-async — stores may return values or Promises; auth `await`s either way (one microtask on sync stores). `MemorySessionStore` is dev/test only, **not for production**. - **Cookies** — `serializeCookie`/`readCookie` plus `buildSessionCookie` (HttpOnly, Lax, Secure, Max-Age bound to TTL), `buildExpiredSessionCookie` for logout, `buildCsrfCookie` (not HttpOnly by design — double-submit needs a JS read). - **Passwords** — `hashPassword`/`verifyPassword` in the self-describing `scrypt:N:r:p:salt:hash` format with OWASP defaults; parameter upgrades don't invalidate old hashes, and verify dummy-hashes on malformed rows to flatten timing. - **CSRF** — `generateCsrfToken`, `verifyCsrfToken` (constant-time compare), `verifyRequestOrigin` (Origin allowlist) — the two halves of the pattern. - **JWT** — `signJwt`/`verifyJwt`/`verifyJwtWith`, JWS compact, HS256. Verify is null-on-invalid for every failure and `none` is hard-rejected; `verifyJwtWith` parses claims through any [Standard Schema](https://standardschema.dev) validator (`@chttp/val`, valibot, zod). - **OAuth2 + PKCE** — `generateState`, `generateCodeVerifier`, `codeChallenge`, `createAuthorizationURL`, `exchangeCodeForTokens` (form POST + basic auth; JSON and form-urlencoded responses both parse), `fetchUserInfo`/`fetchUserInfoWith`. `googleProvider`, `githubProvider`, `discordProvider` presets; a new provider is a 15-LoC record in user code. - **TOTP** — `generateTotpSecret` (base32, RFC 4648), `totpAuthUri` (otpauth:// enrollment URI), `verifyTotp` (HMAC-SHA1, 6 digits, 30 s step, ±1 window, constant-time). ## Recipe: API keys No dedicated module is needed — an API key is a session token whose record carries scopes and a far-future expiry. Only the SHA-256 hash is ever stored, so a database leak doesn't leak usable keys. ```ts import { createSession, generateSessionToken, hashToken, invalidateSession, validateSessionToken, } from "@chttp/auth"; // Provisioning: show `apiKey` to the caller exactly once. const TEN_YEARS = 10 * 365 * 24 * 60 * 60; const apiKey = generateSessionToken(); // id.secret bearer token (~64 chars) await createSession(store, apiKey, ownerUserId, { scopes: ["read", "deploy"] }, { ttlSeconds: TEN_YEARS, }); // Per request: "Authorization: Bearer ". extensionThresholdSeconds: 0 // keeps the record's expiry fixed — API keys shouldn't slide on every call. const key = bearer(c.req.header("authorization")); const rec = key === null ? null : await validateSessionToken(store, key, { extensionThresholdSeconds: 0 }); if (rec === null) return c.status(401).text("unauthorized"); const scopes = rec.attributes.scopes as string[]; if (scopes.indexOf("deploy") < 0) return c.status(403).text("forbidden"); // Revocation is the same as sessions: await invalidateSession(store, leakedKey); ``` Use `store.get(hashToken(key))` directly instead of `validateSessionToken` when you want to skip the expiry check entirely and manage revocation/rotation yourself; stamp `lastUsedAt` into `rec.attributes` on a throttle if you need audit fields. ## Status v0.1.0. ES256 is recognized but unlowerable under scriptc 0.3.3 (no asymmetric `node:crypto` lowering): `signJwt` throws `E_BAD_ALG`, `verifyJwt` returns null, and the `string | KeyObject` signature is kept for the upstream drop. JWE, JWKS, EdDSA, WebAuthn and Argon2 are out of scope by design — see the package AGENTS.md and the plan in `.agents/plans/`. --- ## [Benchmarks](https://chttpjs.pages.dev/benchmarks/) # Benchmarks Shared VM, compare ratios not absolutes. Full matrix, methodology, ceilings (go-chi / axum / mongoose), and a one-command Docker repro: [bench/RESULTS-v2.md](https://github.com/sigilco/chttp/blob/main/bench/RESULTS-v2.md). | subject | json | pipelined (-c100 -p10) | cold start | RSS after 100k req | | ------------------ | -------------- | ---------------------- | ---------- | ------------------ | | chttp on node | 0.87× raw-node | 0.95× | 83 ms | 116 MB | | chttp compiled (ELF) | 1.15× raw-node | 1.76× (204k rps) | 3 ms | 4 MB | | fastify on node | 0.69× | 0.65× | 111 ms | 108 MB | The compiled build sits at ≥96% of a bare compiled `node:http` server on every cell: the router costs ~0.1-0.3 MB of binary and ~1 MB RSS. --- ## [Typed client](https://chttpjs.pages.dev/client/) # Typed client `@chttp/router/client` ships a typed HTTP client for chttp apps. The same source compiles into your ELF — transport is HTTP/1.1 over `node:net` / `node:tls`, so it runs on Node.js and native with no `fetch` dependency. ```ts import { createClient } from "@chttp/router/client"; interface Api {} // your app's surface, for future manifest typing const client = createClient("http://localhost:3000"); const user = await client.get<{ id: string }>("/users/:id", { params: { id: "42" }, }); ``` ## Calls `get` / `post` / `put` / `patch` / `delete` take a path and an optional input: ```ts interface ClientInput

{ params?: Record, string>; // '/users/:id' -> { id: string } query?: Record; body?: unknown; // string -> text/plain, else JSON.stringify headers?: Record; } ``` - `:param` segments and `*` wildcards are substituted from `params` (URI-encoded). Missing params throw before the wire. - `query` pairs are appended URI-encoded. - Return type is caller-declared: `client.get(...)`. JSON responses (by `content-type`) are parsed; everything else resolves to the body string. - `client.request(method, path, input)` returns `{ status, headers, body }` for full control. `createClient(baseUrl, opts?)` accepts `{ timeoutMs?, headers? }`; base URLs may include a port and path prefix. `https:` URLs use `node:tls` with certificate verification on. v1 scope: one request per connection (`Connection: close`), no redirects, no streaming bodies. `Transfer-Encoding: chunked` responses are decoded. ## Compiling to ELF The typed signatures are a `.d.ts` surface — scriptc dispatches generic methods only through the concrete class. In app code that compiles to native, bind the receiver as `ClientCore`: ```ts import { createClient } from "@chttp/router/client"; import type { ClientCore } from "@chttp/router/client"; const client: ClientCore = createClient("http://127.0.0.1:3000"); const user = await client.get<{ id: string }>("/users/:id", { params: { id: "42" } }); ``` `params` then types as `Record` — path keys aren't checked inside the compiled subset. The non-generic `request` works through either receiver. Coverage is gated by `bench/apps/chttp-client.ts` (100% static) and the loopback ELF smoke in `packages/router/test/elf.test.sh`. --- ## [Context](https://chttpjs.pages.dev/context/) # Context Handlers and middleware receive `c`. Request access is synchronous: the body is pre-buffered, so `c.req` never awaits. ## Request ```ts c.req.param("id"); // string | null, percent-decoded c.req.query("q"); // string | null c.req.queries("tag"); // string[], all values for a repeated key c.req.queryAll(); // Record c.req.header("x-foo"); c.req.body(); // string c.req.file("f"); // UploadedFile | null — multipart routes only c.req.field("f"); // string | null — multipart routes only c.req.method; c.req.path; c.req.raw; ``` ## Response Helpers write at statement level and return `c` for chaining. After one runs, the response is sent, so set headers first. ```ts c.json({ ok: true }); c.text("plain"); c.html("

hi

"); c.bytes(new Uint8Array([0xff, 0xd8]), "image/jpeg"); // binary body c.status(201).json({ created: true }); c.header("x-trace", "abc"); c.redirect("/elsewhere", 302); ``` Next: [Errors](errors.md). Request bodies and the `{ body: true }` flag: [Middleware](middleware.md). --- ## [Deploy](https://chttpjs.pages.dev/deploy/) # Deploy Pick one of three ways to ship a chttp app. | Option | Ships | Image or artifact | Cold start | | ----------------------- | --------------------------- | ----------------- | ---------- | | Docker (`FROM scratch`) | one static binary | ~2.4 MB | unverified | | Binary only | one static binary | ~2.4–2.6 MB | unverified | | Node | your JS plus `node_modules` | node runtime | 83 ms | Binary sizes measured on macOS arm64 with router 0.3.0 and scriptc 0.3.4: the `examples/hello` app builds to 2,558,232 B, or 2,407,680 B with `--strip` (RSS ~3 MB). The `chttp-scriptc` bench cell ([`bench/RESULTS-v2.md`](../bench/RESULTS-v2.md)) measured a 3 ms median cold start for its own app, but per-app cold start was not re-measured for this release — treat it as unverified until the cell is re-run. ## Docker Copy [`examples/deploy-docker/Dockerfile`](https://github.com/sigilco/chttp/blob/main/examples/deploy-docker/Dockerfile) next to your app. It builds the binary in an alpine stage and copies it into `scratch`. ```sh docker build -t my-app . docker run --rm -p 8080:8080 my-app ``` The image has no shell, so debug with a different base if you need one. ## Binary only Build, copy the file, run it. No runtime needed on the host. ```sh scriptc build src/main.ts -o app --strip ./app ``` scriptc cannot cross-compile (no `--target`), so build on the OS and architecture you deploy to. For a linux server, build in CI on linux or inside an alpine container. An alpine build is fully static (musl), so it runs on any linux of the same architecture. ## Node No build step. Deploy the JS like any node service: ```sh node app.js ``` Use this when you need npm packages outside the scriptc subset. See [Run targets](targets.md). ## Web Standards adapter The canonical entry is `app.fetch(req: Request) → Promise`. The same `createRouter()` symbol works on Cloudflare Workers, Deno Deploy, Bun, Vercel Edge, Netlify Edge, Supabase, and as a Service Worker. `app.handler()` is a thin Node adapter around `fetch()` for `node:http` users. ```ts import { CtxCore, createRouter } from "@chttp/router"; const app = createRouter(); app.get("/user/:id", (c: CtxCore) => c.json({ id: c.req.param("id") })); // Cloudflare Workers / Deno Deploy / Bun / Vercel Edge / Service Worker export default { async fetch(req: Request, env: unknown, ctx: unknown): Promise { return app.fetch(req, env, ctx); }, }; ``` The Node `app.handler()` surface compiles to a native ELF via `scriptc build`. `app.fetch` itself is JS-runtime-only today — `new Request` is not constructible inside the binary under scriptc 0.3.x — but the fetch dispatch path is ELF-verified through the [`app.fetchShim`](targets.md#native-elf) host-shim entry (the `chttp-fetch` gate stays at 100% static coverage). Recipes for the two most common targets: - [`examples/integrations/cloudflare-workers/`](../examples/integrations/cloudflare-workers/) — `wrangler dev` / `wrangler deploy`, `app.fetch` as the Workerd fetch handler. Needs `nodejs_compat` + `enable_nodejs_fs_module` compat flags (the router statically imports `node:http` and `node:fs`); verified by a real-workerd smoke in the recipe's test suite. - [`examples/integrations/aws-lambda/`](../examples/integrations/aws-lambda/) — APIGW v2 / Lambda URL bridge (`provided.al2023` ELF or Node runtime). --- ## [Errors](https://chttpjs.pages.dev/errors/) # Errors ```ts app.onError((err, c) => { c.status(500).json({ message: err.message }); }); ``` An error thrown anywhere in the chain (middleware, handler, response) lands here. Fallback behavior: - No `onError` registered, or the handler never responds, or the handler itself throws: built-in `500 Internal Server Error`. - Headers already sent: socket destroy. The built-in `405` error carries `.allow` (the `Allow` header value), so custom bodies are easy: ```ts app.onError((err, c) => { if ("allow" in err) { c.status(405).header("allow", err.allow).text("method not allowed"); return; } c.status(500).text("boom"); }); ``` --- ## [Examples](https://chttpjs.pages.dev/examples/) # Examples Standalone templates in [`examples/`](https://github.com/sigilco/chttp/tree/main/examples). Each is a normal project: copy the folder, install, build. | Template | Shows | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | [`hello`](https://github.com/sigilco/chttp/tree/main/examples/hello) | Static, param and wildcard GET routes | | [`middleware`](https://github.com/sigilco/chttp/tree/main/examples/middleware) | `app.use` global and per-route middleware | | [`json-api`](https://github.com/sigilco/chttp/tree/main/examples/json-api) | POST body, `onError`, `notFound` | | [`router-mount`](https://github.com/sigilco/chttp/tree/main/examples/router-mount) | `app.route` sub-router mounting | | [`sse-clock`](https://github.com/sigilco/chttp/tree/main/examples/sse-clock) | Server-sent events, see [Streaming](streaming.md) | | [`ws-echo`](https://github.com/sigilco/chttp/tree/main/examples/ws-echo) | `@chttp/ws` echo and room, see [WebSocket](websocket.md) | | [`vite-elf`](https://github.com/sigilco/chttp/tree/main/examples/vite-elf) | Vite with `unplugin-scriptc` | | [`vite-lowering`](https://github.com/sigilco/chttp/tree/main/examples/vite-lowering) | Build-time route lowering, see [Route lowering plugin](unplugin.md) | | [`deploy-docker`](https://github.com/sigilco/chttp/tree/main/examples/deploy-docker) | Scratch image, see [Deploy](deploy.md) | | [`csr-react`](https://github.com/sigilco/chttp/tree/main/examples/integrations/csr-react) | React SPA on `@chttp/static`, baked-assets single binary, see [Static files](static.md) | ```sh cd examples/hello pnpm install pnpm build # scriptc build src/main.ts -o app ./app # ELF listening on :3000 ``` Full index: [examples/README.md](https://github.com/sigilco/chttp/blob/main/examples/README.md). --- ## [HTML rendering](https://chttpjs.pages.dev/html/) # HTML rendering `@chttp/html` renders HTML strings inside the scriptc compilable subset, so server side rendering works in a compiled chttp binary. It is independent of `@chttp/router`: it only produces strings you hand to `c.html(...)`. Names follow [kitajs/html](https://github.com/kitajs/html) where the subset allows; the audit is in `.agents/notes/2026-10-14-kitajs-api-audit.md`. ## API | Export | Signature | Notes | | ------------------------------------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | `escapeHtml` | `(s: string) => string` | Escapes `& < " '` to `& < " '`, same bytes as kitajs. Charcode loop, no regex | | `h` | `(tag: string, attrs: string[], kids: string[]) => string` | `attrs` is a flat `[name, value, ...]` array. Void elements self close | | `createElement` | `(name: string, attrs: string[], ...children: string[]) => string` | kitajs shaped alias of `h` | | `Fragment` | `(kids: string[]) => string` | Siblings without a wrapper | | `html` | tagged template | Escapes interpolations, static text is trusted markup | | `raw` | tagged template | No escaping, for composing already rendered markup | | `BARE` | `string` | Attribute value sentinel for bare boolean attributes | | `isVoidElement`, `isValidTag`, `isValidAttrName` | `(s: string) => boolean` | Exposed helpers | ```ts import { CtxCore } from "@chttp/router"; import { BARE, escapeHtml, h, html } from "@chttp/html"; function Todo(props: { text: string; done: boolean }): string { return h("li", ["class", props.done ? "done" : "open"], [escapeHtml(props.text)]); } app.get("/todos", (c: CtxCore) => c.html( h("ul", [], [Todo({ text: "ship", done: false })]) + h("input", ["disabled", BARE, "value", "x"], []), ), ); ``` ## Escaping model Like kitajs, children are pre-rendered markup and are not escaped by `h`: a string child cannot be told apart from a nested element. Escape text yourself with `escapeHtml`, or write it with the `html` tag, which escapes every interpolation. A `safe` attribute pair escapes the joined children once (`h("p", ["safe", "true"], [userText])`) and is not emitted. Attribute values are always escaped. Tag and attribute names are validated and an invalid name throws, so a user controlled key cannot inject an attribute. Differences from kitajs on purpose: the `html` tag escapes only interpolations (kitajs `escape` escapes the whole string, static text included), attribute values escape `&`, `<`, `"` and `'` (kitajs escapes only `"`), and there are no attribute objects, async children, or function typed element names. ## Escaping footguns > **Warning:** `@chttp/html` is not auto escaping. Anything you pass as a string child is emitted as markup. > > - `h("p", [], [userText])` writes `userText` verbatim, so `