diff --git a/scripts/print-bundle-report.mjs b/scripts/print-bundle-report.mjs new file mode 100644 index 000000000..9b5af43a2 --- /dev/null +++ b/scripts/print-bundle-report.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * Reads `.next/analyze/client.json` (emitted when `pnpm analyze` runs + * `@next/bundle-analyzer` with `ANALYZE=1`) and prints a sorted table + * of the largest client chunks. Turbopack dropped the webpack + * "First Load JS" summary line from `next build`; this script restores + * an equivalent at-a-glance signal so release rounds can spot + * client-bundle regressions without re-running Lighthouse. + * + * Usage: + * pnpm analyze # writes .next/analyze/client.json + * pnpm bundle-report # reads it back, prints top chunks + * + * Output columns: rank, chunk label, parsed (post-minify) size, gzip + * size, stat (pre-minify) size. Sizes report in KiB to two decimals. + * Exits 0 unless `client.json` is missing or unreadable. + */ + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const REPORT_PATH = resolve(process.cwd(), ".next", "analyze", "client.json"); +const DEFAULT_TOP_N = 10; + +function formatKib(bytes) { + if (typeof bytes !== "number" || Number.isNaN(bytes)) return "—"; + return `${(bytes / 1024).toFixed(2)} KiB`; +} + +function padCell(value, width) { + const str = String(value ?? ""); + return str.length >= width ? str : str + " ".repeat(width - str.length); +} + +async function main() { + const argTopN = Number(process.argv[2]); + const topN = + Number.isFinite(argTopN) && argTopN > 0 + ? Math.floor(argTopN) + : DEFAULT_TOP_N; + + let raw; + try { + raw = await readFile(REPORT_PATH, "utf8"); + } catch (err) { + if (err && err.code === "ENOENT") { + console.error( + `bundle-report: ${REPORT_PATH} not found. Run \`pnpm analyze\` first.`, + ); + } else { + console.error(`bundle-report: failed to read ${REPORT_PATH}:`, err); + } + process.exit(1); + } + + let chunks; + try { + chunks = JSON.parse(raw); + } catch (err) { + console.error(`bundle-report: failed to parse ${REPORT_PATH}:`, err); + process.exit(1); + } + + if (!Array.isArray(chunks)) { + console.error( + "bundle-report: client.json is not an array of chunks — analyzer output shape changed?", + ); + process.exit(1); + } + + const sorted = [...chunks].sort( + (a, b) => (b.parsedSize ?? 0) - (a.parsedSize ?? 0), + ); + const top = sorted.slice(0, topN); + + const totals = chunks.reduce( + (acc, c) => ({ + parsed: acc.parsed + (c.parsedSize ?? 0), + gzip: acc.gzip + (c.gzipSize ?? 0), + stat: acc.stat + (c.statSize ?? 0), + }), + { parsed: 0, gzip: 0, stat: 0 }, + ); + + const labelWidth = Math.max( + 20, + ...top.map((c) => String(c.label ?? "").length), + ); + const header = + `${padCell("#", 3)} ${padCell("chunk", labelWidth)} ` + + `${padCell("parsed", 12)} ${padCell("gzip", 12)} ${padCell("stat", 12)}`; + + console.log( + `Top ${top.length} client chunks by parsed size (of ${chunks.length} total):`, + ); + console.log(header); + console.log("-".repeat(header.length)); + top.forEach((chunk, idx) => { + console.log( + `${padCell(idx + 1, 3)} ${padCell(chunk.label ?? "(unnamed)", labelWidth)} ` + + `${padCell(formatKib(chunk.parsedSize), 12)} ` + + `${padCell(formatKib(chunk.gzipSize), 12)} ` + + `${padCell(formatKib(chunk.statSize), 12)}`, + ); + }); + console.log("-".repeat(header.length)); + console.log( + `${padCell("Σ", 3)} ${padCell("all chunks", labelWidth)} ` + + `${padCell(formatKib(totals.parsed), 12)} ` + + `${padCell(formatKib(totals.gzip), 12)} ` + + `${padCell(formatKib(totals.stat), 12)}`, + ); +} + +main().catch((err) => { + console.error("bundle-report: unexpected failure:", err); + process.exit(1); +}); diff --git a/src/lib/http/__tests__/cache-headers.test.ts b/src/lib/http/__tests__/cache-headers.test.ts new file mode 100644 index 000000000..6fa8c69b5 --- /dev/null +++ b/src/lib/http/__tests__/cache-headers.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { + NO_STORE_BUT_BFCACHE, + SHORT_LIVED_PUBLIC, + applyAuthedHeaders, +} from "../cache-headers"; + +describe("cache-headers presets", () => { + it("NO_STORE_BUT_BFCACHE is bfcache-eligible (no `no-store` token)", () => { + expect(NO_STORE_BUT_BFCACHE).toBe("private, max-age=0, must-revalidate"); + expect(NO_STORE_BUT_BFCACHE).not.toMatch(/no-store/); + // Must keep proxies out of the loop for authed content. + expect(NO_STORE_BUT_BFCACHE).toMatch(/private/); + // Force revalidation on every navigation so stale state never sticks. + expect(NO_STORE_BUT_BFCACHE).toMatch(/max-age=0/); + expect(NO_STORE_BUT_BFCACHE).toMatch(/must-revalidate/); + }); + + it("SHORT_LIVED_PUBLIC carries a one-hour public TTL", () => { + expect(SHORT_LIVED_PUBLIC).toBe("public, max-age=3600"); + }); +}); + +describe("applyAuthedHeaders", () => { + it("stamps NO_STORE_BUT_BFCACHE onto a fresh Response", () => { + const res = new Response("payload", { status: 200 }); + const out = applyAuthedHeaders(res); + expect(out.headers.get("Cache-Control")).toBe(NO_STORE_BUT_BFCACHE); + }); + + it("overwrites any pre-existing Cache-Control directive", () => { + const res = new Response("payload", { + status: 200, + headers: { "Cache-Control": "no-store, must-revalidate" }, + }); + applyAuthedHeaders(res); + expect(res.headers.get("Cache-Control")).toBe(NO_STORE_BUT_BFCACHE); + }); + + it("returns the same response for chaining", () => { + const res = new Response("payload"); + const returned = applyAuthedHeaders(res); + expect(returned).toBe(res); + }); +}); diff --git a/src/lib/http/cache-headers.ts b/src/lib/http/cache-headers.ts new file mode 100644 index 000000000..4f5cc624f --- /dev/null +++ b/src/lib/http/cache-headers.ts @@ -0,0 +1,40 @@ +/** + * Centralised `Cache-Control` directives for HealthLog HTTP responses. + * + * Two presets cover the load-bearing cases for the web tree: + * + * - {@link NO_STORE_BUT_BFCACHE} — the default for every authenticated page + * response. The directive replaces the framework's stock + * `no-store, must-revalidate` (which Chromium counts as a hard bfcache + * breaker per the eligibility matrix) with `private, max-age=0, + * must-revalidate`. `private` keeps shared caches (proxies, CDNs) from + * storing personal data, `max-age=0` forces revalidation on every + * navigation so cookie + session swaps still detect on the wire, and + * `must-revalidate` honours the staleness contract. The combination is + * bfcache-eligible — Chromium admits the page on back-forward navigation, + * restoring scroll position and DOM state instead of paying a full + * reload. + * + * - {@link SHORT_LIVED_PUBLIC} — for static-ish public payloads (manifest + * metadata, the AASA blob, etc.) where a one-hour shared-cache TTL is + * appropriate. Not for authenticated content. + * + * {@link applyAuthedHeaders} stamps `NO_STORE_BUT_BFCACHE` onto an existing + * `Response` (or `NextResponse`) in-place. Use it inside a route handler + * that needs the bfcache-friendly directive on a non-HTML payload (the + * `next.config.ts` `headers()` rule handles the HTML default; this helper + * covers the API-route edges that opt into the same posture). + */ + +export const NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"; + +export const SHORT_LIVED_PUBLIC = "public, max-age=3600"; + +/** + * Stamp the bfcache-friendly authed `Cache-Control` directive onto a + * response. Returns the same response for chaining. + */ +export function applyAuthedHeaders(res: T): T { + res.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return res; +}