forked from mirrors/misskey
380 lines
13 KiB
YAML
380 lines
13 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 `${(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 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
|
|
? 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: 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 = [
|
|
'| File | Before | After | 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);
|
|
}
|
|
|
|
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.file.localeCompare(b.file);
|
|
}
|
|
|
|
function topDiffKeys(before, after) {
|
|
const allKeys = new Set([
|
|
...Object.keys(before.chunks),
|
|
...Object.keys(after.chunks),
|
|
]);
|
|
return compareRows([...allKeys], 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 topRows = compareRows(unionTopKeys(before, after), before, after)
|
|
.sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file));
|
|
|
|
const diffRows = compareRows(topDiffKeys(before, after), before, after)
|
|
.sort(compareDiffRows);
|
|
|
|
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 size report',
|
|
'',
|
|
'### Top 10 largest chunk diffs',
|
|
'',
|
|
markdownTable(diffRows, '_No JavaScript chunk size changes found._'),
|
|
'',
|
|
'### Top 10 largest chunks',
|
|
'<details>',
|
|
'',
|
|
markdownTable(topRows),
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'### Startup chunks',
|
|
'<details>',
|
|
'',
|
|
markdownTable(startupRows),
|
|
'',
|
|
'_Top 10 is sorted by max(before, after) size. Diff top 10 is sorted by absolute size diff, with missing chunks compared against 0 B. 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
|