Skip to content

fix: stop the dev server from breaking WebSockets and Harper routes - #38

Merged
dawsontoth merged 3 commits into
mainfrom
fix/dev-server-shared-origin
Aug 17, 2026
Merged

fix: stop the dev server from breaking WebSockets and Harper routes#38
dawsontoth merged 3 commits into
mainfrom
fix/dev-server-shared-origin

Conversation

@BboyAkers

@BboyAkers BboyAkers commented Aug 11, 2026

Copy link
Copy Markdown
Member

Found while building a Harper app that pairs the MQTT broker with a Vite frontend: with harper dev running, the browser showed Cannot read properties of undefined (reading 'setHeader') in Vite's HMR error overlay, the MQTT-over-WebSocket connection failed its first handshake, and every REST route returned an empty 404. The same resources worked perfectly over MQTT on port 1883, which is what pointed at the dev server rather than the app.

Two independent faults, both from the dev server not accounting for the fact that it shares an origin with Harper.

1. WebSocket connections were fed into the Connect chain

Harper runs the HTTP handler chain for WebSocket connections too — server/http.ts builds the Request from the bare upgrade IncomingMessage, so it carries no _nodeResponse:

websocketServers[port].on('connection', (ws, incomingMessage) => {
    const request = new Request(incomingMessage);   // no node response
    request.isWebSocket = true;
    const chainCompletion = httpChain[port](request);

registerHttp passed that undefined straight into Connect as res. Vite's CORS middleware is first in the stack and calls res.setHeader() unconditionally:

at applyHeaders  (vite/dist/node/chunks/node.js:7370)
at cors          (vite/dist/node/chunks/node.js:7394)
at corsMiddleware(vite/dist/node/chunks/node.js:7405)

Vite's error middleware broadcasts that TypeError to the browser's HMR overlay, so every app WebSocket — MQTT-over-WebSocket, Harper subscriptions, anything else — raises an error overlay for as long as HMR is on.

In middleware mode Vite builds that middleware with allowNext true, so after logging and broadcasting it calls next() without the error and the connection itself survives. What kills a connection outright is our own 401 challenge, which writes to that same missing res: any upgrade from a peer that isn't a loopback super_user rejects the chain, so REST closes with 1011 and MQTT's user promise rejects. That is the deployed-instance case allowedHosts: true exists for, and it matches the AccessViolation … statusCode=403 entries below. (The HMR socket itself is unaffected either way: it's claimed by the upgrade gate before Harper's default upgrade handler, so it never reaches this chain.)

Socket upgrades now go straight to nextLayer. There is nothing for a Connect middleware to do with one anyway. The guard also covers the Bun and uWS request adapters, which have neither node object and would have thrown a line later on request._nodeRequest.user = ....

2. appType: 'spa' swallowed every request bound for Harper

Vite appends vite404Middleware for appType: 'spa' / 'mpa', and it ends the response instead of calling next():

if (config.appType === "spa" || config.appType === "mpa") {
    middlewares.use(indexHtmlMiddleware(root, server));
    middlewares.use(notFoundMiddleware());   // res.statusCode = 404; res.end()
}

So nothing downstream of the dev server ever ran and every Harper resource 404'd in dev. Vite's HTML fallback is also looser than a shared origin can afford — it claims any GET whose Accept includes the wildcard range, which is exactly what fetch() sends by default:

!(req.headers.accept === undefined || req.headers.accept === "" ||
  req.headers.accept.includes("text/html") || req.headers.accept.includes("*/*"))

An app's own JSON calls therefore received the HTML shell, res.json() threw, and the page silently rendered empty.

The plugin now always creates the server with appType: 'custom' and serves the shell itself in renderSpa, gated on the existing acceptsHtml helper — the same rule setupProduction's SSR handler already uses, so dev and production agree on what counts as a navigation. Deep links still get the shell; assets and module transforms are unchanged (they're served earlier in Vite's stack); everything else falls through to Harper.

Tests

Unit tests (npm test) — 38 pass. Three of the new/changed ones fail against main, verified by reverting the two source files and re-running:

  • hands WebSocket connections to Harper without running the middleware chain
  • serves the transformed SPA shell for HTML navigations
  • creates a Vite dev server with HMR and the "custom" app type…

Plus guards for the fall-through paths (wildcard and JSON Accept, and a root with no index.html).

Integration: added a harper dev (SPA) suite. The existing fixture is SSR-only, which is why neither bug was caught — SSR already used appType: 'custom', so the SPA dev path had no coverage at all. The suite derives an SPA variant of the fixture (a physical copy with ssr dropped from the component config — Harper v5's loader rejects symlinks) and asserts navigations get the shell while /Build stays reachable with both a JSON and a wildcard Accept.

The integration suite has since been run locally: 11/11 pass on this branch, and the new SPA suite fails exactly 2 of its 4 against main's development.ts/http.ts (Harper resources stay reachable → 404, a wildcard Accept reaches the API → not JSON), so it guards what it claims.

The earlier local failure was environmental and unrelated to Harper install: the framework's loopback pool starts at 127.0.0.2 and macOS only binds 127.0.0.1 by default, so every suite died in before() with EADDRNOTAVAIL. Fix it once with npx harper-integration-test-setup-loopback (needs sudo; the framework README also ships a launchd plist, since ifconfig lo0 alias doesn't survive a reboot), or for a one-off run use a single-address pool — deleting the stale pool file first, since dead PID slots in it make allocation skip past your address:

rm -f $TMPDIR/harper-integration-test-loopback-pool.json
HARPER_INTEGRATION_TEST_LOOPBACK_POOL_START=1 HARPER_INTEGRATION_TEST_LOOPBACK_POOL_COUNT=1 npm run test:integration

Verification against the real app

With both fixes applied to node_modules, restarting harper dev: no overlay, MQTT-over-WebSocket connects on the first attempt (retained message delivered on subscribe), POST /live-scoring/:id/events and GET /MatchState/:id return JSON, and a score published out-of-band over REST pushes live to the browser through the Match/:id topic with no reload. The AccessViolation … statusCode=403 entries that the broken chain was producing in hdb.log are also gone.

🤖 Generated with Claude Code

Under `harper dev`, the Vite dev server was interfering with the rest of the
Harper surface it shares an origin with. Two independent faults:

1. Harper runs the HTTP handler chain for WebSocket connections too, building
   the Request from the bare upgrade IncomingMessage — so it has no
   `_nodeResponse`. Passing that into Connect as `res` made Vite's CORS
   middleware throw `Cannot read properties of undefined (reading 'setHeader')`
   on the first WebSocket connection. Vite's error middleware then broadcast the
   TypeError to the browser's HMR overlay, and the rejected chain broke the
   handshake — so MQTT-over-WebSocket and Harper subscriptions failed for as
   long as HMR was on. Socket upgrades now go straight to Harper.

2. `appType: 'spa'` makes Vite append `vite404Middleware`, which *ends* every
   request it did not serve with a bare 404 instead of calling `next()`. Nothing
   downstream ever ran, so every Harper resource 404'd in dev. Vite's HTML
   fallback is also looser than a shared origin can afford: it claims any GET
   whose Accept includes the wildcard range — what `fetch()` sends by default —
   so an app's own API calls silently received the HTML shell instead of JSON.

The plugin now always uses `appType: 'custom'` and serves the SPA shell itself,
gated on `acceptsHtml` — the same rule the production SSR handler already uses,
so dev and production agree on what counts as a navigation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request changes the Vite appType from 'spa' to 'custom' and introduces a custom renderSpa middleware to prevent Vite's default SPA handling from swallowing Harper API requests. It also updates registerHttp to bypass the middleware chain for WebSocket connections, preventing crashes due to missing Node request/response objects. The feedback suggests wrapping nextLayer(request) in a promise chain to safely catch synchronous throws, and delaying response header/status mutations in renderSpa until after the asynchronous HTML transformation completes to avoid inconsistent states on failure.

Comment thread src/http.ts
Comment thread src/development.ts

@dawsontoth dawsontoth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced both faults locally against a fresh SPA app under harper dev (Harper 5.2.1, Vite 8.2.1), then verified the fix. Both are real, and the fix lands in the right places.

On main:

  • GET /Build with Accept: application/json → empty 404. With Accept: */* → the HTML shell, so the page's own fetch() threw Unexpected token '<' and rendered nothing.
  • Every app WebSocket → the exact overlay you reported, Cannot read properties of undefined (reading 'setHeader'), with the same applyHeaders → cors → corsMiddleware stack.

On this branch: JSON and wildcard Accept both reach Harper (200, JSON), navigations and deep links get the transformed shell with /@vite/client, module transforms are still served, HMR is still live (edited a source file, page reloaded), the REST WS subscription delivers, MQTT-over-WS returns CONNACK 0, and the log has zero Vite internal errors.

Integration suite — your blocker was macOS loopback aliases, not the Harper install. Either npx harper-integration-test-setup-loopback (needs sudo; documented in the framework README), or for a one-off run:

rm -f $TMPDIR/harper-integration-test-loopback-pool.json
HARPER_INTEGRATION_TEST_LOOPBACK_POOL_START=1 HARPER_INTEGRATION_TEST_LOOPBACK_POOL_COUNT=1 npm run test:integration

All 11 pass on this branch. Against main's development.ts/http.ts the new SPA suite fails exactly 2 of its 4 (Harper resources stay reachable → 404, a wildcard Accept reaches the API → not JSON), so it guards what it claims. Unit tests are 38/38 here, and the 3 you listed do fail against main.

One correction for the description. On these versions the rejected chain does not break the WebSocket handshake. Vite registers errorMiddleware(server, !!middlewareMode), and in middleware mode allowNext is true — so after logging the error and broadcasting it to the overlay, it calls next() with no error and the connection proceeds to Harper. Measured on main: the REST subscription delivered both messages, and MQTT-over-WS got CONNACK 0. So in local dev the symptom is a spurious overlay plus log noise per connection, not a dead socket.

There is a genuine breakage path, just a different one: superUserAuth's 401 challenge(res, …) touches res.statusCode/res.setHeader, so with res undefined it throws inside the .then(), .catch(next) turns that into next(err), and that rejects the chain for real — REST closes 1011, MQTT's user promise rejects. That covers any WebSocket whose peer is not a loopback super_user, i.e. HMR against a deployed instance (what allowedHosts: true exists for), and it fits the AccessViolation … 403 entries you saw. Worth rewording rather than re-fixing; the guard covers it either way.

Three smaller notes, none blocking:

  • The doc comment at src/development.ts:19 still reads "SPA: Vite serves index.html and assets" — the plugin serves it now.
  • !request._nodeRequest also makes the dev server silently no-op on the Bun/uWS adapters (both node objects are null) where it previously threw. Silent is harder to diagnose than loud; a one-time log(scope, 'warn', …) at setup would cover it. Niche — only if you think it earns the line.
  • With appType: 'custom', Vite no longer runs htmlFallbackMiddleware, so a project with a second HTML entry now gets the root shell where main served the real file: GET /about.html returns index.html's content on this branch and about.html's on main. Reasonable for an SPA plugin — flagging it in case it should be a documented limitation.

Also worth knowing: src/index.test.ts's falls through to Harper for API requests Vite does not serve passes against main too. Its mocked middlewares always calls next(), so it can't model Vite's own 404 middleware — it's a fall-through guard rather than a regression test for the bug its comment describes. The integration suite is what actually pins that down.

Comment thread src/development.ts Outdated
Setting status 200 and `Content-Type: text/html` before awaiting
`transformIndexHtml` left both on the response when the transform threw, so
the error handler's 500 came back labelled as HTML. Transform first, then
write — the order `renderSsr` already uses.

The HEAD short-circuit goes with it: Node clears `_hasBody` for a HEAD
request, so `res.end(html)` already sends the headers and no body.

Also corrects the comments describing the WebSocket fault. In middleware mode
Vite builds its error middleware with `allowNext` true, so it logs the error,
broadcasts it to the browser's HMR overlay, and then calls `next()` without
it — the connection itself survived, and the visible symptom was an overlay on
every app WebSocket. What did kill a connection outright was our own 401
challenge writing to the missing `res`: an upgrade from a peer that is not a
loopback super_user rejected the chain. Same correction in the unit test's
comment, which also now says what it does and does not cover — the mocked
middlewares always call `next()`, so it pins down the `acceptsHtml` gate only,
not Vite's 404 middleware.

Notes the single-`index.html` assumption on `renderSpa`, since `appType:
'custom'` means Vite no longer runs its own multi-page HTML fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor

Austin and I agreed I'd take this the rest of the way, so I've pushed d7416d6 and updated the description.

Code change: renderSpa now transforms before it writes, so a transformIndexHtml failure can't leave a 200 text/html on the response the error handler turns into a 500 (it came back labelled as HTML — verified with a plugin whose transform throws). The HEAD short-circuit went with it rather than getting duplicated: Node clears _hasBody for HEAD, so res.end(html) already sends headers and no body (verified, size_download=0). This also matches renderSsr's ordering, and dev SSR doesn't special-case HEAD either.

Comments: corrected the WebSocket explanation in http.ts and the unit test to say what actually happens — the overlay fires on every app WebSocket, but Vite's allowNext means the connection survives; the real kill is the 401 challenge writing to the missing res. The falls through to Harper for API requests test now states that it covers the acceptsHtml gate only, since its mock can't model Vite's 404 middleware. Also fixed the stale setupDevelopment doc line and noted renderSpa's single-index.html assumption.

Not applied: the suggestion to wrap nextLayer(request) in Promise.resolve().then(...) — every handler downstream on that path is async or a non-throwing fallback, and all three call sites already await or Promise.resolve() the result. And I left the Bun/uWS bypass silent rather than adding a warn: those adapters have no _nodeRequest, so the dev server was already broken there before this PR (it threw on request._nodeRequest.user = …), and a one-shot warn needs module state for a path harper dev can't currently use.

Verification: format:check clean, unit tests 38/38 across repeated runs, integration 11/11 locally. Two things worth knowing for anyone running these locally: the loopback-alias setup is now written up in the description, and dist/index.test.js opens your real local Harper data dir at import time (via harper's security/auth), so it dies with a RocksDB LOCK error if anything else is using that install — a concurrent Harper core test run will do it. Pre-existing, not touched here, but it makes the suite look broken for a reason that has nothing to do with the plugin.

Cross-model review (Codex + Gemini) flagged that the header-ordering fix had
no test, and that several comments recount the incident instead of stating the
invariant.

Adds `leaves the response untouched when the HTML transform fails`: a rejecting
`transformIndexHtml` must leave no status and no headers behind for the error
handler's 500 to inherit. Verified it fails against the previous ordering.
`mockSpaDev` now takes an optional transform so the failure path is reachable.

Trims the WebSocket guard comment and two test comments to the invariants,
dropping the replayed symptom text — the commit that introduced the guard and
the PR description keep the searchable error string. The guard comment now also
says where the WebSocket surfaces are actually gated, which is the question a
reader arrives with: the HMR socket in `gateHmrWebSocket` on the upgrade chain,
an app's own sockets by Harper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor

Cross-model review (Codex + Gemini) — outcome

Ran the pre-push cross-model review on d7416d6. Coverage: Codex (graded) + Gemini. Two legs did not contribute: Cursor Composer failed preflight (cursor-agent not installed) and Cursor Grok was pruned as the round's second Cursor lens. The Harper-domain adjudication leg failed on an expired claude CLI OAuth token, so the outside findings arrived unadjudicated and I adjudicated them by hand — worth knowing when reading the grade, which is a deterministic Human-Review-Need: 4 driven by those two failed legs plus the then-unresolved major.

No blockers. Neither major survived scrutiny:

Refuted — Gemini, major, "POST with Accept: text/html is intercepted and the mutation silently lost". acceptsHtml already returns false for anything that isn't GET or HEAD (src/http.ts:27). Gemini reviews the diff alone and acceptsHtml's body is unchanged context, so it couldn't see the guard. Confirmed live under harper dev before this was raised: PUT /Item/2 → 204, POST /Item/ → 201, both reaching Harper.

Refuted — Gemini, minor (security), "the bypass skips the authenticate gate". That gate exists to protect the dev server's surface — module transforms and /@fs/ reads — and no WebSocket reaches it. The HMR socket is claimed earlier by gateHmrWebSocket's run-first upgrade handler and authenticated via isUpgradeAuthorized; an app's own WebSockets are Harper's surface, authenticated by Harper on this same chain (which is exactly why the broken chain was emitting AccessViolation … 403). Pre-PR, unauthenticated app WebSockets were killed by a TypeError in our 401 path, not by a deliberate gate. I added a line to the guard comment saying where each surface is actually gated, since that's the question a reader arrives with.

Downgraded — Gemini, major, sync existsSync/readFileSync per navigation. Real, but dev-only, once per HTML navigation (not per asset), and the same thing Vite's own indexHtmlMiddleware and the pre-existing renderSsr do. The read has to stay per-request for edits to index.html to show up. Not a regression this PR introduces.

Not actionable — Gemini, minor, async callback to server.middlewares could leak an unhandled rejection. Reachable only if next() throws synchronously; the next we pass wraps nextLayer in try/catch and rejects rather than throwing. Pre-existing pattern in renderSsr either way.

Acted on, in c63e125:

  • Codex, minor — the header-ordering fix had no test. Added leaves the response untouched when the HTML transform fails, which asserts a rejecting transformIndexHtml leaves no status and no headers for the 500 to inherit. I checked it fails against the previous ordering, so it genuinely guards the fix.
  • Codex + Gemini, nit (both lenses independently) — comments recount the incident instead of stating the invariant. Trimmed the WebSocket guard comment and two test comments. Kept renderSpa's rationale intact: it's what stops someone "simplifying" back to appType: 'spa'. The searchable setHeader error string lives in the commit message and above.

Left open deliberately — one judgment call for a human. Two things I'd flag rather than fix:

  1. Codex, minor — SPA HEAD now does the template read and full transform before Node discards the body. That is the cost of dropping the HEAD branch, and I took it knowingly: HEAD navigations to a dev server are effectively nonexistent, dev's renderSsr doesn't special-case HEAD either, and production keeps its short-circuit where it actually matters (src/production.ts:185, avoiding a DB-touching render). Happy to put the branch back if you'd rather have the symmetry.
  2. Codex, minor — the app-WebSocket half of the fix is covered only by a unit mock. Worth saying why an integration test is awkward: on a loopback super_user connection the socket works on main, so "does it connect" passes either way. Catching the real symptom means asserting no {type:'error'} frame arrives on the HMR socket after opening an app WebSocket. Doable, and I'd rather add it deliberately than bolt it on.

@dawsontoth
dawsontoth merged commit 4d1fd1a into main Aug 17, 2026
7 checks passed
@dawsontoth
dawsontoth deleted the fix/dev-server-shared-origin branch August 17, 2026 18:50
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.1.7 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants