Client shell layering and dynamic package boundaries
客户端壳分层与动态包边界
Client npm dependency sections describe installation and development relationships, but they do not reliably describe bundle contents. Treating `dependencies`, `peerDependencies`, or `devDependencies` as implicit bundler instructions can inline a shared React or workspace identity, or leave a built library carrying unresolved child imports without the host that is meant to assemble them. The browser application also
English
Problem
Client npm dependency sections describe installation and development relationships, but they do not reliably describe bundle contents. Treating dependencies, peerDependencies, or devDependencies as implicit bundler instructions can inline a shared React or workspace identity, or leave a built library carrying unresolved child imports without the host that is meant to assemble them.
The browser application also contains distinct roles: the HTML/Vite compilation entry, the framework-free Cordis startup kernel, static assembly libraries, and Loader-governed plugins. Early execution from HTML is an arrival policy, not a package kind. Modules must arrive before the Vite main module while retaining its ordinary lib/client.js artifact and dynamic graph row.
Shared UI libraries still expose synchronous TypeScript and React values to many consumers. Until those values move behind services or slots, making the libraries formal dynamic entries would preserve the value coupling while obscuring which module identity the shell must share.
Decision
Layers and build forms
| Layer | Members | Responsibility | Build and load form |
|---|---|---|---|
| Web compilation shell | apps/web | Owns index.html, Vite configuration, dist chunks, and static assets | Assembles final browser output from built package exports |
| Startup kernel | packages/client/web | Owns the plain-DOM boot page, module-system wiring, Cordis settlement, and renderer handoff | staticLinked lib/index.js; no dsh.client row |
| Static assembly libraries | Cordis, ui-primitives, ui-slots | Supply shared module identities and direct value APIs | ESM lib/index.js, merged and chunked by Vite; not Loader entries |
| Module bootstrap | packages/client/modules | Supplies the client module table and its Cordis wrapper | Dynamic package with one ordinary lib/client.js; the host delivers its factory early |
| Dynamic client packages | connection, ui-renderer, theme, and feature plugins | Participate through Cordis services, slots, and effects | Declare dsh.client, emit self-registering lib/client.js, and remain host-graph entries |
packages/client/web keeps Cordis as matching peer and development dependencies and uses modules and static UI packages as development compilation inputs. apps/web consumes built package exports rather than aliases into workspace source.
The staticLinked preset leaves every bare specifier as an external import in lib/index.js and emits relative CSS assets beside it. The Vite host resolves and deduplicates those imports and decides final chunk boundaries. A static library therefore does not copy the host's bundling policy into its own artifact.
Shared module requests
Dynamic browser bundles implicitly externalize the common baseline: PLATFORM_MODULES names shell-seeded React, Cordis, selected third-party libraries, and static UI identities, while PRELOADED_CLIENT_EXTERNALS is reserved for a dynamic identity that must arrive before shell boot and is currently empty. A package uses dsh.client.external only for an exact non-baseline value request. Type-only imports are erased and create no request; non-baseline third-party implementation libraries remain private bundle contents. Shared third-party libraries use the shell's declared versions without per-plugin version negotiation; promotion into the baseline requires a measured reduction across the shell and shipped plugin bundles.
A request has exactly two suppliers:
- The dynamic package row it names; a trailing
/clientaliases that package row. - An exact key in the shell's static module table.
There is no general dsh.client.provide alias mechanism. Dynamic rows and static keys exhaust the real suppliers, while Cordis service provision remains independent. Graph composition rejects malformed or missing requests, self-requests, and synchronous request cycles, and orders dynamic suppliers before their consumers. ClientModuleSystem.import() and prefetch() recursively register those dynamic supplier factories before the consumer can materialize, so network timing cannot violate the synchronous request graph.
Parser preloading and React handoff
The modules Node half injects the startup protocol into the served HTML in this order:
- Install
window.__ModuleLoader__in queue mode withpendingQueue,load(), andcreate(). - Start preloading every revisioned application combo URL containing the rows other than modules.
- Execute every blocking bootstrap combo URL; these currently contain the ordinary modules factory registration.
- Assign
window.__DSH_BOOT__, including all scheduling descriptors and every row's one-resource HMR combo URL. - Execute the Vite main module.
The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to __ModuleLoader__.create(). The facade removes the modules registration, materializes it with a require function that rejects every external, and invokes its createClientModuleSystem export. The modules bundle parses the graph, constructs and returns ClientModuleSystem, caches its own exports as the modules row, and switches the same facade to live mode. The kernel installs that instance as its Loader's internal, and the modules plugin reads it there when it provides ctx.modules. The modules client face consequently has a zero-external bootstrap requirement and no module-global system identity.
The Host publishes graph and combo descriptors without concatenating response bodies. Each script URL shares one lazy Promise that concatenates its captured bundle bytes on first GET and appends the corresponding map URL; each map URL has a separate lazy Promise that reads and composes source maps only on its first GET. HEAD requests trigger neither body. The Web URL remains gated by Loader settlement and the required-entry audit, but that readiness point does not materialize combo bodies; an index request reads the current graph.
The theme Host contribution is prepended to index collection. CSS in the head selects the initial document canvas palette, using prefers-color-scheme directly for the system preference; a body script applies the existing palette attribute and font-size variable before the loading page and application module.
After the immediately tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls ctx.uiRenderer.mount(container). The dynamic ui-renderer package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
Dependency declarations
Every Client package keeps Cordis in matching peerDependencies and devDependencies; Cordis is its only peer. Browser imports, type references, module augmentations, and dsh.client.inject are development inputs because the Client build and shipped profile supply their runtime identities. A package that also publishes a Host entry keeps that entry's runtime value imports in dependencies. Published dependency faces owns package discovery, exceptions, and the explicit Host roster.
Ordinary installed libraries remain dependencies: a dynamic build may bundle a private implementation, while a staticLinked library retains its bare import for the final host. Each build face decides externality independently from npm sections. Published file lists cover every runtime entry, relative asset, and declaration file reached by the artifact.
verify-package-dependencies enforces and repairs dependency sections. verify-client-packages enforces build forms, parser-preload alignment, shared-module requests, and module-graph acyclicity. The repository publint pass enforces publication closure.
Alternatives considered
Convert every client package into a dynamic plugin immediately. ui-primitives and ui-slots still provide synchronous values without independent service or slot lifecycles; a manifest declaration alone would not remove those imports.
Generate a separate client-static.js for modules. The package remains a dynamic graph row and Cordis plugin; only its factory arrival is early. A second artifact would encode host policy in a filename and create two runtime products from one source.
Compile all shared modules into the Vite entry. This would remove deployment composition and plugin-level replacement from business plugins, including the renderer and theme.
Retain a general module-provider declaration. Package rows and exact static keys already name all suppliers; aliases would add another ownership protocol without a third supply source.
Hardcode preload URLs in apps/web/index.html. URLs and rev values belong to the host's current graph. Rewriting the served HTML keeps the queue, bundle URLs, and manifest on one graph revision.
Consequences
Bundle contents stay stable when an internal DSH relationship is development-only, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance. The shipped profile owns the complete Client package roster, so individual Client packages do not ask npm to solve the same graph again through peer placement.
The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. Deferring response bodies moves concatenation to first access, while separately deferring maps keeps debugger-only work off script delivery. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
The shell consumes built lib/ products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
The two static UI libraries remain deliberate exceptions. Converting either one to a dynamic package requires moving all value consumers to services or slots and removing its identity from the static seed in the same change.
中文
Problem
Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundle 内容。把 dependencies、peerDependencies 或 devDependencies 当作隐式 bundler 指令,可能内联本应共享的 React 或 workspace 身份,也可能让构建后的库携带未解析子 import,却没有交给预期的宿主组装。
浏览器应用还包含不同角色:HTML/Vite 编译入口、不依赖框架的 Cordis 启动内核、静态装配库,以及由 Loader 治理的插件。HTML 提前执行属于到达策略,不定义包类别。Modules 必须先于 Vite 主模块到达,同时继续使用普通 lib/client.js 产物和动态图 row。
共享 UI 库仍向大量消费者暴露同步 TypeScript 与 React 实体。在这些实体进入 service 或 slot 前,形式上把库改为动态 entry 只会保留实体耦合,并模糊外壳必须共享的模块身份。
Decision
分层与构建形态
| 层 | 成员 | 职责 | 构建与加载形态 |
|---|---|---|---|
| Web 编译壳 | apps/web | 拥有 index.html、Vite 配置、dist chunk 和静态资源 | 从已构建 package export 组装最终浏览器产物 |
| 启动内核 | packages/client/web | 拥有纯 DOM 启动页、模块系统接线、Cordis settle 和 renderer handoff | staticLinked lib/index.js;无 dsh.client row |
| 静态装配库 | Cordis、ui-primitives、ui-slots | 提供共享模块身份和直接实体 API | ESM lib/index.js,由 Vite 合并拆分;不是 Loader entry |
| 模块自举包 | packages/client/modules | 提供 client 模块表及其 Cordis wrapper | 带一个普通 lib/client.js 的动态包;host 提前送达其 factory |
| 动态 client 包 | connection、ui-renderer、主题和功能插件 | 通过 Cordis service、slot 和 effect 参与应用 | 声明 dsh.client,产出自注册 lib/client.js,并保留 host graph entry |
packages/client/web 把 Cordis 保持为 matching peer 与开发依赖,并把 modules 和静态 UI 包作为开发期编译输入。apps/web 消费已构建 package export,不通过 alias 读取 workspace 源码。
staticLinked 预设让 lib/index.js 中每个 bare specifier 保持 external import,并在旁边输出相对 CSS 资产。Vite 宿主负责解析和去重这些 import,并决定最终 chunk 边界。静态库不会把宿主打包策略复制进自身产物。
共享模块请求
动态浏览器 bundle 会隐式 external 统一基座:PLATFORM_MODULES 命名由外壳播种的 React、Cordis、选定的第三方库和静态 UI 身份,PRELOADED_CLIENT_EXTERNALS 则为必须先于 shell 启动到达的动态身份预留,当前为空。包只在精确请求基座外实体时使用 dsh.client.external。纯类型 import 会被擦除,不产生请求;基座外的第三方实现库保留为 bundle 私有内容。共享第三方库使用外壳声明的版本,不为单个插件协商版本;只有测量确认外壳与随包插件 bundle 的总体积减少,才将库纳入基座。
请求只有两种提供方:
- 请求所命名的 dynamic package row;末尾
/client会别名到该 package row。 - 外壳静态模块表中的精确 key。
不存在通用 dsh.client.provide 别名机制。动态 row 和静态 key 已穷尽实际提供方,Cordis service provide 与此相互独立。图组合会拒绝畸形或缺失请求、自请求和同步请求环,并把动态提供方排在消费者之前。ClientModuleSystem.import() 与 prefetch() 会在消费者能够物化前递归登记这些动态提供方的 factory,因此网络时序无法破坏同步请求图。
Parser 预载与 React 移交
Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
- 以 queue 模式安装
window.__ModuleLoader__,包含pendingQueue、load()与create()。 - 开始预加载所有带 revision 的 application combo URL,其中包含 modules 之外的 row。
- 执行所有阻塞式 bootstrap combo URL;当前其中包含普通的 modules factory registration。
- 赋值
window.__DSH_BOOT__,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。 - 执行 Vite 主模块。
Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 __ModuleLoader__.create()。Facade 移除 modules registration,用拒绝全部 external 的 require 函数将其物化,再调用其 createClientModuleSystem 导出。Modules bundle 解析图、构造并返回 ClientModuleSystem、把自身 exports 缓存为 modules row,并把同一 facade 切换到 live 模式。内核把该实例装成自身 Loader 的 internal,modules 插件从这里读取并提供 ctx.modules。因此 modules client face 保持零 external 的自举要求,也没有模块级系统身份。
Host 发布 graph 与 combo descriptor 时不会拼接响应 body。每个脚本 URL 共用一个惰性 Promise,在首次 GET 时拼接捕获的 bundle 字节并追加对应的 map URL;每个 map URL 使用另一个惰性 Promise,只在首次 GET 时读取并组合 source map。HEAD 不触发任一 body。Web URL 仍由 Loader 结算和 required-entry audit 控制,但这个就绪点不会物化 combo body;index 请求读取当时的最新 graph。
Theme 的 Host 贡献会前置到 index 收集顺序。head 中的 CSS 选择初始文档画布调色板,system 偏好直接使用 prefers-color-scheme;body 脚本在加载页面和应用模块之前应用既有的调色板属性与字号变量。
immediately 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 ctx.uiRenderer.mount(container)。动态 ui-renderer 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
依赖声明
每个 Client 包都把 Cordis 保持为范围一致的 peerDependencies 和 devDependencies;Cordis 是唯一的 peer。Browser import、类型引用、模块扩充与 dsh.client.inject 都是开发输入,因为 Client 构建与发布 profile 会提供其运行期身份。同时发布 Host 入口的包把该入口的运行期 value import 放在 dependencies。发布依赖门面负责包发现、例外与显式 Host 名册。
普通安装库仍放在 dependencies:动态构建可以内联私有实现,而 staticLinked 库会保留 bare import 交给最终宿主。各构建 face 独立决定 external,不由 npm 区段推导。发布文件列表覆盖产物实际可达的每个运行期入口、相对资产和声明文件。
verify-package-dependencies 检查并修复依赖区段。verify-client-packages 检查构建形态、parser preload 对齐、共享模块请求和模块图无环性。仓库 publint pass 负责检查发布闭包。
Alternatives considered
立即把所有 client 包改为动态插件。 ui-primitives 与 ui-slots 仍提供同步实体,且没有独立 service 或 slot 生命周期;只加 manifest 声明不会移除这些 import。
为 modules 生成单独的 client-static.js。 该包仍是动态图 row 和 Cordis 插件,只有 factory 提前到达。第二份产物会把宿主策略编码进文件名,并让同一源码产生两个运行期产品。
把全部共享模块编进 Vite entry。 这会让业务插件失去部署组合与插件级替换能力,包括 renderer 和主题。
保留通用模块 provider 声明。 Package row 和精确静态 key 已命名全部提供方;别名会增加另一套归属协议,却没有第三种供给来源。
在 apps/web/index.html 中硬编码预载 URL。 URL 与 rev 属于 host 当前 graph。改写实际返回的 HTML 才能让 queue、bundle URL 和 manifest 使用同一 graph revision。
Consequences
内部 DSH 关系仅放在开发区段时,bundle 内容仍保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。发布 profile 拥有完整 Client 包名册,因此各 Client 包不再要求 npm 通过 peer placement 重复求解同一张图。
启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。响应 body 的延迟生成会把拼接移到首次访问,而 map 的独立延迟生成会让仅供调试器使用的工作不进入脚本交付路径。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
外壳消费已构建 lib/ 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
两个静态 UI 库仍是明确例外。把其中任一项转换为动态包时,必须在同一变更中把全部实体消费者迁移到 service 或 slot,并从静态 seed 删除对应身份。