@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.
- package/LICENSE-APACHE +202 -0
- package/README.md +16 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/dependency-paths.d.ts +2 -0
- package/client/dependency-paths.js +24 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +112 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-compare-diff.d.ts +3 -0
- package/common/bundle-compare-diff.js +522 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-metadata.d.ts +14 -0
- package/common/bundle-metadata.js +226 -0
- package/common/bundle-pkg-of.d.ts +1 -0
- package/common/bundle-pkg-of.js +90 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +6 -0
- package/common/bundle-source-links.js +216 -0
- package/common/bundle-sourcemap.d.ts +10 -0
- package/common/bundle-sourcemap.js +100 -0
- package/common/bundle-sources.d.ts +7 -0
- package/common/bundle-sources.js +310 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +65 -0
- package/common/default-scan-models.ts +31 -0
- package/common/dependency-reviews.d.ts +23 -0
- package/common/dependency-reviews.js +81 -0
- package/common/finding-duplicates.js +58 -0
- package/common/finding-filters.js +274 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/gzip.js +59 -0
- package/common/line-diff.d.ts +8 -0
- package/common/line-diff.js +56 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/npm-packages.d.ts +5 -0
- package/common/managed/npm-packages.js +34 -0
- package/common/managed/permissions.ts +31 -0
- package/common/managed/report-content.ts +72 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +254 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +2 -0
- package/common/managed/team-app.js +31 -0
- package/common/managed/triage.ts +83 -0
- package/common/managed/upload-seal.ts +126 -0
- package/common/pretty-print.d.ts +5 -0
- package/common/pretty-print.js +24 -0
- package/common/revalidation-conflicts.js +48 -0
- package/common/revalidation-input-groups.js +56 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +23 -0
- package/common/workspace-groups.js +176 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2KBF5TZ7.js +2 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-35VE6CDH.js +2 -0
- package/out/chunk-3BZ7JERB.js +1 -0
- package/out/chunk-4F6JSF57.js +3 -0
- package/out/chunk-4XCDFGDP.js +10 -0
- package/out/chunk-AQ75QITE.js +1 -0
- package/out/chunk-B2RQY6OP.js +2 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-CMQA4DM6.js +1 -0
- package/out/chunk-CVVJAXVY.js +2 -0
- package/out/chunk-DIJJNTRH.js +10 -0
- package/out/chunk-GJ4IUCNS.js +1 -0
- package/out/chunk-GNXOKIGP.js +80 -0
- package/out/chunk-GZ4MS3TE.js +13 -0
- package/out/chunk-IISEF2BZ.js +1 -0
- package/out/chunk-IU3CCINR.js +3 -0
- package/out/chunk-K3X5EJBQ.js +2 -0
- package/out/chunk-KEX2ATEW.js +2 -0
- package/out/chunk-KYCNJVB7.js +1 -0
- package/out/chunk-MYDDTA7W.js +225 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-RFZQQXSG.js +17 -0
- package/out/chunk-RSHMVYNS.js +1 -0
- package/out/chunk-TGANXVTA.js +1 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-TZ5KB2Y5.js +10 -0
- package/out/chunk-UN5ZSYLE.js +1 -0
- package/out/chunk-UNTRGE26.js +2 -0
- package/out/chunk-UXNUAIIC.js +33 -0
- package/out/chunk-UYAXVRVL.js +1 -0
- package/out/chunk-V3ME6FI5.js +2 -0
- package/out/chunk-V5O7M2YT.js +1 -0
- package/out/chunk-VIHGO77Y.js +1 -0
- package/out/chunk-VQJCXGIY.js +7 -0
- package/out/chunk-W4J3MGLB.js +1 -0
- package/out/chunk-WGG7KQUH.js +1 -0
- package/out/chunk-WZDTZE64.js +1 -0
- package/out/chunk-XYDGU6BM.js +51 -0
- package/out/chunk-XYFXXB2G.js +10 -0
- package/out/chunk-YVZH5SHP.js +1 -0
- package/out/chunk-ZBBOBQ43.js +1 -0
- package/out/chunk-ZMQQCGF6.js +2 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +37 -0
- package/out/client-sync.js +10 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +17 -4
- package/out/index.html +90 -8
- package/out/prism.js +6 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +365 -39
- package/out/view.css +1 -1
- package/out/view.js +83 -117
- package/package.json +297 -59
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +171 -0
- package/risk/files/runs-code.js +358 -0
- package/risk/files/script.js +114 -0
- package/risk/files/stamps.js +46 -0
- package/risk/index.d.ts +142 -0
- package/risk/index.js +7 -0
- package/risk/npm/assess.js +219 -0
- package/risk/npm/combined.js +65 -0
- package/risk/npm/compare.js +228 -0
- package/risk/npm/dependencies.js +153 -0
- package/risk/npm/diff.js +43 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +34 -0
- package/risk/npm/held.js +60 -0
- package/risk/npm/install-scripts.js +34 -0
- package/risk/npm/malware.js +36 -0
- package/risk/npm/manifest.js +65 -0
- package/risk/npm/new-dependency.js +30 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +282 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +113 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +116 -18
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +12 -145
- package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
- package/{server → server-e2e}/objstore/blob.ts +1 -1
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +1 -1
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +27 -26
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +2 -2
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +245 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +120 -0
- package/server-managed/bundle-advisory-inventory.ts +77 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +111 -0
- package/server-managed/bundle-build.ts +198 -0
- package/server-managed/bundle-cache.ts +199 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-commits.ts +140 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +130 -0
- package/server-managed/bundle.ts +120 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +158 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1725 -0
- package/server-managed/db-neon.ts +367 -0
- package/server-managed/db-schema.ts +227 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +132 -0
- package/server-managed/dependency-reviews.ts +248 -0
- package/server-managed/github-app.ts +391 -0
- package/server-managed/github-errors.ts +68 -0
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +235 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +50 -0
- package/server-managed/http.ts +3472 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +187 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/locked-packages.ts +85 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +75 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-insights.ts +440 -0
- package/server-managed/npm-kept.ts +65 -0
- package/server-managed/npm-loads.ts +167 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +378 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +177 -0
- package/server-managed/object-storage-disk.ts +148 -0
- package/server-managed/object-storage-vercel.ts +67 -0
- package/server-managed/object-storage.ts +74 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +352 -0
- package/server-managed/public-workspace.ts +220 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +270 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +178 -0
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +153 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-risk.ts +418 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +92 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +76 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +279 -0
- package/server-managed/storage-maintenance.ts +256 -0
- package/server-managed/storage-payload.ts +103 -0
- package/server-managed/storage-stores.ts +85 -0
- package/server-managed/storage.ts +78 -0
- package/server-managed/team-app.ts +94 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-npm-scopes.ts +77 -0
- package/server-managed/team-reports.ts +228 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +103 -0
- package/server-managed/uploads.ts +86 -0
- package/server-managed/upstream-cache.ts +215 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +166 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /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
|
-
|
|
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`
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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
|
-
|
|
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/
|
|
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
|
|
11
|
+
// POST /api/sync/sse
|
|
12
12
|
// Request body:
|
|
13
|
-
// {
|
|
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
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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,
|
|
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
|
|
105
|
-
// replica doesn't recognise) and that subsequent POSTs echo
|
|
106
|
-
// continue the session. Bounded by
|
|
107
|
-
// POSTs get a 503. POSTs against an
|
|
108
|
-
// mint a fresh session instead, so a
|
|
109
|
-
// require sticky LB routing to
|
|
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):
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
283
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
146
|
-
//
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
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
|