web-product-bundle-isolation.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. /** Experimental-package isolation over Vite's emitted product graph and recorded file inputs. */
  2. import { basename, resolve } from 'node:path'
  3. import { BundleInputIsolation, physicalBundleInput } from './bundle-input-isolation.ts'
  4. /** The emitted chunk fields used to follow the product's actual output graph. */
  5. export interface WebOutputChunk {
  6. type: 'chunk'
  7. fileName: string
  8. preliminaryFileName?: string
  9. facadeModuleId: string | null
  10. modules: Record<string, unknown>
  11. imports: string[]
  12. dynamicImports: string[]
  13. implicitlyLoadedBefore: string[]
  14. referencedFiles: string[]
  15. viteMetadata?: { importedCss: Set<string>; importedAssets: Set<string> }
  16. }
  17. /** Original file names provide ownership for emitted, non-CSS assets. */
  18. export interface WebOutputAsset {
  19. type: 'asset'
  20. fileName: string
  21. names: string[]
  22. originalFileNames: string[]
  23. }
  24. /** Vite's output bundle, restricted to the fields needed by this check. */
  25. export type WebOutputBundle = Record<string, WebOutputChunk | WebOutputAsset>
  26. /** Retained source edges, including CSS modules whose pure output chunks Vite removes. */
  27. export interface WebModuleInfo {
  28. importedIds: readonly string[]
  29. dynamicallyImportedIds: readonly string[]
  30. isExternal: boolean
  31. isIncluded: boolean | null
  32. }
  33. interface AssetReference {
  34. host: string
  35. file: string
  36. type: 'asset' | 'public'
  37. }
  38. /** Output graphs are rebuilt each time; cached transforms retain their file-input observations. */
  39. export class WebProductBundleIsolation {
  40. private readonly cssInputs = new Map<string, Set<string>>()
  41. private readonly chunks = new Map<string, WebOutputChunk>()
  42. private readonly references: AssetReference[] = []
  43. private readonly workerInputs = new Map<string, Set<string>>()
  44. private readonly inputs: BundleInputIsolation
  45. private readonly webRoot: string
  46. constructor(repository: string, webRoot: string) {
  47. this.webRoot = webRoot
  48. this.inputs = new BundleInputIsolation(repository, 'Web product isolation')
  49. }
  50. /** Discard output graphs and package metadata before a build or watch rebuild. */
  51. reset(): void {
  52. this.chunks.clear()
  53. this.references.length = 0
  54. this.workerInputs.clear()
  55. this.inputs.reset()
  56. }
  57. /** Replace dependency observations whenever Vite transforms a module again. */
  58. cssTransform(module: string): void { this.cssInputs.set(module, new Set<string>()) }
  59. /** Record the files the actual CSS transform asks Rollup to watch. */
  60. cssDependency(module: string, file: string): void {
  61. const inputs = this.cssInputs.get(module) ?? new Set<string>()
  62. inputs.add(file)
  63. this.cssInputs.set(module, inputs)
  64. }
  65. /** Record Vite's asset URL edges without changing their rendered values. */
  66. assetReference(file: string, host: string, type: 'asset' | 'public'): void {
  67. this.references.push({ file, host, type })
  68. }
  69. /** Preserve pure CSS chunk inputs before Vite removes their JavaScript wrappers. */
  70. captureChunks(bundle: WebOutputBundle): void {
  71. for (const item of Object.values(bundle)) {
  72. if (item.type !== 'chunk') continue
  73. this.chunks.set(item.fileName, {
  74. ...item,
  75. modules: { ...item.modules },
  76. imports: [...item.imports],
  77. dynamicImports: [...item.dynamicImports],
  78. implicitlyLoadedBefore: [...item.implicitlyLoadedBefore],
  79. referencedFiles: [...item.referencedFiles],
  80. ...(item.viteMetadata === undefined ? {} : { viteMetadata: {
  81. importedCss: new Set(item.viteMetadata.importedCss),
  82. importedAssets: new Set(item.viteMetadata.importedAssets),
  83. } }),
  84. })
  85. }
  86. }
  87. /** Each Vite worker subbuild has one entry and owns its complete watched input set. */
  88. workerBundle(bundle: WebOutputBundle, watchedFiles: readonly string[]): void {
  89. const inputs = new Set(watchedFiles)
  90. const entries: string[] = []
  91. for (const item of Object.values(bundle)) {
  92. if (item.type === 'chunk') {
  93. for (const id of Object.keys(item.modules)) inputs.add(id)
  94. if (item.facadeModuleId !== null) entries.push(physicalBundleInput(item.facadeModuleId) ?? item.facadeModuleId)
  95. }
  96. }
  97. for (const file of [...Object.keys(bundle), ...entries]) {
  98. this.workerInputs.set(file, new Set([...this.workerInputs.get(file) ?? [], ...inputs]))
  99. }
  100. }
  101. /** Reject experimental ownership in everything the emitted index page can load. */
  102. verify(bundle: WebOutputBundle, moduleInfo: (id: string) => WebModuleInfo | null): void {
  103. const html = bundle['index.html']
  104. if (html?.type !== 'asset' || !html.originalFileNames.some(file =>
  105. resolve(this.webRoot, file) === resolve(this.webRoot, 'index.html'))) {
  106. throw new Error('Web product isolation: emitted index.html is missing its original HTML input')
  107. }
  108. const outputs = new Map(Object.entries(bundle))
  109. const aliases = new Map<string, string>()
  110. const cssOwners = new Map<string, Set<string>>()
  111. for (const [file, chunk] of this.chunks) {
  112. aliases.set(chunk.preliminaryFileName ?? file, file)
  113. for (const css of chunk.viteMetadata?.importedCss ?? []) {
  114. const owners = cssOwners.get(css) ?? new Set<string>()
  115. for (const id of Object.keys(chunk.modules)) owners.add(id)
  116. cssOwners.set(css, owners)
  117. }
  118. }
  119. for (const item of Object.values(bundle)) {
  120. if (item.type === 'asset') {
  121. for (const name of item.names) aliases.set(name, item.fileName)
  122. }
  123. }
  124. const outputName = (name: string): string => outputs.has(name) ? name
  125. : aliases.get(name) ?? aliases.get(basename(name)) ?? name
  126. const references = new Map<string, AssetReference[]>()
  127. for (const reference of this.references) {
  128. const host = outputName(reference.host)
  129. references.set(host, [...references.get(host) ?? [], reference])
  130. }
  131. const visitedOutputs = new Set<string>()
  132. const visitedModules = new Set<string>()
  133. const queue = ['index.html']
  134. const checkWorker = (key: string): void => {
  135. const inputs = this.workerInputs.get(key)
  136. if (inputs === undefined) throw new Error(`Web product isolation: worker ${key} has no recorded build inputs`)
  137. for (const input of inputs) this.inputs.assertInput(input)
  138. }
  139. const checkModule = (id: string): void => {
  140. if (visitedModules.has(id)) return
  141. visitedModules.add(id)
  142. this.inputs.assertInput(id)
  143. const info = moduleInfo(id)
  144. if (info === null) throw new Error(`Web product isolation: module ${id} has no Rollup module record`)
  145. if (info.isExternal) throw new Error(`Web product isolation: external module ${id} has no bundled input proof`)
  146. if (/\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/i.test(id) && !this.cssInputs.has(id)) {
  147. throw new Error(`Web product isolation: stylesheet ${id} has no recorded transform inputs`)
  148. }
  149. if (/[?&](?:sharedworker|worker)(?:&|$)/.test(id)) checkWorker(physicalBundleInput(id) ?? id)
  150. for (const file of this.cssInputs.get(id) ?? []) this.inputs.assertInput(file)
  151. for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) {
  152. const childInfo = moduleInfo(child)
  153. if (childInfo === null) throw new Error(`Web product isolation: module ${child} has no Rollup module record`)
  154. if (childInfo.isIncluded || childInfo.isExternal) checkModule(child)
  155. }
  156. }
  157. for (const name of queue) {
  158. const file = outputName(name)
  159. if (visitedOutputs.has(file)) continue
  160. visitedOutputs.add(file)
  161. const item = outputs.get(file)
  162. if (item === undefined) throw new Error(`Web product isolation: referenced output ${file} is missing`)
  163. if (this.workerInputs.has(file)) checkWorker(file)
  164. else if (item.type === 'chunk') {
  165. for (const id of Object.keys(item.modules)) checkModule(id)
  166. queue.push(...item.imports, ...item.dynamicImports, ...item.implicitlyLoadedBefore, ...item.referencedFiles)
  167. queue.push(...item.viteMetadata?.importedCss ?? [], ...item.viteMetadata?.importedAssets ?? [])
  168. } else if (cssOwners.has(file)) {
  169. for (const id of cssOwners.get(file) ?? []) checkModule(id)
  170. } else {
  171. if (item.originalFileNames.length === 0) {
  172. throw new Error(`Web product isolation: asset ${file} has no recorded original files`)
  173. }
  174. for (const original of item.originalFileNames) this.inputs.assertInput(resolve(this.webRoot, original))
  175. }
  176. for (const reference of references.get(file) ?? []) {
  177. if (reference.type === 'public') this.inputs.assertInput(resolve(this.webRoot, 'public', reference.file))
  178. else queue.push(reference.file)
  179. }
  180. }
  181. if (visitedModules.size === 0) throw new Error('Web product isolation: index.html has no recorded reachable modules')
  182. }
  183. }