From 68bac7986bd1225cc7f068e08002367bb7674831 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 31 May 2026 12:24:28 +0200 Subject: [PATCH] nix/hive-gateway: SPA fallback via Accept-header map, not extension allowlist (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara on PR #729: "this still feels hacky - is there a proper way to do this?" damocles: agreed, "Accept-header map is meaningfully better than the allowlist [...] one map definition that encodes browser semantics directly, vs ~20 extensions to keep synced with whatever fluffychat (and any future hyperhive-served SPA) decides to ship". The previous shape (#684 catch-all regex, then this PR v1's extension allowlist) leaned on heuristics to distinguish "missing asset → 404" from "unknown SPA route → fall back to index.html". Both shapes were fragile against a SPA shipping a new extension, and the allowlist became dead code the moment a route ended in `.html-ish-suffix`. The proper distinction lives at the HTTP layer: top-frame browser navigations send `Accept: text/html,...` (chrome/firefox/safari are consistent on this). Asset fetches from script tags / img / fetch() / XHR send asset-typed Accepts (`image/*`, `application/javascript`, `*/*`) without `text/html`. Mechanics: an `nginx http`-context `map` keyed on `$http_accept` emits either `/matrix/index.html` (navigation) or a sentinel nonexistent path (`/__matrix_spa_no_html_fallback`); the location's `try_files $uri $uri/ $matrix_spa_target =404;` does the right thing for both cases. No extension list, no regex narrowing, no `if` block, no named-location fallback. The `map` lives in `services.nginx.appendHttpConfig` (only added when the matrix GUI is on, otherwise no `map` directive at all). The location's `extraConfig` is now a single `try_files` line. Verified via `nix eval` on both the rendered `appendHttpConfig` and the location's `extraConfig`. Full closure build pending operator deploy. Closes #686. --- nix/modules/hive-gateway.nix | 75 ++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 74af4d26..d73b5ce1 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -147,6 +147,35 @@ in enable = true; recommendedProxySettings = true; recommendedOptimisation = true; + # SPA-fallback target keyed on the `Accept` request header + # (#686, mara + damocles on PR #729). This decides whether a + # `/matrix/...` miss falls through to `index.html` (route + # navigation) or returns a clean 404 (asset miss) — see the + # `/matrix/` location comment below for the full rationale. + # + # Top-frame browser navigations always send + # `Accept: text/html,...` (chrome/firefox/safari are + # consistent on this). Asset fetches from script tags / img + # / fetch() / XHR send asset-typed Accepts (`image/*`, + # `application/javascript`, `*/*`) without `text/html`. + # Mapping is purely on the header → no extension allowlist + # to keep in sync with whatever the SPA ships, no regex + # heuristic to false-positive on dot-segment routes. + # + # `$matrix_spa_target` defaults to a sentinel nonexistent + # path so `try_files` falls through to the trailing `=404` + # for asset misses. Browser navigations route to + # `/matrix/index.html` where the SPA's client-side router + # takes over. + # + # Only emitted when the matrix GUI is on (saves a no-op + # `map` directive otherwise). + appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' + map $http_accept $matrix_spa_target { + default "/__matrix_spa_no_html_fallback"; + "~*text/html" "/matrix/index.html"; + } + ''; virtualHosts."_" = { listen = [ { @@ -158,35 +187,31 @@ in # Matrix GUI: when the operator has flipped both # `services.hyperhive.matrix.enable` and `matrix.gui.enable` # on, nginx serves fluffychat-web (or whatever override) - # as a static dist at `/matrix/`. fluffychat is a SPA - # — fall back to its index.html on deep links. + # as a static dist at `/matrix/`. + # + # SPA fallback (iris/#643, rewritten in #686 per mara + # + damocles on PR #729): the original + # `try_files $uri $uri/ /matrix/index.html;` shape + # silently masked missing assets — flutter's bootstrap + # requesting e.g. `/matrix/native_executor.js` got + # `index.html` (Content-Type: text/html, status 200) + # when the file was absent from the dist, so the JS + # runtime never loaded and `/matrix/` rendered blank + # without any visible error. + # + # The followup #729 narrowed it with an extension + # allowlist; this version uses `$matrix_spa_target` + # (defined in the `appendHttpConfig` above, keyed on + # the `Accept` header) so the decision lives in HTTP + # semantics rather than a maintained extension list. + # Navigations (Accept: text/html) fall to index.html; + # asset fetches (Accept: */*, image/*, etc.) get a + # clean 404 via the trailing `=404`. lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable) { "/matrix/" = { alias = "${matrixCfg.gui.package}/"; extraConfig = '' - try_files $uri $uri/ @matrix_spa_fallback; - ''; - }; - # SPA fallback (iris/#643). The naive - # `try_files $uri $uri/ /matrix/index.html;` shape - # silently masked missing static assets — flutter's - # bootstrap requesting e.g. `/matrix/native_executor.js` - # got `index.html` (Content-Type: text/html, status - # 200) when the file was absent from the dist, so - # the JS runtime never loaded and `/matrix/` rendered - # blank without any visible error. - # - # Asset-shaped URIs (anything with a `.` segment) - # get an explicit 404 so the SPA + browser see the - # missing-asset error cleanly. Only route-shaped URIs - # (no extension) fall through to index.html for SPA - # client-side routing. - "@matrix_spa_fallback" = { - extraConfig = '' - if ($uri ~ "\.[A-Za-z0-9]+$") { - return 404; - } - rewrite ^ /matrix/index.html last; + try_files $uri $uri/ $matrix_spa_target =404; ''; }; }