Преглед изворни кода

build: order Host and Client compilation faces

imccyu пре 1 месец
родитељ
комит
9c3d5725a5
32 измењених фајлова са 440 додато и 184 уклоњено
  1. 3 4
      package.json
  2. 22 0
      packages/api/remotes/tsconfig.client.json
  3. 36 0
      packages/api/remotes/tsconfig.host.json
  4. 3 34
      packages/api/remotes/tsconfig.json
  5. 5 1
      packages/api/remotes/tsdown.config.ts
  6. 0 3
      packages/client/runtime/package.json
  7. 2 3
      packages/client/runtime/src/client/index.ts
  8. 0 3
      packages/client/runtime/tsconfig.json
  9. 6 0
      packages/client/schema-form/tsdown.config.ts
  10. 6 0
      packages/client/test-runtime/tsdown.config.ts
  11. 98 11
      packages/client/tsdown.client.ts
  12. 1 1
      packages/client/ui-goal/tsconfig.json
  13. 3 3
      packages/client/ui-primitives/tsdown.config.ts
  14. 6 0
      packages/client/ui-slots/tsdown.config.ts
  15. 6 6
      packages/client/ui-theme/tsdown.config.ts
  16. 2 2
      packages/client/web-react/tsdown.config.ts
  17. 3 3
      packages/client/web/tsdown.config.ts
  18. 1 1
      packages/host/apiproxy/tsconfig.json
  19. 16 13
      packages/host/directory-picker-native/tsdown.config.ts
  20. 13 4
      packages/typert/generator/src/analyzer.ts
  21. 82 0
      packages/typert/generator/tests/type-model.spec.ts
  22. 0 3
      pnpm-lock.yaml
  23. 7 2
      scripts/client-bundle-css.spec.ts
  24. 30 12
      scripts/client-bundle-purity.spec.ts
  25. 16 13
      scripts/doc-typecheck.ts
  26. 14 0
      scripts/package-invariants.spec.ts
  27. 26 4
      scripts/package-invariants.ts
  28. 7 9
      scripts/wine-windows-gates.sh
  29. 1 1
      tsconfig.client.json
  30. 1 0
      tsconfig.host.json
  31. 24 28
      tsdown.config.ts
  32. 0 20
      tsdown.typert-host.config.ts

+ 3 - 4
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",

+ 22 - 0
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"
+    }
+  ]
+}

+ 36 - 0
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"
+    }
+  ]
+}

+ 3 - 34
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"
     }
   ]
 }

+ 5 - 1
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 },
+)

+ 0 - 3
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:^",

+ 2 - 3
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.

+ 0 - 3
packages/client/runtime/tsconfig.json

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

+ 6 - 0
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'],
+)

+ 6 - 0
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'],
+)

+ 98 - 11
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[],
+  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 clientBundle(id: string, libEntry: readonly string[]): [UserConfig, UserConfig] {
-  return [{
+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))
 }

+ 1 - 1
packages/client/ui-goal/tsconfig.json

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

+ 3 - 3
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 {};'
     },
   }],
-})
+}])

+ 6 - 0
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'],
+)

+ 6 - 6
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]

+ 2 - 2
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',

+ 3 - 3
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 {};'
     },
   }],
-})
+}])

+ 1 - 1
packages/host/apiproxy/tsconfig.json

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

+ 16 - 13
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,
+    }],
   },
-]
+)

+ 13 - 4
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),
+          })
         }
       }
     }

+ 82 - 0
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<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 })

+ 0 - 3
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

+ 7 - 2
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

+ 30 - 12
scripts/client-bundle-purity.spec.ts

@@ -14,6 +14,24 @@ interface CssModulePlugin {
   load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
 }
 
+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')

+ 16 - 13
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<string>()
+  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}`,
   }))
 }

+ 14 - 0
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,

+ 26 - 4
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<string>()
+  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,

+ 7 - 9
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"

+ 1 - 1
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" },

+ 1 - 0
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" },

+ 24 - 28
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 `<package>/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'] })],
+  }
 })

+ 0 - 20
tsdown.typert-host.config.ts

@@ -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'] })],
-})