diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 36446f39c3..d0eb349c5a 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -759,7 +759,7 @@ Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/ind
## `ctx.httpServer` — `HttpServerService`
-The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the static dist fallback answers anything not yet claimed during the boot window). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports.
+The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports.
```ts cordis-catalog
/**
@@ -779,15 +779,33 @@ register(route: WebRoute): () => void
registerUpgrade(route: WebUpgradeRoute): () => void
/**
- * Register an index.html transform, applied to every index response in
- * registration order.
+ * Claim the fallback seat: the handler answering every request no named
+ * route matches (the SPA dist server in the shipped Web composition). One
+ * owner only — a second registration throws, because two fallbacks cannot
+ * compose.
+ * @param handler - owns the full response lifecycle of unmatched requests.
+ * @returns the disposer releasing the seat.
+ */
+registerFallback(handler: WebRoute['handler']): () => void
+
+/**
+ * Register an index.html transform, applied by the fallback owner to every
+ * index response ({@link applyIndexTaps}) in registration order.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
tapIndex(transform: (html: string) => string): () => void
+
+/**
+ * Run an index.html body through the registered taps in registration order
+ * — called by the fallback owner on every index response it renders.
+ * @param html - the raw index.html body.
+ * @returns the transformed body.
+ */
+applyIndexTaps(html: string): string
```
-Source: [`packages/host/webserver/src/index.ts:63`](../../packages/host/webserver/src/index.ts)
+Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts)
## `ctx.invariants` — `InvariantService`
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index cd53931df3..f0f54474fa 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -69,7 +69,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
-| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` |
+| `internal/plugin` | - | [`frontend-static`](../packages/host/frontend-static), `hmr`, `loader`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `locale/change` | `locale` (`emit`) | `locale` |
| `models/changed` | `runtime` (`emit`) | `ui-models` |
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index a5903d8be0..8dec2abf7c 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -392,9 +392,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'registerUpgrade(route: WebUpgradeRoute): () => void',
jsDoc: '/**\n * Register an exact-path HTTP upgrade route. Duplicate paths throw because\n * one socket can have only one protocol owner.\n * @param route - pathname and handler owning negotiation plus socket use.\n * @returns the disposer removing the route.\n */',
},
+ {
+ signature: 'registerFallback(handler: WebRoute[\'handler\']): () => void',
+ jsDoc: '/**\n * Claim the fallback seat: the handler answering every request no named\n * route matches (the SPA dist server in the shipped Web composition). One\n * owner only — a second registration throws, because two fallbacks cannot\n * compose.\n * @param handler - owns the full response lifecycle of unmatched requests.\n * @returns the disposer releasing the seat.\n */',
+ },
{
signature: 'tapIndex(transform: (html: string) => string): () => void',
- jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
+ jsDoc: '/**\n * Register an index.html transform, applied by the fallback owner to every\n * index response ({@link applyIndexTaps}) in registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
+ },
+ {
+ signature: 'applyIndexTaps(html: string): string',
+ jsDoc: '/**\n * Run an index.html body through the registered taps in registration order\n * — called by the fallback owner on every index response it renders.\n * @param html - the raw index.html body.\n * @returns the transformed body.\n */',
},
],
},
diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml
index 178db5dcef..1aaacd7ecb 100644
--- a/packages/host/README.i18n.yaml
+++ b/packages/host/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/host/README.md
-README.md: 7cd331f113eeec6c0a56f0ebc60554d9647aee75
-README.zh.md: 07b0e1569e17b9f0465a43f77fa2dbddcb1bae91
+README.md: 269a27f51c842f13bc11c175916b7be22db72bd2
+README.zh.md: 559bf785eb45d59a30f676b98c14143c69d57edd
diff --git a/packages/host/README.md b/packages/host/README.md
index 7cd331f113..269a27f51c 100644
--- a/packages/host/README.md
+++ b/packages/host/README.md
@@ -2,12 +2,13 @@
English | [中文](README.zh.md)
-The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
+The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| [`apiproxy/`](apiproxy/README.md) | Shared host API gateway and wire contract | `ctx.apiProxy` |
| [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.httpServer` |
+| [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.httpServer` |
| [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam | `ctx.directoryPicker` |
| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` |
diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md
index 07b0e1569e..559bf785eb 100644
--- a/packages/host/README.zh.md
+++ b/packages/host/README.zh.md
@@ -2,12 +2,13 @@
[English](README.md) | 中文
-dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),由它提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。
+dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/README.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
| [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议契约 | `ctx.apiProxy` |
| [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.httpServer` |
+| [`frontend-static/`](frontend-static/README.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.httpServer` |
| [`directory-picker/`](directory-picker/README.md) | workspace 目录选择 seam | `ctx.directoryPicker` |
| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` |
diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts
index 9d0b8c7de8..7922592d01 100644
--- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts
+++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts
@@ -7,7 +7,7 @@
* joining the backend's own teardown before the disposer settles.
*/
-import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -43,21 +43,15 @@ afterEach(async () => {
fakeBin = undefined
})
-/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
+/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
- const dist = join(root, 'dist')
- mkdirSync(dist)
- const distIndex = join(dist, 'index.html')
- await writeFile(distIndex, '
shell')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
` host: '${bindHost}'`,
' port: 0',
- ' portConflict: increment',
- ` distIndex: '${distIndex}'`,
`- name: '${AUTO}'`,
'',
].join('\n'))
diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml
new file mode 100644
index 0000000000..07d337775e
--- /dev/null
+++ b/packages/host/frontend-static/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/host/frontend-static/README.md
+README.md: c3a831abb1060b59e1802d38d5407a29d24e3bb3
+README.zh.md: d4dc71763280a3c88c73de50f63f2615570c7182
diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md
new file mode 100644
index 0000000000..c3a831abb1
--- /dev/null
+++ b/packages/host/frontend-static/README.md
@@ -0,0 +1,19 @@
+# `@deepseek-ai/dsh-frontend-static`
+
+English | [中文](README.zh.md)
+
+SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response runs through the webserver's registered index taps (`applyIndexTaps`), which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it.
+
+The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404.
+
+## Model Experience
+
+None, as the package serves browser assets; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md
new file mode 100644
index 0000000000..d4dc717632
--- /dev/null
+++ b/packages/host/frontend-static/README.zh.md
@@ -0,0 +1,19 @@
+# `@deepseek-ai/dsh-frontend-static`
+
+[English](README.md) | 中文
+
+Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名 route 时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换(`applyIndexTaps`),启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
+
+回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。
+
+## 模型体验
+
+无。该包只服务浏览器资产;其中没有任何内容会进入模型请求。
+
+#### KV Cache 影响
+
+无;该包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+- **初始 MIME 表很精简**:vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json
new file mode 100644
index 0000000000..ac690cee24
--- /dev/null
+++ b/packages/host/frontend-static/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@deepseek-ai/dsh-frontend-static",
+ "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-host-webserver": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@cordisjs/plugin-loader": "workspace:^",
+ "@deepseek-ai/dsh-host-webserver": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts
new file mode 100644
index 0000000000..4d5032c2d2
--- /dev/null
+++ b/packages/host/frontend-static/src/index.ts
@@ -0,0 +1,109 @@
+/**
+ * @deepseek-ai/dsh-frontend-static — SPA dist server over the webserver
+ * fallback seat: serves the built frontend directory with the semantics the
+ * Web shell locked at step1 — traversal outside the dist root is 403, any
+ * miss falls back to index.html with HTTP 200 (SPA routing), unknown
+ * extensions ship as octet-stream, non-GET/HEAD is 405. Every index response
+ * runs through the webserver's registered index taps (boot-manifest
+ * injection). The dist location is workspace knowledge of the composing
+ * application, so `distIndex` is typically supplied through a `!!js`
+ * expression, never hardcoded by a deployment.
+ * @module @deepseek-ai/dsh-frontend-static
+ */
+
+import type { ServerResponse } from 'node:http'
+import { readFile } from 'node:fs/promises'
+import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import type {} from '@deepseek-ai/dsh-host-webserver'
+
+/** Stable Cordis plugin name. */
+export const name = 'frontend-static'
+
+/** Service required before the fallback seat can be claimed. */
+export const inject = ['httpServer']
+
+/** Plugin config: the dist anchor. */
+export interface Config {
+ /** Absolute path of index.html inside the dist root. */
+ distIndex: string
+}
+
+export const Config: z = z.object({
+ distIndex: z.string().required(),
+})
+
+const MIME: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.json': 'application/json',
+ '.map': 'application/json',
+}
+
+/**
+ * Serve one GET/HEAD static request from the dist root.
+ * @param pathname - decoded URL pathname of the request.
+ * @param res - the node:http response to write.
+ * @param distRoot - absolute dist root directory (resolved by the caller).
+ * @param distIndex - absolute path of index.html inside distRoot.
+ * @param renderIndex - produces the index.html body (index-tap injection) for
+ * `/` and every SPA fallback.
+ */
+export async function serveStatic(
+ pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
+ renderIndex: () => Promise,
+): Promise {
+ const target = resolve(normalize(join(distRoot, pathname)))
+ // Traversal rejection: the target must be distRoot itself (`/`) or stay under
+ // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
+ // suffix would reject every legitimate subpath as traversal.
+ if (target !== distRoot && !target.startsWith(distRoot + sep)) {
+ res.writeHead(403)
+ res.end()
+ return
+ }
+ const serveIndex = async (): Promise => {
+ const body = await renderIndex()
+ res.writeHead(200, { 'content-type': MIME['.html'] })
+ res.end(body)
+ }
+ if (target === distRoot || target === distIndex) {
+ await serveIndex()
+ return
+ }
+ try {
+ const body = await readFile(target)
+ res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
+ res.end(body)
+ } catch {
+ // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
+ await serveIndex()
+ }
+}
+
+/**
+ * Claim the webserver fallback seat and serve the dist.
+ * @param ctx - plugin context carrying the httpServer service.
+ * @param config - validated {@link Config}.
+ */
+export function apply(ctx: Context, config: Config): void {
+ const distIndex = config.distIndex
+ const distRoot = dirname(distIndex)
+ const renderIndex = async (): Promise =>
+ ctx.httpServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
+ ctx.effect(() => ctx.httpServer.registerFallback(async (req, res) => {
+ // Non-GET/HEAD without a matching named route is 405 (fallback-only
+ // semantics: named routes own their method handling).
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ res.writeHead(405)
+ res.end()
+ return
+ }
+ /* v8 ignore next -- node:http always sets url on server requests */
+ const rawPath = new URL(req.url ?? '/', 'http://x').pathname
+ await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
+ }), 'frontend-static: fallback seat')
+}
diff --git a/packages/host/frontend-static/src/invariant.ts b/packages/host/frontend-static/src/invariant.ts
new file mode 100644
index 0000000000..8a58b309e2
--- /dev/null
+++ b/packages/host/frontend-static/src/invariant.ts
@@ -0,0 +1,53 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-frontend-static`.
+ * @module @deepseek-ai/dsh-frontend-static/invariant
+ */
+
+import type { Context } from 'cordis'
+// Empty type import carries the Loader's Fiber#entry merge read below.
+import type {} from '@cordisjs/plugin-loader'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static'
+
+/** Cordis companion plugin name. */
+export const name = 'frontend-static-invariant'
+/** Service required before the companion can register. */
+export const inject = ['invariants']
+
+/**
+ * Owned relation: the fallback seat and the owning fiber must stay symmetric —
+ * after the fiber holding the seat unloads, the seat must be claimable again
+ * (a stale fallback would keep serving a disposed plugin's dist). Checked on
+ * every fiber teardown by probing the registerFallback single-owner contract:
+ * when this package's plugin is not mounted, a claim+release cycle must
+ * succeed twice; residue from a leaked disposer makes the second claim throw.
+ */
+const install: InvariantInstaller = (ctx, fail) => {
+ ctx.on('internal/plugin', (fiber) => {
+ // Only audit teardowns of this package's own rows: while a live
+ // frontend-static row legitimately holds the seat, the probe would
+ // false-positive on the legitimate owner.
+ if (fiber.entry?.options.name !== PACKAGE_NAME) return
+ const server = ctx.get('httpServer') as
+ | { registerFallback(handler: () => void): () => void }
+ | undefined
+ if (server === undefined) return // torn down with the webserver itself
+ // The probe handlers are registered and immediately released, never invoked.
+ /* v8 ignore next 4 -- the arrow bodies are dead by design */
+ try {
+ server.registerFallback(() => {})()
+ server.registerFallback(() => {})()
+ } catch {
+ fail('frontend-static fallback disposer left the seat claimed — seat ownership and fiber lifecycle diverged')
+ }
+ }, { global: true })
+}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts
new file mode 100644
index 0000000000..5b3525235f
--- /dev/null
+++ b/packages/host/frontend-static/tests/frontend-static.spec.ts
@@ -0,0 +1,171 @@
+/**
+ * REAL-composition coverage: a test-only cordis.yml booted through the
+ * vendored Loader mounts the webserver and frontend-static rows, and every
+ * assertion observes the served HTTP surface — asset serving, MIME fallback,
+ * SPA index fallback with index taps, traversal rejection, 405 on non-GET/
+ * HEAD, and seat release on fiber disposal (HMR safety).
+ */
+
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include from '@cordisjs/plugin-include'
+import HttpServer from '@deepseek-ai/dsh-host-webserver'
+import InvariantService, { type InvariantError } from '@deepseek-ai/dsh-invariants'
+import * as FrontendStatic from '../src/index.ts'
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+ await context?.fiber.dispose()
+ context = undefined
+ if (root !== undefined) await rm(root, { recursive: true, force: true })
+ root = undefined
+})
+
+/** Write a dist fixture and a two-row cordis.yml, then boot it through the real Loader. */
+async function loadComposition(): Promise {
+ root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-'))
+ const dist = join(root, 'dist')
+ await mkdir(dist)
+ const distIndex = join(dist, 'index.html')
+ await writeFile(distIndex, 'shell')
+ await writeFile(join(dist, 'app.js'), 'export {}')
+ await writeFile(join(dist, 'blob.bin'), 'BLOB')
+ const configPath = join(root, 'cordis.yml')
+ await writeFile(configPath, [
+ "- name: '@deepseek-ai/dsh-host-webserver'",
+ ' config:',
+ " host: '127.0.0.1'",
+ ' port: 0',
+ '- id: frontend',
+ " name: '@deepseek-ai/dsh-frontend-static'",
+ ' config:',
+ ` distIndex: '${distIndex}'`,
+ '',
+ ].join('\n'))
+
+ context = new Context()
+ context.baseUrl = pathToFileURL(root).href + '/'
+ await context.plugin(Loader)
+ context.loader.builtins.include = Include
+ const modules = new Map([
+ ['@deepseek-ai/dsh-host-webserver', HttpServer],
+ ['@deepseek-ai/dsh-frontend-static', FrontendStatic],
+ ])
+ context.loader.internal = {
+ version: 'v2',
+ async import(specifier: string) {
+ if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+ return modules.get(specifier)
+ },
+ } as unknown as NonNullable
+ await context.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(configPath).href },
+ })
+ await context.loader.await()
+ return context
+}
+
+/** GET (by default) one path against the running server; returns status, content-type, and a body prefix. */
+async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; type: string | null; body: string }> {
+ const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
+ return {
+ status: response.status,
+ type: response.headers.get('content-type'),
+ body: (await response.text()).slice(0, 80),
+ }
+}
+
+describe('real Loader composition', () => {
+ it('serves the dist with SPA fallback, taps, traversal rejection, and method gating', { timeout: 60_000 }, async () => {
+ const loaded = await loadComposition()
+ const unloaded = [...loaded.loader.entries()]
+ .filter(entry => entry.fiber === undefined && !entry.disabled)
+ .map(entry => entry.options.name)
+ expect(unloaded).toEqual([])
+ const server = loaded.httpServer
+ const port = server.port
+
+ // Real asset with its MIME type; a live rebuild is served on the next read.
+ expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' })
+ await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
+ expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
+
+ // Unknown extension ships as octet-stream.
+ expect(await request(port, '/blob.bin')).toMatchObject({ status: 200, type: 'application/octet-stream', body: 'BLOB' })
+
+ // `/`, the index path, and any miss all render index.html (SPA routing)
+ // through the registered index taps.
+ const untap = server.tapIndex(html => html.replace('', ''))
+ for (const path of ['/', '/index.html', '/no/such/route']) {
+ const got = await request(port, path)
+ expect(got.status).toBe(200)
+ expect(got.body).toContain('__T__')
+ expect(got.body).toContain('shell')
+ }
+ untap()
+ expect((await request(port, '/')).body).not.toContain('__T__')
+
+ // Traversal outside the dist root is 403; non-GET/HEAD is 405.
+ expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
+ expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
+
+ // HMR safety: disposing the frontend row releases the fallback seat (the
+ // unclaimed webserver answers 404) and the seat is claimable again.
+ const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend')
+ expect(frontendEntry).toBeDefined()
+ await frontendEntry!.fiber?.dispose()
+ expect((await request(port, '/no/such/route')).status).toBe(404)
+ expect(() => server.registerFallback(() => {})).not.toThrow()
+ })
+})
+
+describe('invariant companion', () => {
+ const OWN_FIBER = { entry: { options: { name: '@deepseek-ai/dsh-frontend-static' } } }
+
+ // The vitest-wide invariant host (scripts/test-invariants.ts) mounts this
+ // package's companion automatically when the service is plugged.
+ async function setup(): Promise {
+ const ctx = new Context()
+ await ctx.plugin(InvariantService)
+ return ctx
+ }
+
+ it('passes on a clean seat release, skips foreign rows, and reports a leaked seat', async () => {
+ const ctx = await setup()
+ let fallback: unknown
+ ctx.provide('httpServer', {
+ registerFallback: (handler: unknown) => {
+ if (fallback !== undefined) throw new Error('webserver: fallback already registered')
+ fallback = handler
+ return () => { fallback = undefined }
+ },
+ } as never)
+
+ // A teardown of this package's own row with the seat released: no violation.
+ expect(() => { ctx.emit('internal/plugin', OWN_FIBER as never) }).not.toThrow()
+ // Foreign-row teardowns are not audited (a live legitimate owner would false-positive).
+ fallback = () => {}
+ expect(() => { ctx.emit('internal/plugin', { entry: { options: { name: 'other-package' } } } as never) }).not.toThrow()
+ // A leaked seat on our own teardown (disposer never ran): the probe cannot claim twice → violation.
+ expect(() => { ctx.emit('internal/plugin', OWN_FIBER as never) })
+ .toThrow(expect.objectContaining>({
+ code: 'INVARIANT',
+ packageName: '@deepseek-ai/dsh-frontend-static',
+ }))
+ await ctx.fiber.dispose()
+ })
+
+ it('skips the audit when the webserver went down with the row', async () => {
+ const ctx = await setup()
+ expect(() => { ctx.emit('internal/plugin', OWN_FIBER as never) }).not.toThrow()
+ await ctx.fiber.dispose()
+ })
+})
diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json
new file mode 100644
index 0000000000..bda9b5bb40
--- /dev/null
+++ b/packages/host/frontend-static/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/loader"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../webserver"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml
index 8b53e55af5..56fd0e7694 100644
--- a/packages/host/webserver/README.i18n.yaml
+++ b/packages/host/webserver/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/host/webserver/README.md
-README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4
-README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977
+README.md: b6dccf2f81c9e2f0b9f53264eafe724edb560f07
+README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a
diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md
index 196f350d87..b6dccf2f81 100644
--- a/packages/host/webserver/README.md
+++ b/packages/host/webserver/README.md
@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
-Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
+Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
-The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
+The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
-A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
+A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
@@ -21,5 +21,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
-- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md
index 0ae0470eab..dbfe420013 100644
--- a/packages/host/webserver/README.zh.md
+++ b/packages/host/webserver/README.zh.md
@@ -2,11 +2,11 @@
[English](README.md) | 中文
-Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
+Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
-该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
+该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
-监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
+监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
@@ -21,5 +21,4 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配
## 已知限制与延期工作
- **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。
-- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
- **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。
diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts
index 6b46b8704d..a536f9e1f5 100644
--- a/packages/host/webserver/src/index.ts
+++ b/packages/host/webserver/src/index.ts
@@ -1,21 +1,19 @@
/**
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
* server plus the `httpServer` service (HTTP and upgrade route registries,
- * index transform taps, and static dist fallback). Knows no harness concepts;
- * feature plugins own every registered protocol. Web shape only — Electron
- * loads dist over file:// and carries fetch over an IPC bridge. This package
- * never prints: the URL line belongs to the shell.
+ * index transform taps, and the single fallback seat for everything no route
+ * claims). Knows no harness concepts and serves no files; the composing
+ * application's frontend plugin owns dist serving through the fallback seam.
+ * Web shape only — Electron loads dist over file:// and carries fetch over an
+ * IPC bridge. This package never prints: the URL line belongs to the shell.
*/
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
-import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
-import { dirname } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
-import { serveStatic } from './static.ts'
declare module 'cordis' {
interface Context {
@@ -43,28 +41,26 @@ export interface WebUpgradeRoute {
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise
}
-/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
+/** Gateway config: the listen address. */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
host: '127.0.0.1' | '0.0.0.0'
/** Listen port; zero requests an OS-assigned port. */
port: number
- /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
- distIndex: string
}
/**
* The web-shape HTTP carrier service. Activation listens immediately (route
* registration order carries no request-facing semantics: named routes are
- * composed to be disjoint, and the static dist fallback answers anything not
- * yet claimed during the boot window). A listen failure throws out of init —
- * a FAILED fiber the boot's fail-loud sweep reports.
+ * composed to be disjoint, and the fallback seat answers anything not yet
+ * claimed during the boot window — 404 until its owner registers). A listen
+ * failure throws out of init — a FAILED fiber the boot's fail-loud sweep
+ * reports.
*/
export class HttpServerService extends Service {
static Config: z = z.object({
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
port: z.natural().max(65535).required(),
- distIndex: z.string().required(),
})
private readonly exact = new Map()
@@ -72,15 +68,12 @@ export class HttpServerService extends Service {
private readonly upgrades = new Map()
private readonly upgradedSockets = new Set()
private readonly indexTaps: ((html: string) => string)[] = []
- private readonly distRoot: string
- private readonly distIndex: string
+ private fallback: WebRoute['handler'] | undefined
private server!: Server
private listenedPort!: number
constructor(ctx: Context, private config: Config) {
super(ctx, 'httpServer')
- this.distIndex = config.distIndex
- this.distRoot = dirname(config.distIndex)
}
/** The listening port (the OS-assigned value when config.port is 0). */
@@ -123,8 +116,24 @@ export class HttpServerService extends Service {
}
/**
- * Register an index.html transform, applied to every index response in
- * registration order.
+ * Claim the fallback seat: the handler answering every request no named
+ * route matches (the SPA dist server in the shipped Web composition). One
+ * owner only — a second registration throws, because two fallbacks cannot
+ * compose.
+ * @param handler - owns the full response lifecycle of unmatched requests.
+ * @returns the disposer releasing the seat.
+ */
+ registerFallback(handler: WebRoute['handler']): () => void {
+ if (this.fallback !== undefined) {
+ throw new Error('webserver: fallback already registered')
+ }
+ this.fallback = handler
+ return () => { this.fallback = undefined }
+ }
+
+ /**
+ * Register an index.html transform, applied by the fallback owner to every
+ * index response ({@link applyIndexTaps}) in registration order.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
@@ -147,14 +156,13 @@ export class HttpServerService extends Service {
await route.handler(req, res)
return
}
- // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
- // traversal 403, miss falls back to index.html 200 (SPA routing).
- if (req.method !== 'GET' && req.method !== 'HEAD') {
- res.writeHead(405)
+ const fallback = this.fallback
+ if (fallback === undefined) {
+ res.writeHead(404)
res.end()
return
}
- await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
+ await fallback(req, res)
}
// Last-resort guard: handle() rejecting would otherwise be an unhandled
// rejection killing the process on one malformed request (bad %-escape,
@@ -243,11 +251,16 @@ export class HttpServerService extends Service {
return best
}
- /** Index body: dist index.html through the registered taps in order. */
- private async renderIndex(): Promise {
- let html = await readFile(this.distIndex, 'utf8')
- for (const transform of this.indexTaps) html = transform(html)
- return html
+ /**
+ * Run an index.html body through the registered taps in registration order
+ * — called by the fallback owner on every index response it renders.
+ * @param html - the raw index.html body.
+ * @returns the transformed body.
+ */
+ applyIndexTaps(html: string): string {
+ let out = html
+ for (const transform of this.indexTaps) out = transform(out)
+ return out
}
}
diff --git a/packages/host/webserver/src/static.ts b/packages/host/webserver/src/static.ts
deleted file mode 100644
index a672f4e5c2..0000000000
--- a/packages/host/webserver/src/static.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * Static file serving for the web shell: the starter MIME table and the
- * request handler with the semantics locked by the step1 acceptance list —
- * traversal outside the dist root is 403, any miss falls back to index.html
- * with HTTP 200 (SPA routing), unknown extensions ship as octet-stream.
- */
-
-import type { ServerResponse } from 'node:http'
-import { extname, join, normalize, resolve, sep } from 'node:path'
-import { readFile } from 'node:fs/promises'
-
-const MIME: Record = {
- '.html': 'text/html; charset=utf-8',
- '.js': 'text/javascript; charset=utf-8',
- '.css': 'text/css; charset=utf-8',
- '.svg': 'image/svg+xml',
- '.json': 'application/json',
- '.map': 'application/json',
-}
-
-/**
- * Serve one GET/HEAD static request from the dist root.
- * @param pathname - decoded URL pathname of the request.
- * @param res - the node:http response to write.
- * @param distRoot - absolute dist root directory (resolved by the caller).
- * @param distIndex - absolute path of index.html inside distRoot.
- * @param renderIndex - when set, produces the index.html body (boot-manifest
- * injection) for `/` and every SPA fallback; undefined serves the file verbatim.
- */
-export async function serveStatic(
- pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
- renderIndex?: () => Promise,
-): Promise {
- const target = resolve(normalize(join(distRoot, pathname)))
- // Traversal rejection: the target must be distRoot itself (`/`) or stay under
- // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
- // suffix would reject every legitimate subpath as traversal.
- if (target !== distRoot && !target.startsWith(distRoot + sep)) {
- res.writeHead(403)
- res.end()
- return
- }
- const serveIndex = async (): Promise => {
- const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex()
- res.writeHead(200, { 'content-type': MIME['.html'] })
- res.end(body)
- }
- if (target === distRoot || target === distIndex) {
- await serveIndex()
- return
- }
- try {
- const body = await readFile(target)
- res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
- res.end(body)
- } catch {
- // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
- await serveIndex()
- }
-}
diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts
index 19a252d53a..d91284c87b 100644
--- a/packages/host/webserver/tests/webserver.spec.ts
+++ b/packages/host/webserver/tests/webserver.spec.ts
@@ -2,11 +2,10 @@
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row, and every assertion observes the
* user-visible HTTP surface of the running server (routing precedence, index
- * taps, static-fallback semantics, per-request error containment, teardown).
+ * taps, fallback-seat semantics, per-request error containment, teardown).
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
-import { mkdir } from 'node:fs/promises'
import { once } from 'node:events'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
@@ -28,21 +27,15 @@ afterEach(async () => {
root = undefined
})
-/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */
+/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */
async function loadComposition(port = 0): Promise {
root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
- const dist = join(root, 'dist')
- await mkdir(dist)
- const distIndex = join(dist, 'index.html')
- await writeFile(distIndex, 'shell')
- await writeFile(join(dist, 'app.js'), 'export {}')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
" host: '127.0.0.1'",
` port: ${String(port)}`,
- ` distIndex: '${distIndex}'`,
'',
].join('\n'))
@@ -96,7 +89,7 @@ describe('real Loader composition', () => {
// Real-Loader composition resolves workspace packages through tsx at test
// time; first resolution after the host/client program split is slow enough
// to trip the default 5s budget on cold caches.
- it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => {
+ it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => {
const loaded = await loadComposition()
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
@@ -120,21 +113,24 @@ describe('real Loader composition', () => {
expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' })
expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' })
- // Index taps apply in registration order on `/` and on the SPA fallback;
- // the disposer removes the transform.
+ // Fallback seat: 404 while unclaimed; the owner answers everything no
+ // named route matches; index taps are the owner's to apply; the seat
+ // admits exactly one owner and the disposer releases it.
+ expect((await request(port, '/no/such/route')).status).toBe(404)
const untap = server.tapIndex(html => html.replace('', ''))
- expect((await request(port, '/')).body).toContain('__T__')
+ expect(server.applyIndexTaps('')).toContain('__T__')
+ const releaseFallback = server.registerFallback((req, res) => {
+ // Decode like a real static server would — a malformed %-escape throws
+ // here, probing the webserver's per-request error containment.
+ decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
+ res.writeHead(200, { 'content-type': 'text/html' })
+ res.end(server.applyIndexTaps('shell'))
+ })
+ expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/)
expect((await request(port, '/no/such/route')).body).toContain('__T__')
untap()
- expect((await request(port, '/')).body).not.toContain('__T__')
-
- // Static fallback semantics: real asset served, traversal 403, non-GET/
- // HEAD without a matching route 405.
- expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
- await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
- expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
- expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
- expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
+ expect((await request(port, '/no/such/route')).body).not.toContain('__T__')
+ expect((await request(port, '/no/such/route')).body).toContain('shell')
// Per-request error containment: a malformed %-escape answers 400 and the
// server keeps serving afterwards (no process-level failure path).
@@ -148,9 +144,14 @@ describe('real Loader composition', () => {
const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } })
expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' })
disposeOnce()
- expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
+ expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
+ // Releasing the seat restores the unclaimed 404 and registrability.
+ releaseFallback()
+ expect((await request(port, '/no/such/route')).status).toBe(404)
+ expect(() => server.registerFallback(() => {})).not.toThrow()
+
// Upgrade routes match exact pathnames, reject duplicate ownership, and
// become registrable again after disposal. The accepted socket stays open
// so the teardown assertion also covers upgraded-connection ownership.
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 041972cb9f..316a4233de 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -86,6 +86,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
+ 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' },
+ 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' },
+ 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 0847ad36ca..1d799f26a2 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -185,6 +185,9 @@
{ "path": "./packages/support/agent-loop-testkit" },
{ "path": "./packages/acp/acp" },
{ "path": "./packages/examples/acp-demo" },
+ { "path": "./packages/bundle/base" },
+ { "path": "./packages/bundle/headless" },
+ { "path": "./packages/bundle/web-app" },
{ "path": "./packages/ui/app-boot" },
{ "path": "./packages/ui/jsonrpc" },
{ "path": "./packages/examples/jsonrpc-demo" },
@@ -228,6 +231,7 @@
// client aggregate's webserver reference.
{ "path": "./packages/host/directory-picker-browse" },
{ "path": "./packages/host/directory-picker-native" },
+ { "path": "./packages/host/frontend-static" },
{ "path": "./packages/host/webserver" },
{ "path": "./packages/sdk/sdk-client" },
{ "path": "./packages/sdk/helper" },