mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
30c8555cee
commit
8e05f9227d
3 files changed
+203
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user