@agimon-ai/doompi-web 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +196 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/bin/serve.cjs +3 -0
  5. package/dist/bin/serve.cjs.map +1 -0
  6. package/dist/bin/serve.d.cts +1 -0
  7. package/dist/bin/serve.d.mts +1 -0
  8. package/dist/bin/serve.mjs +3 -0
  9. package/dist/bin/serve.mjs.map +1 -0
  10. package/dist/bundler.cjs +1 -0
  11. package/dist/bundler.d.cts +2 -0
  12. package/dist/bundler.d.mts +2 -0
  13. package/dist/bundler.mjs +1 -0
  14. package/dist/index.cjs +1 -0
  15. package/dist/index.d.cts +7 -0
  16. package/dist/index.d.mts +7 -0
  17. package/dist/index.mjs +1 -0
  18. package/dist/src/adapters/authRoutes.cjs +2 -0
  19. package/dist/src/adapters/authRoutes.cjs.map +1 -0
  20. package/dist/src/adapters/authRoutes.mjs +2 -0
  21. package/dist/src/adapters/authRoutes.mjs.map +1 -0
  22. package/dist/src/adapters/bundledServer.cjs +2 -0
  23. package/dist/src/adapters/bundledServer.cjs.map +1 -0
  24. package/dist/src/adapters/bundledServer.mjs +2 -0
  25. package/dist/src/adapters/bundledServer.mjs.map +1 -0
  26. package/dist/src/adapters/directoryListing.cjs +2 -0
  27. package/dist/src/adapters/directoryListing.cjs.map +1 -0
  28. package/dist/src/adapters/directoryListing.mjs +2 -0
  29. package/dist/src/adapters/directoryListing.mjs.map +1 -0
  30. package/dist/src/adapters/directorySearch.cjs +3 -0
  31. package/dist/src/adapters/directorySearch.cjs.map +1 -0
  32. package/dist/src/adapters/directorySearch.mjs +3 -0
  33. package/dist/src/adapters/directorySearch.mjs.map +1 -0
  34. package/dist/src/adapters/gitStatus.cjs +2 -0
  35. package/dist/src/adapters/gitStatus.cjs.map +1 -0
  36. package/dist/src/adapters/gitStatus.mjs +2 -0
  37. package/dist/src/adapters/gitStatus.mjs.map +1 -0
  38. package/dist/src/adapters/httpServer.cjs +2 -0
  39. package/dist/src/adapters/httpServer.cjs.map +1 -0
  40. package/dist/src/adapters/httpServer.d.cts +14 -0
  41. package/dist/src/adapters/httpServer.d.cts.map +1 -0
  42. package/dist/src/adapters/httpServer.d.mts +14 -0
  43. package/dist/src/adapters/httpServer.d.mts.map +1 -0
  44. package/dist/src/adapters/httpServer.mjs +2 -0
  45. package/dist/src/adapters/httpServer.mjs.map +1 -0
  46. package/dist/src/adapters/hubProbe.cjs +2 -0
  47. package/dist/src/adapters/hubProbe.cjs.map +1 -0
  48. package/dist/src/adapters/hubProbe.mjs +2 -0
  49. package/dist/src/adapters/hubProbe.mjs.map +1 -0
  50. package/dist/src/adapters/packageApiProxy.cjs +2 -0
  51. package/dist/src/adapters/packageApiProxy.cjs.map +1 -0
  52. package/dist/src/adapters/packageApiProxy.mjs +2 -0
  53. package/dist/src/adapters/packageApiProxy.mjs.map +1 -0
  54. package/dist/src/adapters/piHubService.cjs +2 -0
  55. package/dist/src/adapters/piHubService.cjs.map +1 -0
  56. package/dist/src/adapters/piHubService.mjs +2 -0
  57. package/dist/src/adapters/piHubService.mjs.map +1 -0
  58. package/dist/src/adapters/piWebSocketListener.cjs +2 -0
  59. package/dist/src/adapters/piWebSocketListener.cjs.map +1 -0
  60. package/dist/src/adapters/piWebSocketListener.mjs +2 -0
  61. package/dist/src/adapters/piWebSocketListener.mjs.map +1 -0
  62. package/dist/src/adapters/providerAuth.cjs +2 -0
  63. package/dist/src/adapters/providerAuth.cjs.map +1 -0
  64. package/dist/src/adapters/providerAuth.mjs +2 -0
  65. package/dist/src/adapters/providerAuth.mjs.map +1 -0
  66. package/dist/src/adapters/registryWatcher.cjs +2 -0
  67. package/dist/src/adapters/registryWatcher.cjs.map +1 -0
  68. package/dist/src/adapters/registryWatcher.mjs +2 -0
  69. package/dist/src/adapters/registryWatcher.mjs.map +1 -0
  70. package/dist/src/adapters/serverSpawner.cjs +2 -0
  71. package/dist/src/adapters/serverSpawner.cjs.map +1 -0
  72. package/dist/src/adapters/serverSpawner.mjs +2 -0
  73. package/dist/src/adapters/serverSpawner.mjs.map +1 -0
  74. package/dist/src/adapters/sessionFiles.cjs +3 -0
  75. package/dist/src/adapters/sessionFiles.cjs.map +1 -0
  76. package/dist/src/adapters/sessionFiles.mjs +3 -0
  77. package/dist/src/adapters/sessionFiles.mjs.map +1 -0
  78. package/dist/src/adapters/sessionHub.cjs +2 -0
  79. package/dist/src/adapters/sessionHub.cjs.map +1 -0
  80. package/dist/src/adapters/sessionHub.mjs +2 -0
  81. package/dist/src/adapters/sessionHub.mjs.map +1 -0
  82. package/dist/src/adapters/sessionSocketClient.cjs +2 -0
  83. package/dist/src/adapters/sessionSocketClient.cjs.map +1 -0
  84. package/dist/src/adapters/sessionSocketClient.mjs +2 -0
  85. package/dist/src/adapters/sessionSocketClient.mjs.map +1 -0
  86. package/dist/src/adapters/syncGuard.cjs +2 -0
  87. package/dist/src/adapters/syncGuard.cjs.map +1 -0
  88. package/dist/src/adapters/syncGuard.mjs +2 -0
  89. package/dist/src/adapters/syncGuard.mjs.map +1 -0
  90. package/dist/src/adapters/threadJournals.cjs +2 -0
  91. package/dist/src/adapters/threadJournals.cjs.map +1 -0
  92. package/dist/src/adapters/threadJournals.mjs +2 -0
  93. package/dist/src/adapters/threadJournals.mjs.map +1 -0
  94. package/dist/src/adapters/webBundler.cjs +2 -0
  95. package/dist/src/adapters/webBundler.cjs.map +1 -0
  96. package/dist/src/adapters/webBundler.d.cts +28 -0
  97. package/dist/src/adapters/webBundler.d.cts.map +1 -0
  98. package/dist/src/adapters/webBundler.d.mts +28 -0
  99. package/dist/src/adapters/webBundler.d.mts.map +1 -0
  100. package/dist/src/adapters/webBundler.mjs +2 -0
  101. package/dist/src/adapters/webBundler.mjs.map +1 -0
  102. package/dist/src/adapters/webHubPluginLoader.cjs +2 -0
  103. package/dist/src/adapters/webHubPluginLoader.cjs.map +1 -0
  104. package/dist/src/adapters/webHubPluginLoader.mjs +2 -0
  105. package/dist/src/adapters/webHubPluginLoader.mjs.map +1 -0
  106. package/dist/src/adapters/webHubPlugins.generated.cjs +2 -0
  107. package/dist/src/adapters/webHubPlugins.generated.cjs.map +1 -0
  108. package/dist/src/adapters/webHubPlugins.generated.mjs +2 -0
  109. package/dist/src/adapters/webHubPlugins.generated.mjs.map +1 -0
  110. package/dist/src/adapters/webPluginGenerate.cjs +4 -0
  111. package/dist/src/adapters/webPluginGenerate.cjs.map +1 -0
  112. package/dist/src/adapters/webPluginGenerate.mjs +4 -0
  113. package/dist/src/adapters/webPluginGenerate.mjs.map +1 -0
  114. package/dist/src/adapters/webPluginScan.cjs +2 -0
  115. package/dist/src/adapters/webPluginScan.cjs.map +1 -0
  116. package/dist/src/adapters/webPluginScan.mjs +2 -0
  117. package/dist/src/adapters/webPluginScan.mjs.map +1 -0
  118. package/dist/src/adapters/webPluginVite.cjs +2 -0
  119. package/dist/src/adapters/webPluginVite.cjs.map +1 -0
  120. package/dist/src/adapters/webPluginVite.mjs +2 -0
  121. package/dist/src/adapters/webPluginVite.mjs.map +1 -0
  122. package/dist/src/services/directoryMatch.cjs +2 -0
  123. package/dist/src/services/directoryMatch.cjs.map +1 -0
  124. package/dist/src/services/directoryMatch.mjs +2 -0
  125. package/dist/src/services/directoryMatch.mjs.map +1 -0
  126. package/dist/src/services/fileMatch.cjs +2 -0
  127. package/dist/src/services/fileMatch.cjs.map +1 -0
  128. package/dist/src/services/fileMatch.mjs +2 -0
  129. package/dist/src/services/fileMatch.mjs.map +1 -0
  130. package/dist/src/services/fileMedia.cjs +2 -0
  131. package/dist/src/services/fileMedia.cjs.map +1 -0
  132. package/dist/src/services/fileMedia.mjs +2 -0
  133. package/dist/src/services/fileMedia.mjs.map +1 -0
  134. package/dist/src/services/frameRing.cjs +2 -0
  135. package/dist/src/services/frameRing.cjs.map +1 -0
  136. package/dist/src/services/frameRing.mjs +2 -0
  137. package/dist/src/services/frameRing.mjs.map +1 -0
  138. package/dist/src/services/journalTail.cjs +3 -0
  139. package/dist/src/services/journalTail.cjs.map +1 -0
  140. package/dist/src/services/journalTail.mjs +3 -0
  141. package/dist/src/services/journalTail.mjs.map +1 -0
  142. package/dist/src/services/loginFlow.cjs +2 -0
  143. package/dist/src/services/loginFlow.cjs.map +1 -0
  144. package/dist/src/services/loginFlow.mjs +2 -0
  145. package/dist/src/services/loginFlow.mjs.map +1 -0
  146. package/dist/src/services/registryStore.cjs +2 -0
  147. package/dist/src/services/registryStore.cjs.map +1 -0
  148. package/dist/src/services/registryStore.mjs +2 -0
  149. package/dist/src/services/registryStore.mjs.map +1 -0
  150. package/dist/src/services/retryPolicy.cjs +2 -0
  151. package/dist/src/services/retryPolicy.cjs.map +1 -0
  152. package/dist/src/services/retryPolicy.mjs +2 -0
  153. package/dist/src/services/retryPolicy.mjs.map +1 -0
  154. package/dist/src/services/serveOptions.cjs +2 -0
  155. package/dist/src/services/serveOptions.cjs.map +1 -0
  156. package/dist/src/services/serveOptions.d.cts +21 -0
  157. package/dist/src/services/serveOptions.d.cts.map +1 -0
  158. package/dist/src/services/serveOptions.d.mts +21 -0
  159. package/dist/src/services/serveOptions.d.mts.map +1 -0
  160. package/dist/src/services/serveOptions.mjs +2 -0
  161. package/dist/src/services/serveOptions.mjs.map +1 -0
  162. package/dist/src/services/sessionFraming.cjs +4 -0
  163. package/dist/src/services/sessionFraming.cjs.map +1 -0
  164. package/dist/src/services/sessionFraming.mjs +4 -0
  165. package/dist/src/services/sessionFraming.mjs.map +1 -0
  166. package/dist/src/services/sessionPresence.cjs +2 -0
  167. package/dist/src/services/sessionPresence.cjs.map +1 -0
  168. package/dist/src/services/sessionPresence.mjs +2 -0
  169. package/dist/src/services/sessionPresence.mjs.map +1 -0
  170. package/dist/src/services/staticAssets.cjs +2 -0
  171. package/dist/src/services/staticAssets.cjs.map +1 -0
  172. package/dist/src/services/staticAssets.mjs +2 -0
  173. package/dist/src/services/staticAssets.mjs.map +1 -0
  174. package/dist/src/services/webDevRoots.cjs +2 -0
  175. package/dist/src/services/webDevRoots.cjs.map +1 -0
  176. package/dist/src/services/webDevRoots.mjs +2 -0
  177. package/dist/src/services/webDevRoots.mjs.map +1 -0
  178. package/dist/src/services/webPluginManifest.cjs +2 -0
  179. package/dist/src/services/webPluginManifest.cjs.map +1 -0
  180. package/dist/src/services/webPluginManifest.mjs +2 -0
  181. package/dist/src/services/webPluginManifest.mjs.map +1 -0
  182. package/dist/src/types/auth.cjs +2 -0
  183. package/dist/src/types/auth.cjs.map +1 -0
  184. package/dist/src/types/auth.d.cts +94 -0
  185. package/dist/src/types/auth.d.cts.map +1 -0
  186. package/dist/src/types/auth.d.mts +94 -0
  187. package/dist/src/types/auth.d.mts.map +1 -0
  188. package/dist/src/types/auth.mjs +2 -0
  189. package/dist/src/types/auth.mjs.map +1 -0
  190. package/dist/src/types/bridge.d.cts +31 -0
  191. package/dist/src/types/bridge.d.cts.map +1 -0
  192. package/dist/src/types/bridge.d.mts +31 -0
  193. package/dist/src/types/bridge.d.mts.map +1 -0
  194. package/dist/src/types/hub.cjs +2 -0
  195. package/dist/src/types/hub.cjs.map +1 -0
  196. package/dist/src/types/hub.d.cts +58 -0
  197. package/dist/src/types/hub.d.cts.map +1 -0
  198. package/dist/src/types/hub.d.mts +58 -0
  199. package/dist/src/types/hub.d.mts.map +1 -0
  200. package/dist/src/types/hub.mjs +2 -0
  201. package/dist/src/types/hub.mjs.map +1 -0
  202. package/dist/src/types/media.cjs +2 -0
  203. package/dist/src/types/media.cjs.map +1 -0
  204. package/dist/src/types/media.mjs +2 -0
  205. package/dist/src/types/media.mjs.map +1 -0
  206. package/dist/src/types/registry.cjs +1 -0
  207. package/dist/src/types/registry.d.cts +2 -0
  208. package/dist/src/types/registry.d.mts +2 -0
  209. package/dist/src/types/registry.mjs +1 -0
  210. package/dist/src/types/session.cjs +2 -0
  211. package/dist/src/types/session.cjs.map +1 -0
  212. package/dist/src/types/session.d.cts +24 -0
  213. package/dist/src/types/session.d.cts.map +1 -0
  214. package/dist/src/types/session.d.mts +24 -0
  215. package/dist/src/types/session.d.mts.map +1 -0
  216. package/dist/src/types/session.mjs +2 -0
  217. package/dist/src/types/session.mjs.map +1 -0
  218. package/dist/web/apple-touch-icon.png +0 -0
  219. package/dist/web/assets/index-Cg52ngKv.css +2 -0
  220. package/dist/web/assets/index-DQO7NTIP.js +90 -0
  221. package/dist/web/assets/index-DQO7NTIP.js.map +1 -0
  222. package/dist/web/favicon-16.png +0 -0
  223. package/dist/web/favicon-32.png +0 -0
  224. package/dist/web/favicon.ico +0 -0
  225. package/dist/web/favicon.svg +16 -0
  226. package/dist/web/icon-512.png +0 -0
  227. package/dist/web/index.html +17 -0
  228. package/llms.txt +8 -0
  229. package/package.json +106 -0
  230. package/src/adapters/authRoutes.ts +95 -0
  231. package/src/adapters/bundledServer.ts +64 -0
  232. package/src/adapters/directoryListing.ts +127 -0
  233. package/src/adapters/directorySearch.ts +124 -0
  234. package/src/adapters/gitStatus.ts +27 -0
  235. package/src/adapters/httpServer.ts +556 -0
  236. package/src/adapters/hubProbe.ts +27 -0
  237. package/src/adapters/packageApiProxy.ts +60 -0
  238. package/src/adapters/piHubService.ts +129 -0
  239. package/src/adapters/piWebSocketListener.ts +98 -0
  240. package/src/adapters/providerAuth.ts +181 -0
  241. package/src/adapters/registryWatcher.ts +112 -0
  242. package/src/adapters/serverSpawner.ts +181 -0
  243. package/src/adapters/sessionFiles.ts +99 -0
  244. package/src/adapters/sessionHub.ts +567 -0
  245. package/src/adapters/sessionSocketClient.ts +105 -0
  246. package/src/adapters/syncGuard.ts +119 -0
  247. package/src/adapters/threadJournals.ts +184 -0
  248. package/src/adapters/webBundler.ts +83 -0
  249. package/src/adapters/webHubPluginLoader.ts +67 -0
  250. package/src/adapters/webHubPlugins.generated.ts +4 -0
  251. package/src/adapters/webPluginGenerate.ts +172 -0
  252. package/src/adapters/webPluginScan.ts +62 -0
  253. package/src/adapters/webPluginVite.ts +51 -0
  254. package/src/bin/serve.ts +64 -0
  255. package/src/exports/bundler.ts +1 -0
  256. package/src/exports/index.ts +9 -0
  257. package/src/services/directoryMatch.ts +71 -0
  258. package/src/services/fileMatch.ts +37 -0
  259. package/src/services/fileMedia.ts +27 -0
  260. package/src/services/frameRing.ts +33 -0
  261. package/src/services/journalTail.ts +47 -0
  262. package/src/services/loginFlow.ts +144 -0
  263. package/src/services/registryStore.ts +83 -0
  264. package/src/services/retryPolicy.ts +15 -0
  265. package/src/services/serveOptions.ts +63 -0
  266. package/src/services/sessionFraming.ts +32 -0
  267. package/src/services/sessionPresence.ts +131 -0
  268. package/src/services/staticAssets.ts +37 -0
  269. package/src/services/webDevRoots.ts +36 -0
  270. package/src/services/webPluginManifest.ts +149 -0
  271. package/src/types/auth.ts +118 -0
  272. package/src/types/bridge.ts +41 -0
  273. package/src/types/hub.ts +290 -0
  274. package/src/types/media.ts +38 -0
  275. package/src/types/registry.ts +2 -0
  276. package/src/types/session.ts +36 -0
  277. package/src/web/app/Providers.tsx +46 -0
  278. package/src/web/app/protocolRuntime.ts +106 -0
  279. package/src/web/app/sessionRuntime.ts +189 -0
  280. package/src/web/app/webPlugins.generated.ts +4 -0
  281. package/src/web/components/MascotMark.tsx +46 -0
  282. package/src/web/components/PluginSurface.tsx +10 -0
  283. package/src/web/components/ToolRendererBoundary.tsx +32 -0
  284. package/src/web/features/activity/ActivityDock.tsx +125 -0
  285. package/src/web/features/connection/RefusedCard.tsx +63 -0
  286. package/src/web/features/dialogs/DialogOverlay.tsx +176 -0
  287. package/src/web/features/leader/CommandPalette.tsx +386 -0
  288. package/src/web/features/selection/SelectionBar.tsx +529 -0
  289. package/src/web/features/session/Composer.tsx +368 -0
  290. package/src/web/features/session/MentionPreviews.tsx +58 -0
  291. package/src/web/features/session/ThreadView.tsx +35 -0
  292. package/src/web/features/session/Timeline.tsx +382 -0
  293. package/src/web/features/session/ToolCard.tsx +79 -0
  294. package/src/web/features/sessions/NewSessionDialog.tsx +222 -0
  295. package/src/web/features/sessions/SessionRail.tsx +411 -0
  296. package/src/web/features/settings/AppearanceSettings.tsx +88 -0
  297. package/src/web/features/settings/LoginFlowDialog.tsx +221 -0
  298. package/src/web/features/settings/PluginSettings.tsx +92 -0
  299. package/src/web/features/settings/ProviderSettings.tsx +238 -0
  300. package/src/web/features/settings/SettingsMenu.tsx +32 -0
  301. package/src/web/features/status/TopBar.tsx +208 -0
  302. package/src/web/index.html +16 -0
  303. package/src/web/lib/authApi.ts +90 -0
  304. package/src/web/lib/commands.ts +61 -0
  305. package/src/web/lib/composition.ts +225 -0
  306. package/src/web/lib/fileMentions.ts +32 -0
  307. package/src/web/lib/hubApi.ts +86 -0
  308. package/src/web/lib/leaderTree.ts +159 -0
  309. package/src/web/lib/piTransport.ts +61 -0
  310. package/src/web/lib/pluginRegistry.ts +488 -0
  311. package/src/web/lib/pluginSlotProps.ts +50 -0
  312. package/src/web/lib/promptFocus.ts +25 -0
  313. package/src/web/lib/protocolTimeline.ts +73 -0
  314. package/src/web/lib/sessionModel.ts +673 -0
  315. package/src/web/lib/sessionSummary.ts +58 -0
  316. package/src/web/lib/settingsSections.ts +21 -0
  317. package/src/web/lib/statusLine.ts +90 -0
  318. package/src/web/lib/threadRenderer.ts +25 -0
  319. package/src/web/lib/toolMessageProps.ts +21 -0
  320. package/src/web/lib/transport.ts +30 -0
  321. package/src/web/lib/wsClient.ts +73 -0
  322. package/src/web/main.tsx +17 -0
  323. package/src/web/public/apple-touch-icon.png +0 -0
  324. package/src/web/public/favicon-16.png +0 -0
  325. package/src/web/public/favicon-32.png +0 -0
  326. package/src/web/public/favicon.ico +0 -0
  327. package/src/web/public/favicon.svg +16 -0
  328. package/src/web/public/icon-512.png +0 -0
  329. package/src/web/routes/CockpitPage.tsx +98 -0
  330. package/src/web/routes/SettingsPage.tsx +60 -0
  331. package/src/web/routes/routeTree.tsx +49 -0
  332. package/src/web/stores/menuStore.ts +78 -0
  333. package/src/web/stores/paletteStore.ts +25 -0
  334. package/src/web/stores/sessionStore.ts +303 -0
  335. package/src/web/stores/sessionsStore.ts +155 -0
  336. package/src/web/stores/themeStore.ts +52 -0
  337. package/src/web/stores/threadStore.ts +68 -0
  338. package/src/web/stores/transientTabsStore.ts +63 -0
  339. package/src/web/stores/uiStore.ts +28 -0
  340. package/src/web/stores/useOpenTab.ts +19 -0
  341. package/src/web/stores/usePluginSlotProps.ts +22 -0
  342. package/src/web/styles/app.css +15 -0
  343. package/src/web/styles/webPluginSources.generated.css +1 -0
  344. package/src/web/vite-env.d.ts +1 -0
