fix(client): load plugin bundles as external scripts
This commit is contained in:
@@ -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/README.md
|
||||
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
|
||||
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
|
||||
README.md: 31d884c04a8b0233713b77b82b3d9cc7052003ca
|
||||
README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4
|
||||
|
||||
@@ -11,7 +11,7 @@ The browser side of the dsh web GUI: shell kernel, module system, wire consumer,
|
||||
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
|
||||
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
|
||||
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `hmr/` | Dev-only hot reload for script-loaded client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
|
||||
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
|
||||
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
|
||||
|
||||
@@ -11,7 +11,7 @@ dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、
|
||||
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
|
||||
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
|
||||
| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `hmr/` | 仅开发用的外部脚本加载型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
|
||||
| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
|
||||
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
|
||||
|
||||
@@ -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/hmr/README.md
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
|
||||
README.md: f91a6c6f685c88a1ea19312985ad3e933222192a
|
||||
README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* `rebuilt` frame it reloads the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all rostered plugin packages share these reload semantics;
|
||||
@@ -14,7 +14,7 @@
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* materialized record) → prefetch (load and register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
@@ -110,7 +110,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// async half while the old fiber still serves: script loading registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
|
||||
@@ -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/modules/README.md
|
||||
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
|
||||
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
|
||||
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
|
||||
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd
|
||||
|
||||
@@ -6,9 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* 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 fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* 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.
|
||||
*
|
||||
* This file is the browser-safe contract face (zero node imports): the
|
||||
@@ -56,7 +56,7 @@ export interface WebBootEntry {
|
||||
rev: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
@@ -210,18 +210,17 @@ export interface ClientModuleLoader {
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* 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
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* load (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* Full reset of one module: drop its registered factory and materialized
|
||||
* record so the next prefetch/import reloads it (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
@@ -233,11 +232,6 @@ export interface ClientModuleSystemOptions {
|
||||
modules: BootModuleRow[]
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -2,38 +2,28 @@
|
||||
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the public interfaces in `./manifest.ts`; this file owns the
|
||||
* state tables and the fetch/execute/materialize machinery.
|
||||
* state tables and the load/materialize machinery.
|
||||
*/
|
||||
import type {
|
||||
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
|
||||
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
|
||||
} from './manifest.ts'
|
||||
|
||||
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
|
||||
interface RegisteredFactory {
|
||||
factory: ClientPluginHandoff['factory']
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Default bundle fetch seam: same-origin fetch().text(). */
|
||||
const defaultFetchBundle = async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
/** Default bundle execution seam: a <script> element carrying the code. */
|
||||
const defaultExecuteBundle = (code: string, url: string): void => {
|
||||
/** Default bundle-load seam: same-origin external classic script. */
|
||||
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
// Execution is synchronous for inline scripts: the factory is registered by
|
||||
// now, so the node (and its source text) has no further job. Removing it
|
||||
// keeps repeated HMR rebuilds from accumulating dead script nodes.
|
||||
el.remove()
|
||||
}
|
||||
el.async = true
|
||||
el.src = url
|
||||
el.addEventListener('load', () => {
|
||||
el.remove()
|
||||
resolve()
|
||||
}, { once: true })
|
||||
el.addEventListener('error', () => {
|
||||
el.remove()
|
||||
reject(new Error(`client-modules: bundle script ${url} failed to load`))
|
||||
}, { once: true })
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
@@ -72,31 +62,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
|
||||
private readonly seed: Map<string, unknown>
|
||||
private readonly statics = new Map<string, unknown>()
|
||||
private readonly factories = new Map<string, RegisteredFactory>()
|
||||
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
|
||||
private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
|
||||
/** In-flight prefetch (script load) per id; concurrent callers share it. */
|
||||
private readonly pendingArrival = new Map<string, Promise<void>>()
|
||||
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, BootModuleRow>()
|
||||
// Execution URL of the bundle currently being executed (bound into the
|
||||
// factory registration so diagnostics can name the source).
|
||||
private executingUrl = ''
|
||||
// Graph id of the row currently being executed ('' outside arrive):
|
||||
// the load sink cross-checks the handoff id against it so a mis-stamped
|
||||
// bundle cannot register under another entry's identity.
|
||||
private executingId = ''
|
||||
|
||||
private readonly fetchBundle: (url: string) => Promise<string>
|
||||
private readonly executeBundle: (code: string, url: string) => void
|
||||
private readonly loadBundle: (url: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Build the module system over the parsed boot rows.
|
||||
* @param options - module rows, module-table staticModules, fetch/execute seams.
|
||||
* @param options - module rows, module-table staticModules, and bundle-load seam.
|
||||
*/
|
||||
constructor(options: ClientModuleSystemOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
|
||||
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
|
||||
this.loadBundle = options.loadBundle ?? defaultLoadBundle
|
||||
|
||||
for (const row of options.modules) {
|
||||
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
|
||||
@@ -110,37 +90,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
// 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?)`)
|
||||
// A fetched row's bundle must register the id its row names — a
|
||||
// mis-stamped bundle registering under another entry's identity
|
||||
// would let that entry silently materialize foreign exports.
|
||||
if (this.executingId !== '' && handoff.id !== this.executingId) {
|
||||
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
|
||||
}
|
||||
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
|
||||
this.factories.set(handoff.id, handoff.factory)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
private arrive(row: BootModuleRow): Promise<void> {
|
||||
const { id, url } = row
|
||||
const pending = this.pendingArrival.get(id)
|
||||
if (pending !== undefined) return pending
|
||||
if (this.factories.has(id)) return Promise.resolve()
|
||||
const task = (async (): Promise<void> => {
|
||||
const code = await this.fetchBundle(url)
|
||||
this.executingUrl = url
|
||||
this.executingId = id
|
||||
try {
|
||||
this.executeBundle(code, url)
|
||||
} finally {
|
||||
this.executingUrl = ''
|
||||
this.executingId = ''
|
||||
}
|
||||
const task = this.loadBundle(url).then(() => {
|
||||
if (!this.factories.has(id)) {
|
||||
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
|
||||
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
|
||||
}
|
||||
})().finally(() => { this.pendingArrival.delete(id) })
|
||||
}).finally(() => { this.pendingArrival.delete(id) })
|
||||
this.pendingArrival.set(id, task)
|
||||
return task
|
||||
}
|
||||
@@ -158,7 +123,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered.factory(this.makeRequire(edges))
|
||||
const surface = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
|
||||
* index render to inject the boot manifest, and provides the
|
||||
* 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).
|
||||
*
|
||||
@@ -424,9 +424,15 @@ export class ClientModuleHostService extends Service {
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
// The id may contain a scope slash. Anything else under /plugins (including
|
||||
// /plugins/events when the HMR row is absent) is an unknown resource.
|
||||
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
|
||||
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
|
||||
const prefix = '/plugins/'
|
||||
const mapSuffix = '/client.js.map'
|
||||
const bundleSuffix = '/client.js'
|
||||
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
|
||||
const suffix = isSourceMap ? mapSuffix : bundleSuffix
|
||||
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
|
||||
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
|
||||
: undefined
|
||||
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
|
||||
if (path === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
@@ -434,7 +440,10 @@ export class ClientModuleHostService extends Service {
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.writeHead(200, {
|
||||
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
* default transport seams, and the loud failure modes (duplicate
|
||||
* default transport seam, and the loud failure modes (duplicate
|
||||
* registration, cycles, table misses, double boot).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -20,7 +20,6 @@ type Factory = ClientPluginHandoff['factory']
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete win.__ModuleLoader__
|
||||
delete (document as unknown as Record<string, unknown>).__realmBridge
|
||||
for (const el of document.querySelectorAll('style, script')) el.remove()
|
||||
})
|
||||
|
||||
@@ -33,9 +32,9 @@ interface Bench {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader over scripted bundles: fetch resolves to the row url (optionally
|
||||
* gated on a release callback); execute registers the scripted factory
|
||||
* through the window sink (`null` scripts a bundle that never calls load).
|
||||
* Loader over scripted bundles: load records the row URL, optionally waits on
|
||||
* a release callback, then registers the scripted factory through the window
|
||||
* sink (`null` scripts a bundle that never calls load).
|
||||
*/
|
||||
function bench(
|
||||
entries: BootModuleRow[],
|
||||
@@ -47,15 +46,12 @@ function bench(
|
||||
const loader = new ClientModuleSystem({
|
||||
modules: entries,
|
||||
staticModules: opts.seed ?? {},
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
|
||||
await new Promise<void>((resolve) => { gates.set(url, resolve) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
|
||||
const factory = id === undefined ? undefined : bundles[id]
|
||||
if (factory == null || id === undefined) return
|
||||
win.__ModuleLoader__?.load({ id, factory })
|
||||
@@ -65,7 +61,7 @@ function bench(
|
||||
}
|
||||
|
||||
describe('lazy CJS arrival', () => {
|
||||
it('prefetch fetches and executes but does not run the factory', async () => {
|
||||
it('prefetch loads and registers but does not run the factory', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
|
||||
await b.loader.prefetch('a')
|
||||
@@ -85,7 +81,7 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.loader.loadCache.get('a')?.id).toBe('a')
|
||||
})
|
||||
|
||||
it('import without prefetch fetches, executes, and materializes in one call', async () => {
|
||||
it('import without prefetch loads, registers, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
@@ -228,7 +224,7 @@ describe('failure modes', () => {
|
||||
})
|
||||
|
||||
describe('HMR reset', () => {
|
||||
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
|
||||
it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
|
||||
let generation = 0
|
||||
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
@@ -275,27 +271,35 @@ describe('style claiming', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('default transport seams', () => {
|
||||
it('fetches same-origin and executes through an inline script tag', async () => {
|
||||
// In a browser the loader's globalThis IS the page window; vitest's jsdom
|
||||
// evaluates <script> in a separate realm that shares only the document,
|
||||
// so the fixture bundle restores the sink from a document bridge before
|
||||
// using the normal calling convention.
|
||||
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
|
||||
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
|
||||
describe('default transport seam', () => {
|
||||
it('loads through an external classic script and removes the settled node', async () => {
|
||||
const append = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
const script = nodes[0]
|
||||
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
|
||||
expect(script.async).toBe(true)
|
||||
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
|
||||
queueMicrotask(() => {
|
||||
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
|
||||
script.dispatchEvent(new Event('load'))
|
||||
})
|
||||
})
|
||||
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
// The script node is removed right after its synchronous execution —
|
||||
// repeated HMR rebuilds must not accumulate dead script nodes.
|
||||
expect(append).toHaveBeenCalledOnce()
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-ok bundle response is loud with the status', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
|
||||
it('a script load failure is loud and removes the node', async () => {
|
||||
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
const script = nodes[0]
|
||||
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
|
||||
queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
|
||||
})
|
||||
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow(
|
||||
'bundle script /plugins/dee/client.js?rev=0 failed to load',
|
||||
)
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** Node-half composition diagnostics for package metadata and built client bundles. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ClientModuleHostService } from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
@@ -33,8 +34,8 @@ function writePackage(packageName: string): string {
|
||||
return clientPath
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
/** Construct the node-half service and capture its plugin-bundle route. */
|
||||
function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
@@ -44,13 +45,24 @@ function construct(packageNames: string[]): ClientModuleHostService {
|
||||
}
|
||||
},
|
||||
})
|
||||
let route: WebRoute | undefined
|
||||
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: () => () => {},
|
||||
register: (candidate) => {
|
||||
if (candidate.path === '/plugins') route = candidate
|
||||
return () => {}
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
return new ClientModuleHostService(ctx)
|
||||
const service = new ClientModuleHostService(ctx)
|
||||
if (route === undefined) throw new Error('client bundle route was not registered')
|
||||
return { service, route }
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
return constructWithRoute(packageNames).service
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
@@ -84,4 +96,40 @@ describe('client bundle activation', () => {
|
||||
expect(String(thrown)).toContain('EISDIR')
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
|
||||
it('serves the source map beside a registered client bundle', async () => {
|
||||
const packageName = '@fixture/source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
|
||||
writeFileSync(`${clientPath}.map`, map)
|
||||
const { route } = constructWithRoute([packageName])
|
||||
let status = 0
|
||||
let headers: Record<string, string> | undefined
|
||||
let body = ''
|
||||
const response = {
|
||||
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
|
||||
status = nextStatus
|
||||
headers = nextHeaders
|
||||
return response
|
||||
},
|
||||
end(chunk?: Uint8Array) {
|
||||
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
|
||||
await route.handler({
|
||||
method: 'GET',
|
||||
url: `/plugins/${packageName}/client.js.map`,
|
||||
} as IncomingMessage, response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(headers).toEqual({
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
expect(body).toBe(map)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* The virtual loader registers each real stylesheet as a watch dependency.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve as resolvePath } from 'node:path'
|
||||
import { basename, dirname, 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'
|
||||
@@ -45,6 +46,16 @@ 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 the browser's repository-shaped URL tree. */
|
||||
function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
if (!source.startsWith('.')) return source
|
||||
const physicalSource = resolvePath(dirname(sourcemapPath), source)
|
||||
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
|
||||
return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tsdown config for one UI plugin package: the node-half lib build
|
||||
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
|
||||
@@ -78,6 +89,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
platform: 'browser',
|
||||
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
|
||||
dts: false,
|
||||
// Plugin code is fetched outside Vite's module graph, so its own bundle
|
||||
// must carry the TS/TSX mapping consumed by browser profiling tools.
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
// Browser bundles inline node-idiom deps (zustand/immer read
|
||||
@@ -156,6 +170,11 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.js',
|
||||
// The map is served from /plugins/<scoped-package>/client.js.map. The
|
||||
// browser resolves its local sources back into the repository-shaped
|
||||
// /packages/<group>/<package>/src tree; sourcesContent keeps them usable
|
||||
// without exposing that tree as an HTTP route.
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
|
||||
@@ -38,9 +38,8 @@ describe('tsdown client artifact', () => {
|
||||
async function loadArtifact() {
|
||||
let handoff: Handoff | undefined
|
||||
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
|
||||
// Same execution form the loader uses (inline script eval, window scope) —
|
||||
// the implied-eval ban targets accidental string execution, not this
|
||||
// deliberate bundle-execution fixture.
|
||||
// The implied-eval ban targets accidental string execution, not this
|
||||
// deliberate built-bundle fixture running in the window scope.
|
||||
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
|
||||
new Function(code!)()
|
||||
expect(handoff).toBeDefined()
|
||||
|
||||
@@ -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/web/README.md
|
||||
README.md: a4325f5dbe8ddd9bbe77086eb16fdb7aed9adc83
|
||||
README.zh.md: d4cb7a43da4e9c84a401ee0a1ac8c30d4816926e
|
||||
README.md: b8b03dcb58442116cc01a2ff4c30e266e3233ee9
|
||||
README.zh.md: 280ec52602321367715ae2a71c22cff265908299
|
||||
|
||||
@@ -8,7 +8,7 @@ Shell self-sufficiency (web2 hard rule): the kernel value-imports no plugin pack
|
||||
|
||||
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for the shared module surface: seed-table keys, tsdown client externals, and the vite alias set are its projections.
|
||||
|
||||
The optional `seams` parameter forwards the module system's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `<script>` execution cannot reach the page context (jsdom).
|
||||
The optional `seams` parameter forwards the module system's `loadBundle` transport override (`BootSeams`); production callers omit it — it exists for test environments where external `<script>` execution cannot reach the page context (jsdom).
|
||||
|
||||
The shell owns browser-title projection. With a selected session carrying a durable title, it renders `<session title> — <existing HTML title>` and reacts to later title revisions; no selection or a selected untitled session preserves the existing title, and shell unmount restores it. The existing HTML title remains the configurable product suffix.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Web 外壳内核:`new AppWebEntry(el, seams?).run()` 通过两阶段启动(w
|
||||
|
||||
`PLATFORM_MODULES`(src/platform.ts)是共享模块表层的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。
|
||||
|
||||
可选 `seams` 参数会转发模块系统的 `fetchBundle`/`executeBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于 `<script>` 执行无法到达页面上下文的测试环境(jsdom)。
|
||||
可选 `seams` 参数会转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);生产调用方省略此参数。它用于外部 `<script>` 执行无法到达页面上下文的测试环境(jsdom)。
|
||||
|
||||
外壳拥有浏览器标题投影。选中带有持久标题的会话时,它会渲染 `<session title> — <existing HTML title>` 并响应后续标题修订;未选择会话或选中无标题会话时,会保留现有标题;外壳卸载时恢复标题。现有 HTML 标题仍是可配置的产品后缀。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* synchronous cross-package require edges (e.g. locale → runtime/client) that
|
||||
* fiber inject waiting cannot protect — a bundle's factory must be
|
||||
* registered before any dependent entry materializes. Per-row prefetch
|
||||
* failures still resolve silently (the create-side import refetches and
|
||||
* failures still resolve silently (the create-side import reloads and
|
||||
* owns the loud failure), so the barrier never turns one bad bundle into a
|
||||
* boot-wide fail-fast.
|
||||
*
|
||||
@@ -47,8 +47,8 @@ import { getStaticModules } from './seed.ts'
|
||||
import { STATE_LABELS, createLoaderStatusStore, createSignal } from './loader-status.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport seams the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleSystemOptions, 'fetchBundle' | 'executeBundle'>
|
||||
/** Module transport seam the shell passes through (jsdom tests replace the <script> path). */
|
||||
export type BootSeams = Pick<ClientModuleSystemOptions, 'loadBundle'>
|
||||
|
||||
/**
|
||||
* The modules package's own graph row id. The kernel adopts that entry
|
||||
@@ -152,7 +152,7 @@ export class AppWebEntry {
|
||||
await Promise.all(this.manifest.plugins
|
||||
.filter(row => row.immediately)
|
||||
.map(row => this.modules.prefetch(row.id).catch(() => {
|
||||
// Import refetches and reports this loudly per entry; swallowing
|
||||
// Import reloads and reports this loudly per entry; swallowing
|
||||
// here keeps one failing prefetch from masking the others.
|
||||
})))
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export class AppWebEntry {
|
||||
const rows = [MODULES_ID, ...this.manifest.plugins.map(row => row.id).filter(id => id !== MODULES_ID), APP_SHELL_ID]
|
||||
// Entry creation order carries no semantics (fiber inject waiting owns
|
||||
// activation order); creating concurrently lets non-prefetched bundle
|
||||
// fetches parallelize. The app-shell assembly entry is appended by the
|
||||
// loads parallelize. The app-shell assembly entry is appended by the
|
||||
// kernel: it is shell-own code (host graph rows are all plugin bundles),
|
||||
// and mounting the assembly is not a composition decision — it rides the
|
||||
// same entry lifecycle so the sweep and status cover it uniformly.
|
||||
|
||||
Reference in New Issue
Block a user