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
+43 -30
View File
@@ -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
}
}
-60
View File
@@ -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()
}
}