Spec: Pluggable HTTP Runtimes (Express / Fastify / h3)
Status: IMPLEMENTED (M1–M3). The
HttpRuntimeseam, the engine-neutralRouteTable+RequestContextresponse driver, the runtime-typed registry, and the@forinda/kickjs/fastifyruntime have shipped. Adopter usage lives in the HTTP Runtimes guide; this file is kept as the design record. Remaining niceties:@fastify/multipartuploads, thekick/runtimetypegen plugin, and the h3 runtime (M4). Mirrors thedocs/db/spec convention. Companion inventory: the coupling audit summarized in §2 (31 core coupling points + per-package downstream matrix, file:line refs in §2.3/§2.4).
1. Goal & non-goals
Goal. Let an adopter pick the HTTP engine at bootstrap:
// Express stays the zero-config default — nothing changes for existing apps.
export const app = await bootstrap({ modules })
// Opt in to Fastify or h3 — runtimes ship as SUBPATHS of the core
// package (the @forinda/kickjs-db dialect model: one package, engine
// drivers as optional peers, install only the one you use):
import { fastifyRuntime } from '@forinda/kickjs/fastify'
export const app = await bootstrap({ modules, runtime: fastifyRuntime() })
import { h3Runtime } from '@forinda/kickjs/h3'
export const app = await bootstrap({ modules, runtime: h3Runtime() })Controllers, modules, context decorators, DI, typegen, and the dev loop must not change. The runtime is an infrastructure choice, not an application rewrite.
Non-goals (this cycle).
- No edge-runtime / serverless deployment story (h3 makes it reachable later; see §8).
- No performance-parity promise on day one — Fastify behind the runtime seam initially routes through the shared pipeline, not Fastify's schema-compiled serializers (see §7 Risks).
- No migration of the deprecated
@forinda/kickjs-authpackage (BYO auth already composes fromctx, which is runtime-neutral by design here).
2. Where Express actually lives today
Full audit: 31 coupling points in packages/kickjs, plus downstream. The load-bearing observation: the handler pipeline is already ctx-based. Controllers, contributors, and most built-in middleware never touch Express APIs — they touch RequestContext. Express appears in exactly three strata:
- The wrapper layer —
router-builder.tswraps every ctx-handler in(req: express.Request, res, next)and registers it on anexpress.Router(router-builder.ts:62,118-184). - The
ctxinternals —RequestContextstores rawreq/resand implementsjson()/html()/sse()/render()/fileover Express response methods (context.ts:256-754). - The public type surface —
AdapterContext.app: Express(core/adapter.ts:80),AdapterMiddleware.handler: RequestHandler(core/adapter.ts:54),ApplicationOptions.middlewares: RequestHandler[],ctx.req/ctx.resExpress-typed,Express.Multer.Fileonctx.file.
2.1 Already runtime-neutral (verified)
- Vite dev piping needs only a node
(req, res, next?)callable (vite/dev-server.ts:134-174) and a rawhttp.ServeronglobalThis— both satisfiable by all three engines. - Built-in middleware (helmet, cors, csrf, rate-limit, request-id, request-logger, trace-context, session, validate): node-http primitives only (
setHeader,statusCode,headers,on('finish')). Two strays:req.originalUrl(request-logger) andreq.ip(rate-limit) — one-line normalizations. ctx.signal, request draining, graceful shutdown: stream events only.packages/ws: attaches tohttp.Serverupgrade— zero coupling.- Query parsing:
parseQuery(obj)is a pure function; only thereq.queryread is runtime-supplied.
2.2 Hard couplings (the actual work)
| Surface | File:line | Consumers |
|---|---|---|
Route registration emits express.Router | kickjs/src/http/router-builder.ts:62,184 | Application mount, swagger/devtools app.use(path, router) |
RequestContext response helpers | kickjs/src/http/context.ts:522-754 | every controller |
AdapterContext.app: Express | kickjs/src/core/adapter.ts:80 | swagger, devtools, mcp, queue adapters mount routes on it |
AdapterMiddleware.handler: RequestHandler | kickjs/src/core/adapter.ts:54 | every adapter shipping middleware |
ApplicationOptions.middlewares / onNotFound / onError | kickjs/src/http/application.ts:38,101,239,257 | adopter bootstrap code (express.json() in every scaffold) |
Multer (ctx.file, upload() middleware) | context.ts:474-496, middleware/upload.ts | file-upload routes |
Views (app.engine, res.render) | middleware/views.ts:62-95, context.ts:647 | ViewAdapter users |
| Error-handler 4-arity convention | middleware/error-handler.ts:28 | global error path |
2.3 Downstream impact matrix
| Package | Verdict | Work |
|---|---|---|
| vite | connect-compat piping | None beyond runtime providing nodeHandler() |
| ws | transport-only | None |
| swagger | Router + express.static | Migrate to mount facade (§4.4) |
| devtools | Router + 26 endpoints + SSE + static | Migrate to mount facade + ctx-handlers (largest downstream item) |
| mcp | app.post/get/delete (HTTP transport) | Mount facade (3 routes) |
| queue | 2 DevTools panel routes | Mount facade (trivial) |
| testing | getExpressApp() + supertest | Return node handler; deprecated alias kept |
| cli templates | express.json() in scaffold | Template emits runtime-neutral bodyParser.json() re-export |
| auth (deprecated) | type-only | None (frozen) |
| ai / db* / schema / others | none | None |
3. Three avenues considered
Avenue A — Express-compat emulation on the new engines
Run Fastify with @fastify/express / @fastify/middie (officially maintained for v5) or h3's node bridge, and feed them KickJS's existing express-shaped pipeline unchanged.
- ✅ Smallest diff.
- ❌ Defeats the purpose: Fastify's router/serialization are bypassed, so adopters get Express semantics with extra layers —
@fastify/express's own README says not to use it long-term. No real h3 story (its v2 model is web-standardRequest/Response, not connect emulation). - Verdict: rejected as the architecture;
@fastify/middieremains useful inside the Fastify runtime adapter for adopter-supplied connect middleware (§5.2).
Avenue B — HttpRuntime seam over the existing ctx pipeline ⭐ recommended
Make the runtime an injected driver. KickJS keeps owning: decorators → RouteTable (plain data), contributor pipeline, RequestContext surface, error mapping. The runtime owns: app/server creation, route materialization, body parsing, and a small RuntimeResponse driver that ctx helpers call instead of Express methods.
- ✅ Controllers/contributors untouched; Express remains default with zero behavior change; per-runtime native routing (Fastify routes are real Fastify routes — its router, its 404, its onRequest hooks).
- ✅ The audit shows the pipeline is one wrapper-layer away from this already.
- ❌ Public-type churn on the adapter contract + bootstrap options (mitigated via aliases + one deprecation cycle, §6).
Avenue C — Web-standard core (Request/Response), engines as bridges
Rebuild ctx over WHATWG Request/Response (h3 v2's model; srvx bridges Node at ~97% native throughput per h3's published numbers).
- ✅ Most future-proof: h3 becomes the thin runtime, edge/Bun/Deno fall out for free.
- ❌ Biggest break: streaming/SSE rewrite, multer gone, express middleware option gone,
ctx.ressemantics change for every adopter. h3 v2 is also still in beta. - Verdict: deferred, but Avenue B is designed so the
RuntimeRequest/RuntimeResponsedrivers CAN later be implemented over web standards — C becomes an additional runtime + an internal driver swap, not a second migration.
4. Design (Avenue B)
4.1 The HttpRuntime contract (new: kickjs/src/http/runtime.ts)
export interface HttpRuntime<TApp = unknown> {
readonly name: 'express' | 'fastify' | 'h3' | (string & {})
/** Create the engine app. Called once per Application (and per HMR rebuild). */
createApp(options: RuntimeAppOptions): TApp
/**
* Node-compatible request listener. THE transport contract:
* `http.createServer(handler)` in prod, Vite post-middleware in dev.
* MUST invoke `next` (when given) instead of 404-ing if no route
* matched — the Vite chain depends on fall-through.
*/
nodeHandler(
app: TApp,
): (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void
/** Materialize the framework-built route table on the engine. */
mountRoutes(app: TApp, table: RouteTable): void
/** Mount a connect-style middleware (built-ins + adopter express middleware). */
useConnect(
app: TApp,
mw: ConnectMiddleware,
opts?: { path?: MiddlewarePath; phase?: MiddlewarePhase },
): void
/** Static directory serving (swagger-ui assets, devtools SPA). */
serveStatic(app: TApp, path: string, dir: string): void
/** Bind the engine's request/response into runtime drivers for ctx. */
bind(req: unknown, res: unknown): RuntimeBinding
/** Terminal handlers — runtime adapts arity/registration conventions. */
setNotFound(app: TApp, handler: CtxHandler): void
setErrorHandler(app: TApp, handler: (err: unknown, ctx: RequestContext) => void): void
/** Optional capabilities — absence = feature errors with a clear message. */
readonly capabilities: {
render?: boolean // express: true (view engines); fastify/h3: false initially
uploads?: boolean // all three eventually; different backends
connectMiddleware: boolean // express/fastify(middie): true; h3: best-effort
}
}4.2 RouteTable — decorators stop emitting express.Router
buildRoutes() (router-builder.ts) becomes a pure transform: decorator metadata → RouteTable:
export interface RouteEntry {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
path: string // ':param' syntax — all three engines accept it
pipeline: CtxHandler[] // contributors (topo-sorted) + route middleware + handler
meta: RouteMeta // schema refs, upload config, version — for swagger/typegen
}
export type RouteTable = { mountPath: string; routes: RouteEntry[] }[]
export type CtxHandler = (ctx: RequestContext) => unknown | Promise<unknown>Everything currently wrapped per-handler in express (req,res,next) closures (router-builder.ts:118-183) is ALREADY a ctx pipeline — this change deletes the express wrapper rather than adding abstraction. Path syntax: :param is the portable subset (Express 5 / Fastify / h3 all support it); regex paths become an Express-capability.
4.3 RequestContext over runtime drivers
ctx keeps its exact public method surface. Internals re-target:
interface RuntimeRequest {
method: string
url: string
path: string
headers: IncomingHttpHeaders
params: Record<string, string>
query: Record<string, unknown>
body: unknown
ip: string | undefined
raw: unknown // engine-native escape hatch
once(event: 'close', cb: () => void): void
}
interface RuntimeResponse {
status(code: number): this
header(name: string, value: string | string[]): this
json(data: unknown): void
send(data: string | Buffer): void
// SSE/streaming primitive — enough for ctx.sse() and devtools streams:
writeHead(code: number, headers: Record<string, string>): void
write(chunk: string | Buffer): boolean
end(): void
readonly headersSent: boolean
raw: unknown
}ctx.req/ctx.resremain — typed asRuntimeRequest/RuntimeResponsewith.rawfor engine natives. Breaking-ish: adopters doingctx.res.sendFile(...)move toctx.res.raw. One codemod-able pattern.ctx.file→ neutralUploadedFileinterface (field/originalname/ mimetype/size/buffer|path). Express runtime backs it with multer; Fastify with@fastify/multipart; h3 withreadFormData.ctx.render()→ throwsRuntimeCapabilityError('render')on runtimes withoutcapabilities.render.ctx.sse()already writes viawriteHead/write/end— maps directly.
4.3b Runtime-typed context — the escape hatches follow the chosen runtime
raw: unknown would force casts at every engine-native touchpoint. Instead the raw types come from an augmentable runtime registry — the exact KickDbRegister / KickEnv mechanism the framework already uses, so the type story is uniform with kick/db and env typing:
// core — empty, augmentable; ExpressRuntimeTypes is the un-augmented fallback
export interface KickRuntimeRegister {}
export interface RuntimeTypeMap {
request: unknown // engine-native request (express.Request / FastifyRequest / H3Event)
response: unknown // engine-native response (express.Response / FastifyReply / H3Event)
app: unknown // engine-native app (Express / FastifyInstance / H3 App)
}
type ActiveRuntime = KickRuntimeRegister extends { runtime: infer R extends RuntimeTypeMap }
? R
: ExpressRuntimeTypes // default mirrors the runtime default
// flows into the surfaces:
// ctx.req.raw : ActiveRuntime['request']
// ctx.res.raw : ActiveRuntime['response']
// AdapterContext.app : ActiveRuntime['app']
// getRuntimeApp() : ActiveRuntime['app']The augmentation is emitted by typegen (a kick/runtime plugin reading the configured runtime from kick.config.ts), mirroring how kick/db emits KickDbRegister:
// .kickjs/types/kick__runtime.d.ts — generated
declare module '@forinda/kickjs' {
interface KickRuntimeRegister {
runtime: import('@forinda/kickjs/fastify').FastifyRuntimeTypes
}
}Consequences:
- Under Fastify,
ctx.req.rawISFastifyRequest— autocomplete, no casts. Same controller code, runtime-correct types. - Single-runtime-per-app is enforced by the type system for free: two conflicting augmentations are a compile error ("Subsequent property declarations must have the same type").
- Typegen-emitted rather than import-side-effect, so merely importing a runtime subpath in a script never flips the app's global types; the config is the single source of truth. Hand-written augmentation stays documented for no-typegen projects.
- Under the default (no config, no typegen) everything types as Express — existing code compiles unchanged, which is what makes the §6 “structural no-op under express” guarantee hold at the type level too.
4.4 Adapter contract — the mount facade
AdapterContext changes from app: Express to:
export interface AdapterContext {
http: AdapterHttp // NEW — the supported surface
app: ActiveRuntime['app'] // engine-native escape hatch, typed by the runtime registry (§4.3b)
container: Container
server?: http.Server
// …
}
export interface AdapterHttp {
route(method: HttpMethod, path: string, handler: CtxHandler): void
mount(prefix: string, routes: RouteEntry[]): void
serveStatic(prefix: string, dir: string): void
use(mw: ConnectMiddleware, opts?: { path?: MiddlewarePath; phase?: MiddlewarePhase }): void
}First-party migrations (mechanical — all their handlers are (req,res) => res.json(...) one-liners that become ctx-handlers):
- swagger (
swagger.adapter.ts:173-278): 3 routes + 1 static dir. - devtools (
adapter.ts:456-919): 26 routes + SSE (viactx.sse) + static SPA. Largest item; pure transcription. - mcp (
mcp.adapter.ts:372-391): 3 routes. - queue (
queue.adapter.ts:135-154): 2 routes.
AdapterMiddleware.handler keeps the connect signature (it IS the portable middleware format — Fastify consumes it via middie, h3 via its node bridge), renamed type ConnectMiddleware with RequestHandler kept as a deprecated alias.
4.5 Bootstrap & dev loop
ApplicationOptions.runtime?: HttpRuntime— defaultexpressRuntime()(lives in core; express stays a peer dep of@forinda/kickjs).Applicationreplaces its 12this.app.use(...)sites withruntime.useConnect(...)andhttp.createServer(this.app)withhttp.createServer(runtime.nodeHandler(app)).- Vite dev:
dev-server.ts:156callsexpressApp.handle(req,res,next)today → callsapp.handle(req,res,next)whereApplication.handlealready exists (application.ts:385) and simply delegates toruntime.nodeHandler. The Fastify runtime implements next-fall-through by setting a notFound handler that invokes a per-request continuation (stashed on the request before dispatch); h3 equivalently from its node adapter. No vite-package changes needed. getExpressApp()→ deprecated alias forgetRuntimeApp(): unknown; returns the engine app under any runtime (testing keeps working — supertest accepts any node handler:request(app.nodeHandler())).
4.6 Packaging — subpaths, not new packages
Runtimes follow the @forinda/kickjs-db dialect model: one package, engine adapters as export subpaths, engines as optional peers. No new npm packages — the package count stays flat and kick add stays a one-liner per runtime.
| Entry | Contents | Peer (optional) |
|---|---|---|
@forinda/kickjs (root) | HttpRuntime contract, RouteTable, runtime drivers, expressRuntime() default | express (unchanged) |
@forinda/kickjs/fastify | fastifyRuntime(opts?: FastifyServerOptions) | fastify (+ @fastify/middie, @fastify/multipart) |
@forinda/kickjs/h3 | h3Runtime() | h3 (pin v2 once stable; v1 fallback path documented) |
Bundling notes (mirrors how kickjs-db/pg|mysql|sqlite already works):
- Each subpath is its own tsdown entry; engine imports are dynamic / type-only so importing the root never loads (or requires) fastify/h3.
package.jsonexportsgains./fastifyand./h3; peers declared optional viapeerDependenciesMeta.kick addcatalog growsfastify/h3entries that install the engine peer alongside core — exactly like thepg/sqlite/mysqlcatalog entries do for kickjs-db today.
5. Per-engine notes
5.2 Fastify runtime
- App:
fastify({ ...opts }); routes registered natively (app.route({ method, url, handler })) — handler builds theRuntimeBindingfrom(request, reply)and runs the ctx pipeline. nodeHandler:await app.ready()once, then(req,res,next) =>— unmatched routes fall through tonextvia notFound continuation.- Connect middleware (built-ins + adopter Express middleware) via
@fastify/middie(no 4-arity error middleware — error path goes through the runtime'ssetErrorHandlerinstead, which we control). RuntimeResponseoverreply(reply.code/header/send); SSE viareply.raw(the underlying ServerResponse) — same primitive as today.- Body parsing: Fastify-native (scaffold's
express.json()becomes a no-op marker the runtime recognizes — see §6 template change).
5.3 h3 runtime
- v2 (beta, web-standard core, srvx node bridge) is the design target; the adapter shape works for v1 (
createApp/toNodeListener) if v2 stability slips. - Routes via h3 router;
RuntimeBindingover h3's event object; nodereq/resreachable in node mode for SSE/static. - Connect middleware: h3 node-middleware bridge (
fromNodeHandler-style); capability-flagged best-effort. - This runtime is also the proving ground for the §8 web-standard driver.
6. Compatibility & migration
- Default unchanged: no
runtimeoption →expressRuntime(), byte-equivalent behavior. All existing apps unaffected. - Type aliases, one deprecation cycle:
RequestHandler→ConnectMiddleware,getExpressApp()→getRuntimeApp(),AdapterContext.appstays present (typedunknown; Express adapters cast — a codemodkick codemod adapter-httprewrites first-party patterns). - Scaffold:
express.json()in templates →bodyParser.json()re-exported from@forinda/kickjs(express impl under the hood; recognized + replaced natively by other runtimes). ctx.req/ctx.resraw access: the only adopter-visible break, gated to the moment they switch runtime — under express the runtime drivers ARE the express objects (structural), so existing code keeps compiling.
7. Risks
| Risk | Mitigation |
|---|---|
| Fastify perf expectations (pipeline bypasses schema serialization) | Document; later: feed RouteMeta schemas into Fastify's serializer when present |
| h3 v2 beta churn | Pin minor; adapter is ~300 LOC; v1 fallback documented |
| next-fall-through hacks (Fastify notFound continuation) | Covered by runtime conformance tests (§9); only exercised in dev/Vite |
| DevTools migration size (26 handlers) | Mechanical; ctx-handlers are shorter than the originals |
| Multer semantics differences across backends | UploadedFile interface is the contract; conformance fixtures upload through all runtimes |
Adapter-ecosystem breakage (third-party adapters using app as Express) | app escape hatch remains; deprecation cycle + loud changelog |
8. Future: web-standard driver (Avenue C convergence)
RuntimeRequest/RuntimeResponse were shaped so a webRuntime() can implement them over WHATWG Request/Response + ReadableStream (h3 v2 native; srvx for Node). When that lands, edge targets (Bun, Deno, workers) cost one runtime package, not a core rewrite. SSE becomes a ReadableStream under that driver; write/end remain the portable primitive until then.
9. Milestones
| Milestone | Scope | Risk |
|---|---|---|
| M1 — seam extraction | HttpRuntime + RouteTable + runtime drivers in core; expressRuntime() implements them; zero behavior change (golden tests: full kickjs suite must pass untouched) | Medium (core refactor) |
| M2 — adapter facade | AdapterContext.http; migrate swagger/queue/mcp; devtools last | Low (mechanical) |
M3 — @forinda/kickjs/fastify subpath | Runtime adapter + middie bridge + uploads + kick/runtime typegen plugin (runtime-typed registry, §4.3b) + conformance suite (shared fixture app run under both runtimes via supertest: routes, contributors, errors, SSE, uploads, shutdown draining, Vite dev boot) | Medium |
M4 — @forinda/kickjs/h3 subpath | Runtime adapter on v2 beta (or v1 fallback), same conformance suite | Medium-high (upstream beta) |
| M5 — docs + scaffold | kick new --runtime fastify|h3|express, runtime guide, capability matrix in docs | Low |
The conformance suite (M3) is the centerpiece: one fixture app, one spec file, parameterized over every registered runtime — the same trick the db package uses for its dialect emitters.
10. Open questions
trustProxysemantics differ per engine — normalize in core (parse X-Forwarded-For ourselves) or pass through per-runtime config? Leaning: normalize in core; engines disable their own handling.- Should
bodyParserlive in core or per-runtime? Leaning: marker object in core, implementation per-runtime (§6). - Fastify logging: its built-in pino vs kickjs Logger — disable Fastify's (
logger: false) and keep request-logger middleware, or bridge? Leaning: disable, keep ours (consistency across runtimes). - Versioned mounting (
/api/v1) is plain path prefixing — confirmed portable; multi-mountroutes()arrays too. Any adapter relying on expressRouterparam inheritance (mergeParams) needs an audit during M2.