| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- /**
- * 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).
- * The virtual loader registers each real stylesheet as a watch dependency.
- */
- import { readFile } from 'node:fs/promises'
- import { existsSync } from 'node:fs'
- import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
- import { fileURLToPath } from 'node:url'
- 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 contracts
- * 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)(\/|$)/
- /**
- * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
- * would read them as plugin packages. They carry no cross-plugin runtime
- * identity to share — the framework itself is a platform module (external),
- * while these are ordinary libraries a browser bundle inlines.
- */
- const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
- /** Generated descriptor/codec contribution with no shared runtime identity. */
- const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
- /**
- * Workspace mode replaces an empty config array with the root defaults. A
- * falsey entry instead removes this package before entry resolution.
- */
- const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
- /**
- * 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]
- const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
- /** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
- function browserSourcePath(source: string, sourcemapPath: string): string {
- if (!source.startsWith('.')) return source
- const physicalSource = resolvePath(dirname(sourcemapPath), source)
- const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
- return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
- }
- /**
- * Build the tsdown config for one UI plugin package: the node-half lib build
- * plus the browser client bundle. Client packages emit both halves during the
- * Client pass by default; packages needed for Host reflection may opt into the
- * earlier Host pass. A package-level tsdown.config.ts REPLACES the root
- * workspace layout, 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).
- * @param options - phase placement, lib overrides, and companion Node configs.
- * @returns ENV-selected tsdown config for the current build face.
- */
- export function clientBundle(
- id: string,
- libEntry: readonly string[],
- options: ClientBundleOptions = {},
- ): BuildFaceConfig {
- const lib = clientLibraryConfig(id, libEntry, options.lib)
- return ({ env }) => {
- const face = buildFace(env?.DSH_BUILD_FACE)
- const client = clientConfig(id, face === undefined
- ? 'src/client/index.ts'
- : 'lib/types/client/index.js')
- const node = [lib, ...(options.companions ?? [])]
- if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
- if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
- return [...node, client]
- }
- }
- /**
- * Build a Client-only Node library during the Client pass.
- * @param id - Package name used in tsdown diagnostics.
- * @param libEntry - Emitted JavaScript entries consumed from `lib/types`.
- * @returns ENV-selected tsdown config for the Client build face.
- */
- export function clientLibrary(id: string, libEntry: readonly string[]): BuildFaceConfig {
- const lib = clientLibraryConfig(id, libEntry)
- return clientOnly([lib])
- }
- /**
- * Select arbitrary package-local configs only during the Client pass.
- * @param configs - Node-side configs emitted after Client tsc.
- * @returns ENV-selected tsdown config for the Client build face.
- */
- export function clientOnly(configs: readonly UserConfig[]): BuildFaceConfig {
- return ({ env }) => buildFace(env?.DSH_BUILD_FACE) === 'host'
- ? [SKIP_WORKSPACE_BUILD]
- : [...configs]
- }
- interface ClientBundleOptions {
- /** Emit the Node-side artifacts during the Host pass instead of the Client pass. */
- readonly hostPhase?: boolean
- /** Additional Node-side configs emitted alongside the package library. */
- readonly companions?: readonly UserConfig[]
- /** Overrides for the package's primary Node-side library config. */
- readonly lib?: UserConfig
- }
- type BuildFace = 'host' | 'client' | undefined
- type BuildFaceConfig = (inlineConfig: Pick<UserConfig, 'env'>) => UserConfig[]
- function buildFace(value: unknown): BuildFace {
- if (value === undefined || value === 'host' || value === 'client') return value
- throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`)
- }
- function clientLibraryConfig(
- id: string,
- libEntry: readonly string[],
- overrides: UserConfig = {},
- ): UserConfig {
- return {
- name: id,
- entry: [...libEntry],
- outDir: 'lib',
- format: ['esm'],
- platform: 'node',
- target: 'es2024',
- fixedExtension: false,
- dts: false,
- clean: false,
- ...overrides,
- }
- }
- function clientConfig(id: string, entry: string): UserConfig {
- return {
- name: `${id}/client`,
- entry: { client: entry },
- // 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,
- // Plugin code is fetched outside Vite's module graph, so its own bundle
- // must carry the TS/TSX mapping consumed by browser profiling tools.
- sourcemap: true,
- 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 (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
- if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
- throw new Error(
- `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
- + '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 ? sourceAssetPath(source, importer) : 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)
- // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
- this.addWatchFile(fileId)
- 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',
- // The map is served from /plugins/<scoped-package>/client.js.map. The
- // browser resolves its local sources back into URLs that mirror the
- // /packages/<group>/<package>/src directories; sourcesContent keeps them usable
- // without exposing that tree as an HTTP route.
- sourcemapPathTransform: browserSourcePath,
- banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
- footer: 'return module.exports; } });',
- intro: 'var module = { exports: {} }; var exports = module.exports;',
- },
- }
- }
- /** Resolve an emitted JS asset import against its source-tree counterpart. */
- function sourceAssetPath(source: string, importer: string): string {
- const emitted = resolvePath(dirname(importer), source)
- if (existsSync(emitted)) return emitted
- const marker = `${sep}lib${sep}types${sep}`
- const boundary = emitted.indexOf(marker)
- if (boundary < 0) return emitted
- return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
- }
|