/** * @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 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 type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' import { Context, Service } from 'cordis' import z from 'schemastery' declare module 'cordis' { interface Context { httpServer: HttpServerService } } /** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/. */ export type WebRouteKind = 'exact' | 'prefix' /** One named route registration. */ export interface WebRoute { kind: WebRouteKind /** Absolute pathname, no trailing slash. */ path: string /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise } /** One exact-path HTTP upgrade registration. */ export interface WebUpgradeRoute { /** Absolute pathname, no trailing slash. */ path: string /** Owns protocol negotiation and the upgraded socket after dispatch. */ handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise } /** 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 } /** * 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. */ 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(), }) private readonly exact = new Map() private readonly prefixes = new Map() private readonly upgrades = new Map() private readonly upgradedSockets = new Set() private readonly indexTaps: ((html: string) => string)[] = [] private fallback: WebRoute['handler'] | undefined private server!: Server private listenedPort!: number constructor(ctx: Context, private config: Config) { super(ctx, 'httpServer') } /** The listening port (the OS-assigned value when config.port is 0). */ get port(): number { return this.listenedPort } /** The configured bind host (the loopback or all-interfaces literal). */ get host(): Config['host'] { return this.config.host } /** * Register a named route. Duplicate (kind, path) throws — route patterns are * a composition-level contract, so a collision is a misconfiguration. * @param route - kind, path, and the owning handler. * @returns the disposer removing the route. */ register(route: WebRoute): () => void { const table = route.kind === 'exact' ? this.exact : this.prefixes if (table.has(route.path)) { throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`) } table.set(route.path, route) return () => { table.delete(route.path) } } /** * Register an exact-path HTTP upgrade route. Duplicate paths throw because * one socket can have only one protocol owner. * @param route - pathname and handler owning negotiation plus socket use. * @returns the disposer removing the route. */ registerUpgrade(route: WebUpgradeRoute): () => void { if (this.upgrades.has(route.path)) { throw new Error(`webserver: duplicate upgrade route "${route.path}"`) } this.upgrades.set(route.path, route) return () => { this.upgrades.delete(route.path) } } /** * 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. */ tapIndex(transform: (html: string) => string): () => void { this.indexTaps.push(transform) return () => { const at = this.indexTaps.indexOf(transform) if (at !== -1) this.indexTaps.splice(at, 1) } } /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */ async [Service.init](): Promise { const handle = async (req: IncomingMessage, res: ServerResponse): Promise => { /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests; the field is only optional on the client-side IncomingMessage type */ const rawPath = new URL(req.url ?? '/', 'http://x').pathname const route = this.match(rawPath) if (route !== undefined) { await route.handler(req, res) return } const fallback = this.fallback if (fallback === undefined) { res.writeHead(404) res.end() return } await fallback(req, res) } // Last-resort guard: handle() rejecting would otherwise be an unhandled // rejection killing the process on one malformed request (bad %-escape, // client dropping mid-body). Per-request failures log and answer 400 — // never a process exit. this.server = createServer((req, res) => { handle(req, res).catch((err: unknown) => { this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err))) if (res.headersSent) { res.destroy() return } res.writeHead(400) res.end() }) }) this.server.on('upgrade', (req, socket, head) => { const onError = (error: Error): void => { this.ctx.logger.warn(error) socket.destroy() } socket.on('error', onError) socket.once('close', () => { socket.off('error', onError) this.upgradedSockets.delete(socket) }) let route: WebUpgradeRoute | undefined try { /* v8 ignore next -- node:http always sets url on server requests. */ route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname) } catch (error) { this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) socket.destroy() return } if (route === undefined) { socket.destroy() return } this.upgradedSockets.add(socket) try { Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => { this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) socket.destroy() }) } catch (error) { this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) socket.destroy() } }) await new Promise((resolve, reject) => { this.server.once('error', reject) this.server.listen(this.config.port, this.config.host, () => { this.server.off('error', reject) this.server.on('error', (err) => { this.ctx.logger.error(err) }) this.listenedPort = (this.server.address() as AddressInfo).port resolve() }) }) // Node does not include upgraded sockets in closeAllConnections(), so the // service tracks and destroys them as part of the same ownership boundary. this.ctx.effect(() => async () => { const serverClosed = new Promise((resolve) => { this.server.close(() => { resolve() }) }) this.server.closeAllConnections() const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise((resolve) => { socket.once('close', () => { resolve() }) socket.destroy() })) await Promise.all([serverClosed, ...upgradedClosed]) }, 'httpServer.listen') } /** Longest-prefix-wins over the prefix table after an exact-table miss. */ private match(pathname: string): WebRoute | undefined { const exact = this.exact.get(pathname) if (exact !== undefined) return exact let best: WebRoute | undefined for (const [prefix, route] of this.prefixes) { if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue if (best === undefined || prefix.length > best.path.length) best = route } return best } /** * 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 } } export default HttpServerService