| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- /** Experimental-package isolation over Vite's emitted product graph and recorded file inputs. */
- import { basename, resolve } from 'node:path'
- import { BundleInputIsolation, physicalBundleInput } from './bundle-input-isolation.ts'
- /** The emitted chunk fields used to follow the product's actual output graph. */
- export interface WebOutputChunk {
- type: 'chunk'
- fileName: string
- preliminaryFileName?: string
- facadeModuleId: string | null
- modules: Record<string, unknown>
- imports: string[]
- dynamicImports: string[]
- implicitlyLoadedBefore: string[]
- referencedFiles: string[]
- viteMetadata?: { importedCss: Set<string>; importedAssets: Set<string> }
- }
- /** Original file names provide ownership for emitted, non-CSS assets. */
- export interface WebOutputAsset {
- type: 'asset'
- fileName: string
- names: string[]
- originalFileNames: string[]
- }
- /** Vite's output bundle, restricted to the fields needed by this check. */
- export type WebOutputBundle = Record<string, WebOutputChunk | WebOutputAsset>
- /** Retained source edges, including CSS modules whose pure output chunks Vite removes. */
- export interface WebModuleInfo {
- importedIds: readonly string[]
- dynamicallyImportedIds: readonly string[]
- isExternal: boolean
- isIncluded: boolean | null
- }
- interface AssetReference {
- host: string
- file: string
- type: 'asset' | 'public'
- }
- /** Output graphs are rebuilt each time; cached transforms retain their file-input observations. */
- export class WebProductBundleIsolation {
- private readonly cssInputs = new Map<string, Set<string>>()
- private readonly chunks = new Map<string, WebOutputChunk>()
- private readonly references: AssetReference[] = []
- private readonly workerInputs = new Map<string, Set<string>>()
- private readonly inputs: BundleInputIsolation
- private readonly webRoot: string
- constructor(repository: string, webRoot: string) {
- this.webRoot = webRoot
- this.inputs = new BundleInputIsolation(repository, 'Web product isolation')
- }
- /** Discard output graphs and package metadata before a build or watch rebuild. */
- reset(): void {
- this.chunks.clear()
- this.references.length = 0
- this.workerInputs.clear()
- this.inputs.reset()
- }
- /** Replace dependency observations whenever Vite transforms a module again. */
- cssTransform(module: string): void { this.cssInputs.set(module, new Set<string>()) }
- /** Record the files the actual CSS transform asks Rollup to watch. */
- cssDependency(module: string, file: string): void {
- const inputs = this.cssInputs.get(module) ?? new Set<string>()
- inputs.add(file)
- this.cssInputs.set(module, inputs)
- }
- /** Record Vite's asset URL edges without changing their rendered values. */
- assetReference(file: string, host: string, type: 'asset' | 'public'): void {
- this.references.push({ file, host, type })
- }
- /** Preserve pure CSS chunk inputs before Vite removes their JavaScript wrappers. */
- captureChunks(bundle: WebOutputBundle): void {
- for (const item of Object.values(bundle)) {
- if (item.type !== 'chunk') continue
- this.chunks.set(item.fileName, {
- ...item,
- modules: { ...item.modules },
- imports: [...item.imports],
- dynamicImports: [...item.dynamicImports],
- implicitlyLoadedBefore: [...item.implicitlyLoadedBefore],
- referencedFiles: [...item.referencedFiles],
- ...(item.viteMetadata === undefined ? {} : { viteMetadata: {
- importedCss: new Set(item.viteMetadata.importedCss),
- importedAssets: new Set(item.viteMetadata.importedAssets),
- } }),
- })
- }
- }
- /** Each Vite worker subbuild has one entry and owns its complete watched input set. */
- workerBundle(bundle: WebOutputBundle, watchedFiles: readonly string[]): void {
- const inputs = new Set(watchedFiles)
- const entries: string[] = []
- for (const item of Object.values(bundle)) {
- if (item.type === 'chunk') {
- for (const id of Object.keys(item.modules)) inputs.add(id)
- if (item.facadeModuleId !== null) entries.push(physicalBundleInput(item.facadeModuleId) ?? item.facadeModuleId)
- }
- }
- for (const file of [...Object.keys(bundle), ...entries]) {
- this.workerInputs.set(file, new Set([...this.workerInputs.get(file) ?? [], ...inputs]))
- }
- }
- /** Reject experimental ownership in everything the emitted index page can load. */
- verify(bundle: WebOutputBundle, moduleInfo: (id: string) => WebModuleInfo | null): void {
- const html = bundle['index.html']
- if (html?.type !== 'asset' || !html.originalFileNames.some(file =>
- resolve(this.webRoot, file) === resolve(this.webRoot, 'index.html'))) {
- throw new Error('Web product isolation: emitted index.html is missing its original HTML input')
- }
- const outputs = new Map(Object.entries(bundle))
- const aliases = new Map<string, string>()
- const cssOwners = new Map<string, Set<string>>()
- for (const [file, chunk] of this.chunks) {
- aliases.set(chunk.preliminaryFileName ?? file, file)
- for (const css of chunk.viteMetadata?.importedCss ?? []) {
- const owners = cssOwners.get(css) ?? new Set<string>()
- for (const id of Object.keys(chunk.modules)) owners.add(id)
- cssOwners.set(css, owners)
- }
- }
- for (const item of Object.values(bundle)) {
- if (item.type === 'asset') {
- for (const name of item.names) aliases.set(name, item.fileName)
- }
- }
- const outputName = (name: string): string => outputs.has(name) ? name
- : aliases.get(name) ?? aliases.get(basename(name)) ?? name
- const references = new Map<string, AssetReference[]>()
- for (const reference of this.references) {
- const host = outputName(reference.host)
- references.set(host, [...references.get(host) ?? [], reference])
- }
- const visitedOutputs = new Set<string>()
- const visitedModules = new Set<string>()
- const queue = ['index.html']
- const checkWorker = (key: string): void => {
- const inputs = this.workerInputs.get(key)
- if (inputs === undefined) throw new Error(`Web product isolation: worker ${key} has no recorded build inputs`)
- for (const input of inputs) this.inputs.assertInput(input)
- }
- const checkModule = (id: string): void => {
- if (visitedModules.has(id)) return
- visitedModules.add(id)
- this.inputs.assertInput(id)
- const info = moduleInfo(id)
- if (info === null) throw new Error(`Web product isolation: module ${id} has no Rollup module record`)
- if (info.isExternal) throw new Error(`Web product isolation: external module ${id} has no bundled input proof`)
- if (/\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/i.test(id) && !this.cssInputs.has(id)) {
- throw new Error(`Web product isolation: stylesheet ${id} has no recorded transform inputs`)
- }
- if (/[?&](?:sharedworker|worker)(?:&|$)/.test(id)) checkWorker(physicalBundleInput(id) ?? id)
- for (const file of this.cssInputs.get(id) ?? []) this.inputs.assertInput(file)
- for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) {
- const childInfo = moduleInfo(child)
- if (childInfo === null) throw new Error(`Web product isolation: module ${child} has no Rollup module record`)
- if (childInfo.isIncluded || childInfo.isExternal) checkModule(child)
- }
- }
- for (const name of queue) {
- const file = outputName(name)
- if (visitedOutputs.has(file)) continue
- visitedOutputs.add(file)
- const item = outputs.get(file)
- if (item === undefined) throw new Error(`Web product isolation: referenced output ${file} is missing`)
- if (this.workerInputs.has(file)) checkWorker(file)
- else if (item.type === 'chunk') {
- for (const id of Object.keys(item.modules)) checkModule(id)
- queue.push(...item.imports, ...item.dynamicImports, ...item.implicitlyLoadedBefore, ...item.referencedFiles)
- queue.push(...item.viteMetadata?.importedCss ?? [], ...item.viteMetadata?.importedAssets ?? [])
- } else if (cssOwners.has(file)) {
- for (const id of cssOwners.get(file) ?? []) checkModule(id)
- } else {
- if (item.originalFileNames.length === 0) {
- throw new Error(`Web product isolation: asset ${file} has no recorded original files`)
- }
- for (const original of item.originalFileNames) this.inputs.assertInput(resolve(this.webRoot, original))
- }
- for (const reference of references.get(file) ?? []) {
- if (reference.type === 'public') this.inputs.assertInput(resolve(this.webRoot, 'public', reference.file))
- else queue.push(reference.file)
- }
- }
- if (visitedModules.size === 0) throw new Error('Web product isolation: index.html has no recorded reachable modules')
- }
- }
|