Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
067a260b85 | ||
|
|
24f6690fe5 |
5 changed files with 329 additions and 19 deletions
|
|
@ -815,6 +815,25 @@ request still gets the login redirect, and `swarmctl --swarm` still treats the
|
|||
401 as a sign to log in again. The page skips a second reload within ten
|
||||
seconds, so a page that keeps answering 401 can't loop.
|
||||
|
||||
## What the browser keeps
|
||||
|
||||
The vhost tells the browser what to cache. The bundle under `/static/` has
|
||||
content hashes in its names, so the browser keeps it for a year. The entry
|
||||
document, `/sw.js`, the manifest, the icons and the theme's `colors.css` have
|
||||
fixed names, so the browser revalidates them on every load, and a deploy
|
||||
reaches an open browser at its next load. A file that doesn't exist answers
|
||||
404, including a `/static/` file from an older build, rather than the entry
|
||||
document.
|
||||
|
||||
The service worker fetches its shell past the HTTP cache and refreshes the
|
||||
cached entry document on each page load that reaches the network. When its
|
||||
install fails, the previous worker keeps running and the browser console shows
|
||||
why.
|
||||
|
||||
The pages carry a content security policy: scripts and connections only from
|
||||
this origin, images from this origin, `data:` and https, and no framing from
|
||||
another origin.
|
||||
|
||||
## What it costs to be reachable
|
||||
|
||||
hyperhive publishes the apex to the hive's resolver like every other
|
||||
|
|
|
|||
|
|
@ -45,9 +45,41 @@ const SHELL_ASSETS = [
|
|||
"/manifest.webmanifest",
|
||||
];
|
||||
|
||||
// Every shell asset, fetched past the HTTP cache. `cache.addAll` goes through
|
||||
// it, so it could store a stale copy of the entry document under the new
|
||||
// version, and it rejects as a whole with nothing said when one asset fails.
|
||||
// Here a failure is logged and the install fails with it, which leaves the
|
||||
// previous worker in charge. A redirect counts as a failure: it is the login
|
||||
// page answering for an ended session, and must not be cached as the shell.
|
||||
async function precacheShell() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await Promise.all(
|
||||
SHELL_ASSETS.map(async (url) => {
|
||||
const response = await fetch(url, { cache: "reload" });
|
||||
if (!response.ok || response.redirected) {
|
||||
throw new Error(
|
||||
`${url}: status ${response.status}${response.redirected ? " after a redirect" : ""}`,
|
||||
);
|
||||
}
|
||||
await cache.put(url, response);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the cached entry document as fresh as the last navigation that
|
||||
// reached the network, so the offline fallback is not stuck on install time.
|
||||
function refreshShell(response) {
|
||||
return caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(SHELL_DOCUMENT, response));
|
||||
}
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)),
|
||||
precacheShell().catch((err) => {
|
||||
console.error("swarm-ui: service worker install failed", err);
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
// Take over from any previous SW as soon as this one's installed — an
|
||||
// operator installing the PWA for the first time (or after a SW
|
||||
|
|
@ -86,7 +118,14 @@ self.addEventListener("fetch", (event) => {
|
|||
// since every route renders the same SPA shell.
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(SHELL_DOCUMENT)),
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok && !response.redirected) {
|
||||
event.waitUntil(refreshShell(response.clone()));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(SHELL_DOCUMENT)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -107,9 +146,13 @@ self.addEventListener("fetch", (event) => {
|
|||
// asset on the next offline/failed fetch.
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, copy));
|
||||
// Held open with `waitUntil`: the worker can be stopped as soon as
|
||||
// it answers, which would drop a write still in flight.
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(event.request, copy)),
|
||||
);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
|
|
|
|||
148
frontend/packages/swarm-ui/test/sw.test.mjs
Normal file
148
frontend/packages/swarm-ui/test/sw.test.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// The service worker's own logic, run in a sandbox with a fake cache and a fake
|
||||
// fetch: what the install keeps, when it fails, and when a navigation refreshes
|
||||
// the cached entry document. Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
const SOURCE = readFileSync(new URL("../src/sw.js", import.meta.url), "utf8");
|
||||
const ORIGIN = "https://swarm.example";
|
||||
|
||||
function res({ ok = true, redirected = false, status = 200 } = {}) {
|
||||
const r = { ok, redirected, status, body: `body-${Math.random()}` };
|
||||
r.clone = () => ({ ...r });
|
||||
return r;
|
||||
}
|
||||
|
||||
// A worker loaded against a fake network. `network(url, init)` returns a
|
||||
// response or throws; `stores` is every cache the worker opened.
|
||||
function load(network) {
|
||||
const handlers = {};
|
||||
const stores = new Map();
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
const open = async (name) => {
|
||||
if (!stores.has(name)) stores.set(name, new Map());
|
||||
const store = stores.get(name);
|
||||
return {
|
||||
put: async (key, value) => {
|
||||
store.set(typeof key === "string" ? key : key.url, value);
|
||||
},
|
||||
keys: async () => [...store.keys()],
|
||||
};
|
||||
};
|
||||
const sandbox = {
|
||||
self: {
|
||||
location: { origin: ORIGIN },
|
||||
addEventListener: (type, fn) => (handlers[type] = fn),
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: async () => {} },
|
||||
},
|
||||
caches: {
|
||||
open,
|
||||
keys: async () => [...stores.keys()],
|
||||
delete: async (name) => stores.delete(name),
|
||||
match: async (url) => {
|
||||
for (const store of stores.values()) {
|
||||
if (store.has(url)) return store.get(url);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
fetch: async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
calls.push({ url, init });
|
||||
return network(url, init);
|
||||
},
|
||||
URL,
|
||||
console: { error: (...args) => errors.push(args.join(" ")) },
|
||||
};
|
||||
runInNewContext(SOURCE, sandbox);
|
||||
return { handlers, stores, calls, errors };
|
||||
}
|
||||
|
||||
function install(worker) {
|
||||
const waits = [];
|
||||
worker.handlers.install({ waitUntil: (p) => waits.push(p) });
|
||||
return Promise.all(waits);
|
||||
}
|
||||
|
||||
function navigate(worker, url) {
|
||||
const waits = [];
|
||||
let answer;
|
||||
worker.handlers.fetch({
|
||||
request: { url: ORIGIN + url, mode: "navigate" },
|
||||
respondWith: (p) => (answer = p),
|
||||
waitUntil: (p) => waits.push(p),
|
||||
});
|
||||
return { answer, settled: () => Promise.all(waits) };
|
||||
}
|
||||
|
||||
test("the install fetches every shell asset past the HTTP cache and keeps it", async () => {
|
||||
const worker = load(() => res());
|
||||
await install(worker);
|
||||
assert.ok(worker.calls.length >= 7);
|
||||
assert.ok(worker.calls.every((c) => c.init?.cache === "reload"));
|
||||
const [store] = [...worker.stores.values()];
|
||||
assert.ok(store.has("/"));
|
||||
assert.ok(store.has("/static/colors.css"));
|
||||
});
|
||||
|
||||
test("a redirected asset fails the install and says why", async () => {
|
||||
const worker = load((url) =>
|
||||
url === "/static/colors.css" ? res({ redirected: true }) : res(),
|
||||
);
|
||||
await assert.rejects(install(worker), /colors\.css.*redirect/);
|
||||
assert.equal(worker.errors.length, 1);
|
||||
});
|
||||
|
||||
test("an asset the server refuses fails the install", async () => {
|
||||
const worker = load((url) =>
|
||||
url === "/manifest.webmanifest" ? res({ ok: false, status: 404 }) : res(),
|
||||
);
|
||||
await assert.rejects(install(worker), /manifest\.webmanifest.*404/);
|
||||
});
|
||||
|
||||
test("a navigation that reaches the network refreshes the cached entry document", async () => {
|
||||
const fresh = res();
|
||||
const net = load(() => fresh);
|
||||
await install(net);
|
||||
const nav = navigate(net, "/agents/iris/terminal");
|
||||
const answered = await nav.answer;
|
||||
await nav.settled();
|
||||
assert.equal(answered, fresh);
|
||||
const [store] = [...net.stores.values()];
|
||||
assert.equal(store.get("/").body, fresh.body);
|
||||
});
|
||||
|
||||
test("a redirected or failed navigation does not replace the cached entry document", async () => {
|
||||
let next = res();
|
||||
const worker = load((url) => (url === "/" ? res() : next));
|
||||
await install(worker);
|
||||
const [store] = [...worker.stores.values()];
|
||||
const before = store.get("/");
|
||||
for (const bad of [
|
||||
res({ redirected: true }),
|
||||
res({ ok: false, status: 502 }),
|
||||
]) {
|
||||
next = bad;
|
||||
const nav = navigate(worker, "/agents");
|
||||
await nav.answer;
|
||||
await nav.settled();
|
||||
assert.equal(store.get("/"), before);
|
||||
}
|
||||
});
|
||||
|
||||
test("offline, a navigation is answered from the cached entry document", async () => {
|
||||
let online = true;
|
||||
const worker = load((url) => {
|
||||
if (online || url === "/") return res();
|
||||
throw new TypeError("offline");
|
||||
});
|
||||
await install(worker);
|
||||
online = false;
|
||||
const [store] = [...worker.stores.values()];
|
||||
const nav = navigate(worker, "/jobs");
|
||||
assert.equal(await nav.answer, store.get("/"));
|
||||
});
|
||||
|
|
@ -56,6 +56,38 @@ let
|
|||
${authRequest}
|
||||
error_page 401 =302 ${loginRedirect};
|
||||
'';
|
||||
|
||||
# What the swarm UI's pages may load. It is a same-origin SPA that holds an
|
||||
# operator session: no inline script, nothing from another origin except
|
||||
# images (agent messages can render a markdown image), and no framing
|
||||
# outside this origin. `style-src` keeps `'unsafe-inline'` because the
|
||||
# bundle sets inline styles.
|
||||
contentSecurityPolicy = lib.concatStringsSep "; " [
|
||||
"default-src 'self'"
|
||||
"img-src 'self' data: https:"
|
||||
"style-src 'self' 'unsafe-inline'"
|
||||
"frame-ancestors 'self'"
|
||||
"base-uri 'self'"
|
||||
"form-action 'self'"
|
||||
"object-src 'none'"
|
||||
];
|
||||
# `add_header` in a location replaces every header inherited from the
|
||||
# server, so each static location repeats the gateway's own and adds the
|
||||
# policy and its cache rule. The bundle's files are named by content hash
|
||||
# (`/static/main-<hash>.js`): those never change and are cached for a year.
|
||||
# The cache rule has no `always`, so an error answer (a 404 for a hash that
|
||||
# is gone) is never marked cacheable.
|
||||
# Everything else the page loads by a fixed name (the entry document,
|
||||
# `sw.js`, the manifest, `colors.css`, which the theme swaps) must be
|
||||
# revalidated, or a deploy can leave a browser on an old document that points
|
||||
# at scripts that no longer exist.
|
||||
staticHeaders = cache: ''
|
||||
${gatewayCfg.lib.securityHeaders}
|
||||
add_header Content-Security-Policy "${contentSecurityPolicy}" always;
|
||||
add_header Cache-Control "${cache}";
|
||||
'';
|
||||
revalidate = "no-cache";
|
||||
immutable = "public, max-age=31536000, immutable";
|
||||
swarmCfg = config.services.hyperhive.swarm;
|
||||
hiveDomain = config.services.hyperhive.domain;
|
||||
|
||||
|
|
@ -185,26 +217,57 @@ in
|
|||
root = "${deployCfg.swarm-controller.ui.package}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
${staticHeaders revalidate}
|
||||
# SPA: any path the bundle routes client-side is served the
|
||||
# entry document rather than a 404 from the filesystem.
|
||||
try_files $uri /index.html;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs (stylixThemeColors != null) {
|
||||
# Stylix theming (see the `let` block above): overrides just
|
||||
# this one file from `deployCfg.swarm-controller.ui.package`'s own static root, rather
|
||||
# than copying the whole tree to change one file inside it —
|
||||
# nginx resolves the more specific `=` exact match over the
|
||||
# `/` prefix root above, so this simply doesn't exist (falling
|
||||
# through to the package's own untouched colors.css) when
|
||||
# there's no active palette. Same `auth_request` gate as every
|
||||
# other location here — no reason for this one path to have a
|
||||
# different failure mode than the page that loads it.
|
||||
"= /static/colors.css" = {
|
||||
alias = "${themedColorsCss stylixThemeColors}";
|
||||
extraConfig = swarmAuthRequest;
|
||||
# The hashed bundle. A file that is not there is a 404, not the entry
|
||||
# document: after a deploy a browser still holding the old page asks for
|
||||
# `/static/main-<oldhash>.js`, and HTML answered with 200 left a blank
|
||||
# page with only a MIME-type error to explain it.
|
||||
"^~ /static/" = {
|
||||
root = "${deployCfg.swarm-controller.ui.package}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
${staticHeaders immutable}
|
||||
try_files $uri =404;
|
||||
'';
|
||||
};
|
||||
# The files at the root, by their fixed names: the worker (a fixed name
|
||||
# is how the browser finds an update), the manifest, the icons. They are
|
||||
# revalidated, and one that is not there is a 404 rather than the entry
|
||||
# document. One path segment only, so `/api/openapi.json` and the
|
||||
# `/.well-known/` answers keep their own locations.
|
||||
"~ ^/[^/]+\\.[A-Za-z0-9]+$" = {
|
||||
root = "${deployCfg.swarm-controller.ui.package}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
${staticHeaders revalidate}
|
||||
try_files $uri =404;
|
||||
'';
|
||||
};
|
||||
# The one unhashed file under `/static/`: the theme swaps it (see the
|
||||
# stylix note above), so it is revalidated, not immutable.
|
||||
"= /static/colors.css" =
|
||||
if stylixThemeColors != null then
|
||||
{
|
||||
alias = "${themedColorsCss stylixThemeColors}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
${staticHeaders revalidate}
|
||||
'';
|
||||
}
|
||||
else
|
||||
{
|
||||
root = "${deployCfg.swarm-controller.ui.package}";
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
${staticHeaders revalidate}
|
||||
try_files $uri =404;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// lib.optionalAttrs servesMatrixDiscovery {
|
||||
# Same JSON, headers and `:443` as the hive-domain copy in
|
||||
|
|
|
|||
|
|
@ -445,6 +445,43 @@ let
|
|||
&& bare.services.hyperhive.swarm.serviceDomains != [ ]
|
||||
&& lib.hasInfix "want_svc=0" bare.systemd.services.swarm-services-cert.script;
|
||||
}
|
||||
{
|
||||
# 🩸 A deploy left browsers on an old entry document that named
|
||||
# `/static/main-<oldhash>.js`; with no cache rule the browser kept that
|
||||
# document for a heuristic stretch of days, and the missing script was
|
||||
# answered with the entry document (200, text/html), so the page was blank
|
||||
# with only a MIME-type error to say why. The hashed bundle is immutable
|
||||
# and a missing file is a 404; every fixed name (the entry document, the
|
||||
# worker and the manifest, the theme's `colors.css`) is revalidated. The
|
||||
# cache rule has no `always`, so a 404 is never marked cacheable.
|
||||
name = "the swarm UI's static locations cache by name, 404 a missing file and carry the policy";
|
||||
ok =
|
||||
let
|
||||
l = allLocal.services.nginx.virtualHosts."t.local".locations;
|
||||
has = key: needle: lib.hasInfix needle l.${key}.extraConfig;
|
||||
gated = key: has key "auth_request /__hive_authelia;";
|
||||
armed =
|
||||
key:
|
||||
has key "add_header Content-Security-Policy \"default-src 'self'"
|
||||
&& has key "add_header X-Frame-Options";
|
||||
rootFiles = "~ ^/[^/]+\\.[A-Za-z0-9]+$";
|
||||
in
|
||||
has "^~ /static/" "add_header Cache-Control \"public, max-age=31536000, immutable\";"
|
||||
&& has "^~ /static/" "try_files $uri =404;"
|
||||
&& has "/" "add_header Cache-Control \"no-cache\";"
|
||||
&& has "/" "try_files $uri /index.html;"
|
||||
&& has "= /static/colors.css" "add_header Cache-Control \"no-cache\";"
|
||||
&& !(has "= /static/colors.css" "immutable")
|
||||
&& has rootFiles "add_header Cache-Control \"no-cache\";"
|
||||
&& has rootFiles "try_files $uri =404;"
|
||||
&& lib.all (k: gated k && armed k) [
|
||||
"/"
|
||||
"^~ /static/"
|
||||
"= /static/colors.css"
|
||||
rootFiles
|
||||
]
|
||||
&& !(has "^~ /static/" "Cache-Control \"public, max-age=31536000, immutable\" always");
|
||||
}
|
||||
{
|
||||
# nixos asserts when a vhost declares both, so this is also a
|
||||
# statement that the `removeAttrs` upstream of it still happens.
|
||||
|
|
|
|||
Loading…
Reference in a new issue