build: order Host and Client compilation faces

This commit is contained in:
imccyu
2026-08-08 03:29:12 +08:00
parent 8158a0e63a
commit 9c3d5725a5
32 changed files with 440 additions and 184 deletions

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../goal/goal"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/agent-lookup.ts",
"src/index.ts",
"src/invariant.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
},
{
"path": "../../typert/registry"
},
{
"path": "../../typert/type-meta"
}
]
}

View File

@@ -1,42 +1,11 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"files": [],
"references": [
{
"path": "../../../vendor/cordis"
"path": "./tsconfig.host.json"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../typert/type-meta"
},
{
"path": "../../typert/registry"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/goal"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../support/invariants"
"path": "./tsconfig.client.json"
}
]
}

View File

@@ -1,3 +1,7 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-api-remotes', ['lib/types/index.js', 'lib/types/invariant.js'])
export default clientBundle(
'@deepseek-ai/dsh-api-remotes',
['lib/types/index.js', 'lib/types/invariant.js'],
{ hostPhase: true },
)

View File

@@ -25,7 +25,6 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-typert-registry"
],
"platform": "web",
@@ -49,14 +48,12 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-api-remotes": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",

View File

@@ -1,7 +1,6 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -179,8 +178,8 @@ declare module 'cordis' {
}
}
/** Required services: the Remote root, wire handle, and Client TypeRT registry. */
export const inject = ['remote', 'connection', 'typert']
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.

View File

@@ -20,9 +20,6 @@
{
"path": "../connection"
},
{
"path": "../../api/remotes"
},
{
"path": "../../host/apiproxy"
},

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-schema-form',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-test-runtime',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -9,6 +9,7 @@
* 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'
@@ -34,6 +35,12 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
/** 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/
@@ -61,19 +68,83 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
/**
* 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.
* 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 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 __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).
* @returns tsdown user configs emitting lib/*.js and lib/client.js.
* @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[]): [UserConfig, UserConfig] {
return [{
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 host = [lib, ...(options.host ?? [])]
if (face === 'host') return options.hostPhase === true ? host : [SKIP_WORKSPACE_BUILD]
if (face === 'client') return options.hostPhase === true ? [client] : [...host, client]
return [...host, 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
readonly host?: readonly UserConfig[]
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'],
@@ -82,8 +153,14 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
fixedExtension: false,
dts: false,
clean: false,
}, {
entry: { client: 'src/client/index.ts' },
...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.
@@ -139,7 +216,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
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
const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
@@ -182,5 +259,15 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
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))
}

View File

@@ -15,7 +15,7 @@
"path": "../locale"
},
{
"path": "../../api/remotes"
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../runtime"

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* ui-primitives is browser-only, but its lib bundle IS imported under plain
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* (loader module table / vite source paths), which compile src directly and
* never read lib.
*/
export default defineConfig({
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
@@ -28,4 +28,4 @@ export default defineConfig({
return 'export default {};'
},
}],
})
}])

View File

@@ -0,0 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
export default clientLibrary(
'@deepseek-ai/dsh-client-ui-slots',
['lib/types/index.js', 'lib/types/invariant.js'],
)

View File

@@ -1,11 +1,11 @@
import { clientBundle } from '../tsdown.client.ts'
const [lib, client] = clientBundle(
export default clientBundle(
'@deepseek-ai/dsh-client-ui-theme',
['lib/types/index.js', 'lib/types/invariant.js'],
{
lib: {
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
},
},
)
export default [{
...lib,
copy: [{ from: 'src/styles/*', to: 'lib/styles' }],
}, client]

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* Root and invariant shapes as SEPARATE single-entry bundles: a multi-entry
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* runtime — browser consumers resolve this package through the loader module
* table.
*/
export default defineConfig([
export default clientOnly([
{
entry: { index: 'lib/types/index.js' },
outDir: 'lib',

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'tsdown'
import { clientOnly } from '../tsdown.client.ts'
/**
* Root-shape lib build plus a css stub: the shell's components import
@@ -8,7 +8,7 @@ import { defineConfig } from 'tsdown'
* this node lib build stubs every css import to an empty module — importing
* the lib under plain node must not crash on an asset specifier.
*/
export default defineConfig({
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
@@ -28,4 +28,4 @@ export default defineConfig({
return 'export default {};'
},
}],
})
}])

View File

@@ -24,7 +24,7 @@
"path": "../../../vendor/schemastery"
},
{
"path": "../../api/remotes"
"path": "../../api/remotes/tsconfig.host.json"
},
{
"path": "../../util/brand"

View File

@@ -3,18 +3,21 @@ import { clientBundle } from '../../client/tsdown.client.ts'
// The Win32 dialog worker builds as its own CJS entry (mirroring
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
// the dialog logic while koffi stays an external native require.
export default [
...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']),
export default clientBundle(
'@deepseek-ai/dsh-host-directory-picker-native',
['lib/types/index.js', 'lib/types/invariant.js'],
{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'] as ['cjs'],
platform: 'node' as const,
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
host: [{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}],
},
]
)

View File

@@ -476,11 +476,20 @@ export class WorkspaceAnalyzer {
config: this.caches.config(configPath),
manifest,
}
if (isDualFacePackage(manifest)) {
registrations.push({ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) })
registrations.push({ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) })
} else {
if (!isDualFacePackage(manifest)) {
registrations.push(registration)
} else if (configPath === join(packageRoot, 'tsconfig.json')) {
registrations.push(
{ ...registration, face: 'host', exportSubpaths: hostExportSubpaths(manifest) },
{ ...registration, face: 'client', exportSubpaths: clientExportSubpaths(manifest) },
)
} else {
registrations.push({
...registration,
exportSubpaths: face === 'host'
? hostExportSubpaths(manifest)
: clientExportSubpaths(manifest),
})
}
}
}

View File

@@ -864,6 +864,31 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
.toEqual(['@fixture/host'])
})
it('keeps both runtime faces for an ordinary dshClient project', () => {
const root = copyFixture('typert-dual-runtime-')
configureDualRuntimeClient(root, false)
expect(new WorkspaceAnalyzer({ root }).discoverPackages()).toContainEqual({
package: '@fixture/client',
root: 'packages/client',
faces: ['client', 'host'],
})
})
it('confines explicit face projects to their selected TypeRT face', () => {
const root = copyFixture('typert-split-project-')
configureDualRuntimeClient(root, true)
const markers = new WorkspaceAnalyzer({ root }).indexSourceDeclarations()
.filter(declaration => declaration.package === '@fixture/client'
&& declaration.name.endsWith('OnlyMarker'))
.map(declaration => ({ face: declaration.face, name: declaration.name }))
expect(markers).toEqual([
{ face: 'client', name: 'ClientOnlyMarker' },
{ face: 'host', name: 'HostOnlyMarker' },
])
})
it('accepts package export forms while skipping artifact-only rows and unexported packages', { timeout: 180_000 }, () => {
const root = copyFixture('typert-export-forms-')
const hostRoot = join(root, 'packages/host')
@@ -1193,6 +1218,63 @@ function copyFixture(prefix: string): string {
return root
}
function configureDualRuntimeClient(root: string, splitProjects: boolean): void {
const packageRoot = join(root, 'packages/client')
const manifestPath = join(packageRoot, 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
dshClient?: object
exports: Record<string, unknown>
}
manifest.dshClient = {}
manifest.exports['./client'] = {
types: './lib/types/client.d.ts',
default: './lib/client.js',
}
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
writeFileSync(join(packageRoot, 'src/client.ts'), [
"import { Service } from 'cordis'",
'export interface ClientOnlyMarker { readonly client: true }',
'export class BrowserBridge extends Service {}',
"declare module 'cordis' { interface Context { browserBridge: BrowserBridge } }",
'',
].join('\n'))
const indexPath = join(packageRoot, 'src/index.ts')
writeFileSync(indexPath, `${readFileSync(indexPath, 'utf8')}\nexport interface HostOnlyMarker { readonly host: true }\n`)
if (!splitProjects) return
const project = JSON.parse(readFileSync(join(packageRoot, 'tsconfig.json'), 'utf8')) as Record<string, unknown>
delete project.include
writeFileSync(join(packageRoot, 'tsconfig.host.json'), `${JSON.stringify({
...project,
files: ['src/index.ts'],
}, null, 2)}\n`)
writeFileSync(join(packageRoot, 'tsconfig.client.json'), `${JSON.stringify({
...project,
files: ['src/client.ts'],
}, null, 2)}\n`)
writeFileSync(join(packageRoot, 'tsconfig.json'), `${JSON.stringify({
files: [],
references: [
{ path: './tsconfig.host.json' },
{ path: './tsconfig.client.json' },
],
}, null, 2)}\n`)
const hostAggregatePath = join(root, 'tsconfig.host.json')
const hostAggregate = JSON.parse(readFileSync(hostAggregatePath, 'utf8')) as {
references: { path: string }[]
}
hostAggregate.references.push({ path: './packages/client/tsconfig.host.json' })
writeFileSync(hostAggregatePath, `${JSON.stringify(hostAggregate, null, 2)}\n`)
const clientAggregatePath = join(root, 'tsconfig.client.json')
const clientAggregate = JSON.parse(readFileSync(clientAggregatePath, 'utf8')) as {
references: { path: string }[]
}
clientAggregate.references = [{ path: './packages/client/tsconfig.client.json' }]
writeFileSync(clientAggregatePath, `${JSON.stringify(clientAggregate, null, 2)}\n`)
}
function addSameFacePackage(root: string, specifier: string, importedName: string): void {
const packageRoot = join(root, 'packages/consumer')
mkdirSync(join(packageRoot, 'src'), { recursive: true })