From aa03eff500355f50c0592ce4695706fd7f2edf33 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:59:29 +0800 Subject: [PATCH] build(client): enforce client package boundaries --- apps/web/package.json | 10 +- apps/web/vite.config.ts | 28 +- knip.json | 35 + package.json | 3 +- packages/client/AGENTS.md | 41 +- packages/client/connection/package.json | 22 +- packages/client/locale/package.json | 9 +- packages/client/modules/package.json | 4 +- packages/client/modules/src/client/index.ts | 16 +- .../client/modules/src/client/manifest.ts | 111 ++- packages/client/modules/src/client/system.ts | 53 +- packages/client/modules/src/index.ts | 155 +++- packages/client/modules/tsdown.config.ts | 5 +- packages/client/runtime/package.json | 41 +- packages/client/tsdown.client.ts | 342 +++++++- packages/client/ui-agent-preset/package.json | 5 +- packages/client/ui-commands/package.json | 5 +- packages/client/ui-deliverables/package.json | 8 +- .../ui-directory-picker-browse/package.json | 5 +- .../ui-directory-picker-native/package.json | 4 +- packages/client/ui-goal/package.json | 9 +- packages/client/ui-input-trigger/package.json | 5 +- packages/client/ui-jobs/package.json | 8 +- packages/client/ui-layout/package.json | 4 +- .../client/ui-message-feedback/package.json | 5 +- .../client/ui-model-selection/package.json | 14 +- packages/client/ui-plan/package.json | 5 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/package.json | 1 + .../client/ui-primitives/tsdown.config.ts | 35 +- .../client/ui-settings-general/package.json | 8 +- .../ui-settings-plugin-inventory/package.json | 5 +- .../client/ui-settings-plugins/package.json | 5 +- packages/client/ui-settings/package.json | 3 +- packages/client/ui-sidebar/package.json | 4 +- packages/client/ui-skill/package.json | 5 +- packages/client/ui-slots/tsdown.config.ts | 4 +- packages/client/ui-subagent/package.json | 8 +- packages/client/ui-theme/package.json | 9 +- packages/client/ui-tool/package.json | 5 +- packages/client/ui-trajectory/package.json | 4 +- .../client/ui-user-questions/package.json | 20 +- packages/client/ui-workflow-run/package.json | 8 +- packages/client/ui-workspace/package.json | 5 +- packages/client/web/package.json | 14 +- packages/client/web/src/index.ts | 2 +- packages/client/web/src/platform.ts | 5 + packages/client/web/tsdown.config.ts | 35 +- .../cordis-client-runner/package.json | 4 +- packages/extensions/ui-cordis/package.json | 5 +- .../session-log-export/package.json | 5 +- pnpm-lock.yaml | 246 +++--- scripts/check-workspace-constraints.ts | 7 + scripts/client-bundle-purity.spec.ts | 58 +- scripts/dev-web.spec.ts | 27 +- scripts/dev-web.ts | 150 +++- scripts/publint-all.spec.ts | 38 +- scripts/publint-all.ts | 96 +- scripts/rescope-vendor.ts | 4 +- scripts/run-gates.spec.ts | 9 + scripts/run-gates.ts | 2 + scripts/verify-client-packages.spec.ts | 317 +++++++ scripts/verify-client-packages.ts | 819 ++++++++++++++++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + vitest.config.ts | 4 + 68 files changed, 2408 insertions(+), 532 deletions(-) create mode 100644 scripts/verify-client-packages.spec.ts create mode 100644 scripts/verify-client-packages.ts diff --git a/apps/web/package.json b/apps/web/package.json index 65b7b25c56..db7a5a786d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4a02abb5f3..33c4a048ae 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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: { diff --git a/knip.json b/knip.json index 05f7dbc0e0..f9216a1f93 100644 --- a/knip.json +++ b/knip.json @@ -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" + ] } } } diff --git a/package.json b/package.json index 4229920f59..69ad740dbb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 18b11f4c14..73558fb152 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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/` plugin package (ui-workspace is a com 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, 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 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 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 6590050f8a..49921b4df9 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -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:^" } } diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 8c66764dd3..7ec5b185e5 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -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": [ diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 15123d4dc2..6fe482ceca 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -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:^" } } diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index c7efe7df9f..eae5ac09ea 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -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' /** diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index 3f30074fe1..568e553e67 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/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 `/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 } +/** 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 `