attribute-chunk-bytes.mjs 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. // Attribute a built chunk's minified bytes to source npm packages / workspace dirs via its
  2. // sourcemap (zero-dependency VLQ decoder). The dist-audit companion of the shell chunk-layout
  3. // decision (.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md):
  4. // verifies vendor/index membership after changing VENDOR_PACKAGES or bumping render deps.
  5. // Usage: node scripts/attribute-chunk-bytes.mjs <chunk.js> [--top N]
  6. import { readFileSync } from 'node:fs'
  7. const chunkPath = process.argv[2]
  8. const topN = Number(process.argv[process.argv.indexOf('--top') + 1] || 40)
  9. const code = readFileSync(chunkPath, 'utf8')
  10. const map = JSON.parse(readFileSync(chunkPath + '.map', 'utf8'))
  11. const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
  12. const charToInt = new Map([...B64].map((c, i) => [c, i]))
  13. // Decode one VLQ-encoded segment list line by line.
  14. const lines = code.split('\n')
  15. const bySource = new Float64Array(map.sources.length)
  16. let unmapped = 0
  17. let srcIdx = 0, srcLine = 0, srcCol = 0, nameIdx = 0
  18. const mappingLines = map.mappings.split(';')
  19. for (let li = 0; li < mappingLines.length; li++) {
  20. const lineLen = li < lines.length ? lines[li].length + 1 : 0
  21. const segsRaw = mappingLines[li]
  22. if (segsRaw === '') { unmapped += lineLen; continue }
  23. let genCol = 0
  24. const segs = []
  25. for (const segStr of segsRaw.split(',')) {
  26. const fields = []
  27. let shift = 0, value = 0
  28. for (const ch of segStr) {
  29. const digit = charToInt.get(ch)
  30. value += (digit & 31) << shift
  31. if (digit & 32) { shift += 5 } else {
  32. fields.push((value & 1) ? -(value >>> 1) : (value >>> 1))
  33. shift = 0; value = 0
  34. }
  35. }
  36. genCol += fields[0]
  37. if (fields.length > 1) {
  38. srcIdx += fields[1]; srcLine += fields[2]; srcCol += fields[3]
  39. if (fields.length > 4) nameIdx += fields[4]
  40. segs.push([genCol, srcIdx])
  41. } else {
  42. segs.push([genCol, -1])
  43. }
  44. }
  45. if (segs[0][0] > 0) unmapped += segs[0][0]
  46. for (let i = 0; i < segs.length; i++) {
  47. const end = i + 1 < segs.length ? segs[i + 1][0] : lineLen
  48. const span = Math.max(0, end - segs[i][0])
  49. if (segs[i][1] >= 0) bySource[segs[i][1]] += span
  50. else unmapped += span
  51. }
  52. }
  53. // Aggregate source path -> package bucket.
  54. function bucketOf(src) {
  55. if (src.startsWith('�') || src.includes('vite/')) return '(vite virtual/helpers)'
  56. const nm = [...src.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^@./][^/]*)\//g)]
  57. if (nm.length > 0) return nm[nm.length - 1][1]
  58. let m = src.match(/packages\/([^/]+\/[^/]+)\//)
  59. if (m) return 'ws:packages/' + m[1]
  60. m = src.match(/vendor\/([^/]+)\//)
  61. if (m) return 'ws:vendor/' + m[1]
  62. m = src.match(/apps\/web\//)
  63. if (m) return 'ws:apps/web'
  64. return src
  65. }
  66. const byBucket = new Map()
  67. for (let i = 0; i < map.sources.length; i++) {
  68. if (bySource[i] === 0) continue
  69. const b = bucketOf(map.sources[i])
  70. byBucket.set(b, (byBucket.get(b) || 0) + bySource[i])
  71. }
  72. byBucket.set('(unmapped: interop glue/helpers)', unmapped)
  73. const total = code.length
  74. const rows = [...byBucket.entries()].sort((a, b) => b[1] - a[1])
  75. const kb = n => (n / 1024).toFixed(1).padStart(8)
  76. const pc = n => ((n / total) * 100).toFixed(1).padStart(5)
  77. console.log(`chunk: ${chunkPath} total ${(total / 1024).toFixed(1)} kB (minified, pre-gzip)`)
  78. console.log(`${'kB'.padStart(8)} ${'%'.padStart(5)} package`)
  79. let shown = 0, acc = 0
  80. for (const [name, bytes] of rows) {
  81. if (shown++ < topN) console.log(`${kb(bytes)} ${pc(bytes)} ${name}`)
  82. acc += bytes
  83. }
  84. if (rows.length > topN) console.log(` ... ${rows.length - topN} more buckets`)
  85. console.log(`accounted: ${(acc / 1024).toFixed(1)} kB of ${(total / 1024).toFixed(1)} kB`)
  86. // Group summary: npm vendor vs workspace.
  87. let vendor = 0, ws = 0, other = 0
  88. for (const [name, bytes] of rows) {
  89. if (name.startsWith('ws:')) ws += bytes
  90. else if (name.startsWith('(')) other += bytes
  91. else vendor += bytes
  92. }
  93. console.log(`\nGROUPS npm-vendor ${(vendor / 1024).toFixed(1)} kB | workspace ${(ws / 1024).toFixed(1)} kB | glue ${(other / 1024).toFixed(1)} kB`)