Files
fofos-admin/api/lighthouse.ts
T
pastilhasandClaude Opus 5 db781701e3 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>
2026-09-04 19:22:33 +01:00

284 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}