openclaw/scripts/check-control-ui-performance.mts
Peter Steinberger 4157ed60ff
fix(ui): stop serving a failed Control UI build on the next Gateway start (#161438)
* fix(ui): stop serving a failed Control UI build on the next Gateway start

Rolldown writes the complete bundle, runs the writeBundle hook, and only
then reports aggregated resolve errors and exits non-zero. scripts/ui.mts
built straight into the served dist/control-ui and reused the runtime build
identity, so a failed build left a correctly stamped tree that the Gateway's
asset health check accepted as ready on the next start (observed: a bare
markdown-it-emoji import broke the Control UI after a gateway-profile worktree
install skipped ui/ dependencies).

scripts/ui.mts now builds into a same-depth dist/control-ui.build-<pid>-*
sibling, runs both validators against it, and renames it into place only on
success, restoring the previous output if publication fails. Dead-process
leftovers are reclaimed on the next build and tsdown's dist cleaning skips
in-flight staging trees. The gateway worktree-setup workload now installs
./ui... because the Gateway builds the Control UI from source on first start.

* fix(ui): restore the previous Control UI bundle after an interrupted swap

A builder killed between moving dist/control-ui aside and renaming its staged build into place left the previous complete bundle only in a dead-process .retired sibling, which the next build's leftover cleanup deleted. The next build now restores the newest dead-process retired bundle when the served path is missing, before reclaiming leftovers, so a failed retry still serves it.

* fix(ui): keep interrupted Control UI builds out of packages

A killed builder cannot reach its cleanup, so a dist/control-ui.build-* staging or retired sibling can survive until the next UI build. Exclude those siblings from the package files list, which also drives the dist inventory, and type the new UI test fixtures for the scripts test lane.

* fix(ui): retry transient Windows denials when publishing the Control UI build

Windows scanners and indexers can briefly deny renaming a freshly written or served directory with EPERM, which failed an otherwise valid rebuild on its first attempt. Every publication rename now retries EPERM, EACCES, and EBUSY on a bounded 100-1600 ms schedule (about 3 s) and keeps restoring the previous bundle when the denial is permanent.
2026-09-30 03:28:17 +00:00

745 lines
29 KiB
TypeScript

#!/usr/bin/env node
// Reports and enforces compressed Control UI asset budgets after a production build.
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import {
CONTROL_UI_ROUTE_PRELOAD_ATTRIBUTE,
selectControlUiRoutePreloads,
} from "../src/gateway/control-ui-route-preloads.ts";
import { reportLimitViolations } from "./lib/check-limits.mts";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
function isMetricsRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
const KIB = 1024;
const STARTUP_JS_BASELINE_RATCHET_BYTES = 4096;
const BASELINE_UPDATE_COMMAND =
'node --import ./scripts/tsx.mjs scripts/check-control-ui-performance.mts --update-baseline --reason "<reason>"';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_STARTUP_BUDGET_BASELINE_PATH = path.resolve(
SCRIPT_DIR,
"../config/control-ui-startup-budget-baseline.json",
);
// Each landed change can consume this much ratchet tolerance, so small increases
// may accumulate. The fixed startup JS ceiling bounds that cumulative creep.
const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 512;
const CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES = 64;
const CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES = 45 * KIB;
// Immediate Home and diagnostic frames approved in #147574, including shared header styles.
const CONTROL_UI_CSS_GZIP_GROWTH_BYTES = 1.5 * KIB;
// The opaque Mermaid sandbox loads one self-contained classic script only when
// a diagram is viewed. Keep its size visible without relaxing ordinary chunks.
const MERMAID_RENDERER_ASSET = /^assets\/mermaid\.min-[\w-]+\.js$/u;
const MERMAID_RENDERER_GZIP_BYTES = 960 * KIB;
// Locale catalogs are named Vite chunks loaded only after a language selection.
// Base and config-hint chunks share one budget because they activate atomically.
const CONTROL_UI_LOCALE_ASSET_PATTERNS = CONTROL_UI_LOCALE_ENTRIES.flatMap(({ locale }) => [
{
locale,
kind: "base" as const,
pattern: new RegExp(`^assets/${escapeRegExp(locale)}-[^/]+\\.js$`, "u"),
},
{
locale,
kind: "configHints" as const,
pattern: new RegExp(`^assets/locale-config-hints-${escapeRegExp(locale)}-[^/]+\\.js$`, "u"),
},
]);
const CONTROL_UI_LOCALE_GZIP_BYTES = 300 * KIB;
// Small, explicit headroom over the optimized baseline. Budget changes should
// accompany an intentional loading or chunking decision.
const controlUiPerformanceBudgets = {
startupJsRequests: 18,
startupCssRequests: 1,
// Approved measured upload-control baseline; retain the fixed growth and variance allowances.
startupJsGzipBytes: 371_771,
// Keep 45 KiB advisory: tiny integrated changes must not exhaust the budget.
// The fixed 50 KiB ceiling bounds accumulation of small changes.
startupCssGzipBytes: 50 * KIB,
largestJsGzipBytes: 215 * KIB,
// Composer multiline surface (stack #124301) legitimately grew boot CSS;
// operator decision 2026-08-25 rejected boot splitting due to precedence risk.
// 53.0 KiB was exhausted by organic growth (main sat at 99.94% by 2026-08-29);
// bumped to 53.5 KiB with operator approval on PR #132054. 2026-09-02: side
// panel, workboard chip, and Lobsterdex styles moved to their lazy owners,
// measured boot sheet 52,337 B; ceiling lowered to keep ~1 KiB headroom.
largestCssGzipBytes: 53_400,
} satisfies Record<string, number>;
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets);
function controlUiAssetPathFromUrl(value: string): string | null {
const normalized = value.split(/[?#]/u, 1)[0]?.replace(/\\/gu, "/") ?? "";
const markerIndex = normalized.lastIndexOf("assets/");
if (markerIndex === -1) {
return null;
}
const assetPath = normalized.slice(markerIndex);
if (assetPath.includes("../") || !/\.(?:css|js)$/u.test(assetPath)) {
return null;
}
return assetPath;
}
export function extractControlUiStartupAssetPaths(html: string): string[] {
const assets = new Set<string>();
for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) {
const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu);
const assetPath = attribute?.[1] ? controlUiAssetPathFromUrl(attribute[1]) : null;
if (assetPath) {
assets.add(assetPath);
}
}
return [...assets].toSorted((left, right) => left.localeCompare(right));
}
function readAssetMetrics(assetsDir: string, entry: fs.Dirent) {
const file = `assets/${entry.name}`;
const sourcePath = path.join(assetsDir, entry.name);
const gzipPath = `${sourcePath}.gz`;
const brotliPath = `${sourcePath}.br`;
for (const sidecarPath of [gzipPath, brotliPath]) {
if (!fs.existsSync(sidecarPath)) {
throw new Error(`Control UI performance check missing ${path.basename(sidecarPath)}`);
}
}
const type = entry.name.endsWith(".js") ? "js" : "css";
return {
file,
type,
rawBytes: fs.statSync(sourcePath).size,
gzipBytes: fs.statSync(gzipPath).size,
brotliBytes: fs.statSync(brotliPath).size,
};
}
function summarizeAssets(assets: Array<ReturnType<typeof readAssetMetrics>>) {
return assets.reduce(
(summary, asset) => ({
requests: summary.requests + 1,
rawBytes: summary.rawBytes + asset.rawBytes,
gzipBytes: summary.gzipBytes + asset.gzipBytes,
brotliBytes: summary.brotliBytes + asset.brotliBytes,
}),
{ requests: 0, rawBytes: 0, gzipBytes: 0, brotliBytes: 0 },
);
}
function largestAsset(assets: Array<ReturnType<typeof readAssetMetrics>>) {
return assets.toSorted(
(left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file),
)[0]!;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
function controlUiLocaleAssetIdentity(
file: string,
): { locale: string; kind: "base" | "configHints" } | null {
const match = CONTROL_UI_LOCALE_ASSET_PATTERNS.find(({ pattern }) => pattern.test(file));
return match ? { locale: match.locale, kind: match.kind } : null;
}
function collectControlUiLocaleAssetGroups(assets: Array<ReturnType<typeof readAssetMetrics>>) {
const groups = new Map<
string,
{
locale: string;
base: Array<ReturnType<typeof readAssetMetrics>>;
configHints: Array<ReturnType<typeof readAssetMetrics>>;
}
>();
for (const asset of assets) {
const identity = controlUiLocaleAssetIdentity(asset.file);
if (!identity) {
continue;
}
const group = groups.get(identity.locale) ?? {
locale: identity.locale,
base: [],
configHints: [],
};
group[identity.kind].push(asset);
groups.set(identity.locale, group);
}
return [...groups.values()];
}
export function collectControlUiPerformanceMetrics(distDir: string) {
const assetsDir = path.join(distDir, "assets");
const html = fs.readFileSync(path.join(distDir, "index.html"), "utf8");
const assets = fs
.readdirSync(assetsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(?:css|js)$/u.test(entry.name))
.map((entry) => readAssetMetrics(assetsDir, entry));
const assetsByFile = new Map(assets.map((asset) => [asset.file, asset]));
const collectBootAssets = (document: string) => {
const bootAssets = extractControlUiStartupAssetPaths(document).map((file) => {
const asset = assetsByFile.get(file);
if (!asset) {
throw new Error(`Control UI performance check cannot find startup asset ${file}`);
}
return asset;
});
return {
js: summarizeAssets(bootAssets.filter((asset) => asset.type === "js")),
css: summarizeAssets(bootAssets.filter((asset) => asset.type === "css")),
assets: bootAssets,
};
};
// Preserve the historical entry budget; route preloads also account for the
// measured immediate dynamic imports that it never included.
const startup = collectBootAssets(selectControlUiRoutePreloads(html, null));
const routeBoot = html.includes(CONTROL_UI_ROUTE_PRELOAD_ATTRIBUTE)
? {
chat: collectBootAssets(selectControlUiRoutePreloads(html, "chat")),
new: collectBootAssets(selectControlUiRoutePreloads(html, "new")),
}
: null;
const jsAssets = assets.filter((asset) => asset.type === "js");
const mermaidRenderer = jsAssets.filter((asset) => MERMAID_RENDERER_ASSET.test(asset.file));
const localeCatalogs = jsAssets.filter(
(asset) => controlUiLocaleAssetIdentity(asset.file) !== null,
);
const ordinaryJsAssets = jsAssets.filter(
(asset) =>
!MERMAID_RENDERER_ASSET.test(asset.file) && controlUiLocaleAssetIdentity(asset.file) === null,
);
const cssAssets = assets.filter((asset) => asset.type === "css");
if (ordinaryJsAssets.length === 0 || cssAssets.length === 0 || startup.assets.length === 0) {
throw new Error("Control UI performance check found an incomplete production bundle");
}
return {
schemaVersion: 1 as const,
startup,
routeBoot,
total: {
js: summarizeAssets(jsAssets),
css: summarizeAssets(cssAssets),
},
largest: {
js: largestAsset(ordinaryJsAssets),
css: largestAsset(cssAssets),
},
mermaidRenderer,
localeCatalogs,
};
}
export function evaluateControlUiPerformanceBudgets(
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
baseMetrics: ReturnType<typeof collectControlUiPerformanceMetrics> | null = null,
) {
const baselineBytes = startupBudgetBaseline?.startupJsGzipBytes;
const startupJsLimits = resolveControlUiStartupJsGzipLimits(
budgets,
startupBudgetBaseline,
startupJsTolerance,
);
const localeGroups = collectControlUiLocaleAssetGroups(metrics.localeCatalogs);
const startupAssetFiles = new Set(
[
...metrics.startup.assets,
...Object.values(metrics.routeBoot ?? {}).flatMap((route) => route.assets),
].map((asset) => asset.file),
);
const checks: Array<[string, number, number, "count" | "bytes"]> = [
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
["startup CSS requests", metrics.startup.css.requests, budgets.startupCssRequests, "count"],
["startup JS gzip", metrics.startup.js.gzipBytes, startupJsLimits.enforcementLimit, "bytes"],
["startup CSS gzip", metrics.startup.css.gzipBytes, budgets.startupCssGzipBytes, "bytes"],
["largest JS gzip", metrics.largest.js.gzipBytes, budgets.largestJsGzipBytes, "bytes"],
["largest CSS gzip", metrics.largest.css.gzipBytes, budgets.largestCssGzipBytes, "bytes"],
["isolated Mermaid JS assets", metrics.mermaidRenderer.length, 1, "count"],
[
"isolated Mermaid JS gzip",
summarizeAssets(metrics.mermaidRenderer).gzipBytes,
MERMAID_RENDERER_GZIP_BYTES,
"bytes",
],
[
"startup Mermaid JS assets",
[...startupAssetFiles].filter((file) => MERMAID_RENDERER_ASSET.test(file)).length,
0,
"count",
],
[
"locale catalog JS assets",
metrics.localeCatalogs.length,
CONTROL_UI_LOCALE_ENTRIES.length * 2,
"count",
],
[
"locale catalog base JS assets per locale",
Math.max(0, ...localeGroups.map((group) => group.base.length)),
1,
"count",
],
[
"locale config-hint JS assets per locale",
Math.max(0, ...localeGroups.map((group) => group.configHints.length)),
1,
"count",
],
[
"largest locale catalog pair JS gzip",
Math.max(
0,
...localeGroups.map(
(group) => summarizeAssets([...group.base, ...group.configHints]).gzipBytes,
),
),
CONTROL_UI_LOCALE_GZIP_BYTES,
"bytes",
],
[
"startup locale catalog JS assets",
[...startupAssetFiles].filter((file) => controlUiLocaleAssetIdentity(file) !== null).length,
0,
"count",
],
];
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
actual > limit ? [{ metric, actual, limit, unit }] : [],
);
if (baseMetrics) {
for (const area of ["startup", "largest"] as const) {
const growth = metrics[area].css.gzipBytes - baseMetrics[area].css.gzipBytes;
if (growth >= CONTROL_UI_CSS_GZIP_GROWTH_BYTES) {
violations.push({
metric: `${area} CSS gzip growth`,
actual: growth,
limit: CONTROL_UI_CSS_GZIP_GROWTH_BYTES - 1,
unit: "bytes",
});
}
}
}
if (baselineBytes !== undefined && baselineBytes > budgets.startupJsGzipBytes) {
violations.unshift({
metric: "startup JS gzip baseline",
actual: baselineBytes,
limit: budgets.startupJsGzipBytes,
unit: "bytes",
});
}
return violations;
}
function resolveControlUiStartupJsGzipLimits(
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS>,
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null,
startupJsTolerance: number,
) {
const baselineCap = budgets.startupJsGzipBytes;
if (!startupBudgetBaseline) {
return {
baselineCap,
growthLimit: baselineCap,
buildVariance: 0,
enforcementLimit: baselineCap,
};
}
const growthLimit =
Math.min(startupBudgetBaseline.startupJsGzipBytes, baselineCap) + startupJsTolerance;
return {
baselineCap,
growthLimit,
buildVariance: CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
enforcementLimit: growthLimit + CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
};
}
type ControlUiPerformanceBudgetViolation = ReturnType<
typeof evaluateControlUiPerformanceBudgets
>[number];
function formatControlUiPerformanceBytes(bytes: number): string {
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
}
function formatRequestCount(count: number): string {
return `${count} ${count === 1 ? "request" : "requests"}`;
}
function formatAssetSummary(summary: ReturnType<typeof summarizeAssets>): string {
return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip (${summary.gzipBytes} B), ${formatControlUiPerformanceBytes(summary.brotliBytes)} br, ${summary.rawBytes} B raw`;
}
function controlUiPerformanceWarnings(
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS>,
): string[] {
const warnings: string[] = [];
if (metrics.startup.css.gzipBytes > CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES) {
warnings.push(
`startup CSS gzip is above the ${CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES} B advisory target; remaining hard-ceiling headroom: ${budgets.startupCssGzipBytes - metrics.startup.css.gzipBytes} B`,
);
}
const largestCssHeadroom = budgets.largestCssGzipBytes - metrics.largest.css.gzipBytes;
if (largestCssHeadroom < CONTROL_UI_CSS_GZIP_GROWTH_BYTES) {
warnings.push(`largest CSS gzip has ${largestCssHeadroom} B of hard-ceiling headroom`);
}
return warnings;
}
function formatViolation(violation: ControlUiPerformanceBudgetViolation): string {
const format = violation.unit === "bytes" ? formatControlUiPerformanceBytes : String;
const actual = format(violation.actual);
const limit = format(violation.limit);
const exactBytes =
violation.unit === "bytes" && actual === limit
? ` (${violation.actual} B vs ${violation.limit} B)`
: "";
return `${violation.metric}: ${actual} exceeds ${limit}${exactBytes}`;
}
export function formatControlUiPerformanceReport(
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
startupJsTolerance: number = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
baseMetrics: ReturnType<typeof collectControlUiPerformanceMetrics> | null = null,
): string {
const startupJsLimits = resolveControlUiStartupJsGzipLimits(
budgets,
startupBudgetBaseline,
startupJsTolerance,
);
const violations = evaluateControlUiPerformanceBudgets(
metrics,
budgets,
startupBudgetBaseline,
startupJsTolerance,
baseMetrics,
);
const lines = [
"Control UI performance:",
` measurement: shipped gzip sidecars; Node ${process.version}`,
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(startupJsLimits.enforcementLimit)} gzip / ${startupJsLimits.enforcementLimit} B)`,
];
if (startupBudgetBaseline) {
lines.push(
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + growth allowance ${startupJsTolerance} B = growth limit ${startupJsLimits.growthLimit} B; build-variance allowance ${startupJsLimits.buildVariance} B; enforcement limit ${startupJsLimits.enforcementLimit} B; max committed baseline ${startupJsLimits.baselineCap} B)`,
);
}
lines.push(
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, advisory target ${CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES} B, hard ceiling ${budgets.startupCssGzipBytes} B; headroom ${budgets.startupCssGzipBytes - metrics.startup.css.gzipBytes} B)`,
` largest ordinary JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
` largest CSS: ${metrics.largest.css.file}, ${metrics.largest.css.gzipBytes} B gzip (hard ceiling ${budgets.largestCssGzipBytes} B; headroom ${budgets.largestCssGzipBytes - metrics.largest.css.gzipBytes} B)`,
` all JS: ${formatAssetSummary(metrics.total.js)}`,
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
);
if (metrics.routeBoot) {
lines.push(
" route boot accounting: initial-entry assets plus measured immediate dynamic imports; existing initial-entry limits unchanged",
);
for (const route of ["chat", "new"] as const) {
const boot = metrics.routeBoot[route];
lines.push(
` ${route} boot JS: ${formatAssetSummary(boot.js)} (${boot.js.gzipBytes - metrics.startup.js.gzipBytes} B beyond initial-entry JS)`,
` ${route} boot CSS: ${formatAssetSummary(boot.css)}`,
);
if (baseMetrics) {
const baseBoot = baseMetrics.routeBoot?.[route];
if (baseBoot) {
for (const type of ["js", "css"] as const) {
const growth = boot[type].gzipBytes - baseBoot[type].gzipBytes;
lines.push(
` ${route} boot ${type.toUpperCase()} gzip vs base: ${baseBoot[type].gzipBytes} B -> ${boot[type].gzipBytes} B (${growth >= 0 ? "+" : ""}${growth} B); requests ${baseBoot[type].requests} -> ${boot[type].requests}`,
);
}
} else {
lines.push(` ${route} boot vs base: unavailable (base has no route preload templates)`);
}
}
}
} else {
lines.push(" route boot accounting: unavailable (build has no route preload templates)");
}
if (baseMetrics) {
for (const area of ["startup", "largest"] as const) {
const growth = metrics[area].css.gzipBytes - baseMetrics[area].css.gzipBytes;
lines.push(
` ${area} CSS gzip vs base: ${baseMetrics[area].css.gzipBytes} B -> ${metrics[area].css.gzipBytes} B (${growth >= 0 ? "+" : ""}${growth} B; growth below ${CONTROL_UI_CSS_GZIP_GROWTH_BYTES} B allowed)`,
);
}
}
for (const warning of controlUiPerformanceWarnings(metrics, budgets)) {
lines.push(` warning: ${warning}`);
}
if (metrics.mermaidRenderer.length > 0) {
lines.push(
` isolated Mermaid JS: ${formatAssetSummary(summarizeAssets(metrics.mermaidRenderer))} (limits: 1 deferred asset, ${formatControlUiPerformanceBytes(MERMAID_RENDERER_GZIP_BYTES)} gzip; forbidden at startup)`,
);
}
if (metrics.localeCatalogs.length > 0) {
const localeGroups = collectControlUiLocaleAssetGroups(metrics.localeCatalogs);
const largestLocalePair = localeGroups.toSorted(
(left, right) =>
summarizeAssets([...right.base, ...right.configHints]).gzipBytes -
summarizeAssets([...left.base, ...left.configHints]).gzipBytes ||
left.locale.localeCompare(right.locale),
)[0]!;
const largestLocalePairSummary = summarizeAssets([
...largestLocalePair.base,
...largestLocalePair.configHints,
]);
lines.push(
` locale catalog JS: ${formatAssetSummary(summarizeAssets(metrics.localeCatalogs))} (largest pair: ${largestLocalePair.locale}, ${formatControlUiPerformanceBytes(largestLocalePairSummary.gzipBytes)} gzip; limits: 1 base + 1 config-hint asset per locale, ${formatControlUiPerformanceBytes(CONTROL_UI_LOCALE_GZIP_BYTES)} combined; forbidden at startup)`,
);
}
if (
startupBudgetBaseline &&
metrics.startup.js.gzipBytes + STARTUP_JS_BASELINE_RATCHET_BYTES <
startupBudgetBaseline.startupJsGzipBytes
) {
lines.push(
` hint: startup JS gzip is more than ${STARTUP_JS_BASELINE_RATCHET_BYTES} B below the ${startupBudgetBaseline.startupJsGzipBytes} B baseline; lower it with ${BASELINE_UPDATE_COMMAND}`,
);
}
if (violations.length > 0) {
lines.push(
" violations:",
...violations.map((violation) => ` - ${formatViolation(violation)}`),
);
}
return lines.join("\n");
}
function isIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
return false;
}
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
}
function readControlUiStartupBudgetBaseline(baselinePath: string): ControlUiStartupBudgetBaseline {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
const record: Record<string, unknown> = isMetricsRecord(parsed) ? parsed : {};
const { startupJsGzipBytes, reason, updatedAt } = record;
if (
typeof startupJsGzipBytes !== "number" ||
!Number.isSafeInteger(startupJsGzipBytes) ||
startupJsGzipBytes < 0 ||
typeof reason !== "string" ||
reason.trim().length === 0 ||
typeof updatedAt !== "string" ||
!isIsoDate(updatedAt)
) {
throw new Error(
"expected non-negative integer startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt",
);
}
return { startupJsGzipBytes, reason, updatedAt };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot read Control UI startup budget baseline ${baselinePath}: ${detail}. Regenerate it with ${BASELINE_UPDATE_COMMAND}.`,
{ cause: error },
);
}
}
function writeControlUiStartupBudgetBaseline(
baselinePath: string,
startupJsGzipBytes: number,
reason: string,
) {
if (startupJsGzipBytes > CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes) {
throw new Error("startup JS gzip baseline exceeds the committed-baseline cap");
}
const baseline = {
startupJsGzipBytes,
reason,
updatedAt: new Date().toISOString().slice(0, 10),
};
fs.writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
return baseline;
}
type ControlUiStartupBudgetBaseline = ReturnType<typeof writeControlUiStartupBudgetBaseline>;
function validateExplicitStartupJsBytes(
startupJsBytes: number,
currentBaseline: ControlUiStartupBudgetBaseline,
): void {
const delta = Math.abs(startupJsBytes - currentBaseline.startupJsGzipBytes);
if (delta > STARTUP_JS_BASELINE_RATCHET_BYTES) {
throw new Error(
`startup JS gzip baseline update: ${startupJsBytes} B differs from current baseline ${currentBaseline.startupJsGzipBytes} B by ${delta} B, exceeding the ${STARTUP_JS_BASELINE_RATCHET_BYTES} B ratchet`,
);
}
}
export function runControlUiPerformanceCheck(
distDir: string,
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
baselinePath = DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
baseDistDir?: string,
) {
const startupBudgetBaseline = readControlUiStartupBudgetBaseline(baselinePath);
const metrics = collectControlUiPerformanceMetrics(distDir);
const baseMetrics = baseDistDir ? collectControlUiPerformanceMetrics(baseDistDir) : null;
const violations = evaluateControlUiPerformanceBudgets(
metrics,
budgets,
startupBudgetBaseline,
undefined,
baseMetrics,
);
const report = formatControlUiPerformanceReport(
metrics,
budgets,
startupBudgetBaseline,
undefined,
baseMetrics,
);
return {
metrics,
baseMetrics,
budgets,
startupBudgetBaseline,
startupJsTolerance: CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
startupJsBuildVariance: CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
violations,
warnings: controlUiPerformanceWarnings(metrics, budgets),
report,
};
}
function main(argv: string[] = process.argv.slice(2)): void {
let json = false;
let reportOnly = false;
let distDir: string | undefined;
let baseDistDir: string | undefined;
let updateBaseline = false;
let reason: string | undefined;
let startupJsBytes: number | undefined;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--json") {
json = true;
} else if (arg === "--report-only") {
reportOnly = true;
} else if (arg === "--base-dist" || arg === "--dist") {
const value = argv[++index];
if (!value || value.startsWith("--")) {
throw new Error(`${arg} requires a directory`);
}
if (arg === "--dist") {
distDir = path.resolve(value);
} else {
baseDistDir = path.resolve(value);
}
} else if (arg === "--update-baseline") {
updateBaseline = true;
} else if (arg === "--reason") {
reason = argv[index + 1];
if (!reason || reason.trim().length === 0 || reason.startsWith("--")) {
throw new Error("--reason requires a non-empty value");
}
index += 1;
} else if (arg === "--startup-js-bytes") {
const value = argv[index + 1];
if (!value || !/^[1-9]\d*$/u.test(value)) {
throw new Error("--startup-js-bytes requires a positive integer");
}
startupJsBytes = Number(value);
if (!Number.isSafeInteger(startupJsBytes)) {
throw new Error("--startup-js-bytes requires a positive integer");
}
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
if (reason !== undefined && !updateBaseline) {
throw new Error("--reason requires --update-baseline");
}
if (startupJsBytes !== undefined && !updateBaseline) {
throw new Error("--startup-js-bytes requires --update-baseline");
}
if (json && updateBaseline) {
throw new Error("--json cannot be combined with --update-baseline");
}
if (updateBaseline && (reportOnly || baseDistDir || distDir)) {
throw new Error(
"--report-only, --base-dist and --dist cannot be combined with --update-baseline",
);
}
distDir ??= path.resolve(SCRIPT_DIR, "../dist/control-ui");
if (updateBaseline) {
if (startupJsBytes !== undefined) {
const currentBaseline = readControlUiStartupBudgetBaseline(
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
);
validateExplicitStartupJsBytes(startupJsBytes, currentBaseline);
}
const nextStartupJsBytes =
startupJsBytes ?? collectControlUiPerformanceMetrics(distDir).startup.js.gzipBytes;
const baseline = writeControlUiStartupBudgetBaseline(
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
nextStartupJsBytes,
reason ?? "manual baseline update",
);
process.stdout.write(
`Updated config/control-ui-startup-budget-baseline.json to ${baseline.startupJsGzipBytes} B (${baseline.reason}).\n`,
);
return;
}
const result = runControlUiPerformanceCheck(distDir, undefined, undefined, baseDistDir);
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stdout.write(`${result.report}\n`);
}
if (!reportOnly) {
const artifactContractMetrics = new Set([
"isolated Mermaid JS assets",
"startup Mermaid JS assets",
"locale catalog base JS assets per locale",
"locale config-hint JS assets per locale",
"startup locale catalog JS assets",
]);
const limitsFailed = reportLimitViolations(
result.violations
.filter((violation) => !artifactContractMetrics.has(violation.metric))
.map((violation) => ({
file:
violation.metric === "startup JS gzip baseline"
? "config/control-ui-startup-budget-baseline.json"
: "scripts/check-control-ui-performance.mts",
title: "Control UI asset budget",
message: formatViolation(violation),
})),
);
if (
limitsFailed ||
result.violations.some((violation) => artifactContractMetrics.has(violation.metric))
) {
process.exitCode = 1;
}
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}