feat(dev): フロントエンドのバンドルサイズ比較のAction (#17586)

Create frontend-js-size.yml
This commit is contained in:
syuilo 2026-06-20 11:00:58 +09:00 committed by GitHub
commit 0956da49e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

372
.github/workflows/frontend-js-size.yml vendored Normal file
View file

@ -0,0 +1,372 @@
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
permissions:
contents: read
issues: write
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 `${(size / 1024).toFixed(1)} KiB`;
return `${(size / 1024 / 1024).toFixed(2)} MiB`;
}
function formatDiff(diff) {
if (diff == null) return '-';
if (diff === 0) return '0 B';
const sign = diff > 0 ? '+' : '-';
return `${sign}${formatBytes(Math.abs(diff))}`;
}
function escapeCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function entryDisplayName(entry) {
if (!entry) return '';
return entry.displayName === entry.file
? entry.displayName
: `${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) {
const originalPath = path.join(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}`,
};
}
}
return {
absolutePath: originalPath,
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);
}
for await (const fullPath of walk(outDir)) {
if (!fullPath.endsWith('.js')) continue;
const relativePath = normalizePath(path.relative(outDir, fullPath));
if (byFile.has(relativePath)) continue;
if (relativePath.startsWith('scripts/') || relativePath.startsWith(`${locale}/`)) 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 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,
file: entryDisplayName(afterEntry ?? beforeEntry),
beforeSize,
afterSize,
diff: beforeSize == null || afterSize == null ? null : afterSize - beforeSize,
sortSize: Math.max(beforeSize ?? 0, afterSize ?? 0),
};
});
}
function markdownTable(rows) {
if (rows.length === 0) {
return '_No JavaScript chunks found._';
}
const lines = [
'| File | Size (before) | Size (after) | Size (diff) |',
'| --- | ---: | ---: | ---: |',
];
for (const row of rows) {
lines.push(`| ${escapeCell(row.file)} | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.diff)} |`);
}
return lines.join('\n');
}
function unionTopKeys(before, after) {
const allKeys = new Set([
...Object.keys(before.chunks),
...Object.keys(after.chunks),
]);
return compareRows([...allKeys], before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file))
.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 topRows = compareRows(unionTopKeys(before, after), before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file));
const startupKeys = new Set([
...before.startupKeys,
...after.startupKeys,
]);
const startupRows = compareRows([...startupKeys], before, after)
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file));
const body = [
marker,
'## Frontend JavaScript size',
'',
`Compared locale: \`${locale}\``,
`Before: \`${beforeSha}\``,
`After: \`${afterSha}\``,
'',
'### Top 10 largest JS chunks',
'',
markdownTable(topRows),
'',
'### Startup JS chunks',
'',
markdownTable(startupRows),
'',
'_Top 10 is sorted by max(before, after) size. Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._',
'',
].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 }}
run: |
node .github/tmp/frontend-js-size-report.mjs before after frontend-js-size-report.md
cat frontend-js-size-report.md >> "$GITHUB_STEP_SUMMARY"
- name: Comment on pull request
uses: actions/github-script@v9
continue-on-error: true
with:
script: |
const fs = require('node:fs');
const marker = '<!-- misskey-frontend-js-size -->';
const body = fs.readFileSync('frontend-js-size-report.md', 'utf8');
const issue_number = context.payload.pull_request.number;
const { owner, repo } = context.repo;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const previous = comments.find((comment) =>
comment.user?.type === 'Bot' && comment.body?.includes(marker));
if (previous) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: previous.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}