Route lowering plugin

Route lowering plugin

ch3 lowers to a native ELF with scriptc build directly: the core package is authored in the scriptc compilable subset like any subset library, and no plugin is involved in that step.

@ch3/unplugin (a separate workspace package) is an elective build-time dispatch specialization on top. It statically recovers the route table from your imperative app.get() / app.use() / app.route() registrations and emits a CompiledRoutes dispatch module next to them, so matching requests skip the runtime trie walk. Anything it cannot prove stays on runtime dispatch with a build warning: the plugin bails, it never guesses.

Install:

npm i -D @ch3/unplugin

Full option reference: packages/unplugin/PLUGIN.md.

Two output modes

JS dist (plugin standalone). With plain vite or rollup the plugin works on its own: it emits .ch3/routes.gen.ts, your bundler bundles the app as ordinary JavaScript, and you run it on node. No scriptc anywhere in the pipeline. See examples/vite-lowering.

ELF (compose with unplugin-scriptc). The emitted dispatch is authored inside the scriptc-compilable subset, so it composes with unplugin-scriptc: ch3Plugin lowers routes, scriptcPlugin runs scriptc coverage and scriptc build in the same pipeline. Compiled dispatch requires ch3 as program code (vendored source or workspace layout); see the “Compiling with scriptc” section of PLUGIN.md.

Contract stamping

The emitted module carries a ROUTE_CONTRACT_VERSION stamp (module header constant plus an instance property), mirrored from ch3’s exported ROUTE_CONTRACT_VERSION. app.withDispatch(d) validates the stamp once at startup: a numeric mismatch throws immediately, naming both versions. It means the dispatch was generated against a different route contract than the runtime implements, typically after upgrading one of the two packages; regenerate .ch3/*.gen.ts (upgrade @ch3/unplugin to match ch3, or align ch3). A missing stamp is accepted, covering dispatches generated before versioning existed. Details in route-contract.md.

What the plugin does

At buildStart (and on each watch event for the file) the plugin scans route modules (default **/*.ch3.ts, **/*.ch3.js) and emits .ch3/routes.gen.ts next to them, satisfying the generated-dispatch contract:

  • export class CompiledRoutes extends GenDispatch whose dispatch(req, res) runs per-method sections ordered like the runtime trie (static before param before wildcard, then registration order).
  • routeTable: full-shape literals { method, path, kind, handlerIdx, needsBody }[].
  • Per-route runners: literal send-chains (c.json, c.text, …) compile to straight res writes with no Ctx allocation; ctx routes build ReqView/Ctx through the contract surface and call the composed middleware+handler at app._chains[idx].
  • constructor(a: App) injects the router instance (needed for scriptc lowering; see the scriptc section in PLUGIN.md).

Bail, not guess

The plugin never silently produces wrong routes. Extracted: literal-path app.<method>() calls, app.all/app.on, app.use snapshot semantics, same-file mounts, literal send-chains. Bailed to runtime registration (with a build warning):

  • Non-literal paths (variables, calls, spreads).
  • Conditional or loop-driven registration.
  • Registrations whose handler index cannot be proven against the runtime tables.
  • Mounts of routers imported from other modules.

If a routes module cannot be analyzed at all, the plugin emits a stub whose dispatch always returns false, so the app runs entirely on the runtime path.

Usage (vite)

// vite.config.ts
import { defineConfig } from 'vite'
import { ch3Plugin } from "@ch3/unplugin"

export default defineConfig({
  plugins: [ch3Plugin.vite()],
})

Mark a routes module:

// src/routes.ch3.ts
import { createRouter } from 'ch3'

export const app = createRouter()

app.get('/user/:id', (c) => c.json({ id: c.req.param('id') }))
app.post('/echo', (c) => c.text(c.req.body()), { body: true })

Wire the generated dispatch:

// src/main.ts
import { app } from './routes.ch3.ts'
import { CompiledRoutes } from './.ch3/routes.gen.ts'

app.withDispatch(new CompiledRoutes(app))
app.serve({ port: 3000 })

app.serve() calls the generated dispatch first; false declines to the internal dispatch, which covers bails, notFound, and misses. Body buffering is opt-in per the contract: a handler reading c.req.body() must register with { body: true } or it receives ''.

The factory is unplugin-based, so ch3Plugin.rollup(), ch3Plugin.webpack(), and ch3Plugin.esbuild() work too. Only vite is verified by a fixture (fixtures/vite-app/): run npm i && npm run build there, then node dist/main.js 3100 to hit static, param, middleware, wildcard, bailed, POST-body, and miss routes.

Parser option

By default (parser: "classic", the default) extraction parses route modules with the typescript package’s tsc API. parser: "tsgo" switches to the typescript-go native compiler’s client API (typescript/sync, available on typescript@next 7.x nightlies): parsing runs in a spawned native server process and the client decodes the binary AST into classic-shaped node objects. It is experimental: the nightly API breaks between versions, so pin the exact nightly you test. Without a resolvable nightly the plugin warns and falls back to classic, so a classic-only install never loads tsgo code. Option reference (including include, root, appExport) is in PLUGIN.md. For measured dev/build impact and a parser A/B (classic vs tsgo), see bench/RESULTS-plugin.md.