web.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /**
  2. * `dsh web` — the web-shape assembly: startHost + dist resolution +
  3. * startWebServer + the URL line + signal wiring. Mixing host and carrier
  4. * concerns is this app module's job (packages stay single-sided).
  5. */
  6. import { parseArgs } from 'node:util'
  7. import { networkInterfaces } from 'node:os'
  8. import { createRequire } from 'node:module'
  9. import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
  10. import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
  11. const LOOPBACK_HOST = '127.0.0.1'
  12. const ALL_INTERFACES_HOST = '0.0.0.0'
  13. // --- Client composition (composition decisions live in the composing app) ---
  14. // The composition layer owns one decision: which plugin packages mount (the
  15. // roster). Dependency edges and the boot prefetch tier live in each package's
  16. // dshClient declaration.
  17. /**
  18. * Dev-only plugin: the client HMR driver. Whether it composes in is a
  19. * deployment decision — the dev graph includes its row, the prod graph does
  20. * not mount it at all.
  21. */
  22. const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
  23. /** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
  24. const CLIENT_BUNDLE_POLL_MS = 500
  25. /** The client plugin roster (flat; per-row boot behavior comes from manifests). */
  26. const CLIENT_PACKAGES = [
  27. '@deepseek-ai/dsh-client-connection',
  28. '@deepseek-ai/dsh-client-runtime',
  29. '@deepseek-ai/dsh-client-ui-theme',
  30. '@deepseek-ai/dsh-client-i18n',
  31. '@deepseek-ai/dsh-client-ui-layout',
  32. '@deepseek-ai/dsh-client-ui-sidebar',
  33. '@deepseek-ai/dsh-client-ui-conversation',
  34. '@deepseek-ai/dsh-client-ui-question',
  35. '@deepseek-ai/dsh-client-ui-trajectory',
  36. ] as const
  37. export async function runWeb(argv: string[]): Promise<void> {
  38. const { values } = parseArgs({
  39. args: argv,
  40. options: {
  41. host: { type: 'string', default: LOOPBACK_HOST },
  42. port: { type: 'string', default: '3080' },
  43. dev: { type: 'boolean', default: false },
  44. },
  45. allowPositionals: false,
  46. })
  47. if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
  48. process.stderr.write(
  49. `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
  50. )
  51. process.exit(1)
  52. }
  53. const hostAddress = values.host
  54. const port = Number(values.port)
  55. if (!Number.isInteger(port) || port < 0 || port > 65535) {
  56. process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
  57. process.exit(1)
  58. }
  59. // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
  60. const host = await startHost({
  61. boot: {
  62. persistenceRoot: './.sessions',
  63. workspaceContext: { maxBytes: 65_536 },
  64. sessionTitleLlm: true,
  65. },
  66. })
  67. // Client plugin chain: in-memory Loader tree over the composed roster, then
  68. // the registry that feeds the __DSH_BOOT__ entry graph and
  69. // /plugins/<id>/client.js. All row content comes from dshClient discovery
  70. // over the mounted roster (dev adds the HMR driver row and turns on the
  71. // bundle watch that drives rebuilt frames).
  72. const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
  73. const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
  74. const webPlugins = createHostWebPluginRegistry({
  75. ctx: host.ctx,
  76. loader: mounted.loader,
  77. resolvePkgJson: mounted.resolvePkgJson,
  78. onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
  79. ...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
  80. })
  81. if (values.dev) {
  82. // Dev visibility (the registry is a library and never prints): list what
  83. // the bundle watch covers, then log every observed rebuild. This is a
  84. // second onRebuilt subscription — the SSE relay inside the webserver is
  85. // unaffected (multicast).
  86. const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
  87. const bundlePaths = [...revs.keys()]
  88. .map(id => webPlugins.clientPath(id))
  89. .filter((path): path is string => path !== undefined)
  90. console.log(
  91. `dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
  92. )
  93. webPlugins.onRebuilt((id, rev) => {
  94. console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
  95. revs.set(id, rev)
  96. })
  97. }
  98. // Published so the webserver invariant companion can audit manifest/bundle
  99. // consistency; nothing else reads this key.
  100. host.ctx.reflect.provide('webPlugins', webPlugins)
  101. // Dist location is workspace knowledge of this app: resolved through
  102. // @deepseek-ai/dsh-frontend's package exports, not configured.
  103. const require = createRequire(import.meta.url)
  104. let distIndex: string
  105. try {
  106. distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
  107. } catch {
  108. process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
  109. await host.dispose()
  110. process.exit(1)
  111. }
  112. let exiting = false
  113. async function shutdown(code: number): Promise<void> {
  114. if (exiting) return
  115. exiting = true
  116. try {
  117. await server.close()
  118. await host.dispose()
  119. } finally {
  120. process.exit(code)
  121. }
  122. }
  123. let server: Awaited<ReturnType<typeof startWebServer>>
  124. try {
  125. server = await startWebServer(
  126. { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins },
  127. (err: Error) => {
  128. process.stderr.write(`dsh web: ${String(err)}\n`)
  129. void shutdown(1)
  130. },
  131. )
  132. } catch (error: unknown) {
  133. // listen failed (EADDRINUSE…): no server to close, dispose the host directly.
  134. process.stderr.write(`dsh web: ${String(error)}\n`)
  135. await host.dispose()
  136. process.exit(1)
  137. }
  138. const lan = hostAddress === ALL_INTERFACES_HOST
  139. ? Object.values(networkInterfaces()).flat()
  140. .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
  141. : undefined
  142. const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
  143. console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
  144. process.on('SIGTERM', () => { void shutdown(0) })
  145. process.on('SIGINT', () => { void shutdown(130) })
  146. }