| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- /**
- * The external packages a published browser artifact carries a copy of.
- *
- * Read from the real build configurations rather than declared by hand: each
- * `lib/client.js` plugin bundle is driven through its own `tsdown.config.ts`, and
- * the shell `dist` through `apps/web`'s Vite config. A recording plugin resolves
- * every bare specifier as external and notes it, so the pass walks our own source
- * and stops at the package boundary — which is both fast (about two seconds for
- * the whole repository) and exactly the direct-dependency granularity
- * THIRD_PARTY_NOTICES.md discloses. Erased type imports never appear, because the
- * transform drops them before resolution.
- *
- * Workspace names are followed only on the Vite side, where the shell's aliases
- * map them to source: that is how a browser-only library's own third-party
- * imports — katex and shiki through `ui-primitives`, for one — become visible. A
- * plugin bundle keeps them external, matching the frozen module table it is built
- * against; the wire layers it inlines are host packages that declare their own
- * dependencies, so nothing goes undisclosed.
- *
- * A specifier is recorded only once the host resolves it to a file inside a
- * package. A bundler's own virtual module has no package behind it —
- * `vite/modulepreload-polyfill` is generated by a Vite plugin rather than shipped
- * as a file, so the polyfill in the published `dist` is build glue in the same
- * category as an emitted TypeScript helper, not a redistributed copy of Vite.
- *
- * The pass runs on a clean tree, as a static gate must. The shell's Vite config
- * aliases a few workspace packages to source; every other workspace name would
- * resolve through `node_modules` to a `lib/` entry the real build has emitted but
- * a clean checkout has not, so this module resolves those names to their own
- * source instead. `lib/` is compiled from `src/`, so the third-party edges the
- * pass records are the same either way.
- *
- * rolldown is resolved through tsdown deliberately: the dry run must use the
- * exact bundler the real build uses, which a separate root pin could drift from.
- */
- import { existsSync, globSync, readFileSync } from 'node:fs'
- import { createRequire } from 'node:module'
- import { dirname, join } from 'node:path'
- /** The plugin-context member the recorder needs to resolve before recording. */
- interface ResolveContext {
- resolve: (
- source: string,
- importer: string,
- options: { skipSelf: boolean },
- ) => Promise<{ id: string } | null>
- }
- /** A rolldown/Vite plugin shape, narrowed to what the recorder needs. */
- interface RecorderPlugin {
- name: string
- enforce?: 'pre'
- resolveId: (
- this: ResolveContext,
- source: string,
- importer: string | undefined,
- ) => Promise<{ id: string; external: true } | null>
- }
- /**
- * The package a resolved module file belongs to.
- * @param file - absolute path of a resolved module.
- * @returns the package name, or undefined when the file is not inside a package.
- */
- function packageOfFile(file: string): string | undefined {
- const marker = file.lastIndexOf('node_modules/')
- if (marker < 0) return undefined
- const rest = file.slice(marker + 'node_modules/'.length)
- const parts = rest.split('/')
- return rest.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
- }
- /**
- * Source aliases for the workspace packages the shell does not already alias.
- *
- * A clean checkout has no `lib/`, so a workspace name would otherwise resolve
- * through `node_modules` to an entry that does not exist yet. Aliases are the
- * right seam rather than a plugin hook, because Vite resolves a stylesheet
- * `@import` through them too — the theme package publishes its stylesheets from
- * `lib/styles/`. `lib/` is compiled from `src/`, so the third-party edges the
- * pass records are the same either way.
- * @param root - repository root.
- * @param existing - the shell's own alias patterns, whose entry choices win.
- * @returns alias entries mapping each remaining workspace name to its source.
- */
- function workspaceSourceAliases(root: string, existing: readonly string[]): { find: RegExp | string; replacement: string }[] {
- const aliases: { find: RegExp | string; replacement: string }[] = []
- for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
- for (const relative of globSync(pattern, { cwd: root })) {
- const dir = join(root, dirname(relative))
- const manifest = JSON.parse(readFileSync(join(root, relative), 'utf8')) as Manifest & { name?: string }
- const name = manifest.name
- if (name === undefined || !existsSync(join(dir, 'src'))) continue
- if (existing.some(find => find.includes(name))) continue
- const root_ = manifest.exports?.['.']
- const target = typeof root_ === 'string' ? root_ : root_?.default
- const stem = (target ?? './lib/index.js')
- .replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '')
- const entry = [`${stem}.ts`, `${stem}.tsx`, `${stem}/index.ts`, `${stem}/index.tsx`]
- .map(candidate => join(dir, 'src', candidate))
- .find(candidate => existsSync(candidate))
- // The subpath prefix carries `./client`, `./types`, and `./styles/*` alike:
- // each published subpath mirrors a path under `src/`.
- aliases.push({ find: `${name}/`, replacement: `${join(dir, 'src')}/` })
- if (entry !== undefined) aliases.push({ find: new RegExp(`^${name.replaceAll('/', '\\/')}$`), replacement: entry })
- }
- }
- return aliases
- }
- /**
- * Build the plugin that records bare specifiers and stops the walk at them.
- * @param seen - set the recorder adds package names to.
- * @returns the recording plugin.
- */
- function recorder(seen: Set<string>): RecorderPlugin {
- return {
- name: 'dsh-record-direct-externals',
- enforce: 'pre',
- async resolveId(source, importer) {
- if (importer === undefined) return null // the entry itself
- if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null
- if (source.startsWith('virtual:') || source.includes('?')) return null
- // A workspace name that reaches here is one no alias mapped to source, so
- // nothing of ours is left to walk; it is never a third-party disclosure.
- if (source.startsWith('@deepseek-ai/')) return { id: source, external: true }
- if (source.startsWith('node:')) return { id: source, external: true }
- if (!source.startsWith('@deepseek-ai/')) {
- const resolved = await this.resolve(source, importer, { skipSelf: true })
- const name = resolved === null ? undefined : packageOfFile(resolved.id)
- if (name !== undefined) seen.add(name)
- }
- return { id: source, external: true }
- },
- }
- }
- interface Manifest {
- exports?: Record<string, { default?: string } | string | null>
- files?: string[]
- }
- /** Read one workspace manifest. */
- function manifestOf(dir: string): Manifest {
- return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Manifest
- }
- /** Whether a manifest publishes a tsdown browser bundle at `lib/client.js`. */
- function publishesClientBundle(manifest: Manifest): boolean {
- const target = manifest.exports?.['./client']
- return typeof target === 'object' && target !== null && target.default === './lib/client.js'
- }
- /**
- * Record every external package the plugin client bundles carry.
- * @param root - repository root.
- * @param seen - set the recorder adds package names to.
- */
- async function collectFromClientBundles(root: string, seen: Set<string>): Promise<void> {
- const requireFromTsdown = createRequire(createRequire(import.meta.url).resolve('tsdown'))
- const { rolldown } = await import(requireFromTsdown.resolve('rolldown')) as {
- rolldown: (options: Record<string, unknown>) => Promise<{
- generate: (output: Record<string, unknown>) => Promise<unknown>
- close: () => Promise<void>
- }>
- }
- for (const relative of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
- const dir = join(root, dirname(relative))
- if (!publishesClientBundle(manifestOf(dir))) continue
- const loaded = await import(join(root, relative)) as { default: unknown }
- const factory = loaded.default
- const configs = (typeof factory === 'function'
- ? (factory as (inline: { env: Record<string, string> }) => unknown[])({ env: {} })
- : [factory]) as { name?: string; entry?: unknown; plugins?: unknown[] }[]
- // The `/client` config is the browser bundle; its siblings emit the node half.
- const client = configs.find(config => config.name?.endsWith('/client') === true)
- if (client === undefined) continue
- const bundle = await rolldown({
- cwd: dir,
- input: client.entry,
- plugins: [recorder(seen), ...(client.plugins ?? [])],
- platform: 'browser',
- })
- await bundle.generate({ format: 'cjs', minify: false, sourcemap: false })
- await bundle.close()
- }
- }
- /**
- * Record every external package the prebuilt shell bundle carries.
- * @param root - repository root.
- * @param seen - set the recorder adds package names to.
- */
- async function collectFromShellBundle(root: string, seen: Set<string>): Promise<void> {
- for (const relative of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) {
- const dir = join(root, dirname(relative))
- // Vite belongs to the app that builds with it, so it resolves from there.
- const { build, resolveConfig } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as {
- build: (options: Record<string, unknown>) => Promise<unknown>
- resolveConfig: (options: Record<string, unknown>, command: string) => Promise<{
- resolve: { alias: { find: string | RegExp }[] }
- }>
- }
- // The shell already aliases some workspace names to source, and its entry
- // choices win: a stylesheet `@import` resolves through aliases rather than a
- // plugin hook, so only the names it leaves out get one from here.
- const resolved = await resolveConfig({ root: dir, logLevel: 'error' }, 'build')
- await build({
- root: dir,
- logLevel: 'error',
- plugins: [recorder(seen)],
- resolve: { alias: workspaceSourceAliases(root, resolved.resolve.alias.map(entry => String(entry.find))) },
- build: { write: false, minify: false, sourcemap: false, reportCompressedSize: false },
- })
- }
- }
- /**
- * The external packages a published browser artifact carries a copy of.
- * @param root - repository root.
- * @returns package names, workspace names excluded.
- */
- export async function browserBundledExternals(root: string): Promise<Set<string>> {
- const seen = new Set<string>()
- await collectFromClientBundles(root, seen)
- await collectFromShellBundle(root, seen)
- return seen
- }
|