mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 07:21:16 +00:00
* perf(ui): drop zod and lazy-load json5 out of Control UI startup zod's only UI consumer was the custom-theme shape layer, whose deep CSS validators already re-check every token; replace the two shallow schemas with a plain record reader and delete the zod jitless CSP shim + test. Startup keeps one schema library total (typebox, protocol-owned, already lazy via the approval page). json5 now loads through a lazy runtime boundary: strict JSON.parse is the fast path, the parser warms with the config editor and whenever a config snapshot or raw draft actually needs JSON5 (comments, trailing commas). The redaction sanitize path parses the authoritative raw once at snapshot ingestion (async-safe) and submits against the carried parsed fact, so secret-placeholder handling never races the lazy parser. Startup JS: 321.8 -> 295.8 KiB gzip, 13 -> 12 requests; budgets ratchet to 310 KiB / 18 requests. * fix(ui): gate config submits on pending JSON5 original parse A JSON5 config racing the first parser load could reach the sanitize step with a null parsed original and pass redaction placeholders through. setConfigRawOriginal now parses synchronously whenever the parser is warm and tracks a pending promise otherwise; submit, auto-save, and teardown flush paths defer to that promise (teardown keeps its synchronous prefix when no parse is pending). * fix(ui): keep teardown config flush synchronous and JSON5 diff cache non-sticky Teardown flush must dispatch before unload destroys the context; the gateway's restore-or-reject sentinel contract backs the rare unsanitized window. Raw-diff parse failures no longer cache while the lazy JSON5 parser is still loading, so a transient cold-parser miss retries on the next render instead of pinning an empty diff. * fix(ui): harden lazy JSON5 boundary against double-submit and failed loads Claim the config busy flag before awaiting a pending JSON5 original parse so a second click cannot slip past the busy state (autosave overlap is already serialized by the in-flight registry and drain discipline). A rejected json5 chunk import now resets the loader for retry, the per-state pending promise is never-rejecting and self-clearing, and fire-and-forget warms swallow rejections. * docs(ui): note autosave entry serialization at the JSON5 parse await * fix(ui): fill raw pending-changes diff once the lazy JSON5 parser lands First diff open could race the parser chunk and render an empty list with nothing scheduling a retry; renderConfig now re-renders when the warm completes, and the browser test warms the parser in setup to assert the steady state the view module guarantees in prod.
220 lines
8.0 KiB
JavaScript
220 lines
8.0 KiB
JavaScript
#!/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";
|
|
|
|
const KIB = 1024;
|
|
|
|
// Small, explicit headroom over the optimized baseline. Budget changes should
|
|
// accompany an intentional loading or chunking decision.
|
|
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
|
startupJsRequests: 18,
|
|
startupCssRequests: 1,
|
|
startupJsGzipBytes: 310 * KIB,
|
|
startupCssGzipBytes: 42 * KIB,
|
|
largestJsGzipBytes: 215 * KIB,
|
|
largestCssGzipBytes: 42 * KIB,
|
|
});
|
|
|
|
function controlUiAssetPathFromUrl(value) {
|
|
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) {
|
|
const assets = new Set();
|
|
for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) {
|
|
const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu);
|
|
const assetPath = attribute ? controlUiAssetPathFromUrl(attribute[1]) : null;
|
|
if (assetPath) {
|
|
assets.add(assetPath);
|
|
}
|
|
}
|
|
return [...assets].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function readAssetMetrics(assetsDir, entry) {
|
|
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)}`);
|
|
}
|
|
}
|
|
return {
|
|
file,
|
|
type: entry.name.endsWith(".js") ? "js" : "css",
|
|
rawBytes: fs.statSync(sourcePath).size,
|
|
gzipBytes: fs.statSync(gzipPath).size,
|
|
brotliBytes: fs.statSync(brotliPath).size,
|
|
};
|
|
}
|
|
|
|
function summarizeAssets(assets) {
|
|
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) {
|
|
return assets.toSorted(
|
|
(left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file),
|
|
)[0];
|
|
}
|
|
|
|
export function collectControlUiPerformanceMetrics(distDir) {
|
|
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 startup = extractControlUiStartupAssetPaths(html).map((file) => {
|
|
const asset = assetsByFile.get(file);
|
|
if (!asset) {
|
|
throw new Error(`Control UI performance check cannot find startup asset ${file}`);
|
|
}
|
|
return asset;
|
|
});
|
|
const jsAssets = assets.filter((asset) => asset.type === "js");
|
|
const cssAssets = assets.filter((asset) => asset.type === "css");
|
|
if (jsAssets.length === 0 || cssAssets.length === 0 || startup.length === 0) {
|
|
throw new Error("Control UI performance check found an incomplete production bundle");
|
|
}
|
|
return {
|
|
schemaVersion: 1,
|
|
startup: {
|
|
js: summarizeAssets(startup.filter((asset) => asset.type === "js")),
|
|
css: summarizeAssets(startup.filter((asset) => asset.type === "css")),
|
|
assets: startup,
|
|
},
|
|
total: {
|
|
js: summarizeAssets(jsAssets),
|
|
css: summarizeAssets(cssAssets),
|
|
},
|
|
largest: {
|
|
js: largestAsset(jsAssets),
|
|
css: largestAsset(cssAssets),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function evaluateControlUiPerformanceBudgets(
|
|
metrics,
|
|
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
) {
|
|
const checks = [
|
|
["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, budgets.startupJsGzipBytes, "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"],
|
|
];
|
|
return checks.flatMap(([metric, actual, limit, unit]) =>
|
|
actual > limit ? [{ metric, actual, limit, unit }] : [],
|
|
);
|
|
}
|
|
|
|
export function formatControlUiPerformanceBytes(bytes) {
|
|
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
|
|
}
|
|
|
|
function formatRequestCount(count) {
|
|
return `${count} ${count === 1 ? "request" : "requests"}`;
|
|
}
|
|
|
|
function formatAssetSummary(summary) {
|
|
return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip, ${formatControlUiPerformanceBytes(summary.brotliBytes)} br`;
|
|
}
|
|
|
|
function formatViolation(violation) {
|
|
const actual =
|
|
violation.unit === "bytes"
|
|
? formatControlUiPerformanceBytes(violation.actual)
|
|
: String(violation.actual);
|
|
const limit =
|
|
violation.unit === "bytes"
|
|
? formatControlUiPerformanceBytes(violation.limit)
|
|
: String(violation.limit);
|
|
return `${violation.metric}: ${actual} exceeds ${limit}`;
|
|
}
|
|
|
|
export function formatControlUiPerformanceReport(
|
|
metrics,
|
|
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
) {
|
|
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets);
|
|
const lines = [
|
|
"Control UI performance:",
|
|
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
|
|
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, ${formatControlUiPerformanceBytes(budgets.startupCssGzipBytes)} gzip)`,
|
|
` largest JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
|
|
` largest CSS: ${metrics.largest.css.file}, ${formatControlUiPerformanceBytes(metrics.largest.css.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestCssGzipBytes)})`,
|
|
` all JS: ${formatAssetSummary(metrics.total.js)}`,
|
|
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
|
|
];
|
|
if (violations.length > 0) {
|
|
lines.push(
|
|
" violations:",
|
|
...violations.map((violation) => ` - ${formatViolation(violation)}`),
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) {
|
|
const metrics = collectControlUiPerformanceMetrics(distDir);
|
|
return {
|
|
metrics,
|
|
budgets,
|
|
violations: evaluateControlUiPerformanceBudgets(metrics, budgets),
|
|
report: formatControlUiPerformanceReport(metrics, budgets),
|
|
};
|
|
}
|
|
|
|
function main(argv = process.argv.slice(2)) {
|
|
const unknown = argv.filter((arg) => arg !== "--json");
|
|
if (unknown.length > 0) {
|
|
throw new Error(`Unknown option: ${unknown[0]}`);
|
|
}
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui"));
|
|
if (argv.includes("--json")) {
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} else {
|
|
process.stdout.write(`${result.report}\n`);
|
|
}
|
|
if (result.violations.length > 0) {
|
|
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;
|
|
}
|
|
}
|