forked from mirrors/misskey
467 lines
16 KiB
YAML
467 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}${Math.round(Math.abs(diff) / beforeSize * 100)}%`;
|
|
}
|
|
|
|
function formatMathText(text) {
|
|
return text
|
|
.replaceAll('\\', '\\\\')
|
|
.replaceAll('{', '\\{')
|
|
.replaceAll('}', '\\}')
|
|
.replaceAll('%', '\\\\%');
|
|
}
|
|
|
|
function formatDiff(diff) {
|
|
if (diff == null) return '-';
|
|
if (diff === 0) return '0 B';
|
|
const sign = diff > 0 ? '+' : '-';
|
|
const text = `${sign}${formatBytes(Math.abs(diff))}`;
|
|
const color = diff > 0 ? 'orange' : 'cyan';
|
|
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
|
|
}
|
|
|
|
function formatDiffPercent(beforeSize, diff) {
|
|
if (diff == null) return '-';
|
|
const percent = formatPercent(diff, beforeSize);
|
|
if (diff === 0) return `${percent}`;
|
|
const sign = diff > 0 ? '+' : '-';
|
|
const text = `${sign}${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 | Diff (%) |',
|
|
'| --- | ---: | ---: | ---: | ---: |',
|
|
];
|
|
for (const row of rows) {
|
|
lines.push(`| \`${escapeCell(row.name)}\` | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.diff)} | ${formatDiffPercent(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
|