Serve the script as /app.js, cached until a deploy changes it

The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.

Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:03:25 +00:00
parent e2969bcee8
commit 5483355021
7 changed files with 84 additions and 20 deletions

View File

@@ -27,6 +27,8 @@ The long form, with what was wrong before and how it was found, is in
them, where long notes left them looking missing. them, where long notes left them looking missing.
- The page is served minified, about a quarter smaller. Its script is now TypeScript in - The page is served minified, about a quarter smaller. Its script is now TypeScript in
`web/src`, type-checked, and built with swc; building ipx needs node. `web/src`, type-checked, and built with swc; building ipx needs node.
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
between visits and fetches it again only when an update changes it.
### Fixed ### Fixed

View File

@@ -62,8 +62,10 @@ the shell running the command and kills the session (exit 144). This has happene
## Before you touch the page ## Before you touch the page
The page is markup and CSS in `web/index.html` and TypeScript in `web/src/`. `build.rs` runs The page is markup and CSS in `web/index.html` and TypeScript in `web/src/`. `build.rs` runs
`web/build.mjs`, which uses swc to strip the types, put the script into the page and minify the `web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
whole thing, and the result is `include_str!`d into the binary. So **every page change needs a `login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
loads its script as `/app.js?v=<hash of its contents>`: the page is served `no-cache` and the
script `immutable`, so a browser keeps the script until a deploy changes it and its name. So **every page change needs a
rebuild** before it is visible, and building needs node and `npm ci` run once. rebuild** before it is visible, and building needs node and `npm ci` run once.
The files in `web/src` are not modules. They are one script split up, concatenated in the order The files in `web/src` are not modules. They are one script split up, concatenated in the order

View File

@@ -21,7 +21,7 @@ it to a running daemon.
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — | | `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
| `web/index.html` | The page's markup and CSS | — | | `web/index.html` | The page's markup and CSS | — |
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — | | `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
| `web/build.mjs` | swc: strips the types, puts the script in the page, minifies it | — | | `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — | | `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a

View File

@@ -65,6 +65,8 @@ pub fn router(state: WebState) -> Router {
.route("/login", get(login_page)) .route("/login", get(login_page))
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/icon.png", get(icon))
.route("/app.js", get(app_js))
.route("/login.js", get(login_js))
.route("/inter.woff2", get(inter)) .route("/inter.woff2", get(inter))
.layer(middleware::from_fn(access_log)) .layer(middleware::from_fn(access_log))
.with_state(state) .with_state(state)
@@ -441,7 +443,29 @@ async fn login_page(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state.ctx.cfg(), &req).is_some() { if vouched_name(&state.ctx.cfg(), &req).is_some() {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))).into_response() ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
.into_response()
}
/// The pages are checked on every visit, so a browser always has the one naming the current
/// scripts; the scripts, named by a hash of their contents (/app.js?v=<hash>, see
/// web/build.mjs), are kept a year and never asked for again. A deploy that changes a script
/// changes its name in the page, and the browser fetches it.
const PAGE_CACHE: &str = "no-cache";
const SCRIPT_CACHE: &str = "public, max-age=31536000, immutable";
/// The page's script, and the sign-in page's. Outside the auth layer, like the icon: the sign-in
/// page needs its own before anyone has signed in, and neither holds anything private.
async fn app_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/app.js")))
}
async fn login_js() -> impl IntoResponse {
script(include_str!(concat!(env!("OUT_DIR"), "/login.js")))
}
fn script(js: &'static str) -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, SCRIPT_CACHE)], js)
} }
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The /// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
@@ -478,8 +502,8 @@ const LOG_BUTTON: &str = "<button id=logs ";
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's /// The page, with the log button left out for anyone but an admin. Hiding it from the page's
/// script instead showed it for a moment on every load, until /api/me answered. /// script instead showed it for a moment on every load, until /api/me answered.
async fn index(user: crate::db::User) -> Html<std::borrow::Cow<'static, str>> { async fn index(user: crate::db::User) -> impl IntoResponse {
Html(page_for(user.is_admin)) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin)))
} }
fn page_for(admin: bool) -> std::borrow::Cow<'static, str> { fn page_for(admin: bool) -> std::borrow::Cow<'static, str> {

View File

@@ -11,9 +11,12 @@ const vm = require('vm');
const { buildPage } = require('../web/build.mjs'); const { buildPage } = require('../web/build.mjs');
// What ships: minified, so an id may have lost its quotes. // What ships: minified, so an id may have lost its quotes.
const html = buildPage('index.html'); const { html, js: script, script: file } = buildPage('index.html');
const script = html.split('<script>')[1].split('</script>')[0]; if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
const ids = new Set([...html.matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2])); console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = []; const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },

