Review of #4539. A refusal pnpm view prints as JSON on stdout was read as unknown, so no registry was ever asked after a dead one; stderr and stdout are now read alike, and a lookup killed at its bound is a network refusal. pnpm's own registry joins the fallback plan only once pnpm config get registry names npm's own or a configured fallback, so a company registry in .npmrc is never followed by a public one, and a registry pnpm already names is asked once. The Host lays each failure at the registry or the spec's own host, reading the host off error lines only, and reports it as ChangeResult.failedAt; a stop that lands between attempts is cancelled, not failed. The package publishes its registry rule through ./registry so the dialog plans with the Host's own function, starts from the registry the Host asks first until one is remembered, tags that one as the default, labels pnpm's own by the host it names, and words and offers a change of registry from the Host's attribution. ERR_PNPM_NO_OFFLINE_META is no longer a network failure.
736 lines
34 KiB
TypeScript
736 lines
34 KiB
TypeScript
/**
|
|
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
|
|
* artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
|
|
* and resolves externals through the injected require (loader module table —
|
|
* cordis DI entities, no globals, no import map). CSS is compiled by
|
|
* lightningcss inside the bundle: `x.module.css` yields its hashed class map
|
|
* and injects a tagged style at factory execution, while `x.css?inline`
|
|
* exports compiled text for a plugin-owned lifecycle effect. The virtual
|
|
* loaders register each real stylesheet as a watch dependency.
|
|
* Non-experimental client outputs reject experimental module and stylesheet
|
|
* inputs, including origins recorded by chained source maps.
|
|
*/
|
|
import { readFile, stat, utimes } from 'node:fs/promises'
|
|
import { existsSync, globSync, readFileSync } from 'node:fs'
|
|
import { createRequire, isBuiltin } from 'node:module'
|
|
import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
|
|
import { transform } from 'lightningcss'
|
|
import { optionalStringArray } from './modules/src/client/manifest.ts'
|
|
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
|
|
import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
|
|
import { BundleInputIsolation, physicalBundleInput } from '../../scripts/bundle-input-isolation.ts'
|
|
|
|
/**
|
|
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
|
|
* (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
|
|
* ending in `.css`, so the virtual id must not.
|
|
*/
|
|
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
|
const GLOBAL_CSS_VIRTUAL_PREFIX = '\0dsh-global-css:'
|
|
const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
|
|
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
|
const INLINE_CSS_QUERY = '?inline'
|
|
|
|
/** Emit one plugin-owned style injector and an optional CSS Modules export. */
|
|
function styleInjectionModule(
|
|
id: string,
|
|
fileId: string,
|
|
css: string,
|
|
classMap?: Readonly<Record<string, string>>,
|
|
): string {
|
|
const source = [
|
|
`const css = ${JSON.stringify(css)};`,
|
|
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
|
|
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
|
|
' const tag = document.createElement(\'style\');',
|
|
` tag.dataset.plugin = ${JSON.stringify(id)};`,
|
|
' tag.dataset.pluginCss = tagId;',
|
|
' tag.textContent = css;',
|
|
' document.head.appendChild(tag);',
|
|
'}',
|
|
]
|
|
source.push(classMap === undefined ? 'export {};' : `export default ${JSON.stringify(classMap)};`)
|
|
return source.join('\n')
|
|
}
|
|
|
|
/**
|
|
* Contract layers and pure folds a client bundle may inline: browser-safe
|
|
* values with no runtime identity to share (no Symbol/instanceof/singleton state).
|
|
* Everything else under @deepseek-ai/* is either a module-table entry
|
|
* (external) or a leak the purity gate rejects.
|
|
*/
|
|
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
|
|
|
/**
|
|
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
|
* would read them as plugin packages. They carry no cross-plugin runtime
|
|
* identity to share — the framework itself is a requested module-table row
|
|
* (external), while these are ordinary libraries a browser bundle inlines.
|
|
*/
|
|
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
|
|
|
|
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
|
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
|
|
|
/**
|
|
* Workspace mode replaces an empty config array with the root defaults. A
|
|
* falsey entry instead removes this package before entry resolution.
|
|
*/
|
|
const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
|
|
|
|
const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
|
|
|
/** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
|
|
function browserSourcePath(source: string, sourcemapPath: string): string {
|
|
if (!source.startsWith('.')) return source
|
|
const physicalSource = resolvePath(dirname(sourcemapPath), source)
|
|
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
|
|
return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
|
|
}
|
|
|
|
/**
|
|
* Build the tsdown config for one UI plugin package: the node-half lib build
|
|
* plus the browser client bundle. Client packages emit both halves during the
|
|
* Client pass by default; packages needed for Host reflection may opt into the
|
|
* earlier Host pass. A package-level tsdown.config.ts REPLACES the root
|
|
* workspace layout, so the lib half must be restated here — dropping it leaves
|
|
* the package without lib/index.js and the host Loader cannot import its node
|
|
* half. The Client build consumes `lib/types` and chains those tsc maps, with
|
|
* original source content, into the standalone plugin map.
|
|
* @param id - plugin id (package name), stamped into the __ModuleLoader__.load
|
|
* handoff and onto the injected style tags.
|
|
* @param libEntry - node-half entries, spelled at the call site so the
|
|
* package-invariants gate can see `lib/types/invariant.js` in each package's
|
|
* own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
|
|
* @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner.
|
|
* @returns ENV-selected tsdown config for the current build face.
|
|
*/
|
|
export function clientBundle(
|
|
id: string,
|
|
libEntry: readonly string[],
|
|
options: ClientBundleOptions = {},
|
|
): BuildFaceConfig {
|
|
const lib = clientLibraryConfig(id, libEntry, options.lib)
|
|
return ({ env }) => {
|
|
const face = buildFace(env?.DSH_BUILD_FACE)
|
|
const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
|
|
const client = clientConfig(id, clientEntry, options.clientBanner)
|
|
const node = [lib, ...(options.companions ?? [])]
|
|
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
|
|
if (face === 'client') {
|
|
return options.hostPhase === true ? [client] : [...node, client]
|
|
}
|
|
return [...node, client]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the tsdown config for a client library the compile shell links
|
|
* statically (the static assembly channel: `apps/web` resolves the package
|
|
* name, bundles the artifact, and owns the chunk layout and the CSS pipeline).
|
|
*
|
|
* Calling this preset is what puts a package in the static assembly channel,
|
|
* so the call sites are the roster: gates read it through
|
|
* {@link isStaticLinkedConfig} rather than a second hand-kept list. A package on
|
|
* this roster must not be a module-table row as well — the browser would take
|
|
* the statically linked copy and a provider's bytes would sit unused in its
|
|
* bundle.
|
|
*
|
|
* Four artifact contracts:
|
|
* 1. every bare specifier stays an import. The shell attributes chunk bytes by
|
|
* `node_modules/<pkg>`, so a dependency inlined into a workspace file is
|
|
* attributed to no npm package and its bytes fall into the index chunk,
|
|
* which collapses the vendor/index cache split.
|
|
* 2. `esm` on `platform: 'browser'` — the shell is the only consumer.
|
|
* 3. sourcemaps, chained through the tsc maps under `lib/types` to the sources.
|
|
* 4. stylesheets ship with the package: a relative `.css` import survives as a
|
|
* relative external and the sheet is emitted under `lib/` at its
|
|
* `src`-relative path, so vite stays the only owner of class hashing.
|
|
* @param id - package name, used in tsdown diagnostics.
|
|
* @param libEntry - emitted JavaScript entries consumed from `lib/types`, one
|
|
* bundle each: a multi-entry build would emit a hash-named shared chunk that
|
|
* the exact `files` list cannot publish.
|
|
* @returns ENV-selected tsdown config for the Client build face.
|
|
*/
|
|
export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig {
|
|
// Each entry names its own output file, so two entries with the same basename
|
|
// would overwrite one artifact instead of emitting two.
|
|
const names = new Set(libEntry.map(entry => basename(entry, '.js')))
|
|
if (names.size !== libEntry.length) {
|
|
throw new Error(`tsdown: ${id} entries collide on an output name: ${libEntry.join(', ')}`)
|
|
}
|
|
return clientOnly(libEntry.map(entry => staticLinkedConfig(id, entry)))
|
|
}
|
|
|
|
/**
|
|
* Whether a package's tsdown configs put it in the static assembly channel.
|
|
* The roster has no separate list: gates load each package's own
|
|
* `tsdown.config.ts`, call it for the Client face, and ask this.
|
|
* @param configs - configs a package's build-face function returned.
|
|
* @returns true when at least one config was built by {@link staticLinked}.
|
|
*/
|
|
export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean {
|
|
return configs.some(config => (config.plugins as readonly { name?: string }[] | undefined ?? [])
|
|
.some(plugin => plugin.name === STATIC_LINKED_PLUGIN))
|
|
}
|
|
|
|
/**
|
|
* Build a Client-only Node library during the Client pass.
|
|
* @param id - Package name used in tsdown diagnostics.
|
|
* @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
|
|
* @returns ENV-selected tsdown config for the Client build face.
|
|
*/
|
|
export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
|
|
const lib = clientLibraryConfig(id, libEntry)
|
|
return clientOnly([lib])
|
|
}
|
|
|
|
/**
|
|
* Select arbitrary package-local configs only during the Client pass.
|
|
* @param configs - Node-side configs emitted after Client tsc.
|
|
* @returns ENV-selected tsdown config for the Client build face.
|
|
*/
|
|
export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
|
|
return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
|
|
? [SKIP_WORKSPACE_BUILD]
|
|
: [...configs]
|
|
}
|
|
|
|
interface ClientBundleOptions {
|
|
/** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
|
|
readonly hostPhase?: boolean
|
|
/** Additional Node-side configs emitted alongside the package library. */
|
|
readonly companions?: readonly UserConfig[]
|
|
/** Overrides for the package's primary Node-side library config. */
|
|
readonly lib?: UserConfig
|
|
/** Optional legal or attribution text selected by emitted client filename. */
|
|
readonly clientBanner?: (fileName: string) => string | undefined
|
|
}
|
|
|
|
type BuildFace = 'host' | 'client' | undefined
|
|
|
|
type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
|
|
|
|
function buildFace(value: unknown): BuildFace {
|
|
if (value === undefined || value === 'host' || value === 'client') return value
|
|
throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
|
|
}
|
|
|
|
function clientLibraryConfig(
|
|
id: string,
|
|
libEntry: readonly string[],
|
|
overrides: UserConfig = {},
|
|
): UserConfig {
|
|
const isProductionDependency = (specifier: string): boolean =>
|
|
matchesSpecifier(productionExternals(id), specifier)
|
|
return {
|
|
name: id,
|
|
entry: [...libEntry],
|
|
outDir: 'lib',
|
|
format: ['esm'],
|
|
platform: 'node',
|
|
target: 'es2024',
|
|
fixedExtension: false,
|
|
dts: false,
|
|
clean: false,
|
|
deps: {
|
|
// The Node half runs from a real install: a production dependency is on
|
|
// disk there and stays an import, everything else inlines. Stating both
|
|
// halves takes the artifact off tsdown's getProductionDeps fallback, where
|
|
// moving a dependency between npm sections silently re-bundles it.
|
|
// Builtins keep tsdown's own handling (neither side claims them).
|
|
neverBundle: isProductionDependency,
|
|
alwaysBundle: (specifier: string) => !isBuiltin(specifier) && !isProductionDependency(specifier),
|
|
},
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** The slice of the rolldown plugin context the stylesheet plugin uses. */
|
|
interface AssetEmitter {
|
|
emitFile(file: {
|
|
type: 'asset'
|
|
fileName: string
|
|
source: Uint8Array
|
|
originalFileName: string
|
|
}): string
|
|
}
|
|
|
|
function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
|
|
const emitted = new Set<string>()
|
|
const isolation = clientInputIsolation(id)
|
|
return {
|
|
name: id,
|
|
entry: { [outputName]: entry },
|
|
outDir: 'lib',
|
|
format: ['esm'],
|
|
platform: 'browser',
|
|
target: 'es2024',
|
|
fixedExtension: false,
|
|
dts: false,
|
|
clean: false,
|
|
// The shell compiles this artifact, so its map is the only path from a
|
|
// browser stack frame back to the TSX (tsc emits the lib/types half).
|
|
sourcemap: true,
|
|
outputOptions: {
|
|
sourcemapExcludeSources: false,
|
|
sourcemapPathTransform: isolation.sourcePath,
|
|
},
|
|
plugins: [{
|
|
// Contract 1. `pre` because tsdown's own deps plugin would otherwise
|
|
// resolve and inline every specifier missing from the npm production
|
|
// sections, which is the coupling this preset exists to remove. The name
|
|
// is also the roster marker {@link isStaticLinkedConfig} reads.
|
|
name: STATIC_LINKED_PLUGIN,
|
|
resolveId: {
|
|
order: 'pre' as const,
|
|
handler(source: string, importer: string | undefined) {
|
|
// An entry arrives without an importer and must stay internal.
|
|
if (importer === undefined) return null
|
|
return isBareSpecifier(source) ? { id: source, external: true } : null
|
|
},
|
|
},
|
|
}, tscSourceMapPlugin(), isolation.plugin, {
|
|
// Contract 4. The import survives verbatim and the sheet lands beside the
|
|
// JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
|
|
name: 'dsh-css-asset',
|
|
async resolveId(this: AssetEmitter, source: string, importer: string | undefined) {
|
|
if (!source.endsWith('.css') || importer === undefined) return null
|
|
const { file, fileName } = stylesheetAsset(source, importer)
|
|
if (!emitted.has(fileName)) {
|
|
emitted.add(fileName)
|
|
// originalFileName also puts the physical sheet in the watch graph.
|
|
this.emitFile({ type: 'asset', fileName, source: await readFile(file), originalFileName: file })
|
|
}
|
|
// Every emitted chunk sits at the lib/ root, so the src-relative name
|
|
// is what resolves from there. Rolldown keeps relative externals as
|
|
// written instead of re-normalizing them.
|
|
return { id: `./${fileName}`, external: true }
|
|
},
|
|
}],
|
|
}
|
|
}
|
|
|
|
/** Whether a specifier names a package rather than a file next to its importer. */
|
|
function isBareSpecifier(specifier: string): boolean {
|
|
return !specifier.startsWith('.') && !specifier.startsWith('\0') && !isAbsolute(specifier)
|
|
}
|
|
|
|
/**
|
|
* Locate a stylesheet import against the package sources and name its emitted position.
|
|
* @param source - relative import specifier as written in the source.
|
|
* @param importer - absolute path of the importing module, emitted or source.
|
|
* @returns the stylesheet on disk plus its `src`-relative name under `lib/`.
|
|
*/
|
|
function stylesheetAsset(source: string, importer: string): { readonly file: string, readonly fileName: string } {
|
|
const file = sourceAssetPath(source, importer)
|
|
const boundary = file.lastIndexOf(SOURCE_MARKER)
|
|
if (boundary < 0) throw new Error(`tsdown: stylesheet ${file} is outside the package sources`)
|
|
return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') }
|
|
}
|
|
|
|
/** The manifest fields the build faces read to state their own module edges. */
|
|
interface WorkspaceManifest {
|
|
readonly name?: string
|
|
/** Sections a real install materializes on disk next to the built package. */
|
|
readonly dependencies?: Record<string, string>
|
|
readonly peerDependencies?: Record<string, string>
|
|
readonly optionalDependencies?: Record<string, string>
|
|
readonly dsh?: { readonly client?: { readonly external?: unknown } }
|
|
}
|
|
|
|
const manifestCache = new Map<string, WorkspaceManifest>()
|
|
const productionExternalCache = new Map<string, readonly RegExp[]>()
|
|
const clientExternalCache = new Map<string, ReadonlySet<string>>()
|
|
|
|
/**
|
|
* Read one workspace package's manifest. Located by package name rather than by
|
|
* cwd, because tsdown evaluates every package config with the repository root as
|
|
* `process.cwd()` during a workspace build. Callers read it on the first
|
|
* resolveId of a build, not while a config is built, so selecting a build face
|
|
* never touches a manifest.
|
|
* @param id - package name, as spelled at the preset call site.
|
|
* @returns the parsed manifest.
|
|
* @throws {Error} when no workspace package declares that name.
|
|
*/
|
|
function workspaceManifest(id: string): WorkspaceManifest {
|
|
const cached = manifestCache.get(id)
|
|
if (cached !== undefined) return cached
|
|
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) {
|
|
const manifest = JSON.parse(
|
|
readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'),
|
|
) as WorkspaceManifest
|
|
if (manifest.name !== id) continue
|
|
manifestCache.set(id, manifest)
|
|
return manifest
|
|
}
|
|
throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`)
|
|
}
|
|
|
|
/**
|
|
* External patterns for one package's Node half: its own production sections,
|
|
* subpaths included.
|
|
* @param id - package name, as spelled at the preset call site.
|
|
* @returns one `^name(/|$)` pattern per production dependency, name-sorted.
|
|
*/
|
|
function productionExternals(id: string): readonly RegExp[] {
|
|
const cached = productionExternalCache.get(id)
|
|
if (cached !== undefined) return cached
|
|
const manifest = workspaceManifest(id)
|
|
const names = new Set([
|
|
...Object.keys(manifest.dependencies ?? {}),
|
|
...Object.keys(manifest.peerDependencies ?? {}),
|
|
...Object.keys(manifest.optionalDependencies ?? {}),
|
|
])
|
|
const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`))
|
|
productionExternalCache.set(id, patterns)
|
|
return patterns
|
|
}
|
|
|
|
/**
|
|
* Module-table specifiers one `dsh.client` declaration requests. Matching is
|
|
* exact, never normalized: a package declares the specifier its own code
|
|
* imports, and the loader keys static entries the same way.
|
|
* @param subject - package name, used in diagnostics.
|
|
* @param declaration - the package's `dsh.client` object.
|
|
* @returns the requested specifiers, empty when the package declares none.
|
|
* @throws {Error} when `external` is not a string array.
|
|
*/
|
|
export function requestedExternals(
|
|
subject: string,
|
|
declaration: { readonly external?: unknown },
|
|
): ReadonlySet<string> {
|
|
return new Set(optionalStringArray(subject, 'dsh.client.external', declaration.external) ?? [])
|
|
}
|
|
|
|
/**
|
|
* Module-table specifiers one package requests. The shell baseline is implicit
|
|
* for every dynamic bundle; `dsh.client.external` only adds package-specific
|
|
* dynamic rows or subpaths.
|
|
* @param id - package name, as spelled at the preset call site.
|
|
* @returns the baseline plus the package's explicit requests.
|
|
*/
|
|
function clientExternals(id: string): ReadonlySet<string> {
|
|
const cached = clientExternalCache.get(id)
|
|
if (cached !== undefined) return cached
|
|
const externals = new Set([
|
|
...PLATFORM_MODULES,
|
|
...PRELOADED_CLIENT_EXTERNALS,
|
|
...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}),
|
|
])
|
|
clientExternalCache.set(id, externals)
|
|
return externals
|
|
}
|
|
|
|
/** Escape a package name for literal use inside a RegExp source. */
|
|
function escapeSpecifier(name: string): string {
|
|
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
/** Whether an import specifier is the package a pattern names, or one of its subpaths. */
|
|
function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boolean {
|
|
return patterns.some(pattern => pattern.test(specifier))
|
|
}
|
|
|
|
/** Render package-local dynamic imports through the Client module loader's asynchronous operation. */
|
|
function asyncChunkRequirePlugin(): TsdownPlugin {
|
|
return {
|
|
name: 'dsh-client-async-chunk-require',
|
|
renderChunk(code, chunk, outputOptions) {
|
|
if (outputOptions.format !== 'cjs') return null
|
|
const transformed = new Rolldown.RolldownMagicString(code)
|
|
for (const dynamicImport of chunk.dynamicImports) {
|
|
const fileName = dynamicImport.startsWith('./') ? dynamicImport.slice(2) : dynamicImport
|
|
if (!/^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/.test(fileName)) continue
|
|
const specifier = `./${fileName}`
|
|
const call = new RegExp(
|
|
`Promise\\.resolve\\(\\)\\.then\\(\\(\\)\\s*=>\\s*require\\((['"])${escapeSpecifier(specifier)}\\1\\)\\)`,
|
|
'gu',
|
|
)
|
|
const matches = [...code.matchAll(call)]
|
|
if (matches.length === 0) {
|
|
throw new Error(`client bundle compiler: dynamic chunk ${JSON.stringify(specifier)} has no generated import expression`)
|
|
}
|
|
for (const match of matches) {
|
|
transformed.overwrite(match.index, match.index + match[0].length, `require.async(${JSON.stringify(specifier)})`)
|
|
}
|
|
}
|
|
return transformed.hasChanged() ? transformed : null
|
|
},
|
|
async writeBundle(outputOptions, bundle) {
|
|
const entry = Object.values(bundle).find(output => output.type === 'chunk' && output.isEntry)
|
|
if (entry === undefined || outputOptions.dir === undefined) return
|
|
const entryPath = resolvePath(outputOptions.dir, entry.fileName)
|
|
const current = await stat(entryPath)
|
|
const completedAt = new Date(Math.max(Date.now(), current.mtimeMs + 1))
|
|
await utimes(entryPath, current.atime, completedAt)
|
|
},
|
|
}
|
|
}
|
|
|
|
function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig {
|
|
const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
|
|
const isolation = clientInputIsolation(id)
|
|
return {
|
|
name: `${id}/client`,
|
|
entry: { client: entry },
|
|
// Browser bundle lands next to the node half (single lib/ artifact dir;
|
|
// the entryFileNames pin keeps it exactly lib/client.js). clean must stay
|
|
// off — a default clean would wipe the node-half output emitted above.
|
|
outDir: 'lib',
|
|
format: 'cjs',
|
|
platform: 'browser',
|
|
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
|
|
dts: false,
|
|
// Plugin code is fetched outside Vite's module graph, so its own bundle
|
|
// must carry the TS/TSX mapping consumed by browser profiling tools.
|
|
sourcemap: true,
|
|
clean: false,
|
|
deps: {
|
|
neverBundle: isRequested,
|
|
// Anything NOT requested from the loader module table must inline
|
|
// (wire/type layers, zod, clsx — every non-shared dep). A require() the
|
|
// table cannot answer is a guaranteed runtime throw, so the rule is the
|
|
// package's own request list: requested specifiers stay imports,
|
|
// everything else is bundled.
|
|
alwaysBundle: (specifier: string) => !isRequested(specifier),
|
|
},
|
|
// Dual-mode libraries (lexical's exports carry development/production/
|
|
// node conditions; the node file picks its flavor with a top-level await
|
|
// a CJS bundle cannot carry) resolve their static flavor matching the
|
|
// NODE_ENV the defines below bake in.
|
|
inputOptions: {
|
|
resolve: {
|
|
conditionNames: [
|
|
(process.env.NODE_ENV ?? 'production') === 'development' ? 'development' : 'production',
|
|
'browser', 'import', 'module', 'default',
|
|
],
|
|
},
|
|
},
|
|
// Browser bundles inline node-idiom deps (zustand/immer read
|
|
// process.env.NODE_ENV; zustand's esm build also probes
|
|
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
|
|
// EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
|
|
// needs the substitutions here or the factory throws ReferenceError at
|
|
// boot / the build gate reds. Both keys honor the build's NODE_ENV so a
|
|
// dev build keeps the dev-branch semantics; artifacts default to production.
|
|
// The bare `import.meta.env` key is required alongside the precise MODE
|
|
// key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
|
|
// the truthiness probe would otherwise survive as an empty import.meta.
|
|
define: {
|
|
...clientBuildEnvironmentDefines(process.env),
|
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
|
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
|
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
|
},
|
|
plugins: [{
|
|
// Bundle purity gate (build-time mirror of the module-edge rules): the
|
|
// baseline and package-specific requests stay external, inline-safe wire layers
|
|
// inline, and every other @deepseek-ai value import is a build error — a
|
|
// cross-plugin value import either inlines a duplicate runtime instance
|
|
// or requires a specifier the module table cannot answer for this package.
|
|
// Cross-plugin collaboration goes through cordis services instead.
|
|
name: 'dsh-client-bundle-purity',
|
|
resolveId(source: string) {
|
|
if (!source.startsWith('@deepseek-ai/')) return null
|
|
if (isRequested(source)) return null // requested module-table row: external wins
|
|
if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
|
|
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
|
|
throw new Error(
|
|
`client bundle purity: "${source}" is not in the default client externals or ${id}'s dsh.client.external, an inline-safe wire layer, or a generated /remote contribution — `
|
|
+ 'cross-plugin value imports are forbidden; declare a non-default module request or collaborate through cordis services '
|
|
+ '(type-only imports are erased and never reach this gate)',
|
|
)
|
|
},
|
|
}, tscSourceMapPlugin(), asyncChunkRequirePlugin(), isolation.plugin, {
|
|
name: 'dsh-css-modules-inline',
|
|
resolveId(source: string, importer: string | undefined) {
|
|
if (!source.endsWith('.module.css')) return null
|
|
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
|
|
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
|
},
|
|
async load(virtualId: string) {
|
|
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
|
|
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
|
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
|
|
this.addWatchFile(fileId)
|
|
const source = await readFile(fileId)
|
|
const { code, exports: cssExports } = transform({
|
|
filename: fileId,
|
|
code: source,
|
|
cssModules: { pattern: '[hash]_[local]' },
|
|
minify: true,
|
|
})
|
|
const classMap: Record<string, string> = {}
|
|
const exportEntries = Object.entries(cssExports ?? {})
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
for (const [local, exp] of exportEntries) classMap[local] = exp.name
|
|
return styleInjectionModule(id, fileId, code.toString(), classMap)
|
|
},
|
|
}, {
|
|
name: 'dsh-css-text-inline',
|
|
resolveId(source: string, importer: string | undefined) {
|
|
if (!source.endsWith(`.css${INLINE_CSS_QUERY}`)) return null
|
|
const stylesheet = source.slice(0, -INLINE_CSS_QUERY.length)
|
|
const abs = importer !== undefined ? sourceAssetPath(stylesheet, importer) : stylesheet
|
|
return INLINE_CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
|
},
|
|
async load(virtualId: string) {
|
|
if (!virtualId.startsWith(INLINE_CSS_VIRTUAL_PREFIX)) return null
|
|
const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
|
this.addWatchFile(fileId)
|
|
const source = await readFile(fileId)
|
|
const { code } = transform({ filename: fileId, code: source, minify: true })
|
|
return `export default ${JSON.stringify(code.toString())};`
|
|
},
|
|
}, {
|
|
name: 'dsh-css-global-inline',
|
|
resolveId(source: string, importer: string | undefined) {
|
|
if (!source.endsWith('.css') || source.endsWith('.module.css')) return null
|
|
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
|
|
return GLOBAL_CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
|
|
},
|
|
async load(virtualId: string) {
|
|
if (!virtualId.startsWith(GLOBAL_CSS_VIRTUAL_PREFIX)) return null
|
|
const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
|
|
this.addWatchFile(fileId)
|
|
const source = await readFile(fileId)
|
|
const { code } = transform({ filename: fileId, code: source, minify: true })
|
|
return styleInjectionModule(id, fileId, code.toString())
|
|
},
|
|
}],
|
|
outputOptions: {
|
|
entryFileNames: 'client.js',
|
|
// The imported source basename becomes the published chunk name; package
|
|
// files lists and artifact tests pin every intentional chunk.
|
|
chunkFileNames: 'client.[name].js',
|
|
sourcemapExcludeSources: false,
|
|
// The map is served from /plugins/<scoped-package>/client.js.map. The
|
|
// browser resolves its local sources back into URLs that mirror the
|
|
// /packages/<group>/<package>/src directories; sourcesContent keeps them usable
|
|
// without exposing that tree as an HTTP route.
|
|
sourcemapPathTransform(source, mapPath) {
|
|
isolation.sourcePath(source, mapPath)
|
|
return browserSourcePath(source, mapPath)
|
|
},
|
|
banner: (chunk) => {
|
|
const registration = `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`
|
|
const prefix = clientBanner?.(chunk.fileName)
|
|
return prefix === undefined ? registration : `${prefix}\n${registration}`
|
|
},
|
|
footer: 'return module.exports; } });',
|
|
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Check browser bundle inputs before their original package identity is folded into an artifact. */
|
|
function clientInputIsolation(id: string): {
|
|
plugin: TsdownPlugin
|
|
sourcePath: (source: string, mapPath: string) => string
|
|
} {
|
|
const experimental = id.startsWith('@deepseek-ai/dsh-experimental-')
|
|
const inputs = new BundleInputIsolation(REPOSITORY_ROOT, `client bundle isolation (${id})`)
|
|
return {
|
|
plugin: {
|
|
name: 'dsh-client-input-isolation',
|
|
buildStart() { inputs.reset() },
|
|
generateBundle(_options, bundle) {
|
|
if (experimental) return
|
|
for (const output of Object.values(bundle)) {
|
|
if (output.type === 'chunk') {
|
|
for (const module of Object.keys(output.modules)) {
|
|
inputs.assertInput(clientInputFile(module))
|
|
const info = this.getModuleInfo(module)
|
|
if (info === null) {
|
|
// Rolldown's runtime helper is compiler-generated and has no source module record.
|
|
if (module === '\0rolldown/runtime.js') continue
|
|
throw new Error(`client bundle isolation (${id}): module ${module} has no bundler module record`)
|
|
}
|
|
for (const dependency of [...info.importedIds, ...info.dynamicallyImportedIds]) {
|
|
inputs.assertInput(clientInputFile(dependency))
|
|
}
|
|
}
|
|
for (const external of [...output.imports, ...output.dynamicImports]) {
|
|
if (!(external in bundle)) inputs.assertInput(external)
|
|
}
|
|
} else {
|
|
for (const original of output.originalFileNames) inputs.assertInput(original)
|
|
}
|
|
}
|
|
},
|
|
},
|
|
sourcePath(source, mapPath) {
|
|
if (!experimental) {
|
|
const decoded = clientInputFile(source)
|
|
const file = physicalBundleInput(decoded) ?? resolvePath(dirname(mapPath), decoded)
|
|
inputs.assertSourceMapInput(file)
|
|
}
|
|
return source
|
|
},
|
|
}
|
|
}
|
|
|
|
/** CSS loader ids append a JavaScript suffix to the physical stylesheet path. */
|
|
function clientInputFile(id: string): string {
|
|
const prefix = [CSS_VIRTUAL_PREFIX, GLOBAL_CSS_VIRTUAL_PREFIX, INLINE_CSS_VIRTUAL_PREFIX]
|
|
.find(prefix => id.startsWith(prefix))
|
|
return prefix === undefined ? id : id.slice(prefix.length, -CSS_VIRTUAL_SUFFIX.length)
|
|
}
|
|
|
|
/** Chain tsc's emitted maps into any Client bundle that consumes `lib/types`. */
|
|
function tscSourceMapPlugin() {
|
|
return {
|
|
name: 'dsh-tsc-sourcemap',
|
|
async load(id: string) {
|
|
if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
|
|
const code = await readFile(id, 'utf8')
|
|
const mapPath = `${id}.map`
|
|
const map = JSON.parse(await readFile(mapPath, 'utf8')) as {
|
|
sourceRoot?: unknown
|
|
sources?: unknown
|
|
sourcesContent?: unknown
|
|
[key: string]: unknown
|
|
}
|
|
if (!Array.isArray(map.sources) || map.sources.some(source => typeof source !== 'string')) {
|
|
throw new Error(`client sourcemap: ${mapPath} has invalid sources`)
|
|
}
|
|
const sources = map.sources as string[]
|
|
if (
|
|
!Array.isArray(map.sourcesContent)
|
|
|| map.sourcesContent.length !== sources.length
|
|
|| map.sourcesContent.some(source => typeof source !== 'string')
|
|
) {
|
|
const sourceRoot = typeof map.sourceRoot === 'string' ? map.sourceRoot : ''
|
|
map.sourcesContent = await Promise.all(sources.map(async source =>
|
|
await readFile(resolvePath(dirname(mapPath), sourceRoot, source), 'utf8')))
|
|
}
|
|
return { code: code.replace(SOURCEMAP_COMMENT, ''), map }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Path segment separating a package's tsc output from the sources it was emitted from. */
|
|
const TYPES_MARKER = `${sep}lib${sep}types${sep}`
|
|
|
|
/** Plugin name carrying contract 1, and the marker that identifies a statically linked config. */
|
|
const STATIC_LINKED_PLUGIN = 'dsh-static-linked-external'
|
|
|
|
/** Path segment a package's sources hang under, and the root emitted assets mirror. */
|
|
const SOURCE_MARKER = `${sep}src${sep}`
|
|
|
|
/** Trailing sourcemap reference tsc appends to every emitted module. */
|
|
const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/
|
|
|
|
/** Resolve an emitted JS asset import against its source-tree counterpart. */
|
|
function sourceAssetPath(source: string, importer: string): string {
|
|
if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source)
|
|
const emitted = resolvePath(dirname(importer), source)
|
|
if (existsSync(emitted)) return emitted
|
|
const boundary = emitted.indexOf(TYPES_MARKER)
|
|
if (boundary < 0) return emitted
|
|
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + TYPES_MARKER.length))
|
|
}
|