refactor(gui): one plugin-package shape — dshClient manifests, clientBundle preset, purity gate over all nine

Every client plugin package carries dshClient ({platform, inject,
immediately?}) and emits lib/client.js through the shared clientBundle
preset; exports["./client"] points at the bundle. The infrastructure
tier (connection, runtime, ui-theme, i18n, hmr) declares immediately: true
in its manifest — absent means lazy. The bundle purity gate covers all
nine packages: platform modules stay external, INLINE_SAFE wire layers
inline, any other cross-plugin value import is a build error. Migrations
that rule forced: scopeOf became a SessionsService method and
transportError moved into dsh-host-apiproxy's wire layer; the store
engine stays in runtime under a documented temporary exemption
(TODO(webload/store-rehome)).
This commit is contained in:
imccyu
2026-07-23 21:55:59 +08:00
parent b58f0989f9
commit c2e0c16d6a
18 changed files with 152 additions and 125 deletions

View File

@@ -1,6 +1,6 @@
/**
* Shared tsdown preset for UI plugin client bundles. Emits a closure-factory
* artifact: the bundle calls window.DSHClientProxy.loadPlugin({id, 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
@@ -11,6 +11,7 @@ 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
@@ -28,22 +29,20 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/
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',
]
/**
* 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 (i18n, 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
@@ -51,8 +50,8 @@ export const CLIENT_EXTERNALS = [
* 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 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).
@@ -79,7 +78,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
// 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,
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
@@ -102,24 +101,20 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
// 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.
// 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 // external wins
if (CLIENT_EXTERNALS.includes(`${source}/client`)) {
return { id: `${source}/client`, external: true }
}
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 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',
`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)',
)
},
}, {
@@ -158,7 +153,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
}],
outputOptions: {
entryFileNames: 'client.js',
banner: `window.DSHClientProxy.loadPlugin({ id: ${JSON.stringify(id)}, factory: (require) => {`,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
},