Skip to content

Spec: Pluggable HTTP Runtimes (Express / Fastify / h3)

Status: IMPLEMENTED (M1–M3). The HttpRuntime seam, the engine-neutral RouteTable + RequestContext response driver, the runtime-typed registry, and the @forinda/kickjs/fastify runtime have shipped. Adopter usage lives in the HTTP Runtimes guide; this file is kept as the design record. Remaining niceties: @fastify/multipart uploads, the kick/runtime typegen plugin, and the h3 runtime (M4). Mirrors the docs/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:

ts
// 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-auth package (BYO auth already composes from ctx, 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:

  1. The wrapper layerrouter-builder.ts wraps every ctx-handler in (req: express.Request, res, next) and registers it on an express.Router (router-builder.ts:62,118-184).
  2. The ctx internalsRequestContext stores raw req/res and implements json()/html()/sse()/render()/file over Express response methods (context.ts:256-754).
  3. The public type surfaceAdapterContext.app: Express (core/adapter.ts:80), AdapterMiddleware.handler: RequestHandler (core/adapter.ts:54), ApplicationOptions.middlewares: RequestHandler[], ctx.req/ctx.res Express-typed, Express.Multer.File on ctx.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 raw http.Server on globalThis — 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) and req.ip (rate-limit) — one-line normalizations.
  • ctx.signal, request draining, graceful shutdown: stream events only.
  • packages/ws: attaches to http.Server upgrade — zero coupling.
  • Query parsing: parseQuery(obj) is a pure function; only the req.query read is runtime-supplied.

2.2 Hard couplings (the actual work)

SurfaceFile:lineConsumers
Route registration emits express.Routerkickjs/src/http/router-builder.ts:62,184Application mount, swagger/devtools app.use(path, router)
RequestContext response helperskickjs/src/http/context.ts:522-754every controller
AdapterContext.app: Expresskickjs/src/core/adapter.ts:80swagger, devtools, mcp, queue adapters mount routes on it
AdapterMiddleware.handler: RequestHandlerkickjs/src/core/adapter.ts:54every adapter shipping middleware
ApplicationOptions.middlewares / onNotFound / onErrorkickjs/src/http/application.ts:38,101,239,257adopter bootstrap code (express.json() in every scaffold)
Multer (ctx.file, upload() middleware)context.ts:474-496, middleware/upload.tsfile-upload routes
Views (app.engine, res.render)middleware/views.ts:62-95, context.ts:647ViewAdapter users
Error-handler 4-arity conventionmiddleware/error-handler.ts:28global error path

2.3 Downstream impact matrix

PackageVerdictWork
viteconnect-compat pipingNone beyond runtime providing nodeHandler()
wstransport-onlyNone
swaggerRouter + express.staticMigrate to mount facade (§4.4)
devtoolsRouter + 26 endpoints + SSE + staticMigrate to mount facade + ctx-handlers (largest downstream item)
mcpapp.post/get/delete (HTTP transport)Mount facade (3 routes)
queue2 DevTools panel routesMount facade (trivial)
testinggetExpressApp() + supertestReturn node handler; deprecated alias kept
cli templatesexpress.json() in scaffoldTemplate emits runtime-neutral bodyParser.json() re-export
auth (deprecated)type-onlyNone (frozen)
ai / db* / schema / othersnoneNone

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-standard Request/Response, not connect emulation).
  • Verdict: rejected as the architecture; @fastify/middie remains useful inside the Fastify runtime adapter for adopter-supplied connect middleware (§5.2).

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.res semantics change for every adopter. h3 v2 is also still in beta.
  • Verdict: deferred, but Avenue B is designed so the RuntimeRequest/RuntimeResponse drivers 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)

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:

ts
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:

