Skip to content

Port turboAPI's HTTP server to consume nanoapi's runtime layer (collapses zig/src/server.zig) #166

@justrach

Description

@justrach

Goal

Replace turboAPI's `zig/src/server.zig` (109 KB of HTTP/1.1 plumbing) with a thin Python FFI dispatcher that drives nanoapi's runtime layer. After this lands, turboAPI gets io_uring on Linux, kqueue on macOS, SO_REUSEPORT multicore, and the pre-rendered static-response cache — none of which it has today — without forking or duplicating the HTTP wire layer.

Conceptually: nanoapi becomes turboAPI's `server.zig`. turboAPI keeps its Python FFI glue (GIL/free-threading shims, async pool, dhi pre-GIL validation, the 5-tuple streaming contract from #164) and discards the duplicated socket/parse/dispatch code.

Dependencies

  • nanoapi#12 — `Dispatcher` vtable + `serveGeneric` (DONE, awaiting merge)
  • turboAPI#165 — pg.zig Zig 0.16 compat (blocks adding nanoapi to main build)
  • nanoapi#12 merged + tagged `v0.X.Y` — consumers pin to tag SHA in build.zig.zon, never main

Empirical proof of shape: `feat/nanoapi-runtime-spike` — a 68-line Zig binary in this repo's `spike/` constructs a `nanoapi.server.Dispatcher` with no `App`, calls `serveGeneric`, serves real HTTP. The Python port replaces `SpikeContext` with an interpreter pointer and the dispatch body with the existing handler-tuple machinery.

Implementation plan

Step 1 — wire nanoapi as a build dep

After #165: add nanoapi to `zig/build.zig.zon` (pinned to tag SHA), import as `nanoapi` module in `zig/build.zig`, and add `lib.root_module.addImport("nanoapi", nanoapi_mod)`.

Step 2 — build a `PyDispatcher` in Zig

New file `zig/src/py_dispatcher.zig`. Holds:

```zig
pub const PyDispatcher = struct {
interp: *anyopaque, // PyInterpreterState pointer (or whatever turboAPI uses)
routes: *PyObject, // turboAPI's Router instance from Python
// ... whatever else the existing zig→python dispatch needs

pub fn dispatcher(self: *PyDispatcher) nanoapi.server.Dispatcher {
    return .{
        .ctx = @ptrCast(self),
        .handle = pyHandle,
        .has_middleware = pyHasMiddleware,
        .try_static_dispatch = null,  // optional optimization, defer to v2
    };
}

};

fn pyHandle(ctx: *anyopaque, req: *nanoapi.Request) anyerror!nanoapi.Response {
const self: *PyDispatcher = @ptrCast(@aligncast(ctx));
// 1. Acquire GIL (or check free-threading state)
// 2. Build Python request representation from req.method/path/headers/body
// 3. Call into turboAPI's existing Python dispatch (request_handler.py)
// 4. Receive 3-tuple (status, content_type, body) or
// 5-tuple (status, content_type, b"", iterator, headers) for streaming
// 5. Marshal back to nanoapi.Response (or to ResponseWriter for streaming)
// 6. Release GIL
}
```

Step 3 — replace server.zig accept loop

Currently `zig/src/main.zig` boots its own listener and dispatch. New shape:

```zig
const py_disp = try PyDispatcher.init(allocator, ...);
defer py_disp.deinit();
try nanoapi.server.serveGeneric(py_disp.dispatcher(), allocator, .{
.host = host,
.port = port,
.runtime = .auto, // io_uring on Linux, kqueue on macOS
.worker_threads = 0, // one worker per CPU
});
```

`zig/src/server.zig` (the 109KB file) gets deleted. Maybe keep multipart.zig if nanoapi's multipart isn't equivalent yet; verify with parity tests.

Step 4 — preserve the 5-tuple streaming path from #164

The existing streaming dispatch in `request_handler.py` returns either a 3-tuple (sync) or 5-tuple `(status, content_type, b"", iterator, headers)` (streaming). `PyDispatcher.handle` needs to detect the 5-tuple and use nanoapi's streaming primitives (`StreamingResponse` / `SseWriter` / `LLMStreamResponse`) instead of building a plain `Response`. May need a small `ResponseWriter` extension on the nanoapi side if the existing streaming-response API doesn't expose chunked-write directly enough — in which case file as a follow-up nanoapi issue.

Step 5 — preserve free-threading + dhi pre-GIL validation

The `py_atomic_shim.c` and `py_gil_shim.c` C files stay. dhi pre-GIL JSON validation runs before GIL acquisition in `PyDispatcher.handle`. The exact spot is the same as today's `zig/src/main.zig`.

Step 6 — turn parity green

Run turbohttp-parity against the ported turboAPI. Every test that was green on nanoapi should now be green on turboAPI too. CI gate the merge on PARITY.md being green.

Risks

  • Free-threading invariants. nanoapi's runtime is single-GIL-aware (it's pure Zig, no Python concept). The Python dispatch happens entirely in user code (`pyHandle`), so this should Just Work, but worth fuzzing.
  • dhi version skew. turboAPI pins dhi at `Fz3bn1CFAwD…`, nanoapi at `Fz3bn4aKAwD…`. Zig's package manager can resolve both into separate cache slots when imported via different module trees, but if any shared struct (e.g. a `dhi.Validator`) crosses the boundary, types won't unify. Plan: converge to a single dhi pin before this PR merges.
  • Benchmark regression. turboAPI's current numbers are 140k req/s. nanoapi's macOS numbers are ~149k. Linux io_uring takes nanoapi to 983k single-route. Expect turboAPI's macOS numbers to stay flat or improve slightly; Linux numbers to jump dramatically. If macOS regresses, root-cause before merging.
  • PR feat: real end-to-end SSE / StreamingResponse over Zig HTTP server (closes #163) #164 (SSE) interaction. That PR's `sendStreamingResponse` in turboAPI's server.zig gets deleted as part of step 3. The streaming logic moves into `PyDispatcher.handle` calling nanoapi's streaming primitives. Tests in `tests/test_sse_e2e.py` must stay green.

Acceptance

Out of scope

  • WebSocket — separate, turboAPI#114.
  • Pre-rendered static-response cache as a turboAPI-facing API — defer; focus on parity first.
  • HTTP/2, trailers, gzip response compression — all neither-has-it concerns.

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