Goal
Make nanoapi's runtime layer (HTTP/1.1 parser, accept loop, io_uring + kqueue runtimes, chunked I/O, static-response cache) consumable by turboAPI (Python framework, Zig FFI dispatch) and merjs (Zig SSR framework, codegen-time routing) without dragging in App, typed routes, OpenAPI metadata, or serverless adapters.
The framework overlay (App, typed.zig, openapi.zig, security.zig, serverless.zig) stays where it is and remains nanoapi's public ergonomic surface — it just becomes one of three sibling consumers of its own runtime, not the only one.
Why now
- turboAPI's server.zig is 109 KB of HTTP/1.1 plumbing duplicating what nanoapi already does better, and has no event-loop runtime (no io_uring, no kqueue, no SO_REUSEPORT).
- merjs has a third independent HTTP stack (server.zig 19KB,
runtime.zig, runtime_threaded.zig) and CI throughput consistent with thread-per-conn fallback.
- Every wire-level fix or perf tweak currently has to land in 1–3 places.
Current state
src/root.zig already publicly re-exports server, request, response, routing, etc. The actual coupling lives in server.zig:
// server.zig:239
pub fn serve(app: *app_mod.App, allocator: std.mem.Allocator, options: Options) !void
…and the io_uring runtime in io_uring.zig:177 (pub fn run(server: *Server, ...)) reaches back into App for dispatch. So the parser (parseRequestHead) and writer (sendResponse) are already reusable as-is; the accept loop and dispatch glue are not.
Proposed shape
Introduce a generic dispatch callback that the runtime calls per request, and keep the existing App-bound entry point as a thin wrapper.
// server.zig — new generic entry point
pub const DispatchFn = *const fn (
ctx: *anyopaque,
req: *Request,
writer: *ResponseWriter,
) anyerror!void;
pub fn serveGeneric(
allocator: std.mem.Allocator,
options: Options,
dispatch_ctx: *anyopaque,
dispatch: DispatchFn,
) !void { /* existing accept loop */ }
// Existing App-bound serve() becomes:
pub fn serve(app: *app_mod.App, allocator, options) !void {
return serveGeneric(allocator, options, @ptrCast(app), appDispatchAdapter);
}
Same shape for io_uring.run() — take an opaque ctx + DispatchFn rather than *Server (which currently knows about *App).
ResponseWriter already exists conceptually inside sendResponse/streaming helpers; this issue would promote it to a stable struct passed to the dispatcher so that consumers can either build a Response and call writer.send(response), or stream chunks directly via writer.startChunked() / writer.writeChunk() / writer.endChunked().
Downstream consumers
turboAPI dispatch context = pointer to the Python interpreter shim; dispatcher calls into Python, marshals the (status, content_type, body, [iter, headers]) tuple back through ResponseWriter. The 5-tuple streaming path landed in turboAPI #164 maps cleanly to writer.startChunked() / writeChunk() / endChunked().
merjs dispatch context = pointer to the codegen'd routes table; dispatcher does file-based dispatch + SSR render → writer.send(response).
Scope (this issue)
Out of scope (separate issues)
- Pre-rendered static-response cache as an opt-in for non-
App consumers
- WebSocket upgrade path (turboAPI #114)
- HTTP/2, trailers, gzip/br response compression
Coordination notes
- nanoapi currently pins `dhi` at `ba44a5d0` (`codex/dhi-fast-validators` branch); turboAPI pins `4f607331` (main). Consumers will need to converge — likely after the dhi codex branch lands.
- A neutral parity test harness is being scaffolded at `turbohttp-parity` (cross-repo, boots both servers, runs identical HTTP black-box tests, emits `PARITY.md`). Failing parity blocks a runtime release.
Acceptance
- nanoapi's existing tests still pass with `App` going through `serveGeneric` internally
- A toy second consumer (e.g. a 50-line Zig binary in `bench/` that registers a non-App dispatcher and serves `GET / -> "ok"`) compiles and runs against the same runtime
- turboAPI spike branch (work-in-progress, unrelated to this issue's merge) successfully imports `nanoapi.server` and links
Goal
Make nanoapi's runtime layer (HTTP/1.1 parser, accept loop, io_uring + kqueue runtimes, chunked I/O, static-response cache) consumable by turboAPI (Python framework, Zig FFI dispatch) and merjs (Zig SSR framework, codegen-time routing) without dragging in
App, typed routes, OpenAPI metadata, or serverless adapters.The framework overlay (
App,typed.zig,openapi.zig,security.zig,serverless.zig) stays where it is and remains nanoapi's public ergonomic surface — it just becomes one of three sibling consumers of its own runtime, not the only one.Why now
runtime.zig,runtime_threaded.zig) and CI throughput consistent with thread-per-conn fallback.Current state
src/root.zigalready publicly re-exportsserver,request,response,routing, etc. The actual coupling lives inserver.zig:…and the io_uring runtime in
io_uring.zig:177(pub fn run(server: *Server, ...)) reaches back intoAppfor dispatch. So the parser (parseRequestHead) and writer (sendResponse) are already reusable as-is; the accept loop and dispatch glue are not.Proposed shape
Introduce a generic dispatch callback that the runtime calls per request, and keep the existing
App-bound entry point as a thin wrapper.Same shape for
io_uring.run()— take an opaque ctx + DispatchFn rather than*Server(which currently knows about*App).ResponseWriteralready exists conceptually insidesendResponse/streaming helpers; this issue would promote it to a stable struct passed to the dispatcher so that consumers can either build aResponseand callwriter.send(response), or stream chunks directly viawriter.startChunked()/writer.writeChunk()/writer.endChunked().Downstream consumers
turboAPI dispatch context = pointer to the Python interpreter shim; dispatcher calls into Python, marshals the
(status, content_type, body, [iter, headers])tuple back throughResponseWriter. The 5-tuple streaming path landed in turboAPI #164 maps cleanly towriter.startChunked()/writeChunk()/endChunked().merjs dispatch context = pointer to the codegen'd routes table; dispatcher does file-based dispatch + SSR render →
writer.send(response).Scope (this issue)
ResponseWriterto a stable public struct inresponse.zig(chunked + non-chunked write API)serveGeneric(allocator, options, ctx, dispatch_fn)inserver.zig; keep existingserve(*App, ...)as a one-liner wrapperio_uring.zig— opaque ctx + dispatch fn instead of*Serverreaching intoAppparseRequestHead,sendResponse,canUseFastJsonBytes,appendDecimalstay public and stable as-isOut of scope (separate issues)
AppconsumersCoordination notes
Acceptance