mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
285 lines
14 KiB
TypeScript
285 lines
14 KiB
TypeScript
/**
|
||
* 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))
|
||
}
|