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:
Turtle
2026-08-06 04:39:52 +08:00
parent d0cb6770a9
commit 2ee2ee2f96
23 changed files with 567 additions and 141 deletions

View 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

View 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.

View 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` 提供GETHEAD 之外的方法在没有匹配的具名 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`;实际发布新的资产类别时再扩展该表。

View 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"
}
}

View 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')
}

View 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))

View 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()
})
})

View 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"
}
]
}