Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: a request that finds the login ended reloads the page to the login

The gateway answered every non-page request without a session with a login
redirect, which fetch and EventSource cannot follow across origins, so polls,
forms and streams failed with errors that said nothing about the session. It
now answers anything that does not ask for text/html with a 401 and a
WWW-Authenticate header, and the swarm UI reloads on that 401, from a wrapped
fetch (one place for every call) and from a probe after a stream dies. A second
reload within ten seconds is skipped so a page that keeps answering 401 cannot
loop. The proxy test and the docs follow.
This commit is contained in:
iris 2026-10-28 18:14:58 +01:00 • committed by mara
commit 4d5a65a185
6 changed files with 187 additions and 9 deletions

View file

@ -21,6 +21,7 @@ import {
connectionAfterError,
type ConnectionState,
} from "../../ui/stream/connection.js";
import { probeSession } from "../../ui/session/session.js";
export type { ConnectionState };
@ -87,7 +88,12 @@ export function useSwarmAgentStateStream(
// badge cluster over — the next valid header replaces it.
}
};
es.onerror = () => setConnection(connectionAfterError(es.readyState));
es.onerror = () => {
const state = connectionAfterError(es.readyState);
setConnection(state);
// The browser gave up: say whether the session ended, which `EventSource` does not tell.
if (state === "closed") probeSession();
};
return () => es.close();
}, [streamUrl, attempt]);

View file

@ -16,6 +16,7 @@ import {
connectionAfterError,
type ConnectionState,
} from "../../ui/stream/connection.js";
import { probeSession } from "../../ui/session/session.js";
export type { ConnectionState };
@ -68,7 +69,12 @@ export function useSwarmTermStream(
}
setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false }));
};
es.onerror = () => setConnection(connectionAfterError(es.readyState));
es.onerror = () => {
const state = connectionAfterError(es.readyState);
setConnection(state);
// The browser gave up: say whether the session ended, which `EventSource` does not tell.
if (state === "closed") probeSession();
};
return () => es.close();
}, [streamUrl, attempt]);

View file

@ -1,5 +1,9 @@
import { render } from "preact";
import { App } from "./App.js";
import { installSessionGuard } from "./ui/session/session.js";
// Before anything fetches: a 401 that means "log in again" reloads the page.
installSessionGuard();
const root = document.getElementById("root");
if (root) {

View file

@ -15,6 +15,7 @@ import {
connectionAfterError,
type ConnectionState,
} from "../../ui/stream/connection.js";
import { probeSession } from "../../ui/session/session.js";
export type { ConnectionState };
@ -70,7 +71,12 @@ export function useMessageStream(sinceMinutes: number): {
pending.push(m);
if (frame === null) frame = requestAnimationFrame(flush);
};
es.onerror = () => setConnection(connectionAfterError(es.readyState));
es.onerror = () => {
const state = connectionAfterError(es.readyState);
setConnection(state);
// The browser gave up: say whether the session ended, which `EventSource` does not tell.
if (state === "closed") probeSession();
};
return () => {
drop();
es.close();

View file

@ -0,0 +1,91 @@
// What the swarm UI does when the operator's login ends. The gateway answers
// an `/api/` request that has no session with a plain 401 and a
// `WWW-Authenticate` header (a page request still gets the login redirect), and
// until now nothing read it: polled pages showed "Failed to fetch", forms
// failed the same way and streams went to "closed" until a manual reload.
// Now that 401 reloads the page, which lands on the login and, once logged in,
// back on the same address.
//
// `installSessionGuard` wraps `fetch` once at startup, so the ~40 call sites
// need no change; `probeSession` is for a stream, whose `EventSource` is told
// only that it failed, never why.
const API_PREFIX = "/api/";
const MIN_RELOAD_GAP_MS = 10_000;
const RELOAD_KEY = "swarm-ui:session-reload-at";
// Not `/api/whoami`: the gateway proxies that to authelia, which answers a
// logged-out caller its own way. Any route behind the gateway's session check
// would do; this one is a cheap read.
const PROBE_URL = "/api/hives/status";
/** Whether a response means the session ended: a 401 from the gateway (it
* carries the challenge header; the controller's own 401s do not) on a
* same-origin `/api/` URL. */
export function isSessionExpired(
url: string,
status: number,
hasChallenge: boolean,
origin: string,
): boolean {
if (status !== 401 || !hasChallenge) return false;
const u = new URL(url, origin);
return u.origin === origin && u.pathname.startsWith(API_PREFIX);
}
/** Whether a reload may happen now. A reload that lands on a page which still
* answers 401 would otherwise loop, so a second one within the gap is skipped;
* a clock that went backwards counts as no reload yet. */
export function mayReload(lastAtMs: number | null, nowMs: number): boolean {
return (
lastAtMs === null ||
nowMs < lastAtMs ||
nowMs - lastAtMs >= MIN_RELOAD_GAP_MS
);
}
function reloadForLogin(): void {
const now = Date.now();
let last: number | null = null;
try {
const saved = Number(sessionStorage.getItem(RELOAD_KEY));
last = Number.isFinite(saved) && saved > 0 ? saved : null;
if (!mayReload(last, now)) return;
sessionStorage.setItem(RELOAD_KEY, String(now));
} catch {
// No session storage (a locked-down browser): reload anyway; the gateway
// sends a logged-out page to the login, so this cannot loop there.
}
window.location.reload();
}
function urlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
/** Wrap `window.fetch` so a 401 that means "log in again" reloads the page. */
export function installSessionGuard(): void {
const original = window.fetch.bind(window);
window.fetch = async (input, init) => {
const res = await original(input, init);
if (
isSessionExpired(
urlOf(input),
res.status,
res.headers.has("www-authenticate"),
window.location.origin,
)
) {
reloadForLogin();
}
return res;
};
}
/** Ask the gateway whether the session is still good, after a stream failed.
* An expired session answers 401 and the guard reloads; any other answer, or
* none, leaves the stream's own "closed" state to say what happened. */
export function probeSession(): void {
fetch(PROBE_URL, { cache: "no-store" }).catch(() => {});
}

View file

@ -0,0 +1,38 @@
// When the swarm UI reads a response as "the login ended". Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import { isSessionExpired, mayReload } from "../src/ui/session/session.ts";
const ORIGIN = "https://swarm.example";
test("a gateway 401 on an api address is an ended session", () => {
assert.equal(isSessionExpired("/api/agents/status", 401, true, ORIGIN), true);
assert.equal(
isSessionExpired(`${ORIGIN}/api/jobq/graph`, 401, true, ORIGIN),
true,
);
});
test("a 401 without the gateway's challenge is the controller's own", () => {
assert.equal(isSessionExpired("/api/agents", 401, false, ORIGIN), false);
});
test("other statuses and other addresses are not an ended session", () => {
assert.equal(isSessionExpired("/api/agents", 403, true, ORIGIN), false);
assert.equal(isSessionExpired("/api/agents", 500, true, ORIGIN), false);
assert.equal(isSessionExpired("/static/main.js", 401, true, ORIGIN), false);
assert.equal(
isSessionExpired("https://other.example/api/x", 401, true, ORIGIN),
false,
);
});
test("a reload is skipped when one just happened", () => {
assert.equal(mayReload(null, 1_000_000), true);
assert.equal(mayReload(1_000_000, 1_005_000), false);
assert.equal(mayReload(1_000_000, 1_010_000), true);
});
test("a clock that went backwards does not block the reload", () => {
assert.equal(mayReload(2_000_000, 1_000_000), true);
});