tsdown.client.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  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 Modules are compiled by
  6. * lightningcss inside the bundle: importing `x.module.css` yields the
  7. * hashed class map, and the css text auto-injects a <style data-plugin="<id>">
  8. * tag at factory execution (the loader removes plugin-owned tags on unload).
  9. * The virtual loader registers each real stylesheet as a watch dependency.
  10. */
  11. import { readFile } from 'node:fs/promises'
  12. import { existsSync } from 'node:fs'
  13. import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
  14. import { fileURLToPath } from 'node:url'
  15. import type { UserConfig } from 'tsdown'
  16. import { transform } from 'lightningcss'
  17. import { PLATFORM_MODULES } from './web/src/platform.ts'
  18. /**
  19. * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
  20. * (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
  21. * ending in `.css`, so the virtual id must not.
  22. */
  23. const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
  24. const CSS_VIRTUAL_SUFFIX = '.mjs'
  25. /**
  26. * Wire/type layers a client bundle may inline: browser-safe contracts
  27. * with no runtime identity to share (no Symbol/instanceof/singleton state).
  28. * Everything else under @deepseek-ai/* is either a module-table entry
  29. * (external) or a leak the purity gate rejects.
  30. */
  31. export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
  32. /**
  33. * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
  34. * would read them as plugin packages. They carry no cross-plugin runtime
  35. * identity to share — the framework itself is a platform module (external),
  36. * while these are ordinary libraries a browser bundle inlines.
  37. */
  38. const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
  39. /** Generated descriptor/codec contribution with no shared runtime identity. */
  40. const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
  41. /**
  42. * Workspace mode replaces an empty config array with the root defaults. A
  43. * falsey entry instead removes this package before entry resolution.
  44. */
  45. const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
  46. /**
  47. * Documented TEMPORARY exemption, not a platform module (hence not in
  48. * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
  49. * shallowEqual) lives in runtime pending its promotion-time rehoming, and
  50. * five importers (locale, ui-layout, ui-conversation ×3) ride this single
  51. * exemption. At runtime the lazy CJS table answers the require natively:
  52. * runtime is an immediately-tier row, its factory is registered before any
  53. * dependent bundle materializes. TODO(webload/store-rehome): remove with the
  54. * store-engine relocation follow-up.
  55. */
  56. const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
  57. /** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
  58. export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
  59. const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
  60. /** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
  61. function browserSourcePath(source: string, sourcemapPath: string): string {
  62. if (!source.startsWith('.')) return source
  63. const physicalSource = resolvePath(dirname(sourcemapPath), source)
  64. const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
  65. return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
  66. }
  67. /**
  68. * Build the tsdown config for one UI plugin package: the node-half lib build
  69. * plus the browser client bundle. Client packages emit both halves during the
  70. * Client pass by default; packages needed for Host reflection may opt into the
  71. * earlier Host pass. A package-level tsdown.config.ts REPLACES the root
  72. * workspace layout, so the lib half must be restated here — dropping it leaves
  73. * the package without lib/index.js and the host Loader cannot import its node
  74. * half.
  75. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load
  76. * handoff and onto the injected style tags.
  77. * @param libEntry - node-half entries, spelled at the call site so the
  78. * package-invariants gate can see `lib/types/invariant.js` in each package's
  79. * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
  80. * @param options - phase placement, lib overrides, and companion Node configs.
  81. * @returns ENV-selected tsdown config for the current build face.
  82. */
  83. export function clientBundle(
  84. id: string,
  85. libEntry: readonly string[],
  86. options: ClientBundleOptions = {},
  87. ): BuildFaceConfig {
  88. const lib = clientLibraryConfig(id, libEntry, options.lib)
  89. return ({ env }) => {
  90. const face = buildFace(env?.DSH_BUILD_FACE)
  91. const client = clientConfig(id, face === undefined
  92. ? 'src/client/index.ts'
  93. : 'lib/types/client/index.js')
  94. const node = [lib, ...(options.companions ?? [])]
  95. if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
  96. if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
  97. return [...node, client]
  98. }
  99. }
  100. /**
  101. * Build a Client-only Node library during the Client pass.
  102. * @param id - Package name used in tsdown diagnostics.
  103. * @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
  104. * @returns ENV-selected tsdown config for the Client build face.
  105. */
  106. export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
  107. const lib = clientLibraryConfig(id, libEntry)
  108. return clientOnly([lib])
  109. }
  110. /**
  111. * Select arbitrary package-local configs only during the Client pass.
  112. * @param configs - Node-side configs emitted after Client tsc.
  113. * @returns ENV-selected tsdown config for the Client build face.
  114. */
  115. export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
  116. return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
  117. ? [SKIP_WORKSPACE_BUILD]
  118. : [...configs]
  119. }
  120. interface ClientBundleOptions {
  121. /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
  122. readonly hostPhase?: boolean
  123. /** Additional Node-side configs emitted alongside the package library. */
  124. readonly companions?: readonly UserConfig[]
  125. /** Overrides for the package's primary Node-side library config. */
  126. readonly lib?: UserConfig
  127. }
  128. type BuildFace = 'host' | 'client' | undefined
  129. type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
  130. function buildFace(value: unknown): BuildFace {
  131. if (value === undefined || value === 'host' || value === 'client') return value
  132. throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
  133. }
  134. function clientLibraryConfig(
  135. id: string,
  136. libEntry: readonly string[],
  137. overrides: UserConfig = {},
  138. ): UserConfig {
  139. return {
  140. name: id,
  141. entry: [...libEntry],
  142. outDir: 'lib',
  143. format: ['esm'],
  144. platform: 'node',
  145. target: 'es2024',
  146. fixedExtension: false,
  147. dts: false,
  148. clean: false,
  149. ...overrides,
  150. }
  151. }
  152. function clientConfig(id: string, entry: string): UserConfig {
  153. return {
  154. name: `${id}/client`,
  155. entry: { client: entry },
  156. // Browser bundle lands next to the node half (single lib/ artifact dir;
  157. // the entryFileNames pin keeps it exactly lib/client.js). clean must stay
  158. // off — a default clean would wipe the node-half output emitted above.
  159. outDir: 'lib',
  160. format: 'cjs',
  161. platform: 'browser',
  162. // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
  163. dts: false,
  164. // Plugin code is fetched outside Vite's module graph, so its own bundle
  165. // must carry the TS/TSX mapping consumed by browser profiling tools.
  166. sourcemap: true,
  167. clean: false,
  168. external: [...CLIENT_EXTERNALS],
  169. // Browser bundles inline node-idiom deps (zustand/immer read
  170. // process.env.NODE_ENV; zustand's esm build also probes
  171. // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
  172. // EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
  173. // needs the substitutions here or the factory throws ReferenceError at
  174. // boot / the build gate reds. Both keys honor the build's NODE_ENV so a
  175. // dev build keeps the dev-branch semantics; artifacts default to production.
  176. // The bare `import.meta.env` key is required alongside the precise MODE
  177. // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
  178. // the truthiness probe would otherwise survive as an empty import.meta.
  179. define: {
  180. 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  181. 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  182. 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
  183. },
  184. // tsdown auto-externalizes package dependencies; anything NOT in the
  185. // loader module table must inline instead (wire/type layers, zod, clsx —
  186. // every non-shared dep). A require() the table cannot answer is a
  187. // guaranteed runtime throw, so the rule is the table list itself: no
  188. // opinion for table entries (external above wins), bundle everything else.
  189. noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
  190. plugins: [{
  191. // Bundle purity gate (build-time mirror of the module-edge rules):
  192. // platform seed entries stay external, inline-safe wire layers inline,
  193. // and every other @deepseek-ai value import is a build error — a
  194. // cross-plugin value import either inlines a duplicate runtime instance
  195. // or requires a specifier the frozen module table cannot answer.
  196. // Cross-plugin collaboration goes through cordis services instead.
  197. name: 'dsh-client-bundle-purity',
  198. resolveId(source: string) {
  199. if (!source.startsWith('@deepseek-ai/')) return null
  200. if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
  201. if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
  202. if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
  203. throw new Error(
  204. `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
  205. + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
  206. )
  207. },
  208. }, {
  209. name: 'dsh-css-modules-inline',
  210. resolveId(source: string, importer: string | undefined) {
  211. if (!source.endsWith('.module.css')) return null
  212. const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
  213. return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
  214. },
  215. async load(virtualId: string) {
  216. if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
  217. const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
  218. // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
  219. this.addWatchFile(fileId)
  220. const source = await readFile(fileId)
  221. const { code, exports: cssExports } = transform({
  222. filename: fileId,
  223. code: source,
  224. cssModules: { pattern: '[hash]_[local]' },
  225. minify: true,
  226. })
  227. const classMap: Record<string, string> = {}
  228. for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
  229. // One <style data-plugin> per module file; idempotent under re-evaluation.
  230. return [
  231. `const css = ${JSON.stringify(code.toString())};`,
  232. `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
  233. 'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
  234. ' const tag = document.createElement(\'style\');',
  235. ` tag.dataset.plugin = ${JSON.stringify(id)};`,
  236. ' tag.dataset.pluginCss = tagId;',
  237. ' tag.textContent = css;',
  238. ' document.head.appendChild(tag);',
  239. '}',
  240. `export default ${JSON.stringify(classMap)};`,
  241. ].join('\n')
  242. },
  243. }],
  244. outputOptions: {
  245. entryFileNames: 'client.js',
  246. // The map is served from /plugins/<scoped-package>/client.js.map. The
  247. // browser resolves its local sources back into URLs that mirror the
  248. // /packages/<group>/<package>/src directories; sourcesContent keeps them usable
  249. // without exposing that tree as an HTTP route.
  250. sourcemapPathTransform: browserSourcePath,
  251. banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
  252. footer: 'return module.exports; } });',
  253. intro: 'var module = { exports: {} }; var exports = module.exports;',
  254. },
  255. }
  256. }
  257. /** Resolve an emitted JS asset import against its source-tree counterpart. */
  258. function sourceAssetPath(source: string, importer: string): string {
  259. const emitted = resolvePath(dirname(importer), source)
  260. if (existsSync(emitted)) return emitted
  261. const marker = `${sep}lib${sep}types${sep}`
  262. const boundary = emitted.indexOf(marker)
  263. if (boundary < 0) return emitted
  264. return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
  265. }