@@ -0,0 +1,556 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { serve } from '@hono/node-server';
6
+ import { createNodeWebSocket } from '@hono/node-ws';
7
+ import { PiServer } from '@earendil-works/pi-server';
8
+ import { createPiHubService } from './piHubService.ts';
9
+ import { createSyncGuard } from './syncGuard.ts';
10
+ import { createPiWebSocketListener } from './piWebSocketListener.ts';
11
+ import { Hono } from 'hono';
12
+ import type { WebHubChannel } from '@agimon-ai/doompi-web-contracts';
13
+ import {
14
+ DOOM_API_ROUTE_PREFIX,
15
+ type DoomApi,
16
+ type DoomApiHandler,
17
+ } from '@agimon-ai/doompi-extension-contracts/package-api';
18
+ import { loadPackageApis } from '@agimon-ai/doompi-extension-contracts/package-api-loader';
19
+ import { sessionFileHeaders } from '../services/fileMedia.ts';
20
+ import { contentTypeFor, resolveAssetPath } from '../services/staticAssets.ts';
21
+ import { MAX_SESSION_FILE_BYTES, SESSION_FILE_ROUTE } from '../types/media.ts';
22
+ import type { WebServer, WebServerOptions } from '../types/bridge.ts';
23
+ import {
24
+ API_SESSION_QUERY_PARAM,
25
+ DIRECTORIES_API_ROUTE,
26
+ HISTORY_REQUEST_TYPE,
27
+ HUB_PROTOCOL_VERSION,
28
+ HUB_RESYNCED_TYPE,
29
+ HUB_ROLE,
30
+ SESSIONS_API_ROUTE,
31
+ SESSIONS_SNAPSHOT_TYPE,
32
+ SESSION_COMMAND_TYPE,
33
+ SESSION_REMOVED_TYPE,
34
+ SESSION_UPSERT_TYPE,
35
+ SUBSCRIBE_THREAD_TYPE,
36
+ SUBSCRIBE_TYPE,
37
+ UNSUBSCRIBE_THREAD_TYPE,
38
+ UNSUBSCRIBE_TYPE,
39
+ hubHello,
40
+ sessionFrameEnvelope,
41
+ threadBacklog,
42
+ threadFrameEnvelope,
43
+ } from '../types/hub.ts';
44
+ import { ATTACH_TYPE, type SessionFrame } from '../types/session.ts';
45
+ import { readGitStatus } from './gitStatus.ts';
46
+ import { watchRegistry } from './registryWatcher.ts';
47
+ import { createServerSpawner } from './serverSpawner.ts';
48
+ import { createSessionHub, type SessionHub } from './sessionHub.ts';
49
+ import { registerAuthRoutes } from './authRoutes.ts';
50
+ import { createProviderAuth } from './providerAuth.ts';
51
+ import { suggestDirectories } from './directoryListing.ts';
52
+ import { listSessionFiles, readSessionFile } from './sessionFiles.ts';
53
+ import { proxyToSocket } from './packageApiProxy.ts';
54
+ import { createThreadJournals } from './threadJournals.ts';
55
+ import { loadHubChannels } from './webHubPluginLoader.ts';
56
+
57
+ const SESSION_ROUTE = '/api/session';
58
+ // Pi's protocol rides its own socket so the DoomPi channel keeps the
59
+ // vocabulary the protocol has no shape for: dialogs, minor modes, selection.
60
+ const PROTOCOL_ROUTE = '/api/pi';
61
+ /** Directory suggestions per picker query; more than this means "type further". */
62
+ const DIRECTORY_SUGGESTION_LIMIT = 12;
63
+ const INDEX_FILE = 'index.html';
64
+ /** Env override for the assets directory, set by launchers that know a synced bundle. */
65
+ const WEB_DIST_ENV = 'DOOMPI_WEB_DIST';
66
+ /** Where `doompi sync` publishes the machine's cockpit bundle. */
67
+ const SYNCED_WEB_DIRECTORY = ['.doompi', 'web', 'current', 'web'];
68
+
69
+ /**
70
+ * Locates the built SPA next to the package that owns this module.
71
+ *
72
+ * Walking to the manifest rather than counting `..` segments keeps this correct
73
+ * whether the code runs from `src` in development or from the unbundled `dist`
74
+ * tree, whose depth is a build detail.
75
+ */
76
+ function packagedAssetsDir(): string {
77
+ let dir = path.dirname(fileURLToPath(import.meta.url));
78
+ for (;;) {
79
+ if (fs.existsSync(path.join(dir, 'package.json'))) return path.join(dir, 'dist', 'web');
80
+ const parent = path.dirname(dir);
81
+ if (parent === dir) throw new Error('doompi-web could not locate its own package root.');
82
+ dir = parent;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The assets to serve, in preference order: an explicit option, the env
88
+ * override, the bundle `doompi sync` published for this machine (which
89
+ * carries the installed plugin set), then the package's own prebuilt bundle
90
+ * (built-in plugins only).
91
+ */
92
+ function resolveAssetsDir(explicit: string | undefined, notice: (message: string) => void): string {
93
+ if (explicit !== undefined) return explicit;
94
+ const fromEnv = process.env[WEB_DIST_ENV];
95
+ if (fromEnv !== undefined && fromEnv !== '') return fromEnv;
96
+ const synced = path.join(os.homedir(), ...SYNCED_WEB_DIRECTORY);
97
+ if (fs.existsSync(path.join(synced, INDEX_FILE))) {
98
+ notice(`serving the synced cockpit bundle from ${synced}`);
99
+ return synced;
100
+ }
101
+ return packagedAssetsDir();
102
+ }
103
+
104
+ function readAsset(filePath: string): Buffer | undefined {
105
+ try {
106
+ const stat = fs.statSync(filePath);
107
+ if (!stat.isFile()) return undefined;
108
+ return fs.readFileSync(filePath);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ function isRecord(value: unknown): value is Record<string, unknown> {
115
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
116
+ }
117
+
118
+ function describeError(error: unknown): string {
119
+ return error instanceof Error ? error.message : String(error);
120
+ }
121
+
122
+ /** One page's hold on one thread; a session id never contains a newline, so the pair cannot collide. */
123
+ function threadKey(sessionId: string, threadId: string): string {
124
+ return `${sessionId}\n${threadId}`;
125
+ }
126
+
127
+ function buildHub(
128
+ options: WebServerOptions,
129
+ notice: (message: string) => void,
130
+ channels: readonly WebHubChannel[],
131
+ ): SessionHub {
132
+ return createSessionHub({
133
+ source: watchRegistry(options.registryDir, notice),
134
+ spawner: createServerSpawner({
135
+ registryDir: options.registryDir,
136
+ command: options.spawnCommand,
137
+ onNotice: notice,
138
+ }),
139
+ readGit: readGitStatus,
140
+ channels,
141
+ onNotice: notice,
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Mounts the hub-scoped package APIs under /api/plugin/<basePath>/.
147
+ *
148
+ * The prefix is stripped before the API sees the request, so a package
149
+ * declares its routes relative to its own mount and never repeats where a host
150
+ * put it. An API that throws answers 500 for its own routes alone; one bad
151
+ * package never takes the cockpit down with it.
152
+ */
153
+ function mountHubApis(app: Hono, apis: readonly DoomApi[], notice: (message: string) => void): DoomApiHandler[] {
154
+ const handlers: DoomApiHandler[] = [];
155
+ for (const api of apis) {
156
+ const mount = `${DOOM_API_ROUTE_PREFIX}/${api.basePath}`;
157
+ let handler: DoomApiHandler;
158
+ try {
159
+ handler = api.start({ scope: 'hub', onNotice: notice });
160
+ } catch (error) {
161
+ notice(`hub API '${api.basePath}' did not start (${describeError(error)}); its routes stay unmounted`);
162
+ continue;
163
+ }
164
+ handlers.push(handler);
165
+ app.all(`${mount}/*`, async (context) => {
166
+ if (context.req.query(API_SESSION_QUERY_PARAM) !== undefined) return context.notFound();
167
+ const url = new URL(context.req.url);
168
+ url.pathname = url.pathname.slice(mount.length) || '/';
169
+ try {
170
+ return await handler.fetch(new Request(url, context.req.raw));
171
+ } catch (error) {
172
+ notice(`hub API '${api.basePath}' failed on ${url.pathname} (${describeError(error)})`);
173
+ return context.json({ error: `The '${api.basePath}' API failed.` }, 500);
174
+ }
175
+ });
176
+ }
177
+ return handlers;
178
+ }
179
+
180
+ /**
181
+ * Forwards a package API request carrying ?session= to that session's own
182
+ * server, over the API socket its registry record names.
183
+ *
184
+ * The session servers are where session-scoped data lives, but the browser must
185
+ * not talk to them: their attach tokens stay in this process and the page only
186
+ * ever reaches loopback. So the hub is the one door, and this is the hop behind
187
+ * it. A session that never mounted an API answers 404 with the reason rather
188
+ * than leaving the page waiting.
189
+ */
190
+ function mountSessionApiProxy(app: Hono, hub: SessionHub, notice: (message: string) => void): void {
191
+ app.all(`${DOOM_API_ROUTE_PREFIX}/*`, async (context) => {
192
+ const sessionId = context.req.query(API_SESSION_QUERY_PARAM);
193
+ if (sessionId === undefined) return context.notFound();
194
+ const summary = hub.snapshot().find((candidate) => candidate.id === sessionId);
195
+ if (summary === undefined) return context.json({ error: `No session ${sessionId}.` }, 404);
196
+ if (summary.apiSocketPath === undefined) {
197
+ return context.json({ error: `Session ${sessionId} serves no package API.` }, 404);
198
+ }
199
+ const url = new URL(context.req.url);
200
+ url.searchParams.delete(API_SESSION_QUERY_PARAM);
201
+ try {
202
+ return await proxyToSocket({
203
+ socketPath: summary.apiSocketPath,
204
+ path: `${url.pathname}${url.search}`,
205
+ method: context.req.method,
206
+ headers: context.req.raw.headers,
207
+ body: context.req.raw.body,
208
+ });
209
+ } catch (error) {
210
+ notice(`session ${sessionId} API is unreachable (${describeError(error)})`);
211
+ return context.json({ error: `Session ${sessionId} is not answering.` }, 502);
212
+ }
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Serves the cockpit and multiplexes every session behind it.
218
+ *
219
+ * Attach tokens stay in this process. The browser authenticates by reaching a
220
+ * loopback port, so a page never holds a credential that would let it talk to
221
+ * a session directly. One page WebSocket carries all sessions: hub frames
222
+ * describe the set, and session traffic travels enveloped by session id.
223
+ */
224
+ export async function serveWeb(options: WebServerOptions): Promise<WebServer> {
225
+ const host = options.host ?? '127.0.0.1';
226
+ const notice = options.onNotice ?? ((): void => {});
227
+ const assetsDir = resolveAssetsDir(options.assetsDir, notice);
228
+ const hub = buildHub(options, notice, await loadHubChannels(assetsDir, notice));
229
+ // Threads are journals the data channels can name (a subagent run's own
230
+ // session file); the hub tails one only while a page follows it.
231
+ const threads = createThreadJournals({
232
+ resolve: (sessionId, threadId) => hub.threadJournal(sessionId, threadId),
233
+ onNotice: notice,
234
+ });
235
+ // A session reads what sync produced, and nothing makes the person who
236
+ // opened the cockpit run sync first. The hub keeps the repository current.
237
+ const syncGuard = createSyncGuard({ repoRoot: process.cwd(), onNotice: notice });
238
+ await syncGuard.ensureSynced();
239
+ /** Every attached page, so a rebuild can tell them all to pick it up. */
240
+ const pages = new Set<(frame: object) => void>();
241
+
242
+ const app = new Hono();
243
+ const nodeWs = createNodeWebSocket({ app });
244
+ // Every running session already serves Pi's protocol; the hub composes them
245
+ // into one server so a browser sees a single endpoint with many sessions.
246
+ const protocolListener = createPiWebSocketListener({ onError: (error) => notice(`protocol: ${error.message}`) });
247
+ const protocolServer = new PiServer(
248
+ createPiHubService({
249
+ records: () => hub.records(),
250
+ spawn: (input) => hub.create(input),
251
+ onNotice: notice,
252
+ }),
253
+ { listeners: [protocolListener], onError: (error) => notice(`protocol: ${error.message}`) },
254
+ );
255
+ await protocolServer.start();
256
+ // Provider credentials belong to the machine, not to a session: the hub
257
+ // keeps one Pi runtime over the shared auth.json and signs in for all.
258
+ const providerAuth = createProviderAuth({ runtime: options.authRuntime, onNotice: notice });
259
+ registerAuthRoutes(app, providerAuth);
260
+ // Package APIs, mounted before the SPA fallback so their routes are reachable
261
+ // and everything else still falls through to the bundle. Hub-scoped ones run
262
+ // here; session-scoped ones live in each session's own server and are proxied.
263
+ const pluginApis = mountHubApis(app, await loadPackageApis('hub', { onNotice: notice }), notice);
264
+ mountSessionApiProxy(app, hub, notice);
265
+
266
+ app.get('/api/health', (context) =>
267
+ context.json({
268
+ ok: true,
269
+ role: HUB_ROLE,
270
+ protocol: HUB_PROTOCOL_VERSION,
271
+ sessions: hub.snapshot().length,
272
+ pid: process.pid,
273
+ }),
274
+ );
275
+
276
+ app.post(SESSIONS_API_ROUTE, async (context) => {
277
+ let body: unknown;
278
+ try {
279
+ body = await context.req.json();
280
+ } catch {
281
+ return context.json({ error: 'The request body must be JSON.' }, 400);
282
+ }
283
+ if (!isRecord(body) || typeof body.cwd !== 'string' || body.cwd === '') {
284
+ return context.json({ error: 'A cwd string is required.' }, 400);
285
+ }
286
+ const name = typeof body.name === 'string' && body.name !== '' ? body.name : undefined;
287
+ await syncGuard.ensureSynced();
288
+ const outcome = await hub.create({ cwd: body.cwd, name });
289
+ if (outcome.ok) return context.json({ sessionId: outcome.sessionId }, 201);
290
+ return context.json({ error: outcome.error }, outcome.code === 'invalid_request' ? 400 : 502);
291
+ });
292
+
293
+ app.delete(`${SESSIONS_API_ROUTE}/:sessionId`, (context) => {
294
+ const sessionId = context.req.param('sessionId');
295
+ const outcome = hub.stop(sessionId);
296
+ if (outcome.ok) return context.json({ sessionId }, 202);
297
+ return context.json(
298
+ { error: outcome.error },
299
+ outcome.code === 'unknown' ? 404 : outcome.code === 'self' ? 409 : 502,
300
+ );
301
+ });
302
+
303
+ // File completion for the composer's @ references: bounded, cwd-scoped,
304
+ // and only for sessions the hub actually manages.
305
+ app.get('/api/sessions/:sessionId/files', async (context) => {
306
+ const sessionId = context.req.param('sessionId');
307
+ const summary = hub.snapshot().find((candidate) => candidate.id === sessionId);
308
+ if (!summary) return context.json({ error: 'Unknown session.' }, 404);
309
+ const query = context.req.query('q') ?? '';
310
+ const files = await listSessionFiles(summary.cwd, query, 20);
311
+ return context.json({ files });
312
+ });
313
+
314
+ // Directory suggestions for the new-session picker: the children of the
315
+ // typed parent while a path is being drilled into, and a ranked search of
316
+ // the home directory for anything else, so a name or a path remembered from
317
+ // another machine still finds the folder.
318
+ app.get(DIRECTORIES_API_ROUTE, async (context) => {
319
+ const directories = await suggestDirectories(context.req.query('q') ?? '', {
320
+ limit: DIRECTORY_SUGGESTION_LIMIT,
321
+ });
322
+ return context.json({ directories });
323
+ });
324
+
325
+ // The timeline's @file previews: one cwd-contained file, capped in size.
326
+ app.get(SESSION_FILE_ROUTE, async (context) => {
327
+ const sessionId = context.req.param('sessionId');
328
+ const summary = hub.snapshot().find((candidate) => candidate.id === sessionId);
329
+ if (!summary) return context.json({ error: 'Unknown session.' }, 404);
330
+ const relativePath = context.req.query('path') ?? '';
331
+ const file = await readSessionFile(summary.cwd, relativePath, MAX_SESSION_FILE_BYTES);
332
+ if (file.status === 'forbidden') return context.json({ error: 'The path leaves the session directory.' }, 403);
333
+ if (file.status === 'not-found') return context.json({ error: 'No such file.' }, 404);
334
+ if (file.status === 'too-large') return context.json({ error: 'The file is too large to preview.' }, 413);
335
+ return context.body(new Uint8Array(file.body), 200, sessionFileHeaders(relativePath));
336
+ });
337
+
338
+ app.get(
339
+ SESSION_ROUTE,
340
+ nodeWs.upgradeWebSocket(() => {
341
+ const subscriptions = new Set<string>();
342
+ /** The threads this page follows; one socket may follow several of one session. */
343
+ const threadSubscriptions = new Map<string, { sessionId: string; threadId: string }>();
344
+ let disconnect: (() => void) | undefined;
345
+ let disconnectThreads: (() => void) | undefined;
346
+ /** Held on the socket, not inside onOpen, so close can withdraw it. */
347
+ let registered: ((frame: object) => void) | undefined;
348
+ /** Lets go of every followed thread, or only a departed session's. */
349
+ const releaseThreads = (sessionId?: string): void => {
350
+ for (const [key, held] of threadSubscriptions) {
351
+ if (sessionId !== undefined && held.sessionId !== sessionId) continue;
352
+ threadSubscriptions.delete(key);
353
+ threads.unsubscribe(held.sessionId, held.threadId);
354
+ }
355
+ };
356
+ return {
357
+ onOpen(_event, ws) {
358
+ const post = (frame: SessionFrame | object): void => {
359
+ try {
360
+ ws.send(JSON.stringify(frame));
361
+ } catch {
362
+ // The browser went away mid-write; onClose tears the socket down.
363
+ }
364
+ };
365
+ registered = post;
366
+ pages.add(post);
367
+ post(hubHello(hub.channelTypes()));
368
+ post({ type: SESSIONS_SNAPSHOT_TYPE, sessions: hub.snapshot() });
369
+ disconnect = hub.onEvent((event) => {
370
+ if (event.kind === 'upsert') post({ type: SESSION_UPSERT_TYPE, session: event.session });
371
+ else if (event.kind === 'removed') {
372
+ subscriptions.delete(event.sessionId);
373
+ releaseThreads(event.sessionId);
374
+ post({ type: SESSION_REMOVED_TYPE, sessionId: event.sessionId });
375
+ } else if (event.kind === 'channel') {
376
+ if (subscriptions.has(event.sessionId)) {
377
+ post({ type: event.frameType, sessionId: event.sessionId, payload: event.payload });
378
+ }
379
+ } else if (subscriptions.has(event.sessionId)) {
380
+ post(sessionFrameEnvelope(event.sessionId, event.frame));
381
+ }
382
+ });
383
+ disconnectThreads = threads.onFrame((event) => {
384
+ if (threadSubscriptions.has(threadKey(event.sessionId, event.threadId))) {
385
+ post(threadFrameEnvelope(event.sessionId, event.threadId, event.frame));
386
+ }
387
+ });
388
+ notice('browser attached');
389
+ },
390
+ onMessage(event, ws) {
391
+ if (typeof event.data !== 'string') return;
392
+ let parsed: unknown;
393
+ try {
394
+ parsed = JSON.parse(event.data);
395
+ } catch {
396
+ return;
397
+ }
398
+ if (!isRecord(parsed) || typeof parsed.sessionId !== 'string') return;
399
+ const sessionId = parsed.sessionId;
400
+ if (parsed.type === SUBSCRIBE_TYPE) {
401
+ const backlog = hub.backlog(sessionId);
402
+ if (!backlog) return; // Unknown session; the snapshot said otherwise.
403
+ subscriptions.add(sessionId);
404
+ try {
405
+ ws.send(JSON.stringify(backlog));
406
+ for (const frame of hub.channelFrames(sessionId)) ws.send(JSON.stringify(frame));
407
+ } catch {
408
+ // The browser went away mid-write; onClose tears the socket down.
409
+ }
410
+ return;
411
+ }
412
+ if (parsed.type === UNSUBSCRIBE_TYPE) {
413
+ subscriptions.delete(sessionId);
414
+ return;
415
+ }
416
+ if (parsed.type === HISTORY_REQUEST_TYPE) {
417
+ // Older transcript, on demand. The hub kept what the attach path
418
+ // was too small to publish, so scrolling back reads from memory
419
+ // rather than asking the session to re-read its journal.
420
+ const page = hub.history(sessionId, {
421
+ ...(typeof parsed.before === 'string' ? { before: parsed.before } : {}),
422
+ ...(typeof parsed.limit === 'number' ? { limit: parsed.limit } : {}),
423
+ });
424
+ if (!page) return;
425
+ try {
426
+ ws.send(JSON.stringify(page));
427
+ } catch {
428
+ // The browser went away mid-write; onClose tears the socket down.
429
+ }
430
+ return;
431
+ }
432
+ if (parsed.type === SUBSCRIBE_THREAD_TYPE || parsed.type === UNSUBSCRIBE_THREAD_TYPE) {
433
+ if (typeof parsed.threadId !== 'string') return;
434
+ const threadId = parsed.threadId;
435
+ const key = threadKey(sessionId, threadId);
436
+ if (parsed.type === UNSUBSCRIBE_THREAD_TYPE) {
437
+ if (threadSubscriptions.delete(key)) threads.unsubscribe(sessionId, threadId);
438
+ return;
439
+ }
440
+ if (threadSubscriptions.has(key) || hub.backlog(sessionId) === undefined) return;
441
+ threadSubscriptions.set(key, { sessionId, threadId });
442
+ try {
443
+ ws.send(JSON.stringify(threadBacklog(sessionId, threadId, threads.subscribe(sessionId, threadId))));
444
+ } catch {
445
+ // The browser went away mid-write; onClose tears the socket down.
446
+ }
447
+ return;
448
+ }
449
+ if (parsed.type === SESSION_COMMAND_TYPE && isRecord(parsed.frame)) {
450
+ // The hub owns the handshake; a page must not be able to replay it.
451
+ if (parsed.frame.type === ATTACH_TYPE) return;
452
+ hub.command(sessionId, parsed.frame);
453
+ }
454
+ },
455
+ onClose() {
456
+ if (registered) pages.delete(registered);
457
+ registered = undefined;
458
+ disconnect?.();
459
+ disconnect = undefined;
460
+ disconnectThreads?.();
461
+ disconnectThreads = undefined;
462
+ subscriptions.clear();
463
+ releaseThreads();
464
+ notice('browser detached');
465
+ },
466
+ };
467
+ }),
468
+ );
469
+
470
+ app.get(
471
+ PROTOCOL_ROUTE,
472
+ nodeWs.upgradeWebSocket(() => {
473
+ let handler: ReturnType<typeof protocolListener.accept>;
474
+ return {
475
+ onOpen(_event, ws) {
476
+ handler = protocolListener.accept({
477
+ send: (data) => ws.send(data as ArrayBuffer),
478
+ close: () => ws.close(),
479
+ get readyState() {
480
+ return ws.readyState;
481
+ },
482
+ });
483
+ },
484
+ onMessage(event) {
485
+ const data = event.data;
486
+ if (typeof data === 'string') return;
487
+ if (data instanceof ArrayBuffer) handler?.onData(new Uint8Array(data));
488
+ else if (ArrayBuffer.isView(data)) {
489
+ handler?.onData(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
490
+ }
491
+ },
492
+ onClose() {
493
+ handler?.onClose();
494
+ handler = undefined;
495
+ },
496
+ onError(event) {
497
+ handler?.onError(event instanceof Error ? event : new Error('The protocol socket failed'));
498
+ handler = undefined;
499
+ },
500
+ };
501
+ }),
502
+ );
503
+
504
+ app.get('*', (context) => {
505
+ const requested = new URL(context.req.url).pathname;
506
+ const direct = requested === '/' ? undefined : resolveAssetPath(assetsDir, requested);
507
+ const body = direct ? readAsset(direct) : undefined;
508
+ if (direct && body) {
509
+ return context.body(new Uint8Array(body), 200, { 'Content-Type': contentTypeFor(direct) });
510
+ }
511
+ const index = readAsset(path.join(assetsDir, INDEX_FILE));
512
+ if (!index) return context.text('The cockpit bundle is missing. Run the package build.', 500);
513
+ return context.body(new Uint8Array(index), 200, { 'Content-Type': 'text/html; charset=utf-8' });
514
+ });
515
+
516
+ // A rebuilt bundle only changes on disk, so the page it replaced has to be
517
+ // told; nothing about a loaded bundle notices that its source moved.
518
+ syncGuard.watch(() => {
519
+ for (const post of pages) post({ type: HUB_RESYNCED_TYPE });
520
+ });
521
+
522
+ return new Promise<WebServer>((resolve, reject) => {
523
+ const server = serve({ fetch: app.fetch, port: options.port, hostname: host }, (info) => {
524
+ nodeWs.injectWebSocket(server);
525
+ const url = `http://${host}:${info.port}`;
526
+ notice(`cockpit on ${url}`);
527
+ resolve({
528
+ url,
529
+ port: info.port,
530
+ close: () =>
531
+ new Promise<void>((done) => {
532
+ threads.close();
533
+ syncGuard.close();
534
+ void protocolServer.close();
535
+ hub.close();
536
+ providerAuth.close();
537
+ for (const handler of pluginApis) handler.close();
538
+ // An upgraded socket leaves the HTTP server's connection tracking,
539
+ // so only the WebSocket server can let go of it. Without this the
540
+ // close callback waits on a browser that has no reason to leave.
541
+ for (const client of nodeWs.wss.clients) client.terminate();
542
+ nodeWs.wss.close();
543
+ (server as { closeAllConnections?: () => void }).closeAllConnections?.();
544
+ server.close(() => done());
545
+ }),
546
+ });
547
+ });
548
+ server.once('error', (error) => {
549
+ threads.close();
550
+ hub.close();
551
+ providerAuth.close();
552
+ for (const handler of pluginApis) handler.close();
553
+ reject(error);
554
+ });
555
+ });
556
+ }
@@ -0,0 +1,27 @@
1
+ const HEALTH_ROUTE = '/api/health';
2
+ const HUB_ROLE = 'hub';
3
+ const PROBE_TIMEOUT_MS = 1000;
4
+
5
+ /**
6
+ * True when a DoomPi cockpit hub already answers on the address.
7
+ *
8
+ * The cockpit is a multi-session hub, so a second one is never what someone
9
+ * wants: running `doompi-web` again should hand back the running cockpit
10
+ * rather than fail on a port clash. Anything else answering the port is not a
11
+ * hub and must not be mistaken for one, which is why the role is checked and
12
+ * not just the status code.
13
+ */
14
+ export async function hubAnswers(host: string, port: number): Promise<boolean> {
15
+ try {
16
+ const response = await fetch(`http://${host}:${String(port)}${HEALTH_ROUTE}`, {
17
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
18
+ });
19
+ if (!response.ok) return false;
20
+ const body = (await response.json()) as { ok?: boolean; role?: string };
21
+ return body.ok === true && body.role === HUB_ROLE;
22
+ } catch {
23
+ // Nothing listening, a timeout, or a body that is not the hub's: all mean
24
+ // "no hub here", which is the caller's cue to start one.
25
+ return false;
26
+ }
27
+ }
@@ -0,0 +1,60 @@
1
+ import http from 'node:http';
2
+ import { Readable } from 'node:stream';
3
+
4
+ /** Headers that describe one hop and must not be copied onto the next. */
5
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade']);
6
+
7
+ export interface ProxyToSocketInput {
8
+ socketPath: string;
9
+ /** Path and query as the upstream should see them. */
10
+ path: string;
11
+ method: string;
12
+ headers: Headers;
13
+ body: BodyInit | null;
14
+ }
15
+
16
+ function outgoingHeaders(headers: Headers): Record<string, string> {
17
+ const out: Record<string, string> = {};
18
+ headers.forEach((value, key) => {
19
+ if (!HOP_BY_HOP.has(key.toLowerCase())) out[key] = value;
20
+ });
21
+ // The upstream is a unix socket with no meaningful authority; a stable value
22
+ // keeps its own URL parsing predictable.
23
+ out.host = 'session.local';
24
+ return out;
25
+ }
26
+
27
+ /**
28
+ * Forwards one request to a session server's API socket and returns its answer.
29
+ *
30
+ * The response body is handed back as a stream rather than buffered, so a
31
+ * server-sent event stream reaches the browser as it is produced instead of
32
+ * arriving all at once when the runner finally exits.
33
+ */
34
+ export async function proxyToSocket(input: ProxyToSocketInput): Promise<Response> {
35
+ return new Promise<Response>((resolve, reject) => {
36
+ const request = http.request(
37
+ { socketPath: input.socketPath, path: input.path, method: input.method, headers: outgoingHeaders(input.headers) },
38
+ (incoming) => {
39
+ const headers = new Headers();
40
+ for (const [key, value] of Object.entries(incoming.headers)) {
41
+ if (value === undefined || HOP_BY_HOP.has(key.toLowerCase())) continue;
42
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value);
43
+ }
44
+ const status = incoming.statusCode ?? 502;
45
+ // 204 and 304 carry no body, and constructing one with a stream throws.
46
+ const body = status === 204 || status === 304 ? null : (Readable.toWeb(incoming) as ReadableStream);
47
+ resolve(new Response(body, { status, headers }));
48
+ },
49
+ );
50
+ request.once('error', reject);
51
+ if (input.body === null) {
52
+ request.end();
53
+ return;
54
+ }
55
+ void new Response(input.body)
56
+ .arrayBuffer()
57
+ .then((buffer) => request.end(Buffer.from(buffer)))
58
+ .catch(reject);
59
+ });
60
+ }