From 4ed01f2fa40aa7a614c46208e7a2c3e8f463f419 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 30 May 2026 20:05:49 -0400 Subject: [PATCH] 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 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. --- apps/desktop/electron/main.cjs | 25 ++++++++++++++++++++++--- hermes_cli/web_server.py | 11 +++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 17a37e15d6f..150f0cfe9b7 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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)}`)) } }) } diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index cd294681fc8..eee4710bbc3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 + # `` 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 (