measure the site instead of reading a report back

Every performance decision here was made from a Lighthouse run somebody did by
hand in DevTools and typed into a conversation. That loop is slow and it is
lossy: nothing measured after a build, so three real defects sat live for weeks
— a `sizes` that overstated a column by 48px, font preloads that had gone
missing, an LCP image left lazy by a rename.

api/lighthouse.ts runs it: both form factors in about 20 seconds, four
categories, the five metrics, and every failing audit with the resources it
names. A finding that says "Improve image delivery — 32 KiB" is not actionable;
the same finding with the URL and the size served is a one-line fix.

The browser is looked for in three places before anything is downloaded —
CHROME_PATH, chrome-launcher's own search, then the Chrome-family binaries by
name, which is what finds the Brave already on this machine. chrome-launcher
THROWS rather than returning null when it finds nothing, so an unguarded call
never reaches the fallback; that is caught. Only if all three miss does
@puppeteer/browsers fetch one, as a library call rather than a postinstall,
because bun does not run postinstall for an untrusted package and a silent no-op
there would surface as a failure at the first audit.

First result: Total Blocking Time is 0 ms on both form factors, down from 130.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:22:33 +01:00
co-authored by Claude Opus 5
parent 52164a7fec
commit db781701e3
2 changed files with 311 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
import { existsSync } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* Lighthouse, run against the built site from the panel that builds it.
*
* ── Why this is here and not a CLI somebody runs ──
*
* Every performance decision on this site was made by reading a report run by hand in DevTools and
* typed back into a conversation. That loop is slow, and it is lossy in one particular way: nothing
* measures AFTER a build, so a regression is found whenever somebody next happens to look. Three real
* defects lived that way — a `sizes` string that overstated a column by 48px, font preloads that had
* gone missing entirely, an LCP image left `loading="lazy"` by a rename.
*
* ── The browser ──
*
* This machine has none. Ubuntu 24.04's apt `chromium` is a snap stub, and there is no Chrome repo.
* So the plugin fetches its own, once, through `@puppeteer/browsers` — a library call rather than a
* postinstall, because bun does not run postinstall for an untrusted package and a silent no-op there
* would surface as a failure at the first audit instead of at install time.
*
* An already-installed Chrome wins if there is one. Nothing is downloaded on a machine that has a
* browser already, and the download is ~150 MB the first time and free after that.
*/
/** Where the fetched browser lives. Beside the other caches, not in the plugin's own directory. */
const BROWSER_CACHE = join(homedir(), '.cache', 'officer', 'browsers');
/** What Lighthouse is asked for. The four scored categories; nothing here needs a trace viewer. */
const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'] as const;
export type FormFactor = 'mobile' | 'desktop';
export type AuditMetric = { id: string; label: string; value: number; display: string };
export type AuditFinding = {
id: string;
title: string;
/** 0–1, or null for an audit that is diagnostic rather than scored. */
score: number | null;
/** Bytes or milliseconds Lighthouse thinks this costs, when it says. */
savings: string | null;
/**
* WHICH resources, when the audit names any.
*
* "Improve image delivery — 32 KiB" is not actionable; the same finding with the URL of the image
* and the size it was served at is a one-line fix. Lighthouse carries them in `details.items` and
* the shape varies per audit, so this takes the two fields every table has and leaves the rest.
*/
items: { url: string; detail: string }[];
};
export type AuditResult = {
url: string;
formFactor: FormFactor;
scores: Record<string, number | null>;
metrics: AuditMetric[];
/** Everything that did not pass, worst first — the list to work down. */
findings: AuditFinding[];
finishedAt: string;
};
/**
* A Chrome to drive, downloading one if this machine has none.
*
* Resolved once per process and remembered: the check is a few stats, the download is minutes, and an
* audit of two form factors would otherwise ask twice.
*/
let resolving: Promise<string> | null = null;
export function chromePath(log: (line: string) => void = () => {}): Promise<string> {
if (resolving) return resolving;
resolving = (async () => {
/*
An installed browser wins, and it is looked for in three ways because one is not enough.
`CHROME_PATH` first: it is what chrome-launcher itself honours, so anybody who has already told
one tool where their browser is has told this one too.
Then chrome-launcher's own search, which knows every place a Chrome hides per platform. It
THROWS when it finds nothing rather than returning null — so an unguarded call does not fall
through to the download, it takes the whole audit down with `ChromePathNotSetError`. Caught.
Then the Chrome-family binaries by name, because the search above misses two that work perfectly:
Brave, which is Chromium underneath and is what this machine has, and a snap chromium. Same order
the iris plugin's wrapper uses, and for the same reason — the browser somebody already installed
is better than a second copy of one.
*/
const fromEnv = process.env.CHROME_PATH;
if (fromEnv && existsSync(fromEnv)) {
log(`using CHROME_PATH: ${fromEnv}`);
return fromEnv;
}
const { Launcher } = await import('chrome-launcher');
try {
const found = Launcher.getFirstInstallation();
if (found) {
log(`using the browser already installed: ${found}`);
return found;
}
} catch {
// Nothing where it looked. Not an error here — there are two more places to try.
}
for (const name of ['brave-browser', 'brave', 'chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) {
const which = Bun.which(name);
if (which) {
log(`using ${name}: ${which}`);
return which;
}
}
const browsers = await import('@puppeteer/browsers');
await mkdir(BROWSER_CACHE, { recursive: true });
const buildId = await browsers.resolveBuildId(
browsers.Browser.CHROMEHEADLESSSHELL,
browsers.detectBrowserPlatform()!,
'stable',
);
const existing = browsers.computeExecutablePath({
browser: browsers.Browser.CHROMEHEADLESSSHELL,
buildId,
cacheDir: BROWSER_CACHE,
});
if (existsSync(existing)) return existing;
log(`no browser on this machine — fetching Chrome ${buildId} (about 150 MB, once)`);
let lastPercent = -1;
const installed2 = await browsers.install({
browser: browsers.Browser.CHROMEHEADLESSSHELL,
buildId,
cacheDir: BROWSER_CACHE,
// A percentage per ten points rather than per chunk: this goes to a log somebody may be reading
// live, and a progress bar rendered one line per chunk is thousands of lines.
downloadProgressCallback: (downloaded, total) => {
const percent = Math.floor((downloaded / total) * 10) * 10;
if (percent === lastPercent) return;
lastPercent = percent;
log(` downloading Chrome… ${percent}%`);
},
});
log(`Chrome ready at ${installed2.executablePath}`);
return installed2.executablePath;
})();
// Not cached on failure: a download that fell over should be retried by the next caller rather than
// remembered as the answer forever.
resolving.catch(() => {
resolving = null;
});
return resolving;
}
/** Lighthouse's own numbers for a metric, in the shape a table wants. */
const METRIC_IDS = [
'first-contentful-paint',
'largest-contentful-paint',
'total-blocking-time',
'cumulative-layout-shift',
'speed-index',
] as const;
/**
* One audit.
*
* `formFactor` is the whole difference between the two runs somebody cares about: mobile throttles the
* CPU 4x and emulates a slow connection, desktop does neither, and a site can be 100 on one and 95 on
* the other for reasons that have nothing in common.
*/
export async function audit(
url: string,
formFactor: FormFactor,
log: (line: string) => void = () => {},
): Promise<AuditResult> {
const chrome = await chromePath(log);
const { launch } = await import('chrome-launcher');
const lighthouse = (await import('lighthouse')).default;
log(`${formFactor}: launching`);
const instance = await launch({
chromePath: chrome,
// `--headless=new` is the default for the headless shell; the rest is what a container wants.
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
try {
log(`${formFactor}: auditing ${url}`);
const run = await lighthouse(url, {
port: instance.port,
output: 'json',
logLevel: 'error',
onlyCategories: [...CATEGORIES],
formFactor,
// Lighthouse will not apply mobile emulation to a desktop form factor or the reverse, and
// getting this wrong is the classic way to produce a number that means nothing.
screenEmulation:
formFactor === 'desktop'
? { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }
: { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false },
throttlingMethod: 'simulate',
// Never. It ships to Sentry, and a build tool that phones home about this site is not something
// anybody asked for. Off by default in lighthouse too; said here so it stays off.
// biome-ignore lint/suspicious/noExplicitAny: the flags type is not exported
} as any);
if (!run?.lhr) throw new Error('lighthouse returned nothing');
const lhr = run.lhr;
const scores: Record<string, number | null> = {};
for (const key of CATEGORIES) {
const category = lhr.categories[key];
scores[key] = category?.score === null || category?.score === undefined ? null : Math.round(category.score * 100);
}
const metrics: AuditMetric[] = METRIC_IDS.map((id) => {
const found = lhr.audits[id];
return {
id,
label: found?.title ?? id,
value: typeof found?.numericValue === 'number' ? found.numericValue : 0,
display: found?.displayValue ?? '—',
};
});
/*
Everything that did not pass, worst first.
`scoreDisplayMode` is what separates a real failure from a note: `informative` and `manual` are
diagnostics that never had a score, and listing them as failures is how a report becomes noise
nobody reads. Anything scored below 0.9 is a finding.
*/
const findings: AuditFinding[] = Object.values(lhr.audits)
.filter((one) => {
if (one.scoreDisplayMode === 'informative' || one.scoreDisplayMode === 'manual') return false;
if (one.scoreDisplayMode === 'notApplicable') return false;
return typeof one.score === 'number' && one.score < 0.9;
})
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
.map((one) => {
/*
The rows, flattened to a URL and one line about it.
Every table-shaped audit carries `details.items`; what is IN an item differs — a byte saving,
a millisecond saving, a pair of dimensions, a node. So the numbers that exist are named and
the rest is dropped rather than guessed at, and an audit with no table simply has no rows.
*/
const details = one.details as { items?: Record<string, unknown>[] } | undefined;
const items = (details?.items ?? []).slice(0, 5).map((item) => {
const parts: string[] = [];
if (typeof item.wastedBytes === 'number') parts.push(`${Math.round(item.wastedBytes / 1024)} KiB wasted`);
if (typeof item.totalBytes === 'number') parts.push(`${Math.round(item.totalBytes / 1024)} KiB served`);
if (typeof item.wastedMs === 'number') parts.push(`${Math.round(item.wastedMs)} ms`);
const node = item.node as { nodeLabel?: string } | undefined;
if (node?.nodeLabel) parts.push(node.nodeLabel);
return {
url: typeof item.url === 'string' ? item.url : String(item.source ?? ''),
detail: parts.join(', '),
};
});
return {
id: one.id,
title: one.title,
score: typeof one.score === 'number' ? one.score : null,
savings: one.displayValue ?? null,
items: items.filter((item) => item.url || item.detail),
};
});
return { url, formFactor, scores, metrics, findings, finishedAt: new Date().toISOString() };
} finally {
await instance.kill();
}
}