tsdown.client.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  1. /**
  2. * Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
  3. * artifact: the bundle calls window.__ModuleLoader__.load({id, factory})
  4. * and resolves externals through the injected require (loader module table —
  5. * cordis DI entities, no globals, no import map). CSS is compiled by
  6. * lightningcss inside the bundle: `x.module.css` yields its hashed class map
  7. * and injects a tagged style at factory execution, while `x.css?inline`
  8. * exports compiled text for a plugin-owned lifecycle effect. The virtual
  9. * loaders register each real stylesheet as a watch dependency.
  10. * Non-experimental client outputs reject experimental module and stylesheet
  11. * inputs, including origins recorded by chained source maps.
  12. */
  13. import { readFile, stat, utimes } from 'node:fs/promises'
  14. import { existsSync, globSync, readFileSync } from 'node:fs'
  15. import { createRequire, isBuiltin } from 'node:module'
  16. import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
  17. import { fileURLToPath } from 'node:url'
  18. import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
  19. import { transform } from 'lightningcss'
  20. import { optionalStringArray } from './modules/src/client/manifest.ts'
  21. import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
  22. import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
  23. import { BundleInputIsolation, physicalBundleInput } from '../../scripts/bundle-input-isolation.ts'
  24. /**
  25. * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
  26. * (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
  27. * ending in `.css`, so the virtual id must not.
  28. */
  29. const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
  30. const GLOBAL_CSS_VIRTUAL_PREFIX = '\0dsh-global-css:'
  31. const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
  32. const CSS_VIRTUAL_SUFFIX = '.mjs'
  33. const INLINE_CSS_QUERY = '?inline'
  34. /** Emit one plugin-owned style injector and an optional CSS Modules export. */
  35. function styleInjectionModule(
  36. id: string,
  37. fileId: string,
  38. css: string,
  39. classMap?: Readonly<Record<string, string>>,
  40. ): string {
  41. const source = [
  42. `const css = ${JSON.stringify(css)};`,
  43. `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
  44. 'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
  45. ' const tag = document.createElement(\'style\');',
  46. ` tag.dataset.plugin = ${JSON.stringify(id)};`,
  47. ' tag.dataset.pluginCss = tagId;',
  48. ' tag.textContent = css;',
  49. ' document.head.appendChild(tag);',
  50. '}',
  51. ]
  52. source.push(classMap === undefined ? 'export {};' : `export default ${JSON.stringify(classMap)};`)
  53. return source.join('\n')
  54. }
  55. /**
  56. * Contract layers and pure folds a client bundle may inline: browser-safe
  57. * values with no runtime identity to share (no Symbol/instanceof/singleton state).
  58. * Everything else under @deepseek-ai/* is either a module-table entry
  59. * (external) or a leak the purity gate rejects.
  60. */
  61. 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-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/
  62. /**
  63. * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
  64. * would read them as plugin packages. They carry no cross-plugin runtime
  65. * identity to share — the framework itself is a requested module-table row
  66. * (external), while these are ordinary libraries a browser bundle inlines.
  67. */
  68. const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
  69. /** Generated descriptor/codec contribution with no shared runtime identity. */
  70. const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
  71. /**
  72. * Workspace mode replaces an empty config array with the root defaults. A
  73. * falsey entry instead removes this package before entry resolution.
  74. */
  75. const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
  76. const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
  77. /** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
  78. function browserSourcePath(source: string, sourcemapPath: string): string {
  79. if (!source.startsWith('.')) return source
  80. const physicalSource = resolvePath(dirname(sourcemapPath), source)
  81. const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
  82. return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
  83. }
  84. /**
  85. * Build the tsdown config for one UI plugin package: the node-half lib build
  86. * plus the browser client bundle. Client packages emit both halves during the
  87. * Client pass by default; packages needed for Host reflection may opt into the
  88. * earlier Host pass. A package-level tsdown.config.ts REPLACES the root
  89. * workspace layout, so the lib half must be restated here — dropping it leaves
  90. * the package without lib/index.js and the host Loader cannot import its node
  91. * half. The Client build consumes `lib/types` and chains those tsc maps, with
  92. * original source content, into the standalone plugin map.
  93. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load
  94. * handoff and onto the injected style tags.
  95. * @param libEntry - node-half entries, spelled at the call site so the
  96. * package-invariants gate can see `lib/types/invariant.js` in each package's
  97. * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
  98. * @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner.
  99. * @returns ENV-selected tsdown config for the current build face.
  100. */
  101. export function clientBundle(
  102. id: string,
  103. libEntry: readonly string[],
  104. options: ClientBundleOptions = {},
  105. ): BuildFaceConfig {
  106. const lib = clientLibraryConfig(id, libEntry, options.lib)
  107. return ({ env }) => {
  108. const face = buildFace(env?.DSH_BUILD_FACE)
  109. const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
  110. const client = clientConfig(id, clientEntry, options.clientBanner)
  111. const node = [lib, ...(options.companions ?? [])]
  112. if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
  113. if (face === 'client') {
  114. return options.hostPhase === true ? [client] : [...node, client]
  115. }
  116. return [...node, client]
  117. }
  118. }
  119. /**
  120. * Build the tsdown config for a client library the compile shell links
  121. * statically (the static assembly channel: `apps/web` resolves the package
  122. * name, bundles the artifact, and owns the chunk layout and the CSS pipeline).
  123. *
  124. * Calling this preset is what puts a package in the static assembly channel,
  125. * so the call sites are the roster: gates read it through
  126. * {@link isStaticLinkedConfig} rather than a second hand-kept list. A package on
  127. * this roster must not be a module-table row as well — the browser would take
  128. * the statically linked copy and a provider's bytes would sit unused in its
  129. * bundle.
  130. *
  131. * Four artifact contracts:
  132. * 1. every bare specifier stays an import. The shell attributes chunk bytes by
  133. * `node_modules/<pkg>`, so a dependency inlined into a workspace file is
  134. * attributed to no npm package and its bytes fall into the index chunk,
  135. * which collapses the vendor/index cache split.
  136. * 2. `esm` on `platform: 'browser'` — the shell is the only consumer.
  137. * 3. sourcemaps, chained through the tsc maps under `lib/types` to the sources.
  138. * 4. stylesheets ship with the package: a relative `.css` import survives as a
  139. * relative external and the sheet is emitted under `lib/` at its
  140. * `src`-relative path, so vite stays the only owner of class hashing.
  141. * @param id - package name, used in tsdown diagnostics.
  142. * @param libEntry - emitted JavaScript entries consumed from `lib/types`, one
  143. * bundle each: a multi-entry build would emit a hash-named shared chunk that
  144. * the exact `files` list cannot publish.
  145. * @returns ENV-selected tsdown config for the Client build face.
  146. */
  147. export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig {
  148. // Each entry names its own output file, so two entries with the same basename
  149. // would overwrite one artifact instead of emitting two.
  150. const names = new Set(libEntry.map(entry => basename(entry, '.js')))
  151. if (names.size !== libEntry.length) {
  152. throw new Error(`tsdown: ${id} entries collide on an output name: ${libEntry.join(', ')}`)
  153. }
  154. return clientOnly(libEntry.map(entry => staticLinkedConfig(id, entry)))
  155. }
  156. /**
  157. * Whether a package's tsdown configs put it in the static assembly channel.
  158. * The roster has no separate list: gates load each package's own
  159. * `tsdown.config.ts`, call it for the Client face, and ask this.
  160. * @param configs - configs a package's build-face function returned.
  161. * @returns true when at least one config was built by {@link staticLinked}.
  162. */
  163. export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean {
  164. return configs.some(config => (config.plugins as readonly { name?: string }[] | undefined ?? [])
  165. .some(plugin => plugin.name === STATIC_LINKED_PLUGIN))
  166. }
  167. /**
  168. * Build a Client-only Node library during the Client pass.
  169. * @param id - Package name used in tsdown diagnostics.
  170. * @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
  171. * @returns ENV-selected tsdown config for the Client build face.
  172. */
  173. export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
  174. const lib = clientLibraryConfig(id, libEntry)
  175. return clientOnly([lib])
  176. }
  177. /**
  178. * Select arbitrary package-local configs only during the Client pass.
  179. * @param configs - Node-side configs emitted after Client tsc.
  180. * @returns ENV-selected tsdown config for the Client build face.
  181. */
  182. export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
  183. return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
  184. ? [SKIP_WORKSPACE_BUILD]
  185. : [...configs]
  186. }
  187. interface ClientBundleOptions {
  188. /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
  189. readonly hostPhase?: boolean
  190. /** Additional Node-side configs emitted alongside the package library. */
  191. readonly companions?: readonly UserConfig[]
  192. /** Overrides for the package's primary Node-side library config. */
  193. readonly lib?: UserConfig
  194. /** Optional legal or attribution text selected by emitted client filename. */
  195. readonly clientBanner?: (fileName: string) => string | undefined
  196. }
  197. type BuildFace = 'host' | 'client' | undefined
  198. type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
  199. function buildFace(value: unknown): BuildFace {
  200. if (value === undefined || value === 'host' || value === 'client') return value
  201. throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
  202. }
  203. function clientLibraryConfig(
  204. id: string,
  205. libEntry: readonly string[],
  206. overrides: UserConfig = {},
  207. ): UserConfig {
  208. const isProductionDependency = (specifier: string): boolean =>
  209. matchesSpecifier(productionExternals(id), specifier)
  210. return {
  211. name: id,
  212. entry: [...libEntry],
  213. outDir: 'lib',
  214. format: ['esm'],
  215. platform: 'node',
  216. target: 'es2024',
  217. fixedExtension: false,
  218. dts: false,
  219. clean: false,
  220. deps: {
  221. // The Node half runs from a real install: a production dependency is on
  222. // disk there and stays an import, everything else inlines. Stating both
  223. // halves takes the artifact off tsdown's getProductionDeps fallback, where
  224. // moving a dependency between npm sections silently re-bundles it.
  225. // Builtins keep tsdown's own handling (neither side claims them).
  226. neverBundle: isProductionDependency,
  227. alwaysBundle: (specifier: string) => !isBuiltin(specifier) && !isProductionDependency(specifier),
  228. },
  229. ...overrides,
  230. }
  231. }
  232. /** The slice of the rolldown plugin context the stylesheet plugin uses. */
  233. interface AssetEmitter {
  234. emitFile(file: {
  235. type: 'asset'
  236. fileName: string
  237. source: Uint8Array
  238. originalFileName: string
  239. }): string
  240. }
  241. function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
  242. const emitted = new Set<string>()
  243. const isolation = clientInputIsolation(id)
  244. return {
  245. name: id,
  246. entry: { [outputName]: entry },
  247. outDir: 'lib',
  248. format: ['esm'],
  249. platform: 'browser',
  250. target: 'es2024',
  251. fixedExtension: false,
  252. dts: false,
  253. clean: false,
  254. // The shell compiles this artifact, so its map is the only path from a
  255. // browser stack frame back to the TSX (tsc emits the lib/types half).
  256. sourcemap: true,
  257. outputOptions: {
  258. sourcemapExcludeSources: false,
  259. sourcemapPathTransform: isolation.sourcePath,
  260. },
  261. plugins: [{
  262. // Contract 1. `pre` because tsdown's own deps plugin would otherwise
  263. // resolve and inline every specifier missing from the npm production
  264. // sections, which is the coupling this preset exists to remove. The name
  265. // is also the roster marker {@link isStaticLinkedConfig} reads.
  266. name: STATIC_LINKED_PLUGIN,
  267. resolveId: {
  268. order: 'pre' as const,
  269. handler(source: string, importer: string | undefined) {
  270. // An entry arrives without an importer and must stay internal.
  271. if (importer === undefined) return null
  272. return isBareSpecifier(source) ? { id: source, external: true } : null
  273. },
  274. },
  275. }, tscSourceMapPlugin(), isolation.plugin, {
  276. // Contract 4. The import survives verbatim and the sheet lands beside the
  277. // JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
  278. name: 'dsh-css-asset',
  279. async resolveId(this: AssetEmitter, source: string, importer: string | undefined) {
  280. if (!source.endsWith('.css') || importer === undefined) return null
  281. const { file, fileName } = stylesheetAsset(source, importer)
  282. if (!emitted.has(fileName)) {
  283. emitted.add(fileName)
  284. // originalFileName also puts the physical sheet in the watch graph.
  285. this.emitFile({ type: 'asset', fileName, source: await readFile(file), originalFileName: file })
  286. }
  287. // Every emitted chunk sits at the lib/ root, so the src-relative name
  288. // is what resolves from there. Rolldown keeps relative externals as
  289. // written instead of re-normalizing them.
  290. return { id: `./${fileName}`, external: true }
  291. },
  292. }],
  293. }
  294. }
  295. /** Whether a specifier names a package rather than a file next to its importer. */
  296. function isBareSpecifier(specifier: string): boolean {
  297. return !specifier.startsWith('.') && !specifier.startsWith('\0') && !isAbsolute(specifier)
  298. }
  299. /**
  300. * Locate a stylesheet import against the package sources and name its emitted position.
  301. * @param source - relative import specifier as written in the source.
  302. * @param importer - absolute path of the importing module, emitted or source.
  303. * @returns the stylesheet on disk plus its `src`-relative name under `lib/`.
  304. */
  305. function stylesheetAsset(source: string, importer: string): { readonly file: string, readonly fileName: string } {
  306. const file = sourceAssetPath(source, importer)
  307. const boundary = file.lastIndexOf(SOURCE_MARKER)
  308. if (boundary < 0) throw new Error(`tsdown: stylesheet ${file} is outside the package sources`)
  309. return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') }
  310. }
  311. /** The manifest fields the build faces read to state their own module edges. */
  312. interface WorkspaceManifest {
  313. readonly name?: string
  314. /** Sections a real install materializes on disk next to the built package. */
  315. readonly dependencies?: Record<string, string>
  316. readonly peerDependencies?: Record<string, string>
  317. readonly optionalDependencies?: Record<string, string>
  318. readonly dsh?: { readonly client?: { readonly external?: unknown } }
  319. }
  320. const manifestCache = new Map<string, WorkspaceManifest>()
  321. const productionExternalCache = new Map<string, readonly RegExp[]>()
  322. const clientExternalCache = new Map<string, ReadonlySet<string>>()
  323. /**
  324. * Read one workspace package's manifest. Located by package name rather than by
  325. * cwd, because tsdown evaluates every package config with the repository root as
  326. * `process.cwd()` during a workspace build. Callers read it on the first
  327. * resolveId of a build, not while a config is built, so selecting a build face
  328. * never touches a manifest.
  329. * @param id - package name, as spelled at the preset call site.
  330. * @returns the parsed manifest.
  331. * @throws {Error} when no workspace package declares that name.
  332. */
  333. function workspaceManifest(id: string): WorkspaceManifest {
  334. const cached = manifestCache.get(id)
  335. if (cached !== undefined) return cached
  336. for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) {
  337. const manifest = JSON.parse(
  338. readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'),
  339. ) as WorkspaceManifest
  340. if (manifest.name !== id) continue
  341. manifestCache.set(id, manifest)
  342. return manifest
  343. }
  344. throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`)
  345. }
  346. /**
  347. * External patterns for one package's Node half: its own production sections,
  348. * subpaths included.
  349. * @param id - package name, as spelled at the preset call site.
  350. * @returns one `^name(/|$)` pattern per production dependency, name-sorted.
  351. */
  352. function productionExternals(id: string): readonly RegExp[] {
  353. const cached = productionExternalCache.get(id)
  354. if (cached !== undefined) return cached
  355. const manifest = workspaceManifest(id)
  356. const names = new Set([
  357. ...Object.keys(manifest.dependencies ?? {}),
  358. ...Object.keys(manifest.peerDependencies ?? {}),
  359. ...Object.keys(manifest.optionalDependencies ?? {}),
  360. ])
  361. const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`))
  362. productionExternalCache.set(id, patterns)
  363. return patterns
  364. }
  365. /**
  366. * Module-table specifiers one `dsh.client` declaration requests. Matching is
  367. * exact, never normalized: a package declares the specifier its own code
  368. * imports, and the loader keys static entries the same way.
  369. * @param subject - package name, used in diagnostics.
  370. * @param declaration - the package's `dsh.client` object.
  371. * @returns the requested specifiers, empty when the package declares none.
  372. * @throws {Error} when `external` is not a string array.
  373. */
  374. export function requestedExternals(
  375. subject: string,
  376. declaration: { readonly external?: unknown },
  377. ): ReadonlySet<string> {
  378. return new Set(optionalStringArray(subject, 'dsh.client.external', declaration.external) ?? [])
  379. }
  380. /**
  381. * Module-table specifiers one package requests. The shell baseline is implicit
  382. * for every dynamic bundle; `dsh.client.external` only adds package-specific
  383. * dynamic rows or subpaths.
  384. * @param id - package name, as spelled at the preset call site.
  385. * @returns the baseline plus the package's explicit requests.
  386. */
  387. function clientExternals(id: string): ReadonlySet<string> {
  388. const cached = clientExternalCache.get(id)
  389. if (cached !== undefined) return cached
  390. const externals = new Set([
  391. ...PLATFORM_MODULES,
  392. ...PRELOADED_CLIENT_EXTERNALS,
  393. ...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}),
  394. ])
  395. clientExternalCache.set(id, externals)
  396. return externals
  397. }
  398. /** Escape a package name for literal use inside a RegExp source. */
  399. function escapeSpecifier(name: string): string {
  400. return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  401. }
  402. /** Whether an import specifier is the package a pattern names, or one of its subpaths. */
  403. function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boolean {
  404. return patterns.some(pattern => pattern.test(specifier))
  405. }
  406. /** Render package-local dynamic imports through the Client module loader's asynchronous operation. */
  407. function asyncChunkRequirePlugin(): TsdownPlugin {
  408. return {
  409. name: 'dsh-client-async-chunk-require',
  410. renderChunk(code, chunk, outputOptions) {
  411. if (outputOptions.format !== 'cjs') return null
  412. const transformed = new Rolldown.RolldownMagicString(code)
  413. for (const dynamicImport of chunk.dynamicImports) {
  414. const fileName = dynamicImport.startsWith('./') ? dynamicImport.slice(2) : dynamicImport
  415. if (!/^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/.test(fileName)) continue
  416. const specifier = `./${fileName}`
  417. const call = new RegExp(
  418. `Promise\\.resolve\\(\\)\\.then\\(\\(\\)\\s*=>\\s*require\\((['"])${escapeSpecifier(specifier)}\\1\\)\\)`,
  419. 'gu',
  420. )
  421. const matches = [...code.matchAll(call)]
  422. if (matches.length === 0) {
  423. throw new Error(`client bundle compiler: dynamic chunk ${JSON.stringify(specifier)} has no generated import expression`)
  424. }
  425. for (const match of matches) {
  426. transformed.overwrite(match.index, match.index + match[0].length, `require.async(${JSON.stringify(specifier)})`)
  427. }
  428. }
  429. return transformed.hasChanged() ? transformed : null
  430. },
  431. async writeBundle(outputOptions, bundle) {
  432. const entry = Object.values(bundle).find(output => output.type === 'chunk' && output.isEntry)
  433. if (entry === undefined || outputOptions.dir === undefined) return
  434. const entryPath = resolvePath(outputOptions.dir, entry.fileName)
  435. const current = await stat(entryPath)
  436. const completedAt = new Date(Math.max(Date.now(), current.mtimeMs + 1))
  437. await utimes(entryPath, current.atime, completedAt)
  438. },
  439. }
  440. }
  441. function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig {
  442. const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
  443. const isolation = clientInputIsolation(id)
  444. return {
  445. name: `${id}/client`,
  446. entry: { client: entry },
  447. // Browser bundle lands next to the node half (single lib/ artifact dir;
  448. // the entryFileNames pin keeps it exactly lib/client.js). clean must stay
  449. // off — a default clean would wipe the node-half output emitted above.
  450. outDir: 'lib',
  451. format: 'cjs',
  452. platform: 'browser',
  453. // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
  454. dts: false,
  455. // Plugin code is fetched outside Vite's module graph, so its own bundle
  456. // must carry the TS/TSX mapping consumed by browser profiling tools.
  457. sourcemap: true,
  458. clean: false,
  459. deps: {
  460. neverBundle: isRequested,
  461. // Anything NOT requested from the loader module table must inline
  462. // (wire/type layers, zod, clsx — every non-shared dep). A require() the
  463. // table cannot answer is a guaranteed runtime throw, so the rule is the
  464. // package's own request list: requested specifiers stay imports,
  465. // everything else is bundled.
  466. alwaysBundle: (specifier: string) => !isRequested(specifier),
  467. },
  468. // Dual-mode libraries (lexical's exports carry development/production/
  469. // node conditions; the node file picks its flavor with a top-level await
  470. // a CJS bundle cannot carry) resolve their static flavor matching the
  471. // NODE_ENV the defines below bake in.
  472. inputOptions: {
  473. resolve: {
  474. conditionNames: [
  475. (process.env.NODE_ENV ?? 'production') === 'development' ? 'development' : 'production',
  476. 'browser', 'import', 'module', 'default',
  477. ],
  478. },
  479. },
  480. // Browser bundles inline node-idiom deps (zustand/immer read
  481. // process.env.NODE_ENV; zustand's esm build also probes
  482. // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
  483. // EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
  484. // needs the substitutions here or the factory throws ReferenceError at
  485. // boot / the build gate reds. Both keys honor the build's NODE_ENV so a
  486. // dev build keeps the dev-branch semantics; artifacts default to production.
  487. // The bare `import.meta.env` key is required alongside the precise MODE
  488. // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
  489. // the truthiness probe would otherwise survive as an empty import.meta.
  490. define: {
  491. ...clientBuildEnvironmentDefines(process.env),
  492. 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  493. 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  494. 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
  495. },
  496. plugins: [{
  497. // Bundle purity gate (build-time mirror of the module-edge rules): the
  498. // baseline and package-specific requests stay external, inline-safe wire layers
  499. // inline, and every other @deepseek-ai value import is a build error — a
  500. // cross-plugin value import either inlines a duplicate runtime instance
  501. // or requires a specifier the module table cannot answer for this package.
  502. // Cross-plugin collaboration goes through cordis services instead.
  503. name: 'dsh-client-bundle-purity',
  504. resolveId(source: string) {
  505. if (!source.startsWith('@deepseek-ai/')) return null
  506. if (isRequested(source)) return null // requested module-table row: external wins
  507. if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
  508. if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
  509. throw new Error(
  510. `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 — `
  511. + 'cross-plugin value imports are forbidden; declare a non-default module request or collaborate through cordis services '
  512. + '(type-only imports are erased and never reach this gate)',
  513. )
  514. },
  515. }, tscSourceMapPlugin(), asyncChunkRequirePlugin(), isolation.plugin, {
  516. name: 'dsh-css-modules-inline',
  517. resolveId(source: string, importer: string | undefined) {
  518. if (!source.endsWith('.module.css')) return null
  519. const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
  520. return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
  521. },
  522. async load(virtualId: string) {
  523. if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
  524. const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
  525. // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
  526. this.addWatchFile(fileId)
  527. const source = await readFile(fileId)
  528. const { code, exports: cssExports } = transform({
  529. filename: fileId,
  530. code: source,
  531. cssModules: { pattern: '[hash]_[local]' },
  532. minify: true,
  533. })
  534. const classMap: Record<string, string> = {}
  535. const exportEntries = Object.entries(cssExports ?? {})
  536. .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
  537. for (const [local, exp] of exportEntries) classMap[local] = exp.name
  538. return styleInjectionModule(id, fileId, code.toString(), classMap)
  539. },
  540. }, {
  541. name: 'dsh-css-text-inline',
  542. resolveId(source: string, importer: string | undefined) {
  543. if (!source.endsWith(`.css${INLINE_CSS_QUERY}`)) return null
  544. const stylesheet = source.slice(0, -INLINE_CSS_QUERY.length)
  545. const abs = importer !== undefined ? sourceAssetPath(stylesheet, importer) : stylesheet
  546. return INLINE_CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
  547. },
  548. async load(virtualId: string) {
  549. if (!virtualId.startsWith(INLINE_CSS_VIRTUAL_PREFIX)) return null
  550. const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
  551. this.addWatchFile(fileId)
  552. const source = await readFile(fileId)
  553. const { code } = transform({ filename: fileId, code: source, minify: true })
  554. return `export default ${JSON.stringify(code.toString())};`
  555. },
  556. }, {
  557. name: 'dsh-css-global-inline',
  558. resolveId(source: string, importer: string | undefined) {
  559. if (!source.endsWith('.css') || source.endsWith('.module.css')) return null
  560. const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
  561. return GLOBAL_CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
  562. },
  563. async load(virtualId: string) {
  564. if (!virtualId.startsWith(GLOBAL_CSS_VIRTUAL_PREFIX)) return null
  565. const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
  566. this.addWatchFile(fileId)
  567. const source = await readFile(fileId)
  568. const { code } = transform({ filename: fileId, code: source, minify: true })
  569. return styleInjectionModule(id, fileId, code.toString())
  570. },
  571. }],
  572. outputOptions: {
  573. entryFileNames: 'client.js',
  574. // The imported source basename becomes the published chunk name; package
  575. // files lists and artifact tests pin every intentional chunk.
  576. chunkFileNames: 'client.[name].js',
  577. sourcemapExcludeSources: false,
  578. // The map is served from /plugins/<scoped-package>/client.js.map. The
  579. // browser resolves its local sources back into URLs that mirror the
  580. // /packages/<group>/<package>/src directories; sourcesContent keeps them usable
  581. // without exposing that tree as an HTTP route.
  582. sourcemapPathTransform(source, mapPath) {
  583. isolation.sourcePath(source, mapPath)
  584. return browserSourcePath(source, mapPath)
  585. },
  586. banner: (chunk) => {
  587. const registration = `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`
  588. const prefix = clientBanner?.(chunk.fileName)
  589. return prefix === undefined ? registration : `${prefix}\n${registration}`
  590. },
  591. footer: 'return module.exports; } });',
  592. intro: 'var module = { exports: {} }; var exports = module.exports;',
  593. },
  594. }
  595. }
  596. /** Check browser bundle inputs before their original package identity is folded into an artifact. */
  597. function clientInputIsolation(id: string): {
  598. plugin: TsdownPlugin
  599. sourcePath: (source: string, mapPath: string) => string
  600. } {
  601. const experimental = id.startsWith('@deepseek-ai/dsh-experimental-')
  602. const inputs = new BundleInputIsolation(REPOSITORY_ROOT, `client bundle isolation (${id})`)
  603. return {
  604. plugin: {
  605. name: 'dsh-client-input-isolation',
  606. buildStart() { inputs.reset() },
  607. generateBundle(_options, bundle) {
  608. if (experimental) return
  609. for (const output of Object.values(bundle)) {
  610. if (output.type === 'chunk') {
  611. for (const module of Object.keys(output.modules)) {
  612. inputs.assertInput(clientInputFile(module))
  613. const info = this.getModuleInfo(module)
  614. if (info === null) {
  615. // Rolldown's runtime helper is compiler-generated and has no source module record.
  616. if (module === '\0rolldown/runtime.js') continue
  617. throw new Error(`client bundle isolation (${id}): module ${module} has no bundler module record`)
  618. }
  619. for (const dependency of [...info.importedIds, ...info.dynamicallyImportedIds]) {
  620. inputs.assertInput(clientInputFile(dependency))
  621. }
  622. }
  623. for (const external of [...output.imports, ...output.dynamicImports]) {
  624. if (!(external in bundle)) inputs.assertInput(external)
  625. }
  626. } else {
  627. for (const original of output.originalFileNames) inputs.assertInput(original)
  628. }
  629. }
  630. },
  631. },
  632. sourcePath(source, mapPath) {
  633. if (!experimental) {
  634. const decoded = clientInputFile(source)
  635. const file = physicalBundleInput(decoded) ?? resolvePath(dirname(mapPath), decoded)
  636. inputs.assertSourceMapInput(file)
  637. }
  638. return source
  639. },
  640. }
  641. }
  642. /** CSS loader ids append a JavaScript suffix to the physical stylesheet path. */
  643. function clientInputFile(id: string): string {
  644. const prefix = [CSS_VIRTUAL_PREFIX, GLOBAL_CSS_VIRTUAL_PREFIX, INLINE_CSS_VIRTUAL_PREFIX]
  645. .find(prefix => id.startsWith(prefix))
  646. return prefix === undefined ? id : id.slice(prefix.length, -CSS_VIRTUAL_SUFFIX.length)
  647. }
  648. /** Chain tsc's emitted maps into any Client bundle that consumes `lib/types`. */
  649. function tscSourceMapPlugin() {
  650. return {
  651. name: 'dsh-tsc-sourcemap',
  652. async load(id: string) {
  653. if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
  654. const code = await readFile(id, 'utf8')
  655. const mapPath = `${id}.map`
  656. const map = JSON.parse(await readFile(mapPath, 'utf8')) as {
  657. sourceRoot?: unknown
  658. sources?: unknown
  659. sourcesContent?: unknown
  660. [key: string]: unknown
  661. }
  662. if (!Array.isArray(map.sources) || map.sources.some(source => typeof source !== 'string')) {
  663. throw new Error(`client sourcemap: ${mapPath} has invalid sources`)
  664. }
  665. const sources = map.sources as string[]
  666. if (
  667. !Array.isArray(map.sourcesContent)
  668. || map.sourcesContent.length !== sources.length
  669. || map.sourcesContent.some(source => typeof source !== 'string')
  670. ) {
  671. const sourceRoot = typeof map.sourceRoot === 'string' ? map.sourceRoot : ''
  672. map.sourcesContent = await Promise.all(sources.map(async source =>
  673. await readFile(resolvePath(dirname(mapPath), sourceRoot, source), 'utf8')))
  674. }
  675. return { code: code.replace(SOURCEMAP_COMMENT, ''), map }
  676. },
  677. }
  678. }
  679. /** Path segment separating a package's tsc output from the sources it was emitted from. */
  680. const TYPES_MARKER = `${sep}lib${sep}types${sep}`
  681. /** Plugin name carrying contract 1, and the marker that identifies a statically linked config. */
  682. const STATIC_LINKED_PLUGIN = 'dsh-static-linked-external'
  683. /** Path segment a package's sources hang under, and the root emitted assets mirror. */
  684. const SOURCE_MARKER = `${sep}src${sep}`
  685. /** Trailing sourcemap reference tsc appends to every emitted module. */
  686. const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/
  687. /** Resolve an emitted JS asset import against its source-tree counterpart. */
  688. function sourceAssetPath(source: string, importer: string): string {
  689. if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source)
  690. const emitted = resolvePath(dirname(importer), source)
  691. if (existsSync(emitted)) return emitted
  692. const boundary = emitted.indexOf(TYPES_MARKER)
  693. if (boundary < 0) return emitted
  694. return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + TYPES_MARKER.length))
  695. }