mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(webserver): extract SPA dist serving to the frontend-static fallback seat
The webserver's built-in static dist serving becomes a single-owner fallback seat (registerFallback/applyIndexTaps); the SPA server moves to the new @deepseek-ai/dsh-frontend-static plugin so the composing application owns its dist as composition, not carrier config. distIndex leaves the webserver schema; unclaimed fallback answers 404.
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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, '<head></head><body>shell</body>')
|
||||
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'))
|
||||
|
||||
6
packages/host/frontend-static/README.i18n.yaml
Normal file
6
packages/host/frontend-static/README.i18n.yaml
Normal file
@@ -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
|
||||
19
packages/host/frontend-static/README.md
Normal file
19
packages/host/frontend-static/README.md
Normal file
@@ -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.
|
||||
19
packages/host/frontend-static/README.zh.md
Normal file
19
packages/host/frontend-static/README.zh.md
Normal file
@@ -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`;实际发布新的资产类别时再扩展该表。
|
||||
41
packages/host/frontend-static/package.json
Normal file
41
packages/host/frontend-static/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
109
packages/host/frontend-static/src/index.ts
Normal file
109
packages/host/frontend-static/src/index.ts
Normal file
@@ -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<Config> = z.object({
|
||||
distIndex: z.string().required(),
|
||||
})
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.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<string>,
|
||||
): Promise<void> {
|
||||
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<void> => {
|
||||
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<string> =>
|
||||
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')
|
||||
}
|
||||
53
packages/host/frontend-static/src/invariant.ts
Normal file
53
packages/host/frontend-static/src/invariant.ts
Normal file
@@ -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))
|
||||
171
packages/host/frontend-static/tests/frontend-static.spec.ts
Normal file
171
packages/host/frontend-static/tests/frontend-static.spec.ts
Normal file
@@ -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<Context> {
|
||||
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, '<head></head><body>shell</body>')
|
||||
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<string, unknown>([
|
||||
['@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<typeof context.loader.internal>
|
||||
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('<head>', '<head><script>window.__T__=1</script>'))
|
||||
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<Context> {
|
||||
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<Partial<InvariantError>>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
27
packages/host/frontend-static/tsconfig.json
Normal file
27
packages/host/frontend-static/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 设置仍保持内部实现。
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/** 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<Config> = 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<string, WebRoute>()
|
||||
@@ -72,15 +68,12 @@ export class HttpServerService extends Service {
|
||||
private readonly upgrades = new Map<string, WebUpgradeRoute>()
|
||||
private readonly upgradedSockets = new Set<Duplex>()
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'.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<string>,
|
||||
): Promise<void> {
|
||||
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<void> => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Context> {
|
||||
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, '<head></head><body>shell</body>')
|
||||
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('<head>', '<head><script>window.__T__=1</script>'))
|
||||
expect((await request(port, '/')).body).toContain('__T__')
|
||||
expect(server.applyIndexTaps('<head></head>')).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('<head></head><body>shell</body>'))
|
||||
})
|
||||
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.
|
||||
|
||||
@@ -86,6 +86,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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.' },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user