Files
ipx/web/build.mjs
rays 1949f3e493 Compression across the board: directory answers, uploads, metrics, traces, the page (#253)
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:02:39 +00:00

127 lines
7.7 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 swc from '@swc/core';
import html from '@swc/html';
const here = path.dirname(fileURLToPath(import.meta.url));
// 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', 'gestures', '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)}`;
/// 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 page and its script, built: { html, js, script }, where script is the file's name.
export function buildPage(name, { minify = true } = {}) {
const { script, src: files } = PAGES[name];
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
.replaceAll('__HLS_JS__', HLS_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,
}).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, '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'],
['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));
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 }));
}
}