Skip to content

Decouple runtime layer from App so turboAPI / merjs can consume nanoapi as the shared HTTP base #11

@justrach

Description

@justrach

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)

  • Promote ResponseWriter to a stable public struct in response.zig (chunked + non-chunked write API)
  • Add serveGeneric(allocator, options, ctx, dispatch_fn) in server.zig; keep existing serve(*App, ...) as a one-liner wrapper
  • Same split for io_uring.zig — opaque ctx + dispatch fn instead of *Server reaching into App
  • Same split for the kqueue / threaded fallback paths
  • Confirm parseRequestHead, sendResponse, canUseFastJsonBytes, appendDecimal stay public and stable as-is
  • Tag `v0.1.0` once API stabilizes — downstream consumers pin to tag SHA in `build.zig.zon`

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions