index.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. /**
  2. * @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
  3. * node:http server plus the `httpServer` service (named-route registry + index
  4. * transform taps + static dist fallback). Knows no harness concepts — every
  5. * feature surface (API bridge, plugin bundles, SSE) is a route some other
  6. * plugin registers. Web (browser) shape only — Electron loads dist over
  7. * file:// and carries fetch over an IPC bridge, not this server. This package
  8. * 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 { readFile } from 'node:fs/promises'
  13. import type { AddressInfo } from 'node:net'
  14. import { dirname } from 'node:path'
  15. import { Context, Service } from 'cordis'
  16. import z from 'schemastery'
  17. import { serveStatic } from './static.ts'
  18. declare module 'cordis' {
  19. interface Context {
  20. httpServer: HttpServerService
  21. }
  22. }
  23. /** Route match kind: 'exact' matches the pathname verbatim; 'prefix' p matches p and p/<anything>. */
  24. export type WebRouteKind = 'exact' | 'prefix'
  25. /** One named route registration. */
  26. export interface WebRoute {
  27. kind: WebRouteKind
  28. /** Absolute pathname, no trailing slash. */
  29. path: string
  30. /** Owns the full response lifecycle (may hold the response open, e.g. SSE). */
  31. handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
  32. }
  33. /** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
  34. export interface Config {
  35. /** Listen host; the two supported values are loopback and all-interfaces. */
  36. host: '127.0.0.1' | '0.0.0.0'
  37. /** Listen port; zero requests an OS-assigned port. */
  38. port: number
  39. /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
  40. distIndex: string
  41. }
  42. /**
  43. * The web-shape HTTP carrier service. Activation listens immediately (route
  44. * registration order carries no request-facing semantics: named routes are
  45. * composed to be disjoint, and the static dist fallback answers anything not
  46. * yet claimed during the boot window). A listen failure throws out of init —
  47. * a FAILED fiber the boot's fail-loud sweep reports.
  48. */
  49. export class HttpServerService extends Service {
  50. static Config: z<Config> = z.object({
  51. host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
  52. port: z.natural().max(65535).required(),
  53. distIndex: z.string().required(),
  54. })
  55. private readonly exact = new Map<string, WebRoute>()
  56. private readonly prefixes = new Map<string, WebRoute>()
  57. private readonly indexTaps: ((html: string) => string)[] = []
  58. private readonly distRoot: string
  59. private readonly distIndex: string
  60. private server!: Server
  61. private listenedPort!: number
  62. constructor(ctx: Context, private config: Config) {
  63. super(ctx, 'httpServer')
  64. this.distIndex = config.distIndex
  65. this.distRoot = dirname(config.distIndex)
  66. }
  67. /** The listening port (the OS-assigned value when config.port is 0). */
  68. get port(): number {
  69. return this.listenedPort
  70. }
  71. /**
  72. * Register a named route. Duplicate (kind, path) throws — route patterns are
  73. * a composition-level contract, so a collision is a misconfiguration.
  74. * @param route - kind, path, and the owning handler.
  75. * @returns the disposer removing the route.
  76. */
  77. register(route: WebRoute): () => void {
  78. const table = route.kind === 'exact' ? this.exact : this.prefixes
  79. if (table.has(route.path)) {
  80. throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
  81. }
  82. table.set(route.path, route)
  83. return () => { table.delete(route.path) }
  84. }
  85. /**
  86. * Register an index.html transform, applied to every index response in
  87. * registration order.
  88. * @param transform - pure html-to-html function.
  89. * @returns the disposer removing the transform.
  90. */
  91. tapIndex(transform: (html: string) => string): () => void {
  92. this.indexTaps.push(transform)
  93. return () => {
  94. const at = this.indexTaps.indexOf(transform)
  95. if (at !== -1) this.indexTaps.splice(at, 1)
  96. }
  97. }
  98. /** Listen; resolves once the socket is bound (rejection = FAILED fiber). */
  99. async [Service.init](): Promise<void> {
  100. const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
  101. /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server
  102. requests; the field is only optional on the client-side IncomingMessage type */
  103. const rawPath = new URL(req.url ?? '/', 'http://x').pathname
  104. const route = this.match(rawPath)
  105. if (route !== undefined) {
  106. await route.handler(req, res)
  107. return
  108. }
  109. // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
  110. // traversal 403, miss falls back to index.html 200 (SPA routing).
  111. if (req.method !== 'GET' && req.method !== 'HEAD') {
  112. res.writeHead(405)
  113. res.end()
  114. return
  115. }
  116. await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
  117. }
  118. // Last-resort guard: handle() rejecting would otherwise be an unhandled
  119. // rejection killing the process on one malformed request (bad %-escape,
  120. // client dropping mid-body). Per-request failures log and answer 400 —
  121. // never a process exit.
  122. this.server = createServer((req, res) => {
  123. handle(req, res).catch((err: unknown) => {
  124. this.ctx.logger.warn(err instanceof Error ? err : new Error(String(err)))
  125. if (res.headersSent) {
  126. res.destroy()
  127. return
  128. }
  129. res.writeHead(400)
  130. res.end()
  131. })
  132. })
  133. await new Promise<void>((resolve, reject) => {
  134. this.server.once('error', reject)
  135. this.server.listen(this.config.port, this.config.host, () => {
  136. this.server.off('error', reject)
  137. this.server.on('error', (err) => { this.ctx.logger.error(err) })
  138. this.listenedPort = (this.server.address() as AddressInfo).port
  139. resolve()
  140. })
  141. })
  142. // close + closeAllConnections: held-open responses (SSE) never end on
  143. // their own; without the force-close, close() would hang teardown.
  144. this.ctx.effect(() => () => new Promise<void>((resolve) => {
  145. this.server.close(() => { resolve() })
  146. this.server.closeAllConnections()
  147. }), 'httpServer.listen')
  148. }
  149. /** Longest-prefix-wins over the prefix table after an exact-table miss. */
  150. private match(pathname: string): WebRoute | undefined {
  151. const exact = this.exact.get(pathname)
  152. if (exact !== undefined) return exact
  153. let best: WebRoute | undefined
  154. for (const [prefix, route] of this.prefixes) {
  155. if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue
  156. if (best === undefined || prefix.length > best.path.length) best = route
  157. }
  158. return best
  159. }
  160. /** Index body: dist index.html through the registered taps in order. */
  161. private async renderIndex(): Promise<string> {
  162. let html = await readFile(this.distIndex, 'utf8')
  163. for (const transform of this.indexTaps) html = transform(html)
  164. return html
  165. }
  166. }
  167. export default HttpServerService