| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
- import { brotliCompressSync, gzipSync } from 'node:zlib'
- import { fileURLToPath } from 'node:url'
- const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
- const outputDir = resolveOutputDir(
- process.env.DEMO_OUTPUT_DIR || process.env.CLOUDFLARE_PAGES_OUTPUT_DIR || 'apps/viewer-demo/dist'
- )
- const assetsDir = join(outputDir, 'assets')
- const htmlEntries = {
- 'index.html': {
- label: 'main demo',
- maxScriptRawBytes: 160_000,
- maxScriptGzipBytes: 55_000,
- maxScriptBrotliBytes: 50_000,
- maxStyleRawBytes: 100_000,
- maxStyleGzipBytes: 30_000,
- maxStyleBrotliBytes: 25_000
- },
- 'compare.html': {
- label: 'compare demo',
- maxScriptRawBytes: 60_000,
- maxScriptGzipBytes: 25_000,
- maxScriptBrotliBytes: 20_000,
- maxStyleRawBytes: 60_000,
- maxStyleGzipBytes: 20_000,
- maxStyleBrotliBytes: 16_000
- }
- }
- const heavyRendererGroups = [
- { id: 'pdf', pattern: /^pdf-|pdfjs/i },
- { id: 'word', pattern: /^(?:wordDoc|wordDocx|docx-preview|RTFJS)/i },
- { id: 'spreadsheet', pattern: /^spreadsheet-|xlsx|excel/i },
- { id: 'presentation', pattern: /^pptx(?:\.worker)?-|presentation/i },
- { id: 'ofd', pattern: /^ofd-/i },
- { id: 'typst', pattern: /^(?:typst|compiler|renderer|global-compiler|global-renderer)-/i },
- { id: 'cad', pattern: /^cad-/i },
- { id: 'archive', pattern: /^(?:archive|libarchive)-/i },
- { id: 'drawing', pattern: /^(?:drawing|mermaid-parser|prod|plantuml)-|excalidraw|mermaid/i },
- {
- id: 'model',
- pattern:
- /^(?:model|three\.module|GLTFLoader|FBXLoader|OBJLoader|STLLoader|PLYLoader|ColladaLoader|3MFLoader|AMFLoader|KMZLoader|PCDLoader|TDSLoader|USDLoader|VRMLLoader|VTKLoader|XYZLoader)-/i
- },
- { id: 'mindmap', pattern: /^xmind-|mindmap-/i },
- { id: 'geo', pattern: /^(?:geo|togeojson)-|shp/i },
- { id: 'email', pattern: /^(?:email|postal-mime)-/i },
- { id: 'ebook', pattern: /^epub-/i },
- { id: 'text', pattern: /^(?:code|markdown|patch|gitBundle)-|highlight|marked|diff2html/i },
- { id: 'media', pattern: /^(?:audio|video|hls|Midi)-/i },
- { id: 'image', pattern: /^(?:image|heic2any)-/i },
- { id: 'data', pattern: /^data-/i },
- { id: 'eda', pattern: /^eda-/i },
- { id: 'umd', pattern: /^umd-/i }
- ]
- const presetBundleBudgets = [
- {
- id: 'lite',
- label: 'lite preset async renderer chunks',
- groups: ['text', 'image', 'media'],
- maxRawBytes: 2_500_000,
- maxGzipBytes: 700_000,
- maxBrotliBytes: 560_000
- },
- {
- id: 'office',
- label: 'office preset async renderer chunks',
- groups: ['pdf', 'word', 'spreadsheet', 'presentation', 'ofd'],
- maxRawBytes: 4_700_000,
- maxGzipBytes: 1_650_000,
- maxBrotliBytes: 800_000
- },
- {
- id: 'engineering',
- label: 'engineering preset async renderer chunks',
- groups: ['cad', 'model', 'drawing', 'mindmap', 'geo', 'typst', 'archive', 'data', 'eda'],
- maxRawBytes: 3_000_000,
- maxGzipBytes: 850_000,
- maxBrotliBytes: 750_000
- },
- {
- id: 'all',
- label: 'all preset async renderer chunks',
- groups: heavyRendererGroups.map((group) => group.id),
- maxRawBytes: 10_000_000,
- maxGzipBytes: 3_300_000,
- maxBrotliBytes: 2_100_000
- }
- ]
- const deniedEntryMarkers = [
- 'pdfjs-dist',
- '@file-viewer/docx',
- '@flyfish-dev/cad-viewer',
- 'libarchive.js',
- '@myriaddreamin/typst',
- 'docx-preview',
- 'RTFJS',
- 'heic2any',
- 'hls.js',
- 'three',
- 'epubjs',
- 'postal-mime',
- '@ljheee/xmind-parser',
- 'shpjs'
- ]
- function resolveOutputDir(value) {
- return isAbsolute(value) ? value : resolve(root, value)
- }
- function fail(message) {
- console.error(`[bundle-budget] ${message}`)
- process.exit(1)
- }
- function assertFile(filePath, label = filePath) {
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
- fail(`Missing ${label}. Run pnpm build-only before verifying bundle budgets.`)
- }
- }
- function assertDirectory(dirPath, label = dirPath) {
- if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
- fail(`Missing ${label}. Run pnpm build-only before verifying bundle budgets.`)
- }
- }
- function readAsset(filePath) {
- assertFile(filePath)
- const source = readFileSync(filePath)
- return {
- rawBytes: source.length,
- gzipBytes: gzipSync(source).length,
- brotliBytes: brotliCompressSync(source).length,
- text: source.toString('utf8')
- }
- }
- function formatBytes(value) {
- if (value < 1024) {
- return `${value} B`
- }
- if (value < 1024 * 1024) {
- return `${(value / 1024).toFixed(1)} KiB`
- }
- return `${(value / 1024 / 1024).toFixed(2)} MiB`
- }
- function readText(filePath) {
- assertFile(filePath)
- return readFileSync(filePath, 'utf8')
- }
- function stripQuery(value) {
- return value.split(/[?#]/)[0]
- }
- function resolveHtmlReference(htmlFile, reference) {
- const normalized = stripQuery(reference)
- if (/^(https?:)?\/\//.test(normalized) || normalized.startsWith('data:')) {
- return null
- }
- const baseDir = dirname(htmlFile)
- return normalized.startsWith('/')
- ? join(outputDir, normalized.slice(1))
- : resolve(baseDir, normalized)
- }
- function collectTags(html, tagName) {
- return html.match(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')) || []
- }
- function readAttribute(tag, name) {
- const match = tag.match(new RegExp(`\\b${name}=["']([^"']+)["']`, 'i'))
- return match ? match[1] : null
- }
- function collectEntryAssets(htmlFile, html) {
- const scripts = collectTags(html, 'script')
- .filter((tag) => readAttribute(tag, 'type') === 'module')
- .map((tag) => readAttribute(tag, 'src'))
- .filter(Boolean)
- .map((reference) => resolveHtmlReference(htmlFile, reference))
- .filter(Boolean)
- const styles = collectTags(html, 'link')
- .filter((tag) => readAttribute(tag, 'rel') === 'stylesheet')
- .map((tag) => readAttribute(tag, 'href'))
- .filter(Boolean)
- .map((reference) => resolveHtmlReference(htmlFile, reference))
- .filter(Boolean)
- return { scripts, styles }
- }
- function assertBudget(label, metric, value, max) {
- if (value > max) {
- fail(`${label} ${metric} ${formatBytes(value)} exceeds budget ${formatBytes(max)}`)
- }
- }
- function summarizeAssets(assetPaths) {
- const assets = assetPaths.map((filePath) => ({
- filePath,
- name: basename(filePath),
- ...readAsset(filePath)
- }))
- return {
- assets,
- rawBytes: assets.reduce((sum, asset) => sum + asset.rawBytes, 0),
- gzipBytes: assets.reduce((sum, asset) => sum + asset.gzipBytes, 0),
- brotliBytes: assets.reduce((sum, asset) => sum + asset.brotliBytes, 0)
- }
- }
- function summarizeJsFiles(files) {
- return summarizeAssets(files.map((file) => join(assetsDir, file)))
- }
- function verifyEntry(entryName, budget) {
- const htmlFile = join(outputDir, entryName)
- const html = readText(htmlFile)
- const { scripts, styles } = collectEntryAssets(htmlFile, html)
- if (scripts.length !== 1) {
- fail(`${entryName} must reference exactly one module entry script, found ${scripts.length}`)
- }
- const scriptSummary = summarizeAssets(scripts)
- const styleSummary = summarizeAssets(styles)
- const scriptNames = scriptSummary.assets.map((asset) => asset.name).join(', ')
- for (const asset of scriptSummary.assets) {
- for (const marker of deniedEntryMarkers) {
- if (asset.text.includes(marker)) {
- fail(`${budget.label} entry script ${asset.name} contains heavy renderer marker ${marker}`)
- }
- }
- for (const group of heavyRendererGroups) {
- if (group.pattern.test(asset.name)) {
- fail(`${budget.label} directly loads heavy renderer chunk ${asset.name}`)
- }
- }
- }
- assertBudget(budget.label, 'entry JS raw size', scriptSummary.rawBytes, budget.maxScriptRawBytes)
- assertBudget(
- budget.label,
- 'entry JS gzip size',
- scriptSummary.gzipBytes,
- budget.maxScriptGzipBytes
- )
- assertBudget(
- budget.label,
- 'entry JS brotli size',
- scriptSummary.brotliBytes,
- budget.maxScriptBrotliBytes
- )
- assertBudget(budget.label, 'entry CSS raw size', styleSummary.rawBytes, budget.maxStyleRawBytes)
- assertBudget(
- budget.label,
- 'entry CSS gzip size',
- styleSummary.gzipBytes,
- budget.maxStyleGzipBytes
- )
- assertBudget(
- budget.label,
- 'entry CSS brotli size',
- styleSummary.brotliBytes,
- budget.maxStyleBrotliBytes
- )
- return {
- label: budget.label,
- scriptNames,
- scriptRawBytes: scriptSummary.rawBytes,
- scriptGzipBytes: scriptSummary.gzipBytes,
- scriptBrotliBytes: scriptSummary.brotliBytes,
- styleRawBytes: styleSummary.rawBytes,
- styleGzipBytes: styleSummary.gzipBytes,
- styleBrotliBytes: styleSummary.brotliBytes
- }
- }
- function verifyHeavyRendererChunks() {
- assertDirectory(assetsDir, 'demo assets directory')
- const jsFiles = readdirSync(assetsDir).filter((file) => file.endsWith('.js'))
- const missingGroups = heavyRendererGroups
- .filter((group) => !jsFiles.some((file) => group.pattern.test(file)))
- .map((group) => group.id)
- if (missingGroups.length) {
- fail(`Missing expected async renderer chunks: ${missingGroups.join(', ')}`)
- }
- return heavyRendererGroups.map((group) => {
- const files = jsFiles.filter((file) => group.pattern.test(file)).sort()
- return {
- id: group.id,
- files,
- ...summarizeJsFiles(files)
- }
- })
- }
- function verifyPresetBundleBudgets(groupReports) {
- const groupById = new Map(groupReports.map((group) => [group.id, group]))
- return presetBundleBudgets.map((budget) => {
- const missingGroups = budget.groups.filter((groupId) => !groupById.has(groupId))
- if (missingGroups.length) {
- fail(`${budget.label} references unknown renderer groups: ${missingGroups.join(', ')}`)
- }
- const assets = budget.groups.flatMap((groupId) => groupById.get(groupId).assets)
- const rawBytes = assets.reduce((sum, asset) => sum + asset.rawBytes, 0)
- const gzipBytes = assets.reduce((sum, asset) => sum + asset.gzipBytes, 0)
- const brotliBytes = assets.reduce((sum, asset) => sum + asset.brotliBytes, 0)
- const chunkCount = assets.length
- if (!chunkCount) {
- fail(`${budget.label} did not match any async renderer chunks`)
- }
- assertBudget(budget.label, 'raw size', rawBytes, budget.maxRawBytes)
- assertBudget(budget.label, 'gzip size', gzipBytes, budget.maxGzipBytes)
- assertBudget(budget.label, 'brotli size', brotliBytes, budget.maxBrotliBytes)
- return {
- id: budget.id,
- label: budget.label,
- groups: budget.groups,
- chunkCount,
- rawBytes,
- gzipBytes,
- brotliBytes
- }
- })
- }
- const entryReports = Object.entries(htmlEntries).map(([entryName, budget]) =>
- verifyEntry(entryName, budget)
- )
- const rendererReports = verifyHeavyRendererChunks()
- const presetReports = verifyPresetBundleBudgets(rendererReports)
- console.log('[bundle-budget] Entry budgets')
- for (const report of entryReports) {
- console.log(
- ` - ${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})`
- )
- }
- console.log('[bundle-budget] Async renderer chunks')
- for (const report of rendererReports) {
- console.log(
- ` - ${report.id}: ${report.files.length} chunks, ${formatBytes(report.rawBytes)} raw / ${formatBytes(report.gzipBytes)} gzip / ${formatBytes(report.brotliBytes)} br`
- )
- }
- console.log('[bundle-budget] Preset async renderer budgets')
- for (const report of presetReports) {
- console.log(
- ` - ${report.id}: ${report.chunkCount} chunks across ${report.groups.join(', ')}, ${formatBytes(report.rawBytes)} raw / ${formatBytes(report.gzipBytes)} gzip / ${formatBytes(report.brotliBytes)} br`
- )
- }
|