You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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.
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.
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:
Bun.serve()fetch handlernode:httpemulationVerified at runtime by instrumenting a Bun integration run:
Bun.serve()is called exactly once, for the operations port. The fetch handler ingetBunHTTPServernever executes for the main port.Why
onWebSocket()callsgetHTTPServer()unconditionally (server/http.ts). It has a uWS branch and no Bun branch, because Bun native WebSockets are unimplemented:threadServer.jsreadsconfig.websocketwhen callingBun.serve(), but nothing in the tree ever populates that field.Both
REST.ts(WebSocket subscriptions, unlesshttp.webSocket: false) andmqtt.tsregister a.ws()listener on the main port, and whichever runs first creates a Nodehttp.Serverthere.getBunHTTPServerthen seeshttpServers[port]already populated and returns early without registering a serve config, solistenOnPortsBunnever binds that port withBun.serve()— the Node server binds it throughregisterServer()instead.Instrumented trace from one worker (
tid=1):Why this matters
Bun.servefetch path is dead code for the main port, but reads as the live one. Every Bun HTTP bug is therefore anode: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.http.webSocket: falseand MQTT disabled would flip the main port ontoBun.serve, silently, which is where the next point bites.Bun.servepath is not currently able to serve the main port. Measured by forcingonWebSocketdown the Bun branch and running the streaming contract suite under Bun:chunked=false, no terminal chunk — because the fetch handler drainsbody.pipeinto a Buffer before responding. A finite generator still returns; an open-ended SSE or subscription stream would never respond at all.server.on('upgrade', …)is called on theBun.serveconfig object, which has no.on.500+ plain error text instead of200+ 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 motivatedBun.servehas not materialized. Lets us delete the fetch handler andbunDelegateToNodeServer, which currently exist to serve one port. Small: drop theisBunbranch inhttpServer(), keep the operations port's exclusive (non-reusePort) bind semantics, and re-run the Bun shards.Option B — make
Bun.servecapable and switch the main port to it. Comparable in size to the uWS transport work (#914), not a patch:BunWebSocketadapter overserver.upgrade(), mirroringserverHelpers/uwsServer.ts'sapp.ws()bridge, behind Harper's existingws-shaped abstraction;Readable.toWeb()instead of buffering (bunDelegateToNodeServeralready does this for the MCP SSE case);Connection: closewas 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
appendFileSyncprobe at the first line ofgetBunHTTPServer'sfetchHandlerand at theBun.serve(serveOptions)call inthreadServer.js: the former never fires for the main port, the latter fires once for the operations port.Related: #2210 (the
node:httpclose divergence this uncovered, fixed in #2351), #1679, #913.