feat(web): move connection downlinks to WebSocket
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
|
||||
* node:http server plus the `httpServer` service (named-route registry + index
|
||||
* transform taps + static dist fallback). Knows no harness concepts — every
|
||||
* feature surface (API bridge, plugin bundles, SSE) is a route some other
|
||||
* plugin registers. Web (browser) shape only — Electron loads dist over
|
||||
* file:// and carries fetch over an IPC bridge, not this server. This package
|
||||
* @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.
|
||||
*/
|
||||
|
||||
@@ -12,6 +11,7 @@ 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'
|
||||
@@ -35,6 +35,14 @@ export interface WebRoute {
|
||||
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: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
@@ -61,6 +69,8 @@ export class HttpServerService extends Service {
|
||||
|
||||
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 readonly distRoot: string
|
||||
private readonly distIndex: string
|
||||
@@ -98,6 +108,20 @@ export class HttpServerService extends Service {
|
||||
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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied to every index response in
|
||||
* registration order.
|
||||
@@ -147,6 +171,32 @@ export class HttpServerService extends Service {
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
this.server.on('upgrade', (req, socket, head) => {
|
||||
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)
|
||||
socket.once('close', () => { this.upgradedSockets.delete(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)
|
||||
@@ -158,11 +208,12 @@ export class HttpServerService extends Service {
|
||||
})
|
||||
})
|
||||
|
||||
// close + closeAllConnections: held-open responses (SSE) never end on
|
||||
// their own; without the force-close, close() would hang teardown.
|
||||
// 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(() => () => new Promise<void>((resolve) => {
|
||||
this.server.close(() => { resolve() })
|
||||
this.server.closeAllConnections()
|
||||
for (const socket of this.upgradedSockets) socket.destroy()
|
||||
}), 'httpServer.listen')
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const name = 'host-webserver-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relation: route registrations and their disposers must stay
|
||||
* Owned relation: HTTP and upgrade route registrations and their disposers must stay
|
||||
* symmetric — after the owning fiber of a registered route unloads, the
|
||||
* route table must no longer answer for its path (a stale route would keep
|
||||
* serving a disposed plugin's handler). Checked on every fiber teardown
|
||||
@@ -26,7 +26,10 @@ export const inject = ['invariants']
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
ctx.on('internal/plugin', () => {
|
||||
const server = ctx.get('httpServer') as
|
||||
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
|
||||
| {
|
||||
register(route: { kind: 'exact'; path: string; handler: () => void }): () => void
|
||||
registerUpgrade(route: { path: string; handler: () => void }): () => void
|
||||
}
|
||||
| undefined
|
||||
if (server === undefined) return // no webserver row in this composition
|
||||
// Register/dispose probe on a reserved path: if dispose leaves the route
|
||||
@@ -37,8 +40,11 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
try {
|
||||
server.register(probe)()
|
||||
server.register(probe)()
|
||||
const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} }
|
||||
server.registerUpgrade(upgradeProbe)()
|
||||
server.registerUpgrade(upgradeProbe)()
|
||||
} catch {
|
||||
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
|
||||
fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged')
|
||||
}
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user