build(client): enforce client package boundaries

This commit is contained in:
imccyu
2026-08-18 01:34:53 +08:00
parent ed35d11dde
commit aa03eff500
68 changed files with 2408 additions and 532 deletions
+4 -6
View File
@@ -22,19 +22,15 @@
"scripts": {
"build": "vite build",
"dev": "vite",
"watch": "vite build --watch"
"watch": "vite build --watch --no-emptyOutDir"
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-client-web": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-group": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@types/node": "^22.0.0",
@@ -42,6 +38,8 @@
"@types/react-dom": "~18.3.0",
"@vitejs/plugin-react": "^4.0.0",
"playwright": "^1.49.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^6.0.3",
"vite": "^6.0.0",
"vitest": "^4.1.8",
+14 -14
View File
@@ -127,22 +127,22 @@ export default defineConfig({
},
},
resolve: {
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
// Only the shell's normal package entry is aliased — plugin packages are
// NEVER bundled here (shell self-sufficiency — see
// packages/client/web/README.md); they arrive as runtime
// bundles through the client module system. Order matters — subpath
// aliases must win over bare-name prefixes.
// One instance per shared npm identity: a bare specifier otherwise resolves
// from the importer's directory, so a diverging range ships a second React
// and splits hook and element identity. Entries are package ids — they cover
// react/jsx-runtime and react-dom/client — and resolve from this package's
// node_modules, so react must stay a devDependency here and any watcher must
// run vite from this directory (scripts/dev-web.ts). Workspace packages need
// no entry: pnpm links each of them to a single directory.
dedupe: ['react', 'react-dom'],
// Workspace packages are consumed as built lib products: each resolves
// through its own package.json exports from the importer's directory, and
// CSS still rides Vite's pipeline because the client build preset emits it
// beside the bundle. Plugin packages never enter this graph; they arrive as
// runtime bundles through the client module system. The remaining alias
// browserizes the vendored Cordis Loader's only Node import.
alias: [
// Browserization of the vendored cordis Loader: its only node-only
// import; the two process probes are mapped by `define` below.
{ find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
{ find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
],
},
define: {
+35
View File
@@ -708,6 +708,9 @@
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-client-connection"
]
},
"apps/web": {
@@ -787,6 +790,38 @@
"ignoreDependencies": [
"@deepseek-ai/.+"
]
},
"packages/client/locale": {
"ignoreDependencies": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-connection"
]
},
"packages/client/ui-agent-preset": {
"ignoreDependencies": [
"@deepseek-ai/dsh-client-connection"
]
},
"packages/client/ui-conversation": {
"ignoreDependencies": [
"@deepseek-ai/dsh-api-remotes"
]
},
"packages/client/ui-permission-presets": {
"ignoreDependencies": [
"@deepseek-ai/dsh-client-connection"
]
},
"packages/client/ui-settings-general": {
"ignoreDependencies": [
"@deepseek-ai/dsh-client-connection"
]
},
"packages/client/ui-theme": {
"ignoreDependencies": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-connection"
]
}
}
}
+2 -1
View File
@@ -97,6 +97,7 @@
"verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
"verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts",
"verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
"verify-client-packages": "tsx scripts/verify-client-packages.ts",
"verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
"verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
"rescope-vendor": "tsx scripts/rescope-vendor.ts",
@@ -126,7 +127,7 @@
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
"hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links",
"hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-packages && pnpm run verify-vendored-links",
"publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts",
"release:dsh": "tsx scripts/release/bump.ts --family dsh",
"release:vendor": "tsx scripts/release/bump.ts --family vendor",
+40 -1
View File
@@ -54,6 +54,44 @@ Non-negotiables across the layers:
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Dependency declaration
Npm sections describe installation and development relationships; each build face independently decides what its artifact contains. [`verify-client-packages`](../../scripts/verify-client-packages.ts) checks the client-specific rules and can repair unambiguous manifest drift with `--fix`.
1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract.
2. **A dynamic package declares internal dynamic relationships as peer plus dev.** Production source imports, re-exports, module augmentations, and type-only references to an `@deepseek-ai/dsh-*` package count, as does a package named by `dsh.client.inject`. A test-only internal dependency stays dev-only.
3. **Static client inputs are dev-only for a dynamic consumer.** A package without `dsh.client`, plus the React modules seeded by the web shell, belongs only in the consumer's `devDependencies`; it never belongs in that dynamic package's `dependencies` or `peerDependencies`. `packages/client/web` likewise keeps Loader, modules, and static UI inputs as development inputs; Cordis remains peer plus dev.
4. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter. A dynamic package never puts an `@deepseek-ai/dsh-*` package in `dependencies`.
5. **Every peer has a matching development range.** npm dependency and peer cycles are allowed; only the synchronous module-request graph has the separate acyclicity rule below.
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
## Shared modules and the module graph
A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot.
1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, runtime, `ui-primitives`, or `ui-slots` in package manifests.
2. **`dsh.client.external` adds a package-specific request.** Use it only for a non-baseline value import whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row.
3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request.
4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol.
5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles.
### The module graph sits below cordis DI
Three declarations read like dependency edges and none is interchangeable: Cordis service `inject`, module-graph `external`, and `dsh.client.inject` — the informational package-name edges of the [new-package checklist](#new-plugin-package-checklist).
| | Cordis service `inject` | module graph `external` |
|---|---|---|
| Unit | service name | module specifier |
| Timing | runtime; the fiber waits | materialization; the `require` handed to a factory is synchronous and cannot wait |
| Unsatisfied | stays PENDING, with no timeout | throws on the spot |
| Who may satisfy it | any plugin providing that service, replaceable | the single module identity, not replaceable |
| Cycles | allowed | rejected |
The seam is `loader.internal = modules`: cordis reaches plugin code through `EntryTree.import`, so every module request must be satisfiable before cordis can order activation above it. Script-tag order is therefore the module graph's topological order — providers before consumers — computed by the modules node half and injected by the host. The two orders can run opposite: a provider that injects services loads its script first and activates last.
`packages/client/web` is not a Loader entry. Its static imports seed `PLATFORM_MODULES`; parser-preloaded dynamic rows remain ordinary Loader entries and ordinary `lib/client.js` artifacts.
## Conversation Node discipline
- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md).
@@ -93,9 +131,10 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph).
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
6. **Declaration decisions**, each settled by [dependency declaration](#dependency-declaration) and [shared modules](#shared-modules-and-the-module-graph): does the package ship a `./client` export; which non-baseline value imports require `dsh.client.external`; which dynamic value dependencies are peer plus dev; which static compile inputs are dev-only; and whether `files` covers every relative runtime import and emitted asset.
## New component checklist
+14 -8
View File
@@ -38,12 +38,6 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"ws": "^8.21.0"
},
@@ -56,12 +50,24 @@
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/ws": "^8.18.1",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
}
}
+4 -5
View File
@@ -47,11 +47,9 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -63,10 +61,11 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
},
"files": [
+3 -1
View File
@@ -55,6 +55,8 @@
],
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^"
}
}
+9 -7
View File
@@ -2,21 +2,23 @@
* Browser half (the standard `./client` export): the module-system class and
* wire contract, plus the enrollment plugin face. The module system itself is
* built by the shell kernel BEFORE cordis exists (the bootstrap exception —
* the mechanism that loads plugins cannot arrive through
* itself); the plugin face only enrolls that pre-existing instance by
* providing it as `ctx.modules`. The kernel statically registers this module,
* so the graph row for this package never triggers a real fetch — arrival is
* a no-op against the already-registered entry.
* the mechanism that loads plugins cannot arrive through itself). The host
* parser-preloads this ordinary client bundle into the handoff queue; the
* kernel claims and materializes that handoff, constructs the system, and
* registers the same exports for this package's graph row. The plugin face
* only enrolls that pre-existing instance by providing it as `ctx.modules`.
* @module @deepseek-ai/dsh-client-modules/client
*/
import type { Context } from '@deepseek-ai/cordis'
import type { DshWindow } from './manifest.ts'
export { ClientModuleSystem } from './system.ts'
export { parseBootManifest } from './manifest.ts'
export { parseBootManifest, stripClientSuffix } from './manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow, WebBootEntry, WebBootGraph,
ClientModuleHandoffQueue, ClientModuleHandoffSink, ClientModuleHandoffTarget,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
WebBootEntry, WebBootGraph,
} from './manifest.ts'
/**
+90 -21
View File
@@ -16,13 +16,13 @@
* so load order needs no external sequencing.
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → exports; static registry (shell-own modules, e.g. app-shell) →
* record → exports; static registry (pre-materialized bootstrap modules) →
* module; registered factory → materialize; graph row → load + materialize;
* anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the load branch: loading is async,
* so only already-registered bundles can be required — and cross-plugin value
* imports are a build error anyway.
* anything else → throw (loud — the runtime mirror of the build-time bundle
* purity gate).
* The synchronous `require` handed to factories walks the same order minus
* the load branch. Loading is async, so a requested dynamic package must have
* registered its factory before a consumer materializes.
*
* This file is the browser-safe contract face (zero node imports): the
* `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around
@@ -45,7 +45,9 @@ declare module '@deepseek-ai/cordis' {
* single source: the host node half (package root) produces this same shape.
* `immediately` marks stage-one prefetch; `inject` is informational graph
* metadata (the authoritative edges live in each package's `dsh.client`
* declaration and reach fibers through entry creation).
* declaration and reach fibers through entry creation). `external` carries
* module-graph edges: unlike `inject`, they constrain code arrival because
* `require` is synchronous (see {@link WebBootGraph.entries}).
*/
export interface WebBootEntry {
/** Entry name == package name. */
@@ -58,13 +60,19 @@ export interface WebBootEntry {
inject?: string[]
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
immediately?: boolean
/** Non-baseline module specifiers this row requests; omitted when it requests none. */
external?: string[]
}
/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */
export interface WebBootGraph {
/** Consistency anchor over the whole graph (content + bundle hashes). */
rev: string
/** Composed entries; order carries no semantics (activation order is fiber inject waiting). */
/**
* Composed entries in module-graph order — a dynamic package row precedes
* rows whose `external` requests that package. Cordis activation order is
* unrelated and remains owned by fiber service waiting.
*/
entries: WebBootEntry[]
}
@@ -76,6 +84,8 @@ export interface BootModuleRow {
url: string
/** Bundle content hash. */
rev: string
/** Module specifiers this row requests from the module table ([] when the wire omits them). */
external: string[]
}
/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */
@@ -98,6 +108,36 @@ export interface BootManifest {
plugins: BootPluginRow[]
}
/**
* Validate an optional string-array field read from a `dsh.client` declaration
* or from the boot wire.
* @param subject - diagnostic prefix naming the package or the wire row.
* @param field - field name as it appears in the diagnostic.
* @param value - the raw field value.
* @returns the validated array, or undefined when the field is absent.
* @throws {Error} when the value is present but is not an array of strings.
*/
export function optionalStringArray(subject: string, field: string, value: unknown): string[] | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
throw new Error(`client-modules: ${subject} ${field} must be a string array`)
}
return value as string[]
}
/**
* Normalize a module specifier onto the graph row that owns it: a plugin bundle
* IS its package's client half, so `<id>/client` (the exports subpath external
* bundles emit) and the bare package name resolve to the same exports. Both the
* require path and graph composition normalize here, which is what lets each
* importing package request the subpath its own code imports.
* @param spec - module specifier as a bundle requires it or a declaration spells it.
* @returns the specifier with a trailing `/client` removed.
*/
export function stripClientSuffix(spec: string): string {
return spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
}
/**
* Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary:
* a missing or malformed graph throws (the shell shows the loud failure —
@@ -127,16 +167,21 @@ export function parseBootManifest(wire: unknown): BootManifest {
if (typeof row.id !== 'string' || typeof row.url !== 'string' || typeof row.rev !== 'string') {
throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`)
}
if (row.inject !== undefined && (!Array.isArray(row.inject) || row.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: boot manifest entry ${where} inject must be a string array`)
}
const subject = `boot manifest entry ${where}`
const inject = optionalStringArray(subject, 'inject', row.inject)
const external = optionalStringArray(subject, 'external', row.external)
if (row.immediately !== undefined && typeof row.immediately !== 'boolean') {
throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`)
}
modules.push({ id: row.id, url: row.url, rev: row.rev })
modules.push({
id: row.id,
url: row.url,
rev: row.rev,
external: external === undefined ? [] : [...external],
})
plugins.push({
id: row.id,
inject: row.inject === undefined ? [] : [...row.inject as string[]],
inject: inject === undefined ? [] : [...inject],
immediately: row.immediately === true,
})
}
@@ -155,12 +200,36 @@ export interface ClientPluginHandoff {
factory: (require: (spec: string) => unknown) => Record<string, unknown>
}
/** Inline HTML queue installed before a preloaded client bundle executes. */
export interface ClientModuleHandoffQueue {
/** Discriminant that lets the module system distinguish the bootstrap queue from a live sink. */
mode: 'queue'
/**
* Handoffs received before {@link ClientModuleSystem} exists; the kernel
* claims modules before the rest drain.
*/
handoffs: ClientPluginHandoff[]
/** Append one preloaded bundle handoff for later adoption. */
load(handoff: ClientPluginHandoff): void
}
/** Live registration sink installed by {@link ClientModuleSystem}. */
export interface ClientModuleHandoffSink {
/** Discriminant used to reject a second module-system boot. */
mode: 'live'
/** Register one bundle factory immediately. */
load(handoff: ClientPluginHandoff): void
}
/** Bootstrap queue before module-system construction, then the live registration sink. */
export type ClientModuleHandoffTarget = ClientModuleHandoffQueue | ClientModuleHandoffSink
/** Window API of the web boot protocol: the host-injected graph, registration sink, and kernel handoff slot. */
export interface DshWindow {
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
__DSH_BOOT__?: unknown
/** Bundle registration sink; installed once per page by the {@link ClientModuleSystem} constructor. */
__ModuleLoader__?: { load(handoff: ClientPluginHandoff): void }
/** Bundle handoff target: an HTML bootstrap queue, then the live module-system sink. */
__ModuleLoader__?: ClientModuleHandoffTarget
/**
* Kernel handoff slot: the shell kernel stores the instance here right
* after construction (before cordis exists) so the `./client` wrapper
@@ -174,7 +243,7 @@ export interface DshWindow {
export interface ClientModuleRecord {
/** Module id (entry name / package name). */
id: string
/** Materialized exports (`module.exports` from a factory, or a statically registered shell module). */
/** Materialized exports (`module.exports` from a factory or bootstrap registration). */
exports: unknown
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
styles: string[]
@@ -203,16 +272,16 @@ export interface ClientModuleLoader {
*/
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
/**
* Register a shell-own module (app-shell — code that ships inside the shell
* bundle and never arrives as a plugin bundle).
* @param id - entry name (shell-owned pseudo id).
* @param module - the statically imported module namespace.
* Register an already-materialized bootstrap module whose handoff was
* removed from the HTML queue before this system was constructed.
* @param id - graph entry name.
* @param module - the materialized module exports.
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: load the entry's script to register its factory (no
* materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* No-op for bootstrap-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* load (HMR), {@link invalidate} first.
* @param id - graph entry name.
+28 -25
View File
@@ -4,9 +4,10 @@
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the load/materialize machinery.
*/
import { stripClientSuffix } from './manifest.ts'
import type {
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
ClientModuleHandoffSink, ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** Default bundle-load hook: same-origin external classic script. */
@@ -25,14 +26,6 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
document.head.append(el)
})
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
* subpath external bundles emit) and the bare graph id name the same
* exports, so table lookups normalize the suffix away.
*/
const stripClientSuffix = (spec: string): string =>
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
/**
* Claim and inventory the <style> tags a factory injected during
* materialization: preset-emitted tags arrive pre-tagged with data-plugin;
@@ -84,15 +77,26 @@ export class ClientModuleSystem implements ClientModuleLoader {
}
const win = globalThis as DshWindow
if (win.__ModuleLoader__ !== undefined) throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
win.__ModuleLoader__ = {
load: (handoff: ClientPluginHandoff): void => {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
this.factories.set(handoff.id, handoff.factory)
},
const queued = win.__ModuleLoader__
if (queued !== undefined && queued.mode !== 'queue') {
throw new Error('client-modules: window.__ModuleLoader__ already installed (double boot?)')
}
const sink: ClientModuleHandoffSink = {
mode: 'live',
load: (handoff) => { this.register(handoff) },
}
// Replace first: a bundle that executes while queued handoffs are draining
// must register against the live sink rather than append behind the drain.
win.__ModuleLoader__ = sink
for (const handoff of queued?.handoffs ?? []) sink.load(handoff)
}
/** Register one bundle factory, rejecting a script that executes twice without invalidation. */
private register(handoff: ClientPluginHandoff): void {
if (this.factories.has(handoff.id)) {
throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
}
this.factories.set(handoff.id, handoff.factory)
}
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
@@ -134,10 +138,9 @@ export class ClientModuleSystem implements ClientModuleLoader {
/**
* The synchronous require answered to factories: seed → static → memoized
* record → registered factory (recursive materialization — this is what
* makes load order self-resolving). Fetching is async and therefore
* unreachable from here; an unregistered plugin specifier is loud (and a
* cross-plugin value import is already a build error upstream).
* record → registered factory. Fetching is async and therefore unreachable
* from here; an external dynamic package must have arrived before its
* consumer materializes.
*/
private makeRequire(edges: Set<string>): (spec: string) => unknown {
return (spec: string): unknown => {
@@ -149,8 +152,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
if (record !== undefined) return record.exports
if (this.factories.has(id)) return this.materialize(id).exports
throw new Error(
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a bootstrap module, `
+ 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
)
}
}
@@ -168,7 +171,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
const row = this.graphRows.get(specifier)
if (row === undefined) {
throw new Error(
`client-modules: cannot resolve "${specifier}" — not a seed word, not a shell-own module, `
`client-modules: cannot resolve "${specifier}" — not a seed word, not a bootstrap module, `
+ 'and not a row in the boot graph (the runtime mirror of the bundle purity gate)',
)
}
@@ -178,7 +181,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
}
registerStatic(id: string, module: unknown): void {
if (this.statics.has(id)) throw new Error(`client-modules: shell-own module "${id}" registered twice`)
if (this.statics.has(id)) throw new Error(`client-modules: bootstrap module "${id}" registered twice`)
this.statics.set(id, module)
}
+127 -28
View File
@@ -2,10 +2,11 @@
* Node half of the client module system (`dsh.client` dual-face package): scans
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
* map, taps the index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
* in `./client/manifest.ts`) in module-graph order, serves
* `/plugins/<id>/client.js` and its source map, taps the index render to
* inject the boot manifest plus the parser-blocking bootstrap preloads, and
* provides the `clientModuleHost` service (the HMR node half's
* registration/notification face).
*
* Scanning is incremental per package — there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -30,8 +31,10 @@ import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
export { stripClientSuffix } from './client/manifest.ts'
export type {
BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph,
} from './client/manifest.ts'
@@ -49,13 +52,27 @@ interface DshClientDeclaration {
platform: string
/** Boot phase-one prefetch mark; absent means lazy (fetched on demand). */
immediately?: boolean
/**
* Exact module-table requests beyond the implicit client baseline. Any
* specifier is valid, including subpaths such as `<pkg>/client`; each
* importing package declares its own exceptional requests. A type-only
* import is not a request because the transform erases it before resolution.
* Absent means the package uses only the baseline externals.
*/
external?: string[]
}
/** The declared fields a graph row carries, normalized (absent array declarations become empty). */
interface WebBootRowFields {
inject?: string[]
/** Module specifiers the package requests from the module table. */
external: string[]
immediately: boolean
}
/** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
interface PkgMeta {
interface PkgMeta extends WebBootRowFields {
clientPath: string
inject?: string[]
immediately: boolean
}
/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
@@ -99,10 +116,10 @@ class ClientPackageCompositionError extends AggregateError {
}
}
/** One composed table row: the wire entry plus its bundle path. */
/** One composed table row: the wire entry plus the resolved package metadata behind it. */
interface WebPluginRecord {
entry: WebBootEntry
clientPath: string
meta: PkgMeta
}
/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */
@@ -115,15 +132,15 @@ function parseDshClient(pkgName: string, value: unknown): DshClientDeclaration |
if (typeof decl.platform !== 'string') {
throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`)
}
if (decl.inject !== undefined && (!Array.isArray(decl.inject) || decl.inject.some(i => typeof i !== 'string'))) {
throw new Error(`client-modules: ${pkgName} dsh.client.inject must be a string array`)
}
const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject)
const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external)
if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') {
throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`)
}
return {
platform: decl.platform,
...(decl.inject !== undefined ? { inject: decl.inject as string[] } : {}),
...(inject !== undefined ? { inject } : {}),
...(external !== undefined ? { external } : {}),
...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}),
}
}
@@ -147,27 +164,98 @@ function shortHash(input: string | Buffer): string {
}
/** Graph row for one bundle rev (url carries the rev as its cache-busting query). */
function graphRow(id: string, rev: string, injectEdges: string[] | undefined, immediately: boolean): WebBootEntry {
function graphRow(id: string, rev: string, fields: WebBootRowFields): WebBootEntry {
return {
id,
url: `/plugins/${id}/client.js?rev=${rev}`,
rev,
...(injectEdges !== undefined ? { inject: injectEdges } : {}),
...(immediately ? { immediately: true } : {}),
...(fields.inject !== undefined ? { inject: fields.inject } : {}),
...(fields.immediately ? { immediately: true } : {}),
...(fields.external.length > 0 ? { external: fields.external } : {}),
}
}
/**
* Inject the boot entry graph into index.html: `window.__DSH_BOOT__` as the
* first script in <head> (before the shell bundle reads it). `<` is escaped in
* the JSON so plugin-controlled strings cannot break out of the script element.
* Order composed rows so every requested dynamic package precedes its
* consumers. An `external` specifier is either the package row it names
* (`<pkg>/client` aliases the bare package) or a static-table name that adds no
* graph edge.
* @param entries - composed rows in scan order.
* @returns the same rows reordered; scan order breaks every tie.
* @throws {Error} when a row requests itself or when the module graph has a
* cycle; the message lists the packages on it.
*/
export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEntry[] {
const rowsById = new Map<string, WebBootEntry>()
for (const entry of entries) rowsById.set(entry.id, entry)
const ordered: WebBootEntry[] = []
const placed = new Set<string>()
const open: string[] = []
const visit = (entry: WebBootEntry): void => {
if (placed.has(entry.id)) return
const cycleStart = open.indexOf(entry.id)
if (cycleStart !== -1) {
throw new Error(
`client-modules: module graph cycle ${[...open.slice(cycleStart), entry.id].join(' -> ')} `
+ '— a requested package row must precede its consumers, and factory-form CJS cannot deliver partial exports',
)
}
open.push(entry.id)
for (const name of entry.external ?? []) {
const dependency = rowsById.get(name) ?? rowsById.get(stripClientSuffix(name))
if (dependency === entry) {
throw new Error(
`client-modules: "${entry.id}" requests module "${name}" that it answers itself `
+ '— a row must not declare its own package in dsh.client.external',
)
}
if (dependency !== undefined) visit(dependency)
}
open.pop()
placed.add(entry.id)
ordered.push(entry)
}
for (const entry of entries) visit(entry)
return ordered
}
/** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */
const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules'
/** Dynamic package whose ordinary client bundle must be registered before plugin boot starts. */
const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
/** Escape a graph URL before placing it in a quoted HTML attribute. */
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
/**
* Inject the boot protocol into index.html. The inline handoff queue precedes
* blocking classic scripts for modules' and runtime's ordinary
* `lib/client.js` artifacts. The shell claims the modules handoff to construct
* the module system, which then adopts the remaining queued registrations.
* The graph script follows before the shell reads it. `<` is escaped in JSON
* so a plugin-controlled string cannot break out of the script element.
* @param html - the index.html source.
* @param graph - the composed entry graph.
* @returns the html with the graph script injected.
*/
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
const queue = '<script>(()=>{const handoffs=[];window.__ModuleLoader__={mode:"queue",handoffs,load(handoff){handoffs.push(handoff)}}})()</script>'
const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
.filter((entry): entry is WebBootEntry => entry !== undefined)
.map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
.join('')
const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
@@ -262,7 +350,7 @@ export class ClientModuleRegistry extends Service {
* @returns the path, or undefined for an unknown id.
*/
clientPath(id: string): string | undefined {
return this.table.get(id)?.clientPath
return this.table.get(id)?.meta.clientPath
}
/**
@@ -274,9 +362,9 @@ export class ClientModuleRegistry extends Service {
rebuilt(id: string): string | undefined {
const record = this.table.get(id)
if (record === undefined) return undefined
const rev = shortHash(readFileSync(record.clientPath))
const rev = shortHash(readFileSync(record.meta.clientPath))
if (rev === record.entry.rev) return rev
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
record.entry = graphRow(id, rev, record.meta)
this.composed = this.compose()
for (const notify of this.rebuildListeners) {
// Containment: rebuilt() runs inside the HMR watch callback — a
@@ -313,7 +401,7 @@ export class ClientModuleRegistry extends Service {
}
private compose(): WebBootGraph {
const entries = [...this.table.values()].map(record => record.entry)
const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
return { rev: shortHash(JSON.stringify(entries)), entries }
}
@@ -358,6 +446,7 @@ export class ClientModuleRegistry extends Service {
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
external: decl.external ?? [],
immediately: decl.immediately === true,
}
this.pkgMeta.set(pkgName, meta)
@@ -396,7 +485,7 @@ export class ClientModuleRegistry extends Service {
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
const rev = this.initialBundleRevision(entryName, meta.clientPath)
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
this.table.set(entryName, { entry: graphRow(entryName, rev, meta), meta })
return true
}
@@ -412,10 +501,20 @@ export class ClientModuleRegistry extends Service {
onError(error instanceof Error ? error : new Error(String(error)))
}
}
if (changed) {
this.composed = this.compose()
this.notifyGraphChanged()
if (!changed) return
let composed: WebBootGraph
try {
composed = this.compose()
} catch (error) {
// An unorderable module graph (cycle, or one specifier claimed by two
// providers) is a property of the whole table, not of the arriving
// package, so it surfaces here: aggregated into the activation throw, or
// warned in steady state while the last orderable graph stays served.
onError(error as Error)
return
}
this.composed = composed
this.notifyGraphChanged()
}
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
+4 -1
View File
@@ -1,3 +1,6 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-modules', ['lib/types/index.js', 'lib/types/invariant.js'])
export default clientBundle(
'@deepseek-ai/dsh-client-modules',
['lib/types/index.js', 'lib/types/invariant.js'],
)
+26 -15
View File
@@ -42,20 +42,7 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"immer": "^10.1.1",
"react": "^18.2.0",
"zustand": "~4.4.7"
},
"peerDependencies": {
@@ -63,7 +50,18 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^"
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -72,7 +70,20 @@
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1"
"@types/react": "~18.3.1",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"files": [
"lib/index.js",
+300 -42
View File
@@ -9,12 +9,14 @@
* loaders register 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 { existsSync, globSync, readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { PLATFORM_MODULES } from './web/src/platform.ts'
import { optionalStringArray } from './modules/src/client/manifest.ts'
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
/**
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
@@ -60,8 +62,8 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
* would read them as plugin packages. They carry no cross-plugin runtime
* identity to share — the framework itself is a platform module (external),
* while these are ordinary libraries a browser bundle inlines.
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
@@ -74,21 +76,6 @@ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
*/
const SKIP_WORKSPACE_BUILD: UserConfig = { entry: '' }
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
* five importers (locale, ui-layout, ui-conversation ×3) ride this single
* exemption. At runtime the lazy CJS table answers the require natively:
* runtime is an immediately-tier row, its factory is registered before any
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
* store-engine relocation follow-up.
*/
const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
/** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */
@@ -123,16 +110,67 @@ export function clientBundle(
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 clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
const client = clientConfig(id, clientEntry)
const node = [lib, ...(options.companions ?? [])]
if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
if (face === 'client') return options.hostPhase === true ? [client] : [...node, client]
if (face === 'client') {
return options.hostPhase === true ? [client] : [...node, client]
}
return [...node, client]
}
}
/**
* Build the tsdown config for a client library the compile shell links
* statically (the static assembly channel: `apps/web` resolves the package
* name, bundles the artifact, and owns the chunk layout and the CSS pipeline).
*
* Calling this preset is what puts a package in the static assembly channel,
* so the call sites are the roster: gates read it through
* {@link isStaticLinkedConfig} rather than a second hand-kept list. A package on
* this roster must not be a module-table row as well — the browser would take
* the statically linked copy and a provider's bytes would sit unused in its
* bundle.
*
* Four artifact contracts:
* 1. every bare specifier stays an import. The shell attributes chunk bytes by
* `node_modules/<pkg>`, so a dependency inlined into a workspace file is
* attributed to no npm package and its bytes fall into the index chunk,
* which collapses the vendor/index cache split.
* 2. `esm` on `platform: 'browser'` — the shell is the only consumer.
* 3. sourcemaps, chained through the tsc maps under `lib/types` to the sources.
* 4. stylesheets ship with the package: a relative `.css` import survives as a
* relative external and the sheet is emitted under `lib/` at its
* `src`-relative path, so vite stays the only owner of class hashing.
* @param id - package name, used in tsdown diagnostics.
* @param libEntry - emitted JavaScript entries consumed from `lib/types`, one
* bundle each: a multi-entry build would emit a hash-named shared chunk that
* the exact `files` list cannot publish.
* @returns ENV-selected tsdown config for the Client build face.
*/
export function staticLinked(id: string, libEntry: readonly string[]): BuildFaceConfig {
// Each entry names its own output file, so two entries with the same basename
// would overwrite one artifact instead of emitting two.
const names = new Set(libEntry.map(entry => basename(entry, '.js')))
if (names.size !== libEntry.length) {
throw new Error(`tsdown: ${id} entries collide on an output name: ${libEntry.join(', ')}`)
}
return clientOnly(libEntry.map(entry => staticLinkedConfig(id, entry)))
}
/**
* Whether a package's tsdown configs put it in the static assembly channel.
* The roster has no separate list: gates load each package's own
* `tsdown.config.ts`, call it for the Client face, and ask this.
* @param configs - configs a package's build-face function returned.
* @returns true when at least one config was built by {@link staticLinked}.
*/
export function isStaticLinkedConfig(configs: readonly UserConfig[]): boolean {
return configs.some(config => (config.plugins as readonly { name?: string }[] | undefined ?? [])
.some(plugin => plugin.name === STATIC_LINKED_PLUGIN))
}
/**
* Build a Client-only Node library during the Client pass.
* @param id - Package name used in tsdown diagnostics.
@@ -178,6 +216,8 @@ function clientLibraryConfig(
libEntry: readonly string[],
overrides: UserConfig = {},
): UserConfig {
const isProductionDependency = (specifier: string): boolean =>
matchesSpecifier(productionExternals(id), specifier)
return {
name: id,
entry: [...libEntry],
@@ -188,11 +228,213 @@ function clientLibraryConfig(
fixedExtension: false,
dts: false,
clean: false,
deps: {
// The Node half runs from a real install: a production dependency is on
// disk there and stays an import, everything else inlines. Stating both
// halves takes the artifact off tsdown's getProductionDeps fallback, where
// moving a dependency between npm sections silently re-bundles it.
// Builtins keep tsdown's own handling (neither side claims them).
neverBundle: isProductionDependency,
alwaysBundle: (specifier: string) => !isBuiltin(specifier) && !isProductionDependency(specifier),
},
...overrides,
}
}
/** The slice of the rolldown plugin context the stylesheet plugin uses. */
interface AssetEmitter {
emitFile(file: {
type: 'asset'
fileName: string
source: Uint8Array
originalFileName: string
}): string
}
function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
const emitted = new Set<string>()
return {
name: id,
entry: { [outputName]: entry },
outDir: 'lib',
format: ['esm'],
platform: 'browser',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
// The shell compiles this artifact, so its map is the only path from a
// browser stack frame back to the TSX (tsc emits the lib/types half).
sourcemap: true,
plugins: [{
// Contract 1. `pre` because tsdown's own deps plugin would otherwise
// resolve and inline every specifier missing from the npm production
// sections, which is the coupling this preset exists to remove. The name
// is also the roster marker {@link isStaticLinkedConfig} reads.
name: STATIC_LINKED_PLUGIN,
resolveId: {
order: 'pre' as const,
handler(source: string, importer: string | undefined) {
// An entry arrives without an importer and must stay internal.
if (importer === undefined) return null
return isBareSpecifier(source) ? { id: source, external: true } : null
},
},
}, {
// Contract 3. Rolldown does not read the `//# sourceMappingURL` of its
// inputs, so each tsc map is handed over as that module's map and
// composed into the bundle map; without it frames stop at the emitted
// lib/types JavaScript instead of reaching the TSX.
name: 'dsh-tsc-sourcemap',
async load(id: string) {
if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
const code = await readFile(id, 'utf8')
return { code: code.replace(SOURCEMAP_COMMENT, ''), map: await readFile(`${id}.map`, 'utf8') }
},
}, {
// Contract 4. The import survives verbatim and the sheet lands beside the
// JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
name: 'dsh-css-asset',
async resolveId(this: AssetEmitter, source: string, importer: string | undefined) {
if (!source.endsWith('.css') || importer === undefined) return null
const { file, fileName } = stylesheetAsset(source, importer)
if (!emitted.has(fileName)) {
emitted.add(fileName)
// originalFileName also puts the physical sheet in the watch graph.
this.emitFile({ type: 'asset', fileName, source: await readFile(file), originalFileName: file })
}
// Every emitted chunk sits at the lib/ root, so the src-relative name
// is what resolves from there. Rolldown keeps relative externals as
// written instead of re-normalizing them.
return { id: `./${fileName}`, external: true }
},
}],
}
}
/** Whether a specifier names a package rather than a file next to its importer. */
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('\0') && !isAbsolute(specifier)
}
/**
* Locate a stylesheet import against the package sources and name its emitted position.
* @param source - relative import specifier as written in the source.
* @param importer - absolute path of the importing module, emitted or source.
* @returns the stylesheet on disk plus its `src`-relative name under `lib/`.
*/
function stylesheetAsset(source: string, importer: string): { readonly file: string, readonly fileName: string } {
const file = sourceAssetPath(source, importer)
const boundary = file.lastIndexOf(SOURCE_MARKER)
if (boundary < 0) throw new Error(`tsdown: stylesheet ${file} is outside the package sources`)
return { file, fileName: file.slice(boundary + SOURCE_MARKER.length).split(sep).join('/') }
}
/** The manifest fields the build faces read to state their own module edges. */
interface WorkspaceManifest {
readonly name?: string
/** Sections a real install materializes on disk next to the built package. */
readonly dependencies?: Record<string, string>
readonly peerDependencies?: Record<string, string>
readonly optionalDependencies?: Record<string, string>
readonly dsh?: { readonly client?: { readonly external?: unknown } }
}
const manifestCache = new Map<string, WorkspaceManifest>()
const productionExternalCache = new Map<string, readonly RegExp[]>()
const clientExternalCache = new Map<string, ReadonlySet<string>>()
/**
* Read one workspace package's manifest. Located by package name rather than by
* cwd, because tsdown evaluates every package config with the repository root as
* `process.cwd()` during a workspace build. Callers read it on the first
* resolveId of a build, not while a config is built, so selecting a build face
* never touches a manifest.
* @param id - package name, as spelled at the preset call site.
* @returns the parsed manifest.
* @throws {Error} when no workspace package declares that name.
*/
function workspaceManifest(id: string): WorkspaceManifest {
const cached = manifestCache.get(id)
if (cached !== undefined) return cached
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: REPOSITORY_ROOT })) {
const manifest = JSON.parse(
readFileSync(resolvePath(REPOSITORY_ROOT, manifestPath), 'utf8'),
) as WorkspaceManifest
if (manifest.name !== id) continue
manifestCache.set(id, manifest)
return manifest
}
throw new Error(`tsdown: no packages/*/*/package.json declares the name ${id}`)
}
/**
* External patterns for one package's Node half: its own production sections,
* subpaths included.
* @param id - package name, as spelled at the preset call site.
* @returns one `^name(/|$)` pattern per production dependency, name-sorted.
*/
function productionExternals(id: string): readonly RegExp[] {
const cached = productionExternalCache.get(id)
if (cached !== undefined) return cached
const manifest = workspaceManifest(id)
const names = new Set([
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.peerDependencies ?? {}),
...Object.keys(manifest.optionalDependencies ?? {}),
])
const patterns = [...names].sort().map(name => new RegExp(`^${escapeSpecifier(name)}(/|$)`))
productionExternalCache.set(id, patterns)
return patterns
}
/**
* Module-table specifiers one `dsh.client` declaration requests. Matching is
* exact, never normalized: a package declares the specifier its own code
* imports, and the loader keys static entries the same way.
* @param subject - package name, used in diagnostics.
* @param declaration - the package's `dsh.client` object.
* @returns the requested specifiers, empty when the package declares none.
* @throws {Error} when `external` is not a string array.
*/
export function requestedExternals(
subject: string,
declaration: { readonly external?: unknown },
): ReadonlySet<string> {
return new Set(optionalStringArray(subject, 'dsh.client.external', declaration.external) ?? [])
}
/**
* Module-table specifiers one package requests. The shell baseline is implicit
* for every dynamic bundle; `dsh.client.external` only adds package-specific
* dynamic rows or subpaths.
* @param id - package name, as spelled at the preset call site.
* @returns the baseline plus the package's explicit requests.
*/
function clientExternals(id: string): ReadonlySet<string> {
const cached = clientExternalCache.get(id)
if (cached !== undefined) return cached
const externals = new Set([
...PLATFORM_MODULES,
...PRELOADED_CLIENT_EXTERNALS,
...requestedExternals(id, workspaceManifest(id).dsh?.client ?? {}),
])
clientExternalCache.set(id, externals)
return externals
}
/** Escape a package name for literal use inside a RegExp source. */
function escapeSpecifier(name: string): string {
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Whether an import specifier is the package a pattern names, or one of its subpaths. */
function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boolean {
return patterns.some(pattern => pattern.test(specifier))
}
function clientConfig(id: string, entry: string): UserConfig {
const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
return {
name: `${id}/client`,
entry: { client: entry },
@@ -208,7 +450,15 @@ function clientConfig(id: string, entry: string): UserConfig {
// must carry the TS/TSX mapping consumed by browser profiling tools.
sourcemap: true,
clean: false,
external: [...CLIENT_EXTERNALS],
deps: {
neverBundle: isRequested,
// Anything NOT requested from the loader module table must inline
// (wire/type layers, zod, clsx — every non-shared dep). A require() the
// table cannot answer is a guaranteed runtime throw, so the rule is the
// package's own request list: requested specifiers stay imports,
// everything else is bundled.
alwaysBundle: (specifier: string) => !isRequested(specifier),
},
// Browser bundles inline node-idiom deps (zustand/immer read
// process.env.NODE_ENV; zustand's esm build also probes
// import.meta.env.MODE, which a CJS output cannot carry — rolldown flags
@@ -224,28 +474,23 @@ function clientConfig(id: string, entry: string): UserConfig {
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
},
// tsdown auto-externalizes package dependencies; anything NOT in the
// loader module table must inline instead (wire/type layers, zod, clsx —
// every non-shared dep). A require() the table cannot answer is a
// guaranteed runtime throw, so the rule is the table list itself: no
// opinion for table entries (external above wins), bundle everything else.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate (build-time mirror of the module-edge rules):
// platform seed entries stay external, inline-safe wire layers inline,
// and every other @deepseek-ai value import is a build error — a
// Bundle purity gate (build-time mirror of the module-edge rules): the
// baseline and package-specific requests stay external, inline-safe wire layers
// inline, and every other @deepseek-ai value import is a build error — a
// cross-plugin value import either inlines a duplicate runtime instance
// or requires a specifier the frozen module table cannot answer.
// or requires a specifier the module table cannot answer for this package.
// Cross-plugin collaboration goes through cordis services instead.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (isRequested(source)) return null // requested module-table row: external wins
if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
`client bundle purity: "${source}" is not in the default client externals or ${id}'s dsh.client.external, an inline-safe wire layer, or a generated /remote contribution — `
+ 'cross-plugin value imports are forbidden; declare a non-default module request or collaborate through cordis services '
+ '(type-only imports are erased and never reach this gate)',
)
},
}, {
@@ -268,7 +513,9 @@ function clientConfig(id: string, entry: string): UserConfig {
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
const exportEntries = Object.entries(cssExports ?? {})
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
for (const [local, exp] of exportEntries) classMap[local] = exp.name
return styleInjectionModule(id, fileId, code.toString(), classMap)
},
}, {
@@ -317,12 +564,23 @@ function clientConfig(id: string, entry: string): UserConfig {
}
}
/** Path segment separating a package's tsc output from the sources it was emitted from. */
const TYPES_MARKER = `${sep}lib${sep}types${sep}`
/** Plugin name carrying contract 1, and the marker that identifies a statically linked config. */
const STATIC_LINKED_PLUGIN = 'dsh-static-linked-external'
/** Path segment a package's sources hang under, and the root emitted assets mirror. */
const SOURCE_MARKER = `${sep}src${sep}`
/** Trailing sourcemap reference tsc appends to every emitted module. */
const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/
/** 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)
const boundary = emitted.indexOf(TYPES_MARKER)
if (boundary < 0) return emitted
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + marker.length))
return resolvePath(emitted.slice(0, boundary), 'src', emitted.slice(boundary + TYPES_MARKER.length))
}
+1 -4
View File
@@ -54,11 +54,8 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
+1 -4
View File
@@ -54,13 +54,10 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
+3 -5
View File
@@ -45,15 +45,11 @@
"watch": "tsdown --watch"
},
"license": "MIT",
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
@@ -68,7 +64,9 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^"
},
"files": [
"lib/index.js",
@@ -50,12 +50,9 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -45,11 +45,9 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
+5 -4
View File
@@ -50,13 +50,12 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -73,7 +72,9 @@
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
},
"files": [
"lib/index.js",
@@ -49,11 +49,8 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
+2 -6
View File
@@ -45,15 +45,10 @@
"publishConfig": {
"access": "public"
},
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
@@ -66,7 +61,8 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
+1 -3
View File
@@ -45,11 +45,9 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -51,13 +51,10 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -52,13 +52,9 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"clsx": "^2.1.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -68,12 +64,11 @@
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"clsx": "^2.1.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
@@ -82,5 +77,8 @@
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
],
"dependencies": {
"clsx": "^2.1.1"
}
}
+1 -4
View File
@@ -49,12 +49,9 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: f96e931472a0946bd023b97b034643f079a67e44
README.zh.md: bf47fa2bca6f685fc52ca6ff786439a8d51230fc
README.md: 9a5b33e6b2ecae0652d640d2a6927e3f1d05b1a1
README.zh.md: a94935235790504bbeb7f5091e8c8fba86e1c903
+1 -1
View File
@@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Read rendering
+1 -1
View File
@@ -18,7 +18,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## Read 渲染
@@ -57,6 +57,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/**/*.css",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
+5 -30
View File
@@ -1,31 +1,6 @@
import { clientOnly } from '../tsdown.client.ts'
import { staticLinked } from '../tsdown.client.ts'
/**
* ui-primitives is browser-only, but its lib bundle IS imported under plain
* Node because the web shell is a lib (dsh-client-web's lib chain reaches
* this package). CSS imports are therefore stubbed to empty modules instead
* of externalized — the hashed class maps only matter in bundler contexts
* (loader module table / vite source paths), which compile src directly and
* never read lib.
*/
export default clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
plugins: [{
name: 'dsh-css-stub',
resolveId(source: string) {
if (!source.endsWith('.css')) return null
return `\0dsh-css-stub:${source}.mjs`
},
load(id: string) {
if (!id.startsWith('\0dsh-css-stub:')) return null
return 'export default {};'
},
}],
}])
export default staticLinked(
'@deepseek-ai/dsh-client-ui-primitives',
['lib/types/index.js', 'lib/types/invariant.js'],
)
@@ -48,7 +48,6 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"clsx": "^2.0.0"
},
@@ -57,13 +56,11 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -78,7 +75,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"files": [
"lib/index.js",
@@ -49,12 +49,9 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -52,11 +52,8 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+2 -1
View File
@@ -64,7 +64,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-client-connection": "workspace:^"
},
"files": [
"lib/index.js",
+1 -3
View File
@@ -50,11 +50,9 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-client-ui-layout": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
+1 -4
View File
@@ -51,13 +51,10 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
+2 -2
View File
@@ -1,6 +1,6 @@
import { clientLibrary } from '../tsdown.client.ts'
import { staticLinked } from '../tsdown.client.ts'
export default clientLibrary(
export default staticLinked(
'@deepseek-ai/dsh-client-ui-slots',
['lib/types/index.js', 'lib/types/invariant.js'],
)
+2 -6
View File
@@ -46,16 +46,11 @@
"watch": "tsdown --watch"
},
"license": "MIT",
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
@@ -73,7 +68,8 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
+4 -5
View File
@@ -49,12 +49,10 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -68,7 +66,9 @@
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^"
},
"files": [
"lib/index.js",
@@ -82,7 +82,6 @@
"watch": "tsdown --watch"
},
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"clsx": "^2.0.0",
"@deepseek-ai/schemastery": "workspace:^"
}
+1 -4
View File
@@ -53,10 +53,7 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+1 -3
View File
@@ -53,12 +53,10 @@
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
+11 -9
View File
@@ -44,19 +44,15 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -67,7 +63,13 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@types/react": "~18.3.1"
"@types/react": "~18.3.1",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^"
},
"files": [
"lib/index.js",
+2 -6
View File
@@ -50,15 +50,10 @@
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
@@ -77,6 +72,7 @@
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
}
}
+2 -3
View File
@@ -51,11 +51,10 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
+6 -8
View File
@@ -26,30 +26,28 @@
"./package.json": "./package.json"
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^6.0.3"
},
"peerDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/**/*.css",
"lib/types/**/*.d.ts"
]
}
+1 -1
View File
@@ -8,4 +8,4 @@
export { AppWebEntry, type BootSeams } from './boot.ts'
export { getStaticModules } from './seed.ts'
export { PLATFORM_MODULES, type PlatformModule } from './platform.ts'
export { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS, type PlatformModule } from './platform.ts'
+5
View File
@@ -11,5 +11,10 @@ export const PLATFORM_MODULES = [
'@deepseek-ai/dsh-client-ui-primitives',
] as const
/** Client-bundle specifiers whose factories the parser preloads before the shell starts. */
export const PRELOADED_CLIENT_EXTERNALS = [
'@deepseek-ai/dsh-client-runtime/client',
] as const
/** One platform module specifier (a seed-table key). */
export type PlatformModule = (typeof PLATFORM_MODULES)[number]
+5 -30
View File
@@ -1,31 +1,6 @@
import { clientOnly } from '../tsdown.client.ts'
import { staticLinked } from '../tsdown.client.ts'
/**
* Root-shape lib build plus a CSS stub: the boot page imports
* .module.css/.css assets that tsc passes through untouched, so the JS under
* lib/types references css files that do not exist there. The browser
* consumer (apps/web) compiles src directly through vite where css is real;
* 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 clientOnly([{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'neutral',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
plugins: [{
name: 'dsh-css-stub',
resolveId(source: string) {
if (!source.endsWith('.css')) return null
return `\0dsh-css-stub:${source}.mjs`
},
load(id: string) {
if (!id.startsWith('\0dsh-css-stub:')) return null
return 'export default {};'
},
}],
}])
export default staticLinked(
'@deepseek-ai/dsh-client-web',
['lib/types/index.js', 'lib/types/invariant.js'],
)
@@ -51,11 +51,9 @@
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
+1 -4
View File
@@ -55,14 +55,11 @@
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -27,11 +27,8 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+125 -121
View File
@@ -354,16 +354,6 @@ importers:
version: 10.0.0
apps/web:
dependencies:
'@deepseek-ai/dsh-client-web':
specifier: workspace:^
version: link:../../packages/client/web
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
devDependencies:
'@deepseek-ai/cordis-plugin-group':
specifier: workspace:^
@@ -377,6 +367,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../../packages/client/ui-slots
'@deepseek-ai/dsh-client-web':
specifier: workspace:^
version: link:../../packages/client/web
'@deepseek-ai/dsh-cmdline':
specifier: workspace:^
version: link:../../packages/boot/cmdline
@@ -401,6 +394,12 @@ importers:
playwright:
specifier: ^1.49.0
version: 1.61.1
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
typescript:
specifier: ^6.0.3
version: 6.0.3
@@ -1460,24 +1459,6 @@ importers:
packages/client/connection:
dependencies:
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
'@deepseek-ai/dsh-commands':
specifier: workspace:^
version: link:../../interaction/commands
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../host/apiproxy
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -1488,12 +1469,30 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
'@deepseek-ai/dsh-commands':
specifier: workspace:^
version: link:../../interaction/commands
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../host/apiproxy
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../host/webserver
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
@@ -1522,12 +1521,6 @@ importers:
packages/client/locale:
dependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -1538,6 +1531,9 @@ importers:
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -1556,6 +1552,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@types/react':
specifier: ~18.3.1
version: 18.3.31
@@ -1580,9 +1579,22 @@ importers:
packages/client/runtime:
dependencies:
immer:
specifier: ^10.1.1
version: 10.2.0
zustand:
specifier: ~4.4.7
version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
@@ -1598,6 +1610,9 @@ importers:
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../host/apiproxy
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
@@ -1613,31 +1628,12 @@ importers:
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session/session-title
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
immer:
specifier: ^10.1.1
version: 10.2.0
react:
specifier: ^18.2.0
version: 18.3.1
zustand:
specifier: ~4.4.7
version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1)
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
@@ -1647,6 +1643,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-agent-preset:
devDependencies:
@@ -1877,10 +1876,6 @@ importers:
version: 18.3.1
packages/client/ui-deliverables:
dependencies:
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -1900,6 +1895,9 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
@@ -1912,6 +1910,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-directory-picker-browse:
dependencies:
@@ -2021,6 +2022,12 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -2069,10 +2076,6 @@ importers:
version: 18.3.1
packages/client/ui-jobs:
dependencies:
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -2101,6 +2104,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-layout:
devDependencies:
@@ -2181,6 +2187,10 @@ importers:
version: 18.3.1(react@18.3.1)
packages/client/ui-model-selection:
dependencies:
clsx:
specifier: ^2.1.1
version: 2.1.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -2221,9 +2231,6 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
clsx:
specifier: ^2.1.1
version: 2.1.1
react:
specifier: ^18.2.0
version: 18.3.1
@@ -2427,9 +2434,6 @@ importers:
packages/client/ui-settings:
dependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -2440,6 +2444,9 @@ importers:
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -2464,9 +2471,6 @@ importers:
packages/client/ui-settings-general:
dependencies:
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -2507,6 +2511,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@types/react':
specifier: ~18.3.1
version: 18.3.31
@@ -2736,10 +2743,6 @@ importers:
version: 18.3.31
packages/client/ui-subagent:
dependencies:
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -2777,15 +2780,12 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-theme:
dependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -2799,6 +2799,9 @@ importers:
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
@@ -2823,6 +2826,9 @@ importers:
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@types/react':
specifier: ~18.3.1
version: 18.3.31
@@ -2936,9 +2942,25 @@ importers:
packages/client/ui-user-questions:
dependencies:
clsx:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -2951,25 +2973,6 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
clsx:
specifier: ^2.0.0
version: 2.1.1
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
@@ -2985,12 +2988,11 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
packages/client/ui-workflow-run:
dependencies:
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-workflow-run:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -3028,6 +3030,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
react:
specifier: ^18.2.0
version: 18.3.1
packages/client/ui-workspace:
dependencies:
@@ -3070,22 +3075,6 @@ importers:
version: 18.3.1
packages/client/web:
dependencies:
'@deepseek-ai/dsh-client-modules':
specifier: workspace:^
version: link:../modules
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
@@ -3093,9 +3082,18 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-client-modules':
specifier: workspace:^
version: link:../modules
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:^
version: link:../ui-renderer
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
@@ -3105,6 +3103,12 @@ importers:
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/react@18.3.31)
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
typescript:
specifier: ^6.0.3
version: 6.0.3
+7
View File
@@ -135,6 +135,13 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-base': ['cordis.patch.yml'],
'@deepseek-ai/dsh-web-app': ['cordis.patch.yml'],
'@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
// Statically linked client libraries keep their stylesheets next to the emitted
// JavaScript, which imports them by relative path: the compile shell runs
// them through its own CSS pipeline, so the sheets are published artifacts.
// The glob covers whichever sheets a package emits; sourcemaps stay
// unpublished, as everywhere else in the repository.
'@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
// The CPython side ships as source .py files, published as-is rather than built.
'@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'],
+47 -11
View File
@@ -4,7 +4,7 @@
*/
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts'
import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
type ResolveId = (source: string) => null | { id: string; external: boolean }
@@ -14,7 +14,10 @@ interface CssModulePlugin {
load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
}
function clientConfigs(id = '@deepseek-ai/dsh-client-test') {
/** A representative dynamic bundle using the shared client baseline. */
const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
function clientConfigs(id = REQUESTING_PACKAGE) {
return clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])(
{ env: { DSH_BUILD_FACE: 'client' } },
).filter(config => config.platform === 'browser')
@@ -36,10 +39,10 @@ function clientSourceMapPath(packagePath: string): string {
return fileURLToPath(new URL(`../packages/${packagePath}/lib/client.js.map`, import.meta.url))
}
function purityResolveId(): ResolveId {
function purityResolveId(id = REQUESTING_PACKAGE): 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 = clientConfigs()
const configs = clientConfigs(id)
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')
@@ -49,7 +52,7 @@ function purityResolveId(): ResolveId {
function cssModulePlugin(): CssModulePlugin {
const configs = clientConfigs()
const plugins = (configs[0] as { plugins: CssModulePlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-inline')
if (plugin?.resolveId === undefined || plugin.load === undefined) {
throw new Error('CSS Modules plugin missing from client config')
}
@@ -59,9 +62,10 @@ function cssModulePlugin(): CssModulePlugin {
describe('client bundle purity gate', () => {
const resolveId = purityResolveId()
it('leaves platform table entries and non-scoped specifiers alone', () => {
it('leaves default externals and non-scoped specifiers alone', () => {
expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
expect(resolveId('react')).toBeNull()
expect(resolveId('zod')).toBeNull()
})
@@ -89,17 +93,49 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-web')).toThrow(/purity/)
})
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike (the rewrite arm is gone)', () => {
it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => {
expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/)
})
it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
it('admits the parser-preloaded runtime for every dynamic bundle', () => {
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
const clientChannels = CLIENT_EXTERNALS.filter(
entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal')
expect(withoutRequest('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
})
it('externalizes the baseline independently of each package manifest', () => {
const requesting = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
const plain = clientConfigs('@deepseek-ai/dsh-client-connection')[0]?.deps as {
neverBundle: (specifier: string) => boolean
}
expect(requesting.neverBundle('react')).toBe(true)
expect(requesting.neverBundle('zod')).toBe(false)
expect(plain.neverBundle('react')).toBe(true)
expect(plain.neverBundle('@deepseek-ai/dsh-client-runtime/client')).toBe(true)
})
})
describe('client bundle module requests', () => {
it('requests what the declaration lists', () => {
const requests = requestedExternals('@deepseek-ai/dsh-client-fixture', {
external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-slots'],
})
expect([...requests].sort()).toEqual([
'@deepseek-ai/dsh-client-ui-slots', 'react', 'react/jsx-runtime',
])
})
it('requests nothing when the declaration is absent', () => {
expect(requestedExternals('@deepseek-ai/dsh-client-fixture', {}).size).toBe(0)
})
it('rejects a malformed declaration instead of reading past it', () => {
expect(() => requestedExternals('@deepseek-ai/dsh-client-fixture', { external: 'react' }))
.toThrow(/dsh\.client\.external must be a string array/)
})
})
+26 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type { TsdownBundle } from 'tsdown'
import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
it('discovers dsh.client packages with sibling roles', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
@@ -24,6 +24,31 @@ it('discovers dsh.client packages with sibling roles', async () => {
}
})
it('discovers client-preset packages the shell links, excluding loader-delivered and test infrastructure', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-library-'))
try {
const write = async (dir: string, manifest: unknown, config: string): Promise<void> => {
await mkdir(join(root, dir), { recursive: true })
await writeFile(join(root, dir, 'package.json'), JSON.stringify(manifest))
await writeFile(join(root, dir, 'tsdown.config.ts'), config)
}
const clientPreset = "import { clientLibrary } from '../tsdown.client.ts'\nexport default clientLibrary('x', [])\n"
// Linked by the compile shell: client preset, no loader-delivered half.
await write('packages/client/linked', {}, clientPreset)
// Loader-delivered: discoverPluginDirs owns it, so it must not appear twice.
await write('packages/client/delivered', { dsh: { client: { platform: 'web' } } }, clientPreset)
// Test infrastructure builds through the preset but never enters the shell graph.
await write('packages/test-support/harness', {}, clientPreset)
// Host package with its own config: not a client-face build at all.
await write('packages/host/server', {}, "import { defineConfig } from 'tsdown'\nexport default defineConfig({})\n")
expect(discoverLibraryDirs(root)).toEqual(['packages/client/linked'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rebuilds a client-plugin bundle after its source changes', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
let bundles: TsdownBundle[] = []
+137 -13
View File
@@ -1,17 +1,28 @@
/**
* Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
* through the tsdown JS API in watch mode. Reload signaling is not this
* script's business — the host webserver stat-polls the bundles it serves and
* broadcasts `rebuilt` frames itself (`dsh web`), so any process that
* rewrites `lib/client.js` files triggers reloads; this script is merely the
* convenient way to keep them all rebuilt on source change.
* Watch-build for the web dev loop: rebuilds every artifact the browser reads
* from a source edit. Reload signaling is not this script's business — the host
* webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
* itself (`dsh web`), so any process that rewrites `lib/client.js` files
* triggers reloads; this script is merely the convenient way to keep them all
* rebuilt on source change.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires the
* packages' node halves built once (`tsc -b tsconfig.build.json`): the lib
* config's entries are tsc output. `--poll` switches the source-file watcher
* to polling (default 500ms): network mounts (weka) deliver no inotify
* Three stages, because the compile shell links built lib products rather than
* sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
* entries are that emit, not `src`), tsdown bundles `lib/index.js` and
* `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
* serves. A missing stage does not fail — it silently shows the previous
* artifact, so an edit appears to do nothing.
*
* MUST NOT run concurrently with `pnpm run build`: both write the same
* `lib/` and `apps/web/dist/` trees.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
* `pnpm run build`: every stage is incremental over the previous stage's output
* and none of them bootstraps a missing tree. `--poll` switches the source
* watchers to polling (default 500ms): network mounts (weka) deliver no inotify
* events, so native watching sees the initial build only and never a source
* change.
* change. Polling has to reach tsc too — a native-watching tsc never re-emits
* `lib/types`, which strands the other two stages on stale input.
*
* Each package keeps its own tsdown.config.ts untouched: this script layers
* `watch` through API-level inline config (tsdown workspace mode fills inline
@@ -20,11 +31,24 @@
import { globSync, readFileSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { build } from 'tsdown'
import type { TsdownBundle } from 'tsdown'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
/** Client-face type emit feeding every tsdown lib entry in the watch set. */
const CLIENT_TYPE_PROGRAM = 'tsconfig.client.json'
/** Compile-shell workspace whose dist `dsh web` serves. */
const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
/**
* Test infrastructure builds through the client preset but never enters the
* shell's module graph, so it is not a dev-loop artifact.
*/
const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
/**
* Discover the watch workspace by declaration: every packages/<group>/<name>
* whose package.json carries `dsh.client` with platform "web" is a client
@@ -44,6 +68,32 @@ export function discoverPluginDirs(root = repoRoot): string[] {
return dirs
}
/**
* Discover the statically linked library packages: the other half of the same
* partition {@link discoverPluginDirs} takes. A package that builds through the
* client preset without declaring `dsh.client` has no loader-delivered browser
* half, so the compile shell links its `lib/index.js` instead — and an edit to
* its source reaches the browser only once that bundle is rewritten. Deriving
* the set from the build preset rather than a hand list keeps it correct when
* dependency sections move around; deriving it from `dependencies` would not,
* because client packages declare their build inputs as devDependencies.
* @param root - repository root containing the grouped package directories.
* @returns workspace-relative library package directories.
*/
export function discoverLibraryDirs(root = repoRoot): string[] {
const dirs: string[] = []
for (const configPath of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) {
const dir = dirname(configPath).split(sep).join('/')
if (dir.startsWith(TEST_INFRASTRUCTURE_PREFIX)) continue
if (!readFileSync(join(root, configPath), 'utf8').includes('tsdown.client.ts')) continue
const manifest = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')) as {
dsh?: { client?: unknown }
}
if (manifest.dsh?.client === undefined) dirs.push(dir)
}
return dirs
}
/**
* Start the tsdown watch build used by `pnpm run dev:web`.
* @param root - repository or fixture root passed to tsdown.
@@ -85,14 +135,56 @@ export async function watchClientPlugins(
return bundles
}
/**
* Live watcher processes to terminate when this script is interrupted. Stages
* register themselves as they start, so the set is complete from the first
* spawn: an interrupt during a later stage's startup still tears down the
* earlier ones instead of orphaning them.
*/
const stages: StageHandle[] = []
/**
* Spawn one watcher stage, inheriting stdio, registering it for teardown, and
* failing loud if it ever exits: a dead stage leaves the artifact chain silently
* stale, which reads as "my edit did nothing" — the one failure this script
* exists to prevent.
* @param stage - command label used in the exit diagnostic.
* @param command - executable, resolved from the workspace bin when local.
* @param args - command arguments.
* @param local - whether to resolve `command` from the workspace's installed bins.
*/
function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
const child = execa(command, [...args], {
cwd: repoRoot,
stdio: 'inherit',
preferLocal: local,
reject: false,
})
stages.push({ kill: () => { child.kill() } })
void child.then((result) => {
console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
process.exit(1)
})
}
/** The only capability this script needs from a live watcher process. */
interface StageHandle {
readonly kill: () => void
}
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const pluginDirs = discoverPluginDirs()
const libraryDirs = discoverLibraryDirs()
if (pluginDirs.length === 0) {
console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
process.exit(1)
}
if (libraryDirs.length === 0) {
console.error('dev-web: no client-preset library packages found under packages/ — the compile shell links their lib products, so an empty set means the discovery predicate is stale')
process.exit(1)
}
const args = process.argv.slice(2)
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
@@ -106,9 +198,41 @@ if (isMain) {
process.exit(1)
}
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
// Registered before any stage starts: `stages` is read at signal time, so an
// interrupt during tsdown's initial builds still kills whatever is running.
const stop = (): void => { for (const stage of stages) stage.kill() }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
// tsc has no polling interval flag, so `--poll` selects its fixed-interval
// watchers rather than an interval. Dropping that translation leaves tsc
// natively watching on a network mount where inotify never fires: it stops
// re-emitting lib/types, and the two later stages then rebuild forever from
// stale input without printing anything.
spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
'-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
...pollInterval !== undefined
? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
: [],
], true)
// tsdown's initial builds are awaited before the dist watcher starts so vite's
// first build reads current lib bundles rather than whatever the last full
// build left. Its own watch then covers later lib rewrites — those files are
// in its module graph.
await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
// Through the shell's own `watch` script rather than vite's API: vite is not a
// repository-root dependency, and more importantly the vite root is its
// working directory — `resolve.dedupe` resolves react from that root, so
// running vite from anywhere but apps/web silently switches which react copy
// the bundle gets.
spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
console.log(
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
+ ` and ${String(libraryDirs.length)} statically linked library packages`
+ (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
+ [...pluginDirs, ...libraryDirs].join('\n '),
)
}
+33 -5
View File
@@ -13,7 +13,11 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function fixture(exportPath = './lib/index.js'): string {
function fixture(options: {
exportPath?: string
indexSource?: string
files?: Record<string, string>
} = {}): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-publint-all-'))
roots.push(root)
const packageDir = join(root, 'packages/core/probe')
@@ -26,10 +30,14 @@ function fixture(exportPath = './lib/index.js'): string {
engines: { node: '>=22.19' },
sideEffects: false,
files: ['lib'],
exports: { '.': { default: exportPath } },
exports: { '.': { default: options.exportPath ?? './lib/index.js' } },
}, null, 2)}\n`)
writeFileSync(join(packageDir, 'README.md'), '# Probe\n')
writeFileSync(join(packageDir, 'lib/index.js'), 'export const probe = true\n')
writeFileSync(join(packageDir, 'lib/index.js'), options.indexSource ?? 'export const probe = true\n')
for (const [path, source] of Object.entries(options.files ?? {})) {
mkdirSync(join(packageDir, path, '..'), { recursive: true })
writeFileSync(join(packageDir, path), source)
}
writeFileSync(join(packageDir, 'unpublished.js'), 'export const hidden = true\n')
return root
}
@@ -54,14 +62,34 @@ describe('publint package runner', () => {
})
it('rejects an export that exists in the workspace but is not published', () => {
const result = run(fixture('./unpublished.js'))
const result = run(fixture({ exportPath: './unpublished.js' }))
expect(result.status).toBe(1)
expect(result.stdout).toContain('unpublished.js')
})
it('rejects a public export whose built file is missing', () => {
const result = run(fixture('./lib/missing.js'))
const result = run(fixture({ exportPath: './lib/missing.js' }))
expect(result.status).toBe(1)
expect(result.stdout).toContain('missing.js')
})
it('accepts published relative JavaScript and CSS targets', () => {
const result = run(fixture({
indexSource: "export { helper } from './helper.js'\nimport './theme.css'\n",
files: {
'lib/helper.js': 'export const helper = true\n',
'lib/theme.css': ':root {}\n',
},
}))
expect(result.status, result.stderr).toBe(0)
})
it('rejects unpublished relative JavaScript and CSS targets', () => {
const result = run(fixture({
indexSource: "export { helper } from './missing.js'\nimport './missing.css'\n",
}))
expect(result.status).toBe(1)
expect(result.stderr).toContain('imports "./missing.js"')
expect(result.stderr).toContain('imports "./missing.css"')
})
})
+88 -8
View File
@@ -7,10 +7,11 @@ import {
statSync,
} from 'node:fs'
import { availableParallelism } from 'node:os'
import { dirname, relative, resolve, sep } from 'node:path'
import { dirname, posix, relative, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
import ts from 'typescript'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -32,8 +33,21 @@ interface PackageManifest {
}
type PublintResult =
| { path: string; status: 'passed'; messages: Message[]; manifest: Record<string, unknown> }
| { path: string; status: 'failed'; messages: Message[]; manifest: Record<string, unknown>; failure?: string }
| {
path: string
status: 'passed'
messages: Message[]
closureViolations: string[]
manifest: Record<string, unknown>
}
| {
path: string
status: 'failed'
messages: Message[]
closureViolations: string[]
manifest: Record<string, unknown>
failure?: string
}
function workspacePackages(): PackageTarget[] {
return globSync('packages/*/*/package.json', { cwd: packagesRoot })
@@ -103,21 +117,84 @@ function addPath(path: string, paths: Set<string>): void {
}
}
interface RelativeImport {
specifier: string
line: number
}
/** Return relative imports whose targets are absent from the publication view. */
function publicationClosureViolations(target: PackageTarget, files: readonly PackFile[]): string[] {
const published = new Set(files.map(file => file.name))
const violations: string[] = []
for (const file of files) {
if (!/\.(?:js|mjs|cjs)$/.test(file.name)) continue
const bytes = file.data instanceof ArrayBuffer ? new Uint8Array(file.data) : file.data
const source = typeof bytes === 'string' ? bytes : Buffer.from(bytes).toString('utf8')
for (const imported of relativeImports(file.name, source)) {
const resolved = posix.normalize(posix.join(posix.dirname(file.name), imported.specifier))
if (resolutionCandidates(resolved).some(candidate => published.has(candidate))) continue
violations.push(
`${target.path}/${file.name.slice('package/'.length)}:${String(imported.line)}`
+ ` imports ${JSON.stringify(imported.specifier)}, but ${target.manifest.name ?? target.path}`
+ ` does not publish ${JSON.stringify(resolved.slice('package/'.length))}`,
)
}
}
return violations
}
/** Paths a relative JavaScript module request can resolve to in a published package. */
function resolutionCandidates(target: string): string[] {
const base = target.replace(/\/+$/, '')
return [
target,
...['.js', '.mjs', '.cjs', '/index.js', '/index.mjs', '/index.cjs'].map(suffix => base + suffix),
]
}
/** Extract relative static imports, re-exports, dynamic imports, and requires. */
function relativeImports(file: string, sourceText: string): RelativeImport[] {
const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS)
const imports: RelativeImport[] = []
const record = (node: ts.Node, literal: ts.Expression | undefined): void => {
if (literal === undefined || !ts.isStringLiteralLike(literal) || !literal.text.startsWith('.')) return
imports.push({
specifier: literal.text,
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
})
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
record(node, node.moduleSpecifier)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
record(node, node.arguments[0])
}
ts.forEachChild(node, visit)
}
visit(source)
return imports
}
async function runPublint(target: PackageTarget): Promise<PublintResult> {
try {
const files = publicationFiles(target)
const closureViolations = publicationClosureViolations(target, files)
const result = await publint({
pkgDir: 'package',
pack: { files: publicationFiles(target) },
pack: { files },
})
const manifest = result.pkg as Record<string, unknown>
return result.messages.some(message => message.type === 'error')
? { path: target.path, status: 'failed', messages: result.messages, manifest }
: { path: target.path, status: 'passed', messages: result.messages, manifest }
return result.messages.some(message => message.type === 'error') || closureViolations.length > 0
? { path: target.path, status: 'failed', messages: result.messages, closureViolations, manifest }
: { path: target.path, status: 'passed', messages: result.messages, closureViolations, manifest }
} catch (error: unknown) {
return {
path: target.path,
status: 'failed',
messages: [],
closureViolations: [],
manifest: target.manifest as Record<string, unknown>,
failure: error instanceof Error ? error.message : String(error),
}
@@ -150,7 +227,10 @@ function printResult(result: PublintResult): void {
for (const message of result.messages) {
console.log(formatMessage(message, result.manifest, { color: false }) ?? message.code)
}
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
for (const violation of result.closureViolations) console.error(violation)
if (result.status === 'passed' && result.messages.length === 0 && result.closureViolations.length === 0) {
console.log('All good!')
}
}
const packages = workspacePackages()
+2 -2
View File
@@ -275,8 +275,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [
replace: `/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
* would read them as plugin packages. They carry no cross-plugin runtime
* identity to share — the framework itself is a platform module (external),
* while these are ordinary libraries a browser bundle inlines.
* identity to share — the framework itself is a requested module-table row
* (external), while these are ordinary libraries a browser bundle inlines.
*/
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
+9
View File
@@ -92,6 +92,15 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps the client dependency policy in %s',
(mode) => {
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
expect(ids).toContain('client-packages')
},
)
it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
const byId = new Map(gates.map(subject => [subject.id, subject]))
+2
View File
@@ -252,6 +252,7 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
]
}
@@ -584,6 +585,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
label: 'optional dependency imports',
}),
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
]
}
+317
View File
@@ -0,0 +1,317 @@
/** Tests for client package modes, dependency sections, and module requests. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectClientPackageViolations,
collectSourcePackageUses,
fixClientPackageManifests,
readClientDeclarations,
type ClientDeclaration,
type ClientPackage,
type ClientPackageFacts,
} from './verify-client-packages.ts'
const CORDIS = '@deepseek-ai/cordis'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function declaration(
short: string,
fields: Partial<Omit<ClientDeclaration, 'name' | 'manifest'>> = {},
): ClientDeclaration {
return {
name: short.startsWith('@') ? short : '@deepseek-ai/dsh-client-' + short,
manifest: 'packages/client/' + short.replace(/^.*\//, '') + '/package.json',
dynamic: true,
external: [],
inject: [],
...fields,
}
}
function pkg(
short: string,
fields: Partial<Omit<ClientPackage, 'name' | 'manifest'>> = {},
): ClientPackage {
return {
...declaration(short),
staticLinked: false,
sourceUses: {},
dependencies: {},
peerDependencies: { [CORDIS]: 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^' },
...fields,
}
}
function facts(
packages: readonly ClientPackage[],
options: Partial<Omit<ClientPackageFacts, 'packages'>> = {},
): ClientPackageFacts {
return {
packages,
declarations: options.declarations ?? packages,
staticLinkedPackages: options.staticLinkedPackages ?? new Set(
packages.filter(item => item.staticLinked).map(item => item.name),
),
platformModules: options.platformModules ?? [],
preloadedExternals: options.preloadedExternals ?? [],
malformed: options.malformed ?? [],
}
}
describe('source package uses', () => {
it('counts type imports, module augmentations, dynamic imports, and JSX', () => {
const uses = collectSourcePackageUses('feature.tsx', [
"import type { A } from '@deepseek-ai/dsh-a/subpath'",
"declare module '@deepseek-ai/dsh-client-ui-slots' {}",
"const load = () => import('@deepseek-ai/dsh-b')",
'export const view = <div />',
"export type { Local } from './local.ts'",
].join('\n'))
expect([...uses].sort()).toEqual([
'@deepseek-ai/dsh-a',
'@deepseek-ai/dsh-b',
'@deepseek-ai/dsh-client-ui-slots',
'react',
])
})
})
describe('package modes', () => {
it('accepts one dynamic package and one statically linked package', () => {
const dynamic = pkg('runtime')
const shell = pkg('ui-slots', { dynamic: false, staticLinked: true })
expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([])
})
it('rejects a package with both modes or neither mode', () => {
const both = pkg('both', { staticLinked: true })
const neither = pkg('neither', { dynamic: false })
const found = collectClientPackageViolations(facts([both, neither]))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('must be dynamic or statically linked, not both')
expect(found.join('\n')).toContain('has no supported client package mode')
})
it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => {
const slots = declaration('ui-slots', { dynamic: false })
const runtime = declaration('runtime', { dynamic: false })
const found = collectClientPackageViolations(facts([], {
declarations: [slots, runtime],
platformModules: [slots.name],
preloadedExternals: [runtime.name + '/client'],
}))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('does not use the staticLinked preset')
expect(found.join('\n')).toContain('has no dynamic dsh.client row')
})
})
describe('dependency sections', () => {
it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => {
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
const runtime = pkg('runtime', {
inject: ['@deepseek-ai/dsh-client-feature'],
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'],
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'],
react: ['packages/client/runtime/src/client/view.tsx'],
},
dependencies: { immer: '^10.1.1' },
peerDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:^',
'@deepseek-ai/dsh-client-feature': 'workspace:^',
},
devDependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:^',
'@deepseek-ai/dsh-client-feature': 'workspace:^',
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
react: '^18.2.0',
},
})
expect(collectClientPackageViolations(facts([slots, runtime], {
platformModules: ['react', slots.name],
}))).toEqual([])
})
it('rejects internal dependencies, static peers, and mismatched peer development ranges', () => {
const slots = pkg('ui-slots', { dynamic: false, staticLinked: true })
const subject = pkg('feature', {
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
[slots.name]: ['packages/client/feature/src/view.tsx'],
},
dependencies: { '@deepseek-ai/dsh-agent': 'workspace:^' },
peerDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', [slots.name]: 'workspace:*' },
})
const found = collectClientPackageViolations(facts([slots, subject]))
expect(found).toHaveLength(2)
expect(found.join('\n')).toContain('peer-installed DSH relationship')
expect(found.join('\n')).toContain('static client input')
})
it('requires every peer to have the same development range', () => {
const subject = pkg('feature', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/cordis-plugin-loader': 'workspace:^' },
})
expect(collectClientPackageViolations(facts([subject]))).toEqual([
'packages/client/feature/package.json: peerDependencies.@deepseek-ai/cordis-plugin-loader'
+ ' is workspace:^, so devDependencies.@deepseek-ai/cordis-plugin-loader must use the same range;'
+ ' found no declaration',
])
})
it('allows npm dependency cycles', () => {
const a = pkg('a', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-b': 'workspace:^' },
})
const b = pkg('b', {
peerDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
devDependencies: { [CORDIS]: 'workspace:^', '@deepseek-ai/dsh-client-a': 'workspace:^' },
})
expect(collectClientPackageViolations(facts([a, b]))).toEqual([])
})
})
describe('module requests', () => {
it('accepts a dynamic row supplier and its client subpath', () => {
const ui = declaration('ui', { external: ['@deepseek-ai/dsh-client-slots/client'] })
const slots = declaration('slots')
expect(collectClientPackageViolations(facts([], { declarations: [ui, slots] }))).toEqual([])
})
it('rejects an explicit baseline request', () => {
const ui = declaration('ui', { external: ['react'] })
expect(collectClientPackageViolations(facts([], {
declarations: [ui],
platformModules: ['react'],
}))).toEqual([
ui.manifest + ': dsh.client.external repeats baseline module "react"; remove the explicit declaration',
])
})
it('rejects duplicates, empty values, self-requests, and missing suppliers', () => {
const ui = declaration('ui', {
external: ['', '@deepseek-ai/dsh-client-ui', '@deepseek-ai/dsh-missing', '@deepseek-ai/dsh-missing'],
inject: ['', '@deepseek-ai/dsh-a', '@deepseek-ai/dsh-a'],
})
const found = collectClientPackageViolations(facts([], { declarations: [ui] }))
expect(found).toHaveLength(6)
expect(found.join('\n')).toContain('dsh.client.external contains an empty value')
expect(found.join('\n')).toContain('dsh.client.inject contains an empty value')
expect(found.join('\n')).toContain('names its own row')
expect(found.join('\n')).toContain('has no supplier')
})
it('rejects synchronous module-request cycles but ignores inject cycles', () => {
const a = declaration('a', {
external: ['@deepseek-ai/dsh-client-b'],
inject: ['@deepseek-ai/dsh-client-b'],
})
const b = declaration('b', {
external: ['@deepseek-ai/dsh-client-a'],
inject: ['@deepseek-ai/dsh-client-a'],
})
const found = collectClientPackageViolations(facts([], { declarations: [a, b] }))
expect(found).toHaveLength(1)
expect(found[0]).toContain('synchronous dsh.client.external cycle')
})
})
describe('manifest declarations', () => {
it('reports malformed arrays without hiding other packages', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-'))
roots.push(root)
const files: Record<string, unknown> = {
'packages/g/a/package.json': {
name: '@f/a', dsh: { client: { external: 'react', inject: ['@f/b', 1] } },
},
'packages/g/b/package.json': { name: '@f/b', dsh: { client: {} } },
}
for (const [path, value] of Object.entries(files)) {
mkdirSync(dirname(join(root, path)), { recursive: true })
writeFileSync(join(root, path), JSON.stringify(value))
}
const result = readClientDeclarations(root)
expect(result.declarations).toHaveLength(2)
expect(result.malformed).toEqual([
'packages/g/a/package.json: @f/a dsh.client.external must be a string array',
'packages/g/a/package.json: @f/a dsh.client.inject must be a string array',
])
})
it('fixes unambiguous dependency sections and declaration entries', () => {
const root = mkdtempSync(join(tmpdir(), 'client-packages-fix-'))
roots.push(root)
const subject = pkg('feature', {
external: ['', 'react', '@deepseek-ai/dsh-client-feature', '@deepseek-ai/dsh-missing'],
inject: ['', '@deepseek-ai/dsh-agent', '@deepseek-ai/dsh-agent'],
sourceUses: {
'@deepseek-ai/dsh-agent': ['packages/client/feature/src/index.ts'],
'@deepseek-ai/dsh-client-ui-slots': ['packages/client/feature/src/view.tsx'],
},
dependencies: {
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
},
peerDependencies: {
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
},
devDependencies: {},
})
const slots = declaration('ui-slots', { dynamic: false })
const manifest = {
name: subject.name,
dsh: { client: { external: subject.external, inject: subject.inject, platform: 'web' } },
dependencies: subject.dependencies,
peerDependencies: subject.peerDependencies,
devDependencies: subject.devDependencies,
}
mkdirSync(dirname(join(root, subject.manifest)), { recursive: true })
writeFileSync(join(root, subject.manifest), JSON.stringify(manifest))
writeFileSync(join(root, 'package.json'), JSON.stringify({ private: true }))
expect(fixClientPackageManifests(root, facts([subject], {
declarations: [subject, slots],
staticLinkedPackages: new Set([slots.name]),
platformModules: ['react', slots.name],
}))).toEqual([subject.manifest])
const fixed = JSON.parse(readFileSync(join(root, subject.manifest), 'utf8')) as {
dsh: { client: { external: string[]; inject: string[] } }
dependencies?: Record<string, string>
peerDependencies: Record<string, string>
devDependencies: Record<string, string>
}
expect(fixed.dsh.client).toMatchObject({
external: ['@deepseek-ai/dsh-missing'],
inject: ['@deepseek-ai/dsh-agent'],
})
expect(fixed.dependencies).toBeUndefined()
expect(fixed.peerDependencies).toEqual({
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
})
expect(fixed.devDependencies).toEqual({
'@deepseek-ai/dsh-client-ui-slots': 'workspace:^',
[CORDIS]: 'workspace:^',
'@deepseek-ai/dsh-agent': 'workspace:*',
'@deepseek-ai/cordis-plugin-loader': 'workspace:^',
})
})
})
+819
View File
@@ -0,0 +1,819 @@
/**
* Verify client package modes, npm dependency sections, and the synchronous
* browser module-request graph.
*/
import { globSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
import { TypeScriptProject } from './ts-project.ts'
const GATE = 'verify-client-packages'
const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts'
const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts'
const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts'
const CORDIS = '@deepseek-ai/cordis'
const DSH_PREFIX = '@deepseek-ai/dsh-'
/** One workspace package's browser-module declaration. */
export interface ClientDeclaration {
/** npm package name. */
readonly name: string
/** Repository-relative package manifest. */
readonly manifest: string
/** Whether the manifest declares a dynamic dsh.client row. */
readonly dynamic: boolean
/** Exact module-table specifiers requested by the row. */
readonly external: readonly string[]
/** Informational package dependencies declared by the row. */
readonly inject: readonly string[]
}
/** One package directly under packages/client. */
export interface ClientPackage extends ClientDeclaration {
/** Whether its build config uses the staticLinked preset. */
readonly staticLinked: boolean
/** Production source locations grouped by imported package name. */
readonly sourceUses: Readonly<Record<string, readonly string[]>>
/** Installed implementation dependencies. */
readonly dependencies: Readonly<Record<string, string>>
/** Consumer-supplied dependencies. */
readonly peerDependencies: Readonly<Record<string, string>>
/** Dependencies available while developing the package. */
readonly devDependencies: Readonly<Record<string, string>>
}
/** Complete source-plane input to the client package verifier. */
export interface ClientPackageFacts {
/** Packages directly under packages/client. */
readonly packages: readonly ClientPackage[]
/** Every workspace package, including packages without a browser row. */
readonly declarations: readonly ClientDeclaration[]
/** Packages whose build config uses the staticLinked preset. */
readonly staticLinkedPackages: ReadonlySet<string>
/** Specifiers the web shell seeds into the module table. */
readonly platformModules: readonly string[]
/** Dynamic factories the HTML parser loads before shell boot. */
readonly preloadedExternals: readonly string[]
/** Manifest field errors found while reading declarations. */
readonly malformed: readonly string[]
}
/** Result of reading every workspace browser-module declaration. */
export interface ClientDeclarations {
/** One declaration record per named workspace manifest. */
readonly declarations: ClientDeclaration[]
/** Manifest field errors that prevent a reliable declaration. */
readonly malformed: string[]
}
/**
* Collect bare packages referenced by one production source file.
* @param path - File path used to select TypeScript's parser mode.
* @param source - Source text to inspect.
* @returns Bare package names referenced by imports, declarations, or JSX.
*/
export function collectSourcePackageUses(path: string, source: string): Set<string> {
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
return collectSourceFilePackageUses(sourceFile)
}
function collectSourceFilePackageUses(sourceFile: ts.SourceFile): Set<string> {
const uses = new Set<string>()
const add = (specifier: ts.Expression | undefined): void => {
if (specifier === undefined || !ts.isStringLiteral(specifier) || !isBareSpecifier(specifier.text)) return
uses.add(packageNameOf(specifier.text))
}
const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
add(node.moduleSpecifier)
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
add(node.moduleReference.expression)
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node.argument.literal)
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
add(node.arguments[0])
} else if (ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
add(node.name)
} else if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
uses.add('react')
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return uses
}
/**
* Read browser-module declarations from workspace manifests.
* @param root - Absolute repository root.
* @returns Declarations and malformed dsh.client fields.
*/
export function readClientDeclarations(root: string): ClientDeclarations {
const malformed: string[] = []
const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
.map(normalizePath)
.sort()
.flatMap(path => readDeclaration(root, path, malformed) ?? [])
return { declarations, malformed }
}
/**
* Return every client package policy violation.
* @param facts - Package modes, manifests, source uses, and platform module lists.
* @returns Stable self-contained diagnostics.
*/
export function collectClientPackageViolations(facts: ClientPackageFacts): string[] {
return [
...facts.malformed,
...collectModeViolations(facts),
...collectDependencyViolations(facts),
...collectModuleViolations(facts),
].sort((left, right) => left.localeCompare(right))
}
interface ManifestDocument {
readonly path: string
readonly manifest: Manifest
changed: boolean
}
type DependencySection = 'dependencies' | 'peerDependencies' | 'devDependencies'
/**
* Repair manifest declarations whose intended result follows uniquely from the policy.
* @param root - Absolute repository root.
* @param facts - Facts used by the verification pass.
* @returns Repository-relative manifests written by the fixer.
*/
export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] {
const documents = new Map<string, ManifestDocument>()
const document = (path: string): ManifestDocument => {
const cached = documents.get(path)
if (cached !== undefined) return cached
const loaded: ManifestDocument = {
path,
manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest,
changed: false,
}
documents.set(path, loaded)
return loaded
}
const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
for (const declaration of facts.declarations.filter(entry => entry.dynamic)) {
const target = document(declaration.manifest)
const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined
const client = isRecord(dsh?.client) ? dsh.client : undefined
if (client === undefined) continue
target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed
target.changed = normalizeClientArray(
client,
'external',
value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name,
) || target.changed
}
const staticInputs = new Set([
...facts.staticLinkedPackages,
...facts.platformModules.map(packageNameOf),
])
staticInputs.delete(CORDIS)
const inferredRanges = dependencyRangeCandidates(root)
for (const pkg of facts.packages) {
const target = document(pkg.manifest)
const expected = expectedSections(pkg, staticInputs)
for (const [name, rule] of expected) {
const range = preferredRange(target.manifest, name, rule.kind, inferredRanges)
if (range === undefined) continue
target.changed = rule.kind === 'dev'
? ensureDevOnly(target.manifest, name, range) || target.changed
: ensurePeerDev(target.manifest, name, range) || target.changed
}
if (pkg.dynamic) {
const productionNames = new Set([
...Object.keys(section(target.manifest, 'dependencies')),
...Object.keys(section(target.manifest, 'peerDependencies')),
])
for (const name of productionNames) {
if (expected.has(name)) continue
const range = preferredRange(
target.manifest,
name,
staticInputs.has(name) ? 'dev' : 'peer-dev',
inferredRanges,
)
if (range === undefined) continue
if (staticInputs.has(name)) {
target.changed = ensureDevOnly(target.manifest, name, range) || target.changed
} else if (section(target.manifest, 'dependencies')[name] !== undefined && isInternalDsh(name)) {
target.changed = ensurePeerDev(target.manifest, name, range) || target.changed
}
}
}
for (const [name, range] of Object.entries(section(target.manifest, 'peerDependencies'))) {
target.changed = setDependency(target.manifest, 'devDependencies', name, range) || target.changed
}
target.changed = deleteEmptySections(target.manifest) || target.changed
}
const changed = [...documents.values()].filter(target => target.changed).sort((left, right) =>
left.path.localeCompare(right.path))
for (const target of changed) {
writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n')
}
return changed.map(target => target.path)
}
function normalizeClientArray(
client: Record<string, unknown>,
field: 'external' | 'inject',
remove: (value: string) => boolean,
): boolean {
const value = client[field]
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false
const seen = new Set<string>()
const normalized = value.filter((entry: string) => {
if (entry === '' || seen.has(entry) || remove(entry)) return false
seen.add(entry)
return true
})
if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false
if (normalized.length === 0) {
if (field === 'external') delete client.external
else delete client.inject
} else {
client[field] = normalized
}
return true
}
function ensureDevOnly(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'dependencies', name)
changed = deleteDependency(manifest, 'peerDependencies', name) || changed
return setDependency(manifest, 'devDependencies', name, range) || changed
}
function ensurePeerDev(manifest: Manifest, name: string, range: string): boolean {
let changed = deleteDependency(manifest, 'dependencies', name)
changed = setDependency(manifest, 'peerDependencies', name, range) || changed
return setDependency(manifest, 'devDependencies', name, range) || changed
}
function setDependency(manifest: Manifest, field: DependencySection, name: string, range: string): boolean {
const dependencies = mutableSection(manifest, field)
if (dependencies[name] === range) return false
dependencies[name] = range
return true
}
function deleteDependency(manifest: Manifest, field: DependencySection, name: string): boolean {
const dependencies = section(manifest, field)
if (dependencies[name] === undefined) return false
manifest[field] = Object.fromEntries(Object.entries(dependencies).filter(([key]) => key !== name))
return true
}
function deleteEmptySections(manifest: Manifest): boolean {
let changed = false
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
if (manifest[field] === undefined || Object.keys(section(manifest, field)).length > 0) continue
if (field === 'dependencies') delete manifest.dependencies
else if (field === 'peerDependencies') delete manifest.peerDependencies
else delete manifest.devDependencies
changed = true
}
return changed
}
function preferredRange(
manifest: Manifest,
name: string,
kind: ExpectedRule['kind'],
inferred: ReadonlyMap<string, ReadonlySet<string>>,
): string | undefined {
const order: readonly DependencySection[] = kind === 'dev'
? ['devDependencies', 'peerDependencies', 'dependencies']
: ['peerDependencies', 'devDependencies', 'dependencies']
for (const field of order) {
const range = section(manifest, field)[name]
if (range !== undefined) return range
}
if (isInternalDsh(name)) return 'workspace:^'
const candidates = inferred.get(name)
return candidates?.size === 1 ? [...candidates][0] : undefined
}
function dependencyRangeCandidates(root: string): Map<string, Set<string>> {
const candidates = new Map<string, Set<string>>()
const paths = globSync([
'package.json',
...MANIFEST_GLOBS,
'website/package.json',
], { cwd: root }).map(normalizePath)
for (const path of new Set(paths)) {
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest
for (const field of ['dependencies', 'peerDependencies', 'devDependencies'] as const) {
for (const [name, range] of Object.entries(section(manifest, field))) {
const ranges = candidates.get(name) ?? new Set<string>()
ranges.add(range)
candidates.set(name, ranges)
}
}
}
return candidates
}
function section(manifest: Manifest, field: DependencySection): Record<string, string> {
return manifest[field] ?? {}
}
function mutableSection(manifest: Manifest, field: DependencySection): Record<string, string> {
const value = manifest[field]
if (value !== undefined) return value
const created: Record<string, string> = {}
manifest[field] = created
return created
}
function collectModeViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
for (const pkg of facts.packages) {
if (pkg.dynamic && pkg.staticLinked) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;'
+ ' a client package must be dynamic or statically linked, not both',
)
} else if (!pkg.dynamic && !pkg.staticLinked) {
violations.push(
pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;'
+ ' declare dsh.client or use the staticLinked preset',
)
}
}
const workspaceNames = new Set(facts.declarations.map(entry => entry.name))
for (const specifier of facts.platformModules) {
const owner = packageNameOf(specifier)
if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue
violations.push(
PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier)
+ ' belongs to ' + owner + ', whose build does not use the staticLinked preset',
)
}
const rows = rowNames(facts.declarations)
for (const specifier of facts.preloadedExternals) {
if (rowPackageOf(specifier, rows) !== undefined) continue
violations.push(
PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier)
+ ' has no dynamic dsh.client row',
)
}
return violations
}
interface ExpectedRule {
readonly kind: 'dev' | 'peer-dev'
readonly origins: Set<string>
}
function collectDependencyViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const staticInputs = new Set([
...facts.staticLinkedPackages,
...facts.platformModules.map(packageNameOf),
])
staticInputs.delete(CORDIS)
for (const pkg of [...facts.packages].sort((left, right) => left.manifest.localeCompare(right.manifest))) {
const expected = expectedSections(pkg, staticInputs)
for (const [name, rule] of [...expected].sort(([left], [right]) => left.localeCompare(right))) {
const actual = declaredSections(pkg, name)
if (rule.kind === 'dev') {
if (actual.length === 1 && actual[0] === 'devDependencies') continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ') is a static client input;'
+ ' declare it only in devDependencies, found ' + describeSections(actual),
)
continue
}
const peerRange = pkg.peerDependencies[name]
const devRange = pkg.devDependencies[name]
if (actual.length === 2
&& actual.includes('peerDependencies')
&& actual.includes('devDependencies')
&& peerRange === devRange) continue
violations.push(
pkg.manifest + ': ' + name + ' (' + describeOrigins(rule.origins) + ')'
+ ' is a peer-installed DSH relationship; declare it in peerDependencies and devDependencies'
+ ' with matching ranges, not dependencies; found ' + describeSections(actual)
+ describeRangeMismatch(peerRange, devRange),
)
}
for (const [name, peerRange] of Object.entries(pkg.peerDependencies).sort(([left], [right]) => left.localeCompare(right))) {
if (expected.has(name)) continue
const devRange = pkg.devDependencies[name]
if (devRange === peerRange) continue
violations.push(
pkg.manifest + ': peerDependencies.' + name + ' is ' + peerRange + ', so devDependencies.' + name
+ ' must use the same range; found ' + (devRange ?? 'no declaration'),
)
}
if (!pkg.dynamic) continue
for (const section of ['dependencies', 'peerDependencies'] as const) {
for (const name of Object.keys(pkg[section]).sort()) {
if (expected.has(name)) continue
if (staticInputs.has(name)) {
violations.push(
pkg.manifest + ': dynamic package declares static input ' + name + ' in ' + section + ';'
+ ' move it to devDependencies or delete the stale declaration',
)
} else if (section === 'dependencies' && isInternalDsh(name)) {
violations.push(
pkg.manifest + ': dynamic package declares ' + name + ' in dependencies;'
+ ' dynamic DSH relationships are peer plus dev, and static client inputs are dev-only',
)
}
}
}
}
return violations
}
function expectedSections(pkg: ClientPackage, staticInputs: ReadonlySet<string>): Map<string, ExpectedRule> {
const expected = new Map<string, ExpectedRule>([
[CORDIS, { kind: 'peer-dev', origins: new Set(['client package baseline']) }],
])
if (!pkg.dynamic) return expected
const add = (name: string, origin: string): void => {
if (name === pkg.name) return
const kind = staticInputs.has(name) ? 'dev' : isInternalDsh(name) ? 'peer-dev' : undefined
if (kind === undefined) return
const current = expected.get(name)
if (current !== undefined) current.origins.add(origin)
else expected.set(name, { kind, origins: new Set([origin]) })
}
for (const [name, locations] of Object.entries(pkg.sourceUses)) {
for (const location of locations) add(name, location)
}
for (const name of pkg.inject) add(name, 'dsh.client.inject')
return expected
}
interface ModuleEdge {
readonly from: string
readonly to: string
readonly specifier: string
}
function collectModuleViolations(facts: ClientPackageFacts): string[] {
const violations: string[] = []
const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals])
const staticModules = new Set(facts.platformModules)
const rows = rowNames(facts.declarations)
const byName = new Map(facts.declarations.map(entry => [entry.name, entry]))
const edges: ModuleEdge[] = []
for (const pkg of facts.declarations.filter(entry => entry.dynamic)) {
for (const field of ['external', 'inject'] as const) {
const seen = new Set<string>()
for (const value of pkg[field]) {
if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value')
else if (seen.has(value)) {
violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice')
}
seen.add(value)
}
}
for (const specifier of new Set(pkg.external)) {
if (specifier === '') continue
if (baseline.has(specifier)) {
violations.push(
pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier)
+ '; remove the explicit declaration',
)
continue
}
if (staticModules.has(specifier)) continue
const supplier = rowPackageOf(specifier, rows)
if (supplier === pkg.name) {
violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier))
} else if (supplier !== undefined) {
edges.push({ from: pkg.name, to: supplier, specifier })
} else {
const owner = stripClientSuffix(specifier)
violations.push(
pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;'
+ (byName.has(owner)
? ' workspace package ' + owner
+ ' declares no dynamic dsh.client row and the shell does not seed this specifier'
: ' no dynamic row or PLATFORM_MODULES entry answers it'),
)
}
}
}
violations.push(...collectModuleCycles(edges, byName))
return violations
}
function collectModuleCycles(
edges: readonly ModuleEdge[],
byName: ReadonlyMap<string, ClientDeclaration>,
): string[] {
const outgoing = new Map<string, ModuleEdge[]>()
for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) {
outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge])
}
const finished = new Set<string>()
const onPath = new Set<string>()
const path: ModuleEdge[] = []
const reported = new Map<string, string>()
const walk = (name: string): void => {
onPath.add(name)
for (const edge of outgoing.get(name) ?? []) {
if (onPath.has(edge.to)) {
const start = path.findIndex(entry => entry.from === edge.to)
const cycle = start === -1 ? [edge] : [...path.slice(start), edge]
const key = cycleKey(cycle)
if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName))
} else if (!finished.has(edge.to)) {
path.push(edge)
walk(edge.to)
path.pop()
}
}
onPath.delete(name)
finished.add(name)
}
for (const name of [...outgoing.keys()].sort()) {
if (!finished.has(name)) walk(name)
}
return [...reported.values()]
}
function cycleKey(cycle: readonly ModuleEdge[]): string {
const labels = cycle.map(edge => edge.from + ' ' + edge.specifier)
const first = [...labels].sort()[0]
const offset = first === undefined ? 0 : labels.indexOf(first)
return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ')
}
function formatCycle(
cycle: readonly ModuleEdge[],
byName: ReadonlyMap<string, ClientDeclaration>,
): string {
const entry = cycle[0]
const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ')
const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from
return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '')
}
interface Manifest {
name?: unknown
dsh?: unknown
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
function readDeclaration(
root: string,
manifestPath: string,
malformed: string[],
): ClientDeclaration | undefined {
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
if (typeof manifest.name !== 'string') return undefined
const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined
const rawClient = dsh?.client
if (rawClient === undefined) {
return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
}
if (!isRecord(rawClient)) {
malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object')
return { name: manifest.name, manifest: manifestPath, dynamic: false, external: [], inject: [] }
}
return {
name: manifest.name,
manifest: manifestPath,
dynamic: true,
external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed),
inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed),
}
}
function stringArray(
value: unknown,
packageName: string,
manifestPath: string,
field: string,
malformed: string[],
): readonly string[] {
if (value === undefined) return []
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) {
malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array')
return []
}
return value as string[]
}
async function readStaticLinkedRoster(root: string): Promise<Set<string>> {
const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href
const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown }
if (typeof preset.isStaticLinkedConfig !== 'function') {
throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig')
}
const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean
const roster = new Set<string>()
for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) {
const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown }
if (typeof loaded.default !== 'function') continue
const configs = (loaded.default as (input: { env: Record<string, string> }) => unknown)({
env: { DSH_BUILD_FACE: 'client' },
})
if (!Array.isArray(configs) || !predicate(configs)) continue
const manifest = JSON.parse(
readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'),
) as Manifest
if (typeof manifest.name === 'string') roster.add(manifest.name)
}
return roster
}
function readStringLiteralArray(root: string, name: string): string[] {
const path = resolve(root, PLATFORM_SOURCE)
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
const expression = declaration.initializer !== undefined && ts.isAsExpression(declaration.initializer)
? declaration.initializer.expression
: declaration.initializer
if (expression === undefined || !ts.isArrayLiteralExpression(expression)) {
throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must be an array literal')
}
return expression.elements.map((element) => {
if (!ts.isStringLiteral(element)) {
throw new Error(GATE + ': ' + name + ' in ' + PLATFORM_SOURCE + ' must contain only string literals')
}
return element.text
})
}
}
throw new Error(GATE + ': ' + PLATFORM_SOURCE + ' declares no ' + name)
}
async function readFacts(root: string): Promise<ClientPackageFacts> {
const { declarations, malformed } = readClientDeclarations(root)
const byManifest = new Map(declarations.map(entry => [entry.manifest, entry]))
const staticLinkedPackages = await readStaticLinkedRoster(root)
const project = new TypeScriptProject(root, 'client')
const packages: ClientPackage[] = []
for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) {
const declaration = byManifest.get(manifestPath)
if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath)
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest
if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name')
const sourceUses = new Map<string, Set<string>>()
const packageDirectory = dirname(manifestPath)
const sourcePrefix = packageDirectory + '/src/'
for (const sourceFile of project.sourceFiles()) {
if (sourceFile.isDeclarationFile) continue
const file = project.relativePath(sourceFile)
if (!file.startsWith(sourcePrefix)) continue
for (const name of collectSourceFilePackageUses(sourceFile)) {
const locations = sourceUses.get(name) ?? new Set<string>()
locations.add(file)
sourceUses.set(name, locations)
}
}
packages.push({
...declaration,
staticLinked: staticLinkedPackages.has(declaration.name),
sourceUses: Object.fromEntries(
[...sourceUses].sort(([left], [right]) => left.localeCompare(right))
.map(([name, locations]) => [name, [...locations].sort()]),
),
dependencies: manifest.dependencies ?? {},
peerDependencies: manifest.peerDependencies ?? {},
devDependencies: manifest.devDependencies ?? {},
})
}
return {
packages,
declarations,
staticLinkedPackages,
platformModules: readStringLiteralArray(root, 'PLATFORM_MODULES'),
preloadedExternals: readStringLiteralArray(root, 'PRELOADED_CLIENT_EXTERNALS'),
malformed,
}
}
function packageNameOf(specifier: string): string {
const segments = specifier.split('/')
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
}
function stripClientSuffix(specifier: string): string {
return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier
}
function rowNames(declarations: readonly ClientDeclaration[]): Set<string> {
return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name))
}
function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | undefined {
if (rows.has(specifier)) return specifier
const stripped = stripClientSuffix(specifier)
return rows.has(stripped) ? stripped : undefined
}
function declaredSections(pkg: ClientPackage, name: string): string[] {
return (['dependencies', 'peerDependencies', 'devDependencies'] as const)
.filter(section => pkg[section][name] !== undefined)
}
function describeSections(sections: readonly string[]): string {
return sections.length === 0 ? 'no dependency declaration' : sections.join(' + ')
}
function describeRangeMismatch(peer: string | undefined, dev: string | undefined): string {
if (peer === undefined || dev === undefined || peer === dev) return ''
return ' (peer ' + peer + ', dev ' + dev + ')'
}
function describeOrigins(origins: ReadonlySet<string>): string {
const sorted = [...origins].sort()
const [first, second, ...rest] = sorted
if (first === undefined) return 'production use'
if (second === undefined) return first
return rest.length === 0 ? first + ', ' + second : first + ', ' + second + ', and ' + String(rest.length) + ' more'
}
function isInternalDsh(name: string): boolean {
return name === CORDIS || name.startsWith(DSH_PREFIX)
}
function isBareSpecifier(specifier: string): boolean {
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizePath(path: string): string {
return path.split(sep).join('/')
}
async function main(): Promise<void> {
const root = resolve(import.meta.dirname, '..')
let facts = await readFacts(root)
if (process.argv.includes('--fix')) {
const changed = fixClientPackageManifests(root, facts)
console.log(
changed.length === 0
? GATE + ': no mechanically fixable manifest changes.'
: GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '),
)
facts = await readFacts(root)
}
const violations = collectClientPackageViolations(facts)
if (violations.length > 0) {
console.error(GATE + ': ' + String(violations.length) + ' violation(s):')
for (const violation of violations) console.error(' ' + violation)
process.exit(1)
}
const dynamic = facts.packages.filter(pkg => pkg.dynamic).length
const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0)
console.log(
GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, '
+ String(facts.packages.length - dynamic) + ' statically linked) satisfy dependency and module-request rules; '
+ String(requests) + ' explicit external request(s).',
)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
await main()
}
@@ -95,6 +95,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' },
'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
'packages/client/render-service': { kind: 'none', reason: 'Browser-side UI assembly layer; registers nothing model-facing.' },
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
+3
View File
@@ -220,6 +220,9 @@
"@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"],
"@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
"@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],
"@deepseek-ai/dsh-client-render-service": ["./packages/client/render-service/src"],
"@deepseek-ai/dsh-client-render-service/client": ["./packages/client/render-service/src/client"],
"@deepseek-ai/dsh-client-render-service/invariant": ["./packages/client/render-service/src/invariant.ts"],
// sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes,
// so the generic wildcard cannot map these three package names.
"@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"],
+4
View File
@@ -199,6 +199,10 @@ export default defineConfig({
'packages/client/ui-slots/src/*',
'packages/client/ui-layout/src/*',
'packages/client/web/src/*',
// The render service's node half and invariant companion are the empty
// dual-face pair every client plugin carries; its browser half is
// covered by this package's specs.
'packages/client/render-service/src/*',
'packages/host/webserver/src/*',
'packages/client/modules/src/client/system.ts',
'packages/client/hmr/src/client/index.ts',