HarperFast / HarperFast/harper

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

Open
#2,355 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.