diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f85c64d77c..99337fc16f 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.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: 02dadf6e1dc1f2c4fd99907446bc6d07b35ba471 -2026-07-23-client-plugin-loading-model.zh.md: eaf10d32a6b51189867d2a52f76dc190380cbca0 +2026-07-23-client-plugin-loading-model.md: 07c3f2a1f2cb60a6e60e33c81dcf1c9060d7aebb +2026-07-23-client-plugin-loading-model.zh.md: 5d8c553f0d7eda3c05fdaecf1ac2ee6a515b8304 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 02dadf6e1d..07c3f2a1f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -28,7 +28,7 @@ The first-generation client loader (`createClientLoader`) hand-wrote both layers The [client shell layering note](2026-08-15-client-shells-and-dynamic-packages.md) defines the current static and dynamic package sets and the import rules between them. The loading machinery treats every `dsh.client` package as a host-graph row with one ordinary `lib/client.js` factory bundle. Its declaration carries Cordis `inject` edges, synchronous module-table `external` requests, and the optional `immediately` prefetch mark; the composing app owns only the mounted roster. -The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its ordinary factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Runtime arrives through the same pending queue; static React, Cordis, and UI library identities come from the shell seed. +The web kernel remains framework-free and imports no dynamic package value. Modules is itself a dynamic row, but the host parser delivers its factory before the Vite main module. The HTML-installed `__ModuleLoader__` facade uses that factory to construct the module system when the kernel calls `create()`. Every other dynamic row arrives through the application batch; static React, Cordis, and UI library identities come from the shell seed. ### One module system, one plugin governor @@ -38,13 +38,13 @@ The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientM The vendored Loader consumes the module system through its `internal` contract — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`. -### External-script arrival and source maps +### Batched external-script arrival and source maps -Each graph row's `url` goes to a same-origin external classic `') + const applicationAt = html.indexOf( + ``, + ) + const bootstrapAt = html.indexOf(``) const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') - expect(html).not.toContain('') - expect([facadeAt, modulesAt, graphAt, entryAt]).toEqual([...new Set([ - facadeAt, modulesAt, graphAt, entryAt, + expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([ + facadeAt, applicationAt, bootstrapAt, graphAt, entryAt, ])].sort((a, b) => a - b)) target.load({ id: MODULES_ID, factory: () => modulesClient }) - target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) }) - const system = target.create({ boot: graph, staticModules: {} }) + const system = target.create({ + boot: graph, + staticModules: {}, + loadBundle: async (url) => { + expect(url).toBe(APPLICATION_URL) + target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) }) + }, + }) expect(target.mode).toBe('live') expect(target.pendingQueue).toEqual([]) @@ -209,40 +258,161 @@ describe('client bundle activation', () => { expect(String(thrown)).not.toContain('pnpm run build') }) + it('rejects a malformed built source map during composition', () => { + 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`, '{"version":3,"sources":[null]}\n') + expect(() => construct([packageName])) + .toThrow(`${clientPath}.map is not a regular Source Map v3 object`) + }) + + it('retains one prior immutable batch generation across rebuild recomposition', async () => { + const packageName = '@fixture/batch-rebuild-race' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(clientPath, 'module.exports = { generation: 1 }\n') + const { service, route } = constructWithRoute([packageName]) + const first = service.graph().batches[0]!.url + + writeFileSync(clientPath, 'module.exports = { generation: 2 }\n') + service.rebuilt(packageName) + const second = service.graph().batches[0]!.url + expect(second).not.toBe(first) + expect((await routeRequest(route, first)).status).toBe(200) + expect((await routeRequest(route, second)).status).toBe(200) + + writeFileSync(clientPath, 'module.exports = { generation: 3 }\n') + service.rebuilt(packageName) + const third = service.graph().batches[0]!.url + expect((await routeRequest(route, first)).status).toBe(404) + expect((await routeRequest(route, second)).status).toBe(200) + expect((await routeRequest(route, third)).status).toBe(200) + }) + + it('frames bundle and map fields before hashing an immutable revision', () => { + const packageName = '@fixture/framed-artifact-hash' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["src.ts"]}\n' + writeFileSync(clientPath, 'module.exports = {} ') + writeFileSync(`${clientPath}.map`, map) + const first = construct([packageName]).graph().entries[0]!.rev + + writeFileSync(clientPath, 'module.exports = {}') + writeFileSync(`${clientPath}.map`, ` ${map}`) + const second = construct([packageName]).graph().entries[0]!.rev + expect(second).not.toBe(first) + }) + 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, 'module.exports = {}\n//# sourceMappingURL=client.js.map') + const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n' writeFileSync(`${clientPath}.map`, map) - const { route } = constructWithRoute([packageName]) - let status = 0 - let headers: Record | undefined - let body = '' - const response = { - writeHead(nextStatus: number, nextHeaders?: Record) { - 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({ + const { service, route } = constructWithRoute([packageName]) + const row = service.graph().entries[0]! + const individualScript = await routeRequest(route, row.url) + expect(individualScript.body.toString('utf8')).toContain(`sourceMappingURL=client.js.map?rev=${row.rev}`) + const individual = await routeRequest(route, row.url.replace('/client.js?', '/client.js.map?')) + expect(individual.status).toBe(200) + expect(individual.headers).toEqual({ 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-cache', + 'cache-control': 'public, max-age=31536000, immutable', }) - expect(body).toBe(map) + expect(individual.body.toString('utf8')).toBe(map) + + const batch = service.graph().batches[0]! + expect(batch).toMatchObject({ phase: 'application', entries: [packageName] }) + const batchScript = await routeRequest(route, batch.url) + expect(batchScript.status).toBe(200) + expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable') + expect(batchScript.body.toString('utf8')).toContain('//# sourceMappingURL=client.js.map') + expect(batchScript.body.toString('utf8')).not.toContain('sourceMappingURL=client.js.map?rev=') + expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0) + expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405) + const batchMap = await routeRequest(route, `${batch.url}.map`) + const parsedBatchMap = JSON.parse(batchMap.body.toString('utf8')) as unknown + const parsedIndividualMap = JSON.parse(map) as Record + expect(parsedBatchMap).toMatchObject({ + version: 3, + file: 'client.js', + sections: [{ + offset: { line: 0, column: 0 }, + map: { + ...parsedIndividualMap, + sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'], + }, + }], + }) + expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404) + + writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n') + expect(construct([packageName]).graph().entries[0]?.rev).not.toBe(row.rev) + }) + + it('applies sourceRoot before relocating absolute-looking section sources', async () => { + const packageName = '@fixture/source-root' + const clientPath = writePackage(packageName) + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(clientPath, 'module.exports = {}\n') + writeFileSync(`${clientPath}.map`, JSON.stringify({ + version: 3, + names: [], + mappings: 'AAAA', + sourceRoot: '../root', + sources: ['/absolute.ts'], + })) + const { service, route } = constructWithRoute([packageName]) + const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`) + const map = JSON.parse(response.body.toString('utf8')) as { + sections: { map: { sourceRoot?: string; sources: string[] } }[] + } + expect(map.sections[0]?.map).toMatchObject({ + sources: ['/plugins/@fixture/root/absolute.ts'], + }) + expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot') + }) + + it('maps a non-zero second batch section through a standard source-map consumer', async () => { + const firstName = '@fixture/offset-first' + const secondName = '@fixture/offset-second' + const firstPath = writePackage(firstName) + const secondPath = writePackage(secondName) + for (const [path, source] of [ + [firstPath, '../../../packages/demo/first.ts'], + [secondPath, '../../../packages/demo/second.ts'], + ] as const) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, 'window.first = true\nwindow.second = true\n') + writeFileSync(`${path}.map`, JSON.stringify({ + version: 3, + names: [], + mappings: 'AAAA', + sources: [source], + sourcesContent: ['export {}\n'], + })) + } + const { service, route } = constructWithRoute([firstName, secondName]) + const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`) + const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters[0] + const sections = (payload as unknown as { + sections: { offset: { line: number; column: number } }[] + }).sections + expect(sections.map(section => section.offset)).toEqual([ + { line: 0, column: 0 }, + { line: 3, column: 0 }, + ]) + const consumer = new SourceMap(payload) + expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' }) + expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' }) }) }) diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index e63b485800..855acd9bc7 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/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/web/README.md -README.md: 3208cb202dd9c101f1ab5f3936aac50fae35ab1c -README.zh.md: c6be7daf8a86660627095063590b063b80e14839 +README.md: c95c5601b6e61d434e585bbf1887135fe177efb6 +README.zh.md: 5335760011f2e801503011d49240e08a7638981e diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 3208cb202d..c95c5601b6 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting parser-preloaded registrations. This package then prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, parser preloads, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format. +Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage calls the Host-installed `window.__ModuleLoader__.create()` with `window.__DSH_BOOT__`, the shell's static modules, and any test transport override; the facade returns the constructed module system and parsed manifest after adopting the parser-loaded bootstrap batch. This package then prefetches the `immediately` tier, whose shared application-batch URL executes once. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry uniformly, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The Host owns the graph, batch preload, and facade; AppWebEntry does not know the bootstrap package id or parse the wire format. The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading. Fiber updates retain one spinner node and grow its CSS arc as entries first become active; hydration preserves that node and its animation phase until the application commit. React mounting, slot rendering, and application assembly live in [`ui-renderer`](../ui-renderer/README.md); [`ui-layout`](../ui-layout/README.md) owns the assembled browser-title projection. The modules bundle caches its own materialized exports and provides the closed-over system when its ordinary graph entry activates; Cordis service waiting makes graph-row creation order independent from that activation. diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index c6be7daf8a..5335760011 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 预载的 registration 后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、parser preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。 +Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段调用 Host 安装的 `window.__ModuleLoader__.create()`,传入 `window.__DSH_BOOT__`、外壳静态模块以及可选测试传输覆盖;facade 接纳 parser 已加载的 bootstrap 批次后返回构造好的模块系统与已解析 manifest。本包随后预取 `immediately` 层级,其共享的 application 批次 URL 只执行一次。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,统一创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。Graph、批次 preload 与 facade 归 Host 所有;AppWebEntry 不感知 bootstrap package id,也不解析 wire 格式。 启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致。fiber 更新会保留同一个 spinner 节点,并在 entry 首次进入 active 时增长其 CSS 圆弧;hydrate 会继续保留该节点及其动画相位,直到应用提交。React 挂载、slot 渲染和应用组装位于 [`ui-renderer`](../ui-renderer/README.zh.md);[`ui-layout`](../ui-layout/README.zh.md) 拥有组装后的浏览器标题投影。Modules bundle 会缓存自身已物化导出,并在其普通图 entry 激活时提供闭包中的系统;Cordis service 等待使图 row 创建顺序不依赖该激活时点。 diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts index def708d2c5..8d75949b78 100644 --- a/packages/client/web/tests/boot.client.spec.ts +++ b/packages/client/web/tests/boot.client.spec.ts @@ -80,7 +80,11 @@ describe('bootstrap failure rendering', () => { await expectBootFailure(() => { installFacade() const duplicate = { id: 'duplicate', url: '/duplicate/client.js', rev: '1' } - win.__DSH_BOOT__ = { rev: 'graph', entries: [duplicate, duplicate] } + win.__DSH_BOOT__ = { + rev: 'graph', + entries: [duplicate, duplicate], + batches: [{ phase: 'application', url: '/batch.js', rev: 'batch', entries: ['duplicate'] }], + } }, 'duplicate graph entry "duplicate"') }) }) @@ -159,7 +163,16 @@ describe('plugin activation', () => { { id: MODULES_ID, url: '/modules.js', rev: '1' }, { id: 'renderer', url: '/renderer.js', rev: '1' }, ] - win.__DSH_BOOT__ = { rev: 'graph', entries } + win.__DSH_BOOT__ = { + rev: 'graph', + entries, + batches: [{ + phase: 'application', + url: '/application.js', + rev: 'batch', + entries: entries.map(row => row.id), + }], + } const registrations = new Map([ ['/consumer.js', { id: 'consumer', @@ -188,9 +201,8 @@ describe('plugin activation', () => { ]) const entry = new AppWebEntry(container, { loadBundle: async (url) => { - const registration = registrations.get(url) - if (registration === undefined) throw new Error(`missing fixture registration ${url}`) - target.load(registration) + if (url !== '/application.js') throw new Error(`missing fixture batch ${url}`) + for (const registration of registrations.values()) target.load(registration) }, }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 320c44b9aa..65f359f42a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5443,13 +5443,21 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'VerifiedWebhookDelivery', declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}', }, + { + name: 'WebBootBatch', + declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}', + }, + { + name: 'WebBootBatchPhase', + declaration: 'export type WebBootBatchPhase = \'bootstrap\' | \'application\';', + }, { name: 'WebBootEntry', declaration: 'export interface WebBootEntry {\n id: string;\n url: string;\n rev: string;\n inject?: string[];\n immediately?: boolean;\n external?: string[];\n}', }, { name: 'WebBootGraph', - declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n}', + declaration: 'export interface WebBootGraph {\n rev: string;\n entries: WebBootEntry[];\n batches: WebBootBatch[];\n}', }, { name: 'WebFetchBody', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3bedaa9804..a2d0152493 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1761,6 +1761,16 @@ "symbol": "WebBootEntry", "source": "packages/client/modules/src/client/manifest.ts" }, + { + "doc": "docs/subsystems/client-modules.md", + "symbol": "WebBootBatchPhase", + "source": "packages/client/modules/src/client/manifest.ts" + }, + { + "doc": "docs/subsystems/client-modules.md", + "symbol": "WebBootBatch", + "source": "packages/client/modules/src/client/manifest.ts" + }, { "doc": "docs/subsystems/client-modules.md", "symbol": "WebBootGraph",