HarperFast / HarperFast/harper
Bun runs the main HTTP port on node:http, not Bun.serve — pick one transport
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
## 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 — #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
```sh
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.
Contributor guide
Research direction
Start with server/http.ts, getBunHTTPServer, threadServer.js, and serverHelpers/uwsServer.ts, then run the Bun streaming contract command in the issue. Trace both transport paths and decide which implementation can consistently handle streaming, WebSockets, binding, and connection closure; done means one explicit Bun transport serves the relevant ports and the Bun integration shards pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bun, javascript, node.js
- Domain
- backend, networking
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100