tsdown.client.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. /**
  2. * Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
  3. * artifact: the bundle calls window.DSHClientProxy.loadPlugin({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. */
  10. import { readFile } from 'node:fs/promises'
  11. import { basename, dirname, resolve as resolvePath } from 'node:path'
  12. import type { UserConfig } from 'tsdown'
  13. import { transform } from 'lightningcss'
  14. /**
  15. * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
  16. * (which requires @tsdown/css). The suffix matters: tsdown's guard matches ids
  17. * ending in `.css`, so the virtual id must not.
  18. */
  19. const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
  20. const CSS_VIRTUAL_SUFFIX = '.mjs'
  21. /**
  22. * Wire/type layers a client bundle may inline: browser-safe contract surfaces
  23. * with no runtime identity to share (no Symbol/instanceof/singleton state).
  24. * Everything else under @deepseek-ai/* is either a module-table entry
  25. * (external) or a leak the purity gate rejects.
  26. */
  27. export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
  28. /** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
  29. export const CLIENT_EXTERNALS = [
  30. 'react',
  31. 'react-dom',
  32. 'react/jsx-runtime',
  33. 'cordis',
  34. '@deepseek-ai/dsh-client-ui-slots',
  35. '@deepseek-ai/dsh-client-web-react',
  36. '@deepseek-ai/dsh-client-ui-primitives',
  37. '@deepseek-ai/dsh-client-connection/client',
  38. '@deepseek-ai/dsh-client-runtime/client',
  39. '@deepseek-ai/dsh-client-ui-layout/client',
  40. '@deepseek-ai/dsh-client-ui-conversation/client',
  41. '@deepseek-ai/dsh-client-ui-theme/client',
  42. '@deepseek-ai/dsh-client-i18n/client',
  43. ]
  44. /**
  45. * Build the tsdown config for one UI plugin package: the node-half lib build
  46. * plus the browser client bundle. A package-level tsdown.config.ts REPLACES
  47. * the root workspace shape, so the lib half must be restated here — dropping
  48. * it leaves the package without lib/index.js and the host Loader cannot
  49. * import its node half.
  50. * @param id - plugin id (package name), stamped into the loadPlugin handoff
  51. * and onto the injected style tags.
  52. * @param libEntry - node-half entries, spelled at the call site so the
  53. * package-invariants gate can see `lib/types/invariant.js` in each package's
  54. * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
  55. * @returns tsdown user configs emitting lib/*.js and lib/client.js.
  56. */
  57. export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] {
  58. return [{
  59. entry: [...libEntry],
  60. outDir: 'lib',
  61. format: ['esm'],
  62. platform: 'node',
  63. target: 'es2024',
  64. fixedExtension: false,
  65. dts: false,
  66. clean: false,
  67. }, {
  68. entry: { client: 'src/client/index.ts' },
  69. // Browser bundle lands next to the node half (single lib/ artifact dir;
  70. // the entryFileNames pin keeps it exactly lib/client.js). clean must stay
  71. // off — a default clean would wipe the node-half output emitted above.
  72. outDir: 'lib',
  73. format: 'cjs',
  74. platform: 'browser',
  75. // Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
  76. dts: false,
  77. clean: false,
  78. external: CLIENT_EXTERNALS,
  79. // Browser bundles inline node-idiom deps (zustand/immer read
  80. // process.env.NODE_ENV; zustand's esm build also probes
  81. // import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
  82. // EMPTY_IMPORT_META). vite defined both on the seed path; tsdown inlining
  83. // needs the substitutions here or the factory throws ReferenceError at
  84. // boot / the build gate reds. Both keys honor the build's NODE_ENV so a
  85. // dev build keeps the dev-branch semantics; artifacts default to production.
  86. // The bare `import.meta.env` key is required alongside the precise MODE
  87. // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and
  88. // the truthiness probe would otherwise survive as an empty import.meta.
  89. define: {
  90. 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  91. 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
  92. 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
  93. },
  94. // tsdown auto-externalizes package dependencies; anything NOT in the
  95. // loader module table must inline instead (wire/type layers, zod, clsx —
  96. // every non-shared dep). A require() the table cannot answer is a
  97. // guaranteed runtime throw, so the rule is the table list itself: no
  98. // opinion for table entries (external above wins), bundle everything else.
  99. noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
  100. plugins: [{
  101. // Bundle purity gate: a bare-name import of a module-table package would
  102. // slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
  103. // second copy of that package — duplicate runtime identity (a second
  104. // scope Symbol was tonight's white-screen root cause). Resolve-time is
  105. // the earliest, most precise interception: rewrite bare table names to
  106. // their /client form (the loader registers both specifiers), and reject
  107. // any other @deepseek-ai/* leak that is not an inline-safe wire layer.
  108. name: 'dsh-client-bundle-purity',
  109. resolveId(source: string) {
  110. if (!source.startsWith('@deepseek-ai/')) return null
  111. if (CLIENT_EXTERNALS.includes(source)) return null // external wins
  112. if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
  113. return { id: `${source}/client`, external: true }
  114. }
  115. if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
  116. throw new Error(
  117. `client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
  118. + 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
  119. )
  120. },
  121. }, {
  122. name: 'dsh-css-modules-inline',
  123. resolveId(source: string, importer: string | undefined) {
  124. if (!source.endsWith('.module.css')) return null
  125. const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
  126. return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
  127. },
  128. async load(virtualId: string) {
  129. if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
  130. const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
  131. const source = await readFile(fileId)
  132. const { code, exports: cssExports } = transform({
  133. filename: fileId,
  134. code: source,
  135. cssModules: { pattern: `[hash]_[local]` },
  136. minify: true,
  137. })
  138. const classMap: Record<string, string> = {}
  139. for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
  140. // One <style data-plugin> per module file; idempotent under re-evaluation.
  141. return [
  142. `const css = ${JSON.stringify(code.toString())};`,
  143. `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
  144. `if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
  145. ` const tag = document.createElement('style');`,
  146. ` tag.dataset.plugin = ${JSON.stringify(id)};`,
  147. ` tag.dataset.pluginCss = tagId;`,
  148. ` tag.textContent = css;`,
  149. ` document.head.appendChild(tag);`,
  150. `}`,
  151. `export default ${JSON.stringify(classMap)};`,
  152. ].join('\n')
  153. },
  154. }],
  155. outputOptions: {
  156. entryFileNames: 'client.js',
  157. banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
  158. footer: `return module.exports; } });`,
  159. intro: 'var module = { exports: {} }; var exports = module.exports;',
  160. },
  161. }]
  162. }