mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
267 lines
9.8 KiB
TypeScript
267 lines
9.8 KiB
TypeScript
/**
|
|
* @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 hook.
|
|
* 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 '@deepseek-ai/cordis'
|
|
import z from '@deepseek-ai/schemastery'
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
httpServer: HttpServerService
|
|
}
|
|
}
|
|
|
|
/** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
|
|
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<void>
|
|
}
|
|
|
|
/** 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<void>
|
|
}
|
|
|
|
/** 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 browser HTTP carrier service. Activation listens immediately. Route
|
|
* registration order does not affect requests because configured named routes
|
|
* must be distinct, and the fallback handler answers anything not yet claimed
|
|
* during startup with 404 until its owner registers. A listen failure rejects
|
|
* initialization, and the boot process reports the failed fiber.
|
|
*/
|
|
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(),
|
|
})
|
|
|
|
private readonly exact = new Map<string, WebRoute>()
|
|
private readonly prefixes = new Map<string, WebRoute>()
|
|
private readonly upgrades = new Map<string, WebUpgradeRoute>()
|
|
private readonly upgradedSockets = new Set<Duplex>()
|
|
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<void> {
|
|
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
|
|
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<void>((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(). The service
|
|
// owns them with the other connections, so it tracks and destroys them explicitly.
|
|
this.ctx.effect(() => async () => {
|
|
const serverClosed = new Promise<void>((resolve) => {
|
|
this.server.close(() => { resolve() })
|
|
})
|
|
this.server.closeAllConnections()
|
|
const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((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
|