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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
30
src/web.rs
30
src/web.rs
@@ -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> {
|
||||||
|
|||||||
@@ -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; } },
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user