'use strict';
const {existsSync, readFileSync, writeFileSync} = require('fs');
const SUPPORTED_VERSIONS = new Set([1]);
const SUPPORTED_STATUSES = new Set([
'ok',
'base-artifacts-unavailable',
'base-build-not-found',
]);
const CRITICAL_THRESHOLD = 0.02;
const SIGNIFICANCE_THRESHOLD = 0.002;
const CRITICAL_ARTIFACT_PATHS = new Set([
'oss-stable/react-dom/cjs/react-dom.production.js',
'oss-stable/react-dom/cjs/react-dom-client.production.js',
'oss-experimental/react-dom/cjs/react-dom.production.js',
'oss-experimental/react-dom/cjs/react-dom-client.production.js',
'facebook-www/ReactDOM-prod.classic.js',
'facebook-www/ReactDOM-prod.modern.js',
]);
const MAX_COMMENT_LENGTH = 65536;
const MARKER_PREFIX = '<!-- sizebot-comment';
const NOTICE_START = '<!-- sizebot-notice-start -->';
const NOTICE_END = '<!-- sizebot-notice-end -->';
const REPORT_START = '<!-- sizebot-report-start -->';
const REPORT_END = '<!-- sizebot-report-end -->';
const CONTEXT_PATH = 'sizebot-context.json';
const RESULTS_PATH = 'sizebot-results.json';
const COMMENT_PATH = 'sizebot-comment.md';
const MESSAGE_PATH = 'sizebot-message.md';
const PROBLEM_PATH = 'sizebot-problem.txt';
const SAFE_ARTIFACT_PATH = /^[A-Za-z0-9_@./+-]+$/;
function isSafeArtifactPath(value) {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length < 512 &&
SAFE_ARTIFACT_PATH.test(value) &&
!value.includes('..') &&
!value.startsWith('/')
);
}
function isSize(value) {
return value === null || (Number.isFinite(value) && value >= 0);
}
function isSha(value) {
return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value);
}
const kilobyteFormatter = new Intl.NumberFormat('en', {
style: 'unit',
unit: 'kilobyte',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function kbs(bytes) {
return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000);
}
const percentFormatter = new Intl.NumberFormat('en', {
style: 'percent',
signDisplay: 'exceptZero',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function ratio(baseSize, headSize) {
if (baseSize === null) {
return Infinity;
}
if (headSize === null) {
return -1;
}
return (headSize - baseSize) / baseSize;
}
function change(decimal) {
if (decimal === Infinity) {
return 'New file';
}
if (decimal === -1) {
return 'Deleted';
}
if (Math.abs(decimal) < 0.0001) {
return '=';
}
return percentFormatter.format(decimal);
}
const header = `| Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip |
| ---- | --- | ---- | ------- | -------- | --------- | ------------ |`;
function row(result, baseSha, headSha) {
const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`;
const rowArr = [
`| [${result.path}](${diffViewUrl})`,
`**${change(result.change)}**`,
`${kbs(result.baseSize)}`,
`${kbs(result.headSize)}`,
`${change(result.changeGzip)}`,
`${kbs(result.baseSizeGzip)}`,
`${kbs(result.headSizeGzip)}`,
];
return rowArr.join(' | ');
}
function validateResults(raw) {
if (raw === null || typeof raw !== 'object') {
return {ok: false, reason: 'malformed'};
}
if (!SUPPORTED_VERSIONS.has(raw.version)) {
return {ok: false, reason: 'unsupported-version'};
}
if (!SUPPORTED_STATUSES.has(raw.status)) {
return {ok: false, reason: 'malformed'};
}
if (raw.status === 'base-artifacts-unavailable') {
return {ok: true, results: {status: raw.status}};
}
if (raw.status === 'base-build-not-found') {
return {
ok: true,
results: {
status: raw.status,
baseSha: isSha(raw.baseSha) ? raw.baseSha : null,
},
};
}
if (!isSha(raw.baseSha) || !isSha(raw.headSha)) {
return {ok: false, reason: 'malformed'};
}
if (!Array.isArray(raw.artifacts)) {
return {ok: false, reason: 'malformed'};
}
for (const artifact of raw.artifacts) {
if (artifact === null || typeof artifact !== 'object') {
return {ok: false, reason: 'malformed'};
}
if (!isSafeArtifactPath(artifact.path)) {
return {ok: false, reason: 'malformed'};
}
if (
!isSize(artifact.baseSize) ||
!isSize(artifact.baseSizeGzip) ||
!isSize(artifact.headSize) ||
!isSize(artifact.headSizeGzip)
) {
return {ok: false, reason: 'malformed'};
}
if (artifact.baseSize === null && artifact.headSize === null) {
return {ok: false, reason: 'malformed'};
}
}
return {ok: true, results: raw};
}
function renderTable(results) {
const {baseSha, headSha} = results;
const resultsMap = new Map();
for (const artifact of results.artifacts) {
resultsMap.set(artifact.path, {
...artifact,
change: ratio(artifact.baseSize, artifact.headSize),
changeGzip: ratio(artifact.baseSizeGzip, artifact.headSizeGzip),
});
}
const sorted = Array.from(resultsMap.values());
sorted.sort((a, b) => b.change - a.change);
const criticalResults = [];
const missingCriticalPaths = [];
for (const artifactPath of CRITICAL_ARTIFACT_PATHS) {
const result = resultsMap.get(artifactPath);
if (result === undefined) {
missingCriticalPaths.push(artifactPath);
continue;
}
criticalResults.push(row(result, baseSha, headSha));
}
const significantResults = [];
for (const result of sorted) {
if (
(Math.abs(result.change) > CRITICAL_THRESHOLD ||
result.change === Infinity ||
result.change === -1) &&
!CRITICAL_ARTIFACT_PATHS.has(result.path)
) {
criticalResults.push(row(result, baseSha, headSha));
}
if (
Math.abs(result.change) > SIGNIFICANCE_THRESHOLD ||
result.change === Infinity ||
result.change === -1
) {
significantResults.push(row(result, baseSha, headSha));
}
}
const markdown = `Comparing: ${baseSha}...${headSha}
## Critical size changes
Includes critical production bundles, as well as any change greater than ${
CRITICAL_THRESHOLD * 100
}%:
${header}
${criticalResults.join('\n')}
## Significant size changes
Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%:
${
significantResults.length > 0
? `<details>
<summary>Expand to show</summary>
${header}
${significantResults.join('\n')}
</details>`
: '(No significant changes)'
}`;
return {markdown, missingCriticalPaths};
}
function renderCompletedReport(context) {
const {runConclusion, runUrl, devtoolsOnly} = context;
if (runConclusion === 'action_required') {
return {
markdown: `[The build for this commit](${runUrl}) needs maintainer approval before it can run, so there is no size report yet.`,
missingCriticalPaths: [],
};
}
if (runConclusion !== 'success') {
return {
markdown: `The build for this commit did not complete, so there is no size report. See [the workflow run](${runUrl}) for details.`,
missingCriticalPaths: [],
};
}
if (devtoolsOnly) {
return {
markdown:
'No size report: this pull request only touches `packages/react-devtools`, which does not affect production bundle size.',
missingCriticalPaths: [],
};
}
if (!existsSync(RESULTS_PATH)) {
return {
markdown: `The build succeeded but produced no size results, so there is no size report. See [the workflow run](${runUrl}) for details.`,
missingCriticalPaths: [],
};
}
let raw;
try {
raw = JSON.parse(readFileSync(RESULTS_PATH, 'utf8'));
} catch {
raw = null;
}
const validated = validateResults(raw);
if (!validated.ok) {
if (validated.reason === 'unsupported-version') {
return {
markdown:
'This pull request produced a size report in a format this repository no longer reads. ' +
'Merge the latest changes from the `main` branch to pick up the current one.',
missingCriticalPaths: [],
};
}
return {
markdown: `The size results for this commit could not be read, so there is no size report. See [the workflow run](${runUrl}) for details.`,
missingCriticalPaths: [],
};
}
if (validated.results.status === 'base-artifacts-unavailable') {
return {
markdown:
"Failed to read build artifacts. It's possible a build configuration has changed upstream. " +
'Try pulling the latest changes from the `main` branch.',
missingCriticalPaths: [],
};
}
if (validated.results.status === 'base-build-not-found') {
const {baseSha} = validated.results;
return {
markdown:
`No build was found for the base commit${
baseSha === null ? '' : ` (${baseSha})`
} that this pull request diverged from, so there is no size report. ` +
'The build for that commit may have failed, or its artifacts may be ' +
'older than the retention window. Rebase the pull request onto a ' +
'newer `main` to compare against a base commit that has a build.',
missingCriticalPaths: [],
problem: `No base build found for ${
baseSha === null ? 'the merge-base' : baseSha
}`,
};
}
return renderTable(validated.results);
}
function renderNotice(context, reportHead) {
const {action, prHeadSha, runHeadSha, runStatus, runUrl} = context;
const lines = [];
if (reportHead !== null && reportHead !== prHeadSha) {
lines.push(
`These sizes are for ${reportHead}, which is no longer the head of this pull request.`
);
if (action === 'requested') {
lines.push(`A build for ${runHeadSha} is in progress.`);
}
} else if (action === 'requested' && runStatus === 'waiting') {
lines.push(
`[The build for this commit](${runUrl}) is waiting for maintainer approval before it can run.`
);
}
if (lines.length === 0) {
return '';
}
return lines.map(line => `> ${line}`).join('\n> \n');
}
function renderBody(context) {
let reportHead;
let report;
let missingCriticalPaths = [];
let problem;
if (context.action === 'requested') {
if (
context.existingReportHead !== null &&
context.existingReport !== null
) {
reportHead = context.existingReportHead;
report = context.existingReport;
} else {
reportHead = null;
report = `A size report will appear here when [the build](${context.runUrl}) finishes.`;
}
} else {
reportHead = context.runHeadSha;
const rendered = renderCompletedReport(context);
report = rendered.markdown;
missingCriticalPaths = rendered.missingCriticalPaths;
problem = rendered.problem;
}
if (missingCriticalPaths.length > 0) {
report =
'> [!CAUTION]\n' +
'> These critical bundles are missing from the build. If that was an intentional\n' +
'> change to the build configuration, update `CRITICAL_ARTIFACT_PATHS` in\n' +
'> `scripts/sizebot/render-comment.js`:\n' +
missingCriticalPaths.map(p => `> - \`${p}\``).join('\n') +
'\n\n' +
report;
}
const notice = renderNotice(context, reportHead);
const footerSha = reportHead === null ? context.runHeadSha : reportHead;
function assemble(reportRegion) {
return `${MARKER_PREFIX} report-head=${
reportHead === null ? 'none' : reportHead
} -->
${NOTICE_START}
${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END}
${REPORT_START}
${reportRegion}
${REPORT_END}
<sub>Generated by sizebot against ${footerSha}</sub>
`;
}
return {
body: assemble(report),
report,
assemble,
reportHead,
missingCriticalPaths,
problem,
};
}
function extractReport(body) {
const start = body.indexOf(REPORT_START);
const end = body.indexOf(REPORT_END);
if (start === -1 || end === -1 || end < start) {
return null;
}
const report = body.slice(start + REPORT_START.length, end).trim();
return report === '' ? null : report;
}
function parseReportHead(body) {
const match =
/<!-- sizebot-comment report-head=([0-9a-f]{7,40}|none) -->/.exec(body);
if (match === null || match[1] === 'none') {
return null;
}
return match[1];
}
function main() {
const context = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8'));
const {body, report, assemble, missingCriticalPaths, problem} =
renderBody(context);
let comment = body;
if (body.length > MAX_COMMENT_LENGTH) {
writeFileSync(MESSAGE_PATH, report + '\n');
comment = assemble(
`The size diff is too large to display in a single comment. [This workflow run](${context.commentRunUrl}) contains an artifact called \`sizebot-message.md\` with the full report.`
);
}
writeFileSync(COMMENT_PATH, comment);
if (missingCriticalPaths.length > 0) {
writeFileSync(
PROBLEM_PATH,
`Missing expected bundles:\n${missingCriticalPaths.join('\n')}\n`
);
}
if (problem !== undefined) {
writeFileSync(PROBLEM_PATH, problem + '\n');
}
process.stdout.write(comment);
}
module.exports = {
MARKER_PREFIX,
extractReport,
parseReportHead,
renderBody,
};
if (require.main === module) {
main();
}