ts
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.res remain — typed as RuntimeRequest/RuntimeResponse with .raw for engine natives. Breaking-ish: adopters doing ctx.res.sendFile(...) move to ctx.res.raw. One codemod-able pattern.
  • ctx.file → neutral UploadedFile interface (field/originalname/ mimetype/size/buffer|path). Express runtime backs it with multer; Fastify with @fastify/multipart; h3 with readFormData.
  • ctx.render() → throws RuntimeCapabilityError('render') on runtimes without capabilities.render.
  • ctx.sse() already writes via writeHead/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:

ts
// 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:

ts
// .kickjs/types/kick__runtime.d.ts — generated
declare module '@forinda/kickjs' {
  interface KickRuntimeRegister {
    runtime: import('@forinda/kickjs/fastify').FastifyRuntimeTypes
  }
}

Consequences:

  • Under Fastify, ctx.req.raw IS FastifyRequest — 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:

ts
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 (via ctx.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 — default expressRuntime() (lives in core; express stays a peer dep of @forinda/kickjs).
  • Application replaces its 12 this.app.use(...) sites with runtime.useConnect(...) and http.createServer(this.app) with http.createServer(runtime.nodeHandler(app)).
  • Vite dev: dev-server.ts:156 calls expressApp.handle(req,res,next) today → calls app.handle(req,res,next) where Application.handle already exists (application.ts:385) and simply delegates to runtime.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 for getRuntimeApp(): 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.

EntryContentsPeer (optional)
@forinda/kickjs (root)HttpRuntime contract, RouteTable, runtime drivers, expressRuntime() defaultexpress (unchanged)
@forinda/kickjs/fastifyfastifyRuntime(opts?: FastifyServerOptions)fastify (+ @fastify/middie, @fastify/multipart)
@forinda/kickjs/h3h3Runtime()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.json exports gains ./fastify and ./h3; peers declared optional via peerDependenciesMeta.
  • kick add catalog grows fastify / h3 entries that install the engine peer alongside core — exactly like the pg / sqlite / mysql catalog 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 the RuntimeBinding from (request, reply) and runs the ctx pipeline.
  • nodeHandler: await app.ready() once, then (req,res,next) => — unmatched routes fall through to next via notFound continuation.
  • Connect middleware (built-ins + adopter Express middleware) via @fastify/middie (no 4-arity error middleware — error path goes through the runtime's setErrorHandler instead, which we control).
  • RuntimeResponse over reply (reply.code/header/send); SSE via reply.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; RuntimeBinding over h3's event object; node req/res reachable 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 runtime option → expressRuntime(), byte-equivalent behavior. All existing apps unaffected.
  • Type aliases, one deprecation cycle: RequestHandlerConnectMiddleware, getExpressApp()getRuntimeApp(), AdapterContext.app stays present (typed unknown; Express adapters cast — a codemod kick codemod adapter-http rewrites 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.res raw 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

RiskMitigation
Fastify perf expectations (pipeline bypasses schema serialization)Document; later: feed RouteMeta schemas into Fastify's serializer when present
h3 v2 beta churnPin 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 backendsUploadedFile 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

MilestoneScopeRisk
M1 — seam extractionHttpRuntime + RouteTable + runtime drivers in core; expressRuntime() implements them; zero behavior change (golden tests: full kickjs suite must pass untouched)Medium (core refactor)
M2 — adapter facadeAdapterContext.http; migrate swagger/queue/mcp; devtools lastLow (mechanical)
M3 — @forinda/kickjs/fastify subpathRuntime 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 subpathRuntime adapter on v2 beta (or v1 fallback), same conformance suiteMedium-high (upstream beta)
M5 — docs + scaffoldkick new --runtime fastify|h3|express, runtime guide, capability matrix in docsLow

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

  1. trustProxy semantics 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.
  2. Should bodyParser live in core or per-runtime? Leaning: marker object in core, implementation per-runtime (§6).
  3. 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).
  4. Versioned mounting (/api/v1) is plain path prefixing — confirmed portable; multi-mount routes() arrays too. Any adapter relying on express Router param inheritance (mergeParams) needs an audit during M2.

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.