fix(web): include the HMR receiver in the initial client graph

This commit is contained in:
Turtle
2026-08-10 19:58:40 +08:00
parent 37ee7b0f24
commit a4d8c0da9b
19 changed files with 125 additions and 48 deletions

View File

@@ -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/bundle/web-app/README.md
README.md: fb6a1a3ee5293c7e90afae11a76fe5a8598f3ee8
README.zh.md: d8276514d94e658788371034795a073abefbf6ac
README.md: 47b582225e768ac035d12947939c7a7eb700458c
README.zh.md: 61e134f90e7ae57cb6220e92880c001f0d06bae2

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona插入 Web 宿主行webserver、API 网关、workspace、投影缓存、存储与浏览器插件名录并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL``DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host``--port``--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode``lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona插入 Web 宿主行webserver、API 网关、workspace、投影缓存、存储与浏览器插件名录并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL``DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host``--port``--dev` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode``lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
## 模型体验

View File

@@ -116,8 +116,8 @@
lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? []
# The client-plugin reload chain: a dev-only row this bundle ships off,
# which the runtime row turns on for `--dev`. It is a row rather than a
# child of web-runtime because its node half is a client-side package,
# which the runtime row turns on before client discovery. It is a row rather
# than a child of web-runtime because its node half is a client-side package,
# which a host-side bundle cannot import.
- id: client-hmr
name: '@deepseek-ai/dsh-client-hmr'
@@ -126,12 +126,14 @@
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
# Dual-face: node half scans this very tree for dsh.client rows, composes
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
# module table the shell kernel constructs before cordis exists (adopted
# as a plugin entry by the kernel, never fetched).
# Dual-face: this waits for the runtime row to decide whether HMR belongs
# in the first graph. The node half then scans this tree, composes
# window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
# is the module table the shell kernel constructs before cordis exists
# (adopted as a plugin entry by the kernel, never fetched).
- id: modules
name: '@deepseek-ai/dsh-client-modules'
inject: [webClientRoster]
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.

View File

@@ -25,11 +25,10 @@ import type {} from '@deepseek-ai/dsh-bash-env'
/** Stable Cordis plugin name. */
export const name = 'web-app'
/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */
const HMR_ROW_ID = 'client-hmr'
/** This dsh installation's root, from either this package's source or built entry. */
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const HMR_ROW_ID = 'client-hmr'
const CLIENT_ROSTER_SERVICE = 'webClientRoster'
/** Services required before the web runtime can mount. */
export const inject = ['httpServer']
@@ -118,14 +117,16 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
* variables, and the URL line.
* @param ctx - plugin context carrying the httpServer service.
* @param config - validated {@link Config}.
* @returns nothing once optional development rows are active and runtime contributions are registered.
* @returns nothing once the invocation's client roster and runtime contributions are registered.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
// The client-plugin reload chain is a row this bundle ships off, because it
// exists only in development. Turning it on belongs here rather than in the
// startup row: it needs host services that also activate after webStartup.
// Client discovery must start after the optional HMR row has a pending
// fiber. Otherwise its first browser graph omits the reload receiver, which
// cannot use that receiver to discover itself later.
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
// Release client discovery only after the optional row has a pending fiber.
ctx.provide(CLIENT_ROSTER_SERVICE, true)
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
if (config.surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, SOURCE_ROOT)

View File

@@ -49,6 +49,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } {
return { server, seat: () => fallback }
}
/** Install the optional HMR row the runtime sequences before client discovery. */
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
const updates: string[] = []
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
enableRuntime: async () => { updates.push('client-hmr') },
}],
await: settle,
} as never)
return updates
}
interface BashContribution {
name: string
variables: Record<string, { description: string }>
@@ -68,14 +81,7 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
const hmrUpdates: unknown[] = []
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
update: async (options: unknown) => { hmrUpdates.push(options) },
}],
await: async () => {},
} as never)
const enabledRows = provideHmrRow(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
await ctx.plugin(SystemPrompt, { persona: '' })
@@ -83,7 +89,8 @@ describe('web-app runtime glue', () => {
await new Promise(resolve => setTimeout(resolve, 0))
expect(seat()).toBeDefined() // frontend-static claimed the fallback
expect(hmrUpdates).toEqual([{ disabled: false }])
expect(enabledRows).toEqual(['client-hmr'])
expect(ctx.get('webClientRoster')).toBe(true)
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
@@ -148,7 +155,7 @@ describe('web-app runtime glue', () => {
// this row itself has activated.
const ready = new Context()
ready.provide('httpServer', fakeHttpServer().server)
ready.provide('loader', { await: () => Promise.resolve() } as never)
provideHmrRow(ready)
let announce: () => void
ready.provide('appReady', new Promise<void>((resolve) => { announce = resolve }))
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -182,7 +189,7 @@ describe('web-app runtime glue', () => {
settled.provide('httpServer', fakeHttpServer().server)
let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
settled.provide('loader', { await: () => settlement } as never)
provideHmrRow(settled, () => settlement)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
@@ -202,7 +209,7 @@ describe('web-app runtime glue', () => {
await child
let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
torn.provide('loader', { await: () => tornSettlement } as never)
provideHmrRow(torn, () => tornSettlement)
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await child.dispose() // the httpServer service goes away
releaseTorn!()