Skip to content

Bun runs the main HTTP port on node:http, not Bun.serve — pick one transport #2355

Description

@kriszyp

What is true today

Under Bun, Harper serves its two HTTP ports on two different transports, and the split is not a decision anyone made:

Port Transport
Operations API (and its UDS mirror) Bun.serve() fetch handler
Main port — REST, MQTT-over-WS, WebSocket subscriptions Bun's node:http emulation

Verified at runtime by instrumenting a Bun integration run: Bun.serve() is called exactly once, for the operations port. The fetch handler in getBunHTTPServer never executes for the main port.

Why

onWebSocket() calls getHTTPServer() unconditionally (server/http.ts). It has a uWS branch and no Bun branch, because Bun native WebSockets are unimplemented: threadServer.js reads config.websocket when calling Bun.serve(), but nothing in the tree ever populates that field.

Both REST.ts (WebSocket subscriptions, unless http.webSocket: false) and mqtt.ts register a .ws() listener on the main port, and whichever runs first creates a Node http.Server there. getBunHTTPServer then sees httpServers[port] already populated and returns early without registering a serve config, so listenOnPortsBun never binds that port with Bun.serve() — the Node server binds it through registerServer() instead.

Instrumented trace from one worker (tid=1):

[KR-GETNODE] tid=1 port=127.0.0.12:9926   ← onWebSocket ← mqtt.handleApplication
[KR-GETBUN]  tid=1 port=127.0.0.12:9926 existing=true   ← early-returns, registers nothing
[KR-LOP]     tid=1 keys=["all"]           ← 9926 absent; Bun.serve never called for it

Why this matters

  1. The Bun.serve fetch path is dead code for the main port, but reads as the live one. Every Bun HTTP bug is therefore a node:http-emulation bug, and anyone debugging one starts in the wrong file — Bun leaves finite iterable REST connections open after Connection: close #2210 cost an investigation this way before the instrumentation showed the handler never runs.
  2. It is emergent, not chosen. It holds only because something registers a WebSocket listener on that port. A deployment with http.webSocket: false and MQTT disabled would flip the main port onto Bun.serve, silently, which is where the next point bites.
  3. The Bun.serve path is not currently able to serve the main port. Measured by forcing onWebSocket down the Bun branch and running the streaming contract suite under Bun:
    • Every response comes back buffered — chunked=false, no terminal chunk — because the fetch handler drains body.pipe into a Buffer before responding. A finite generator still returns; an open-ended SSE or subscription stream would never respond at all.
    • WebSocket registration cannot attach: server.on('upgrade', …) is called on the Bun.serve config object, which has no .on.
    • Connection close got worse, not better: captures closed at ~12s (Bun's idle timeout) instead of ~10ms.
    • Error semantics shift: SSE/NDJSON generator throws became 500 + plain error text instead of 200 + a partial stream.

The decision

Pick one transport for Bun and make it explicit.

Option A — standardize on node:http, and move the operations port to it too. It is the only path with working WebSockets and real streaming, and the performance case that motivated Bun.serve has not materialized. Lets us delete the fetch handler and bunDelegateToNodeServer, which currently exist to serve one port. Small: drop the isBun branch in httpServer(), keep the operations port's exclusive (non-reusePort) bind semantics, and re-run the Bun shards.

Option B — make Bun.serve capable and switch the main port to it. Comparable in size to the uWS transport work (#914), not a patch:

  • a BunWebSocket adapter over server.upgrade(), mirroring serverHelpers/uwsServer.ts's app.ws() bridge, behind Harper's existing ws-shaped abstraction;
  • stream responses via Readable.toWeb() instead of buffering (bunDelegateToNodeServer already does this for the MCP SSE case);
  • a separate investigation into why Connection: close was still not honored on that path.

Recommendation: Option A, unless someone wants to re-open the performance question with measurements.

Reproduction

npm run build
HARPER_RUNTIME=bun npm run test:integration -- "integrationTests/server/stream-error-contract.test.ts"

Then add an appendFileSync probe at the first line of getBunHTTPServer's fetchHandler and at the Bun.serve(serveOptions) call in threadServer.js: the former never fires for the main port, the latter fires once for the operations port.

Related: #2210 (the node:http close divergence this uncovered, fixed in #2351), #1679, #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions