diff --git a/package.json b/package.json index b1ad6853fc..def6e5bdcb 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,12 @@ "scripts": { "build": "npm run build:lib && npm run build:web", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json", - "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts", - "build:lib:client": "tsc -b tsconfig.client.json && tsdown", + "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:contracts && tsc -b", + "typecheck": "npm run build:lib:host && tsc -b tsconfig.client.json", "lint": "tsx scripts/run-oxlint.ts .", "lint:fix": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json new file mode 100644 index 0000000000..bc26c0b13f --- /dev/null +++ b/packages/api/remotes/tsconfig.client.json @@ -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" + } + ] +} diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json new file mode 100644 index 0000000000..1d4c35a9e2 --- /dev/null +++ b/packages/api/remotes/tsconfig.host.json @@ -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" + } + ] +} diff --git a/packages/api/remotes/tsconfig.json b/packages/api/remotes/tsconfig.json index 148804dc0f..2eca820546 100644 --- a/packages/api/remotes/tsconfig.json +++ b/packages/api/remotes/tsconfig.json @@ -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" } ] } diff --git a/packages/api/remotes/tsdown.config.ts b/packages/api/remotes/tsdown.config.ts index 287b2c7975..3c72df8718 100644 --- a/packages/api/remotes/tsdown.config.ts +++ b/packages/api/remotes/tsdown.config.ts @@ -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 }, +) diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 711510b705..3d97e70de5 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -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:^", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..e4f8e57b04 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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. diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index efbf7c26d7..f93546e855 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../connection" }, - { - "path": "../../api/remotes" - }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts new file mode 100644 index 0000000000..b03542c74e --- /dev/null +++ b/packages/client/schema-form/tsdown.config.ts @@ -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'], +) diff --git a/packages/client/test-runtime/tsdown.config.ts b/packages/client/test-runtime/tsdown.config.ts new file mode 100644 index 0000000000..e2cb484ffb --- /dev/null +++ b/packages/client/test-runtime/tsdown.config.ts @@ -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'], +) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 74facbd69b..f2b7b7a3e6 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -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[] + +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)) } diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 263dfceb26..1c89771abf 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -15,7 +15,7 @@ "path": "../locale" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../runtime" diff --git a/packages/client/ui-primitives/tsdown.config.ts b/packages/client/ui-primitives/tsdown.config.ts index 1532f5e5f6..cbabf4f2a1 100644 --- a/packages/client/ui-primitives/tsdown.config.ts +++ b/packages/client/ui-primitives/tsdown.config.ts @@ -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 {};' }, }], -}) +}]) diff --git a/packages/client/ui-slots/tsdown.config.ts b/packages/client/ui-slots/tsdown.config.ts new file mode 100644 index 0000000000..b199e31976 --- /dev/null +++ b/packages/client/ui-slots/tsdown.config.ts @@ -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'], +) diff --git a/packages/client/ui-theme/tsdown.config.ts b/packages/client/ui-theme/tsdown.config.ts index 08616753ce..25b80eef68 100644 --- a/packages/client/ui-theme/tsdown.config.ts +++ b/packages/client/ui-theme/tsdown.config.ts @@ -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] diff --git a/packages/client/web-react/tsdown.config.ts b/packages/client/web-react/tsdown.config.ts index 65378be678..676d6ce415 100644 --- a/packages/client/web-react/tsdown.config.ts +++ b/packages/client/web-react/tsdown.config.ts @@ -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', diff --git a/packages/client/web/tsdown.config.ts b/packages/client/web/tsdown.config.ts index 8040221e14..78527fba80 100644 --- a/packages/client/web/tsdown.config.ts +++ b/packages/client/web/tsdown.config.ts @@ -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 {};' }, }], -}) +}]) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 912f2cd794..8686a9e468 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../api/remotes" + "path": "../../api/remotes/tsconfig.host.json" }, { "path": "../../util/brand" diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 529fb7b2ac..4a4727a5aa 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -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, + }], }, -] +) diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index c5d89b3726..6f495aa318 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -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), + }) } } } diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index ca37cd1bbe..40a91e3ef5 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -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 + } + 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 + 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 }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 078f775ecf..4e50be7e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1446,9 +1446,6 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/client-bundle-css.spec.ts b/scripts/client-bundle-css.spec.ts index 30350241fc..41b3eb3679 100644 --- a/scripts/client-bundle-css.spec.ts +++ b/scripts/client-bundle-css.spec.ts @@ -15,8 +15,13 @@ interface CssPlugin { } function cssPlugin(): CssPlugin { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins + const configs = clientBundle( + '@deepseek-ai/dsh-client-test', + ['lib/types/index.js', 'lib/types/invariant.js'], + )({ env: { DSH_BUILD_FACE: 'client' } }) + const client = configs.find(config => config.platform === 'browser') + if (client === undefined) throw new Error('client config missing') + const plugins = (client as { plugins: CssPlugin[] }).plugins const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config') return plugin diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index fb47f8a9c8..aa3bae0b6d 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -14,6 +14,24 @@ interface CssModulePlugin { load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise } +function clientConfigs(id = '@deepseek-ai/dsh-client-test') { + return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])( + { env: { DSH_BUILD_FACE: 'client' } }, + ).filter(config => config.platform === 'browser') +} + +describe('client bundle build faces', () => { + it('watches source in development and consumes emitted JavaScript in the Client build', () => { + const bundle = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js']) + const development = bundle({ env: {} }).find(config => config.platform === 'browser') + const artifact = bundle({ env: { DSH_BUILD_FACE: 'client' } }) + .find(config => config.platform === 'browser') + + expect(development?.entry).toEqual({ client: 'src/client/index.ts' }) + expect(artifact?.entry).toEqual({ client: 'lib/types/client/index.js' }) + }) +}) + function clientSourceMapPath(packagePath: string): string { return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url)) } @@ -21,16 +39,16 @@ function clientSourceMapPath(packagePath: string): string { function purityResolveId(): ResolveId { // libEntry is spelled at every call site (no default) so the // package-invariants text check can see the invariant entry per package. - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: { name: string; resolveId?: unknown }[] }).plugins + const configs = clientConfigs() + const plugins = (configs[0] as { plugins: { name: string; resolveId?: unknown }[] }).plugins const gate = plugins.find(p => p.name === 'dsh-client-bundle-purity') if (gate?.resolveId === undefined) throw new Error('purity plugin missing from client config') return gate.resolveId as ResolveId } function cssModulePlugin(): CssModulePlugin { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins + const configs = clientConfigs() + const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') if (plugin?.resolveId === undefined || plugin.load === undefined) { throw new Error('CSS Modules plugin missing from client config') @@ -87,13 +105,13 @@ describe('client bundle purity gate', () => { describe('client bundle debug artifacts', () => { it('emits source maps for plugin TS and TSX outside the Vite module graph', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) - expect(configs[1]?.sourcemap).toBe(true) + const configs = clientConfigs() + expect(configs[0]?.sourcemap).toBe(true) }) it('maps first-party sources to their repository package paths', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-client-ui-goal') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') @@ -105,8 +123,8 @@ describe('client bundle debug artifacts', () => { }) it('maps dual-face host sources to the host package group', () => { - const configs = clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-host-directory-picker-native') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') @@ -116,8 +134,8 @@ describe('client bundle debug artifacts', () => { }) it('maps inlined workspace sources to packages and leaves dependencies outside it unchanged', () => { - const configs = clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js']) - const outputOptions = configs[1]?.outputOptions + const configs = clientConfigs('@deepseek-ai/dsh-client-connection') + const outputOptions = configs[0]?.outputOptions if (typeof outputOptions !== 'object' || outputOptions === null) throw new Error('client output options missing') const transform = outputOptions.sourcemapPathTransform if (transform === undefined) throw new Error('client sourcemap path transform missing') diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 6ae08150fb..456dedecfe 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -136,22 +136,25 @@ function formatDiagnostics(diagnostics: readonly ts.Diagnostic[], blocks: Block[ } /** - * Reuse the host-aggregate references from a temp project one directory below - * root. Doc fragments speak the host vocabulary, so the standalone project - * seeds tsconfig.host.json (never the root solution: flattening host+client - * into one program collides the cordis Context merges). + * Reuse both aggregate reference sets from a temp project one directory below + * root. Each referenced package remains its own program, while documentation + * examples can import either the Host or Client API. */ function workspaceReferences(): { path: string }[] { - const file = join(root, 'tsconfig.host.json') - // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path - // candidate in the workspace wildcard. - const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) - if (result.error) { - throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + const paths = new Set() + for (const aggregate of ['tsconfig.host.json', 'tsconfig.client.json']) { + const file = join(root, aggregate) + // Parse with TypeScript's own JSONC reader: a regex comment stripper corrupts the `/*/` path + // candidate in the workspace wildcard. + const result = ts.readConfigFile(file, path => readFileSync(path, 'utf8')) + if (result.error) { + throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) + } + // `config` is typed `any` by the TS API; narrow it to the one field read here. + const { references } = result.config as { references: { path: string }[] } + for (const { path } of references) paths.add(path) } - // `config` is typed `any` by the TS API; narrow it to the one field read here. - const { references } = result.config as { references: { path: string }[] } - return references.map(({ path }) => ({ + return [...paths].map(path => ({ path: path.startsWith('./') ? `../${path.slice(2)}` : `../${path}`, })) } diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 32705c4c87..59e56976e6 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -72,6 +72,20 @@ describe('package invariant gate', () => { expect(collectPackageInvariantViolations(fixture())).toEqual([]) }) + it('accepts an invariant reference owned by a package-local leaf project', () => { + const root = fixture({ invariantReference: false }) + const dir = join(root, 'packages/core/probe') + writeFileSync(join(dir, 'tsconfig.json'), `${JSON.stringify({ + files: [], + references: [{ path: './tsconfig.host.json' }], + }, null, 2)}\n`) + writeFileSync(join(dir, 'tsconfig.host.json'), `${JSON.stringify({ + references: [{ path: '../../support/invariants' }], + }, null, 2)}\n`) + + expect(collectPackageInvariantViolations(root)).toEqual([]) + }) + it('rejects missing publication metadata and build output', () => { const violations = collectPackageInvariantViolations(fixture({ invariantExport: false, diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 21bc5931ec..54318ca94f 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -118,11 +118,8 @@ function checkBuild( violations: PackageInvariantViolation[], ): void { const tsconfigPath = `${owner.dir}/tsconfig.json` - const tsconfig = JSON.parse(readFileSync(resolve(root, tsconfigPath), 'utf8')) as { - references?: Array<{ path?: string }> - } if (owner.packageName !== '@deepseek-ai/dsh-invariants' - && !tsconfig.references?.some(reference => reference.path === '../../support/invariants')) { + && !projectReferencesInvariants(root, owner.dir, tsconfigPath)) { addViolation( violations, tsconfigPath, @@ -138,6 +135,31 @@ function checkBuild( } } +function projectReferencesInvariants(root: string, ownerDir: string, entryPath: string): boolean { + const ownerRoot = resolve(root, ownerDir) + const target = resolve(root, 'packages/support/invariants') + const pending = [resolve(root, entryPath)] + const visited = new Set() + while (pending.length > 0) { + const configPath = pending.pop() + if (configPath === undefined) break + if (visited.has(configPath)) continue + visited.add(configPath) + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + references?: Array<{ path?: string }> + } + for (const reference of config.references ?? []) { + if (reference.path === undefined) continue + const referenced = resolve(dirname(configPath), reference.path) + if (referenced === target) return true + if (!referenced.startsWith(`${ownerRoot}${sep}`)) continue + const childConfig = referenced.endsWith('.json') ? referenced : resolve(referenced, 'tsconfig.json') + if (existsSync(childConfig)) pending.push(childConfig) + } + } + return false +} + function checkSource( owner: PackageInvariantOwner, root: string, diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 1f5de1dcbd..b6d8ffffb0 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -204,15 +204,14 @@ cat "$scratch/logs/smoke.log" grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } # ---- the two blocking surfaces, concurrently ------------------------------ -# The build preserves the face order from package.json: generate Host contracts -# before either aggregate typecheck, then bundle the completed workspace. +# The build preserves the face order from package.json: compile and bundle the +# Host face before compiling and bundling the Client face. # Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/contracts-tsc.log" "$tsc_js" -b packages/typert/generator --pretty false || return $? - wine_node "$scratch/logs/contracts-tsdown.log" "$tsdown_js" --config tsdown.typert-host.config.ts || return $? wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? - wine_node "$scratch/logs/tsdown.log" "$tsdown_js" + wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client } site_gate() { cd website @@ -238,12 +237,11 @@ report() { for log in "$@"; do tail -n 200 "$log" >&2 || true; done fi } -report 'build (contract prepass, tsc, tsdown)' "$build_status" \ - "$scratch/logs/contracts-tsc.log" \ - "$scratch/logs/contracts-tsdown.log" \ +report 'build (Host tsc/tsdown, Client tsc/tsdown)' "$build_status" \ "$scratch/logs/host-tsc.log" \ + "$scratch/logs/host-tsdown.log" \ "$scratch/logs/client-tsc.log" \ - "$scratch/logs/tsdown.log" + "$scratch/logs/client-tsdown.log" report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" if (( build_status != 0 )); then exit "$build_status"; fi exit "$site_status" diff --git a/tsconfig.client.json b/tsconfig.client.json index 9821c0e41b..2a2b16e2e7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -53,7 +53,7 @@ { "path": "./packages/client/connection" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway" }, - { "path": "./packages/api/remotes" }, + { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/client/runtime" }, { "path": "./packages/client/test-runtime" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 61e42e2fc6..7e7cd982fc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -104,6 +104,7 @@ { "path": "./packages/typert/type-meta" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway" }, + { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-checkpoint-policy" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d503c62d3..2042e81db3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,34 +1,30 @@ import { defineConfig } from 'tsdown' import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' +function isBuildFaceClient(value: unknown): boolean { + if (value === undefined || value === 'host') return false + if (value === 'client') return true + throw new Error(`tsdown: --env.DSH_BUILD_FACE must be host or client, received ${String(value)}`) +} + /** - * JS bundling for vendored Cordis and Harness TypeScript packages. - * TypeScript source is compiled first by `tsc -b` (the root solution); tsdown - * reads only the emitted JS under lib/types and writes the package root and - * invariant companion runtime bundles. Declarations are NOT produced here, - * hence `dts: false`. - * - * Per-package shape overrides live in `/tsdown.config.ts` - * (schemastery: dual ESM+CJS; logger-console: extra browser entry). + * The ordinary workspace build consumes JavaScript emitted by the Host + * TypeScript project and runs TypeRT. The Client pass selects packages that + * declare a browser bundle and lets their package-local configs emit both + * their Node loader entry and browser artifact. */ -export default defineConfig({ - // Explicit globs keep bundling to vendored Cordis, the TypeScript package tree, and - // the Node CLI assembly. `apps/web` is a Vite application with no lib/types entry; - // `workspace: true` or `apps/*` would incorrectly treat it as a package bundle. - workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - // The brace glob admits the package companion when present while retaining the - // index-only build for vendored Cordis packages outside the Harness package tree. - entry: ['lib/types/{index,invariant}.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - // All packages set "type": "module"; fixedExtension false keeps ESM output - // at .js (not .mjs), matching the package.json main/exports fields. - fixedExtension: false, - dts: false, - clean: false, - // The final pass sees both independent TypeScript faces. Workspace mode - // writes only packages that explicitly publish a Typert/Remote subpath. - plugins: [typertPlugin({ mode: 'workspace' })], +export default defineConfig(({ env }) => { + const client = isBuildFaceClient(env?.DSH_BUILD_FACE) + return { + workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], + entry: client ? '' : ['lib/types/{index,invariant}.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: client ? [] : [typertPlugin({ mode: 'workspace', faces: ['host'] })], + } }) diff --git a/tsdown.typert-host.config.ts b/tsdown.typert-host.config.ts deleted file mode 100644 index 8c8ae11dd1..0000000000 --- a/tsdown.typert-host.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'tsdown' -import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' - -/** - * Host-only TypeRT contract prepass. The generator and its project references - * are compiled first; the plugin then analyzes Host source and emits local and - * Host-for-Client artifacts before either aggregate consumes Remote subpaths. - */ -export default defineConfig({ - workspace: ['packages/typert/generator'], - entry: ['lib/types/{index,invariant}.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - plugins: [typertPlugin({ mode: 'workspace', faces: ['host'] })], -})