perf: defer heavy modules and add performance baselines
Lazy-load Stripe and the chat reply animation, remove the unused Lottie runtime, and add repeatable bundle and Web Vitals baseline tooling.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const analyzeDataDirectory = resolve(
|
||||
root,
|
||||
process.env.BUNDLE_ANALYZE_DIR ?? ".next/diagnostics/analyze/data",
|
||||
);
|
||||
const outputPath = resolve(
|
||||
root,
|
||||
process.env.PERF_REPORT_PATH ??
|
||||
".next/diagnostics/bundle-summary.json",
|
||||
);
|
||||
const routes = parseList(
|
||||
process.env.PERF_ROUTES ?? "/splash,/chat,/subscription,/tip",
|
||||
);
|
||||
|
||||
const auditTargets = [
|
||||
{
|
||||
name: "Stripe React SDK",
|
||||
matches: (path) => path.includes("/node_modules/@stripe/react-stripe-js/"),
|
||||
},
|
||||
{
|
||||
name: "Stripe loader",
|
||||
matches: (path) => path.includes("/node_modules/@stripe/stripe-js/"),
|
||||
},
|
||||
{
|
||||
name: "Stripe dialog",
|
||||
matches: (path) => path.includes("/stripe-payment-dialog.tsx"),
|
||||
},
|
||||
{
|
||||
name: "Reply animation",
|
||||
matches: (path) => path.includes("/lottie-message-bubble"),
|
||||
},
|
||||
{
|
||||
name: "Lottie runtime",
|
||||
matches: (path) => path.includes("/node_modules/lottie-react/"),
|
||||
},
|
||||
];
|
||||
|
||||
function main() {
|
||||
const modulesData = new FramedDataFile(
|
||||
resolve(analyzeDataDirectory, "modules.data"),
|
||||
);
|
||||
const asyncBoundariesByPath = getAsyncBoundariesByModulePath(modulesData);
|
||||
const routeReports = routes.map((route) =>
|
||||
analyzeRoute(route, asyncBoundariesByPath),
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(root, "package.json"), "utf8"),
|
||||
);
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
nextVersion: packageJson.dependencies.next,
|
||||
routes: routeReports,
|
||||
};
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
console.table(
|
||||
routeReports.map((route) => ({
|
||||
route: route.route,
|
||||
"eager client JS": formatBytes(route.eagerClientJs.compressedBytes),
|
||||
"async client JS": formatBytes(route.asyncClientJs.compressedBytes),
|
||||
"total client JS": formatBytes(route.totalClientJs.compressedBytes),
|
||||
})),
|
||||
);
|
||||
|
||||
for (const route of routeReports) {
|
||||
const presentAudits = route.audits.filter(
|
||||
(audit) => audit.phase !== "absent",
|
||||
);
|
||||
if (presentAudits.length === 0) continue;
|
||||
console.log(`\n${route.route} audited modules`);
|
||||
console.table(
|
||||
presentAudits.map((audit) => ({
|
||||
module: audit.name,
|
||||
phase: audit.phase,
|
||||
compressed: formatBytes(audit.compressedBytes),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\nBundle summary written to ${outputPath}`);
|
||||
}
|
||||
|
||||
function analyzeRoute(route, boundariesByPath) {
|
||||
const routeFile =
|
||||
route === "/"
|
||||
? resolve(analyzeDataDirectory, "analyze.data")
|
||||
: resolve(
|
||||
analyzeDataDirectory,
|
||||
route.replace(/^\//, ""),
|
||||
"analyze.data",
|
||||
);
|
||||
const data = new FramedDataFile(routeFile);
|
||||
const sourceIndexes = collectRouteSourceIndexes(data);
|
||||
const clientParts = collectClientJavaScriptParts(data, sourceIndexes);
|
||||
const eagerParts = [];
|
||||
const asyncParts = [];
|
||||
|
||||
for (const partIndex of clientParts) {
|
||||
const part = data.header.chunk_parts[partIndex];
|
||||
const sourcePath = normalizeSourcePath(
|
||||
data.getFullSourcePath(part.source_index),
|
||||
);
|
||||
const boundaryCount = boundariesByPath.get(sourcePath);
|
||||
if (boundaryCount !== undefined && boundaryCount > 0) {
|
||||
asyncParts.push(partIndex);
|
||||
} else {
|
||||
eagerParts.push(partIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route,
|
||||
eagerClientJs: sumParts(data, eagerParts),
|
||||
asyncClientJs: sumParts(data, asyncParts),
|
||||
totalClientJs: sumParts(data, clientParts),
|
||||
audits: auditTargets.map((target) => {
|
||||
const matchingParts = clientParts.filter((partIndex) => {
|
||||
const part = data.header.chunk_parts[partIndex];
|
||||
return target.matches(data.getFullSourcePath(part.source_index));
|
||||
});
|
||||
const boundaryCounts = matchingParts
|
||||
.map((partIndex) => {
|
||||
const part = data.header.chunk_parts[partIndex];
|
||||
return boundariesByPath.get(
|
||||
normalizeSourcePath(data.getFullSourcePath(part.source_index)),
|
||||
);
|
||||
})
|
||||
.filter((value) => value !== undefined);
|
||||
const sizes = sumParts(data, matchingParts);
|
||||
|
||||
return {
|
||||
name: target.name,
|
||||
phase:
|
||||
matchingParts.length === 0
|
||||
? "absent"
|
||||
: boundaryCounts.length > 0 && Math.min(...boundaryCounts) > 0
|
||||
? "async"
|
||||
: "eager",
|
||||
...sizes,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function collectRouteSourceIndexes(data) {
|
||||
const indexes = new Set();
|
||||
const pending = [...data.header.source_roots];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const index = pending.pop();
|
||||
if (index === undefined || indexes.has(index)) continue;
|
||||
indexes.add(index);
|
||||
pending.push(...data.readEdges(data.header.source_children, index));
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function collectClientJavaScriptParts(data, sourceIndexes) {
|
||||
const parts = new Set();
|
||||
|
||||
for (const sourceIndex of sourceIndexes) {
|
||||
for (const partIndex of data.readEdges(
|
||||
data.header.source_chunk_parts,
|
||||
sourceIndex,
|
||||
)) {
|
||||
const part = data.header.chunk_parts[partIndex];
|
||||
const output = data.header.output_files[part.output_file_index];
|
||||
if (
|
||||
output.filename.startsWith("[client-fs]/") &&
|
||||
output.filename.endsWith(".js")
|
||||
) {
|
||||
parts.add(partIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...parts];
|
||||
}
|
||||
|
||||
function sumParts(data, partIndexes) {
|
||||
let bytes = 0;
|
||||
let compressedBytes = 0;
|
||||
|
||||
for (const partIndex of partIndexes) {
|
||||
const part = data.header.chunk_parts[partIndex];
|
||||
bytes += part.size;
|
||||
compressedBytes += part.compressed_size;
|
||||
}
|
||||
|
||||
return { bytes, compressedBytes };
|
||||
}
|
||||
|
||||
function getAsyncBoundariesByModulePath(data) {
|
||||
const modules = data.header.modules;
|
||||
const entryDependents = [
|
||||
"next/dist/esm/build/templates/pages.js",
|
||||
"next/dist/esm/build/templates/pages-api.js",
|
||||
"next/dist/esm/build/templates/pages-edge-api.js",
|
||||
"next/dist/esm/build/templates/edge-ssr.js",
|
||||
"next/dist/esm/build/templates/app-route.js",
|
||||
"next/dist/esm/build/templates/edge-app-route.js",
|
||||
"next/dist/esm/build/templates/app-page.js",
|
||||
"next/dist/esm/build/templates/edge-ssr-app.js",
|
||||
"next/dist/esm/build/templates/middleware.js",
|
||||
"[next]/entry/page-loader.ts",
|
||||
];
|
||||
const directEntries = [
|
||||
"next/dist/client/app-next-turbopack.js",
|
||||
"next/dist/client/next-turbopack.js",
|
||||
];
|
||||
const entries = new Set();
|
||||
|
||||
for (let index = 0; index < modules.length; index += 1) {
|
||||
const moduleRecord = modules[index];
|
||||
if (entryDependents.some((entry) => moduleRecord.ident.includes(entry))) {
|
||||
for (const dependency of data.readEdges(
|
||||
data.header.module_dependencies,
|
||||
index,
|
||||
)) {
|
||||
if (!modules[dependency].path.includes("next/dist/")) {
|
||||
entries.add(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directEntries.some((entry) => moduleRecord.ident.includes(entry))) {
|
||||
entries.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
const distances = Array(modules.length).fill(Number.POSITIVE_INFINITY);
|
||||
const buckets = [[...entries]];
|
||||
for (const entry of entries) distances[entry] = 0;
|
||||
|
||||
for (let boundaryCount = 0; boundaryCount < buckets.length; boundaryCount += 1) {
|
||||
const bucket = buckets[boundaryCount] ?? [];
|
||||
for (let cursor = 0; cursor < bucket.length; cursor += 1) {
|
||||
const moduleIndex = bucket[cursor];
|
||||
if (distances[moduleIndex] !== boundaryCount) continue;
|
||||
|
||||
relaxDependencies(
|
||||
data.header.module_dependencies,
|
||||
moduleIndex,
|
||||
boundaryCount,
|
||||
);
|
||||
relaxDependencies(
|
||||
data.header.async_module_dependencies,
|
||||
moduleIndex,
|
||||
boundaryCount + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const boundariesByPath = new Map();
|
||||
for (let index = 0; index < modules.length; index += 1) {
|
||||
const moduleRecord = modules[index];
|
||||
if (!moduleRecord.ident.includes("[app-client]")) continue;
|
||||
const boundaryCount = distances[index];
|
||||
if (!Number.isFinite(boundaryCount)) continue;
|
||||
const current = boundariesByPath.get(moduleRecord.path);
|
||||
if (current === undefined || boundaryCount < current) {
|
||||
boundariesByPath.set(moduleRecord.path, boundaryCount);
|
||||
}
|
||||
}
|
||||
|
||||
return boundariesByPath;
|
||||
|
||||
function relaxDependencies(reference, moduleIndex, nextDistance) {
|
||||
for (const dependency of data.readEdges(reference, moduleIndex)) {
|
||||
if (nextDistance >= distances[dependency]) continue;
|
||||
distances[dependency] = nextDistance;
|
||||
buckets[nextDistance] ??= [];
|
||||
buckets[nextDistance].push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourcePath(path) {
|
||||
return path.replace(/ \[app-(?:client|ssr)\].*$/, "");
|
||||
}
|
||||
|
||||
function parseList(value) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((route) => (route.startsWith("/") ? route : `/${route}`));
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
|
||||
class FramedDataFile {
|
||||
constructor(filePath) {
|
||||
this.buffer = readFileSync(filePath);
|
||||
const headerLength = this.buffer.readUInt32BE(0);
|
||||
this.binaryStart = 4 + headerLength;
|
||||
this.header = JSON.parse(
|
||||
this.buffer.subarray(4, this.binaryStart).toString("utf8"),
|
||||
);
|
||||
this.sourcePathCache = new Map();
|
||||
}
|
||||
|
||||
readEdges(reference, index) {
|
||||
if (reference.length === 0) return [];
|
||||
const referenceStart = this.binaryStart + reference.offset;
|
||||
const indexCount = this.buffer.readUInt32BE(referenceStart);
|
||||
if (index < 0 || index >= indexCount) return [];
|
||||
|
||||
const offsetsStart = referenceStart + 4;
|
||||
const previousOffset =
|
||||
index === 0
|
||||
? 0
|
||||
: this.buffer.readUInt32BE(offsetsStart + (index - 1) * 4);
|
||||
const nextOffset = this.buffer.readUInt32BE(offsetsStart + index * 4);
|
||||
const dataStart = offsetsStart + indexCount * 4;
|
||||
const edges = [];
|
||||
|
||||
for (let cursor = previousOffset; cursor < nextOffset; cursor += 1) {
|
||||
edges.push(this.buffer.readUInt32BE(dataStart + cursor * 4));
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
getFullSourcePath(index) {
|
||||
const cached = this.sourcePathCache.get(index);
|
||||
if (cached !== undefined) return cached;
|
||||
const source = this.header.sources[index];
|
||||
const path =
|
||||
source.parent_source_index === null
|
||||
? source.path
|
||||
: `${this.getFullSourcePath(source.parent_source_index)}${source.path}`;
|
||||
this.sourcePathCache.set(index, path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user