From 0956da49e943d1aff3d71d394f2ad95919dfb59f Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:00:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(dev):=20=E3=83=95=E3=83=AD=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=A8=E3=83=B3=E3=83=89=E3=81=AE=E3=83=90=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=83=AB=E3=82=B5=E3=82=A4=E3=82=BA=E6=AF=94=E8=BC=83?= =?UTF-8?q?=E3=81=AEAction=20(#17586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create frontend-js-size.yml --- .github/workflows/frontend-js-size.yml | 372 +++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 .github/workflows/frontend-js-size.yml diff --git a/.github/workflows/frontend-js-size.yml b/.github/workflows/frontend-js-size.yml new file mode 100644 index 0000000000..ca0eb96ce3 --- /dev/null +++ b/.github/workflows/frontend-js-size.yml @@ -0,0 +1,372 @@ +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 +permissions: + contents: read + issues: write + 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 = ''; + const locale = process.env.FRONTEND_JS_SIZE_LOCALE || 'ja-JP'; + const topLimit = 10; + + function normalizePath(filePath) { + return filePath.split(path.sep).join('/'); + } + + async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + async function fileSize(filePath) { + const stat = await fs.stat(filePath); + return stat.size; + } + + async function* walk(dir) { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(fullPath); + } else if (entry.isFile()) { + yield fullPath; + } + } + } + + function formatBytes(size) { + if (size == null) return '-'; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`; + return `${(size / 1024 / 1024).toFixed(2)} MiB`; + } + + function formatDiff(diff) { + if (diff == null) return '-'; + if (diff === 0) return '0 B'; + const sign = diff > 0 ? '+' : '-'; + return `${sign}${formatBytes(Math.abs(diff))}`; + } + + function escapeCell(value) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); + } + + function entryDisplayName(entry) { + if (!entry) return ''; + return entry.displayName === entry.file + ? entry.displayName + : `${entry.displayName} (${entry.file})`; + } + + function findEntryKey(manifest) { + const entries = Object.entries(manifest); + return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] + ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] + ?? entries.find(([, chunk]) => chunk.isEntry)?.[0] + ?? null; + } + + function stableChunkKey(manifestKey, chunk) { + return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey); + } + + function collectStartupKeys(manifest) { + const entryKey = findEntryKey(manifest); + const keys = new Set(); + if (entryKey == null) return keys; + + function visit(key) { + if (keys.has(key)) return; + const chunk = manifest[key]; + if (!chunk || !chunk.file?.endsWith('.js')) return; + keys.add(stableChunkKey(key, chunk)); + for (const importKey of chunk.imports ?? []) { + visit(importKey); + } + } + + visit(entryKey); + return keys; + } + + async function resolveBuiltFile(outDir, file) { + const originalPath = path.join(outDir, file); + if (file.startsWith('scripts/')) { + const localizedFile = file.slice('scripts/'.length); + const localizedPath = path.join(outDir, locale, localizedFile); + if (await exists(localizedPath)) { + return { + absolutePath: localizedPath, + relativePath: `${locale}/${localizedFile}`, + }; + } + } + return { + absolutePath: originalPath, + relativePath: file, + }; + } + + async function collectReport(repoDir) { + const outDir = path.join(repoDir, 'built/_frontend_vite_'); + const manifestPath = path.join(outDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + const byKey = new Map(); + const byFile = new Set(); + + for (const [key, chunk] of Object.entries(manifest)) { + if (!chunk.file?.endsWith('.js')) continue; + const builtFile = await resolveBuiltFile(outDir, chunk.file); + const size = await fileSize(builtFile.absolutePath); + const stableKey = stableChunkKey(key, chunk); + const displayName = chunk.src ?? chunk.name ?? key; + byKey.set(stableKey, { + key: stableKey, + displayName, + file: builtFile.relativePath, + size, + }); + byFile.add(builtFile.relativePath); + } + + for await (const fullPath of walk(outDir)) { + if (!fullPath.endsWith('.js')) continue; + const relativePath = normalizePath(path.relative(outDir, fullPath)); + if (byFile.has(relativePath)) continue; + if (relativePath.startsWith('scripts/') || relativePath.startsWith(`${locale}/`)) continue; + const size = await fileSize(fullPath); + byKey.set(relativePath, { + key: relativePath, + displayName: relativePath, + file: relativePath, + size, + }); + } + + return { + manifest, + chunks: Object.fromEntries(byKey), + startupKeys: [...collectStartupKeys(manifest)], + }; + } + + function compareRows(keys, before, after) { + return keys.map((key) => { + const beforeEntry = before.chunks[key]; + const afterEntry = after.chunks[key]; + const beforeSize = beforeEntry?.size ?? null; + const afterSize = afterEntry?.size ?? null; + return { + key, + file: entryDisplayName(afterEntry ?? beforeEntry), + beforeSize, + afterSize, + diff: beforeSize == null || afterSize == null ? null : afterSize - beforeSize, + sortSize: Math.max(beforeSize ?? 0, afterSize ?? 0), + }; + }); + } + + function markdownTable(rows) { + if (rows.length === 0) { + return '_No JavaScript chunks found._'; + } + + const lines = [ + '| File | Size (before) | Size (after) | Size (diff) |', + '| --- | ---: | ---: | ---: |', + ]; + for (const row of rows) { + lines.push(`| ${escapeCell(row.file)} | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${formatDiff(row.diff)} |`); + } + return lines.join('\n'); + } + + function unionTopKeys(before, after) { + const allKeys = new Set([ + ...Object.keys(before.chunks), + ...Object.keys(after.chunks), + ]); + return compareRows([...allKeys], before, after) + .sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file)) + .slice(0, topLimit) + .map((row) => row.key); + } + + const beforeDir = process.argv[2]; + const afterDir = process.argv[3]; + const outFile = process.argv[4]; + const beforeSha = process.env.BASE_SHA; + const afterSha = process.env.HEAD_SHA; + + const before = await collectReport(beforeDir); + const after = await collectReport(afterDir); + + const topRows = compareRows(unionTopKeys(before, after), before, after) + .sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file)); + + const startupKeys = new Set([ + ...before.startupKeys, + ...after.startupKeys, + ]); + const startupRows = compareRows([...startupKeys], before, after) + .sort((a, b) => b.sortSize - a.sortSize || a.file.localeCompare(b.file)); + + const body = [ + marker, + '## Frontend JavaScript size', + '', + `Compared locale: \`${locale}\``, + `Before: \`${beforeSha}\``, + `After: \`${afterSha}\``, + '', + '### Top 10 largest JS chunks', + '', + markdownTable(topRows), + '', + '### Startup JS chunks', + '', + markdownTable(startupRows), + '', + '_Top 10 is sorted by max(before, after) size. Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._', + '', + ].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 }} + run: | + node .github/tmp/frontend-js-size-report.mjs before after frontend-js-size-report.md + cat frontend-js-size-report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment on pull request + uses: actions/github-script@v9 + continue-on-error: true + with: + script: | + const fs = require('node:fs'); + + const marker = ''; + const body = fs.readFileSync('frontend-js-size-report.md', 'utf8'); + const issue_number = context.payload.pull_request.number; + 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, + }); + }