Ctrl K
ComunidadGuías…

tools/bundle-size.mjs

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Bundle size manifest generator + comparator, used by the CI bundle-size report.
//
// Usage:
//   node tools/bundle-size.mjs generate <dist-dir> <out.json>
//   node tools/bundle-size.mjs compare <base.json> <head.json>
//
// No dependencies — only node:fs, node:path and node:zlib. Sizes are gzip'd since
// server/nginx/default.conf.template serves assets gzipped; that's the number that
// matters for what a browser actually downloads.

import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { gzipSync } from 'node:zlib'
import { join, relative, sep } from 'node:path'

// Vite/rolldown content hashes are always 8 chars of [A-Za-z0-9_-], immediately
// before the extension (e.g. `setupMonaco-BGNH_NaM.js` -> `setupMonaco-*.js`).
// Stripping exactly 8 (not "8 or more") avoids false-positives on names that
// merely end in enough letters, e.g. `editor.worker-D9zwrD0f.js`'s `.worker` part.
const HASH_RE = /-[\w-]{8}(\.[a-z0-9]+)$/

function stripHash (filename) {
  return filename.replace(HASH_RE, '-*$1')
}

function walk (distDir) {
  const entries = {}

  for (const entry of readdirSync(distDir, { recursive: true, withFileTypes: true })) {
    if (entry.isDirectory() || !/\.(?:js|css)$/.test(entry.name)) continue

    const full = join(entry.parentPath, entry.name)
    const gzip = gzipSync(readFileSync(full), { level: 9 }).length
    const relDir = relative(distDir, entry.parentPath).split(sep).join('/')
    const key = (relDir ? `${relDir}/` : '') + stripHash(entry.name)

    // A hash collision after stripping (unlikely, but not impossible for tiny
    // chunks) would silently overwrite — sum instead so nothing goes missing.
    entries[key] = (entries[key] ?? 0) + gzip
  }

  return entries
}

function generate (distDir, outFile) {
  const entries = walk(distDir)
  const manifest = { generatedAt: new Date().toISOString(), entries }

  writeFileSync(outFile, JSON.stringify(manifest, null, 2))

  const count = Object.keys(entries).length
  console.error(`Wrote ${count} entries to ${outFile}`)
}

function formatBytes (bytes) {
  if (bytes === 0) return '0 B'

  const units = ['B', 'kB', 'MB']
  let value = Math.abs(bytes)
  let unit = 0

  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024
    unit++
  }

  const formatted = unit === 0 ? String(Math.round(value)) : value.toFixed(1)

  return `${bytes < 0 ? '-' : ''}${formatted} ${units[unit]}`
}

function formatDelta (bytes) {
  if (bytes === 0) return '±0 B'

  return `${bytes > 0 ? '+' : ''}${formatBytes(bytes)}`
}

// A malformed (or older-format) manifest would flow through as NaN and publish a
// table of `NaN B` cells on a green check. Fail instead.
function readManifest (file) {
  const { entries } = JSON.parse(readFileSync(file, 'utf8'))

  if (entries === null || typeof entries !== 'object' || Array.isArray(entries)) {
    throw new Error(`${file}: "entries" is not an object`)
  }

  for (const [key, value] of Object.entries(entries)) {
    if (!Number.isFinite(value)) {
      throw new Error(`${file}: entry "${key}" is not a byte count (got ${JSON.stringify(value)}) — manifest is malformed, or was written by an incompatible version of this tool`)
    }
  }

  return entries
}

function compare (baseFile, headFile) {
  const base = readManifest(baseFile)
  const head = readManifest(headFile)

  const keys = new Set([...Object.keys(base), ...Object.keys(head)])

  const rows = [...keys].map(key => {
    const baseGzip = base[key] ?? null
    const headGzip = head[key] ?? null
    const delta = (headGzip ?? 0) - (baseGzip ?? 0)
    const status = baseGzip == null ? 'added' : headGzip == null ? 'removed' : 'changed'

    return { key, baseGzip, headGzip, delta, status }
  })

  const changed = rows
    .filter(row => row.delta !== 0)
    .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta) || a.key.localeCompare(b.key))

  const totalBase = rows.reduce((sum, row) => sum + (row.baseGzip ?? 0), 0)
  const totalHead = rows.reduce((sum, row) => sum + (row.headGzip ?? 0), 0)
  const totalDelta = totalHead - totalBase

  const lines = []

  lines.push('## Bundle size report (gzip)')
  lines.push('')

  if (changed.length === 0) {
    lines.push('No chunk size changes detected.')
  } else {
    lines.push('| Chunk | Base | Head | Δ |')
    lines.push('|---|---|---|---|')

    for (const row of changed) {
      const chunk = row.status === 'added'
        ? `${row.key} 🆕`
        : row.status === 'removed'
          ? `${row.key} 🗑️`
          : row.key

      lines.push(`| \`${chunk}\` | ${row.baseGzip == null ? '—' : formatBytes(row.baseGzip)} | ${row.headGzip == null ? '—' : formatBytes(row.headGzip)} | ${formatDelta(row.delta)} |`)
    }

    lines.push(`| **Total** | **${formatBytes(totalBase)}** | **${formatBytes(totalHead)}** | **${formatDelta(totalDelta)}** |`)
  }

  lines.push('')
  lines.push(`<sub>${rows.length} chunks compared, ${changed.length} changed. Sizes are gzip, matching what nginx serves.</sub>`)

  console.log(lines.join('\n'))
}

const [, , command, ...args] = process.argv

switch (command) {
  case 'generate': {
    const [distDir, outFile] = args
    if (!distDir || !outFile) {
      console.error('Usage: bundle-size.mjs generate <dist-dir> <out.json>')
      process.exit(1)
    }
    generate(distDir, outFile)
    break
  }

  case 'compare': {
    const [baseFile, headFile] = args
    if (!baseFile || !headFile) {
      console.error('Usage: bundle-size.mjs compare <base.json> <head.json>')
      process.exit(1)
    }
    compare(baseFile, headFile)
    break
  }

  default:
    console.error('Usage: bundle-size.mjs <generate|compare> ...')
    process.exit(1)
}