| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- /**
- * 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 Modules are compiled by
- * lightningcss inside the bundle: importing `x.module.css` yields the
- * hashed class map, and the css text auto-injects a <style data-plugin="<id>">
- * tag at factory execution (the loader removes plugin-owned tags on unload).
- */
- import { readFile } from 'node:fs/promises'
- import { basename, dirname, resolve as resolvePath } from 'node:path'
- import type { UserConfig } from 'tsdown'
- import { transform } from 'lightningcss'
- import { PLATFORM_MODULES } from './web/src/platform.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 CSS_VIRTUAL_SUFFIX = '.mjs'
- /**
- * Wire/type layers a client bundle may inline: browser-safe contract surfaces
- * 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-(host-apiproxy|session|llm|tools|brand)(\/|$)/
- /**
- * Documented TEMPORARY exemption, not a platform module (hence not in
- * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
- * shallowEqual) lives in runtime pending its promotion-time rehoming, and
- * five importers (locale, ui-layout, ui-conversation ×3) ride this single
- * exemption. At runtime the lazy CJS table answers the require natively:
- * runtime is an immediately-tier row, its factory is registered before any
- * dependent bundle materializes. TODO(webload/store-rehome): remove with the
- * store-engine relocation follow-up.
- */
- const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
- /** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
- export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
- /**
- * Build the tsdown config for one UI plugin package: the node-half lib build
- * plus the browser client bundle. A package-level tsdown.config.ts REPLACES
- * the root workspace shape, 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.
- * @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).
- * @returns tsdown user configs emitting lib/*.js and lib/client.js.
- */
- export function clientBundle(id: string, libEntry: readonly string[]): UserConfig[] {
- return [{
- entry: [...libEntry],
- outDir: 'lib',
- format: ['esm'],
- platform: 'node',
- target: 'es2024',
- fixedExtension: false,
- dts: false,
- clean: false,
- }, {
- entry: { client: 'src/client/index.ts' },
- // 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,
- clean: false,
- external: [...CLIENT_EXTERNALS],
- // 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: {
- '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' }),
- },
- // tsdown auto-externalizes package dependencies; anything NOT in the
- // loader module table must inline instead (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 table list itself: no
- // opinion for table entries (external above wins), bundle everything else.
- noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
- plugins: [{
- // Bundle purity gate (build-time mirror of the module-edge rules):
- // platform seed entries 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 frozen module table cannot answer.
- // Cross-plugin collaboration goes through cordis services instead.
- name: 'dsh-client-bundle-purity',
- resolveId(source: string) {
- if (!source.startsWith('@deepseek-ai/')) return null
- if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
- if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
- throw new Error(
- `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
- + 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
- )
- },
- }, {
- name: 'dsh-css-modules-inline',
- resolveId(source: string, importer: string | undefined) {
- if (!source.endsWith('.module.css')) return null
- const abs = importer !== undefined ? resolvePath(dirname(importer), source) : 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)
- 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> = {}
- for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
- // One <style data-plugin> per module file; idempotent under re-evaluation.
- return [
- `const css = ${JSON.stringify(code.toString())};`,
- `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);`,
- `}`,
- `export default ${JSON.stringify(classMap)};`,
- ].join('\n')
- },
- }],
- outputOptions: {
- entryFileNames: 'client.js',
- banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
- footer: `return module.exports; } });`,
- intro: 'var module = { exports: {} }; var exports = module.exports;',
- },
- }]
- }
|