mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
455 lines
16 KiB
YAML
455 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';
|
|
|
|
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 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' : 'green';
|
|
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
|
|
}
|
|
|
|
function formatDiffPercent(beforeSize, afterSize) {
|
|
if (beforeSize == null || beforeSize === 0 || afterSize == null || afterSize === 0) return '-';
|
|
const diff = afterSize - beforeSize;
|
|
if (diff === 0) return `0%`;
|
|
const percent = Math.round(diff / beforeSize * 100);
|
|
const color = diff > 0 ? 'orange' : 'green';
|
|
return `$\\color{${color}}{\\text{${formatMathText(percent.toString() + '%')}}}$`;
|
|
}
|
|
|
|
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 getChunkComparisonRows(keys, before, after) {
|
|
return keys.map((key) => {
|
|
const beforeEntry = before.chunks[key];
|
|
const afterEntry = after.chunks[key];
|
|
const beforeSize = beforeEntry?.size ?? 0;
|
|
const afterSize = afterEntry?.size ?? 0;
|
|
return {
|
|
key,
|
|
name: entryDisplayName(beforeEntry ?? afterEntry),
|
|
chunkFile: beforeEntry?.file ?? afterEntry?.file,
|
|
beforeSize,
|
|
afterSize,
|
|
sortSize: Math.max(beforeSize, afterSize),
|
|
};
|
|
});
|
|
}
|
|
|
|
function markdownTable(rows, total) {
|
|
if (rows.length === 0) return '_No data_';
|
|
|
|
const lines = [
|
|
'| Chunk | Before | After | Diff | Diff (%) |',
|
|
'| --- | ---: | ---: | ---: | ---: |',
|
|
];
|
|
if (total != null) {
|
|
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${formatDiff(total.afterSize - total.beforeSize)} | ${formatDiffPercent(total.beforeSize, total.afterSize)} |`);
|
|
lines.push('| | | | | |');
|
|
}
|
|
for (const row of rows) {
|
|
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.afterSize - row.beforeSize)} | ${formatDiffPercent(row.beforeSize, row.afterSize)} |`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function chunkRows(keys, report) {
|
|
return keys.map((key) => {
|
|
const entry = report.chunks[key];
|
|
return {
|
|
key,
|
|
name: entryDisplayName(entry),
|
|
chunkFile: entry.file,
|
|
size: entry.size,
|
|
};
|
|
});
|
|
}
|
|
|
|
function markdownChunkTable(rows) {
|
|
if (rows.length === 0) return '_No data_';
|
|
|
|
const lines = [
|
|
'| Chunk | Size |',
|
|
'| --- | ---: |',
|
|
];
|
|
for (const row of rows) {
|
|
lines.push(`| <details><summary>\`${escapeCell(row.name)}\`</summary> \`${escapeCell(row.chunkFile)}\` </details> | ${formatBytes(row.size)} |`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
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 comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after);
|
|
|
|
const diffRows = comparisonRows
|
|
.filter((row) => row.beforeSize !== row.afterSize)
|
|
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
|
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
|
|| b.sortSize - a.sortSize
|
|
|| a.name.localeCompare(b.name))
|
|
.slice(0, 30);
|
|
|
|
const diffTotal = {
|
|
beforeSize: comparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
|
|
afterSize: comparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
|
|
};
|
|
|
|
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 startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after);
|
|
const startupRows = startupComparisonRows
|
|
.sort((a, b) => Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
|
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
|
|| b.sortSize - a.sortSize
|
|
|| a.name.localeCompare(b.name));
|
|
const startupTotal = {
|
|
beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0),
|
|
afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0),
|
|
};
|
|
|
|
const largeRows = comparisonRows
|
|
.sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name))
|
|
.slice(0, 30);
|
|
|
|
const body = [
|
|
marker,
|
|
`## Frontend chunk size report (${locale})`,
|
|
'',
|
|
'<details open>',
|
|
`<summary>Diffs</summary>`,
|
|
'',
|
|
markdownTable(diffRows, diffTotal),
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'<details>',
|
|
`<summary>Added (${addedRows.length})</summary>`,
|
|
'',
|
|
markdownChunkTable(addedRows),
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'<details>',
|
|
`<summary>Removed (${removedRows.length})</summary>`,
|
|
'',
|
|
markdownChunkTable(removedRows),
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'<details>',
|
|
`<summary>Startup</summary>`,
|
|
'',
|
|
markdownTable(startupRows, startupTotal),
|
|
'',
|
|
`_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>',
|
|
'',
|
|
'<details>',
|
|
`<summary>Largest</summary>`,
|
|
'',
|
|
markdownTable(largeRows),
|
|
'',
|
|
'</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
|