b4904e738b
Lazy-load Stripe and the chat reply animation, remove the unused Lottie runtime, and add repeatable bundle and Web Vitals baseline tooling.
330 lines
9.7 KiB
JavaScript
330 lines
9.7 KiB
JavaScript
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
import { chromium, devices } from "@playwright/test";
|
|
|
|
const root = process.cwd();
|
|
const externalBaseUrl = process.env.PERF_BASE_URL;
|
|
const localEnvPath = resolve(root, ".env.local");
|
|
const baseUrl = externalBaseUrl ?? "http://127.0.0.1:3100";
|
|
const routes = parseList(
|
|
process.env.PERF_ROUTES ?? "/splash,/tip",
|
|
);
|
|
const runCount = readPositiveInteger(process.env.PERF_RUNS, 3);
|
|
const outputPath = resolve(
|
|
root,
|
|
process.env.PERF_REPORT_PATH ??
|
|
".next/diagnostics/web-vitals-baseline.json",
|
|
);
|
|
const shouldThrottle = process.env.PERF_DISABLE_THROTTLING !== "1";
|
|
let serverProcess;
|
|
|
|
try {
|
|
if (!externalBaseUrl) {
|
|
const buildIdPath = resolve(root, ".next/BUILD_ID");
|
|
if (!existsSync(buildIdPath)) {
|
|
throw new Error(
|
|
"Production build not found. Run `pnpm build` before `pnpm perf:vitals`.",
|
|
);
|
|
}
|
|
serverProcess = startProductionServer();
|
|
await waitForServer(baseUrl);
|
|
}
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const samples = [];
|
|
|
|
try {
|
|
for (const route of routes) {
|
|
for (let run = 1; run <= runCount; run += 1) {
|
|
const sample = await measureRoute(browser, route, run);
|
|
samples.push(sample);
|
|
console.log(
|
|
`${route} run ${run}/${runCount}: LCP ${formatMetric(sample.lcpMs, "ms")}, CLS ${formatMetric(sample.cls, "")}`,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const routeReports = routes.map((route) => summarizeRoute(route, samples));
|
|
const report = {
|
|
generatedAt: new Date().toISOString(),
|
|
baseUrl,
|
|
profile: {
|
|
device: "Pixel 7",
|
|
runs: runCount,
|
|
network: shouldThrottle
|
|
? "1.6 Mbps down / 750 Kbps up / 150 ms latency"
|
|
: "unthrottled",
|
|
cpuSlowdownMultiplier: shouldThrottle ? 4 : 1,
|
|
},
|
|
routes: routeReports,
|
|
samples,
|
|
};
|
|
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
|
|
console.table(
|
|
routeReports.map((route) => ({
|
|
route: route.route,
|
|
finalPath: route.finalPaths.join(", "),
|
|
TTFB: formatMetric(route.median.ttfbMs, " ms"),
|
|
FCP: formatMetric(route.median.fcpMs, " ms"),
|
|
LCP: formatMetric(route.median.lcpMs, " ms"),
|
|
CLS: formatDecimal(route.median.cls, 3),
|
|
"synthetic INP": formatMetric(route.median.syntheticInpMs, " ms"),
|
|
"initial JS": formatBytes(route.median.initialJsTransferBytes),
|
|
})),
|
|
);
|
|
console.log(`\nWeb Vitals baseline written to ${outputPath}`);
|
|
} finally {
|
|
serverProcess?.kill("SIGTERM");
|
|
}
|
|
|
|
async function measureRoute(browser, route, run) {
|
|
const context = await browser.newContext({
|
|
...devices["Pixel 7"],
|
|
serviceWorkers: "block",
|
|
});
|
|
const page = await context.newPage();
|
|
const session = await context.newCDPSession(page);
|
|
|
|
if (shouldThrottle) {
|
|
await session.send("Network.enable");
|
|
await session.send("Network.emulateNetworkConditions", {
|
|
offline: false,
|
|
latency: 150,
|
|
downloadThroughput: (1.6 * 1024 * 1024) / 8,
|
|
uploadThroughput: (750 * 1024) / 8,
|
|
});
|
|
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
|
}
|
|
|
|
await page.addInitScript(installPerformanceObservers);
|
|
|
|
try {
|
|
await page.goto(new URL(route, baseUrl).toString(), {
|
|
waitUntil: "load",
|
|
timeout: 45_000,
|
|
});
|
|
await page.waitForTimeout(2_500);
|
|
await page.mouse.click(1, 1);
|
|
await page.waitForTimeout(500);
|
|
|
|
return await page.evaluate(
|
|
({ requestedRoute, sampleRun }) => {
|
|
const state = window.__cozsweetPerformanceBaseline;
|
|
const navigation = performance.getEntriesByType("navigation")[0];
|
|
const fcp = performance
|
|
.getEntriesByType("paint")
|
|
.find((entry) => entry.name === "first-contentful-paint");
|
|
const scriptResources = performance
|
|
.getEntriesByType("resource")
|
|
.filter(
|
|
(entry) =>
|
|
entry.initiatorType === "script" &&
|
|
new URL(entry.name).pathname.includes("/_next/"),
|
|
);
|
|
const interactions = [...state.interactions.values()].sort(
|
|
(left, right) => right - left,
|
|
);
|
|
const inpIndex = Math.min(
|
|
interactions.length - 1,
|
|
Math.floor(interactions.length / 50),
|
|
);
|
|
|
|
return {
|
|
route: requestedRoute,
|
|
run: sampleRun,
|
|
finalPath: window.location.pathname,
|
|
ttfbMs: navigation
|
|
? navigation.responseStart - navigation.requestStart
|
|
: null,
|
|
fcpMs: fcp?.startTime ?? null,
|
|
lcpMs: state.lcp,
|
|
cls: state.cls,
|
|
syntheticInpMs: inpIndex >= 0 ? interactions[inpIndex] : null,
|
|
initialJsTransferBytes: scriptResources.reduce(
|
|
(total, entry) => total + entry.transferSize,
|
|
0,
|
|
),
|
|
initialJsEncodedBytes: scriptResources.reduce(
|
|
(total, entry) => total + entry.encodedBodySize,
|
|
0,
|
|
),
|
|
};
|
|
},
|
|
{ requestedRoute: route, sampleRun: run },
|
|
);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
function installPerformanceObservers() {
|
|
const state = {
|
|
lcp: null,
|
|
cls: 0,
|
|
clsSessionValue: 0,
|
|
clsSessionStart: 0,
|
|
clsSessionEnd: 0,
|
|
interactions: new Map(),
|
|
};
|
|
window.__cozsweetPerformanceBaseline = state;
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
state.lcp = entries.at(-1)?.startTime ?? state.lcp;
|
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
} catch {}
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
if (entry.hadRecentInput) continue;
|
|
const startsNewSession =
|
|
state.clsSessionEnd === 0 ||
|
|
entry.startTime - state.clsSessionEnd >= 1_000 ||
|
|
entry.startTime - state.clsSessionStart >= 5_000;
|
|
if (startsNewSession) {
|
|
state.clsSessionValue = entry.value;
|
|
state.clsSessionStart = entry.startTime;
|
|
} else {
|
|
state.clsSessionValue += entry.value;
|
|
}
|
|
state.clsSessionEnd = entry.startTime;
|
|
state.cls = Math.max(state.cls, state.clsSessionValue);
|
|
}
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
} catch {}
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
if (!entry.interactionId) continue;
|
|
state.interactions.set(
|
|
entry.interactionId,
|
|
Math.max(
|
|
state.interactions.get(entry.interactionId) ?? 0,
|
|
entry.duration,
|
|
),
|
|
);
|
|
}
|
|
}).observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
} catch {}
|
|
}
|
|
|
|
function summarizeRoute(route, samples) {
|
|
const routeSamples = samples.filter((sample) => sample.route === route);
|
|
const metricNames = [
|
|
"ttfbMs",
|
|
"fcpMs",
|
|
"lcpMs",
|
|
"cls",
|
|
"syntheticInpMs",
|
|
"initialJsTransferBytes",
|
|
"initialJsEncodedBytes",
|
|
];
|
|
const median = Object.fromEntries(
|
|
metricNames.map((name) => [
|
|
name,
|
|
getMedian(routeSamples.map((sample) => sample[name])),
|
|
]),
|
|
);
|
|
return {
|
|
route,
|
|
finalPaths: [...new Set(routeSamples.map((sample) => sample.finalPath))],
|
|
median,
|
|
};
|
|
}
|
|
|
|
function getMedian(values) {
|
|
const numbers = values
|
|
.filter((value) => typeof value === "number" && Number.isFinite(value))
|
|
.sort((left, right) => left - right);
|
|
if (numbers.length === 0) return null;
|
|
const middle = Math.floor(numbers.length / 2);
|
|
return numbers.length % 2 === 0
|
|
? (numbers[middle - 1] + numbers[middle]) / 2
|
|
: numbers[middle];
|
|
}
|
|
|
|
function startProductionServer() {
|
|
const standaloneRoot = resolve(root, ".next/standalone");
|
|
const standaloneServer = resolve(standaloneRoot, "server.js");
|
|
const standaloneNextDirectory = resolve(standaloneRoot, ".next");
|
|
mkdirSync(standaloneNextDirectory, { recursive: true });
|
|
cpSync(resolve(root, "public"), resolve(standaloneRoot, "public"), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
cpSync(
|
|
resolve(root, ".next/static"),
|
|
resolve(standaloneNextDirectory, "static"),
|
|
{ recursive: true, force: true },
|
|
);
|
|
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
...(existsSync(localEnvPath) ? [`--env-file=${localEnvPath}`] : []),
|
|
standaloneServer,
|
|
],
|
|
{
|
|
cwd: standaloneRoot,
|
|
env: {
|
|
...process.env,
|
|
HOSTNAME: "127.0.0.1",
|
|
PORT: "3100",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
|
|
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
return child;
|
|
}
|
|
|
|
async function waitForServer(url) {
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url, { redirect: "manual" });
|
|
if (response.status < 500) return;
|
|
} catch {}
|
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
|
|
}
|
|
throw new Error(`Timed out waiting for ${url}`);
|
|
}
|
|
|
|
function parseList(value) {
|
|
return value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
.map((route) => (route.startsWith("/") ? route : `/${route}`));
|
|
}
|
|
|
|
function readPositiveInteger(value, fallback) {
|
|
const number = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
}
|
|
|
|
function formatMetric(value, suffix) {
|
|
return typeof value === "number" ? `${value.toFixed(1)}${suffix}` : "n/a";
|
|
}
|
|
|
|
function formatDecimal(value, fractionDigits) {
|
|
return typeof value === "number" ? value.toFixed(fractionDigits) : "n/a";
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
return typeof value === "number"
|
|
? `${(value / 1024).toFixed(1)} KiB`
|
|
: "n/a";
|
|
}
|