index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. /**
  2. * @deepseek-ai/dsh-host-webserver — node:http route registration with optional
  3. * gzip, index injection, and one fallback seat. It knows no harness concepts
  4. * and serves no files; the composing application owns dist serving. Electron
  5. * uses file:// plus IPC instead, and this package never prints the URL.
  6. * Route handlers retain direct response ownership.
  7. */
  8. import { createServer } from 'node:http'
  9. import type { IncomingMessage, ServerResponse, Server } from 'node:http'
  10. import type { AddressInfo } from 'node:net'
  11. import type { Duplex } from 'node:stream'
  12. import { Context, Service } from '@deepseek-ai/cordis'
  13. import z from '@deepseek-ai/schemastery'
  14. import compressionMiddleware from 'compression'
  15. import Negotiator from 'negotiator'
  16. import { renderIndexInjections, type IndexInjection } from './injections.ts'
  17. export { renderIndexInjections } from './injections.ts'
  18. export type { IndexInjection, IndexInjectionPlacement } from './injections.ts'
  19. declare module '@deepseek-ai/cordis' {
  20. interface Context {
  21. webServer: WebServer
  22. }
  23. interface Events {
  24. /**
  25. * Collect the structured index injection table. Emitted on every index
  26. * render and every worker boot-payload request; listeners push their
  27. * current rows, so a row's data is read fresh at emit time.
  28. * @param table - Mutable row table; listeners append in activation order.
  29. * @mode emit
  30. */
  31. 'webserver/index-inject'(table: IndexInjection[]): void
  32. }
  33. }
  34. /** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
  35. export type WebRouteKind = 'exact' | 'prefix'
  36. /** One named route registration. */
  37. export interface WebRoute {
  38. kind: WebRouteKind
  39. /** Absolute pathname, no trailing slash. */
  40. path: string
  41. /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */
  42. handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
  43. }
  44. /** One exact-path HTTP upgrade registration. */
  45. export interface WebUpgradeRoute {
  46. /** Absolute pathname, no trailing slash. */
  47. path: string
  48. /** Owns protocol negotiation and the upgraded socket after dispatch. */
  49. handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
  50. }
  51. /** Web server listen and response-compression config. */
  52. export interface Config {
  53. /** Listen host; the two supported values are loopback and all-interfaces. */
  54. host: '127.0.0.1' | '0.0.0.0'
  55. /** Listen port; zero requests an OS-assigned port. */
  56. port: number
  57. /** Response compression for socket-backed HTTP requests. @default 'none' */
  58. compression?: 'none' | 'gzip'
  59. /** Gzip DEFLATE level from 0 through 9. @default 1 */
  60. compressionLevel?: number
  61. /** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
  62. compressionThresholdBytes?: number
  63. }
  64. const DEFAULT_COMPRESSION = 'none' as const
  65. const DEFAULT_COMPRESSION_LEVEL = 1
  66. const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024
  67. interface ResolvedConfig extends Config {
  68. compression: 'none' | 'gzip'
  69. compressionLevel: number
  70. compressionThresholdBytes: number
  71. }
  72. type NodeMiddleware = (
  73. req: IncomingMessage,
  74. res: ServerResponse,
  75. next: () => void,
  76. ) => void
  77. function createGzipMiddleware(config: ResolvedConfig): NodeMiddleware {
  78. // `compression` is typed for Express, but its runtime uses only the
  79. // node:http request and response members supplied here.
  80. const middleware = compressionMiddleware({
  81. level: config.compressionLevel,
  82. threshold: config.compressionThresholdBytes,
  83. filter(request, response) {
  84. if (response.getHeader('content-range') !== undefined) return false
  85. const contentType = response.getHeader('content-type')
  86. if (typeof contentType === 'string' && contentType.toLowerCase().startsWith('text/event-stream')) return false
  87. return compressionMiddleware.filter(request, response)
  88. },
  89. }) as unknown as NodeMiddleware
  90. return (req, res, next) => {
  91. // The Web Worker tunnel has no socket and transfers identity bytes.
  92. if ((res as { socket?: unknown }).socket === undefined) {
  93. next()
  94. return
  95. }
  96. const encoding = new Negotiator(req).encoding(['gzip', 'identity'])
  97. const gzipRequest = Object.create(req) as IncomingMessage
  98. Object.defineProperty(gzipRequest, 'headers', {
  99. value: { ...req.headers, 'accept-encoding': encoding === 'gzip' ? 'gzip' : 'identity' },
  100. })
  101. middleware(gzipRequest, res, next)
  102. }
  103. }
  104. /**
  105. * The browser HTTP carrier service. Activation listens immediately. Route
  106. * registration order does not affect requests because configured named routes
  107. * must be distinct, and the fallback handler answers anything not yet claimed
  108. * during startup with 404 until its owner registers. A listen failure rejects
  109. * initialization, and the boot process reports the failed fiber.
  110. */
  111. export class WebServer extends Service {
  112. static Config: z<Config> = z.object({
  113. host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
  114. port: z.natural().max(65535).required(),
  115. compression: z.union([z.const('none'), z.const('gzip')]).default(DEFAULT_COMPRESSION),
  116. compressionLevel: z.number().step(1).min(0).max(9).default(DEFAULT_COMPRESSION_LEVEL),
  117. compressionThresholdBytes: z.natural().default(DEFAULT_COMPRESSION_THRESHOLD_BYTES),
  118. })
  119. private readonly exact = new Map<string, WebRoute>()
  120. private readonly prefixes = new Map<string, WebRoute>()
  121. private readonly upgrades = new Map<string, WebUpgradeRoute>()
  122. private readonly upgradedSockets = new Set<Duplex>()
  123. private readonly indexTaps: ((html: string) => string)[] = []
  124. private fallback: WebRoute['handler'] | undefined
  125. private server!: Server
  126. private listenedPort!: number
  127. private readonly gzip: NodeMiddleware | undefined
  128. constructor(ctx: Context, private config: Config) {
  129. super(ctx, 'webServer')
  130. const resolved = config as ResolvedConfig
  131. this.gzip = resolved.compression === 'gzip' ? createGzipMiddleware(resolved) : undefined
  132. }
  133. /** The listening port (the OS-assigned value when config.port is 0). */
  134. get port(): number {
  135. return this.listenedPort
  136. }
  137. /** The configured bind host (the loopback or all-interfaces literal). */
  138. get host(): Config['host'] {
  139. return this.config.host
  140. }
  141. /**
  142. * Register a named route. Duplicate (kind, path) throws — route patterns are
  143. * a composition-level contract, so a collision is a misconfiguration.
  144. * @param route - kind, path, and the owning handler.
  145. * @returns the disposer removing the route.
  146. */
  147. register(route: WebRoute): () => void {
  148. const table = route.kind === 'exact' ? this.exact : this.prefixes
  149. if (table.has(route.path)) {
  150. throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
  151. }
  152. table.set(route.path, route)
  153. return () => { table.delete(route.path) }
  154. }
  155. /**
  156. * Register an exact-path HTTP upgrade route. Duplicate paths throw because
  157. * one socket can have only one protocol owner.
  158. * @param route - pathname and handler owning negotiation plus socket use.
  159. * @returns the disposer removing the route.
  160. */
  161. registerUpgrade(route: WebUpgradeRoute): () => void {
  162. if (this.upgrades.has(route.path)) {
  163. throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
  164. }
  165. this.upgrades.set(route.path, route)
  166. return () => { this.upgrades.delete(route.path) }
  167. }
  168. /**
  169. * Claim the fallback seat: the handler answering every request no named
  170. * route matches (the SPA dist server in the shipped Web composition). One
  171. * owner only — a second registration throws, because two fallbacks cannot
  172. * compose.
  173. * @param handler - owns the full response lifecycle of unmatched requests.
  174. * @returns the disposer releasing the seat.
  175. */
  176. registerFallback(handler: WebRoute['handler']): () => void {
  177. if (this.fallback !== undefined) {
  178. throw new Error('webserver: fallback already registered')
  179. }
  180. this.fallback = handler
  181. return () => { this.fallback = undefined }
  182. }
  183. /**
  184. * Register a raw-HTML index transform, the escape hatch for markup no
  185. * {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
  186. * registration order after rendering the structured rows.
  187. * @param transform - pure html-to-html function.
  188. * @returns the disposer removing the transform.
  189. */
  190. tapIndex(transform: (html: string) => string): () => void {
  191. this.indexTaps.push(transform)
  192. return () => {
  193. const at = this.indexTaps.indexOf(transform)
  194. if (at !== -1) this.indexTaps.splice(at, 1)
  195. }
  196. }
  197. /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
  198. async [Service.init](): Promise<void> {
  199. const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
  200. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
  201. requests; the field is only optional on the client-side IncomingMessage type */
  202. const rawPath = new URL(req.url ?? '/', 'http://x').pathname
  203. const route = this.match(rawPath)
  204. if (route !== undefined) {
  205. await route.handler(req, res)
  206. return
  207. }
  208. const fallback = this.fallback
  209. if (fallback === undefined) {
  210. res.writeHead(404)
  211. res.end()
  212. return
  213. }
  214. await fallback(req, res)
  215. }
  216. // Last-resort guard: handle() rejecting would otherwise be an unhandled
  217. // rejection killing the process on one malformed request (bad %-escape,
  218. // client dropping mid-body). Per-request failures log and answer 400 —
  219. // never a process exit.
  220. this.server = createServer((req, res) => {
  221. const next = (): void => {
  222. void handle(req, res).catch((err: unknown) => {
  223. this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
  224. if (res.headersSent) {
  225. res.destroy()
  226. return
  227. }
  228. res.writeHead(400)
  229. res.end()
  230. })
  231. }
  232. if (this.gzip === undefined) next()
  233. else this.gzip(req, res, next)
  234. })
  235. this.server.on('upgrade', (req, socket, head) => {
  236. const onError = (error: Error): void => {
  237. this.ctx.logger.warn(error)
  238. socket.destroy()
  239. }
  240. socket.on('error', onError)
  241. socket.once('close', () => {
  242. socket.off('error', onError)
  243. this.upgradedSockets.delete(socket)
  244. })
  245. let route: WebUpgradeRoute | undefined
  246. try {
  247. /* v8 ignore next -- node:http always sets url on server requests. */
  248. route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
  249. } catch (error) {
  250. this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
  251. socket.destroy()
  252. return
  253. }
  254. if (route === undefined) {
  255. socket.destroy()
  256. return
  257. }
  258. this.upgradedSockets.add(socket)
  259. try {
  260. Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {
  261. this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
  262. socket.destroy()
  263. })
  264. } catch (error) {
  265. this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
  266. socket.destroy()
  267. }
  268. })
  269. await new Promise<void>((resolve, reject) => {
  270. this.server.once('error', reject)
  271. this.server.listen(this.config.port, this.config.host, () => {
  272. this.server.off('error', reject)
  273. this.server.on('error', (err) => { this.ctx.logger.error(err) })
  274. this.listenedPort = (this.server.address() as AddressInfo).port
  275. resolve()
  276. })
  277. })
  278. // Node does not include upgraded sockets in closeAllConnections(). The service
  279. // owns them with the other connections, so it tracks and destroys them explicitly.
  280. this.ctx.effect(() => async () => {
  281. const serverClosed = new Promise<void>((resolve) => {
  282. this.server.close(() => { resolve() })
  283. })
  284. this.server.closeAllConnections()
  285. const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {
  286. socket.once('close', () => { resolve() })
  287. socket.destroy()
  288. }))
  289. await Promise.all([serverClosed, ...upgradedClosed])
  290. }, 'webServer.listen')
  291. }
  292. /** Longest-prefix-wins over the prefix table after an exact-table miss. */
  293. private match(pathname: string): WebRoute | undefined {
  294. const exact = this.exact.get(pathname)
  295. if (exact !== undefined) return exact
  296. let best: WebRoute | undefined
  297. for (const [prefix, route] of this.prefixes) {
  298. if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue
  299. if (best === undefined || prefix.length > best.path.length) best = route
  300. }
  301. return best
  302. }
  303. /**
  304. * Run an index.html body through the registered taps in registration order
  305. * — called by the fallback owner on every index response it renders.
  306. * @param html - the raw index.html body.
  307. * @returns the transformed body.
  308. */
  309. applyIndexTaps(html: string): string {
  310. let out = html
  311. for (const transform of this.indexTaps) out = transform(out)
  312. return out
  313. }
  314. /**
  315. * Gather the structured injection table: one `webserver/index-inject` emit,
  316. * every subscriber pushes its current rows. Fresh per call, so subscribers
  317. * read live state (module graph, theme preference) at emit time.
  318. * @returns rows in subscriber activation order.
  319. */
  320. collectIndexInjections(): IndexInjection[] {
  321. const table: IndexInjection[] = []
  322. this.ctx.emit('webserver/index-inject', table)
  323. return table
  324. }
  325. /**
  326. * Render one index.html body: the structured injection table first, then
  327. * the raw `tapIndex` transforms over the result.
  328. * @param html - the raw index.html body.
  329. * @returns the transformed body.
  330. */
  331. renderIndex(html: string): string {
  332. return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections()))
  333. }
  334. }
  335. export default WebServer