browser-bundled-externals.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /**
  2. * The external packages a published browser artifact carries a copy of.
  3. *
  4. * Read from the real build configurations rather than declared by hand: each
  5. * `lib/client.js` plugin bundle is driven through its own `tsdown.config.ts`, and
  6. * the shell `dist` through `apps/web`'s Vite config. A recording plugin resolves
  7. * every bare specifier as external and notes it, so the pass walks our own source
  8. * and stops at the package boundary — which is both fast (about two seconds for
  9. * the whole repository) and exactly the direct-dependency granularity
  10. * THIRD_PARTY_NOTICES.md discloses. Erased type imports never appear, because the
  11. * transform drops them before resolution.
  12. *
  13. * Workspace names are followed only on the Vite side, where the shell's aliases
  14. * map them to source: that is how a browser-only library's own third-party
  15. * imports — katex and shiki through `ui-primitives`, for one — become visible. A
  16. * plugin bundle keeps them external, matching the frozen module table it is built
  17. * against; the wire layers it inlines are host packages that declare their own
  18. * dependencies, so nothing goes undisclosed.
  19. *
  20. * A specifier is recorded only once the host resolves it to a file inside a
  21. * package. A bundler's own virtual module has no package behind it —
  22. * `vite/modulepreload-polyfill` is generated by a Vite plugin rather than shipped
  23. * as a file, so the polyfill in the published `dist` is build glue in the same
  24. * category as an emitted TypeScript helper, not a redistributed copy of Vite.
  25. *
  26. * The pass runs on a clean tree, as a static gate must. The shell's Vite config
  27. * aliases a few workspace packages to source; every other workspace name would
  28. * resolve through `node_modules` to a `lib/` entry the real build has emitted but
  29. * a clean checkout has not, so this module resolves those names to their own
  30. * source instead. `lib/` is compiled from `src/`, so the third-party edges the
  31. * pass records are the same either way.
  32. *
  33. * rolldown is resolved through tsdown deliberately: the dry run must use the
  34. * exact bundler the real build uses, which a separate root pin could drift from.
  35. */
  36. import { existsSync, globSync, readFileSync } from 'node:fs'
  37. import { createRequire } from 'node:module'
  38. import { dirname, join } from 'node:path'
  39. /** The plugin-context member the recorder needs to resolve before recording. */
  40. interface ResolveContext {
  41. resolve: (
  42. source: string,
  43. importer: string,
  44. options: { skipSelf: boolean },
  45. ) => Promise<{ id: string } | null>
  46. }
  47. /** A rolldown/Vite plugin shape, narrowed to what the recorder needs. */
  48. interface RecorderPlugin {
  49. name: string
  50. enforce?: 'pre'
  51. resolveId: (
  52. this: ResolveContext,
  53. source: string,
  54. importer: string | undefined,
  55. ) => Promise<{ id: string; external: true } | null>
  56. }
  57. /**
  58. * The package a resolved module file belongs to.
  59. * @param file - absolute path of a resolved module.
  60. * @returns the package name, or undefined when the file is not inside a package.
  61. */
  62. function packageOfFile(file: string): string | undefined {
  63. const marker = file.lastIndexOf('node_modules/')
  64. if (marker < 0) return undefined
  65. const rest = file.slice(marker + 'node_modules/'.length)
  66. const parts = rest.split('/')
  67. return rest.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
  68. }
  69. /**
  70. * Source aliases for the workspace packages the shell does not already alias.
  71. *
  72. * A clean checkout has no `lib/`, so a workspace name would otherwise resolve
  73. * through `node_modules` to an entry that does not exist yet. Aliases are the
  74. * right seam rather than a plugin hook, because Vite resolves a stylesheet
  75. * `@import` through them too — the theme package publishes its stylesheets from
  76. * `lib/styles/`. `lib/` is compiled from `src/`, so the third-party edges the
  77. * pass records are the same either way.
  78. * @param root - repository root.
  79. * @param existing - the shell's own alias patterns, whose entry choices win.
  80. * @returns alias entries mapping each remaining workspace name to its source.
  81. */
  82. function workspaceSourceAliases(root: string, existing: readonly string[]): { find: RegExp | string; replacement: string }[] {
  83. const aliases: { find: RegExp | string; replacement: string }[] = []
  84. for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
  85. for (const relative of globSync(pattern, { cwd: root })) {
  86. const dir = join(root, dirname(relative))
  87. const manifest = JSON.parse(readFileSync(join(root, relative), 'utf8')) as Manifest & { name?: string }
  88. const name = manifest.name
  89. if (name === undefined || !existsSync(join(dir, 'src'))) continue
  90. if (existing.some(find => find.includes(name))) continue
  91. const root_ = manifest.exports?.['.']
  92. const target = typeof root_ === 'string' ? root_ : root_?.default
  93. const stem = (target ?? './lib/index.js')
  94. .replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '')
  95. const entry = [`${stem}.ts`, `${stem}.tsx`, `${stem}/index.ts`, `${stem}/index.tsx`]
  96. .map(candidate => join(dir, 'src', candidate))
  97. .find(candidate => existsSync(candidate))
  98. // The subpath prefix carries `./client`, `./types`, and `./styles/*` alike:
  99. // each published subpath mirrors a path under `src/`.
  100. aliases.push({ find: `${name}/`, replacement: `${join(dir, 'src')}/` })
  101. if (entry !== undefined) aliases.push({ find: new RegExp(`^${name.replaceAll('/', '\\/')}$`), replacement: entry })
  102. }
  103. }
  104. return aliases
  105. }
  106. /**
  107. * Build the plugin that records bare specifiers and stops the walk at them.
  108. * @param seen - set the recorder adds package names to.
  109. * @returns the recording plugin.
  110. */
  111. function recorder(seen: Set<string>): RecorderPlugin {
  112. return {
  113. name: 'dsh-record-direct-externals',
  114. enforce: 'pre',
  115. async resolveId(source, importer) {
  116. if (importer === undefined) return null // the entry itself
  117. if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null
  118. if (source.startsWith('virtual:') || source.includes('?')) return null
  119. // A workspace name that reaches here is one no alias mapped to source, so
  120. // nothing of ours is left to walk; it is never a third-party disclosure.
  121. if (source.startsWith('@deepseek-ai/')) return { id: source, external: true }
  122. if (source.startsWith('node:')) return { id: source, external: true }
  123. if (!source.startsWith('@deepseek-ai/')) {
  124. const resolved = await this.resolve(source, importer, { skipSelf: true })
  125. const name = resolved === null ? undefined : packageOfFile(resolved.id)
  126. if (name !== undefined) seen.add(name)
  127. }
  128. return { id: source, external: true }
  129. },
  130. }
  131. }
  132. interface Manifest {
  133. exports?: Record<string, { default?: string } | string | null>
  134. files?: string[]
  135. }
  136. /** Read one workspace manifest. */
  137. function manifestOf(dir: string): Manifest {
  138. return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Manifest
  139. }
  140. /** Whether a manifest publishes a tsdown browser bundle at `lib/client.js`. */
  141. function publishesClientBundle(manifest: Manifest): boolean {
  142. const target = manifest.exports?.['./client']
  143. return typeof target === 'object' && target !== null && target.default === './lib/client.js'
  144. }
  145. /**
  146. * Record every external package the plugin client bundles carry.
  147. * @param root - repository root.
  148. * @param seen - set the recorder adds package names to.
  149. */
  150. async function collectFromClientBundles(root: string, seen: Set<string>): Promise<void> {
  151. const requireFromTsdown = createRequire(createRequire(import.meta.url).resolve('tsdown'))
  152. const { rolldown } = await import(requireFromTsdown.resolve('rolldown')) as {
  153. rolldown: (options: Record<string, unknown>) => Promise<{
  154. generate: (output: Record<string, unknown>) => Promise<unknown>
  155. close: () => Promise<void>
  156. }>
  157. }
  158. for (const relative of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
  159. const dir = join(root, dirname(relative))
  160. if (!publishesClientBundle(manifestOf(dir))) continue
  161. const loaded = await import(join(root, relative)) as { default: unknown }
  162. const factory = loaded.default
  163. const configs = (typeof factory === 'function'
  164. ? (factory as (inline: { env: Record<string, string> }) => unknown[])({ env: {} })
  165. : [factory]) as { name?: string; entry?: unknown; plugins?: unknown[] }[]
  166. // The `/client` config is the browser bundle; its siblings emit the node half.
  167. const client = configs.find(config => config.name?.endsWith('/client') === true)
  168. if (client === undefined) continue
  169. const bundle = await rolldown({
  170. cwd: dir,
  171. input: client.entry,
  172. plugins: [recorder(seen), ...(client.plugins ?? [])],
  173. platform: 'browser',
  174. })
  175. await bundle.generate({ format: 'cjs', minify: false, sourcemap: false })
  176. await bundle.close()
  177. }
  178. }
  179. /**
  180. * Record every external package the prebuilt shell bundle carries.
  181. * @param root - repository root.
  182. * @param seen - set the recorder adds package names to.
  183. */
  184. async function collectFromShellBundle(root: string, seen: Set<string>): Promise<void> {
  185. for (const relative of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) {
  186. const dir = join(root, dirname(relative))
  187. // Vite belongs to the app that builds with it, so it resolves from there.
  188. const { build, resolveConfig } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as {
  189. build: (options: Record<string, unknown>) => Promise<unknown>
  190. resolveConfig: (options: Record<string, unknown>, command: string) => Promise<{
  191. resolve: { alias: { find: string | RegExp }[] }
  192. }>
  193. }
  194. // The shell already aliases some workspace names to source, and its entry
  195. // choices win: a stylesheet `@import` resolves through aliases rather than a
  196. // plugin hook, so only the names it leaves out get one from here.
  197. const resolved = await resolveConfig({ root: dir, logLevel: 'error' }, 'build')
  198. await build({
  199. root: dir,
  200. logLevel: 'error',
  201. plugins: [recorder(seen)],
  202. resolve: { alias: workspaceSourceAliases(root, resolved.resolve.alias.map(entry => String(entry.find))) },
  203. build: { write: false, minify: false, sourcemap: false, reportCompressedSize: false },
  204. })
  205. }
  206. }
  207. /**
  208. * The external packages a published browser artifact carries a copy of.
  209. * @param root - repository root.
  210. * @returns package names, workspace names excluded.
  211. */
  212. export async function browserBundledExternals(root: string): Promise<Set<string>> {
  213. const seen = new Set<string>()
  214. await collectFromClientBundles(root, seen)
  215. await collectFromShellBundle(root, seen)
  216. return seen
  217. }