View File

@@ -20,6 +20,22 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
expect(errors, 'the page script must not throw at load').toEqual([]); expect(errors, 'the page script must not throw at load').toEqual([]);
}); });
test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
const doc = await page.reload();
const r = await js;
// The page is checked every visit, so it always names the current script...
expect(doc.headers()['cache-control']).toBe('no-cache');
// ...by a hash of its contents, which is why the script itself can be kept for a year.
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
expect(r.headers()['cache-control']).toContain('immutable');
expect(r.headers()['content-type']).toContain('javascript');
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
// The sign-in page's script loads before signing in.
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
expect(login.status()).toBe(200);
});
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => { test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]); const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor); const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
@@ -903,6 +919,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
// its own button makes it unread again. // its own button makes it unread again.
await page.locator('.ep').first().click(); await page.locator('.ep').first().click();
await page.locator('#detail [data-a="read"][title="Mark unread"]').click(); await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
// begin with, and a mark-unread still in flight could land after the read-all below.
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
await expect(all.locator('.badge')).not.toHaveText('0'); await expect(all.locator('.badge')).not.toHaveText('0');
page.once('dialog', d => d.accept()); page.once('dialog', d => d.accept());

View File

@@ -1,9 +1,15 @@
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified // Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
// by swc, put into the page in place of its <script data-src>, and the whole page minified. // by swc into a script of its own (app.js, login.js), and the page minified, its
// build.rs runs it into OUT_DIR, where web.rs include_str!s the result, so the binary still // <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
// carries every page and nothing is served from disk. // include_str!s the results, so the binary still carries everything and nothing is served
// from disk.
//
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
// server lets a browser keep that for a year without asking again. A changed script is a new
// URL, and the page, which the browser checks on every visit, is what carries it.
// //
// node web/build.mjs [out-dir] default out-dir: web/dist // node web/build.mjs [out-dir] default out-dir: web/dist
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -15,12 +21,14 @@ const here = path.dirname(fileURLToPath(import.meta.url));
// The files are one script, concatenated in this order, not modules: they share one top-level // The files are one script, concatenated in this order, not modules: they share one top-level
// scope, as the single inline script did, and code that runs at load needs what came before it. // scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = { const PAGES = {
'index.html': ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'], 'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'] },
'login.html': ['login'], 'login.html': { script: 'login.js', src: ['login'] },
}; };
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) { export function buildPage(name, { minify = true } = {}) {
const src = PAGES[name].map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n'); const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n');
const js = swc.transformSync(src, { const js = swc.transformSync(src, {
filename: name + '.ts', filename: name + '.ts',
jsc: { jsc: {
@@ -36,17 +44,23 @@ export function buildPage(name, { minify = true } = {}) {
const page = fs.readFileSync(path.join(here, name), 'utf8'); const page = fs.readFileSync(path.join(here, name), 'utf8');
const marker = /<script data-src="[^"]*"><\/script>/; const marker = /<script data-src="[^"]*"><\/script>/;
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`); if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
// A function, so a `$&` or `$1` in the script is not read as a replacement pattern. const v = crypto.createHash('sha256').update(js).digest('hex').slice(0, 12);
const out = page.replace(marker, () => `<script>${js}</script>`); // Where the inline script was, and a plain <script src>, so it still runs in the same place:
if (!minify) return out; // after the markup it wires up, before anything else.
const out = page.replace(marker, `<script src="/${script}?v=${v}"></script>`);
if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true }); const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error'); const bad = (r.errors || []).filter(e => e.level === 'Error');
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`); if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
return r.code; return { html: r.code, js, script };
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) { if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist'); const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true }); fs.mkdirSync(out, { recursive: true });
for (const name of Object.keys(PAGES)) fs.writeFileSync(path.join(out, name), buildPage(name)); for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);
fs.writeFileSync(path.join(out, script), js);
}
} }