212 lines
9.0 KiB
TypeScript
212 lines
9.0 KiB
TypeScript
/**
|
|
* @deepseek-ai/dsh-host-webserver — the web-shape HTTP carrier: node:http server
|
|
* routing /api/* to an injected fetch-shaped handler (node:http ↔ WHATWG
|
|
* bridge with SSE streamed out chunk by chunk) and everything else to static
|
|
* file serving. 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.
|
|
*/
|
|
|
|
import { createServer } from 'node:http'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { readFile } from 'node:fs/promises'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { dirname } from 'node:path'
|
|
import { serveStatic } from './static.ts'
|
|
import type { HostWebPluginRegistry } from './web-plugins.ts'
|
|
|
|
export { createHostWebPluginRegistry } from './web-plugins.ts'
|
|
export type {
|
|
HostWebPluginRegistry, LoaderEntryView, LoaderView, WebPluginBootEntry, WebPluginRegistryDeps,
|
|
} from './web-plugins.ts'
|
|
|
|
/** Options for startWebServer. */
|
|
export interface WebServerOptions {
|
|
/** Address or hostname to listen on. */
|
|
host: string
|
|
/** Port to listen on; zero requests an OS-assigned port. */
|
|
port: number
|
|
/**
|
|
* Absolute path of index.html inside the static root — the caller resolves
|
|
* it (dist location is workspace knowledge of the shell, not this package's).
|
|
*/
|
|
distIndex: string
|
|
/** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */
|
|
apiHandler: { fetch: typeof fetch }
|
|
/**
|
|
* Web plugin table. When present, every index.html response carries a
|
|
* `window.__DSH_BOOT__` manifest script and `/plugins/<id>/client.js` serves
|
|
* each plugin's client bundle. Absent = both surfaces off (carrier-only use).
|
|
*/
|
|
webPlugins?: Pick<HostWebPluginRegistry, 'snapshot' | 'clientPath'>
|
|
}
|
|
|
|
/** Listening web server handle. */
|
|
export interface RunningWebServer {
|
|
/** The listening port, including the OS-assigned value when options.port is zero. */
|
|
port: number
|
|
/**
|
|
* Shutdown: close + closeAllConnections (SSE connections never end on their
|
|
* own; without the force-close, close() would hang). Idempotent.
|
|
*/
|
|
close(): Promise<void>
|
|
}
|
|
|
|
/**
|
|
* Start the web-shape HTTP server on the caller-selected host and port.
|
|
* Routing: /api/* → apiHandler bridge; non-GET/HEAD → 405; everything else →
|
|
* static with the step1-locked semantics (403 traversal, SPA fallback 200).
|
|
* A listen failure (EADDRINUSE…) rejects — the shell decides how to exit; a
|
|
* server error after listen goes to onError. A request whose handling throws
|
|
* (malformed %-escapes, a client dropping mid-body) is answered 400 — or the
|
|
* socket destroyed when headers are already out — and reported to onError;
|
|
* it never becomes an unhandled rejection.
|
|
* @param options - port, static root anchor, and the API carrier.
|
|
* @param onError - sink for post-listen server errors and per-request handling failures.
|
|
* @returns the running server handle once listening.
|
|
*/
|
|
export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise<RunningWebServer> {
|
|
const { host, port, distIndex, apiHandler, webPlugins } = options
|
|
const distRoot = dirname(distIndex)
|
|
const renderIndex = webPlugins === undefined ? undefined : async (): Promise<string> => {
|
|
const html = await readFile(distIndex, 'utf8')
|
|
return injectBootManifest(html, webPlugins.snapshot())
|
|
}
|
|
|
|
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
/* 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
|
|
if (rawPath.startsWith('/api/')) {
|
|
await bridge(req, res, apiHandler)
|
|
return
|
|
}
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
res.writeHead(405)
|
|
res.end()
|
|
return
|
|
}
|
|
if (webPlugins !== undefined && rawPath.startsWith('/plugins/') && rawPath.endsWith('/client.js')) {
|
|
await servePluginBundle(decodeURIComponent(rawPath), res, webPlugins)
|
|
return
|
|
}
|
|
await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
|
|
}
|
|
// Last-resort guard: handle() rejecting would otherwise be an unhandled
|
|
// rejection, and one malformed request (a bad %-escape hitting
|
|
// decodeURIComponent, a client dropping mid-body) would kill the whole
|
|
// process. Nothing after this catch can throw again on the same response.
|
|
const server = createServer((req, res) => {
|
|
handle(req, res).catch((err: unknown) => {
|
|
onError(err instanceof Error ? err : new Error(String(err)))
|
|
if (res.headersSent) {
|
|
res.destroy()
|
|
return
|
|
}
|
|
res.writeHead(400)
|
|
res.end()
|
|
})
|
|
})
|
|
|
|
let closing: Promise<void> | undefined
|
|
const close = (): Promise<void> => (closing ??= new Promise((resolveClose) => {
|
|
server.close(() => { resolveClose() })
|
|
server.closeAllConnections()
|
|
}))
|
|
|
|
return new Promise((resolveListen, rejectListen) => {
|
|
server.once('error', rejectListen)
|
|
server.listen(port, host, () => {
|
|
server.off('error', rejectListen)
|
|
server.on('error', onError)
|
|
resolveListen({ port: (server.address() as AddressInfo).port, close })
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Inject the boot manifest into index.html: `window.__DSH_BOOT__` as the first
|
|
* script in <head> (before the shell bundle reads it). `<` is escaped in the
|
|
* JSON so plugin-controlled strings cannot break out of the script element.
|
|
* @param html - the index.html source.
|
|
* @param plugins - the manifest rows from the registry snapshot.
|
|
* @returns the html with the manifest script injected.
|
|
*/
|
|
export function injectBootManifest(html: string, plugins: readonly unknown[]): string {
|
|
const json = JSON.stringify({ plugins }).replaceAll('<', '\\u003c')
|
|
const script = `<script>window.__DSH_BOOT__ = ${json}</script>`
|
|
const head = html.indexOf('<head>')
|
|
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
|
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
|
|
return `${script}${html}`
|
|
}
|
|
|
|
/** Serve one plugin client bundle from the registry table (unknown id = 404; the id may contain a scope slash). */
|
|
async function servePluginBundle(
|
|
pathname: string, res: ServerResponse, webPlugins: Pick<HostWebPluginRegistry, 'clientPath'>,
|
|
): Promise<void> {
|
|
const id = pathname.slice('/plugins/'.length, -'/client.js'.length)
|
|
const path = webPlugins.clientPath(id)
|
|
if (path === undefined) {
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
try {
|
|
const body = await readFile(path)
|
|
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
|
|
res.end(body)
|
|
} catch {
|
|
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
|
res.writeHead(404)
|
|
res.end()
|
|
}
|
|
}
|
|
|
|
/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */
|
|
async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
|
const abort = new AbortController()
|
|
// Client-disconnect detection MUST hang off the response, not the request:
|
|
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
|
// fully consumed (immediately for a bodyless GET), which would abort every SSE
|
|
// stream right after open. ServerResponse 'close' fires on connection teardown;
|
|
// writableEnded distinguishes a normal end() from the client going away.
|
|
res.on('close', () => {
|
|
if (!res.writableEnded) abort.abort()
|
|
})
|
|
const chunks: Buffer[] = []
|
|
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
|
|
requests; the fields are only optional on the client-side IncomingMessage type */
|
|
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
|
|
method: req.method ?? 'GET',
|
|
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
|
|
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
|
|
signal: abort.signal,
|
|
})
|
|
const response = await apiHandler.fetch(request)
|
|
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
|
|
if (response.body === null) {
|
|
res.end()
|
|
return
|
|
}
|
|
for await (const chunk of response.body) {
|
|
// Backpressure: a false return means the socket buffer is full — wait for drain
|
|
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
|
|
// resolves so a mid-wait disconnect can't park this loop forever; the close
|
|
// handler above aborts the handler stream, which then ends the iteration.
|
|
if (!res.write(chunk)) {
|
|
await new Promise<void>((resolve) => {
|
|
const done = (): void => {
|
|
res.off('drain', done)
|
|
res.off('close', done)
|
|
resolve()
|
|
}
|
|
res.once('drain', done)
|
|
res.once('close', done)
|
|
})
|
|
}
|
|
}
|
|
res.end()
|
|
}
|