Generated dispatch contract
Generated dispatch contract
Status: frozen — this is the seam between packages/router/src/gen.ts (GenDispatch) and chttp (route table + ctx machinery) and build-time code generators (the Vite/unplugin transform agent emits modules satisfying this contract). v0.3.0, 2026-10-13.
The seam in one paragraph
app.withDispatch(d) registers a generated dispatch object. On every request, app.serve()/app.handler() calls d.dispatch(req, res) first. true = the request is handled (or committed to be handled asynchronously after a body-buffer); false = decline — chttp’s internal specialized dispatch takes over. Routes stay registered on app either way: the generated module is a pure accelerator overlay, never a replacement for the route table.
Module shape
import { GenDispatch } from '@chttp/router'
// Emit a CLASS, not an object literal. Under scriptc's eager
// program-code instantiation a function value inside a record
// instantiates the record as `unknown` and fences every call on it;
// class instances with methods compile cleanly. `extends GenDispatch`
// keeps the assignment nominal for both tsc and scriptc.
export class CompiledRoutes extends GenDispatch {
dispatch(req: IncomingMessage, res: ServerResponse): boolean {
// ... generated matcher ...
}
}
Emitting code then wires it at app construction:
const app = createRouter()
app.withDispatch(new CompiledRoutes())
// app.get(...) calls still run — the route table stays the source of truth.
GenDispatch (from src/gen.ts, re-exported by src/index.ts):
export class GenDispatch {
dispatch(req: IncomingMessage, res: ServerResponse): boolean {
return false
}
}
Dispatch precedence — deterministic
withDispatchmay be called at most once; a second call replaces the previous dispatch object (documented, deterministic — last wins).- Every request calls
dispatchexactly once, before any internal work — including requests the generated module does not recognize. falsemust only be returned before any response bytes, headers, orreq.on(...)listeners are committed. ReturningfalseaftersetHeader/end/body-listeners is a contract violation (undefined behavior, double-response).- Returning
trueafterapp._buffer(...)was invoked is correct even though the response completes later — the commitment is made at listener-attach time. - The internal dispatch never calls generated code; generated code never calls back into
App._dispatch. One direction only.
What generated code may use (the stable surface)
Everything below is a contract member — it will not change shape in 0.x without a major-version note. Members not listed are private.
// Construction: the generated matcher already parses the URL for its own
// matching — pass the pieces through, no double-parse.
const rv = new ReqView(raw, qs, path) // (req, query-string-w/o-?, path-w/o-?)
const c = new Ctx(rv, res)
// Params: after the generated matcher binds a route with params, seed the
// lazy param store with decoded name/value pairs — flat array, pairs of 2.
rv._seedPm(['id', dec(segValue)]) // 'id' → param('id'), '*' for splat
// If nothing is seeded, param() materializes from the entry declared on
// the internal table (only when the internal dispatch ran) — generated
// routes that return true MUST seed their own params.
// Body: ONLY for routes the author declared `body: true` at registration.
app-free helper, attaches 'data'/'end', buffers, then runs k under the
// send500 guard. Returns void; dispatch() must still return true.
_buffer(req, res, c, function (): unknown { return handler(c) })
// Guard: run the handler under the standard 500 path. The closure MUST
// return the chain result — _guard awaits a result that is not already
// settled (`undefined`/`null`/`c` are) so a rejected promise answers 500
// and a settled chain that wrote no response answers 500 too (checked via
// Ctx.sent + res headers).
_guard(res, c, function (): unknown { return handler(c) })
| Export | Signature | Contract |
|---|---|---|
GenDispatch |
class, dispatch(req,res): boolean |
subclass + override |
ReqView |
new (raw, qs, path) |
qs/path already split at ? |
ReqView._seedPm |
(pm: string[]) => void |
flat decoded pairs incl '*' |
ReqView._setBody |
(s: string) => void |
set by _buffer only |
Ctx |
new (rv, res) + json/text/html/status/header/redirect |
unchanged authoring API |
App.withDispatch |
(d: GenDispatch) => App |
last-wins, tried first per request |
_buffer |
(req, res, c, k: () => unknown) => void |
data/end + _setBody + guarded k |
_guard |
(res, c, k: () => unknown) => void |
try { k() }; result not settled → await it; throw/reject/unsent → send500 |
dec |
(s: string) => string |
+→space then decodeURIComponent, fallback raw |
Body buffering — declared-body rule
The request body is buffered only when the route declared body use at registration. This is a hard contract, not a hint:
app.post('/echo', (c) => c.text(c.req.body()), { body: true })
body: true+ a body-carrying method (POST/PUT/PATCH/DELETE) → body is buffered before the handler chain runs;c.req.body()returns it.body: true+GET/HEAD/etc → no buffering (flag ignored for non-carrying methods).- No declaration → no buffering,
c.req.body()returns''— on generated and internal dispatch alike. 404/notFound paths never buffer. - A call-site marker (
ctx.body()auto-detection) is rejected: handler introspection is impossible inside the subset (fn.toString/fn.lengthare SC2020) and would be non-deterministic across the two runtimes. The registration flag is the one way to do it. maxBodyBytesis not implemented in v0.2 — document if needed later.
Rules for emitted code (scriptc subset — same as package internals)
The emitted module compiles into the user’s program, so it lives under the same subset rules as src/:
- Handlers may be async (
Promise-returning). The dispatch seam itself stays synchronous —dispatch(req, res): booleanand the_guard/_bufferclosures return the chain result so the guard can settle it. Rejections inside generated code run the built-insend500;onErroronly intercepts errors on the internal dispatch path. null, neverundefined, in every union and return position.- Class instances (not object literals) for
GenDispatchimpls. - No function values inside records — generated tables hold
numberindices into a class-sideHandler[]or emit staticif/switchdispatch (both compile;switchon method strings and flatifchains are the recommended emit shapes). - Indexed
forloops; nofor…in; no?.(use&&/=== null); no spread calls; nonew Function/eval/Object.create. String(x)/Number(x)coercion at function entry forunknownparams.res.end(...)statement-called. Body accumulation viareq.on('data', (c: Buffer) => …)+toString()+join, or just call_buffer.import type/.tsspecifiers /for…ofover arrays /instanceofvs program classes / class getters / default params all compile clean.
Versioning
The contract majors with the package: any change to a member listed in the stable-surface table bumps minor at minimum and is recorded here. The unplugin agent should pin chttp peer ranges accordingly.
Contract versioning
ROUTE_CONTRACT_VERSION (currently 1) is a plain number exported from chttp (defined in src/gen.ts, re-exported by src/index.ts). It exists so a stale generated module can never silently serve requests against a runtime that expects a different emit shape:
- Lives in chttp. The runtime is the source of truth; the constant is a plain
constnumber, subset-clean. - Stamped into gen files. Emitters write the same number as a literal —
export const ROUTE_CONTRACT_VERSION = 1;in the module header next to the Source comment, andROUTE_CONTRACT_VERSION = 1as an instance field on the emittedCompiledRoutesclass (assigned aftersuper(), shadowing theGenDispatchdefault). The gen file stays standalone: no import of the version, just the literal. - Validated at
withDispatch. If the dispatch object exposes a numericROUTE_CONTRACT_VERSIONthat differs from the runtime’s,app.withDispatch(d)throws immediately, naming both versions. A missing stamp is accepted (dispatches that predate versioning). - Bump semantics. Any breaking change to this contract — member shapes, dispatch obligations, the emitted-module grammar — bumps the number. Bump
chttp’s export and@chttp/unplugin’s mirrored constant (packages/unplugin/src/contract.ts, asserted equal by test) in the same commit; a regenerated module then carries the new stamp.
// runtime (chttp)
import { GenDispatch, ROUTE_CONTRACT_VERSION } from '@chttp/router'
app.withDispatch(new CompiledRoutes(app)) // throws on stamp mismatch
// emitted header (.chttp/routes.gen.ts)
export const ROUTE_CONTRACT_VERSION = 1
export class CompiledRoutes extends GenDispatch {
ROUTE_CONTRACT_VERSION = 1
// ...
}