mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
build(client): enforce client package boundaries
This commit is contained in:
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:^"
|
||||
}
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
Generated
+125
-121
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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
@@ -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 '),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)(\\/|$)/
|
||||
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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' }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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:^',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user