@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.50
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 +3 -0
- package/client/dependency-paths.js +36 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +115 -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 +357 -0
- package/common/bundle-compare-inputs.d.ts +2 -0
- package/common/bundle-compare-inputs.js +65 -0
- package/common/bundle-compare-versions.js +172 -0
- package/common/bundle-comparison.d.ts +8 -0
- package/common/bundle-comparison.js +62 -0
- package/common/bundle-metadata.d.ts +15 -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 +333 -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 +18 -0
- package/common/dependency-reviews.js +64 -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 +57 -0
- package/common/linked-workspace-groups.js +63 -0
- package/common/managed/agent-tasks.ts +292 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/finding-detail.d.ts +2 -0
- package/common/managed/finding-detail.js +32 -0
- package/common/managed/github-agents.ts +114 -0
- package/common/managed/github-logins.ts +49 -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 +109 -0
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/roles.ts +35 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +347 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/team-app.d.ts +5 -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/security-links.d.ts +5 -0
- package/common/security-links.js +33 -0
- package/common/server-info.ts +46 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/common/workspace-app.js +39 -0
- package/common/workspace-groups.js +176 -0
- package/out/activity.js +4 -0
- package/out/brotli-fallback.js +3 -3
- package/out/chunk-2WDZPXVL.js +3 -0
- package/out/chunk-2XUEZHIP.js +28 -0
- package/out/chunk-2ZDZWLFY.js +1 -0
- package/out/chunk-4HI2MSAV.js +17 -0
- package/out/chunk-4XMP7YVH.js +36 -0
- package/out/chunk-6LNQTKAV.js +52 -0
- package/out/chunk-6S2U2UYG.js +232 -0
- package/out/chunk-75YLHBCM.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/chunk-7C7DWL7I.js +1 -0
- package/out/chunk-7VAWINQF.js +6 -0
- package/out/chunk-C4Z6ZBXV.js +10 -0
- package/out/chunk-FC65TPUB.js +1 -0
- package/out/chunk-FKBXNUW4.js +1 -0
- package/out/chunk-GDPCLSOS.js +2 -0
- package/out/chunk-GO7FT4OD.js +1 -0
- package/out/chunk-HTBYHSPG.js +1 -0
- package/out/chunk-IYPY47MC.js +1 -0
- package/out/chunk-IZ7POWI3.js +18 -0
- package/out/chunk-JCIIBLKR.js +1 -0
- package/out/chunk-NRPC74AM.js +1 -0
- package/out/chunk-OOJM4CTU.js +1 -0
- package/out/chunk-PBZPYIG7.js +1 -0
- package/out/chunk-PNHBZGDD.js +1 -0
- package/out/chunk-PWFUOLZK.js +17 -0
- package/out/chunk-PXK7EU4V.js +1 -0
- package/out/chunk-QB4CBOX2.js +1 -0
- package/out/chunk-R3NZFXVR.js +1 -0
- package/out/chunk-R5O6FAW4.js +2 -0
- package/out/chunk-SXV2T5C4.js +80 -0
- package/out/chunk-TQJDYHVE.js +2 -0
- package/out/chunk-TUII34K2.js +1 -0
- package/out/chunk-UK7RGJT6.js +3 -0
- package/out/chunk-VFWMQ3LG.js +2 -0
- package/out/chunk-VH4XMZIW.js +2 -0
- package/out/chunk-WCPJDAYA.js +10 -0
- package/out/chunk-WFYLMF6Z.js +10 -0
- package/out/chunk-WYLQWYLA.js +1 -0
- package/out/client-managed-import.js +4 -0
- package/out/client-managed.js +33 -0
- package/out/client-sync.js +11 -17
- package/out/compare.js +35 -0
- package/out/dialogs.js +55 -0
- 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 +14 -10
- package/out/scan.js +2 -0
- package/out/stasis.svg +20 -0
- package/out/terminal.js +366 -39
- package/out/view.css +1 -1
- package/out/view.js +19 -144
- package/package.json +362 -59
- package/risk/actions/findings.js +161 -0
- package/risk/actions/resolve.js +219 -0
- package/risk/actions/scripts/commands.js +182 -0
- package/risk/actions/scripts/grade.js +113 -0
- package/risk/actions/scripts/packages.js +123 -0
- package/risk/actions/scripts/shell.js +169 -0
- package/risk/actions/uses.js +76 -0
- package/risk/actions/workflow.js +110 -0
- package/risk/files/characters.js +196 -0
- package/risk/files/hosts.js +205 -0
- package/risk/files/readability.js +177 -0
- package/risk/files/runs-code.js +615 -0
- package/risk/files/script.js +118 -0
- package/risk/files/stamps.js +47 -0
- package/risk/index.d.ts +254 -0
- package/risk/index.js +11 -0
- package/risk/npm/assess.js +285 -0
- package/risk/npm/combined.js +68 -0
- package/risk/npm/compare.js +257 -0
- package/risk/npm/dependencies.js +172 -0
- package/risk/npm/diff.js +79 -0
- package/risk/npm/downloads.js +84 -0
- package/risk/npm/files.js +445 -0
- package/risk/npm/held.js +63 -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 +60 -0
- package/risk/npm/old-version.js +108 -0
- package/risk/npm/registry.js +388 -0
- package/risk/npm/release.js +85 -0
- package/risk/npm/score.js +59 -0
- package/risk/npm/specs.js +124 -0
- package/risk/npm/tarball.js +96 -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 +487 -0
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +50 -42
- 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 +44 -13
- 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 +19 -27
- 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 +3 -3
- 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 +60 -33
- package/{server → server-e2e}/static.ts +31 -21
- package/{server → server-e2e}/sync-handlers.ts +64 -30
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +258 -0
- package/server-managed/agent-merges.ts +109 -0
- package/server-managed/app-agents.ts +21 -0
- package/server-managed/avatar-store.ts +13 -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 +115 -0
- package/server-managed/bundle-build.ts +200 -0
- package/server-managed/bundle-cache.ts +211 -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 +119 -0
- package/server-managed/cache-storage.ts +20 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +47 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/commit-coauthors.ts +106 -0
- package/server-managed/commit-lockfiles.ts +73 -0
- package/server-managed/comparison.ts +41 -0
- package/server-managed/config.ts +164 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1923 -0
- package/server-managed/db-neon.ts +458 -0
- package/server-managed/db-schema.ts +238 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +142 -0
- package/server-managed/dependabot-access.ts +60 -0
- package/server-managed/dependabot-alerts.ts +185 -0
- package/server-managed/dependency-review-comments.ts +100 -0
- package/server-managed/dependency-reviews.ts +346 -0
- package/server-managed/github-accounts.ts +60 -0
- package/server-managed/github-actions.ts +71 -0
- package/server-managed/github-app.ts +415 -0
- package/server-managed/github-errors.ts +70 -0
- package/server-managed/github-graphql.ts +39 -0
- package/server-managed/github-issue-links.ts +140 -0
- package/server-managed/github-issue-oauth.ts +47 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-link-oauth.ts +69 -0
- package/server-managed/github-links.ts +204 -0
- package/server-managed/github-metadata.ts +213 -0
- package/server-managed/github-oauth.ts +238 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/github-users.ts +30 -0
- package/server-managed/http-response.ts +76 -0
- package/server-managed/http.ts +5329 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +192 -0
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/kept.ts +28 -0
- package/server-managed/link-reports.ts +162 -0
- package/server-managed/locked-packages.ts +129 -0
- package/server-managed/lockfile-scope.ts +49 -0
- package/server-managed/lru.ts +30 -0
- package/server-managed/managed-issues.ts +78 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +99 -0
- package/server-managed/npm-delta-worker.js +23 -0
- package/server-managed/npm-delta.ts +194 -0
- package/server-managed/npm-insights.ts +449 -0
- package/server-managed/npm-kept.ts +215 -0
- package/server-managed/npm-loads.ts +264 -0
- package/server-managed/npm-manifest.ts +115 -0
- package/server-managed/npm-packages.ts +454 -0
- package/server-managed/npm-provenance.ts +123 -0
- package/server-managed/npm-risk.ts +285 -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 +75 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/pretty-print.ts +434 -0
- package/server-managed/public-workspace.ts +238 -0
- package/server-managed/pull-comment-reviews.ts +161 -0
- package/server-managed/pull-dependencies.ts +348 -0
- package/server-managed/pull-dependency-reviews.ts +328 -0
- package/server-managed/pull-reviews.ts +205 -0
- package/server-managed/queue.ts +39 -0
- package/server-managed/release-commits.ts +84 -0
- package/server-managed/rename-diff-worker.js +26 -0
- package/server-managed/rename-diff.ts +279 -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 +85 -0
- package/server-managed/repository-actions.ts +218 -0
- package/server-managed/repository-agents.ts +355 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/repository-browser.ts +294 -0
- package/server-managed/repository-changes.ts +284 -0
- package/server-managed/repository-code.ts +215 -0
- package/server-managed/repository-dependency-reviews.ts +165 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-history.ts +432 -0
- package/server-managed/repository-issues.ts +195 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/repository-pulls.ts +156 -0
- package/server-managed/repository-pulse.ts +460 -0
- package/server-managed/repository-risk-assess.ts +105 -0
- package/server-managed/repository-risk-audit.ts +79 -0
- package/server-managed/repository-risk-read.ts +170 -0
- package/server-managed/repository-risk.ts +491 -0
- package/server-managed/repository-squashes.ts +168 -0
- package/server-managed/request-timing.ts +63 -0
- package/server-managed/reviewed-dependencies.ts +150 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/scan-downloads.ts +87 -0
- package/server-managed/session.ts +132 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +71 -0
- package/server-managed/sql-postgres.ts +34 -0
- package/server-managed/sql.ts +88 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +183 -0
- package/server-managed/storage-encryption.ts +290 -0
- package/server-managed/storage-maintenance.ts +259 -0
- package/server-managed/storage-payload.ts +121 -0
- package/server-managed/storage-stores.ts +91 -0
- package/server-managed/storage.ts +82 -0
- package/server-managed/team-app.ts +134 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +180 -0
- package/server-managed/team-npm-scopes.ts +106 -0
- package/server-managed/team-reports.ts +307 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/type-only-worker.js +19 -0
- package/server-managed/type-only.ts +105 -0
- package/server-managed/uploads.ts +106 -0
- package/server-managed/upstream-cache.ts +243 -0
- package/server-managed/view-as.ts +58 -0
- package/server-managed/workspace-shares.ts +169 -0
- package/server.ts +70 -0
- package/server/index.ts +0 -480
- package/server/lifecycle.ts +0 -208
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/auth.ts +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}/objstore/sign.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/sse-session.ts +0 -0
- /package/{server → server-e2e}/util.ts +0 -0
- /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
|
|
6
|
-
//
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
160
|
-
//
|
|
161
|
-
// `
|
|
162
|
-
//
|
|
163
|
-
// the client to retry against a different replica.
|
|
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
|
-
|
|
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
|
+
}
|