verify-install-budget.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { spawnSync } from 'node:child_process'
  2. import { dirname, resolve } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { loadEcosystemReleaseContext } from './lib/ecosystem-packages.mjs'
  5. const scriptDir = dirname(fileURLToPath(import.meta.url))
  6. const sourceRoot = resolve(scriptDir, '..')
  7. const { entries } = await loadEcosystemReleaseContext(sourceRoot)
  8. const packageBudgets = {
  9. '@file-viewer/core': {
  10. maxPackedBytes: 130_000,
  11. maxUnpackedBytes: 700_000,
  12. maxFiles: 100,
  13. maxDirectRuntimeDependencies: 1,
  14. maxExternalDependencyClosure: 1,
  15. maxLocalPackageClosure: 1
  16. },
  17. '@file-viewer/vue3': {
  18. maxPackedBytes: 1_900_000,
  19. maxUnpackedBytes: 7_500_000,
  20. maxFiles: 45,
  21. maxDirectRuntimeDependencies: 3,
  22. maxExternalDependencyClosure: 41,
  23. maxLocalPackageClosure: 3
  24. },
  25. '@file-viewer/web': {
  26. maxPackedBytes: 35_000_000,
  27. maxUnpackedBytes: 125_000_000,
  28. maxFiles: 2_900,
  29. maxDirectRuntimeDependencies: 1,
  30. maxExternalDependencyClosure: 39,
  31. maxLocalPackageClosure: 3
  32. },
  33. '@file-viewer/preset-all': {
  34. maxPackedBytes: 10_000,
  35. maxUnpackedBytes: 32_000,
  36. maxFiles: 9,
  37. maxDirectRuntimeDependencies: 20,
  38. maxExternalDependencyClosure: 41,
  39. maxLocalPackageClosure: 25
  40. },
  41. '@file-viewer/vite-plugin': {
  42. maxPackedBytes: 30_000,
  43. maxUnpackedBytes: 100_000,
  44. maxFiles: 12,
  45. maxDirectRuntimeDependencies: 0,
  46. maxExternalDependencyClosure: 0,
  47. maxLocalPackageClosure: 1
  48. },
  49. '@file-viewer/pptx': {
  50. maxPackedBytes: 130_000,
  51. maxUnpackedBytes: 600_000,
  52. maxFiles: 24,
  53. maxDirectRuntimeDependencies: 5,
  54. maxExternalDependencyClosure: 5,
  55. maxLocalPackageClosure: 1
  56. },
  57. '@file-viewer/eda-layout': {
  58. maxPackedBytes: 25_000,
  59. maxUnpackedBytes: 90_000,
  60. maxFiles: 10,
  61. maxDirectRuntimeDependencies: 0,
  62. maxExternalDependencyClosure: 0,
  63. maxLocalPackageClosure: 1
  64. },
  65. '@file-viewer/eda-orcad': {
  66. maxPackedBytes: 25_000,
  67. maxUnpackedBytes: 90_000,
  68. maxFiles: 10,
  69. maxDirectRuntimeDependencies: 0,
  70. maxExternalDependencyClosure: 0,
  71. maxLocalPackageClosure: 1
  72. },
  73. '@file-viewer/geometry-engine': {
  74. maxPackedBytes: 28_000,
  75. maxUnpackedBytes: 110_000,
  76. maxFiles: 10,
  77. maxDirectRuntimeDependencies: 0,
  78. maxExternalDependencyClosure: 0,
  79. maxLocalPackageClosure: 1
  80. },
  81. '@file-viewer/renderer-3d': {
  82. maxPackedBytes: 38_000,
  83. maxUnpackedBytes: 180_000,
  84. maxFiles: 18,
  85. maxDirectRuntimeDependencies: 3,
  86. maxExternalDependencyClosure: 4,
  87. maxLocalPackageClosure: 3
  88. },
  89. '@file-viewer/renderer-ofd': {
  90. maxPackedBytes: 140_000,
  91. maxUnpackedBytes: 520_000,
  92. maxFiles: 34,
  93. maxDirectRuntimeDependencies: 3,
  94. maxExternalDependencyClosure: 39,
  95. maxLocalPackageClosure: 3
  96. },
  97. '@file-viewer/renderer-word': {
  98. maxPackedBytes: 35_000,
  99. maxUnpackedBytes: 160_000,
  100. maxFiles: 18,
  101. maxDirectRuntimeDependencies: 5,
  102. maxExternalDependencyClosure: 39,
  103. maxLocalPackageClosure: 3
  104. },
  105. '@file-viewer/renderer-spreadsheet': {
  106. maxPackedBytes: 45_000,
  107. maxUnpackedBytes: 180_000,
  108. maxFiles: 30,
  109. maxDirectRuntimeDependencies: 4,
  110. maxExternalDependencyClosure: 39,
  111. maxLocalPackageClosure: 3
  112. },
  113. '@file-viewer/renderer-drawing': {
  114. maxPackedBytes: 35_000,
  115. maxUnpackedBytes: 160_000,
  116. maxFiles: 18,
  117. maxDirectRuntimeDependencies: 6,
  118. maxExternalDependencyClosure: 6,
  119. maxLocalPackageClosure: 3
  120. },
  121. '@file-viewer/renderer-text': {
  122. maxPackedBytes: 35_000,
  123. maxUnpackedBytes: 160_000,
  124. maxFiles: 18,
  125. maxDirectRuntimeDependencies: 5,
  126. maxExternalDependencyClosure: 5,
  127. maxLocalPackageClosure: 3
  128. },
  129. '@file-viewer/renderer-data': {
  130. maxPackedBytes: 35_000,
  131. maxUnpackedBytes: 160_000,
  132. maxFiles: 18,
  133. maxDirectRuntimeDependencies: 5,
  134. maxExternalDependencyClosure: 39,
  135. maxLocalPackageClosure: 3
  136. },
  137. // Typst intentionally ships a self-hosted default font set so source files can
  138. // render inside offline/intranet deployments without falling back to public CDN
  139. // font downloads. Keep this explicit so future size growth is still visible.
  140. '@file-viewer/renderer-typst': {
  141. maxPackedBytes: 6_200_000,
  142. maxUnpackedBytes: 9_200_000,
  143. maxFiles: 30,
  144. maxDirectRuntimeDependencies: 4,
  145. maxExternalDependencyClosure: 4,
  146. maxLocalPackageClosure: 3
  147. }
  148. }
  149. const kindBudgets = {
  150. renderer: {
  151. maxPackedBytes: 35_000,
  152. maxUnpackedBytes: 160_000,
  153. maxFiles: 18,
  154. maxDirectRuntimeDependencies: 4
  155. },
  156. 'standard-wrapper': {
  157. maxPackedBytes: 30_000,
  158. maxUnpackedBytes: 120_000,
  159. maxFiles: 12,
  160. maxDirectRuntimeDependencies: 3
  161. },
  162. preset: {
  163. maxPackedBytes: 10_000,
  164. maxUnpackedBytes: 30_000,
  165. maxFiles: 10,
  166. maxDirectRuntimeDependencies: 20
  167. }
  168. }
  169. const entriesByPackageName = new Map(entries.map((entry) => [entry.packageName, entry]))
  170. const packageNames = new Set(entriesByPackageName.keys())
  171. function run(command, commandArgs, options = {}) {
  172. const result = spawnSync(command, commandArgs, {
  173. cwd: options.cwd || sourceRoot,
  174. encoding: 'utf8',
  175. stdio: options.capture ? 'pipe' : 'inherit',
  176. env: {
  177. ...process.env,
  178. npm_config_loglevel: 'error'
  179. }
  180. })
  181. if (result.status !== 0) {
  182. throw new Error(`Command failed: ${command} ${commandArgs.join(' ')}`)
  183. }
  184. return result.stdout || ''
  185. }
  186. function parseNpmPackJson(output, packageName) {
  187. const jsonStart = output.indexOf('[')
  188. if (jsonStart === -1) {
  189. throw new Error(`${packageName} npm pack dry-run did not return JSON output`)
  190. }
  191. const parsed = JSON.parse(output.slice(jsonStart))
  192. if (!Array.isArray(parsed) || parsed.length !== 1) {
  193. throw new Error(`${packageName} npm pack dry-run returned an unexpected payload`)
  194. }
  195. return parsed[0]
  196. }
  197. function readDryRunPack(entry) {
  198. const output = run('npm', ['pack', '--dry-run', '--json'], {
  199. cwd: entry.absoluteDir,
  200. capture: true
  201. })
  202. return parseNpmPackJson(output, entry.packageName)
  203. }
  204. function runtimeDependencyNames(packageJson) {
  205. return Object.keys({
  206. ...(packageJson.dependencies || {}),
  207. ...(packageJson.optionalDependencies || {})
  208. }).sort()
  209. }
  210. function collectInstallClosure(
  211. packageName,
  212. state = {
  213. external: new Set(),
  214. local: new Set(),
  215. visiting: new Set()
  216. }
  217. ) {
  218. if (state.visiting.has(packageName)) {
  219. return state
  220. }
  221. const entry = entriesByPackageName.get(packageName)
  222. if (!entry) {
  223. state.external.add(packageName)
  224. return state
  225. }
  226. state.local.add(packageName)
  227. state.visiting.add(packageName)
  228. for (const dependencyName of runtimeDependencyNames(entry.packageJson)) {
  229. collectInstallClosure(dependencyName, state)
  230. }
  231. state.visiting.delete(packageName)
  232. return state
  233. }
  234. function resolveBudget(entry) {
  235. return packageBudgets[entry.packageName] || kindBudgets[entry.kind] || null
  236. }
  237. function formatBytes(value) {
  238. if (value < 1024) {
  239. return `${value} B`
  240. }
  241. if (value < 1024 * 1024) {
  242. return `${(value / 1024).toFixed(1)} KiB`
  243. }
  244. return `${(value / 1024 / 1024).toFixed(2)} MiB`
  245. }
  246. function assertBudget(metric, value, max, entry, errors) {
  247. if (max === undefined) {
  248. return
  249. }
  250. if (value > max) {
  251. errors.push(`${entry.packageName} ${metric} ${value} exceeds budget ${max}`)
  252. }
  253. }
  254. const reports = []
  255. const errors = []
  256. for (const entry of entries) {
  257. const budget = resolveBudget(entry)
  258. if (!budget) {
  259. continue
  260. }
  261. const pack = readDryRunPack(entry)
  262. const runtimeDependencies = runtimeDependencyNames(entry.packageJson)
  263. const closure = collectInstallClosure(entry.packageName)
  264. const externalClosure = [...closure.external].filter((name) => !packageNames.has(name)).sort()
  265. const localClosure = [...closure.local].sort()
  266. const report = {
  267. packageName: entry.packageName,
  268. kind: entry.kind,
  269. packedBytes: pack.size,
  270. unpackedBytes: pack.unpackedSize,
  271. fileCount: pack.files.length,
  272. directRuntimeDependencyCount: runtimeDependencies.length,
  273. externalDependencyClosureCount: externalClosure.length,
  274. localPackageClosureCount: localClosure.length
  275. }
  276. reports.push(report)
  277. assertBudget('packedBytes', report.packedBytes, budget.maxPackedBytes, entry, errors)
  278. assertBudget('unpackedBytes', report.unpackedBytes, budget.maxUnpackedBytes, entry, errors)
  279. assertBudget('fileCount', report.fileCount, budget.maxFiles, entry, errors)
  280. assertBudget(
  281. 'directRuntimeDependencyCount',
  282. report.directRuntimeDependencyCount,
  283. budget.maxDirectRuntimeDependencies,
  284. entry,
  285. errors
  286. )
  287. assertBudget(
  288. 'externalDependencyClosureCount',
  289. report.externalDependencyClosureCount,
  290. budget.maxExternalDependencyClosure,
  291. entry,
  292. errors
  293. )
  294. assertBudget(
  295. 'localPackageClosureCount',
  296. report.localPackageClosureCount,
  297. budget.maxLocalPackageClosure,
  298. entry,
  299. errors
  300. )
  301. }
  302. if (errors.length) {
  303. console.error('[install-budget] Failed')
  304. errors.forEach((error) => console.error(` - ${error}`))
  305. console.error('\nCurrent install budget report:')
  306. reports.forEach((report) => {
  307. console.error(
  308. ` - ${report.packageName}: packed ${formatBytes(report.packedBytes)}, unpacked ${formatBytes(report.unpackedBytes)}, files ${report.fileCount}, direct deps ${report.directRuntimeDependencyCount}, external closure ${report.externalDependencyClosureCount}, local closure ${report.localPackageClosureCount}`
  309. )
  310. })
  311. process.exitCode = 1
  312. } else {
  313. const tracked = reports.filter((report) =>
  314. [
  315. '@file-viewer/core',
  316. '@file-viewer/vue3',
  317. '@file-viewer/web',
  318. '@file-viewer/preset-lite',
  319. '@file-viewer/preset-office',
  320. '@file-viewer/preset-engineering',
  321. '@file-viewer/preset-all',
  322. '@file-viewer/vite-plugin'
  323. ].includes(report.packageName)
  324. )
  325. tracked.forEach((report) => {
  326. console.log(
  327. `[install-budget] ${report.packageName}: packed ${formatBytes(report.packedBytes)}, unpacked ${formatBytes(report.unpackedBytes)}, files ${report.fileCount}, direct deps ${report.directRuntimeDependencyCount}, external closure ${report.externalDependencyClosureCount}, local closure ${report.localPackageClosureCount}.`
  328. )
  329. })
  330. console.log(`[install-budget] Passed ${reports.length} package install surface budgets.`)
  331. }