misskey/.github/workflows/frontend-js-size.yml
2026-06-20 14:55:54 +09:00

458 lines
16 KiB
YAML

name: Frontend JS size
on:
pull_request:
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/workflows/frontend-js-size.yml
- .github/workflows/frontend-js-size-comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-js-size-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
measure:
name: Measure frontend JS size
runs-on: ubuntu-latest
env:
FRONTEND_JS_SIZE_LOCALE: ja-JP
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
after/pnpm-lock.yaml
- name: Install dependencies for base
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend for base
working-directory: before
run: |
pnpm --filter "frontend^..." run build
pnpm --filter frontend run build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend for pull request
working-directory: after
run: |
pnpm --filter "frontend^..." run build
pnpm --filter frontend run build
- name: Write report script
shell: bash
run: |
mkdir -p .github/tmp
cat > .github/tmp/frontend-js-size-report.mjs <<'NODE'
import { promises as fs } from 'node:fs';
import path from 'node:path';
const marker = '<!-- misskey-frontend-js-size -->';
const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP';
const topLimit = 10;
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function fileSize(filePath) {
const stat = await fs.stat(filePath);
return stat.size;
}
async function* walk(dir) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(fullPath);
} else if (entry.isFile()) {
yield fullPath;
}
}
}
function formatBytes(size) {
if (size == null) return '-';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${stripTrailingZeros((size / 1024).toFixed(1))} KiB`;
return `${stripTrailingZeros((size / 1024 / 1024).toFixed(2))} MiB`;
}
function stripTrailingZeros(value) {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
}
function formatPercent(diff, beforeSize) {
if (diff == null || beforeSize == null) return null;
if (diff === 0) return '0%';
if (beforeSize === 0) return null;
const sign = diff > 0 ? '+' : '-';
return `${sign}${stripTrailingZeros((Math.abs(diff) / beforeSize * 100).toFixed(1))}%`;
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\\\%');
}
function formatDiff(beforeSize, diff) {
if (diff == null) return '-';
const percent = formatPercent(diff, beforeSize);
if (diff === 0) return percent == null ? '0 B' : `0 B (${percent})`;
const sign = diff > 0 ? '+' : '-';
const text = `${sign}${formatBytes(Math.abs(diff))}${percent == null ? '' : ` (${percent})`}`;
const color = diff > 0 ? 'orange' : 'cyan';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function sizeDiff(beforeSize, afterSize) {
if (beforeSize == null && afterSize == null) return null;
return (afterSize ?? 0) - (beforeSize ?? 0);
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName || entry.file;
}
function findEntryKey(manifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
?? null;
}
function stableChunkKey(manifestKey, chunk) {
return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey);
}
function collectStartupKeys(manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set();
if (entryKey == null) return keys;
function visit(key) {
if (keys.has(key)) return;
const chunk = manifest[key];
if (!chunk || !chunk.file?.endsWith('.js')) return;
keys.add(stableChunkKey(key, chunk));
for (const importKey of chunk.imports ?? []) {
visit(importKey);
}
}
visit(entryKey);
return keys;
}
async function resolveBuiltFile(outDir, file) {
if (file.startsWith('scripts/')) {
const localizedFile = file.slice('scripts/'.length);
const localizedPath = path.join(outDir, locale, localizedFile);
if (await exists(localizedPath)) {
return {
absolutePath: localizedPath,
relativePath: `${locale}/${localizedFile}`,
};
}
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
}
return {
absolutePath: path.join(outDir, file),
relativePath: file,
};
}
async function collectReport(repoDir) {
const outDir = path.join(repoDir, 'built/_frontend_vite_');
const manifestPath = path.join(outDir, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
const byKey = new Map();
const byFile = new Set();
for (const [key, chunk] of Object.entries(manifest)) {
if (!chunk.file?.endsWith('.js')) continue;
const builtFile = await resolveBuiltFile(outDir, chunk.file);
const size = await fileSize(builtFile.absolutePath);
const stableKey = stableChunkKey(key, chunk);
const displayName = chunk.src ?? chunk.name ?? key;
byKey.set(stableKey, {
key: stableKey,
displayName,
file: builtFile.relativePath,
size,
});
byFile.add(builtFile.relativePath);
}
const localeDir = path.join(outDir, locale);
if (await exists(localeDir)) {
for await (const fullPath of walk(localeDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
const size = await fileSize(fullPath);
byKey.set(relativePath, {
key: relativePath,
displayName: relativePath,
file: relativePath,
size,
});
}
}
return {
manifest,
chunks: Object.fromEntries(byKey),
startupKeys: [...collectStartupKeys(manifest)],
};
}
function commonKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] != null);
}
function addedKeys(before, after) {
return Object.keys(after.chunks)
.filter((key) => before.chunks[key] == null);
}
function removedKeys(before, after) {
return Object.keys(before.chunks)
.filter((key) => after.chunks[key] == null);
}
function compareRows(keys, before, after) {
return keys.map((key) => {
const beforeEntry = before.chunks[key];
const afterEntry = after.chunks[key];
const beforeSize = beforeEntry?.size ?? null;
const afterSize = afterEntry?.size ?? null;
return {
key,
name: entryDisplayName(afterEntry ?? beforeEntry),
beforeSize,
afterSize,
diff: sizeDiff(beforeSize, afterSize),
sortSize: Math.max(beforeSize ?? 0, afterSize ?? 0),
};
});
}
function markdownTable(rows, emptyMessage = '_No JavaScript chunks found._') {
if (rows.length === 0) {
return emptyMessage;
}
const lines = [
'| Chunk | Before | After | Diff |',
'| --- | ---: | ---: | ---: |',
];
for (const row of rows) {
lines.push(`| \`${escapeCell(row.name)}\` | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.beforeSize, row.diff)} |`);
}
return lines.join('\n');
}
function chunkRows(keys, report) {
return keys.map((key) => {
const entry = report.chunks[key];
return {
key,
name: entryDisplayName(entry),
size: entry.size,
};
});
}
function markdownChunkTable(rows, emptyMessage = '_No JavaScript chunks found._') {
if (rows.length === 0) {
return emptyMessage;
}
const lines = [
'| Chunk | Size |',
'| --- | ---: |',
];
for (const row of rows) {
lines.push(`| \`${escapeCell(row.name)}\` | ${formatBytes(row.size)} |`);
}
return lines.join('\n');
}
function topKeys(keys, before, after) {
return compareRows(keys, before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
.slice(0, topLimit)
.map((row) => row.key);
}
function compareDiffRows(a, b) {
return Math.abs(b.diff ?? 0) - Math.abs(a.diff ?? 0)
|| (b.diff ?? 0) - (a.diff ?? 0)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
function topDiffKeys(keys, before, after) {
return compareRows(keys, before, after)
.filter((row) => row.diff !== 0 && row.diff != null)
.sort(compareDiffRows)
.slice(0, topLimit)
.map((row) => row.key);
}
const beforeDir = process.argv[2];
const afterDir = process.argv[3];
const outFile = process.argv[4];
const beforeSha = process.env.BASE_SHA;
const afterSha = process.env.HEAD_SHA;
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const commonChunkKeys = commonKeys(before, after);
const topRows = compareRows(topKeys(commonChunkKeys, before, after), before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name));
const diffRows = compareRows(topDiffKeys(commonChunkKeys, before, after), before, after)
.sort(compareDiffRows);
const addedRows = chunkRows(addedKeys(before, after), after)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const removedRows = chunkRows(removedKeys(before, after), before)
.sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupRows = compareRows([...startupKeys].filter((key) => before.chunks[key] != null && after.chunks[key] != null), before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name));
const body = [
marker,
`## Frontend size report (${locale})`,
'',
'### Top 10 largest chunk diffs',
'',
markdownTable(diffRows, '_No chunk size changes found._'),
'',
'### Top 10 largest chunks',
'<details>',
'',
markdownTable(topRows),
'',
'</details>',
'',
`### Added chunks (${addedRows.length})`,
'<details>',
'',
markdownChunkTable(addedRows, '_No chunks added._'),
'',
'</details>',
'',
`### Removed chunks (${removedRows.length})`,
'<details>',
'',
markdownChunkTable(removedRows, '_No chunks removed._'),
'',
'</details>',
'',
'### Startup chunks',
'<details>',
'',
markdownTable(startupRows),
'',
`_Only ${locale} localized chunks are reported. Size comparison tables include chunks that exist in both builds. Added and removed chunks are listed separately. Top 10 is sorted by max(before, after) size. Diff top 10 is sorted by absolute size diff. Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
'',
'</details>',
'',
].join('\n');
await fs.writeFile(outFile, body);
NODE
- name: Generate size report
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
mkdir -p frontend-js-size-report
node .github/tmp/frontend-js-size-report.mjs before after frontend-js-size-report.md
mv frontend-js-size-report.md frontend-js-size-report/report.md
printf '%s\n' "$PR_NUMBER" > frontend-js-size-report/pr-number.txt
cat frontend-js-size-report/report.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload size report
uses: actions/upload-artifact@v7
with:
name: frontend-js-size-report
path: frontend-js-size-report/
if-no-files-found: error
retention-days: 1