fix: stop the dev server from breaking WebSockets and Harper routes - #38
Conversation
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>
There was a problem hiding this comment.
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.
dawsontoth
left a comment
There was a problem hiding this comment.
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 /BuildwithAccept: application/json→ empty 404. WithAccept: */*→ the HTML shell, so the page's ownfetch()threwUnexpected token '<'and rendered nothing.- Every app WebSocket → the exact overlay you reported,
Cannot read properties of undefined (reading 'setHeader'), with the sameapplyHeaders → cors → corsMiddlewarestack.
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:19still reads "SPA: Vite servesindex.htmland assets" — the plugin serves it now. !request._nodeRequestalso makes the dev server silently no-op on the Bun/uWS adapters (both node objects arenull) where it previously threw. Silent is harder to diagnose than loud; a one-timelog(scope, 'warn', …)at setup would cover it. Niche — only if you think it earns the line.- With
appType: 'custom', Vite no longer runshtmlFallbackMiddleware, so a project with a second HTML entry now gets the root shell wheremainserved the real file:GET /about.htmlreturnsindex.html's content on this branch andabout.html's onmain. 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.
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>
|
Austin and I agreed I'd take this the rest of the way, so I've pushed d7416d6 and updated the description. Code change: Comments: corrected the WebSocket explanation in Not applied: the suggestion to wrap Verification: |
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>
Cross-model review (Codex + Gemini) — outcomeRan the pre-push cross-model review on d7416d6. Coverage: Codex (graded) + Gemini. Two legs did not contribute: Cursor Composer failed preflight ( No blockers. Neither Refuted — Gemini, Refuted — Gemini, Downgraded — Gemini, Not actionable — Gemini, Acted on, in c63e125:
Left open deliberately — one judgment call for a human. Two things I'd flag rather than fix:
|
|
🎉 This PR is included in version 1.1.7 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Found while building a Harper app that pairs the MQTT broker with a Vite frontend: with
harper devrunning, the browser showedCannot 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.tsbuilds the Request from the bare upgradeIncomingMessage, so it carries no_nodeResponse:registerHttppassed thatundefinedstraight into Connect asres. Vite's CORS middleware is first in the stack and callsres.setHeader()unconditionally: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
allowNexttrue, so after logging and broadcasting it callsnext()without the error and the connection itself survives. What kills a connection outright is our own 401 challenge, which writes to that same missingres: 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 caseallowedHosts: trueexists for, and it matches theAccessViolation … statusCode=403entries below. (The HMR socket itself is unaffected either way: it's claimed by theupgradegate 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 onrequest._nodeRequest.user = ....2.
appType: 'spa'swallowed every request bound for HarperVite appends
vite404MiddlewareforappType: 'spa'/'mpa', and it ends the response instead of callingnext():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
Acceptincludes the wildcard range, which is exactly whatfetch()sends by default: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 inrenderSpa, gated on the existingacceptsHtmlhelper — the same rulesetupProduction'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 againstmain, verified by reverting the two source files and re-running:hands WebSocket connections to Harper without running the middleware chainserves the transformed SPA shell for HTML navigationscreates 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 noindex.html).Integration: added a
harper dev (SPA)suite. The existing fixture is SSR-only, which is why neither bug was caught — SSR already usedappType: 'custom', so the SPA dev path had no coverage at all. The suite derives an SPA variant of the fixture (a physical copy withssrdropped from the component config — Harper v5's loader rejects symlinks) and asserts navigations get the shell while/Buildstays reachable with both a JSON and a wildcardAccept.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'sdevelopment.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()withEADDRNOTAVAIL. Fix it once withnpx harper-integration-test-setup-loopback(needs sudo; the framework README also ships a launchd plist, sinceifconfig lo0 aliasdoesn'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:Verification against the real app
With both fixes applied to
node_modules, restartingharper dev: no overlay, MQTT-over-WebSocket connects on the first attempt (retained message delivered on subscribe),POST /live-scoring/:id/eventsandGET /MatchState/:idreturn JSON, and a score published out-of-band over REST pushes live to the browser through theMatch/:idtopic with no reload. TheAccessViolation … statusCode=403entries that the broken chain was producing inhdb.logare also gone.🤖 Generated with Claude Code