hive-gateway: route dashboard by path, not Accept header
The dashboard vhost split static-vs-backend on the request Accept header (map $http_accept $dashboard_spa_target), so the same URL behaved differently by content-type — e.g. /api/state fetched with Accept: text/html wrongly returned index.html. Now that all hive-c0re routes live under /api/ plus the single /webhook/knowledge endpoint, route by path instead: /api/ and /webhook/ proxy to c0re (SSE settings on /api/), everything else serves the dist with try_files $uri /index.html. Drops the dashboard Accept-header map and the @c0re named location. Updates docs/gateway.md accordingly.
This commit is contained in:
parent
5ae5657ca4
commit
d4f106d590
2 changed files with 38 additions and 27 deletions
|
|
@ -6,7 +6,7 @@ Single nginx in front of every hyperhive web surface. Container `hive-gateway`,
|
||||||
|
|
||||||
| URL | vhost | upstream | source |
|
| URL | vhost | upstream | source |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `<hive>/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`) + API/SSE/actions → hive-c0re (`7000`) via `@c0re` | always |
|
| `<hive>/` | `_` (catch-all) | dashboard dist (static, from `servedFrontend`); `/api/` + `/webhook/` → hive-c0re (`7000`) | always |
|
||||||
| `<hive>/agent/<name>/` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) |
|
| `<hive>/agent/<name>/` | `_` | per-agent harness (UDS or TCP) | `agents.conf` (runtime-generated) |
|
||||||
| `<hive>/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` |
|
| `<hive>/.well-known/matrix/{client,server}` | `_` | inline JSON (no upstream) | `matrix.enable && domain != null` |
|
||||||
| `<hive>/matrix/` (deprecated) | `_` | 301 → `matrix.<hive>/` | `matrix.gui.enable` |
|
| `<hive>/matrix/` (deprecated) | `_` | 301 → `matrix.<hive>/` | `matrix.gui.enable` |
|
||||||
|
|
@ -31,17 +31,24 @@ Federation peers fetch `.well-known/matrix/server` → `{"m.server":"matrix.<hiv
|
||||||
|
|
||||||
## SPA fallback (Accept-header pattern)
|
## SPA fallback (Accept-header pattern)
|
||||||
|
|
||||||
The `<hive>` catch-all (operator dashboard), the per-agent UIs, and the `matrix.<hive>` vhost all serve a flutter/SPA bundle. Two requirements collide:
|
The per-agent UIs and the `matrix.<hive>` vhost serve a flutter/SPA bundle via the Accept-header pattern below. (The `<hive>` dashboard catch-all used this too but now routes by **path** — see the dashboard note after.) Two requirements collide:
|
||||||
|
|
||||||
- hard-refresh on a sub-route must serve `index.html` (SPA's client-side router takes over after JS bootstrap)
|
- hard-refresh on a sub-route must serve `index.html` (SPA's client-side router takes over after JS bootstrap)
|
||||||
- a non-navigation request that isn't an on-disk asset must NOT get HTML with the wrong content-type
|
- a non-navigation request that isn't an on-disk asset must NOT get HTML with the wrong content-type
|
||||||
|
|
||||||
Solution: an `nginx http`-context `map $http_accept $<name>_spa_target { ... }` keyed on the request's Accept header. Browser navigations (`Accept: text/html,...`) get `index.html`; everything else (`Accept: image/*`, `*/*`, `application/json`, `text/event-stream`, …) gets a sentinel nonexistent path, so `try_files $uri $<name>_spa_target <final>` falls through to `<final>`. No extension allowlist, no `if` block, no regex heuristics.
|
Solution: an `nginx http`-context `map $http_accept $<name>_spa_target { ... }` keyed on the request's Accept header. Browser navigations (`Accept: text/html,...`) get `index.html`; everything else (`Accept: image/*`, `*/*`, `application/json`, `text/event-stream`, …) gets a sentinel nonexistent path, so `try_files $uri $<name>_spa_target <final>` falls through to `<final>`. No extension allowlist, no `if` block, no regex heuristics.
|
||||||
|
|
||||||
The two vhosts differ only in `<final>`:
|
For matrix / per-agent static assets, `<final>` is `=404` (a missing asset is just missing).
|
||||||
|
|
||||||
- **matrix / per-agent static assets** → `=404` (a missing asset is just missing).
|
### Dashboard: path-based routing (not Accept-header)
|
||||||
- **dashboard** → `@c0re` (a named location that reverse-proxies to hive-c0re `7000`). The dashboard's dynamic surface — every `/api/*`, the two SSE streams, the ~20 bare action/mutation routes (`/approve/{id}`, `/kill/{name}`, `/op-send`, …), and `/webhook/knowledge` — is all `Accept != text/html`, so it lands on `@c0re` automatically, **without enumerating a single backend prefix**. This is what lets the gateway static-serve the dashboard dist (from the `servedFrontend` nix-store path) while hive-c0re stays API-only — so a frontend-only change no longer rebuilds + restarts the core daemon. `@c0re` carries `proxy_buffering off` + a 1d read timeout (for the SSE streams) and a duplicated `auth_basic` block (named locations don't inherit it). Follow-up #1846 will move every backend route under `/api/`, collapsing this to a trivial `/api/* → c0re, else static` split.
|
|
||||||
|
Now that every hive-c0re backend route lives under `/api/` plus the single `/webhook/knowledge` endpoint, the dashboard vhost routes by **path**, not Accept header:
|
||||||
|
|
||||||
|
- `location /api/` → hive-c0re (`7000`): all dashboard data, actions/mutations, and the two SSE streams (`/api/dashboard/stream`, `/api/build-logs/id/{id}/stream`). Carries `proxy_buffering off` + a 1d read timeout for the streams.
|
||||||
|
- `location /webhook/` → hive-c0re: the knowledge webhook.
|
||||||
|
- `location /` → the dashboard dist (from the `servedFrontend` nix-store path) with `try_files $uri /index.html` (SPA fallback).
|
||||||
|
|
||||||
|
Each location carries a duplicated `auth_basic` block (separate locations don't inherit it). This keeps the gateway static-serving the dashboard dist while hive-c0re stays API-only — a frontend-only change no longer rebuilds + restarts the core daemon. The earlier `map $http_accept` Accept-header split was replaced because it made the *same* URL behave differently by content-type (e.g. `/api/state` fetched with `Accept: text/html` wrongly returned `index.html`); path routing is deterministic. A new top-level c0re route prefix (beyond `/api` + `/webhook`) needs a matching `location` added to the dashboard vhost.
|
||||||
|
|
||||||
## Local dev (`localHostsEntry`)
|
## Local dev (`localHostsEntry`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -747,8 +747,9 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Shared auth block — named locations don't inherit auth_basic, so
|
# Shared auth block — separate locations don't inherit auth_basic, so
|
||||||
# both `/` and `@c0re` need it or the proxied surface is unauthed.
|
# each dashboard location (`/`, `/api/`, `/webhook/`) needs it or that
|
||||||
|
# surface is unauthed.
|
||||||
dashboardAuth = lib.optionalString cfg.auth.enable ''
|
dashboardAuth = lib.optionalString cfg.auth.enable ''
|
||||||
auth_basic "${cfg.auth.realm}";
|
auth_basic "${cfg.auth.realm}";
|
||||||
auth_basic_user_file /run/hive-state/gateway.htpasswd;
|
auth_basic_user_file /run/hive-state/gateway.htpasswd;
|
||||||
|
|
@ -757,30 +758,39 @@ in
|
||||||
error_page 401 =401 /__hive_auth_unauthorized;
|
error_page 401 =401 /__hive_auth_unauthorized;
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Dashboard: nginx static-serves the dist, c0re is API-only. The
|
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
|
||||||
# Accept-header map splits without enumerating routes — html
|
# is by PATH, never content-type. c0re serves exactly two prefixes —
|
||||||
# navigations → SPA index.html, everything else (API/SSE/actions/
|
# `/api/` (all dashboard data + actions + the SSE streams) and
|
||||||
# webhook) → @c0re. Replaces the old `location / { proxy_pass c0re }`
|
# `/webhook/` (the knowledge webhook) — so those proxy to c0re and
|
||||||
# that made c0re ServeDir the dist (and restart on every frontend
|
# everything else serves the dist with an SPA fallback to index.html.
|
||||||
# change). New c0re routes need no gateway change.
|
# The earlier `map $http_accept` Accept-header split made the SAME
|
||||||
|
# url behave differently by content-type (e.g. `/api/state` fetched
|
||||||
|
# with `Accept: text/html` wrongly got index.html); path routing is
|
||||||
|
# deterministic. A new top-level c0re route prefix (beyond /api +
|
||||||
|
# /webhook) would need a matching location added here.
|
||||||
dashboardProxyLocation = {
|
dashboardProxyLocation = {
|
||||||
"/" = {
|
"/" = {
|
||||||
root = dashboardDist;
|
root = dashboardDist;
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
try_files $uri $dashboard_spa_target @c0re;
|
try_files $uri /index.html;
|
||||||
${dashboardAuth}
|
${dashboardAuth}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
"@c0re" = {
|
"/api/" = {
|
||||||
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
# off + 1d keep the SSE streams live.
|
# off + 1d keep the SSE streams (/api/dashboard/stream,
|
||||||
|
# /api/build-logs/id/{id}/stream) live.
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 1d;
|
proxy_read_timeout 1d;
|
||||||
${dashboardAuth}
|
${dashboardAuth}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
"/webhook/" = {
|
||||||
|
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
|
||||||
|
extraConfig = dashboardAuth;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
@ -862,17 +872,11 @@ in
|
||||||
recommendedTlsSettings = true;
|
recommendedTlsSettings = true;
|
||||||
recommendedGzipSettings = true;
|
recommendedGzipSettings = true;
|
||||||
recommendedOptimisation = true;
|
recommendedOptimisation = true;
|
||||||
# Accept-header SPA maps (see docs/gateway.md "SPA fallback"):
|
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
|
||||||
# text/html → index.html, else a sentinel so try_files falls
|
# "SPA fallback"): text/html → index.html, else a sentinel so
|
||||||
# through (dashboard → @c0re, matrix → 404). Dashboard map is
|
# try_files falls through to 404. The dashboard no longer uses an
|
||||||
# unconditional; matrix map only with the matrix GUI.
|
# Accept-header map — it routes by path (see dashboardProxyLocation).
|
||||||
appendHttpConfig = ''
|
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
|
||||||
map $http_accept $dashboard_spa_target {
|
|
||||||
default "/__dashboard_no_html_fallback";
|
|
||||||
"~*text/html" "/index.html";
|
|
||||||
}
|
|
||||||
''
|
|
||||||
+ lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
|
|
||||||
map $http_accept $matrix_spa_target {
|
map $http_accept $matrix_spa_target {
|
||||||
default "/__matrix_spa_no_html_fallback";
|
default "/__matrix_spa_no_html_fallback";
|
||||||
"~*text/html" "/index.html";
|
"~*text/html" "/index.html";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue