ci: report per-route JavaScript budgets from Docker build
This commit is contained in:
1 parent
445ef13846
commit
cc714b427a
9 files changed
+744
-1
No files matched your search
@@ -40,3 +40,7 @@ db_backup_*.sql
|
|||||||
.docker-install
|
.docker-install
|
||||||
.docker-install.tmp.*
|
.docker-install.tmp.*
|
||||||
.env.install.*
|
.env.install.*
|
||||||
|
build-reports
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
@@ -80,6 +80,15 @@ jobs:
|
|||||||
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
||||||
run: bash scripts/ci-deploy.sh
|
run: bash scripts/ci-deploy.sh
|
||||||
|
|
||||||
|
- name: Upload JavaScript size report
|
||||||
|
if: always()
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||||
|
with:
|
||||||
|
name: javascript-size-report
|
||||||
|
path: build-reports/
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
# Publish only after checks and the production deployment have succeeded.
|
# Publish only after checks and the production deployment have succeeded.
|
||||||
# Serial execution also avoids two builds competing on the self-hosted runner.
|
# Serial execution also avoids two builds competing on the self-hosted runner.
|
||||||
publish-container:
|
publish-container:
|
||||||
|
|||||||
@@ -50,3 +50,5 @@ blob-report/
|
|||||||
.docker-install
|
.docker-install
|
||||||
.docker-install.tmp.*
|
.docker-install.tmp.*
|
||||||
.env.install.*
|
.env.install.*
|
||||||
|
build-reports/
|
||||||
|
!/docs/performance-budgets.md
|
||||||
+3
-1
@@ -37,7 +37,8 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
|||||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||||
AUTH_SECRET="build-fixture-not-for-runtime-use-000000000000" \
|
AUTH_SECRET="build-fixture-not-for-runtime-use-000000000000" \
|
||||||
pnpm run build
|
pnpm run build \
|
||||||
|
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||||
|
|
||||||
FROM node:26.8.2-alpine AS runner
|
FROM node:26.8.2-alpine AS runner
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
@@ -54,6 +55,7 @@ RUN apk add --no-cache tini curl \
|
|||||||
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /app/build-reports ./build-reports
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migrations
|
COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migrations
|
||||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Informational route JavaScript budgets
|
||||||
|
|
||||||
|
Run after the existing production build; no second build or server is needed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/performance-report.mjs --next-dir .next --config scripts/performance-budgets.json --output-dir build-reports
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes `report.json` and `report.md` and prints the Markdown report. An optional `PERFORMANCE_COMMIT_SHA` environment variable records the commit declared by the build caller; the script does not infer that an existing build matches the current checkout. JSON also records `BUILD_ID`, Node/zlib versions, manifest provenance, exact file paths, sizes and source entries.
|
||||||
|
|
||||||
|
## What is measured
|
||||||
|
|
||||||
|
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
|
||||||
|
|
||||||
|
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||||
|
|
||||||
|
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||||
|
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||||
|
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||||
|
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
|
||||||
|
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
|
||||||
|
|
||||||
|
## Initial limits
|
||||||
|
|
||||||
|
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
|
||||||
|
|
||||||
|
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| `/me` | 767156 | 238571 | 890880 | 276480 |
|
||||||
|
| `/news` | 765367 | 237599 | 880640 | 276480 |
|
||||||
|
| `/events` | 765851 | 237964 | 890880 | 276480 |
|
||||||
|
| `/search` | 765851 | 237964 | 890880 | 276480 |
|
||||||
|
| `/admin/catalog` | 1654898 | 492619 | 1904640 | 573440 |
|
||||||
|
| `/admin/studio/furni` | 1241312 | 391541 | 1433600 | 450560 |
|
||||||
|
|
||||||
|
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` remains `mode: informational`. Exceeding a limit produces `over-budget` and a warning, with exit code 0. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**, also exit code 0. Malformed budget configuration or an unwritable output directory fails the command. This keeps initial CI reporting non-blocking while preventing invalid configuration from quietly disabling limits.
|
||||||
|
|
||||||
|
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||||
|
|
||||||
@@ -99,6 +99,22 @@ echo "Building $image"
|
|||||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
check_current
|
check_current
|
||||||
|
# Read reports from the already-built image; do not start an extra application.
|
||||||
|
report_container=""
|
||||||
|
if report_container="$(docker create --entrypoint /bin/true "$image")"; then
|
||||||
|
mkdir -p build-reports
|
||||||
|
if docker cp "$report_container:/app/build-reports/." build-reports; then
|
||||||
|
cat build-reports/report.md
|
||||||
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
cat build-reports/report.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: build performance report unavailable" >&2
|
||||||
|
fi
|
||||||
|
docker rm "$report_container" >/dev/null
|
||||||
|
else
|
||||||
|
echo "Warning: could not extract build performance report" >&2
|
||||||
|
fi
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
check_current
|
check_current
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"mode": "informational",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/me",
|
||||||
|
"maxInitialRawBytes": 890880,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/news",
|
||||||
|
"maxInitialRawBytes": 880640,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/events",
|
||||||
|
"maxInitialRawBytes": 890880,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/search",
|
||||||
|
"maxInitialRawBytes": 890880,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/admin/catalog",
|
||||||
|
"maxInitialRawBytes": 1904640,
|
||||||
|
"maxInitialGzipBytes": 573440
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/admin/studio/furni",
|
||||||
|
"maxInitialRawBytes": 1433600,
|
||||||
|
"maxInitialGzipBytes": 450560
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"];
|
||||||
|
export function validateBudgets(config) {
|
||||||
|
if (
|
||||||
|
config?.schemaVersion !== 1 ||
|
||||||
|
config.mode !== "informational" ||
|
||||||
|
!Array.isArray(config.routes) ||
|
||||||
|
config.routes.length === 0
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Budget config requires schemaVersion 1, mode informational and nonempty routes.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Object.keys(config).some(
|
||||||
|
(key) => !["schemaVersion", "mode", "routes"].includes(key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Unknown budget config field.");
|
||||||
|
const seen = new Set();
|
||||||
|
for (const entry of config.routes) {
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
typeof entry.route !== "string" ||
|
||||||
|
!/^\/(?:[^?#\s]*)$/.test(entry.route) ||
|
||||||
|
seen.has(entry.route) ||
|
||||||
|
Object.keys(entry).some((key) => !LIMIT_KEYS.includes(key))
|
||||||
|
)
|
||||||
|
throw new Error("Invalid or duplicate budget route.");
|
||||||
|
seen.add(entry.route);
|
||||||
|
for (const key of LIMIT_KEYS.slice(1))
|
||||||
|
if (
|
||||||
|
entry[key] !== null &&
|
||||||
|
(!Number.isSafeInteger(entry[key]) || entry[key] <= 0)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Invalid ${key} for ${entry.route}: use positive integer bytes or null.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse Next's generated JSON assignment, never execute the manifest as JavaScript. */
|
||||||
|
export function parseClientManifest(source, appPath) {
|
||||||
|
const assignment =
|
||||||
|
/globalThis\.__RSC_MANIFEST\[("(?:[^"\\]|\\.)*")\]\s*=\s*/g;
|
||||||
|
const match = assignment.exec(source);
|
||||||
|
if (!match || JSON.parse(match[1]) !== appPath)
|
||||||
|
throw new Error("Unsupported client-reference manifest assignment.");
|
||||||
|
const value = source
|
||||||
|
.slice(match.index + match[0].length)
|
||||||
|
.trim()
|
||||||
|
.replace(/;$/, "");
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAsset(value) {
|
||||||
|
if (typeof value !== "string")
|
||||||
|
throw new Error("Non-string JavaScript asset.");
|
||||||
|
const clean = value.split(/[?#]/, 1)[0].replace(/^\/_next\//, "");
|
||||||
|
if (
|
||||||
|
!clean.startsWith("static/") ||
|
||||||
|
clean.includes("\\") ||
|
||||||
|
clean
|
||||||
|
.split("/")
|
||||||
|
.some((part) => part === ".." || part === "." || part === "") ||
|
||||||
|
!clean.endsWith(".js")
|
||||||
|
)
|
||||||
|
throw new Error(`Unsupported JavaScript asset: ${value}`);
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesFrom(values) {
|
||||||
|
if (!Array.isArray(values))
|
||||||
|
throw new Error("Unsupported JavaScript chunk list.");
|
||||||
|
return values.map(normalizeAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset,
|
||||||
|
}) {
|
||||||
|
const entries = clientManifest?.entryJSFiles;
|
||||||
|
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||||
|
throw new Error(
|
||||||
|
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
||||||
|
);
|
||||||
|
const sourceEntries = Object.keys(entries);
|
||||||
|
if (
|
||||||
|
!sourceEntries.some((key) =>
|
||||||
|
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||||
|
const bootstrap = filesFrom(
|
||||||
|
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
||||||
|
);
|
||||||
|
if (bootstrap.length === 0)
|
||||||
|
throw new Error("Initial bootstrap chunks are unavailable.");
|
||||||
|
const origins = new Map();
|
||||||
|
const add = (file, origin) => {
|
||||||
|
const sources = origins.get(file) ?? new Set();
|
||||||
|
sources.add(origin);
|
||||||
|
origins.set(file, sources);
|
||||||
|
};
|
||||||
|
for (const file of bootstrap) add(file, "bootstrap");
|
||||||
|
for (const [entry, values] of Object.entries(entries))
|
||||||
|
for (const file of filesFrom(values)) add(file, entry);
|
||||||
|
const size = (file, sources) => {
|
||||||
|
const bytes = readAsset(file);
|
||||||
|
return {
|
||||||
|
path: file,
|
||||||
|
rawBytes: bytes.length,
|
||||||
|
gzipBytes: gzipSync(bytes, { level: 9 }).length,
|
||||||
|
sources: [...sources].sort(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const chunks = [...origins]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([file, sources]) => size(file, sources));
|
||||||
|
const polyfills = [...new Set(filesFrom(buildManifest.polyfillFiles ?? []))]
|
||||||
|
.filter((file) => !origins.has(file) && !file.endsWith(".module.js"))
|
||||||
|
.sort()
|
||||||
|
.map((file) => size(file, ["legacy-nomodule-polyfill"]));
|
||||||
|
const rawBytes = chunks.reduce((total, file) => total + file.rawBytes, 0);
|
||||||
|
const gzipBytes = chunks.reduce((total, file) => total + file.gzipBytes, 0);
|
||||||
|
const exceeded = [];
|
||||||
|
if (
|
||||||
|
budget.maxInitialRawBytes !== null &&
|
||||||
|
rawBytes > budget.maxInitialRawBytes
|
||||||
|
)
|
||||||
|
exceeded.push("raw");
|
||||||
|
if (
|
||||||
|
budget.maxInitialGzipBytes !== null &&
|
||||||
|
gzipBytes > budget.maxInitialGzipBytes
|
||||||
|
)
|
||||||
|
exceeded.push("gzip");
|
||||||
|
return {
|
||||||
|
route: budget.route,
|
||||||
|
appPath,
|
||||||
|
status: exceeded.length ? "over-budget" : "measured",
|
||||||
|
initial: { chunkCount: chunks.length, rawBytes, gzipBytes, chunks },
|
||||||
|
legacyPolyfills: {
|
||||||
|
rawBytes: polyfills.reduce((n, f) => n + f.rawBytes, 0),
|
||||||
|
gzipBytes: polyfills.reduce((n, f) => n + f.gzipBytes, 0),
|
||||||
|
chunks: polyfills,
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
maxInitialRawBytes: budget.maxInitialRawBytes,
|
||||||
|
maxInitialGzipBytes: budget.maxInitialGzipBytes,
|
||||||
|
},
|
||||||
|
exceeded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonFile(file) {
|
||||||
|
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
||||||
|
}
|
||||||
|
function within(root, relative) {
|
||||||
|
const resolved = path.resolve(root, relative);
|
||||||
|
if (!resolved.startsWith(path.resolve(root) + path.sep))
|
||||||
|
throw new Error("Manifest path escapes the build directory.");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
export function collectReport(nextDir, config, metadata = {}) {
|
||||||
|
validateBudgets(config);
|
||||||
|
let routeMap, appMap, manifestError;
|
||||||
|
let buildId = null;
|
||||||
|
try {
|
||||||
|
buildId = readFileSync(path.join(nextDir, "BUILD_ID"), "utf8").trim();
|
||||||
|
if (!buildId)
|
||||||
|
throw new Error("A completed production BUILD_ID is required.");
|
||||||
|
routeMap = jsonFile(path.join(nextDir, "app-path-routes-manifest.json"));
|
||||||
|
appMap = jsonFile(path.join(nextDir, "server/app-paths-manifest.json"));
|
||||||
|
} catch (error) {
|
||||||
|
manifestError = error.message;
|
||||||
|
}
|
||||||
|
const routes = config.routes.map((budget) => {
|
||||||
|
try {
|
||||||
|
if (manifestError) throw new Error(manifestError);
|
||||||
|
const matches = Object.entries(routeMap)
|
||||||
|
.filter(([, route]) => route === budget.route)
|
||||||
|
.map(([appPath]) => appPath);
|
||||||
|
if (matches.length !== 1)
|
||||||
|
throw new Error(
|
||||||
|
`Expected one build entry for ${budget.route}, found ${matches.length}.`,
|
||||||
|
);
|
||||||
|
const appPath = matches[0],
|
||||||
|
serverEntry = appMap[appPath];
|
||||||
|
if (typeof serverEntry !== "string" || !serverEntry.endsWith("/page.js"))
|
||||||
|
throw new Error("Unsupported server app entry.");
|
||||||
|
const clientFile = within(
|
||||||
|
path.join(nextDir, "server"),
|
||||||
|
serverEntry.replace(/\.js$/, "_client-reference-manifest.js"),
|
||||||
|
);
|
||||||
|
const clientManifest = parseClientManifest(
|
||||||
|
readFileSync(clientFile, "utf8"),
|
||||||
|
appPath,
|
||||||
|
);
|
||||||
|
let buildManifest,
|
||||||
|
buildManifestSource = `server/${serverEntry.replace(/\.js$/, "/build-manifest.json")}`;
|
||||||
|
try {
|
||||||
|
buildManifest = jsonFile(within(nextDir, buildManifestSource));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
buildManifestSource = "build-manifest.json";
|
||||||
|
buildManifest = jsonFile(path.join(nextDir, buildManifestSource));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => readFileSync(within(nextDir, file)),
|
||||||
|
}),
|
||||||
|
manifests: {
|
||||||
|
client: path.relative(nextDir, clientFile).split(path.sep).join("/"),
|
||||||
|
build: buildManifestSource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
route: budget.route,
|
||||||
|
status: "unavailable",
|
||||||
|
reason: error.message,
|
||||||
|
initial: null,
|
||||||
|
budget: {
|
||||||
|
maxInitialRawBytes: budget.maxInitialRawBytes,
|
||||||
|
maxInitialGzipBytes: budget.maxInitialGzipBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mode: "informational",
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
buildId,
|
||||||
|
commitSha: metadata.commitSha ?? null,
|
||||||
|
commitProvenance:
|
||||||
|
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
||||||
|
nodeVersion: process.version,
|
||||||
|
zlibVersion: process.versions.zlib,
|
||||||
|
manifestFormat: "Next App Router client-reference entryJSFiles",
|
||||||
|
definition:
|
||||||
|
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||||
|
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
||||||
|
excluded:
|
||||||
|
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
|
||||||
|
routes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownReport(report) {
|
||||||
|
const lines = [
|
||||||
|
"# Route JavaScript size report",
|
||||||
|
"",
|
||||||
|
report.definition,
|
||||||
|
"",
|
||||||
|
report.gzip,
|
||||||
|
"",
|
||||||
|
`Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`,
|
||||||
|
"",
|
||||||
|
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
|
||||||
|
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
||||||
|
];
|
||||||
|
for (const row of report.routes)
|
||||||
|
lines.push(
|
||||||
|
`| ${row.route} | ${row.status} | ${row.initial?.chunkCount ?? "—"} | ${row.initial?.rawBytes ?? "—"} | ${row.initial?.gzipBytes ?? "—"} | ${row.budget.maxInitialRawBytes ?? "observe"} | ${row.budget.maxInitialGzipBytes ?? "observe"} |`,
|
||||||
|
);
|
||||||
|
lines.push("", report.excluded, "");
|
||||||
|
for (const row of report.routes)
|
||||||
|
if (row.reason)
|
||||||
|
lines.push(
|
||||||
|
`- ${row.route}: unavailable — ${row.reason.replaceAll("\n", " ")}`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"Exact file lists, provenance and legacy polyfill sizes are in report.json.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function main(argv = process.argv.slice(2)) {
|
||||||
|
const options = {
|
||||||
|
"--next-dir": ".next",
|
||||||
|
"--config": "scripts/performance-budgets.json",
|
||||||
|
"--output-dir": ".next/performance-report",
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 2) {
|
||||||
|
if (!Object.hasOwn(options, argv[i]) || !argv[i + 1])
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node scripts/performance-report.mjs [--next-dir DIR] [--config FILE] [--output-dir DIR]",
|
||||||
|
);
|
||||||
|
options[argv[i]] = argv[i + 1];
|
||||||
|
}
|
||||||
|
const config = validateBudgets(jsonFile(options["--config"]));
|
||||||
|
const report = collectReport(options["--next-dir"], config, {
|
||||||
|
commitSha: process.env.PERFORMANCE_COMMIT_SHA || null,
|
||||||
|
});
|
||||||
|
mkdirSync(options["--output-dir"], { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(options["--output-dir"], "report.json"),
|
||||||
|
`${JSON.stringify(report, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(options["--output-dir"], "report.md"),
|
||||||
|
markdownReport(report),
|
||||||
|
);
|
||||||
|
console.log(markdownReport(report));
|
||||||
|
for (const row of report.routes)
|
||||||
|
if (row.status !== "measured")
|
||||||
|
console.warn(
|
||||||
|
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
|
||||||
|
);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Performance report configuration/output error: ${error.message}`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
collectReport,
|
||||||
|
markdownReport,
|
||||||
|
measureRoute,
|
||||||
|
normalizeAsset,
|
||||||
|
parseClientManifest,
|
||||||
|
validateBudgets,
|
||||||
|
} from "./performance-report.mjs";
|
||||||
|
|
||||||
|
const budget = {
|
||||||
|
route: "/news",
|
||||||
|
maxInitialRawBytes: 1000,
|
||||||
|
maxInitialGzipBytes: 1000,
|
||||||
|
};
|
||||||
|
const config = { schemaVersion: 1, mode: "informational", routes: [budget] };
|
||||||
|
const appPath = "/(site)/news/page";
|
||||||
|
const buildManifest = {
|
||||||
|
rootMainFiles: ["static/chunks/runtime.js", "static/chunks/shared.js"],
|
||||||
|
polyfillFiles: ["static/chunks/polyfill.js"],
|
||||||
|
};
|
||||||
|
const clientManifest = {
|
||||||
|
entryJSFiles: {
|
||||||
|
"[project]/src/app/layout": ["static/chunks/shared.js"],
|
||||||
|
"[project]/src/app/error": ["static/chunks/boundary.js"],
|
||||||
|
"[project]/src/app/(site)/news/page": [
|
||||||
|
"/_next/static/chunks/shared.js?dpl=old",
|
||||||
|
"static/chunks/page.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clientModules: { dynamic: { chunks: ["static/chunks/dynamic-only.js"] } },
|
||||||
|
};
|
||||||
|
const files = {
|
||||||
|
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
|
||||||
|
"static/chunks/shared.js": Buffer.from("shared".repeat(50)),
|
||||||
|
"static/chunks/boundary.js": Buffer.from("boundary"),
|
||||||
|
"static/chunks/page.js": Buffer.from("page"),
|
||||||
|
"static/chunks/polyfill.js": Buffer.from("legacy"),
|
||||||
|
};
|
||||||
|
const folders = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const folder of folders.splice(0))
|
||||||
|
rmSync(folder, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
function fixture() {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "route-js-report-"));
|
||||||
|
folders.push(dir);
|
||||||
|
const write = (relative, value) => {
|
||||||
|
const file = path.join(dir, relative);
|
||||||
|
mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
file,
|
||||||
|
typeof value === "string" || Buffer.isBuffer(value)
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
write("BUILD_ID", "fixture-build");
|
||||||
|
write("app-path-routes-manifest.json", { [appPath]: "/news" });
|
||||||
|
write("server/app-paths-manifest.json", {
|
||||||
|
[appPath]: "app/(site)/news/page.js",
|
||||||
|
});
|
||||||
|
write(
|
||||||
|
"server/app/(site)/news/page_client-reference-manifest.js",
|
||||||
|
`globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {};\nglobalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = ${JSON.stringify(clientManifest)};`,
|
||||||
|
);
|
||||||
|
write("server/app/(site)/news/page/build-manifest.json", buildManifest);
|
||||||
|
for (const [file, bytes] of Object.entries(files)) write(file, bytes);
|
||||||
|
return { dir, write };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("route JS measurement", () => {
|
||||||
|
it("requires a completed production build instead of measuring development output", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
rmSync(path.join(dir, "BUILD_ID"));
|
||||||
|
expect(collectReport(dir, config).routes[0]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
initial: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("does not guess between multiple app paths for one route", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("app-path-routes-manifest.json", {
|
||||||
|
[appPath]: "/news",
|
||||||
|
"/(other)/news/page": "/news",
|
||||||
|
});
|
||||||
|
expect(collectReport(dir, config).routes[0]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
initial: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates normalized shared chunks within each route and sums per-file gzip", () => {
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
const initial = Object.entries(files).filter(
|
||||||
|
([file]) => !file.includes("polyfill"),
|
||||||
|
);
|
||||||
|
expect(row.initial.chunkCount).toBe(4);
|
||||||
|
expect(row.initial.rawBytes).toBe(
|
||||||
|
initial.reduce((n, [, bytes]) => n + bytes.length, 0),
|
||||||
|
);
|
||||||
|
expect(row.initial.gzipBytes).toBe(
|
||||||
|
initial.reduce(
|
||||||
|
(n, [, bytes]) => n + gzipSync(bytes, { level: 9 }).length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
row.initial.chunks.find((f) => f.path.endsWith("shared.js")).sources,
|
||||||
|
).toHaveLength(3);
|
||||||
|
expect(row.initial.chunks.some((f) => f.path.includes("dynamic"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(row.legacyPolyfills.rawBytes).toBe(6);
|
||||||
|
});
|
||||||
|
it("uses route-specific bootstrap instead of unrelated global bootstrap", () => {
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest: {
|
||||||
|
...buildManifest,
|
||||||
|
rootMainFiles: ["static/chunks/not-used.js"],
|
||||||
|
rootMainFilesTree: { [appPath]: ["static/chunks/runtime.js"] },
|
||||||
|
},
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
expect(row.initial.chunkCount).toBe(4);
|
||||||
|
});
|
||||||
|
it("reports exceeded budgets without throwing", () => {
|
||||||
|
expect(
|
||||||
|
measureRoute({
|
||||||
|
budget: { ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 },
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
}),
|
||||||
|
).toMatchObject({ status: "over-budget", exceeded: ["raw", "gzip"] });
|
||||||
|
});
|
||||||
|
it("parses generated data without executing trailing JavaScript", () => {
|
||||||
|
expect(
|
||||||
|
parseClientManifest(
|
||||||
|
`globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {"entryJSFiles":{}};`,
|
||||||
|
appPath,
|
||||||
|
),
|
||||||
|
).toEqual({ entryJSFiles: {} });
|
||||||
|
expect(() =>
|
||||||
|
parseClientManifest(
|
||||||
|
`globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {}; globalThis.executed = true;`,
|
||||||
|
appPath,
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
"../secrets.js",
|
||||||
|
"static/../secrets.js",
|
||||||
|
"https://cdn.test/a.js",
|
||||||
|
"static\\secret.js",
|
||||||
|
"static/chunks/a.css",
|
||||||
|
])("rejects unsupported asset paths %s", (file) => {
|
||||||
|
expect(() => normalizeAsset(file)).toThrow();
|
||||||
|
});
|
||||||
|
it("reports exact emitted bytes with build provenance", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
expect(collectReport(dir, config, { commitSha: "abc1234" })).toMatchObject({
|
||||||
|
buildId: "fixture-build",
|
||||||
|
commitSha: "abc1234",
|
||||||
|
routes: [{ status: "measured", initial: { chunkCount: 4 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps unavailable manifests separate from a zero metric", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
rmSync(
|
||||||
|
path.join(
|
||||||
|
dir,
|
||||||
|
"server/app/(site)/news/page_client-reference-manifest.js",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const row = collectReport(dir, config).routes[0];
|
||||||
|
expect(row.status).toBe("unavailable");
|
||||||
|
expect(row.initial).toBeNull();
|
||||||
|
});
|
||||||
|
it("does not publish partial totals when one referenced asset is missing", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
rmSync(path.join(dir, "static/chunks/page.js"));
|
||||||
|
expect(collectReport(dir, config).routes[0]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
initial: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("falls back to the root build manifest only when the route manifest is absent", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("build-manifest.json", buildManifest);
|
||||||
|
rmSync(path.join(dir, "server/app/(site)/news/page/build-manifest.json"));
|
||||||
|
expect(collectReport(dir, config).routes[0].manifests.build).toBe(
|
||||||
|
"build-manifest.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("marks unsupported entryJSFiles layouts unavailable", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write(
|
||||||
|
"server/app/(site)/news/page_client-reference-manifest.js",
|
||||||
|
`globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {};`,
|
||||||
|
);
|
||||||
|
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
|
||||||
|
});
|
||||||
|
it("does not deduplicate shared files across independent cold route totals", () => {
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
const another = measureRoute({
|
||||||
|
budget: { ...budget, route: "/other" },
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
expect(another.initial.rawBytes).toBe(row.initial.rawBytes);
|
||||||
|
});
|
||||||
|
it("rejects invalid threshold schemas instead of silently dropping limits", () => {
|
||||||
|
for (const limit of [-1, 0, 1.5, "100", undefined])
|
||||||
|
expect(() =>
|
||||||
|
validateBudgets({
|
||||||
|
...config,
|
||||||
|
routes: [{ ...budget, maxInitialRawBytes: limit }],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
validateBudgets({ ...config, routes: [budget, budget] }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow();
|
||||||
|
expect(() => validateBudgets({ ...config, typo: true })).toThrow();
|
||||||
|
});
|
||||||
|
it("emits warnings and artifacts with exit zero when a build is unavailable", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("budgets.json", config);
|
||||||
|
const run = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/performance-report.mjs",
|
||||||
|
"--next-dir",
|
||||||
|
path.join(dir, "missing"),
|
||||||
|
"--config",
|
||||||
|
path.join(dir, "budgets.json"),
|
||||||
|
"--output-dir",
|
||||||
|
path.join(dir, "reports"),
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
expect(run.status).toBe(0);
|
||||||
|
expect(run.stderr).toContain("Performance warning:");
|
||||||
|
expect(run.stdout).toContain("unavailable");
|
||||||
|
});
|
||||||
|
it("exits nonzero for invalid configuration", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("budgets.json", {
|
||||||
|
...config,
|
||||||
|
routes: [{ ...budget, maxInitialRawBytes: -1 }],
|
||||||
|
});
|
||||||
|
const run = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/performance-report.mjs",
|
||||||
|
"--next-dir",
|
||||||
|
dir,
|
||||||
|
"--config",
|
||||||
|
path.join(dir, "budgets.json"),
|
||||||
|
"--output-dir",
|
||||||
|
path.join(dir, "reports"),
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
expect(run.status).toBe(1);
|
||||||
|
});
|
||||||
|
it("labels markdown with the envelope definition, never as loading time", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
const markdown = markdownReport(collectReport(dir, config));
|
||||||
|
expect(markdown).toContain("not measured browser traffic");
|
||||||
|
expect(markdown).toContain("Raw bytes");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user