mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
- Remove the sync-manifest entry for shared/host/run-guarded.ts and the three
generated copies under packages/{dsh-usage,dsh-task-board,dsh-git-graph}/src/host/:
nothing in this repository imported the module (the satellite repositories
carry their own copies), so the entry only kept three unread files in sync.
The shared source and its spec stay.
- Delete the unused mobileBundle helper and its node:module createRequire import
from shared/tsdown.client.ts; the standalone mobile bundle has been gone since
0.4.0.
- Update the sync composition guard in scripts/sync-shared.test.mjs (99 -> 96
copies, 48 -> 45 host copies) and refresh scripts/lib-artifact-fingerprints.json.
- Re-point the notes, READMEs, CONTRIBUTING and docs that still linked the
skin-center contract files to the dsh-skins repository, and prune the stale
screenshots.
- Record the decision in
.agents/notes/implemented/simplification/2026-09-26-dead-shared-artifacts-removed.md
and correct the run-guarded facts in the aggregate fault-isolation note.
355 lines
18 KiB
TypeScript
355 lines
18 KiB
TypeScript
/**
|
|
* Shared tsdown preset for UI plugin client bundles — the single source of
|
|
* truth for every dsh-web plugin's build (previously copied per-package
|
|
* from the DSH checkout's `packages/client/tsdown.client.ts`). 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. The platform module list mirrors the
|
|
* shell's seed table in `./web-platform.ts`.
|
|
*/
|
|
import { readFile } from 'node:fs/promises'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { dirname, isAbsolute, 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-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.
|
|
*/
|
|
const INLINE_SAFE = /^@deepseek-ai\/dsh-(session|llm|tools|brand)(\/|$)/
|
|
|
|
/** 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: '' }
|
|
|
|
/**
|
|
* The snapshot-store engine's cohort duality. The 0.1.2-alpha.2 cohort
|
|
* freezes the engine into the dsh-client-store platform module; the
|
|
* 0.1.1-rc.2 hosts this family still serves materialize the identical
|
|
* engine (the same contract/store.ts rehomed) as the dsh-client-runtime
|
|
* inject module's ./client face. Each host module table answers only its
|
|
* own cohort's home, so a hard external require of either specifier cannot
|
|
* import on the other host. Value imports of dsh-client-store are
|
|
* therefore not left external: the purity plugin redirects them to the
|
|
* generated store-engine shim (STORE_ENGINE_SHIM), which resolves the
|
|
* engine through the loader require with a legacy fallback. Type-only
|
|
* imports are erased before bundling and keep importing the published
|
|
* declarations.
|
|
*/
|
|
const STORE_ENGINE_MODULE = '@deepseek-ai/dsh-client-store'
|
|
const STORE_ENGINE_SHIM_ID = '\0dsh-store-engine'
|
|
|
|
/**
|
|
* Generated dual-cohort store-engine shim. The join()-built specifiers stay
|
|
* invisible to the static resolver, so the require calls are emitted
|
|
* verbatim and answered by the loader's injected require at bundle
|
|
* evaluation: the platform module on 0.1.2-alpha.2 hosts, the legacy
|
|
* runtime face on rc.2. Forwards exactly the value surface both engines
|
|
* share (notifySubscribers exists only in the cohort package, so nothing
|
|
* may re-export it).
|
|
*/
|
|
const STORE_ENGINE_SHIM = [
|
|
'// Generated by shared/tsdown.client.ts (store-engine shim). Do not edit.',
|
|
"const platform = ['@deepseek-ai/dsh-client', '-store'].join('')",
|
|
"const legacy = ['@deepseek-ai/dsh-client-runtime', '/client'].join('')",
|
|
'let engine',
|
|
'try {',
|
|
' engine = require(platform)',
|
|
'} catch {',
|
|
' engine = require(legacy)',
|
|
'}',
|
|
'export const createSnapshotStore = engine.createSnapshotStore',
|
|
'export const defineStore = engine.defineStore',
|
|
'export const shallowEqual = engine.shallowEqual',
|
|
].join('\n')
|
|
|
|
/**
|
|
* Externals resolved from the loader module table: the platform seed
|
|
* entries except the store engine, whose value imports ride the generated
|
|
* dual-cohort shim instead (see STORE_ENGINE_MODULE).
|
|
*/
|
|
const CLIENT_EXTERNALS: readonly string[] = PLATFORM_MODULES.filter(
|
|
module => module !== STORE_ENGINE_MODULE,
|
|
)
|
|
|
|
const REPOSITORY_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
|
|
/**
|
|
* The building package's own package.json version, baked into the client
|
|
* bundle as __DSH_PKG_VERSION__ so the anonymous install heartbeat can report
|
|
* which release is actually running. Empty when the manifest is unreadable.
|
|
*/
|
|
function buildPackageVersion(): string {
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(resolvePath(process.cwd(), 'package.json'), 'utf8'))
|
|
return typeof manifest.version === 'string' ? manifest.version : ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** Rebase a physical path onto a repository-relative id when it lives under the repo. */
|
|
function repositoryRelativePath(physical: string): string {
|
|
if (!isAbsolute(physical)) return physical
|
|
const repositoryPath = relative(REPOSITORY_ROOT, physical).split(sep).join('/')
|
|
return repositoryPath.startsWith('../') ? physical : repositoryPath
|
|
}
|
|
|
|
/** 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 `src/invariant.ts` (or the tsc emit path) in
|
|
* each package's own tsdown.config.ts.
|
|
* @param options - phase placement, lib overrides, 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, options.libExternal)
|
|
return ({ env }) => {
|
|
const face = buildFace(env?.DSH_BUILD_FACE)
|
|
// Host-only plugins (no src/client entry) skip the browser face entirely.
|
|
const hasClient = existsSync(resolvePath(process.cwd(), 'src/client/index.ts'))
|
|
const client = hasClient ? clientConfig(id, face === undefined
|
|
? 'src/client/index.ts'
|
|
: 'lib/types/client/index.js', options.clientPlugins) : undefined
|
|
const node = [lib, ...(options.companions ?? [])]
|
|
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
|
|
if (face === 'client') return options.hostPhase === true ? (client ? [client] : []) : (client ? [...node, client] : node)
|
|
return client ? [...node, client] : node
|
|
}
|
|
}
|
|
|
|
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
|
|
/** Extra Node-side externals (in addition to the default cordis entry). */
|
|
readonly libExternal?: readonly (string | RegExp)[]
|
|
/** Extra browser-bundle resolve plugins, prepended ahead of the purity gate
|
|
* so a package can remap generated specifiers onto sources before default
|
|
* resolution (e.g. the aggregate's client-children alias). */
|
|
readonly clientPlugins?: readonly NonNullable<UserConfig['plugins']>
|
|
}
|
|
|
|
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 = {},
|
|
extraExternal: readonly (string | RegExp)[] = [],
|
|
): UserConfig {
|
|
return {
|
|
name: id,
|
|
entry: [...libEntry],
|
|
outDir: 'lib',
|
|
format: ['esm'],
|
|
platform: 'node',
|
|
target: 'es2024',
|
|
fixedExtension: false,
|
|
dts: false,
|
|
clean: false,
|
|
// The cordis framework resolves at runtime from the dsh profile tree, never
|
|
// from this repo's install; its built declarations carry .ts-suffixed
|
|
// relative imports rolldown cannot follow, so the import must stay
|
|
// external (the same stance as the peer APIs above).
|
|
external: ['@deepseek-ai/cordis', ...extraExternal],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function clientConfig(id: string, entry: string, extraPlugins: readonly NonNullable<UserConfig['plugins']> = []): 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' }),
|
|
__DSH_PKG_VERSION__: JSON.stringify(buildPackageVersion()),
|
|
},
|
|
// 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),
|
|
// Package-level resolve plugins run FIRST: they may remap generated
|
|
// specifiers onto sources before the purity gate and default resolution.
|
|
plugins: [...extraPlugins, {
|
|
// 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 === STORE_ENGINE_MODULE) return STORE_ENGINE_SHIM_ID // value import: dual-cohort shim (type-only imports are erased and never reach this gate)
|
|
if (!source.startsWith('@deepseek-ai/')) return null
|
|
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
|
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)',
|
|
)
|
|
},
|
|
load(id: string) {
|
|
if (id !== STORE_ENGINE_SHIM_ID) return null
|
|
return STORE_ENGINE_SHIM
|
|
},
|
|
}, {
|
|
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
|
|
// Repo-relative virtual id: the emitted `//#region` comments would
|
|
// otherwise embed each builder's machine path, churning every
|
|
// committed lib/client.js when another machine rebuilds.
|
|
return CSS_VIRTUAL_PREFIX + repositoryRelativePath(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)
|
|
// Rebase the repo-relative id back onto the physical stylesheet; the
|
|
// virtual id otherwise hides it from Rolldown's watch graph.
|
|
const physical = isAbsolute(fileId) ? fileId : resolvePath(REPOSITORY_ROOT, fileId)
|
|
this.addWatchFile(physical)
|
|
const source = await readFile(physical)
|
|
const { code, exports: cssExports } = transform({
|
|
// Repo-relative filename: lightningcss's [hash] placeholder mixes
|
|
// the filename in, so an absolute path would yield machine-dependent
|
|
// class names on top of the region-comment noise.
|
|
filename: fileId,
|
|
code: source,
|
|
cssModules: { pattern: '[hash]_[local]' },
|
|
minify: true,
|
|
})
|
|
const classMap: Record<string, string> = {}
|
|
// Sort deterministically: lightningcss's cssExports iteration order is
|
|
// process-dependent (hash-map seeds), which would otherwise churn the
|
|
// emitted lib/client.js on every rebuild.
|
|
for (const [local, exp] of Object.entries(cssExports ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
classMap[local] = exp.name
|
|
}
|
|
// One <style data-plugin> per module file; idempotent under re-evaluation.
|
|
// The tag id carries the full repo-relative file id, not the basename:
|
|
// the aggregate build inlines several packages whose module files share
|
|
// a basename (eight settings-card.module.css copies), and a basename-only
|
|
// id lets the first tag suppress the others while each package's class
|
|
// map carries a path-derived hash — leaving every later copy's classes
|
|
// with no stylesheet at all.
|
|
return [
|
|
`const css = ${JSON.stringify(code.toString())};`,
|
|
`const tagId = ${JSON.stringify(`${id}/${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))
|
|
}
|