feat(http): centralise authed Cache-Control with bfcache-friendly preset

Add `src/lib/http/cache-headers.ts` exporting two presets and one
applier. `NO_STORE_BUT_BFCACHE` (`private, max-age=0, must-revalidate`)
replaces the framework's stock `no-store, must-revalidate` on
authenticated page responses; Chromium treats the latter as a hard
bfcache breaker, while the former keeps shared caches out, forces
revalidation on every navigation, and stays bfcache-eligible.
`SHORT_LIVED_PUBLIC` covers static-ish one-hour public payloads.
`applyAuthedHeaders(res)` stamps the bfcache-friendly directive onto
a Response in-place for route handlers that opt into the same posture.

Also add `scripts/print-bundle-report.mjs` (wired earlier as the
`bundle-report` npm script). Turbopack dropped the webpack "First
Load JS" summary line from `next build`; the script reads back the
`.next/analyze/client.json` payload that `@next/bundle-analyzer`
writes when `pnpm analyze` runs and prints a sorted table of the
largest client chunks (parsed, gzip, stat sizes plus totals). Output
restores an at-a-glance signal for client-bundle regressions on
release rounds without re-running Lighthouse.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:26:59 +02:00
1 parent 30c8555cee
commit 8e05f9227d
3 files changed
+203

No files matched your search

+118
View File
@@ -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);
});
@@ -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);
});
});
+40
View File
@@ -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<T extends Response>(res: T): T {
res.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE);
return res;
}