Files
ipx/web/build.mjs
rays b509ea4b35 Coverage for the Rust and the page, and tests for the offline gaps (#390, #391)
Nothing measured what the tests reach. scripts/coverage.sh runs the
cargo tests under cargo-llvm-cov in the ipx-coverage image
(Dockerfile.coverage, built by build-image.sh coverage), in a container
held to the image builder's 6 GB and six threads, one crate at a time,
with the same room and load checks: an instrumented build rebuilds every
dependency, the kind of build that took Tower down on 2026-11-04. The
first run stayed under load 8 with 11 GB free; 69% of lines. web.rs and
daemon.rs read low because the browser suite drives them against a
daemon that is not instrumented.

npm run coverage:page runs the browser suite on a build of the page
(IPX_COVERAGE=1) whose script keeps a source map to each web/src file:
web/concat-map.mts maps the joined files line by line and swc takes it
as its input map. Each test's page records V8's coverage
(tests/ui/coverage.ts, which the specs now take test and expect from),
and monocart-coverage-reports writes it file by file: 73% of lines.
build.mjs requires the map helper only for that build, since
Dockerfile.web's Node does not run TypeScript, and not with a top-level
await, which stopped the CommonJS tests requiring build.mjs.

The offline tests move to offline.spec.ts, in TypeScript, and stand
alone (they add their feed and set the read state they need), with what
#391 found untested: another account signing in clears what was kept, a
position saved offline waits and is sent, and pictures past the budget
go oldest first (keepPictures takes the budget, and trims whether or
not anything new came). The fixture JPEGs were a few bytes no browser
could draw; they are real now, and the artwork test checks one draws.
tsconfig.tools.json type-checks the tests' and build's TypeScript, in
npm run typecheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:55:19 +00:00

174 lines
11 KiB
JavaScript

// Builds the frontend (#203): each page's TypeScript from web/src, types stripped and minified
// by swc into a script of its own (app.js, login.js), the page minified, its <script data-src>
// pointing at that script, and the icons, logos and typeface beside them. The result, web/dist,
// is a static site: Caddy serves it (web/Caddyfile, Dockerfile.web) and passes the API to ipx.
//
// 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. Caddy's file
// server ignores the query.
//
// 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 zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import swc from '@swc/core';
import html from '@swc/html';
const here = path.dirname(fileURLToPath(import.meta.url));
// A build for measuring what the browser tests run (#390), IPX_COVERAGE=1: the script not minified
// and with a source map back to each file of web/src, which concat-map.mts makes. Loaded only then:
// it is TypeScript, which the Node of Dockerfile.web does not run.
// Required, not imported with a top-level await, which would stop the tests require()ing this file.
const coverageMap = process.env.IPX_COVERAGE ? createRequire(import.meta.url)('./concat-map.mts').concatMap : null;
// The page's own version (package.json), given to its script in place of __WEB_VERSION__.
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
// 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': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'offline', 'events', 'native'] },
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
'login.html': { script: 'login.js', src: ['login'] },
};
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
const STYLE = 'app.css';
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
// colour, and an index of names and search terms the picker fetches when it opens. The page's
// script is given the index's address, named by its hash, in place of __ICONS__.
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
const FA_STYLES = ['solid', 'regular', 'brands'];
/// [`style/name`, label, search terms] for every icon, solid first.
export function iconIndex() {
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
const name = f.slice(0, -4), m = meta[name] || {};
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
}));
}
const ICONS = JSON.stringify(iconIndex());
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
/// one; the doctype only keeps it from complaining that a fragment has none.
export function buildStyle({ minify = true } = {}) {
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
if (!minify) return css;
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
// The minifier drops the space between a calc() and the value after it in a shorthand --
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
// declaration away, so the element silently loses its padding. It reports no error and the
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
return out;
}
/// The offline service worker (#389), web/sw/sw.ts made JavaScript, at the top of the site.
export function buildWorker({ minify = true } = {}) {
const src = fs.readFileSync(path.join(here, 'sw', 'sw.ts'), 'utf8');
return swc.transformSync(src, {
filename: 'sw.ts',
jsc: { parser: { syntax: 'typescript' }, target: 'es2022', minify: minify ? { compress: true, mangle: true } : undefined },
isModule: false,
minify,
}).code;
}
/// The page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = !coverageMap } = {}) {
const { script, src: files } = PAGES[name];
const parts = files.map(f => ({ name: `web/src/${f}.ts`, text: fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8') }));
const src = parts.map(p => p.text).join('\n')
.replaceAll('__HLS_JS__', HLS_URL)
.replaceAll('__ICONS__', ICONS_URL)
.replaceAll('__WEB_VERSION__', WEB_VERSION);
const js = swc.transformSync(src, {
filename: name + '.ts',
jsc: {
parser: { syntax: 'typescript' },
target: 'es2022',
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
// and the browser tests reach others (player, savePos) through page.evaluate.
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
},
isModule: false,
minify,
...(coverageMap ? { sourceMaps: 'inline', inputSourceMap: JSON.stringify(coverageMap(parts)) } : {}),
}).code;
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`);
// 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.
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
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 { 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 });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'sw.js'), buildWorker());
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
// first when the site is added to a home screen (#109). /icon.png is the 2004 icon, which
// nothing here shows any more but the classic theme.
for (const [from, to] of [
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
// The fonts Settings offers besides Inter (#359), with their licences.
fs.cpSync(path.join(here, 'fonts'), path.join(out, 'fonts'), { recursive: true });
for (const style of FA_STYLES) {
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
}
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
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);
}
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
const body = fs.readFileSync(path.join(out, f));
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
}));
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
}
}