fix(dashboard): return 404 JSON for unmatched /api paths instead of SPA HTML

The SPA catch-all (serve_spa) served index.html for any unmatched GET,
including unregistered /api/* endpoints. A missing API route therefore
came back as <!doctype html> with status 200, and JSON clients (the
desktop app's fetchJson) crashed with an opaque
'SyntaxError: Unexpected token <' instead of a clear error.

- web_server.py: unmatched /api or /api/... now returns 404 JSON
  ('No such API endpoint'); non-api paths still serve the SPA for
  client-side routing.
- main.cjs fetchJson: detect an HTML body / text/html content-type on a
  2xx response and reject with a clear message naming the URL, rather
  than a raw JSON.parse SyntaxError. Empty bodies resolve to null;
  malformed JSON reports the URL plus a snippet.
This commit is contained in:
emozilla 2026-05-30 20:05:49 -04:00
parent 6bd8132bf9
commit 4ed01f2fa4
2 changed files with 33 additions and 3 deletions

View file

@ -1617,10 +1617,29 @@ function fetchJson(url, token, options = {}) {
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
return
}
if (!text) {
resolve(null)
return
}
// A 2xx response whose body is HTML means the request fell through
// to the SPA index.html (e.g. an unregistered /api path). JSON.parse
// would throw an opaque `Unexpected token '<'` here, so surface a
// clear diagnostic with the offending URL instead.
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
const contentType = String(res.headers['content-type'] || '')
if (looksHtml || contentType.includes('text/html')) {
reject(
new Error(
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
'The endpoint is likely missing on the Hermes backend.'
)
)
return
}
try {
resolve(text ? JSON.parse(text) : null)
} catch (error) {
reject(error)
resolve(JSON.parse(text))
} catch {
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
}
})
}

View file

@ -4758,6 +4758,17 @@ def mount_spa(application: FastAPI):
@application.get("/{full_path:path}")
async def serve_spa(full_path: str, request: Request):
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
# An unmatched /api/* path is a missing/renamed endpoint, NOT a
# client-side route. Falling through to index.html here returns
# `<!doctype html>` with status 200, which makes JSON clients (the
# desktop app's fetchJson, dashboard fetch wrappers) blow up with an
# opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON
# so the caller sees a clear "no such endpoint" instead.
if full_path == "api" or full_path.startswith("api/"):
return JSONResponse(
{"detail": f"No such API endpoint: /{full_path}"},
status_code=404,
)
file_path = WEB_DIST / full_path
# Prevent path traversal via url-encoded sequences (%2e%2e/)
if (