Skip to content

Pluggable server-listener seam + a Bun.serve backend #511

@vivek7405

Description

@vivek7405

Problem

startServer (the webjs dev / start convenience) is hard-wired to node:http: in dev.js it does createHttp1Server(handler) and bridges every request toWebRequest(nodeReq) -> handle(webReq) -> sendWebResponse(nodeRes, webResponse). After #508, webjs runs on Bun, but only through Bun's node:http COMPATIBILITY layer, which has translation overhead and still pays the IncomingMessage/ServerResponse bridge cost.

createRequestHandler already returns a runtime-agnostic handle(req): Response, and Bun.serve({ fetch: handle }) takes exactly that shape. So on Bun the right server is Bun.serve (native HTTP, Request/Response-native), which also lets us DROP the toWebRequest / sendWebResponse bridge entirely on that path. The benchmark in #508 showed Bun's Request/Response handling roughly 3x faster than Node at the handler level; native Bun.serve would let that flow through to the actual listening server instead of being eaten by the compat layer.

Design / approach

A pluggable server-listener adapter seam, the same factoring Remix 3 uses (its node-fetch-server package's createRequestListener). createRequestHandler stays the runtime-neutral core; startServer detects the runtime (process.versions.bun) and picks an adapter:

  • Bun: Bun.serve({ fetch: handle, websocket }), Request/Response-native, no node req/res bridge.
  • Node: the existing node:http shell (unchanged).

The shared, runtime-NEUTRAL logic must live in a core both adapters call so the two shells cannot drift: the WS dispatch to route.ts WS exports, the SSE live-reload client registry, the #237 server timeouts, the access log, and the proxy-IP / trustProxy resolution (Bun exposes server.requestIP(req) where node:http reads socket.remoteAddress). The CLI auto-selects the adapter so bun --bun run start gets the fast path with no user action.

Gate the work on a benchmark first. The #508 3x number was measured at handle() directly, with NO HTTP server on either side. The number that actually justifies this is node:http-compat-on-Bun vs Bun.serve on the FULL listening path (req/s). Prototype webjs start both ways on Bun and measure; if the delta is large, ship the adapter, if modest, it can wait.

This seam is valuable independent of Bun: it also sets up future Deno.serve and a cleaner embedded-host adapter.

Follow-up to #508 (runtime support) and #509 (Bun test matrix).

Acceptance criteria

  • A benchmark comparing webjs start on Bun via node:http compat vs Bun.serve on the full listening path (req/s), recorded in the PR, justifies (or defers) the adapter.
  • A server-listener adapter seam: createRequestHandler unchanged; startServer selects a node:http adapter (Node) or a Bun.serve adapter (Bun) at runtime.
  • The Bun adapter preserves EVERY node:http-shell feature: the WS upgrade (to route.ts WS exports), SSE live-reload, request/response body-size limits + server timeouts (Harden request ingress: body-size limit (413) and server timeouts #237), compression, the access log + X-Request-Id, proxy-IP / trustProxy, and 103 Early Hints (or it is documented as not-supported-on-Bun with a reason if Bun.serve cannot emit them).
  • Shared logic lives in a runtime-neutral core, not duplicated per shell.
  • bun --bun run start / dev auto-selects the Bun adapter; bun test/bun/smoke.mjs and an e2e-style probe pass against the Bun-served app.
  • Tests cover the adapter selection + the Bun-served WS / SSE / streaming paths.
  • Docs updated: agent-docs/deployment.md, packages/server/AGENTS.md (the dev.js / startServer entry), and the scaffold's Bun-run note.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions