verify-bundle-budget.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
  2. import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
  3. import { brotliCompressSync, gzipSync } from 'node:zlib'
  4. import { fileURLToPath } from 'node:url'
  5. const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
  6. const outputDir = resolveOutputDir(
  7. process.env.DEMO_OUTPUT_DIR || process.env.CLOUDFLARE_PAGES_OUTPUT_DIR || 'apps/viewer-demo/dist'
  8. )
  9. const assetsDir = join(outputDir, 'assets')
  10. const htmlEntries = {
  11. 'index.html': {
  12. label: 'main demo',
  13. maxScriptRawBytes: 160_000,
  14. maxScriptGzipBytes: 55_000,
  15. maxScriptBrotliBytes: 50_000,
  16. maxStyleRawBytes: 100_000,
  17. maxStyleGzipBytes: 30_000,
  18. maxStyleBrotliBytes: 25_000
  19. },
  20. 'compare.html': {
  21. label: 'compare demo',
  22. maxScriptRawBytes: 60_000,
  23. maxScriptGzipBytes: 25_000,
  24. maxScriptBrotliBytes: 20_000,
  25. maxStyleRawBytes: 60_000,
  26. maxStyleGzipBytes: 20_000,
  27. maxStyleBrotliBytes: 16_000
  28. }
  29. }
  30. const heavyRendererGroups = [
  31. { id: 'pdf', pattern: /^pdf-|pdfjs/i },
  32. { id: 'word', pattern: /^(?:wordDoc|wordDocx|docx-preview|RTFJS)/i },
  33. { id: 'spreadsheet', pattern: /^spreadsheet-|xlsx|excel/i },
  34. { id: 'presentation', pattern: /^pptx(?:\.worker)?-|presentation/i },
  35. { id: 'ofd', pattern: /^ofd-/i },
  36. { id: 'typst', pattern: /^(?:typst|compiler|renderer|global-compiler|global-renderer)-/i },
  37. { id: 'cad', pattern: /^cad-/i },
  38. { id: 'archive', pattern: /^(?:archive|libarchive)-/i },
  39. { id: 'drawing', pattern: /^(?:drawing|mermaid-parser|prod|plantuml)-|excalidraw|mermaid/i },
  40. {
  41. id: 'model',
  42. pattern:
  43. /^(?:model|three\.module|GLTFLoader|FBXLoader|OBJLoader|STLLoader|PLYLoader|ColladaLoader|3MFLoader|AMFLoader|KMZLoader|PCDLoader|TDSLoader|USDLoader|VRMLLoader|VTKLoader|XYZLoader)-/i
  44. },
  45. { id: 'mindmap', pattern: /^xmind-|mindmap-/i },
  46. { id: 'geo', pattern: /^(?:geo|togeojson)-|shp/i },
  47. { id: 'email', pattern: /^(?:email|postal-mime)-/i },
  48. { id: 'ebook', pattern: /^epub-/i },
  49. { id: 'text', pattern: /^(?:code|markdown|patch|gitBundle)-|highlight|marked|diff2html/i },
  50. { id: 'media', pattern: /^(?:audio|video|hls|Midi)-/i },
  51. { id: 'image', pattern: /^(?:image|heic2any)-/i },
  52. { id: 'data', pattern: /^data-/i },
  53. { id: 'eda', pattern: /^eda-/i },
  54. { id: 'umd', pattern: /^umd-/i }
  55. ]
  56. const presetBundleBudgets = [
  57. {
  58. id: 'lite',
  59. label: 'lite preset async renderer chunks',
  60. groups: ['text', 'image', 'media'],
  61. maxRawBytes: 2_500_000,
  62. maxGzipBytes: 700_000,
  63. maxBrotliBytes: 560_000
  64. },
  65. {
  66. id: 'office',
  67. label: 'office preset async renderer chunks',
  68. groups: ['pdf', 'word', 'spreadsheet', 'presentation', 'ofd'],
  69. maxRawBytes: 4_700_000,
  70. maxGzipBytes: 1_650_000,
  71. maxBrotliBytes: 800_000
  72. },
  73. {
  74. id: 'engineering',
  75. label: 'engineering preset async renderer chunks',
  76. groups: ['cad', 'model', 'drawing', 'mindmap', 'geo', 'typst', 'archive', 'data', 'eda'],
  77. maxRawBytes: 3_000_000,
  78. maxGzipBytes: 850_000,
  79. maxBrotliBytes: 750_000
  80. },
  81. {
  82. id: 'all',
  83. label: 'all preset async renderer chunks',
  84. groups: heavyRendererGroups.map((group) => group.id),
  85. maxRawBytes: 10_000_000,
  86. maxGzipBytes: 3_300_000,
  87. maxBrotliBytes: 2_100_000
  88. }
  89. ]
  90. const deniedEntryMarkers = [
  91. 'pdfjs-dist',
  92. '@file-viewer/docx',
  93. '@flyfish-dev/cad-viewer',
  94. 'libarchive.js',
  95. '@myriaddreamin/typst',
  96. 'docx-preview',
  97. 'RTFJS',
  98. 'heic2any',
  99. 'hls.js',
  100. 'three',
  101. 'epubjs',
  102. 'postal-mime',
  103. '@ljheee/xmind-parser',
  104. 'shpjs'
  105. ]
  106. function resolveOutputDir(value) {
  107. return isAbsolute(value) ? value : resolve(root, value)
  108. }
  109. function fail(message) {
  110. console.error(`[bundle-budget] ${message}`)
  111. process.exit(1)
  112. }
  113. function assertFile(filePath, label = filePath) {
  114. if (!existsSync(filePath) || !statSync(filePath).isFile()) {
  115. fail(`Missing ${label}. Run pnpm build-only before verifying bundle budgets.`)
  116. }
  117. }
  118. function assertDirectory(dirPath, label = dirPath) {
  119. if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
  120. fail(`Missing ${label}. Run pnpm build-only before verifying bundle budgets.`)
  121. }
  122. }
  123. function readAsset(filePath) {
  124. assertFile(filePath)
  125. const source = readFileSync(filePath)
  126. return {
  127. rawBytes: source.length,
  128. gzipBytes: gzipSync(source).length,
  129. brotliBytes: brotliCompressSync(source).length,
  130. text: source.toString('utf8')
  131. }
  132. }
  133. function formatBytes(value) {
  134. if (value < 1024) {
  135. return `${value} B`
  136. }
  137. if (value < 1024 * 1024) {
  138. return `${(value / 1024).toFixed(1)} KiB`
  139. }
  140. return `${(value / 1024 / 1024).toFixed(2)} MiB`
  141. }
  142. function readText(filePath) {
  143. assertFile(filePath)
  144. return readFileSync(filePath, 'utf8')
  145. }
  146. function stripQuery(value) {
  147. return value.split(/[?#]/)[0]
  148. }
  149. function resolveHtmlReference(htmlFile, reference) {
  150. const normalized = stripQuery(reference)
  151. if (/^(https?:)?\/\//.test(normalized) || normalized.startsWith('data:')) {
  152. return null
  153. }
  154. const baseDir = dirname(htmlFile)
  155. return normalized.startsWith('/')
  156. ? join(outputDir, normalized.slice(1))
  157. : resolve(baseDir, normalized)
  158. }
  159. function collectTags(html, tagName) {
  160. return html.match(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')) || []
  161. }
  162. function readAttribute(tag, name) {
  163. const match = tag.match(new RegExp(`\\b${name}=["']([^"']+)["']`, 'i'))
  164. return match ? match[1] : null
  165. }
  166. function collectEntryAssets(htmlFile, html) {
  167. const scripts = collectTags(html, 'script')
  168. .filter((tag) => readAttribute(tag, 'type') === 'module')
  169. .map((tag) => readAttribute(tag, 'src'))
  170. .filter(Boolean)
  171. .map((reference) => resolveHtmlReference(htmlFile, reference))
  172. .filter(Boolean)
  173. const styles = collectTags(html, 'link')
  174. .filter((tag) => readAttribute(tag, 'rel') === 'stylesheet')
  175. .map((tag) => readAttribute(tag, 'href'))
  176. .filter(Boolean)
  177. .map((reference) => resolveHtmlReference(htmlFile, reference))
  178. .filter(Boolean)
  179. return { scripts, styles }
  180. }
  181. function assertBudget(label, metric, value, max) {
  182. if (value > max) {
  183. fail(`${label} ${metric} ${formatBytes(value)} exceeds budget ${formatBytes(max)}`)
  184. }
  185. }
  186. function summarizeAssets(assetPaths) {
  187. const assets = assetPaths.map((filePath) => ({
  188. filePath,
  189. name: basename(filePath),
  190. ...readAsset(filePath)
  191. }))
  192. return {
  193. assets,
  194. rawBytes: assets.reduce((sum, asset) => sum + asset.rawBytes, 0),
  195. gzipBytes: assets.reduce((sum, asset) => sum + asset.gzipBytes, 0),
  196. brotliBytes: assets.reduce((sum, asset) => sum + asset.brotliBytes, 0)
  197. }
  198. }
  199. function summarizeJsFiles(files) {
  200. return summarizeAssets(files.map((file) => join(assetsDir, file)))
  201. }
  202. function verifyEntry(entryName, budget) {
  203. const htmlFile = join(outputDir, entryName)
  204. const html = readText(htmlFile)
  205. const { scripts, styles } = collectEntryAssets(htmlFile, html)
  206. if (scripts.length !== 1) {
  207. fail(`${entryName} must reference exactly one module entry script, found ${scripts.length}`)
  208. }
  209. const scriptSummary = summarizeAssets(scripts)
  210. const styleSummary = summarizeAssets(styles)
  211. const scriptNames = scriptSummary.assets.map((asset) => asset.name).join(', ')
  212. for (const asset of scriptSummary.assets) {
  213. for (const marker of deniedEntryMarkers) {
  214. if (asset.text.includes(marker)) {
  215. fail(`${budget.label} entry script ${asset.name} contains heavy renderer marker ${marker}`)
  216. }
  217. }
  218. for (const group of heavyRendererGroups) {
  219. if (group.pattern.test(asset.name)) {
  220. fail(`${budget.label} directly loads heavy renderer chunk ${asset.name}`)
  221. }
  222. }
  223. }
  224. assertBudget(budget.label, 'entry JS raw size', scriptSummary.rawBytes, budget.maxScriptRawBytes)
  225. assertBudget(
  226. budget.label,
  227. 'entry JS gzip size',
  228. scriptSummary.gzipBytes,
  229. budget.maxScriptGzipBytes
  230. )
  231. assertBudget(
  232. budget.label,
  233. 'entry JS brotli size',
  234. scriptSummary.brotliBytes,
  235. budget.maxScriptBrotliBytes
  236. )
  237. assertBudget(budget.label, 'entry CSS raw size', styleSummary.rawBytes, budget.maxStyleRawBytes)
  238. assertBudget(
  239. budget.label,
  240. 'entry CSS gzip size',
  241. styleSummary.gzipBytes,
  242. budget.maxStyleGzipBytes
  243. )
  244. assertBudget(
  245. budget.label,
  246. 'entry CSS brotli size',
  247. styleSummary.brotliBytes,
  248. budget.maxStyleBrotliBytes
  249. )
  250. return {
  251. label: budget.label,
  252. scriptNames,
  253. scriptRawBytes: scriptSummary.rawBytes,
  254. scriptGzipBytes: scriptSummary.gzipBytes,
  255. scriptBrotliBytes: scriptSummary.brotliBytes,
  256. styleRawBytes: styleSummary.rawBytes,
  257. styleGzipBytes: styleSummary.gzipBytes,
  258. styleBrotliBytes: styleSummary.brotliBytes
  259. }
  260. }
  261. function verifyHeavyRendererChunks() {
  262. assertDirectory(assetsDir, 'demo assets directory')
  263. const jsFiles = readdirSync(assetsDir).filter((file) => file.endsWith('.js'))
  264. const missingGroups = heavyRendererGroups
  265. .filter((group) => !jsFiles.some((file) => group.pattern.test(file)))
  266. .map((group) => group.id)
  267. if (missingGroups.length) {
  268. fail(`Missing expected async renderer chunks: ${missingGroups.join(', ')}`)
  269. }
  270. return heavyRendererGroups.map((group) => {
  271. const files = jsFiles.filter((file) => group.pattern.test(file)).sort()
  272. return {
  273. id: group.id,
  274. files,
  275. ...summarizeJsFiles(files)
  276. }
  277. })
  278. }
  279. function verifyPresetBundleBudgets(groupReports) {
  280. const groupById = new Map(groupReports.map((group) => [group.id, group]))
  281. return presetBundleBudgets.map((budget) => {
  282. const missingGroups = budget.groups.filter((groupId) => !groupById.has(groupId))
  283. if (missingGroups.length) {
  284. fail(`${budget.label} references unknown renderer groups: ${missingGroups.join(', ')}`)
  285. }
  286. const assets = budget.groups.flatMap((groupId) => groupById.get(groupId).assets)
  287. const rawBytes = assets.reduce((sum, asset) => sum + asset.rawBytes, 0)
  288. const gzipBytes = assets.reduce((sum, asset) => sum + asset.gzipBytes, 0)
  289. const brotliBytes = assets.reduce((sum, asset) => sum + asset.brotliBytes, 0)
  290. const chunkCount = assets.length
  291. if (!chunkCount) {
  292. fail(`${budget.label} did not match any async renderer chunks`)
  293. }
  294. assertBudget(budget.label, 'raw size', rawBytes, budget.maxRawBytes)
  295. assertBudget(budget.label, 'gzip size', gzipBytes, budget.maxGzipBytes)
  296. assertBudget(budget.label, 'brotli size', brotliBytes, budget.maxBrotliBytes)
  297. return {
  298. id: budget.id,
  299. label: budget.label,
  300. groups: budget.groups,
  301. chunkCount,
  302. rawBytes,
  303. gzipBytes,
  304. brotliBytes
  305. }
  306. })
  307. }
  308. const entryReports = Object.entries(htmlEntries).map(([entryName, budget]) =>
  309. verifyEntry(entryName, budget)
  310. )
  311. const rendererReports = verifyHeavyRendererChunks()
  312. const presetReports = verifyPresetBundleBudgets(rendererReports)
  313. console.log('[bundle-budget] Entry budgets')
  314. for (const report of entryReports) {
  315. console.log(
  316. ` - ${report.label}: JS ${formatBytes(report.scriptRawBytes)} raw / ${formatBytes(report.scriptGzipBytes)} gzip / ${formatBytes(report.scriptBrotliBytes)} br, CSS ${formatBytes(report.styleRawBytes)} raw / ${formatBytes(report.styleGzipBytes)} gzip / ${formatBytes(report.styleBrotliBytes)} br (${report.scriptNames})`
  317. )
  318. }
  319. console.log('[bundle-budget] Async renderer chunks')
  320. for (const report of rendererReports) {
  321. console.log(
  322. ` - ${report.id}: ${report.files.length} chunks, ${formatBytes(report.rawBytes)} raw / ${formatBytes(report.gzipBytes)} gzip / ${formatBytes(report.brotliBytes)} br`
  323. )
  324. }
  325. console.log('[bundle-budget] Preset async renderer budgets')
  326. for (const report of presetReports) {
  327. console.log(
  328. ` - ${report.id}: ${report.chunkCount} chunks across ${report.groups.join(', ')}, ${formatBytes(report.rawBytes)} raw / ${formatBytes(report.gzipBytes)} gzip / ${formatBytes(report.brotliBytes)} br`
  329. )
  330. }