Files
deepseek-harness/scripts/browser-bundled-externals.ts
T
imccyu b6d0195d5f fix(scripts): resolve workspace names to source in the notices dry run
The shell's Vite config aliases a few workspace packages to source; every other
workspace name resolved through node_modules to a lib/ entry the real build has
emitted but a clean checkout has not, so the generator only worked on a built
tree — and a static gate has to pass on a clean one. The dry run now supplies
source aliases for the names the shell leaves out.

Aliases rather than the recorder's resolveId hook, because Vite resolves a
stylesheet @import through aliases alone, and the theme package publishes its
stylesheets from lib/styles/. lib/ is compiled from src/, and the recorded set is
identical either way: 24 packages on a built tree and on a clean one.
2026-08-14 22:35:11 +08:00

231 lines
10 KiB
TypeScript

/**
* 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
}