fix(scripts): disclose browser-bundled packages as shipped
Moving react, shiki, katex and the markdown pipeline to devDependencies took them out of the notices runtime tier, which tiers by declaring section — yet their code is inside lib/client.js and the shell dist. The generator now learns what the browser artifacts carry from the real build configs: each client bundle through its own tsdown config, the shell through apps/web's Vite config, with a recorder that resolves each bare specifier, notes the package behind it, and stops there. About three seconds, and only packages a resolved file backs, so a bundler's virtual module is not mistaken for a shipped one. Net effect on the file: the type-only packages @types/mdast and micromark-util-types move to the development tier, because neither ships code.
This commit is contained in:
177
scripts/browser-bundled-externals.ts
Normal file
177
scripts/browser-bundled-externals.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 { 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plugin that records bare specifiers and stops the walk at them.
|
||||
* @param seen - set the recorder adds package names to.
|
||||
* @param followWorkspace - leave `@deepseek-ai/*` to the host resolver instead of
|
||||
* externalizing it, so the walk continues into our own source.
|
||||
* @returns the recording plugin.
|
||||
*/
|
||||
function recorder(seen: Set<string>, followWorkspace: boolean): 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
|
||||
if (followWorkspace && source.startsWith('@deepseek-ai/')) return null
|
||||
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?: unknown } | 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, false), ...(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 } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as {
|
||||
build: (options: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
await build({
|
||||
root: dir,
|
||||
logLevel: 'error',
|
||||
plugins: [recorder(seen, true)],
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user