Parcourir la source

fix(client-modules): tolerate incomplete source maps

lsdsjy il y a 2 mois
Parent
commit
445de0ab3e

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -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 .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: 07c3f2a1f2cb60a6e60e33c81dcf1c9060d7aebb
-2026-07-23-client-plugin-loading-model.zh.md: 5d8c553f0d7eda3c05fdaecf1ac2ee6a515b8304
+2026-07-23-client-plugin-loading-model.md: a16c022bc2d4f96bd2680a637e02a485ba4f2697
+2026-07-23-client-plugin-loading-model.zh.md: 96c6b85cd6d7bdb0cbfce4479d2cfe5b7e74f2a5

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -96,7 +96,7 @@ The current package inventory and build forms live in the [client shell layering
 
 One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
 
-Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
+Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, revision-stamped individual responses, current batches, and one previous batch generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition.
 
 Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -96,7 +96,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
 
 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
-接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。
+接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、带 revision 的独立响应、当前批次及上一代批次,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
 
 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。
 

+ 2 - 0
apps/web/tests/smoke-real.e2e.ts

@@ -265,6 +265,8 @@ describe('dsh web keyless CLI smoke', () => {
       const page = await newEnglishPage(browser)
       const pluginScripts: string[] = []
       const cacheHeaders = new Map<string, string | undefined>()
+      // Chromium reports `preload as=script` as Script and reuses that same
+      // request when the matching script node executes; this count pins both.
       page.on('request', (request) => {
         const url = new URL(request.url())
         if (request.resourceType() === 'script' && url.pathname.startsWith('/plugins/')) {

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -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: bd6d53de9d847b02fc7a8b1030efe4f9036e44ab
-README.zh.md: d78a2ddc836c1266e92c5ca95b4e54b78bdcbf98
+README.md: 67f6b6216dee90148f78d5b62edbe6009dffb8c9
+README.zh.md: d385b7c3854c9da1c4f22224bc5910ae7cfcdf2f

+ 1 - 0
packages/client/modules/README.md

@@ -26,3 +26,4 @@ None; this package neither assembles nor sends a provider request.
 
 - **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
 - **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.
+- **Snapshot delivery retains artifact bytes** — the Host holds each bundle, optional source map, revision-stamped individual response, and generated batch in memory; HMR additionally retains one prior batch generation. Memory scales as several copies of the composed client artifacts in exchange for immutable responses and one-generation race tolerance.

+ 1 - 0
packages/client/modules/README.zh.md

@@ -26,3 +26,4 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,
 
 - **有意采用扁平模块图**:每个 bundle 是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
 - **自身不维护卸载记录**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。
+- **快照式提供会常驻产物字节**:Host 会在内存中保留每个 bundle、可选 sourcemap、带 revision 的独立响应及生成的批次;HMR 还会保留上一代批次。内存会随组合出的客户端产物增长为数份副本,以换取 immutable 响应和一代竞态容忍。

+ 5 - 2
packages/client/modules/src/client/system.ts

@@ -26,12 +26,15 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
   document.head.append(el)
 })
 
-/** Replace the rev query while preserving same-origin relative URLs. */
+/** Replace the rev query while preserving absolute, protocol-relative, or path-relative form. */
 function atRevision(url: string, rev: string): string {
   const absolute = /^[A-Za-z][A-Za-z\d+.-]*:/.test(url)
+  const protocolRelative = url.startsWith('//')
   const parsed = new URL(url, 'http://dsh.invalid')
   parsed.searchParams.set('rev', rev)
-  return absolute ? parsed.href : `${parsed.pathname}${parsed.search}${parsed.hash}`
+  if (absolute) return parsed.href
+  if (protocolRelative) return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
+  return `${parsed.pathname}${parsed.search}${parsed.hash}`
 }
 
 /**

+ 14 - 12
packages/client/modules/src/index.ts

@@ -514,14 +514,7 @@ export class ClientModuleRegistry extends Service {
     const record = this.table.get(id)
     if (record === undefined) return undefined
     const bundle = readFileSync(record.meta.clientPath)
-    let sourceMap: WebPluginRecord['sourceMap']
-    try {
-      sourceMap = sourceMapSnapshot(record.meta.clientPath)
-    } catch (error) {
-      // A client rebuild remains reloadable when its development-only map is
-      // temporarily incomplete; this revision simply exposes no map.
-      this.ctx.logger.warn(error)
-    }
+    const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath)
     const rev = artifactRevision(bundle, sourceMap)
     if (rev === record.entry.rev) return rev
     record.entry = graphRow(id, rev, record.meta)
@@ -566,14 +559,13 @@ export class ClientModuleRegistry extends Service {
 
   private compose(): WebBootGraph {
     const entries = orderByModuleGraph([...this.table.values()].map(record => record.entry))
-    const records = new Map([...this.table.entries()])
     const bootstrap = PARSER_PRELOAD_IDS
-      .map(id => records.get(id))
+      .map(id => this.table.get(id))
       .filter((record): record is WebPluginRecord => record !== undefined)
     const bootstrapIds = new Set(bootstrap.map(record => record.entry.id))
     const application = entries
       .filter(entry => !bootstrapIds.has(entry.id))
-      .map(entry => records.get(entry.id))
+      .map(entry => this.table.get(entry.id))
       .filter((record): record is WebPluginRecord => record !== undefined)
     const artifacts: BatchArtifact[] = []
     if (bootstrap.length > 0) artifacts.push(buildBatch('bootstrap', bootstrap))
@@ -660,7 +652,7 @@ export class ClientModuleRegistry extends Service {
   } {
     try {
       const bundle = readFileSync(clientPath)
-      const sourceMap = sourceMapSnapshot(clientPath)
+      const sourceMap = this.readSourceMapSnapshot(clientPath)
       return { bundle, rev: artifactRevision(bundle, sourceMap), ...(sourceMap === undefined ? {} : { sourceMap }) }
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
@@ -668,6 +660,16 @@ export class ClientModuleRegistry extends Service {
     }
   }
 
+  /** Treat a missing, torn, or malformed development map as an unmapped artifact revision. */
+  private readSourceMapSnapshot(clientPath: string): WebPluginRecord['sourceMap'] {
+    try {
+      return sourceMapSnapshot(clientPath)
+    } catch (error) {
+      this.ctx.logger.warn(error)
+      return undefined
+    }
+  }
+
   /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
   private processOne(entryName: string): boolean {
     let qualifies = false

+ 10 - 0
packages/client/modules/tests/loader.client.spec.ts

@@ -451,6 +451,16 @@ describe('HMR reset', () => {
     expect(b.fetched.at(-1)).toBe('https://plugins.example.test/plugins/a/client.js?rev=next')
   })
 
+  it('preserves a protocol-relative individual endpoint when applying the rebuilt revision', async () => {
+    const b = bench([
+      row('a', { url: '//plugins.example.test/plugins/a/client.js?rev=0' }),
+    ], { a: () => ({}) })
+    await b.loader.import('a', '', {})
+    b.loader.invalidate('a', 'next')
+    await b.loader.prefetch('a')
+    expect(b.fetched.at(-1)).toBe('//plugins.example.test/plugins/a/client.js?rev=next')
+  })
+
   it('uses the current individual revision when a graph-row invalidation omits an override', async () => {
     const b = bench([row('a')], { a: () => ({}) })
     await b.loader.import('a', '', {})

+ 8 - 6
packages/client/modules/tests/node-half.client.spec.ts

@@ -258,18 +258,20 @@ describe('client bundle activation', () => {
     expect(String(thrown)).not.toContain('pnpm run build')
   })
 
-  it('rejects a malformed built source map during composition', () => {
+  it('omits a torn or malformed source map without blocking composition', async () => {
     const packageName = '@fixture/malformed-source-map'
     const clientPath = writePackage(packageName)
     mkdirSync(dirname(clientPath), { recursive: true })
     writeFileSync(clientPath, 'module.exports = {}\n')
-    writeFileSync(`${clientPath}.map`, '{}\n')
-    expect(() => construct([packageName]))
-      .toThrow(`${clientPath}.map is not a regular Source Map v3 object`)
+    writeFileSync(`${clientPath}.map`, '{')
+    const torn = constructWithRoute([packageName])
+    const tornRow = torn.service.graph().entries[0]!
+    expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
+      .not.toContain('sourceMappingURL')
+    expect((await routeRequest(torn.route, `${torn.service.graph().batches[0]!.url}.map`)).status).toBe(404)
 
     writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
-    expect(() => construct([packageName]))
-      .toThrow(`${clientPath}.map is not a regular Source Map v3 object`)
+    expect(() => construct([packageName])).not.toThrow()
   })
 
   it('retains one prior immutable batch generation across rebuild recomposition', async () => {