index.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
  3. * server plus the `webServer` service (HTTP and upgrade route registries,
  4. * index transform taps, and the single fallback seat for everything no route
  5. * claims). Knows no harness concepts and serves no files; the composing
  6. * application's frontend plugin owns dist serving through the fallback hook.
  7. * Web shape only — Electron loads dist over file:// and carries fetch over an
  8. * IPC bridge. This package never prints: the URL line belongs to the shell.
  9. */
  10. import { createServer } from 'node:http'
  11. import type { IncomingMessage, ServerResponse, Server } from 'node:http'
  12. import type { AddressInfo } from 'node:net'
  13. import type { Duplex } from 'node:stream'
  14. import { Context, Service } from '@deepseek-ai/cordis'
  15. import z from '@deepseek-ai/schemastery'
  16. declare module '@deepseek-ai/cordis' {
  17. interface Context {
  18. webServer: WebServer
  19. }
  20. }
  21. /** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
  22. export type WebRouteKind = 'exact' | 'prefix'
  23. /** One named route registration. */
  24. export interface WebRoute {
  25. kind: WebRouteKind
  26. /** Absolute pathname, no trailing slash. */
  27. path: string
  28. /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */
  29. handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
  30. }
  31. /** One exact-path HTTP upgrade registration. */
  32. export interface WebUpgradeRoute {
  33. /** Absolute pathname, no trailing slash. */
  34. path: string
  35. /** Owns protocol negotiation and the upgraded socket after dispatch. */
  36. handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
  37. }
  38. /** Gateway config: the listen address. */
  39. export interface Config {
  40. /** Listen host; the two supported values are loopback and all-interfaces. */
  41. host: '127.0.0.1' | '0.0.0.0'
  42. /** Listen port; zero requests an OS-assigned port. */
  43. port: number
  44. }
  45. /**
  46. * The browser HTTP carrier service. Activation listens immediately. Route
  47. * registration order does not affect requests because configured named routes
  48. * must be distinct, and the fallback handler answers anything not yet claimed
  49. * during startup with 404 until its owner registers. A listen failure rejects
  50. * initialization, and the boot process reports the failed fiber.
  51. */
  52. export class WebServer extends Service {
  53. static Config: z<Config> = z.object({
  54. host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
  55. port: z.natural().max(65535).required(),
  56. })
  57. private readonly exact = new Map<string, WebRoute>()
  58. private readonly prefixes = new Map<string, WebRoute>()
  59. private readonly upgrades = new Map<string, WebUpgradeRoute>()
  60. private readonly upgradedSockets = new Set<Duplex>()
  61. private readonly indexTaps: ((html: string) => string)[] = []
  62. private fallback: WebRoute['handler'] | undefined
  63. private server!: Server
  64. private listenedPort!: number
  65. constructor(ctx: Context, private config: Config) {
  66. super(ctx, 'webServer')
  67. }
  68. /** The listening port (the OS-assigned value when config.port is 0). */
  69. get port(): number {
  70. return this.listenedPort
  71. }
  72. /** The configured bind host (the loopback or all-interfaces literal). */
  73. get host(): Config['host'] {
  74. return this.config.host
  75. }
  76. /**
  77. * Register a named route. Duplicate (kind, path) throws — route patterns are
  78. * a composition-level contract, so a collision is a misconfiguration.
  79. * @param route - kind, path, and the owning handler.
  80. * @returns the disposer removing the route.
  81. */
  82. register(route: WebRoute): () => void {
  83. const table = route.kind === 'exact' ? this.exact : this.prefixes
  84. if (table.has(route.path)) {
  85. throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
  86. }
  87. table.set(route.path, route)
  88. return () => { table.delete(route.path) }
  89. }
  90. /**
  91. * Register an exact-path HTTP upgrade route. Duplicate paths throw because
  92. * one socket can have only one protocol owner.
  93. * @param route - pathname and handler owning negotiation plus socket use.
  94. * @returns the disposer removing the route.
  95. */
  96. registerUpgrade(route: WebUpgradeRoute): () => void {
  97. if (this.upgrades.has(route.path)) {
  98. throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
  99. }
  100. this.upgrades.set(route.path, route)
  101. return () => { this.upgrades.delete(route.path) }
  102. }
  103. /**
  104. * Claim the fallback seat: the handler answering every request no named
  105. * route matches (the SPA dist server in the shipped Web composition). One
  106. * owner only — a second registration throws, because two fallbacks cannot
  107. * compose.
  108. * @param handler - owns the full response lifecycle of unmatched requests.
  109. * @returns the disposer releasing the seat.
  110. */
  111. registerFallback(handler: WebRoute['handler']): () => void {
  112. if (this.fallback !== undefined) {
  113. throw new Error('webserver: fallback already registered')
  114. }
  115. this.fallback = handler
  116. return () => { this.fallback = undefined }
  117. }
  118. /**
  119. * Register an index.html transform, applied by the fallback owner to every
  120. * index response ({@link applyIndexTaps}) in registration order.
  121. * @param transform - pure html-to-html function.
  122. * @returns the disposer removing the transform.
  123. */
  124. tapIndex(transform: (html: string) => string): () => void {
  125. this.indexTaps.push(transform)
  126. return () => {
  127. const at = this.indexTaps.indexOf(transform)
  128. if (at !== -1) this.indexTaps.splice(at, 1)
  129. }
  130. }
  131. /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
  132. async [Service.init](): Promise<void> {
  133. const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
  134. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
  135. requests; the field is only optional on the client-side IncomingMessage type */
  136. const rawPath = new URL(req.url ?? '/', 'http://x').pathname
  137. const route = this.match(rawPath)
  138. if (route !== undefined) {
  139. await route.handler(req, res)
  140. return
  141. }
  142. const fallback = this.fallback
  143. if (fallback === undefined) {
  144. res.writeHead(404)
  145. res.end()
  146. return
  147. }
  148. await fallback(req, res)
  149. }
  150. // Last-resort guard: handle() rejecting would otherwise be an unhandled
  151. // rejection killing the process on one malformed request (bad %-escape,
  152. // client dropping mid-body). Per-request failures log and answer 400 —
  153. // never a process exit.
  154. this.server = createServer((req, res) => {
  155. handle(req, res).catch((err: unknown) => {
  156. this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
  157. if (res.headersSent) {
  158. res.destroy()
  159. return
  160. }
  161. res.writeHead(400)
  162. res.end()
  163. })
  164. })
  165. this.server.on('upgrade', (req, socket, head) => {
  166. const onError = (error: Error): void => {
  167. this.ctx.logger.warn(error)
  168. socket.destroy()
  169. }
  170. socket.on('error', onError)
  171. socket.once('close', () => {
  172. socket.off('error', onError)
  173. this.upgradedSockets.delete(socket)
  174. })
  175. let route: WebUpgradeRoute | undefined
  176. try {
  177. /* v8 ignore next -- node:http always sets url on server requests. */
  178. route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
  179. } catch (error) {
  180. this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
  181. socket.destroy()
  182. return
  183. }
  184. if (route === undefined) {
  185. socket.destroy()
  186. return
  187. }
  188. this.upgradedSockets.add(socket)
  189. try {
  190. Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {
  191. this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
  192. socket.destroy()
  193. })
  194. } catch (error) {
  195. this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
  196. socket.destroy()
  197. }
  198. })
  199. await new Promise<void>((resolve, reject) => {
  200. this.server.once('error', reject)
  201. this.server.listen(this.config.port, this.config.host, () => {
  202. this.server.off('error', reject)
  203. this.server.on('error', (err) => { this.ctx.logger.error(err) })
  204. this.listenedPort = (this.server.address() as AddressInfo).port
  205. resolve()
  206. })
  207. })
  208. // Node does not include upgraded sockets in closeAllConnections(). The service
  209. // owns them with the other connections, so it tracks and destroys them explicitly.
  210. this.ctx.effect(() => async () => {
  211. const serverClosed = new Promise<void>((resolve) => {
  212. this.server.close(() => { resolve() })
  213. })
  214. this.server.closeAllConnections()
  215. const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {
  216. socket.once('close', () => { resolve() })
  217. socket.destroy()
  218. }))
  219. await Promise.all([serverClosed, ...upgradedClosed])
  220. }, 'webServer.listen')
  221. }
  222. /** Longest-prefix-wins over the prefix table after an exact-table miss. */
  223. private match(pathname: string): WebRoute | undefined {
  224. const exact = this.exact.get(pathname)
  225. if (exact !== undefined) return exact
  226. let best: WebRoute | undefined
  227. for (const [prefix, route] of this.prefixes) {
  228. if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue
  229. if (best === undefined || prefix.length > best.path.length) best = route
  230. }
  231. return best
  232. }
  233. /**
  234. * Run an index.html body through the registered taps in registration order
  235. * — called by the fallback owner on every index response it renders.
  236. * @param html - the raw index.html body.
  237. * @returns the transformed body.
  238. */
  239. applyIndexTaps(html: string): string {
  240. let out = html
  241. for (const transform of this.indexTaps) out = transform(out)
  242. return out
  243. }
  244. }
  245. export default WebServer