mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
enhance(dev): improve frontend bundle report (#17600)
* wip * Update package.json * wip * Update pnpm-lock.yaml * wip * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * Update frontend-bundle-visualizer-report.mjs * refactor * Update frontend-js-size.yml * refactor * Update package.json
This commit is contained in:
parent
1eac4ccf51
commit
266a3c473b
9 changed files with 1529 additions and 739 deletions
276
.github/scripts/frontend-bundle-visualizer-report.mjs
vendored
Normal file
276
.github/scripts/frontend-bundle-visualizer-report.mjs
vendored
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const [beforeFile, afterFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
if (beforeFile == null || afterFile == null || outputFile == null) {
|
||||
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <before-stats.json> <after-stats.json> <report.md>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const byteFormatter = new Intl.NumberFormat('en-US');
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
function sharePercent(value, total) {
|
||||
if (total === 0) return '0%';
|
||||
return formatPercent((value / total) * 100);
|
||||
}
|
||||
|
||||
function formatMathText(text) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
function formatColoredDiff(text, diff) {
|
||||
const color = diff > 0 ? 'orange' : 'green';
|
||||
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
|
||||
}
|
||||
|
||||
function formatDiff(before, after, formatter) {
|
||||
const diff = after - before;
|
||||
if (diff === 0) return formatter(0);
|
||||
|
||||
const sign = diff > 0 ? '+' : '-';
|
||||
return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff);
|
||||
}
|
||||
|
||||
function formatDiffPercent(before, after) {
|
||||
if (before === 0 && after === 0) return '0%';
|
||||
if (before === 0) return '-';
|
||||
|
||||
const diff = after - before;
|
||||
if (diff === 0) return '0%';
|
||||
|
||||
const sign = diff > 0 ? '+' : '-';
|
||||
return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff);
|
||||
}
|
||||
|
||||
function tableCell(value) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
}
|
||||
|
||||
function code(value) {
|
||||
const sanitized = String(value).replaceAll('\r', ' ').replaceAll('\n', ' ');
|
||||
const backtickRuns = sanitized.match(/`+/g) ?? [];
|
||||
const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1));
|
||||
const fence = '`'.repeat(fenceLength);
|
||||
const padding = sanitized.startsWith('`') || sanitized.endsWith('`') ? ' ' : '';
|
||||
|
||||
return `${fence}${padding}${sanitized}${padding}${fence}`;
|
||||
}
|
||||
|
||||
function tableCode(value) {
|
||||
return tableCell(code(value));
|
||||
}
|
||||
|
||||
function collectReport(data) {
|
||||
const nodeParts = data.nodeParts ?? {};
|
||||
const nodeMetas = Object.values(data.nodeMetas ?? {});
|
||||
const moduleRows = [];
|
||||
const bundleMap = new Map();
|
||||
|
||||
for (const meta of nodeMetas) {
|
||||
const row = {
|
||||
id: meta.id,
|
||||
bundles: 0,
|
||||
renderedLength: 0,
|
||||
gzipLength: 0,
|
||||
brotliLength: 0,
|
||||
importedByCount: meta.importedBy?.length ?? 0,
|
||||
importedCount: meta.imported?.length ?? 0,
|
||||
};
|
||||
|
||||
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
|
||||
const part = nodeParts[partUid];
|
||||
if (part == null) continue;
|
||||
|
||||
row.bundles += 1;
|
||||
row.renderedLength += part.renderedLength;
|
||||
row.gzipLength += part.gzipLength;
|
||||
row.brotliLength += part.brotliLength;
|
||||
|
||||
const bundle = bundleMap.get(bundleId) ?? {
|
||||
id: bundleId,
|
||||
modules: 0,
|
||||
renderedLength: 0,
|
||||
gzipLength: 0,
|
||||
brotliLength: 0,
|
||||
};
|
||||
bundle.modules += 1;
|
||||
bundle.renderedLength += part.renderedLength;
|
||||
bundle.gzipLength += part.gzipLength;
|
||||
bundle.brotliLength += part.brotliLength;
|
||||
bundleMap.set(bundleId, bundle);
|
||||
}
|
||||
|
||||
if (row.bundles > 0) {
|
||||
moduleRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
let staticImports = 0;
|
||||
let dynamicImports = 0;
|
||||
for (const meta of nodeMetas) {
|
||||
for (const imported of meta.imported ?? []) {
|
||||
if (imported.dynamic) {
|
||||
dynamicImports += 1;
|
||||
} else {
|
||||
staticImports += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
|
||||
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
|
||||
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
|
||||
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
|
||||
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
|
||||
|
||||
return {
|
||||
options: data.options ?? {},
|
||||
summary: {
|
||||
bundles: bundleRows.length,
|
||||
modules: moduleRows.length,
|
||||
entries: nodeMetas.filter((meta) => meta.isEntry).length,
|
||||
externals: nodeMetas.filter((meta) => meta.isExternal).length,
|
||||
staticImports,
|
||||
dynamicImports,
|
||||
},
|
||||
metrics: {
|
||||
renderedLength: totalRendered,
|
||||
gzipLength: totalGzip,
|
||||
brotliLength: totalBrotli,
|
||||
},
|
||||
hotModules,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSummaryTable(before, after) {
|
||||
const summary = [
|
||||
'bundles',
|
||||
'modules',
|
||||
'entries',
|
||||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
];
|
||||
|
||||
return [
|
||||
`<table>`,
|
||||
`<thead>`,
|
||||
`<tr>`,
|
||||
`<th rowspan="2"></th>`,
|
||||
`<th rowspan="2">Bundles</th>`,
|
||||
`<th rowspan="2">Modules</th>`,
|
||||
`<th rowspan="2">Entries</th>`,
|
||||
`<th colspan="2">Imports</th>`,
|
||||
`<th colspan="3">Size</th>`,
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th>Static</th>`,
|
||||
`<th>Dynamic</th>`,
|
||||
`<th>Rendered</th>`,
|
||||
`<th>Gzip</th>`,
|
||||
`<th>Brotli</th>`,
|
||||
`</tr>`,
|
||||
`</thead>`,
|
||||
`<tbody>`,
|
||||
`<tr>`,
|
||||
`<th><b>Before</b></th>`,
|
||||
...summary.map((key) => `<td>${formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>After</b></th>`,
|
||||
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${formatDiff(before.summary[key], after.summary[key], formatNumber)}</td>`),
|
||||
...metrics.map((key) => `<td>${formatDiff(before.metrics[key], after.metrics[key], formatBytes)}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${formatDiffPercent(before.summary[key], after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatDiffPercent(before.metrics[key], after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
];
|
||||
}
|
||||
|
||||
const beforeData = JSON.parse(await readFile(beforeFile, 'utf8'));
|
||||
const afterData = JSON.parse(await readFile(afterFile, 'utf8'));
|
||||
const before = collectReport(beforeData);
|
||||
const after = collectReport(afterData);
|
||||
const lines = [
|
||||
'## Frontend Bundle Report',
|
||||
'',
|
||||
...renderSummaryTable(before, after),
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Top 10</summary>',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const row of after.hotModules.slice(0, 10)) {
|
||||
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'</details>',
|
||||
);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Hot Modules (Self Size)</summary>',
|
||||
'',
|
||||
'| Module | Bundles | Rendered | Share | Gzip | Brotli | Imports | Imported By |',
|
||||
'|---|---:|---:|---:|---:|---:|---:|---:|',
|
||||
);
|
||||
|
||||
for (const row of after.hotModules.slice(0, 15)) {
|
||||
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'</details>',
|
||||
);
|
||||
|
||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||
344
.github/scripts/frontend-js-size.mjs
vendored
Normal file
344
.github/scripts/frontend-js-size.mjs
vendored
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
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 | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
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 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),
|
||||
'',
|
||||
`_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);
|
||||
287
.github/workflows/frontend-bundle-report-comment.yml
vendored
Normal file
287
.github/workflows/frontend-bundle-report-comment.yml
vendored
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
name: frontend-bundle-report-comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- frontend-bundle-report
|
||||
types:
|
||||
- completed
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
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/scripts/frontend-js-size.mjs
|
||||
- .github/scripts/frontend-bundle-visualizer-report.mjs
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend bundle report
|
||||
if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-bundle-report-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Find bundle report run
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: find-report-run
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const workflow_id = 'frontend-bundle-report.yml';
|
||||
const artifactName = 'frontend-bundle-report';
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pollIntervalMs = 30_000;
|
||||
const timeoutMs = 90 * 60_000;
|
||||
const startedAt = Date.now();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function listReportWorkflowRuns() {
|
||||
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
head_sha: headSha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (runsForHead.length > 0) {
|
||||
return runsForHead;
|
||||
}
|
||||
|
||||
const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
per_page: 100,
|
||||
});
|
||||
return recentRuns.filter((run) =>
|
||||
run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber));
|
||||
}
|
||||
|
||||
async function findReportRun() {
|
||||
const runs = (await listReportWorkflowRuns())
|
||||
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') continue;
|
||||
if (run.conclusion !== 'success') {
|
||||
core.warning(`Frontend bundle report run ${run.id} completed with conclusion: ${run.conclusion}`);
|
||||
return { done: true, run: null };
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
||||
if (report) return { done: true, run };
|
||||
}
|
||||
|
||||
return { done: false, run: null };
|
||||
}
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const { done, run } = await findReportRun();
|
||||
if (run) {
|
||||
core.info(`Found frontend bundle report on workflow run ${run.id}.`);
|
||||
core.setOutput('run-id', String(run.id));
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('Waiting for frontend bundle report artifact...');
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
|
||||
|
||||
- name: Download bundle report from workflow_run
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Download bundle report from pull_request_target
|
||||
if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ steps.find-report-run.outputs.run-id }}
|
||||
|
||||
- name: Comment on pull request
|
||||
if: github.event_name == 'workflow_run' || steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.FRONTEND_BUNDLE_REPORT_COMMENT_TOKEN || secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || secrets.FRONTEND_BUNDLE_VISUALIZER_COMMENT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const jsSizeMarker = '<!-- misskey-frontend-js-size -->';
|
||||
const visualizerMarker = '<!-- misskey-frontend-bundle-visualizer -->';
|
||||
const reportMarkers = [jsSizeMarker, visualizerMarker];
|
||||
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
|
||||
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
|
||||
const visualizerReportPath = path.join(reportDir, 'frontend-bundle-visualizer-report.md');
|
||||
const prNumberPath = path.join(reportDir, 'pr-number.txt');
|
||||
const headShaPath = path.join(reportDir, 'head-sha.txt');
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const headSha = workflowRun?.head_sha ?? pullRequest?.head?.sha ?? null;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
if (!fs.existsSync(jsSizeReportPath)) {
|
||||
core.setFailed('The frontend bundle report artifact does not contain frontend-js-size-report.md.');
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(visualizerReportPath)) {
|
||||
core.setFailed('The frontend bundle report artifact does not contain frontend-bundle-visualizer-report.md.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactHeadSha = fs.existsSync(headShaPath)
|
||||
? fs.readFileSync(headShaPath, 'utf8').trim()
|
||||
: null;
|
||||
if (headSha != null && artifactHeadSha != null && artifactHeadSha !== headSha) {
|
||||
core.setFailed(`The artifact head SHA (${artifactHeadSha}) does not match the workflow head SHA (${headSha}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactPrNumber = fs.existsSync(prNumberPath)
|
||||
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
|
||||
: null;
|
||||
let issue_number = null;
|
||||
if (pullRequest != null) {
|
||||
issue_number = pullRequest.number;
|
||||
if (Number.isInteger(artifactPrNumber) && artifactPrNumber !== issue_number) {
|
||||
core.setFailed(`The artifact pull request number (${artifactPrNumber}) does not match the event pull request number (${issue_number}).`);
|
||||
return;
|
||||
}
|
||||
} else if (workflowRun != null) {
|
||||
const associatedPullRequests = new Map();
|
||||
for (const pullRequest of workflowRun.pull_requests ?? []) {
|
||||
if (Number.isInteger(pullRequest.number)) {
|
||||
associatedPullRequests.set(pullRequest.number, pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
const pullRequestsForCommit = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: headSha,
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pullRequest of pullRequestsForCommit) {
|
||||
associatedPullRequests.set(pullRequest.number, pullRequest);
|
||||
}
|
||||
|
||||
if (Number.isInteger(artifactPrNumber) && associatedPullRequests.has(artifactPrNumber)) {
|
||||
issue_number = artifactPrNumber;
|
||||
} else if (!Number.isInteger(artifactPrNumber) && associatedPullRequests.size === 1) {
|
||||
issue_number = [...associatedPullRequests.keys()][0];
|
||||
} else if (Number.isInteger(artifactPrNumber)) {
|
||||
core.setFailed(`The artifact pull request number (${artifactPrNumber}) is not associated with ${headSha}.`);
|
||||
return;
|
||||
} else {
|
||||
core.setFailed(`Could not determine the pull request associated with ${headSha}.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
core.setFailed('Could not determine the pull request event for this report.');
|
||||
return;
|
||||
}
|
||||
|
||||
const jsSizeReport = fs.readFileSync(jsSizeReportPath, 'utf8').trim();
|
||||
if (!jsSizeReport.includes(jsSizeMarker)) {
|
||||
core.setFailed('The frontend JS size report is missing the expected marker.');
|
||||
return;
|
||||
}
|
||||
const visualizerReport = fs.readFileSync(visualizerReportPath, 'utf8').trim();
|
||||
let body = [
|
||||
jsSizeReport,
|
||||
visualizerReport,
|
||||
].join('\n\n') + '\n';
|
||||
|
||||
const maxCommentLength = 65_000;
|
||||
if (body.length > maxCommentLength) {
|
||||
const reportLocation = workflowRun?.html_url != null
|
||||
? `[workflow run](${workflowRun.html_url})`
|
||||
: 'workflow artifact';
|
||||
const footer = [
|
||||
'',
|
||||
'',
|
||||
`_Report truncated because it exceeded ${maxCommentLength.toLocaleString('en-US')} characters. See the ${reportLocation} for the full report._`,
|
||||
].join('\n');
|
||||
body = `${body.slice(0, maxCommentLength - footer.length)}${footer}`;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const previousReports = comments.filter((comment) =>
|
||||
comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker)));
|
||||
|
||||
if (previousReports.length > 0) {
|
||||
const [previous, ...duplicates] = previousReports;
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: previous.id,
|
||||
body,
|
||||
});
|
||||
for (const duplicate of duplicates) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: duplicate.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
149
.github/workflows/frontend-bundle-report.yml
vendored
Normal file
149
.github/workflows/frontend-bundle-report.yml
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
name: frontend-bundle-report
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
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/scripts/frontend-js-size.mjs
|
||||
- .github/scripts/frontend-bundle-visualizer-report.mjs
|
||||
- .github/workflows/frontend-bundle-report.yml
|
||||
- .github/workflows/frontend-bundle-report-comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-bundle-report-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Build frontend bundle report
|
||||
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: Backport visualizer tooling to base if needed
|
||||
shell: bash
|
||||
run: |
|
||||
if ! grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
|
||||
cp after/packages/frontend/package.json before/packages/frontend/package.json
|
||||
cp after/packages/frontend/vite.config.ts before/packages/frontend/vite.config.ts
|
||||
cp after/pnpm-lock.yaml before/pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
- 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 dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Prepare report output
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
|
||||
|
||||
- name: Build frontend report for base
|
||||
working-directory: before
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Build frontend report for pull request
|
||||
working-directory: after
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Generate report markdown
|
||||
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: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
node after/.github/scripts/frontend-js-size.mjs before after "$REPORT_DIR/frontend-js-size-report.md"
|
||||
node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-visualizer-report.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check report
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
test -s "$REPORT_DIR/before-stats.json"
|
||||
test -s "$REPORT_DIR/after-stats.json"
|
||||
test -s "$REPORT_DIR/frontend-js-size-report.md"
|
||||
test -s "$REPORT_DIR/frontend-bundle-visualizer-report.md"
|
||||
cat "$REPORT_DIR/frontend-js-size-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
printf '\n\n' >> "$GITHUB_STEP_SUMMARY"
|
||||
cat "$REPORT_DIR/frontend-bundle-visualizer-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload bundle report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
206
.github/workflows/frontend-js-size-comment.yml
vendored
206
.github/workflows/frontend-js-size-comment.yml
vendored
|
|
@ -1,206 +0,0 @@
|
|||
name: Frontend JS size comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend JS size
|
||||
types:
|
||||
- completed
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
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:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend JS size
|
||||
if: github.event_name == 'pull_request_target' || (github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-js-size-comment-${{ github.event.pull_request.number || github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Find size report run
|
||||
if: github.event_name == 'pull_request_target'
|
||||
id: find-report-run
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const workflow_id = 'frontend-js-size.yml';
|
||||
const artifactName = 'frontend-js-size-report';
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pollIntervalMs = 30_000;
|
||||
const timeoutMs = 90 * 60_000;
|
||||
const startedAt = Date.now();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function listSizeWorkflowRuns() {
|
||||
const runsForHead = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
head_sha: headSha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (runsForHead.length > 0) {
|
||||
return runsForHead;
|
||||
}
|
||||
|
||||
const recentRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
event: 'pull_request',
|
||||
per_page: 100,
|
||||
});
|
||||
return recentRuns.filter((run) =>
|
||||
run.pull_requests?.some((pullRequest) => pullRequest.number === prNumber));
|
||||
}
|
||||
|
||||
async function findReportRun() {
|
||||
const runs = (await listSizeWorkflowRuns())
|
||||
.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.status !== 'completed') continue;
|
||||
if (run.conclusion !== 'success') {
|
||||
core.warning(`Frontend JS size run ${run.id} completed with conclusion: ${run.conclusion}`);
|
||||
return { done: true, run: null };
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
per_page: 100,
|
||||
});
|
||||
const report = artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
||||
if (report) return { done: true, run };
|
||||
}
|
||||
|
||||
return { done: false, run: null };
|
||||
}
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const { done, run } = await findReportRun();
|
||||
if (run) {
|
||||
core.info(`Found frontend JS size report on workflow run ${run.id}.`);
|
||||
core.setOutput('run-id', String(run.id));
|
||||
return;
|
||||
}
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.info('Waiting for frontend JS size report artifact...');
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
core.warning(`Timed out waiting for ${artifactName} from ${workflow_id} for ${headSha}.`);
|
||||
|
||||
- name: Download size report from workflow_run
|
||||
if: github.event_name == 'workflow_run'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-js-size-report
|
||||
path: ${{ runner.temp }}/frontend-js-size-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Download size report from pull_request_target
|
||||
if: github.event_name == 'pull_request_target' && steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-js-size-report
|
||||
path: ${{ runner.temp }}/frontend-js-size-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ steps.find-report-run.outputs.run-id }}
|
||||
|
||||
- name: Comment on pull request
|
||||
if: github.event_name == 'workflow_run' || steps.find-report-run.outputs.run-id != ''
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.FRONTEND_JS_SIZE_COMMENT_TOKEN || github.token }}
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const marker = '<!-- misskey-frontend-js-size -->';
|
||||
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-js-size-report');
|
||||
const reportPath = [
|
||||
path.join(reportDir, 'report.md'),
|
||||
path.join(reportDir, 'frontend-js-size-report.md'),
|
||||
].find((file) => fs.existsSync(file));
|
||||
if (reportPath == null) {
|
||||
core.setFailed('The frontend JS size report artifact does not contain a report markdown file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = fs.readFileSync(reportPath, 'utf8');
|
||||
const prNumberPath = path.join(reportDir, 'pr-number.txt');
|
||||
let issue_number = fs.existsSync(prNumberPath)
|
||||
? Number(fs.readFileSync(prNumberPath, 'utf8').trim())
|
||||
: context.payload.pull_request?.number;
|
||||
if (!body.includes(marker)) {
|
||||
core.setFailed('The frontend JS size report is missing the expected marker.');
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(issue_number)) {
|
||||
core.setFailed('The frontend JS size report is missing a valid pull request number.');
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
455
.github/workflows/frontend-js-size.yml
vendored
455
.github/workflows/frontend-js-size.yml
vendored
|
|
@ -1,455 +0,0 @@
|
|||
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
|
||||
|
|
@ -129,6 +129,7 @@
|
|||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"rolldown": "1.1.0",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"sass-embedded": "1.100.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "3.0.9",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import path from 'path';
|
|||
import pluginVue from '@vitejs/plugin-vue';
|
||||
import pluginGlsl from 'vite-plugin-glsl';
|
||||
import { replacePlugin } from 'rolldown/plugins';
|
||||
import type { UserConfig } from 'vite';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import type { PluginOption, UserConfig } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { promises as fsp } from 'fs';
|
||||
|
|
@ -23,6 +24,32 @@ const host = url ? (new URL(url)).hostname : undefined;
|
|||
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
|
||||
|
||||
function getBundleVisualizerPlugin(): PluginOption[] {
|
||||
if (process.env.FRONTEND_BUNDLE_VISUALIZER !== 'true') return [];
|
||||
|
||||
const template = process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'markdown'
|
||||
? 'markdown'
|
||||
: process.env.FRONTEND_BUNDLE_VISUALIZER_TEMPLATE === 'raw-data'
|
||||
? 'raw-data'
|
||||
: 'treemap';
|
||||
const defaultFilename = template === 'markdown'
|
||||
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/report.md')
|
||||
: template === 'raw-data'
|
||||
? path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.json')
|
||||
: path.resolve(__dirname, '../../built/_frontend_bundle_visualizer_/stats.html');
|
||||
|
||||
return [
|
||||
visualizer({
|
||||
filename: process.env.FRONTEND_BUNDLE_VISUALIZER_FILE ?? defaultFilename,
|
||||
title: 'Misskey frontend bundle visualizer',
|
||||
template,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
projectRoot: path.resolve(__dirname, '../..'),
|
||||
}) as PluginOption,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 検索インデックスの生成設定
|
||||
*/
|
||||
|
|
@ -129,6 +156,7 @@ export function getConfig(): UserConfig {
|
|||
}),
|
||||
]
|
||||
: [],
|
||||
...getBundleVisualizerPlugin(),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
|
|
|
|||
520
pnpm-lock.yaml
generated
520
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue