@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51

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 (368) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +3 -0
  50. package/common/managed/finding-detail.js +66 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +7 -0
  54. package/common/managed/npm-packages.js +51 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2K6UN3PC.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +364 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +23 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +76 -0
  237. package/server-managed/comparison.ts +52 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +468 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +143 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +376 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +136 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5503 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -2,15 +2,16 @@
2
2
  // vars, the optional config.json) into one immutable `Config`, failing
3
3
  // loud on malformed values so a typo surfaces at startup rather than
4
4
  // deep in `node:net` / at the first token verification. Pure parsing —
5
- // no backends opened, no crypto keys derived, no side effects beyond
6
- // `--help` / fail-fast `process.exit`. index.ts destructures the
7
- // result and does the wiring (backend selection, password HMAC, …).
5
+ // no backends opened or process termination. Invalid configuration throws so
6
+ // embedding hosts can recover; app.ts handles backend selection and wiring.
8
7
 
9
8
  import { Buffer } from 'node:buffer'
10
9
  import { readFileSync } from 'node:fs'
11
- import { argv, env } from 'node:process'
10
+ import { env } from 'node:process'
12
11
  import { fileURLToPath } from 'node:url'
13
12
  import { dirname, join } from 'node:path'
13
+ import { configuredScanServer } from '../server-common/scan-config.ts'
14
+ import { databaseUrls } from '../server-common/database-config.ts'
14
15
 
15
16
  export type Config = {
16
17
  port: number
@@ -26,9 +27,11 @@ export type Config = {
26
27
  tokenSecret: Uint8Array<ArrayBuffer> | null
27
28
  password: string | null
28
29
  trustProxyEnv: string | undefined
30
+ deepviewScanServer: string | null
31
+ githubNewIssueLabels?: string
29
32
  }
30
33
 
31
- // Parse + range-validate an integer env var, exiting with a clear
34
+ // Parse + range-validate an integer env var, throwing a clear
32
35
  // up-front message on a malformed value — a NaN from `Number("abc")`
33
36
  // otherwise surfaces as a confusing crash deep inside `node:net`
34
37
  // (`WebSocketServer({ port: NaN })`) or a 0-ms `setInterval` loop. An
@@ -39,20 +42,32 @@ function intEnv(name: string, def: number, min: number, max: number, hint = ''):
39
42
  const raw = env[name]
40
43
  const n = raw == null ? def : Number(raw)
41
44
  if (!Number.isSafeInteger(n) || n < min || n > max) {
42
- console.error(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].${hint ? ` ${hint}` : ''}`)
43
- process.exit(1)
45
+ throw new Error(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].${hint ? ` ${hint}` : ''}`)
44
46
  }
45
47
  return n
46
48
  }
47
49
 
48
- const HELP = `Usage: node server/index.ts
50
+ export const HELP = `Usage: node server-e2e/index.ts
49
51
  Environment:
50
52
  PORT listen port (default 8765)
51
53
  HOST bind host (default 127.0.0.1)
52
- DB_PATH sqlite file (default: server/data/data.db);
53
- ignored when DATABASE_URL is set
54
- DATABASE_URL Neon Postgres connection string; if set,
55
- selects the Neon backend instead of
54
+ DEEPVIEW_SCAN_SERVER optional HTTP(S) scan-service URL; advertised
55
+ in /api/config and allowed by the UI CSP.
56
+ Unset by default (no external scan service).
57
+ GITHUB_NEW_ISSUE_LABELS optional comma-separated labels for the client's
58
+ prefilled GitHub issue form, in addition to
59
+ deepview and security (for security findings).
60
+ Advertised in /api/config and sync connections.
61
+ DB_PATH sqlite file (default: server-e2e/data/data.db);
62
+ ignored when an e2e database URL is set
63
+ DATABASE_URL shared Neon Postgres URL for enabled modes.
64
+ Cannot be combined with E2E_DATABASE_URL
65
+ or MANAGED_DATABASE_URL.
66
+ E2E_DATABASE_URL e2e-only Neon URL, used without DATABASE_URL.
67
+ Combined mode also requires
68
+ MANAGED_DATABASE_URL; mixing Neon and
69
+ SQLite backends is rejected.
70
+ Either e2e URL selects Neon instead of
56
71
  SQLite. Requires the optional peer dep
57
72
  @neondatabase/serverless. The Neon
58
73
  pairing additionally requires
@@ -61,12 +76,12 @@ Environment:
61
76
  local-FS bytes cannot back a multi-
