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

@@ -1,9 +1,15 @@
// 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.
// build.rs runs it into OUT_DIR, where web.rs include_str!s the result, so the binary still
// carries every page and nothing is served from disk.
// by swc into a script of its own (app.js, login.js), and the page minified, its
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
// 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
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
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
// scope, as the single inline script did, and code that runs at load needs what came before it.
const PAGES = {
'index.html': ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'],
'login.html': ['login'],
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'] },
'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 } = {}) {
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, {
filename: name + '.ts',
jsc: {
@@ -36,17 +44,23 @@ export function buildPage(name, { minify = true } = {}) {
const page = fs.readFileSync(path.join(here, name), 'utf8');
const marker = /<script data-src="[^"]*"><\/script>/;
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 out = page.replace(marker, () => `<script>${js}</script>`);
if (!minify) return out;
const v = crypto.createHash('sha256').update(js).digest('hex').slice(0, 12);
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
// 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 bad = (r.errors || []).filter(e => e.level === 'Error');
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)) {
const out = process.argv[2] || path.join(here, 'dist');
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);
}
}