From db781701e31fb8b95a52d40232f16a0a586947c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 4 Sep 2026 19:22:33 +0100 Subject: [PATCH] measure the site instead of reading a report back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/lighthouse.ts | 283 ++++++++++++++++++++++++++++++++++++++++++++++ manifest.ts | 28 +++++ 2 files changed, 311 insertions(+) create mode 100644 api/lighthouse.ts diff --git a/api/lighthouse.ts b/api/lighthouse.ts new file mode 100644 index 0000000..0ad6c33 --- /dev/null +++ b/api/lighthouse.ts @@ -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; + 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 | null = null; + +export function chromePath(log: (line: string) => void = () => {}): Promise { + 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 { + 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 = {}; + 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[] } | 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(); + } +} diff --git a/manifest.ts b/manifest.ts index 7b40584..34b1034 100644 --- a/manifest.ts +++ b/manifest.ts @@ -68,6 +68,34 @@ export const manifest: PluginManifest = { '@tiptap/pm': '^3.15.3', '@tiptap/extension-text-align': '^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: [