browser-bundled-externals.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */
  2. import { globSync, readFileSync, realpathSync } from 'node:fs'
  3. import { createRequire } from 'node:module'
  4. import { dirname, resolve } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { Rolldown, type UserConfigExport } from 'tsdown'
  7. import ts from 'typescript'
  8. import { browserDependencyAnalysis } from '../apps/web/product-isolation.ts'
  9. interface Manifest {
  10. name: string
  11. private?: boolean
  12. dsh?: { client?: unknown }
  13. exports?: Record<string, unknown>
  14. }
  15. interface ResolveContext {
  16. resolve(source: string, importer: string, options: { skipSelf: boolean }): Promise<{ id: string } | null>
  17. }
  18. /**
  19. * Name of the installed package owning a bundler-resolved file.
  20. * @param file - Resolved module or asset id, including any loader query.
  21. * @returns Package name, or undefined for workspace and virtual modules.
  22. */
  23. export function browserPackageOfFile(file: string): string | undefined {
  24. const normalized = file.replaceAll('\\', '/')
  25. const marker = normalized.lastIndexOf('/node_modules/')
  26. if (marker < 0) return undefined
  27. const parts = normalized.slice(marker + '/node_modules/'.length).split('/')
  28. return parts[0]?.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
  29. }
  30. function recorder(seen: Set<string>, workspaceNames: ReadonlySet<string>, followWorkspace = false) {
  31. return {
  32. name: 'dsh-browser-direct-dependencies',
  33. enforce: 'pre' as const,
  34. resolveId: {
  35. order: 'pre' as const,
  36. async handler(this: ResolveContext, source: string, importer: string | undefined) {
  37. if (importer === undefined || source.startsWith('.') || source.startsWith('/')
  38. || source.startsWith('\0') || source.startsWith('node:')) return null
  39. const parts = source.split('/')
  40. const name = source.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
  41. if (name !== undefined && workspaceNames.has(name)) {
  42. return followWorkspace ? null : { id: source, external: true }
  43. }
  44. const resolved = await this.resolve(source, importer, { skipSelf: true })
  45. if (resolved === null) throw new Error(`browser notices: cannot resolve ${source} from ${importer}`)
  46. const owner = browserPackageOfFile(resolved.id)
  47. if (owner === undefined) return resolved
  48. if (browserPackageOfFile(importer) === undefined) seen.add(owner)
  49. // Notices disclose direct dependencies; upstream implementation imports stay in the lockfile.
  50. return { id: source, external: true }
  51. },
  52. },
  53. }
  54. }
  55. function readManifest(path: string): Manifest {
  56. return JSON.parse(readFileSync(path, 'utf8')) as Manifest
  57. }
  58. /**
  59. * Source aliases shared with the repository's source-plane TypeScript programs.
  60. * @param root - Repository root containing tsconfig.base.json.
  61. * @returns Exact and wildcard aliases for the Vite dependency walk.
  62. */
  63. export function browserSourceAliases(root: string): { find: RegExp; replacement: string }[] {
  64. const path = resolve(root, 'tsconfig.base.json')
  65. const config = ts.readConfigFile(path, file => ts.sys.readFile(file))
  66. if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'))
  67. const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root)
  68. return Object.entries(parsed.options.paths ?? {}).map(([name, targets]) => {
  69. const target = targets[0]
  70. if (target === undefined) throw new Error(`browser notices: ${name} has no source target in ${path}`)
  71. const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('\\*', '(.*)')
  72. return { find: new RegExp(`^${escaped}$`), replacement: resolve(root, target).replace('*', '$1') }
  73. })
  74. }
  75. async function collectClientBundles(
  76. root: string,
  77. manifests: ReadonlyMap<string, Manifest>,
  78. workspaceNames: ReadonlySet<string>,
  79. seen: Set<string>,
  80. ): Promise<void> {
  81. for (const [manifestPath, manifest] of manifests) {
  82. if (manifest.private === true || manifest.dsh?.client === undefined) continue
  83. const dir = dirname(manifestPath)
  84. const loaded = await import(pathToFileURL(resolve(dir, 'tsdown.config.ts')).href) as { default: UserConfigExport }
  85. const factory = await loaded.default
  86. const configured = typeof factory === 'function' ? await factory({ env: {} }, { ci: false }) : factory
  87. const configs = Array.isArray(configured) ? configured : [configured]
  88. const client = configs.find(config => config.name === `${manifest.name}/client`)
  89. if (client === undefined) throw new Error(`browser notices: ${manifest.name} has no browser build config`)
  90. if (typeof client.inputOptions === 'function') throw new Error(`browser notices: ${manifest.name} needs resolved input options`)
  91. const bundle = await Rolldown.rolldown({
  92. ...client.inputOptions,
  93. cwd: dir,
  94. input: client.entry as Rolldown.InputOption,
  95. platform: 'browser',
  96. transform: client.define === undefined ? {} : { define: client.define },
  97. plugins: [recorder(seen, workspaceNames), client.plugins ?? []] as NonNullable<Rolldown.InputOptions['plugins']>,
  98. tsconfig: resolve(root, 'tsconfig.base.client.json'),
  99. })
  100. try {
  101. await bundle.generate({ format: 'cjs', sourcemap: false })
  102. } finally {
  103. await bundle.close()
  104. }
  105. }
  106. }
  107. interface ShellConfig {
  108. build: { rollupOptions?: { input?: string | string[] | Record<string, string> } }
  109. }
  110. interface ViteApi {
  111. resolveConfig(
  112. config: Record<string, unknown>, command: 'build', defaultMode: string, defaultNodeEnv: string,
  113. ): Promise<ShellConfig>
  114. build(config: Record<string, unknown>): Promise<unknown>
  115. }
  116. async function collectShell(
  117. root: string,
  118. workspaceNames: ReadonlySet<string>,
  119. seen: Set<string>,
  120. ): Promise<void> {
  121. for (const path of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) {
  122. const dir = dirname(resolve(root, path))
  123. const manifest = readManifest(resolve(dir, 'package.json'))
  124. if (manifest.private === true || manifest.exports?.['./dist/*'] === undefined) continue
  125. const vitePath = createRequire(resolve(dir, 'package.json')).resolve('vite')
  126. const vite = await import(pathToFileURL(vitePath).href) as ViteApi
  127. const config = await vite.resolveConfig({ root: dir, logLevel: 'error' }, 'build', 'production', 'production')
  128. const input = config.build.rollupOptions?.input
  129. const entries = typeof input === 'string' ? [input] : Object.values(input ?? {})
  130. const pages = entries.filter(entry => entry.endsWith('.html'))
  131. if (pages.length === 0) throw new Error(`browser notices: ${manifest.name} has no HTML build entry`)
  132. await vite.build({
  133. root: dir,
  134. logLevel: 'error',
  135. plugins: [browserDependencyAnalysis(), recorder(seen, workspaceNames, true)],
  136. resolve: { alias: browserSourceAliases(root) },
  137. build: {
  138. write: false,
  139. minify: false,
  140. sourcemap: false,
  141. reportCompressedSize: false,
  142. rollupOptions: {
  143. input: pages.length === 1 ? pages[0] : pages,
  144. // Chunk coloring expects full third-party bodies; the disclosure walk stops at their imports.
  145. output: { manualChunks: () => undefined },
  146. },
  147. },
  148. })
  149. }
  150. }
  151. /**
  152. * Direct third-party packages resolved by published browser builds.
  153. * @param root - Repository root, possibly symlinked, with installed build dependencies; lib/ is not required.
  154. * @returns Names of distributed browser inputs, excluding workspace packages and erased types.
  155. */
  156. export async function browserBundledExternals(root: string): Promise<Set<string>> {
  157. // Vite resolves HTML through native realpath, including Windows 8.3 alias expansion.
  158. root = realpathSync.native(root)
  159. const manifests = new Map<string, Manifest>()
  160. for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) {
  161. for (const path of globSync(glob, { cwd: root }).sort()) {
  162. const absolute = resolve(root, path)
  163. manifests.set(absolute, readManifest(absolute))
  164. }
  165. }
  166. const names = new Set([...manifests.values()].map(manifest => manifest.name))
  167. const seen = new Set<string>()
  168. await collectClientBundles(root, manifests, names, seen)
  169. await collectShell(root, names, seen)
  170. return seen
  171. }