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:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -68,6 +68,34 @@ export const manifest: PluginManifest = {
|
|||||||
'@tiptap/pm': '^3.15.3',
|
'@tiptap/pm': '^3.15.3',
|
||||||
'@tiptap/extension-text-align': '^3.15.3',
|
'@tiptap/extension-text-align': '^3.15.3',
|
||||||
'@tiptap/extension-link': '^3.15.3',
|
'@tiptap/extension-link': '^3.15.3',
|
||||||
|
/*
|
||||||
|
Lighthouse, so the site can be MEASURED from the panel that builds it.
|
||||||
|
|
||||||
|
Every performance decision on this site so far — the `sizes` a column declares, which image is
|
||||||
|
preloaded, whether the lightbox is parsed at load — was made by reading a report somebody ran by
|
||||||
|
hand in DevTools and typed back. That is slow and it is lossy: three real defects (a `sizes` that
|
||||||
|
overstated a column, font preloads that had gone missing, an LCP marked lazy) sat live for weeks
|
||||||
|
because nothing measured after a build.
|
||||||
|
|
||||||
|
A big tree, and worth saying so given bunfig.toml: ~100 transitive packages. It brings its own
|
||||||
|
`chrome-launcher` and `puppeteer-core`, so neither is asked for here.
|
||||||
|
|
||||||
|
`@sentry/node` arrives with it. Lighthouse's error reporting is opt-in — off unless
|
||||||
|
`--enable-error-reporting` — and this never passes it. See api/lighthouse.ts.
|
||||||
|
*/
|
||||||
|
lighthouse: '^13.4.1',
|
||||||
|
/*
|
||||||
|
Fetches a Chrome BINARY, at runtime rather than in a postinstall.
|
||||||
|
|
||||||
|
Lighthouse needs a browser and this machine has none: Ubuntu 24.04's apt `chromium` is the snap
|
||||||
|
stub the iris manifest warns about, and there is no Chrome repo. Puppeteer's own download runs in
|
||||||
|
a postinstall, which bun does not execute for an untrusted package — it would silently install
|
||||||
|
nothing and fail at the first audit.
|
||||||
|
|
||||||
|
So the download is a library call the plugin makes, once, when it first needs a browser. Explicit,
|
||||||
|
observable, and it narrates — see api/lighthouse.ts.
|
||||||
|
*/
|
||||||
|
'@puppeteer/browsers': '^3.2.2',
|
||||||
},
|
},
|
||||||
|
|
||||||
permissions: [
|
permissions: [
|
||||||
|
|||||||
Reference in New Issue
Block a user