tsdown.client.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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 { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
  13. import { fileURLToPath } from 'node:url'
  14. import type { UserConfig } from 'tsdown'
  15. import { transform } from 'lightningcss'
  16. import { PLATFORM_MODULES } from './web/src/platform.ts'
  17. /**
  18. * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
  19. * (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
  20. * ending in `.css`, so the virtual id must not.
  21. */
  22. const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
  23. const CSS_VIRTUAL_SUFFIX = '.mjs'
  24. /**
  25. * Wire/type layers a client bundle may inline: browser-safe contract surfaces
  26. * with no runtime identity to share (no Symbol/instanceof/singleton state).
  27. * Everything else under @deepseek-ai/* is either a module-table entry
  28. * (external) or a leak the purity gate rejects.
  29. */
  30. export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
  31. /** Generated descriptor/codec contribution with no shared runtime identity. */
  32. const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
  33. /**
  34. * Documented TEMPORARY exemption, not a platform module (hence not in
  35. * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
  36. * shallowEqual) lives in runtime pending its promotion-time rehoming, and
  37. * five importers (locale, ui-layout, ui-conversation ×3) ride this single
  38. * exemption. At runtime the lazy CJS table answers the require natively:
  39. * runtime is an immediately-tier row, its factory is registered before any
  40. * dependent bundle materializes. TODO(webload/store-rehome): remove with the
  41. * store-engine relocation follow-up.
  42. */
  43. const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
  44. /** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
  45. export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
  46. const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
  47. /** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */
  48. function browserSourcePath(source: string, sourcemapPath: string): string {
  49. if (!source.startsWith('.')) return source
  50. const physicalSource = resolvePath(dirname(sourcemapPath), source)
  51. const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
  52. return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
  53. }
  54. /**
  55. * Build the tsdown config for one UI plugin package: the node-half lib build
  56. * plus the browser client bundle. A package-level tsdown.config.ts REPLACES
  57. * the root workspace shape, so the lib half must be restated here — dropping
  58. * it leaves the package without lib/index.js and the host Loader cannot
  59. * import its node half.
  60. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load
  61. * handoff and onto the injected style tags.
  62. * @param libEntry - node-half entries, spelled at the call site so the
  63. * package-invariants gate can see `lib/types/invariant.js` in each package's
  64. * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
  65. * @returns tsdown user configs emitting lib/*.js and lib/client.js.
  66. */
  67. export function clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
  68. return [{
  69. entry: [...libEntry],
  70. outDir: 'lib',
  71. format: ['esm'],
  72. platform: 'node',
  73. target: 'es2024',
  74. fixedExtension: false,
  75. dts: false,
  76. clean: false,
  77. }, {
  78. entry: { client: 'src/client/index.ts' },
  79. // Browser bundle lands next to the node half (single lib/ artifact dir;
  80. // the entryFileNames pin keeps it exactly lib/client.js). clean must stay
  81. // off — a default clean would wipe the node-half output emitted above.
  82. outDir: 'lib',
  83. format: 'cjs',
  84. platform: 'browser',
  85. // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
  86. dts: false,
  87. // Plugin code is fetched outside Vite's module graph, so its own bundle
  88. // must carry the TS/TSX mapping consumed by browser profiling tools.
  89. sourcemap: true,
  90. clean: false,
  91. external: [...CLIENT_EXTERNALS],
  92. // Browser bundles inline node-idiom deps (zustand/immer read
  93. // process.env.NODE_ENV; zustand's esm build also probes
  94. // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
  95. // EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
  96. // needs the substitutions here or the factory throws ReferenceError at
  97. // boot / the build gate reds. Both keys honor the build's NODE_ENV so a
  98. // dev build keeps the dev-branch semantics; artifacts default to production.
  99. // The bare `import.meta.env` key is required alongside the precise MODE
  100. // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
  101. // the truthiness probe would otherwise survive as an empty import.meta.
  102. define: {
  103. 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  104. 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  105. 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
  106. },
  107. // tsdown auto-externalizes package dependencies; anything NOT in the
  108. // loader module table must inline instead (wire/type layers, zod, clsx —
  109. // every non-shared dep). A require() the table cannot answer is a
  110. // guaranteed runtime throw, so the rule is the table list itself: no
  111. // opinion for table entries (external above wins), bundle everything else.
  112. noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
  113. plugins: [{
  114. // Bundle purity gate (build-time mirror of the module-edge rules):
  115. // platform seed entries stay external, inline-safe wire layers inline,
  116. // and every other @deepseek-ai value import is a build error — a
  117. // cross-plugin value import either inlines a duplicate runtime instance
  118. // or requires a specifier the frozen module table cannot answer.
  119. // Cross-plugin collaboration goes through cordis services instead.
  120. name: 'dsh-client-bundle-purity',
  121. resolveId(source: string) {
  122. if (!source.startsWith('@deepseek-ai/')) return null
  123. if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
  124. if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
  125. throw new Error(
  126. `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
  127. + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
  128. )
  129. },
  130. }, {
  131. name: 'dsh-css-modules-inline',
  132. resolveId(source: string, importer: string | undefined) {
  133. if (!source.endsWith('.module.css')) return null
  134. const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
  135. return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
  136. },
  137. async load(virtualId: string) {
  138. if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
  139. const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
  140. // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
  141. this.addWatchFile(fileId)
  142. const source = await readFile(fileId)
  143. const { code, exports: cssExports } = transform({
  144. filename: fileId,
  145. code: source,
  146. cssModules: { pattern: `[hash]_[local]` },
  147. minify: true,
  148. })
  149. const classMap: Record<string, string> = {}
  150. for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
  151. // One <style data-plugin> per module file; idempotent under re-evaluation.
  152. return [
  153. `const css = ${JSON.stringify(code.toString())};`,
  154. `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
  155. `if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
  156. ` const tag = document.createElement('style');`,
  157. ` tag.dataset.plugin = ${JSON.stringify(id)};`,
  158. ` tag.dataset.pluginCss = tagId;`,
  159. ` tag.textContent = css;`,
  160. ` document.head.appendChild(tag);`,
  161. `}`,
  162. `export default ${JSON.stringify(classMap)};`,
  163. ].join('\n')
  164. },
  165. }],
  166. outputOptions: {
  167. entryFileNames: 'client.js',
  168. // The map is served from /plugins/<scoped-package>/client.js.map. The
  169. // browser resolves its local sources back into the repository-shaped
  170. // /packages/<group>/<package>/src tree; sourcesContent keeps them usable
  171. // without exposing that tree as an HTTP route.
  172. sourcemapPathTransform: browserSourcePath,
  173. banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
  174. footer: `return module.exports; } });`,
  175. intro: 'var module = { exports: {} }; var exports = module.exports;',
  176. },
  177. }]
  178. }