The data layer no longer depends on the React glue package, and business plugins no longer depend on web-react at all: - The store engine (zustand vanilla + immer + persist + dev freeze), defineStore, and shallowEqual move to @deepseek-ai/dsh-client-runtime, exported from the ./client main entry — no ./store subpath survives on either package (the web-react one is deleted, none is opened on runtime). - Store products are bare snapshot sources: useSelector leaves SnapshotStore/StoreInstance and Session; every hook is composed at the binding site in web-react's renderer (per-source cached uSES binding). The SlotRendererHost sessions face carries bare observables only. - SessionProvider becomes a standard-kit seat: an entry whose children declare a session-scope slot receives the framework component as a prop, retiring the last value import of web-react from plugin packages. UseSession and the session-area types now live in ui-slots. - web-react shrinks to the shell-only React glue (renderer, providers, uSES bridge); zustand/immer belong to runtime alone; the module-table seed and tsdown externals drop the web-react/store seat. - NODE_ENV replacement is defined once in the shared tsdown client preset (browser bundles inline the engine and lost vite's define); the 3-line process.env typecheck shim moves to runtime with the engine. - Stray tsc artifacts (.js/.d.ts/.d.ts.map beside sources under src/) swept repo-wide; they shadow real sources under vitest resolution. Verified: both aggregate typecheck programs at zero; 604 client tests green; repo-wide grep for web-react/store at zero; real-host playwright run 7/7 including persist round-trip. ci: fix test/docs
167 lines
8.1 KiB
TypeScript
167 lines
8.1 KiB
TypeScript
/**
|
|
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
|
|
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({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'
|
|
|
|
/**
|
|
* 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)(\/|$)/
|
|
|
|
/** Externals resolved from the loader module table (keep in sync with the shell's seeding list). */
|
|
export const CLIENT_EXTERNALS = [
|
|
'react',
|
|
'react-dom',
|
|
'react/jsx-runtime',
|
|
'cordis',
|
|
'@deepseek-ai/dsh-client-ui-slots',
|
|
'@deepseek-ai/dsh-client-web-react',
|
|
'@deepseek-ai/dsh-client-ui-primitives',
|
|
'@deepseek-ai/dsh-client-connection/client',
|
|
'@deepseek-ai/dsh-client-runtime/client',
|
|
'@deepseek-ai/dsh-client-ui-layout/client',
|
|
'@deepseek-ai/dsh-client-ui-conversation/client',
|
|
'@deepseek-ai/dsh-client-ui-theme/client',
|
|
'@deepseek-ai/dsh-client-i18n/client',
|
|
]
|
|
|
|
/**
|
|
* 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 loadPlugin 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: a bare-name import of a module-table package would
|
|
// slip past CLIENT_EXTERNALS (which lists the /client form) and INLINE a
|
|
// second copy of that package — duplicate runtime identity (a second
|
|
// scope Symbol was tonight's white-screen root cause). Resolve-time is
|
|
// the earliest, most precise interception: rewrite bare table names to
|
|
// their /client form (the loader registers both specifiers), and reject
|
|
// any other @deepseek-ai/* leak that is not an inline-safe wire layer.
|
|
name: 'dsh-client-bundle-purity',
|
|
resolveId(source: string) {
|
|
if (!source.startsWith('@deepseek-ai/')) return null
|
|
if (CLIENT_EXTERNALS.includes(source)) return null // external wins
|
|
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
|
|
return { id: `${source}/client`, external: true }
|
|
}
|
|
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
|
throw new Error(
|
|
`client bundle purity: "${source}" is not in CLIENT_EXTERNALS and not an inline-safe wire layer — `
|
|
+ 'import the /client form, add it to the module table, or it inlines a duplicate runtime instance',
|
|
)
|
|
},
|
|
}, {
|
|
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.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
|
footer: `return module.exports; } });`,
|
|
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
|
},
|
|
}]
|
|
}
|