62
77
  replica DB plane.
63
78
  BLOB_READ_WRITE_TOKEN Vercel Blob R/W token (private store).
64
- Required when DATABASE_URL is set;
79
+ Required when an e2e database URL is set;
65
80
  ignored otherwise. Requires the optional
66
81
  peer dep @vercel/blob.
67
82
  OBJSTORE_TOKEN_SECRET Base64 (32 bytes) HMAC secret for REST
68
- bearer tokens. REQUIRED when DATABASE_URL
69
- is set (multi-replica deployments: a
83
+ bearer tokens. REQUIRED in Neon mode
84
+ (multi-replica deployments: a
70
85
  token minted on one replica's WS plane
71
86
  must validate on another replica's REST
72
87
  plane). Optional under SQLite (a fresh
@@ -76,16 +91,15 @@ Environment:
76
91
  OBJSTORE_DIR object store root (default: ./objstore
77
92
  next to DB_PATH). Used by the local-FS
78
93
  byte plane only; ignored when
79
- DATABASE_URL + BLOB_READ_WRITE_TOKEN
80
- are set (bytes live in Vercel Blob).
94
+ an e2e database URL selects Neon
95
+ (bytes live in Vercel Blob).
81
96
  OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
82
- OBJSTORE_REAP_DISABLED set '1' / 'true' to disable the orphan
83
- reaper ENTIRELY — no boot sweep, no
84
- periodic GC. Orphaned/superseded blobs and
85
- stale staging rows then accumulate
86
- unbounded; only set this if an external job
87
- handles GC (e.g. a cron calling reapOrphans).
88
- Default OFF (reaper runs).
97
+ OBJSTORE_REAP_DISABLED set '1' / 'true' to disable automatic orphan
98
+ sweeps (boot and periodic). Explicit /api/reap
99
+ remains available. Schedule cleanup externally
100
+ to avoid accumulating orphaned bytes.
101
+ CRON_SECRET bearer secret for GET /api/reap (fails closed
102
+ when unset). Runs cleanup for enabled modes.
89
103
  TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
90
104
  Host / X-Forwarded-Proto when computing
91
105
  the same-origin gate's expected origin.
@@ -103,7 +117,7 @@ Environment:
103
117
  that need to deterministically
104
118
  exercise the cap.
105
119
  CONFIG_PATH operator config JSON path (default:
106
- server/config.json). Currently the
120
+ server-e2e/config.json). Currently the
107
121
  only field is { "password": "..." }
108
122
  which gates first-action creation of
109
123
  a new workspace on the
@@ -121,11 +135,11 @@ function readServerConfigFile(path: string): ServerConfigFile {
121
135
  let raw: string
122
136
  try { raw = readFileSync(path, 'utf8') } catch (err) {
123
137
  if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return {}
124
- console.error(`Failed to read ${path}:`, (err as Error)?.message ?? err); process.exit(1)
138
+ throw new Error(`Failed to read ${path}: ${(err as Error)?.message ?? err}`, { cause: err })
125
139
  }
126
140
  try { return JSON.parse(raw) as ServerConfigFile }
127
141
  catch (err) {
128
- console.error(`Failed to parse ${path} as JSON:`, (err as Error)?.message ?? err); process.exit(1)
142
+ throw new Error(`Failed to parse ${path} as JSON: ${(err as Error)?.message ?? err}`, { cause: err })
129
143
  }
130
144
  }
131
145
 
@@ -140,20 +154,17 @@ function decodeTokenSecret(raw: string): Uint8Array<ArrayBuffer> {
140
154
  // typo-detector below would fail with a misleading message.
141
155
  const trimmed = raw.trim()
142
156
  if (trimmed.length === 0) {
143
- console.error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
144
- process.exit(1)
157
+ throw new Error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
145
158
  }
146
159
  const decoded = Buffer.from(trimmed, 'base64')
147
160
  const reencoded = decoded.toString('base64')
148
161
  const norm = (s: string): string => s.replace(/=+$/u, '')
149
162
  if (norm(reencoded) !== norm(trimmed)) {
150
- console.error('OBJSTORE_TOKEN_SECRET contains non-base64 characters (likely a typo, e.g. base64url chars in a base64 secret).')
151
- console.error('Regenerate with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'')
152
- process.exit(1)
163
+ throw new Error('OBJSTORE_TOKEN_SECRET contains non-base64 characters (likely a typo, e.g. base64url chars in a base64 secret).\n' +
164
+ 'Regenerate with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'')
153
165
  }
154
166
  if (decoded.byteLength !== 32) {
155
- console.error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
156
- process.exit(1)
167
+ throw new Error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
157
168
  }
158
169
  // Copy into a fresh ArrayBuffer so the type matches
159
170
  // `Uint8Array<ArrayBuffer>` (Buffer may be SharedArrayBuffer-backed).
@@ -171,7 +182,7 @@ export function loadConfig(): Config {
171
182
  const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
172
183
  // No practical upper bound beyond the safe-integer range.
173
184
  const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
174
- // Hard off-switch for the orphan reaper (both the boot sweep AND the
185
+ // Off-switch for automatic orphan reaping (both the boot sweep AND the
175
186
  // periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
176
187
  // else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
177
188
  const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
@@ -182,7 +193,7 @@ export function loadConfig(): Config {
182
193
  const serverConfig = readServerConfigFile(configPath)
183
194
  const rawPassword = serverConfig.password
184
195
  if (rawPassword != null && typeof rawPassword !== 'string') {
185
- console.error(`Invalid ${configPath}: "password" must be a string or null`); process.exit(1)
196
+ throw new Error(`Invalid ${configPath}: "password" must be a string or null`)
186
197
  }
187
198
  const password = rawPassword ?? null
188
199
  // Upper bound 65_536 — bounds memory under hostile load; a deployer
@@ -191,12 +202,7 @@ export function loadConfig(): Config {
191
202
  // error-precedence order.
192
203
  const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
193
204
 
194
- if (argv.includes('--help') || argv.includes('-h')) {
195
- console.log(HELP)
196
- process.exit(0)
197
- }
198
-
199
- const neonUrl = env['DATABASE_URL'] ?? null
205
+ const neonUrl = databaseUrls().e2e
200
206
  const blobToken = env['BLOB_READ_WRITE_TOKEN'] ?? null
201
207
  const tokenSecretB64 = env['OBJSTORE_TOKEN_SECRET'] ?? null
202
208
  const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
@@ -205,5 +211,7 @@ export function loadConfig(): Config {
205
211
  port, host, dbPath, objstoreDir, reapIntervalMs, reapDisabled, maxInflightPerSocket,
206
212
  debug, neonUrl, blobToken, tokenSecret, password,
207
213
  trustProxyEnv: env['TRUST_PROXY'],
214
+ deepviewScanServer: configuredScanServer(env['DEEPVIEW_SCAN_SERVER']),
215
+ githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
208
216
  }
209
217
  }
@@ -4,7 +4,7 @@
4
4
  // dialect on the wire.
5
5
  //
6
6
  // `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
7
- // the `DATABASE_URL` branch in `server/index.ts`. The peer dep
7
+ // the configured database URL branch in `server-e2e/index.ts`. The peer dep
8
8
  // itself is loaded lazily via the dynamic `import()` inside
9
9
  // `openNeonDb` below, so a SQLite-only deployment never installs it
10
10
  // (`autoInstallPeers: false` in `pnpm-workspace.yaml`) and never
@@ -340,7 +340,7 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
340
340
  // in-process Postgres (PGlite) via `mock.module`: that hook can only
341
341
  // intercept a specifier it can RESOLVE, and the optional peer dep
342
342
  // isn't installed in a SQLite-only checkout. The wrapper path always
343
- // resolves — see `server/neon-driver.ts`. Cast through `unknown`
343
+ // resolves — see `server-e2e/neon-driver.ts`. Cast through `unknown`
344
344
  // because the wrapper's `export *` re-exports a `@ts-ignore`'d
345
345
  // (possibly-absent) module, so tsc can't see `neon`'s type here.
346
346
  const mod = (await import('./neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
@@ -106,7 +106,7 @@ export const CHAIN_FROM_SQL =
106
106
  export const REVISION_EXISTS_SQL =
107
107
  `SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
108
108
  // Fetch a single revision row by content-addressed id. The cross-instance
109
- // pubsub (server/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
109
+ // pubsub (server-e2e/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
110
110
  // because the full `workspace-state` broadcast envelope is bounded by
111
111
  // `MAX_CIPHERTEXT_LEN` (2 MiB) and Postgres NOTIFY caps payloads at ~8 KB.
112
112
  // The receiver re-fetches the row from this shared table to construct the
@@ -1,5 +1,5 @@
1
- // Shared async-statement primitives. Both `server/db.ts` (workspace_revision
2
- // chain) and `server/objstore/store.ts` (objstore tables) expose Handles
1
+ // Shared async-statement primitives. Both `server-e2e/db.ts` (workspace_revision
2
+ // chain) and `server-e2e/objstore/store.ts` (objstore tables) expose Handles
3
3
  // whose statements look like `{ get(...) → Promise<…>, all(...) → Promise<[…]>,
4
4
  // run(...) → Promise<void> }`. The underlying `node:sqlite` driver is
5
5
  // synchronous; the wrappers below catch sync errors and route them through
@@ -13,7 +13,7 @@
13
13
  // `keyframe` is `1` for a revision the client emits with full state
14
14
  // baked in (rather than a delta). The wire flag is covered by the
15
15
  // signature, so the column value MUST match the signed canonical
16
- // bytes: `canonicalSave` (server/sign.ts, via `handleSave`) encodes
16
+ // bytes: `canonicalSave` (server-e2e/sign.ts, via `handleSave`) encodes
17
17
  // `keyframe ? '1' : ''` into the bytes `verifyEd25519` checks, so a
18
18
  // mismatched wire flag fails verify and never reaches this column.
19
19
  // Client-driven: the server stores what the client sent and treats
@@ -84,7 +84,7 @@ const SCHEMA = `
84
84
 
85
85
  // Row shape from the chain queries. `keyframe` is stored as INTEGER
86
86
  // (0 / 1); the raw row carries the integer — `chainForWire` in
87
- // server/index.ts normalises to a strict boolean before broadcasting.
87
+ // server-e2e/index.ts normalises to a strict boolean before broadcasting.
88
88
  // `base` is nullable on the very first revision.
89
89
  export type RevisionRow = {
90
90
  base: string | null
@@ -125,8 +125,8 @@ export type CommitResult =
125
125
  // (`./db-neon.ts`) constructs a Handle with `db` unset. Callers that
126
126
  // reach into `db` directly (e.g. `openObjstore`, test-only fixture
127
127
  // SQL) are SQLite-coupled by construction; passing them a Neon-backed
128
- // Handle is the operator's mistake to catch at the `if (DATABASE_URL)`
129
- // switch in `server/index.ts`.
128
+ // Handle is the operator's mistake to catch at the database URL
129
+ // switch in `server-e2e/index.ts`.
130
130
  //
131
131
  // `tryCommit` is the backend-specific atomic-commit primitive
132
132
  // `commitRevision` dispatches through. SQLite runs one synchronous
@@ -154,7 +154,7 @@ export type Handle = {
154
154
  revisionExists: GetStmt<[string, string], unknown>
155
155
  // Single-revision fetch by content-addressed id. The cross-instance
156
156
  // pubsub receiver uses this to assemble a `workspace-state` from a
157
- // NOTIFY hint (see `server/pubsub.ts`).
157
+ // NOTIFY hint (see `server-e2e/pubsub.ts`).
158
158
  revisionById: GetStmt<[string, string], RevisionRow>
159
159
  gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
160
160
  tryCommit: (input: RevisionInsert) => Promise<CommitResult>
@@ -164,11 +164,11 @@ export type Handle = {
164
164
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
165
165
  // set. `openDb` returns this so call sites needing direct
166
166
  // `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
167
- // `server/index.ts`'s SQLite branch) reach `handle.db` without an
167
+ // `server-e2e/index.ts`'s SQLite branch) reach `handle.db` without an
168
168
  // optional-chain or non-null assertion. A Neon-backed Handle
169
169
  // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape, so routing
170
170
  // one into a SQLite-coupled call site is a compile-time error. Mirrors
171
- // `server/objstore/store.ts`.
171
+ // `server-e2e/objstore/store.ts`.
172
172
  export type SqliteHandle = Handle & { db: DatabaseSync }
173
173
 
174
174
  export function openDb(path: string): SqliteHandle {
@@ -10,6 +10,7 @@ import { type IncomingMessage as HttpRequest, type Server, type ServerResponse,
10
10
  import { Buffer } from 'node:buffer'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import type { WebSocketServer } from 'ws'
13
+ import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
13
14
  import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
14
15
  import { SSE_OPEN_PATH, type SseServer } from './sse-server.ts'
15
16
  import { dispatchNpmAdvisories } from './npm-proxy.ts'
@@ -25,7 +26,7 @@ export const WS_UPGRADE_PATH = '/api/sync'
25
26
  // here commits + broadcasts WITHOUT taking over the client's SSE event-stream
26
27
  // (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
27
28
  // the WS upgrade path so one nginx `/api/*` block routes both.
28
- export const SAVE_REST_PATH = '/api/sync/save'
29
+ const SAVE_REST_PATH = '/api/sync/save'
29
30
  function isUpgradePath(url: string | undefined): boolean {
30
31
  if (typeof url !== 'string') return false
31
32
  // Strip `?…` so clients can carry build / debug tags. Exact match
@@ -40,6 +41,10 @@ type HasHeaders = { headers: HttpRequest['headers'] }
40
41
  export type HttpServerDeps = {
41
42
  wss: WebSocketServer
42
43
  restDeps: ObjstoreRestDeps
44
+ // This server's `server-info` (mode advertisement), served as JSON at
45
+ // GET /api/config so a client can detect the protocol without a sync
46
+ // connection (the WS connect frame stays the source of truth).
47
+ serverInfo: ServerInfo
43
48
  // SSE+POST fallback. Owns its own session map + lifecycle; we just
44
49
  // give it first crack at requests that match `SSE_OPEN_PATH`. Same
45
50
  // same-origin / shutdown gates run BEFORE the dispatch so the SSE
@@ -54,6 +59,9 @@ export type HttpServerDeps = {
54
59
  handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
55
60
  restPutIdleTimeoutMs: number
56
61
  debug: boolean
62
+ // Serve the static UI bundle (default). False serves only the API and
63
+ // never reads `out/`, for hosts that serve the UI themselves.
64
+ ui?: boolean
57
65
  }
58
66
 
59
67
  // `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
@@ -109,14 +117,27 @@ function dispatchSaveRest(
109
117
  }
110
118
 
111
119
  export function createHttpServer(deps: HttpServerDeps): Server {
112
- const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
120
+ const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug, ui = true } = deps
113
121
  // Static-file plane (see ./static.ts). The directory is the
114
122
  // `build.js build` output sibling to this file; the loader handles
115
123
  // enumeration, pre-compression, and ETag derivation. Plugged in after
116
124
  // the `/api/objstore/...` REST branch.
117
- const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
125
+ const handleStatic = ui ? loadStatic(fileURLToPath(new URL('../out', import.meta.url)), serverInfo.deepviewScanServer ?? null) : () => false
118
126
 
119
127
  const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
128
+ // Static mode probe: GET /api/config → this server's `server-info` as JSON.
129
+ // A client uses it to detect the protocol up front; the WS connect frame
130
+ // stays the source of truth and catches a later mode change. Public, no body.
131
+ if (typeof req.url === 'string' && req.url.split('?', 1)[0] === CONFIG_PATH) {
132
+ if (req.method !== 'GET') {
133
+ res.writeHead(405, { 'content-type': 'application/json', allow: 'GET' })
134
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
135
+ return
136
+ }
137
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
138
+ res.end(JSON.stringify(serverInfo))
139
+ return
140
+ }
120
141
  // SSE+POST fallback plane. Same-origin gate first (Origin header
121
142
  // is set by the browser on cross-origin EventSource + fetch, so
122
143
  // a hostile origin would surface here just like it does on the
@@ -156,12 +177,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
156
177
  // REST handlers go through a separate path and must mirror it.
157
178
  // Without this, a REST PUT arriving on an existing keep-alive
158
179
  // socket AFTER SIGTERM but BEFORE `httpServer.close()` finishes
159
- // draining could land in `withCommitLock`, acquire a lease, and
160
- // finish its `finally { release() }` AFTER the shutdown's
161
- // `heldLeaseCount` snapshot — leaving an orphan lock row that pins
162
- // the key until TTL expiry. The 503 + `shutting-down` reason tells
163
- // the client to retry against a different replica. Transport
164
- // audit + multi-replica shutdown ordering review.
180
+ // draining could start a fresh upload + commit for the drain to
181
+ // outwait — or, landing after the drain's in-flight snapshot
182
+ // (lifecycle.ts `drain`), run on against the closed DB, the race
183
+ // ws-server.ts gates on the WS plane. The 503 + `shutting-down`
184
+ // reason tells the client to retry against a different replica.
185
+ // Transport audit + multi-replica shutdown ordering review.
165
186
  if (isShuttingDown()) {
166
187
  res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
167
188
  res.end(JSON.stringify({ error: 'shutting-down' }))
@@ -171,7 +192,7 @@ export function createHttpServer(deps: HttpServerDeps): Server {
171
192
  // that holds a valid token (e.g. via XSS that read a freshly-minted
172
193
  // one) would PUT with its own Origin header — caught here.
173
194
  // Same-origin XHR/fetch may omit Origin; that path is allowed (see
174
- // `isOriginAllowed`). Transport audit `server/objstore/rest.ts:103`.
195
+ // `isOriginAllowed`). Transport audit `server-e2e/objstore/rest.ts:103`.
175
196
  if (!isOriginAllowed(req)) {
176
197
  res.writeHead(403, { 'content-type': 'application/json' })
177
198
  res.end(JSON.stringify({ error: 'origin-denied' }))
@@ -183,7 +204,7 @@ export function createHttpServer(deps: HttpServerDeps): Server {
183
204
  // blob within its declared Content-Length; POST carries the small
184
205
  // fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
185
206
  // destroy the request, aborting the body pipeline.
186
- // Transport audit `server/objstore/rest.ts:218`.
207
+ // Transport audit `server-e2e/objstore/rest.ts:218`.
187
208
  if (req.method === 'PUT' || req.method === 'POST') {
188
209
  req.setTimeout(restPutIdleTimeoutMs, () => {
189
210
  if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
@@ -212,7 +233,19 @@ export function createHttpServer(deps: HttpServerDeps): Server {
212
233
  res.end(NOT_FOUND_BODY)
213
234
  })
214
235
 
236
+ installUpgradeHandler(httpServer, wss, isOriginAllowed)
237
+
238
+ return httpServer
239
+ }
240
+
241
+ function installUpgradeHandler(httpServer: Server, wss: WebSocketServer, isOriginAllowed: HttpServerDeps['isOriginAllowed']): void {
215
242
  httpServer.on('upgrade', (req, socket, head) => {
243
+ // Combined deployments must not let a managed share capability enter the
244
+ // unscoped sync transport, which bypasses the normal HTTP router.
245
+ if (req.headers['x-deepview-share'] !== undefined) {
246
+ socket.end('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
247
+ return
248
+ }
216
249
  // RFC 6455: the WS upgrade IS an HTTP request; reject with a normal
217
250
  // HTTP response so a misconfigured client sees the JSON body instead
218
251
  // of ECONNRESET. `socket.end(body)` flushes before sending FIN.
@@ -243,6 +276,4 @@ export function createHttpServer(deps: HttpServerDeps): Server {
243
276
  }
244
277
  wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) })
245
278
  })
246
-
247
- return httpServer
248
279
  }
@@ -7,9 +7,13 @@
7
7
  // testable and reasoned about on its own.
8
8
 
9
9
  import type { WebSocket } from 'ws'
10
+ import { Buffer } from 'node:buffer'
10
11
  import type { PeerRegistry } from './peer.ts'
11
12
 
12
13
  export type Hub = {
14
+ // Buffer broadcasts during async subscription snapshots. The returned
15
+ // release flushes them after the snapshot/chain replies, preserving order.
16
+ pauseBroadcasts(socket: WebSocket): () => void
13
17
  subscribe(socket: WebSocket, tag: string): void
14
18
  unsubscribeAll(socket: WebSocket): void
15
19
  send(socket: WebSocket, msg: object): void
@@ -21,13 +25,13 @@ export type Hub = {
21
25
  // WS-originated broadcasts pass the originator so it doesn't see its
22
26
  // own message echoed back. Local-only: callers that ALSO want a
23
27
  // cross-instance fan-out are expected to publish to the pubsub bus
24
- // alongside this call (server/pubsub.ts). The hub deliberately stays
28
+ // alongside this call (server-e2e/pubsub.ts). The hub deliberately stays
25
29
  // ignorant of the bus so its transport invariants (backpressure cap,
26
30
  // stringify-once, terminate-on-overflow) are unchanged.
27
31
  broadcast(tag: string, msg: object, except: WebSocket | null): void
28
32
  // Broadcasts an ALREADY-SERIALISED payload to every local subscriber
29
33
  // for `tag`. Used by the pubsub bus receiver
30
- // (server/bus-receiver.ts, wired up from server/index.ts) to relay
34
+ // (server-e2e/bus-receiver.ts, wired up from server-e2e/index.ts) to relay
31
35
  // a remote-instance event into this instance's fan-out. No `except`:
32
36
  // the originator is on a different instance by construction.
33
37
  broadcastLocalRaw(tag: string, payload: string): void
@@ -38,18 +42,34 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
38
42
  // workspaceTag → Set<WebSocket>. The per-socket reverse index lives on
39
43
  // `Peer.tags` (see ./peer.ts) and is read by `unsubscribeAll` on close.
40
44
  const subscribers = new Map<string, Set<WebSocket>>()
45
+ const paused = new WeakMap<WebSocket, { depth: number; payloads: string[]; bytes: number }>()
46
+
47
+ function pauseBroadcasts(socket: WebSocket): () => void {
48
+ const pending = paused.get(socket) ?? { depth: 0, payloads: [], bytes: 0 }
49
+ pending.depth++
50
+ paused.set(socket, pending)
51
+ let released = false
52
+ return () => {
53
+ if (released) return
54
+ released = true
55
+ if (--pending.depth > 0 || paused.get(socket) !== pending) return
56
+ paused.delete(socket)
57
+ for (const payload of pending.payloads) sendRaw(socket, payload)
58
+ pending.payloads.length = 0
59
+ }
60
+ }
41
61
 
42
62
  function subscribe(socket: WebSocket, tag: string): void {
43
63
  let set = subscribers.get(tag)
44
- if (!set) {
45
- set = new Set()
46
- subscribers.set(tag, set)
47
- }
64
+ if (!set) { set = new Set(); subscribers.set(tag, set) }
48
65
  set.add(socket)
49
66
  peers.get(socket)?.tags.add(tag)
50
67
  }
51
68
 
52
69
  function unsubscribeAll(socket: WebSocket): void {
70
+ const pending = paused.get(socket)
71
+ if (pending) pending.payloads.length = 0
72
+ paused.delete(socket)
53
73
  const tags = peers.get(socket)?.tags
54
74
  if (!tags) return
55
75
  for (const tag of tags) {
@@ -71,7 +91,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
71
91
  // kernel yet — a slow / blackholed peer accumulates them unboundedly
72
92
  // during fan-out broadcasts. Drop above the cap and terminate the
73
93
  // socket so the heartbeat doesn't keep it alive on ping/pong while
74
- // every broadcast piles up. Transport audit `server/index.ts:225`.
94
+ // every broadcast piles up. Transport audit `server-e2e/index.ts:225`.
75
95
  if (socket.bufferedAmount > maxBufferedBytes) {
76
96
  if (debug) console.warn(`drop broadcast: socket buffered ${socket.bufferedAmount}B > cap`)
77
97
  try { socket.terminate() } catch {}
@@ -102,6 +122,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
102
122
  }
103
123
 
104
124
  function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
125
+ const payloadBytes = Buffer.byteLength(payload)
105
126
  // Snapshot before iterating — a socket transitioning to CLOSED
106
127
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
107
128
  // mutating `set` while we walk it. The snapshot also keeps a future
@@ -109,9 +130,19 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
109
130
  // subscribers. Audit M4 round-3.
110
131
  for (const s of [...set]) {
111
132
  if (s === except) continue
112
- sendRaw(s, payload)
133
+ const pending = paused.get(s)
134
+ if (!pending) { sendRaw(s, payload); continue }
135
+ pending.bytes += payloadBytes
136
+ if (pending.bytes > maxBufferedBytes) {
137
+ // Snapshot waits must obey the same memory bound as socket writes.
138
+ pending.payloads.length = 0
139
+ paused.delete(s)
140
+ try { s.terminate() } catch {}
141
+ continue
142
+ }
143
+ pending.payloads.push(payload)
113
144
  }
114
145
  }
115
146
 
116
- return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
147
+ return { pauseBroadcasts, subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
117
148
  }
@@ -0,0 +1,17 @@
1
+ // Compatibility entry point for the published e2e server. Importing exposes
2
+ // one unbound instance; the combined launcher uses the factory directly.
3
+ import { startServer } from '../server-common/standalone.ts'
4
+ import { createE2eApp } from './app.ts'
5
+ import { HELP, loadConfig } from './config.ts'
6
+
7
+ if (import.meta.main && (process.argv.includes('--help') || process.argv.includes('-h'))) {
8
+ console.log(HELP)
9
+ process.exit(0)
10
+ }
11
+
12
+ const config = loadConfig()
13
+ export const { httpServer, wss, isShuttingDown, reap, handleRequest, onShutdown } = await createE2eApp(config)
14
+
15
+ export function start(): void { startServer(httpServer, config) }
16
+
17
+ if (import.meta.main) start()
@@ -0,0 +1,95 @@
1
+ // Per-instance request tracking and graceful disposal. Process shutdown belongs
2
+ // to the standalone launcher; embedded hosts retain their signals and errors.
3
+ import type { Server } from 'node:http'
4
+ import type { WebSocketServer } from 'ws'
5
+ import type { SseSession } from './sse-session.ts'
6
+
7
+ export type ShutdownDeps = {
8
+ httpServer: Server
9
+ wss: WebSocketServer
10
+ heartbeatTimer: ReturnType<typeof setInterval>
11
+ sseKeepaliveTimer: ReturnType<typeof setInterval>
12
+ stopReaper: () => Promise<void>
13
+ sseSessions: () => Iterable<SseSession>
14
+ closeDb: () => Promise<void>
15
+ }
16
+
17
+ export type Lifecycle = {
18
+ track: (promise: Promise<unknown>) => void
19
+ isShuttingDown: () => boolean
20
+ install: (deps: ShutdownDeps) => void
21
+ }
22
+
23
+ function closePeers({ wss, sseSessions }: ShutdownDeps): void {
24
+ for (const peer of [...wss.clients, ...sseSessions()]) {
25
+ try { peer.close(1001, 'Server shutting down') } catch {}
26
+ }
27
+ }
28
+
29
+ function terminatePeers({ httpServer, wss, sseSessions }: ShutdownDeps): void {
30
+ for (const peer of [...wss.clients, ...sseSessions()]) {
31
+ if (peer.readyState === peer.OPEN || peer.readyState === peer.CLOSING) {
32
+ try { peer.terminate() } catch {}
33
+ }
34
+ }
35
+ httpServer.closeAllConnections()
36
+ }
37
+
38
+ async function drain(deps: ShutdownDeps, inFlight: Set<Promise<unknown>>): Promise<void> {
39
+ const { httpServer, wss, stopReaper, closeDb } = deps
40
+ // Bound unresponsive WS/SSE peers and HTTP keep-alives to a one-second
41
+ // grace period. Also works when the listeners are mounted on another host.
42
+ const terminateTimer = setTimeout(() => terminatePeers(deps), 1000)
43
+ terminateTimer.unref()
44
+ try {
45
+ await stopReaper()
46
+ httpServer.closeIdleConnections()
47
+ if (httpServer.listening) {
48
+ await new Promise<void>(resolve => { httpServer.close(() => resolve()) })
49
+ }
50
+ await new Promise<void>(resolve => { wss.close(() => resolve()) })
51
+ if (inFlight.size > 0) await Promise.allSettled([...inFlight])
52
+ } finally {
53
+ clearTimeout(terminateTimer)
54
+ await closeDb()
55
+ }
56
+ }
57
+
58
+ export function createLifecycle(): Lifecycle {
59
+ const inFlight = new Set<Promise<unknown>>()
60
+ function track(promise: Promise<unknown>): void {
61
+ inFlight.add(promise)
62
+ promise.finally(() => inFlight.delete(promise)).catch(() => {})
63
+ }
64
+ let shuttingDown = false
65
+
66
+ function install(deps: ShutdownDeps): void {
67
+ const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer } = deps
68
+ let disposal: Promise<void> | undefined
69
+ function dispose(): Promise<void> {
70
+ if (!disposal) {
71
+ shuttingDown = true
72
+ clearInterval(heartbeatTimer)
73
+ clearInterval(sseKeepaliveTimer)
74
+ // Store the promise before any close/error callback can re-enter.
75
+ disposal = Promise.resolve().then(() => drain(deps, inFlight)).finally(() => {
76
+ httpServer.off('close', onClose)
77
+ httpServer.off('error', onClose)
78
+ wss.off('error', onWsError)
79
+ })
80
+ closePeers(deps)
81
+ }
82
+ return disposal
83
+ }
84
+ function onClose(): void {
85
+ void dispose().catch(err => { console.error('Server cleanup failed:', err) })
86
+ }
87
+ function onWsError(err: Error): void { httpServer.emit('error', err) }
88
+ httpServer[Symbol.asyncDispose] = dispose
89
+ httpServer.once('close', onClose)
90
+ httpServer.on('error', onClose)
91
+ wss.on('error', onWsError)
92
+ }
93
+
94
+ return { track, isShuttingDown: () => shuttingDown, install }
95
+ }