@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40

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 (311) 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 +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -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 +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  // SQLite + filesystem-backed object store for the v1.objstore
2
- // protocol extension. Sibling of `server/db.ts`; shares the
2
+ // protocol extension. Sibling of `server-e2e/db.ts`; shares the
3
3
  // underlying DatabaseSync handle but keeps its own tables.
4
4
  //
5
5
  // workspace_object — one row per LIVE resource (no
@@ -150,7 +150,7 @@ export type DeleteResult =
150
150
  | { ok: true; deletedVersion: number }
151
151
  | { ok: false; reason: 'not-found' | 'conflict'; conflict?: ObjectRow }
152
152
 
153
- // Async statement shapes are shared with server/db.ts via
153
+ // Async statement shapes are shared with server-e2e/db.ts via
154
154
  // ../db-stmt.ts — same `.get/.all/.run` → Promise contract across
155
155
  // both planes.
156
156
 
@@ -186,7 +186,7 @@ export type Handle = {
186
186
  db?: DatabaseSync
187
187
  // Byte-plane backend (local FS or Vercel Blob). All bytes-side
188
188
  // operations go through this — there is no direct fs.* call in
189
- // store / rest / reaper. Selected at boot in server/index.ts.
189
+ // store / rest / reaper. Selected at boot in server-e2e/index.ts.
190
190
  blob: BlobBackend
191
191
  // Storage root for the FS backend — set only when `blob` was
192
192
  // constructed from `openFsBlobBackend(dir)`. Production code
@@ -247,7 +247,7 @@ export type Handle = {
247
247
  // returned. The reaper passes `Date.now() - stagingTtlMs` so the
248
248
  // index `workspace_object_staging_begun_at_idx` is used and the
249
249
  // sweep is O(stale-rows) instead of O(in-flight-uploads-cluster-
250
- // wide). DB-layout audit `server/objstore/store.ts:312`.
250
+ // wide). DB-layout audit `server-e2e/objstore/store.ts:312`.
251
251
  listAllStaging: AllStmt<[number], { workspace_tag: string; resource_tag: string; staging_id: string; begun_at: number }>
252
252
  listLiveTags: AllStmt<[], { workspace_tag: string }>
253
253
  countLive: GetStmt<[string], { c: number }>
@@ -255,7 +255,7 @@ export type Handle = {
255
255
 
256
256
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
257
257
  // to be set. `openObjstore` returns this so call sites (production
258
- // shutdown plumbing in `server/index.ts` + the entire SQLite-only
258
+ // shutdown plumbing in `server-e2e/index.ts` + the entire SQLite-only
259
259
  // test suite in `tests/server-objstore.test.js`) can reach
260
260
  // `handle.db.prepare(...)` without an optional-chain or non-null
261
261
  // assertion. A Neon-backed Handle (`openNeonObjstore`) keeps the
@@ -352,7 +352,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
352
352
  mkdirSync(dir, { recursive: true })
353
353
  db.exec(SCHEMA)
354
354
  // Fail-loud on a pre-existing non-STRICT table — same rationale as
355
- // server/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
355
+ // server-e2e/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
356
356
  // upgrade an existing non-STRICT table, and dropping strict type
357
357
  // affinity opens an operator-attack path. PR #4 review F3.
358
358
  for (const name of ['workspace_object', 'workspace_object_staging']) {
@@ -362,7 +362,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
362
362
  const blob = openFsBlobBackend(dir)
363
363
  // No `close` method on the returned Handle: the underlying
364
364
  // `DatabaseSync` is owned by the caller (in production, the
365
- // workspace_revision handle in `server/db.ts`, closed from
365
+ // workspace_revision handle in `server-e2e/db.ts`, closed from
366
366
  // `shutdown()`; tests close their own DB). A `close()` here would
367
367
  // mislead — it could only no-op or leak, never close the caller-
368
368
  // owned connection.
@@ -24,7 +24,7 @@
24
24
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
25
25
  import { Buffer } from 'node:buffer'
26
26
 
27
- export const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000
27
+ const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000
28
28
 
29
29
  export type PutTokenPayload = {
30
30
  op: 'put'
@@ -119,7 +119,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
119
119
  // different actual value, or — for `exp` — claim a
120
120
  // `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
121
121
  // the IEEE-754 boundary. Matches the codebase's other safe-int gates
122
- // (server/objstore/sign.ts, rest.ts, handlers.ts).
122
+ // (server-e2e/objstore/sign.ts, rest.ts, handlers.ts).
123
123
  if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
124
124
  if (o['op'] === 'put') {
125
125
  return typeof o['sid'] === 'string'
@@ -1,5 +1,5 @@
1
1
  // Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
2
- // fan-out is an in-memory subscriber map (server/hub.ts) by design — it
2
+ // fan-out is an in-memory subscriber map (server-e2e/hub.ts) by design — it
3
3
  // routes a commit only to peers on the SAME instance. A multi-instance
4
4
  // deployment behind a load balancer needs commit-landed-on-A to reach
5
5
  // peers-on-B with the same latency the hub gives same-instance peers.
@@ -57,15 +57,15 @@ function newSenderId(): string {
57
57
  // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
58
  // receiver SELECTs the live row by (tag, res) for the rest of the
59
59
  // metadata fields.
60
- // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
- // — inline because the row is gone from workspace_object after the
62
- // delete commit.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
61
+ // the deleted row, which is gone from workspace_object after commit.
62
+ // Incarnation is optional only for messages from older relay instances.
63
63
  export type BusMessage =
64
64
  | { kind: 'rev'; tag: string; id: string }
65
65
  | { kind: 'objput'; tag: string; res: string }
66
- | { kind: 'objdel'; tag: string; res: string; ver: number }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
67
67
 
68
- // Receiver wired up by the hub layer (see server/index.ts). Each handler
68
+ // Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
69
69
  // runs once per remote message; failures are logged but don't crash the
70
70
  // LISTEN loop — a missed broadcast surfaces to clients on reconnect
71
71
  // (chain re-pull). Async because the workspace-revision handler does a
@@ -164,7 +164,7 @@ type NeonState = {
164
164
  // In-flight bus-message handler promises. `dispatchNotification`
165
165
  // fires handlers fire-and-forget, but `stop()` awaits this set before
166
166
  // returning so the lifecycle's `handle.close()` (which runs after
167
- // `pubsub.stop()` — see closeDb in server/index.ts) can't race a
167
+ // `pubsub.stop()` — see closeDb in server-e2e/index.ts) can't race a
168
168
  // handler mid-`handle.revisionById.get` / `getLive`.
169
169
  pendingHandlers: Set<Promise<void>>
170
170
  }
@@ -333,7 +333,7 @@ export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
333
333
  if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
334
334
  // Drain in-flight bus-message handlers BEFORE returning. The
335
335
  // lifecycle teardown runs `pubsub.stop()` and THEN
336
- // `handle.close()` (see closeDb in server/index.ts); a handler
336
+ // `handle.close()` (see closeDb in server-e2e/index.ts); a handler
337
337
  // still in `handle.revisionById.get` / `getLive` would otherwise
338
338
  // throw against a closed DB. `allSettled` so one handler's
339
339
  // rejection doesn't abort the drain.
@@ -388,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
388
388
  const ver = raw['ver']
389
389
  if (typeof res !== 'string') return null
390
390
  if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
391
- return { kind: 'objdel', tag, res, ver }
391
+ const incarnation = raw['incarnation']
392
+ if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
393
+ if (typeof incarnation !== 'string' || incarnation.length === 0) return null
394
+ return { kind: 'objdel', tag, res, ver, incarnation }
392
395
  }
393
396
  return null
394
397
  }
@@ -92,7 +92,7 @@ export function canonicalSave(
92
92
  // confusing diff in the canonical bytes rather than a clean drop.
93
93
  // Mirrors the `isSafeNonNegativeInt` rigor that the objstore
94
94
  // canonical builders apply. Input-validation audit
95
- // `server/sign.ts:88`.
95
+ // `server-e2e/sign.ts:88`.
96
96
  if (typeof workspaceTag !== 'string') throw new TypeError('canonicalSave: workspaceTag must be string')
97
97
  if (typeof nonce !== 'string') throw new TypeError('canonicalSave: nonce must be string')
98
98
  if (typeof ciphertext !== 'string') throw new TypeError('canonicalSave: ciphertext must be string')
@@ -122,7 +122,7 @@ function canonicalSubscribe(
122
122
  return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
123
123
  }
124
124
 
125
- // Exported because the v1.objstore signing module (server/objstore/sign.ts)
125
+ // Exported because the v1.objstore signing module (server-e2e/objstore/sign.ts)
126
126
  // reuses it for its four verifiers — same workspaceTag-as-pubkey contract,
127
127
  // same domain-separated canonical bytes. Keeping the WebCrypto plumbing
128
128
  // in one place avoids drift between the triage-sync and objstore verify
@@ -157,7 +157,7 @@ export async function verifyEd25519(
157
157
  // `{ ok: false, canonical: null }` on bad shape / bad sig;
158
158
  // `{ ok: true, canonical: <bytes> }` on success.
159
159
  //
160
- // NOT used by `server/index.ts handleSave`. Production composes
160
+ // NOT used by `server-e2e/sync-handlers.ts handleSave`. Production composes
161
161
  // the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
162
162
  // `verifyEd25519`) separately so the dup-precheck via
163
163
  // `revisionExists` can fire BEFORE Ed25519-verify and skip the
@@ -204,7 +204,7 @@ export async function computeRevisionIdFromCanonical(canonical: Uint8Array<Array
204
204
 
205
205
  // `connectionNonce` is the per-socket challenge the server issued
206
206
  // to the originating connection (see `Peer.challenge` in
207
- // server/peer.ts). The client signs a canonical that includes the
207
+ // server-e2e/peer.ts). The client signs a canonical that includes the
208
208
  // nonce; verifying against the SAME nonce here is what blocks
209
209
  // cross-connection replay of a captured subscribe frame. Audit
210
210
  // round-9 H2.
@@ -8,9 +8,10 @@
8
8
  //
9
9
  // One route, POSTs only:
10
10
  //
11
- // POST /api/sync/sse[?id=<sid>]
11
+ // POST /api/sync/sse
12
12
  // Request body:
13
- // { password?: string, — cached client password
13
+ // { id?: string, — session continuation token
14
+ // password?: string, — cached client password
14
15
  // frames?: Array<protocol-frame> — WS-style JSON frames
15
16
  // }
16
17
  // Response:
@@ -25,14 +26,23 @@
25
26
  // carrying the WS protocol's JSON envelopes.
26
27
  //
27
28
  // Continuation: each POST replaces the previous POST's response as the
28
- // session's downstream channel. If the `?id=<sid>` in the URL matches
29
- // a session this replica knows, the session continues (new outbound
30
- // stream attached, old one end()ed). If the id is unknown (different
31
- // replica picked up the POST, or session expired), a fresh session
32
- // with a new id is created and announced via the first `session`
33
- // event; the client uses the new id on all subsequent POSTs and re-
34
- // sends its subscribe frames on the next POST (its `frames` carry the
35
- // signed subscribes — they always do, see client/sync/sse-transport.ts).
29
+ // session's downstream channel. If the body's `id` matches a session
30
+ // this replica knows, the session continues (new outbound stream
31
+ // attached, old one end()ed). If the id is unknown (different replica
32
+ // picked up the POST, or session expired), a fresh session with a new
33
+ // id is created and announced via the first `session` event; the
34
+ // client uses the new id on all subsequent POSTs and re-sends its
35
+ // subscribe frames on the next POST (its `frames` carry the signed
36
+ // subscribes — they always do, see client/sync/sse-transport.ts).
37
+ //
38
+ // The id rides the JSON body, NOT the URL: the sid is a live bearer
39
+ // capability (whoever presents it attaches to the session's downstream
40
+ // and inherits its operator-auth flag), and a query-string token leaks
41
+ // into proxy / LB access logs — the same reason the objstore bearer
42
+ // tokens ride the Authorization header, never the URL. A legacy
43
+ // `?id=<sid>` query form is still ACCEPTED (older client bundles sent
44
+ // it; rejecting would churn them through a fresh session per POST),
45
+ // but current clients never emit it.
36
46
  //
37
47
  // Why POSTs only: a long-lived GET pins the client to one replica via
38
48
  // TCP affinity, which would mean POSTs from the same client must be
@@ -47,17 +57,35 @@ import { type PeerConnectionDeps, setupPeerConnection } from './ws-server.ts'
47
57
  import { SseSession } from './sse-session.ts'
48
58
  import { errMsg, randomId } from './util.ts'
49
59
 
50
- // Same prefix the WS upgrade lives under (server/http.ts WS_UPGRADE_PATH
60
+ // Same prefix the WS upgrade lives under (server-e2e/http.ts WS_UPGRADE_PATH
51
61
  // = '/api/sync'); subroute keeps the SSE plane sibling to the upgrade
52
62
  // path so the same `location` block routes both.
53
63
  export const SSE_OPEN_PATH = '/api/sync/sse'
54
64
 
65
+ // Cadence of the server-driven keepalive sweep: every tick we write a `:`
66
+ // comment to each open session's downstream so intermediary proxies don't
67
+ // idle-close it (nginx et al. default to a ~60s read timeout). This is the
68
+ // server's own liveness upkeep — the client no longer POSTs a periodic ping
69
+ // (which forced a stream takeover every tick).
70
+ //
71
+ // Reaping model (replaces the old POST-driven idle timer): a session is
72
+ // dropped on its downstream response `close` — clean disconnect, or a
73
+ // half-open socket the per-session TCP keepalive forces closed (see
74
+ // SseSession.SOCKET_KEEPALIVE_MS) — NOT by this sweep. We intentionally
75
+ // trust connection-level liveness. The one topology this can't see is a
76
+ // buffering / TLS-terminating proxy that holds the upstream open after the
77
+ // real client vanished (keepalive then probes the proxy hop, not the
78
+ // client); such a session lingers until `maxSessions`, the hard backstop.
79
+ // This is the same exposure the WS heartbeat already has (its ping only
80
+ // proves the proxy↔server hop too), not a new class of leak.
81
+ const KEEPALIVE_SWEEP_MS = 30_000
82
+
55
83
  export type SseServerDeps = {
56
84
  // The WS dispatch is the cohesive unit; SSE just provides another
57
85
  // transport into it. Closure over the same handler / hub / objstore
58
86
  // / track / debug surface the WS path uses.
59
87
  peerDeps: PeerConnectionDeps
60
- // Shutdown gate. Mirror of the REST + WS branches in server/http.ts:
88
+ // Shutdown gate. Mirror of the REST + WS branches in server-e2e/http.ts:
61
89
  // an SSE POST arriving on an existing keep-alive socket after
62
90
  // SIGTERM should be rejected, not dispatched against a draining DB.
63
91
  isShuttingDown: () => boolean
@@ -65,17 +93,11 @@ export type SseServerDeps = {
65
93
  // 503 the open request. Caps the SSE-side equivalent of `wss.clients`.
66
94
  maxSessions: number
67
95
  // Max body size for one POST. Matches the WS `maxPayload` in
68
- // server/index.ts so the SSE plane can't accept frames the WS plane
96
+ // server-e2e/index.ts so the SSE plane can't accept frames the WS plane
69
97
  // would reject. The POST body envelope can hold multiple frames so
70
98
  // the per-frame budget is the same as the WS plane after the
71
99
  // dispatcher splits them.
72
100
  maxBodyBytes: number
73
- // Idle timeout for a session with no inbound POSTs. Detects the
74
- // wandered-off browser tab the WS heartbeat sweep handles via
75
- // ping/pong on real sockets. The client's JSON ping/pong heartbeat
76
- // (every 15s) is the steady-state liveness signal; this is the
77
- // hard ceiling.
78
- sessionIdleMs: number
79
101
  debug: boolean
80
102
  }
81
103
 
@@ -86,48 +108,37 @@ export type SseServer = {
86
108
  // Iterates active sessions. Lifecycle's graceful-shutdown loop
87
109
  // reads this to close SSE sessions alongside WS clients.
88
110
  sessions: () => Iterable<SseSession>
111
+ // The keepalive-sweep timer. Lifecycle clears it on shutdown (parity
112
+ // with the WS heartbeat timer) so a tick can't fire mid-teardown.
113
+ keepaliveTimer: ReturnType<typeof setInterval>
89
114
  }
90
115
 
91
116
  // Inbound POST body. Every field optional — an empty-body POST is a
92
117
  // valid "wake the session" probe (the response stream rides on every
93
118
  // POST), and a body that carries only `password` or only `frames` is
94
- // a normal partial update.
119
+ // a normal partial update. `id` is the session continuation token
120
+ // (see the header's "Continuation" note for why it rides the body).
95
121
  type SseBody = {
122
+ id?: unknown
96
123
  password?: unknown
97
124
  frames?: unknown
98
125
  }
99
126
 
100
127
  export function installSseServer(deps: SseServerDeps): SseServer {
101
- const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, sessionIdleMs, debug } = deps
128
+ const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
102
129
 
103
130
  // Active SSE sessions, keyed by the random session id `createSession`
104
- // mints on the first POST that lacks a `?id=` (or whose id this
105
- // replica doesn't recognise) and that subsequent POSTs echo back to
106
- // continue the session. Bounded by `maxSessions` — over the cap, new
107
- // POSTs get a 503. POSTs against an unknown id are NOT 404'd — they
108
- // mint a fresh session instead, so a multi-replica deployment doesn't
109
- // require sticky LB routing to recover.
131
+ // mints on the first POST that carries no continuation id (or whose
132
+ // id this replica doesn't recognise) and that subsequent POSTs echo
133
+ // back (body `id` field) to continue the session. Bounded by
134
+ // `maxSessions` — over the cap, new POSTs get a 503. POSTs against an
135
+ // unknown id are NOT 404'd — they mint a fresh session instead, so a
136
+ // multi-replica deployment doesn't require sticky LB routing to
137
+ // recover.
110
138
  const sessions = new Map<string, SseSession>()
111
- // Per-session idle timer handle. Reset on every inbound POST; fires
112
- // after `sessionIdleMs` of silence to close a stranded session.
113
- const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
114
-
115
- function armIdleTimer(sid: string, session: SseSession): void {
116
- clearTimeout(idleTimers.get(sid))
117
- if (sessionIdleMs <= 0) return
118
- const t = setTimeout(() => {
119
- if (debug) console.warn(`sse: session ${sid.slice(0, 8)}… idle ${sessionIdleMs}ms → close`)
120
- try { session.terminate() } catch {}
121
- }, sessionIdleMs)
122
- t.unref?.()
123
- idleTimers.set(sid, t)
124
- }
125
139
 
126
140
  function dropSession(sid: string): void {
127
141
  sessions.delete(sid)
128
- const t = idleTimers.get(sid)
129
- if (t) clearTimeout(t)
130
- idleTimers.delete(sid)
131
142
  }
132
143
 
133
144
  function writeSseHeaders(res: ServerResponse): void {
@@ -149,7 +160,7 @@ export function installSseServer(deps: SseServerDeps): SseServer {
149
160
  res.write('retry: 1000\n\n')
150
161
  }
151
162
 
152
- function createSession(res: ServerResponse, req: HttpRequest): { sid: string; session: SseSession } | null {
163
+ function createSession(res: ServerResponse, req: HttpRequest): SseSession | null {
153
164
  if (sessions.size >= maxSessions) {
154
165
  if (debug) console.warn(`sse: refused open — sessions ${sessions.size} >= ${maxSessions}`)
155
166
  return null
@@ -158,7 +169,6 @@ export function installSseServer(deps: SseServerDeps): SseServer {
158
169
  const sid = randomId()
159
170
  const session = new SseSession(res)
160
171
  sessions.set(sid, session)
161
- armIdleTimer(sid, session)
162
172
  session.on('close', () => { dropSession(sid) })
163
173
  // Announce the continuation token BEFORE the dispatcher emits its
164
174
  // `challenge` frame so the client latches the id first and the
@@ -171,7 +181,7 @@ export function installSseServer(deps: SseServerDeps): SseServer {
171
181
  // `setupPeerConnection`'s first action is the protocol `challenge`
172
182
  // frame, on the default-named SSE channel.
173
183
  setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
174
- return { sid, session }
184
+ return session
175
185
  }
176
186
 
177
187
  // Drives a POST body's `password` and `frames` through the shared
@@ -254,6 +264,12 @@ export function installSseServer(deps: SseServerDeps): SseServer {
254
264
  return
255
265
  }
256
266
  }
267
+ // Resolve the continuation token: the JSON body's `id` is the
268
+ // canonical carrier; the `?id=` query form is the legacy
269
+ // fallback for older client bundles (see the header note — a
270
+ // query sid leaks into access logs). Body wins when both are
271
+ // present (current clients only ever send one).
272
+ const sid = parseSid(body.id) ?? sidFromUrl
257
273
  // Look up the session by id (if the client sent one). If found
258
274
  // and alive, attach the new response and dispatch. If not (or
259
275
  // session is closed), mint a fresh one — the response stream
@@ -268,19 +284,16 @@ export function installSseServer(deps: SseServerDeps): SseServer {
268
284
  // through to createSession with `res` still header-virgin, so
269
285
  // the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
270
286
  let session: SseSession | null = null
271
- let sid: string | null = null
272
- if (sidFromUrl) {
273
- const existing = sessions.get(sidFromUrl)
287
+ if (sid) {
288
+ const existing = sessions.get(sid)
274
289
  if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
275
290
  writeSseHeaders(res)
276
291
  session = existing
277
- sid = sidFromUrl
278
- armIdleTimer(sid, session)
279
292
  }
280
293
  }
281
294
  if (!session) {
282
- const created = createSession(res, req)
283
- if (!created) {
295
+ session = createSession(res, req)
296
+ if (!session) {
284
297
  // Cap exceeded; createSession already logged. Response
285
298
  // headers not yet written by writeSseHeaders, so send a
286
299
  // 503 JSON instead.
@@ -288,8 +301,6 @@ export function installSseServer(deps: SseServerDeps): SseServer {
288
301
  res.end(JSON.stringify({ error: 'too-many-sessions' }))
289
302
  return
290
303
  }
291
- session = created.session
292
- sid = created.sid
293
304
  }
294
305
  dispatchBody(session, body)
295
306
  // Do NOT res.end() — the response stays open as the session's
@@ -323,28 +334,51 @@ export function installSseServer(deps: SseServerDeps): SseServer {
323
334
  return true
324
335
  }
325
336
 
326
- return { handle, sessions: () => sessions.values() }
337
+ // Server-driven keepalive sweep. Writes a `:` comment to every open
338
+ // session's downstream so proxies don't idle-close it. `unref` so it can't
339
+ // by itself hold the event loop open (parity with the WS heartbeat timer);
340
+ // skipped during shutdown so a tick can't write to a session the close
341
+ // loop is tearing down. Dead-session reaping is the response `close` event
342
+ // (see SseSession.wireResponse + the per-session TCP keepalive), NOT this
343
+ // sweep — so a half-open client is dropped without ever POSTing.
344
+ const keepaliveTimer = setInterval(() => {
345
+ if (isShuttingDown()) return
346
+ for (const session of sessions.values()) {
347
+ try { session.ping() } catch {}
348
+ }
349
+ }, KEEPALIVE_SWEEP_MS)
350
+ keepaliveTimer.unref?.()
351
+
352
+ return { handle, sessions: () => sessions.values(), keepaliveTimer }
353
+ }
354
+
355
+ // Shape gate for a continuation token from either carrier (body `id`
356
+ // field or the legacy `?id=` query). The {1,64} bound is a
357
+ // deliberately lenient sanity gate: anything outside the base64url
358
+ // alphabet is rejected; an unrecognised sid just gets a fresh session
359
+ // from createSession (no failure mode), and the wide length window
360
+ // keeps a future randomId-length change from silently breaking old
361
+ // clients that round-trip a longer/shorter token. Returns null on
362
+ // missing / non-string / malformed.
363
+ function parseSid(v: unknown): string | null {
364
+ if (typeof v !== 'string') return null
365
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
366
+ return v
327
367
  }
328
368
 
329
- // Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
369
+ // Bare-bones query parse for the LEGACY `id=<base64url>` carrier
370
+ // (older client bundles; current clients send the sid in the POST
371
+ // body — see the header's "Continuation" note). Avoids URLSearchParams
330
372
  // (which decodes percent-escapes) — `randomId()` mints a 22-char
331
373
  // base64url string echoed back unchanged, so no escapes are possible
332
- // on the legitimate path. The {1,64} bound is a deliberately lenient
333
- // sanity gate: anything outside the base64url alphabet is rejected; an
334
- // unrecognised sid just gets a fresh session from createSession (no
335
- // failure mode), and the wide length window keeps a future
336
- // randomId-length change from silently breaking old clients that
337
- // round-trip a longer/shorter token. Returns null on missing /
338
- // malformed.
374
+ // on the legitimate path.
339
375
  function parseSidQuery(query: string | undefined): string | null {
340
376
  if (typeof query !== 'string') return null
341
377
  for (const part of query.split('&')) {
342
378
  const eq = part.indexOf('=')
343
379
  if (eq <= 0) continue
344
380
  if (part.slice(0, eq) !== 'id') continue
345
- const v = part.slice(eq + 1)
346
- if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
347
- return v
381
+ return parseSid(part.slice(eq + 1))
348
382
  }
349
383
  return null
350
384
  }
@@ -28,6 +28,20 @@ import { EventEmitter } from 'node:events'
28
28
  import type { Buffer } from 'node:buffer'
29
29
  import type { ServerResponse } from 'node:http'
30
30
 
31
+ // TCP keepalive idle delay for the downstream socket. With the client no
32
+ // longer POSTing a periodic ping (see client/sync/socket-transport.ts), a
33
+ // QUIET session whose client vanished without a FIN (crash, NAT/idle drop)
34
+ // has no application-level liveness signal — so we lean on the kernel:
35
+ // after this much idle the kernel starts probing, and a dead half-open
36
+ // socket surfaces as a `close`/`error` here. Node's `setKeepAlive` sets
37
+ // only TCP_KEEPIDLE (the delay to the FIRST probe), not the probe
38
+ // interval/count — so full teardown is this delay PLUS the OS's
39
+ // TCP_KEEPINTVL × TCP_KEEPCNT (~minutes on Linux defaults), still bounded
40
+ // and far below the kernel's hours-long default-off behaviour. `maxSessions`
41
+ // is the hard backstop. Behind a TLS-terminating / buffering proxy this
42
+ // probes the proxy hop, not the client — see the reaping note in sse-server.ts.
43
+ const SOCKET_KEEPALIVE_MS = 30_000
44
+
31
45
  export class SseSession extends EventEmitter {
32
46
  static readonly CONNECTING = 0
33
47
  static readonly OPEN = 1
@@ -62,6 +76,10 @@ export class SseSession extends EventEmitter {
62
76
  // spurious session 'error' that operators read as a real transport
63
77
  // failure on a healthy session.
64
78
  private wireResponse(res: ServerResponse): void {
79
+ // Probe half-open downstreams at the kernel level (see SOCKET_KEEPALIVE_MS).
80
+ // A failed probe trips the `close`/`error` handlers below, which is how a
81
+ // dead-but-quiet SSE client is reaped now that there's no client ping.
82
+ try { res.socket?.setKeepAlive(true, SOCKET_KEEPALIVE_MS) } catch {}
65
83
  res.on('close', () => {
66
84
  if (res !== this.currentRes) return // current-response guard (see above)
67
85
  if (this.readyState === SseSession.CLOSED) return
@@ -142,9 +160,8 @@ export class SseSession extends EventEmitter {
142
160
  // double-emit), which means without the explicit emit here neither
143
161
  // sse-server's dropSession cleanup nor setupPeerConnection's
144
162
  // unsubscribeAll/peers.delete would run on any server-initiated
145
- // teardown — sessions / hub.subscribers / idleTimers would leak per
146
- // close. The wireResponse guard then ensures the later async fire
147
- // is a no-op.
163
+ // teardown — the sessions map / hub.subscribers would leak per close.
164
+ // The wireResponse guard then ensures the later async fire is a no-op.
148
165
  close(code?: number, reason?: string): void {
149
166
  if (this.readyState === SseSession.CLOSED) return
150
167
  const res = this.currentRes
@@ -173,11 +190,14 @@ export class SseSession extends EventEmitter {
173
190
  this.emit('close')
174
191
  }
175
192
 
176
- // The heartbeat sweep ping()s every WS client to detect dead sockets
177
- // via the unanswered-pong path. SSE has no `pong` equivalent, so we
178
- // write a comment line that keeps the channel alive across proxies
179
- // without expecting a reply. The per-session idle timeout in
180
- // sse-server.ts owns the dead-client detection.
193
+ // Server-driven keepalive, called on the SSE keepalive sweep in
194
+ // sse-server.ts. SSE has no `pong` equivalent, so we write a `:` comment
195
+ // line — ignored by the client parser — that keeps the downstream from
196
+ // being idle-closed by intermediary proxies (nginx et al. default to a
197
+ // ~60s read timeout). Dead-client detection is the response `close` event
198
+ // (clean disconnect, or a half-open socket the TCP keepalive in
199
+ // `wireResponse` forces closed), NOT this write: a `:`-comment write to a
200
+ // half-open socket just buffers, it doesn't synchronously throw.
181
201
  ping(): void {
182
202
  if (this.readyState !== SseSession.OPEN) return
183
203
  const res = this.currentRes