ch3 generated-dispatch contract (`src/gen.ts`)
ch3 generated-dispatch contract (src/gen.ts)
Status: frozen — this is the seam between @sigilco/ch3 (route table +
ctx machinery) and build-time code generators (the Vite/unplugin transform
agent emits modules satisfying this contract). v0.2.0, 2026-10-11.
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 — ch3’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 '@sigilco/ch3'
// 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, or
req.on(...)listeners are committed. Returningfalseafter
setHeader/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 intoApp._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 (): void { handler(c) })
// Guard: wrap synchronous handler execution with the standard 500 path.
_guard(res, function (): void { 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: () => void) => void |
data/end + _setBody + guarded k |
_guard |
(res, k: () => void) => void |
try { k() } catch → 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.length
are 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/:
- Sync only. No
Promise/asyncon the 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
number
indices into a class-sideHandler[]or emit staticif/switch
dispatch (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 via
req.on('data', (c: Buffer) => …)+toString()+join, or just call
_buffer.import type/.tsspecifiers /for…ofover arrays /instanceof
vs 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 @sigilco/ch3 peer ranges accordingly.