/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import * as util from './utility.mts'; const marker = ''; const locale = 'ja-JP'; //function sharePercent(value, total) { // if (total === 0) return '0%'; // return Math.round((value / total) * 100) + '%'; //} function escapeCell(value: string) { return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); } //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)); //} type Manifest = Record; const stableNamedChunks = new Set(['vue', 'i18n']); type FileEntry = { comparisonKey: string | null; displayName: string; file: string; manifestKeys: string[]; size: number; }; type CollectedReport = { manifest: Manifest; chunks: FileEntry[]; comparableChunks: Record; chunksByManifestKey: Record; startupFiles: string[]; }; function entryDisplayName(entry: FileEntry) { if (!entry) return ''; return entry.displayName || entry.file; } function findEntryKey(manifest: 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(chunk: Manifest[string]) { if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; return null; } function collectStartupManifestKeys(manifest: Manifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); function visit(key: string, importedBy?: string) { if (keys.has(key)) return; const chunk = manifest[key]; const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); keys.add(key); for (const importKey of chunk.imports ?? []) visit(importKey, key); } visit(entryKey); return keys; } async function resolveBuiltFile(outDir: string, file: string) { if (file.startsWith('scripts/')) { const localizedFile = file.slice('scripts/'.length); const localizedPath = path.join(outDir, locale, localizedFile); if (await util.fileExists(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: string): Promise { const outDir = path.join(repoDir, 'built/_frontend_vite_'); const manifestPath = path.join(outDir, 'manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; const chunksByFile = new Map(); const comparableChunks = new Map(); const chunksByManifestKey = new Map(); for (const [manifestKey, chunk] of Object.entries(manifest)) { if (!chunk.file?.endsWith('.js')) continue; const builtFile = await resolveBuiltFile(outDir, chunk.file); const comparisonKey = stableChunkKey(chunk); let entry = chunksByFile.get(builtFile.relativePath); if (entry == null) { entry = { comparisonKey, displayName: chunk.src ?? chunk.name ?? manifestKey, file: builtFile.relativePath, manifestKeys: [manifestKey], size: await util.fileSize(builtFile.absolutePath), }; chunksByFile.set(entry.file, entry); } else if (entry.comparisonKey !== comparisonKey) { throw new Error(`Conflicting identities for ${entry.file}`); } else { entry.manifestKeys.push(manifestKey); } chunksByManifestKey.set(manifestKey, entry); if (comparisonKey != null) { const existing = comparableChunks.get(comparisonKey); if (existing != null && existing.file !== entry.file) { throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); } comparableChunks.set(comparisonKey, entry); } } const localeDir = path.join(outDir, locale); if (await util.fileExists(localeDir)) { for await (const fullPath of util.traverseDirectory(localeDir)) { if (!fullPath.endsWith('.js')) continue; const relativePath = util.normalizePath(path.relative(outDir, fullPath)); if (chunksByFile.has(relativePath)) continue; chunksByFile.set(relativePath, { comparisonKey: null, displayName: relativePath, file: relativePath, manifestKeys: [], size: await util.fileSize(fullPath), }); } } const startupFiles = new Set(); for (const manifestKey of collectStartupManifestKeys(manifest)) { const entry = chunksByManifestKey.get(manifestKey); if (entry != null) startupFiles.add(entry.file); } return { manifest, chunks: [...chunksByFile.values()], comparableChunks: Object.fromEntries(comparableChunks), chunksByManifestKey: Object.fromEntries(chunksByManifestKey), startupFiles: [...startupFiles], }; } type VisualizerReport = { nodeParts?: Record; nodeMetas?: Record; renderedLength: number; gzipLength: number; brotliLength: number; }>; options?: Record; }; function collectVisualizerReport(data: VisualizerReport) { 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 renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { const summary = [ 'bundles', 'modules', 'entries', //'externals', 'staticImports', 'dynamicImports', ] as const; const metrics = [ 'renderedLength', 'gzipLength', 'brotliLength', ] as const; return [ ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ...summary.map((key) => ``), ...metrics.map((key) => ``), ``, ``, ``, ...summary.map((key) => ``), ...metrics.map((key) => ``), ``, ``, ``, ``, ...summary.map((key) => ``), ...metrics.map((key) => ``), ``, ``, ``, ...summary.map((key) => ``), ...metrics.map((key) => ``), ``, ``, `
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before${util.formatNumber(before.summary[key])}${util.formatBytes(before.metrics[key])}
After${util.formatNumber(after.summary[key])}${util.formatBytes(after.metrics[key])}
Δ${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}
Δ (%)${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}
`, ]; } function getChunkComparisonRows(keys: string[], before: Record, after: Record) { return keys.map(key => { const beforeEntry = before[key]; const afterEntry = after[key]; const beforeSize = beforeEntry?.size ?? 0; const afterSize = afterEntry?.size ?? 0; return { key, name: entryDisplayName(beforeEntry ?? afterEntry), beforeFile: beforeEntry?.file, afterFile: afterEntry?.file, beforeSize, afterSize, changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', sortSize: Math.max(beforeSize, afterSize), }; }); } type ChunkComparisonRow = ReturnType[number]; type ChunkAggregate = { beforeSize: number; afterSize: number; beforeCount: number; afterCount: number; }; const smallDeltaThreshold = 5; function sumChunkSizes(chunks: FileEntry[]) { return chunks.reduce((sum, chunk) => sum + chunk.size, 0); } function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); return { beforeSize: sumChunkSizes(beforeGenerated), afterSize: sumChunkSizes(afterGenerated), beforeCount: beforeGenerated.length, afterCount: afterGenerated.length, }; } function hasSmallDelta(row: ChunkComparisonRow) { return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; } function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { return { beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), beforeCount: rows.filter(row => row.beforeFile != null).length, afterCount: rows.filter(row => row.afterFile != null).length, }; } function comparableMap(chunks: FileEntry[]) { const entries: [string, FileEntry][] = []; for (const chunk of chunks) { if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); } return Object.fromEntries(entries); } function summarizeChunkChanges(rows: ReturnType) { return { updated: rows.filter((row) => row.changeType === 'updated').length, added: rows.filter((row) => row.changeType === 'added').length, removed: rows.filter((row) => row.changeType === 'removed').length, }; } function formatChunkChangeSummary(label: string, summary: ReturnType) { return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; } function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { return 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); } function chunkFileDisplay(row: ChunkComparisonRow) { if (row.beforeFile == null) return row.afterFile ?? ''; if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; return `${row.beforeFile} → ${row.afterFile}`; } function chunkMarkdownTable( rows: ReturnType, total?: { beforeSize: number; afterSize: number }, generated?: ChunkAggregate, other?: ChunkAggregate, ) { const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0); const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; const lines = [ '| Chunk | Before | After | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); lines.push('| | | | | |'); } for (const row of rows) { const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } if (hasGenerated) { lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } if (hasOther) { lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } return lines.join('\n'); } function renderFrontendChunkReport(before: Awaited>, after: Awaited>) { const beforeComparable = before.comparableChunks; const afterComparable = after.comparableChunks; const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); const diffSummary = summarizeChunkChanges(changedRows); const diffTotal = { beforeSize: sumChunkSizes(before.chunks), afterSize: sumChunkSizes(after.chunks), }; const diffGenerated = generatedAggregate(before.chunks, after.chunks); const diffOther = comparisonRowsAggregate(changedRows.filter(hasSmallDelta)); const diffRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする const beforeStartupFiles = new Set(before.startupFiles); const afterStartupFiles = new Set(after.startupFiles); const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); const beforeStartupComparable = comparableMap(beforeStartupChunks); const afterStartupComparable = comparableMap(afterStartupChunks); const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); const startupSummary = summarizeChunkChanges(startupComparisonRows); const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); const startupTotal = { beforeSize: sumChunkSizes(beforeStartupChunks), afterSize: sumChunkSizes(afterStartupChunks), }; const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); //const largeRows = comparisonRows // .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name)) // .slice(0, 30); return [ '
', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), '', '
', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), '', `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, '', '
', '', //'
', //`Largest`, //'', //markdownTable(largeRows), //'', //'
', //'', ].join('\n'); } function renderFrontendBundleReport(before: ReturnType, after: ReturnType) { const lines = [ ...renderVisualizerSummaryTable(before, after), '', //'
', //'Top 10', //'', ]; /* 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( '', '
', ); lines.push( '', '
', 'Hot Modules (Self Size)', '', '| 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( '', '
', ); */ return lines.join('\n'); } const args = process.argv.slice(2); const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args; const before = await collectReport(beforeDir); const after = await collectReport(afterDir); const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; const beforeVisualizerReport = collectVisualizerReport(beforeStats); const afterVisualizerReport = collectVisualizerReport(afterStats); const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`; const body = [ marker, '', `## 📦 Frontend Bundle Report`, '', renderFrontendChunkReport(before, after), '', '## Bundle Stats', '', renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport), '', visualizerArtifactLink, ].join('\n'); await fs.writeFile(outFile, body);