@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
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/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -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-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- 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 +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- 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 +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DeepView triage-sync relay server. WebSocket front-end, SQLite
|
|
3
|
+
// backing store. Implements the protocol described in
|
|
4
|
+
// `client/triage-sync.js` (and `server-e2e/sign.ts` for the canonical
|
|
5
|
+
// signature payloads):
|
|
6
|
+
//
|
|
7
|
+
// server → client challenge { nonce } — emitted on every
|
|
8
|
+
// accept, BEFORE any client
|
|
9
|
+
// frame; per-socket random
|
|
10
|
+
// 128-bit value the client
|
|
11
|
+
// must bind into every
|
|
12
|
+
// `workspace-subscribe`
|
|
13
|
+
// signature (round-9 H2)
|
|
14
|
+
// client → server workspace-save { workspaceTag, base,
|
|
15
|
+
// keyframe, nonce, ciphertext,
|
|
16
|
+
// signature } — `keyframe` is
|
|
17
|
+
// a boolean (`true` exactly,
|
|
18
|
+
// else falsy), bound into the
|
|
19
|
+
// signed canonical
|
|
20
|
+
// client → server workspace-subscribe { workspaceTag, from,
|
|
21
|
+
// signature } — `from` is the
|
|
22
|
+
// last revision id the client
|
|
23
|
+
// claims to have applied (or
|
|
24
|
+
// null for fresh)
|
|
25
|
+
// client → server ping — heartbeat
|
|
26
|
+
// server → client pong — heartbeat reply
|
|
27
|
+
// server → client workspace-save-ack { workspaceTag, base, id }
|
|
28
|
+
// server → client workspace-save-error { workspaceTag, base, reason }
|
|
29
|
+
// — explicit failure surface for
|
|
30
|
+
// the legit-signer case where
|
|
31
|
+
// the server rejects a signed
|
|
32
|
+
// save (e.g. `too-large` past
|
|
33
|
+
// MAX_CIPHERTEXT_LEN). Sent
|
|
34
|
+
// AFTER sig verify so the
|
|
35
|
+
// response only reaches a
|
|
36
|
+
// legitimate seed holder; shape
|
|
37
|
+
// attacks still drop silently.
|
|
38
|
+
// server → client workspace-subscribed { workspaceTag } — explicit
|
|
39
|
+
// handshake-complete ack so
|
|
40
|
+
// the client can flip its
|
|
41
|
+
// status from `connecting` to
|
|
42
|
+
// `online` only after the
|
|
43
|
+
// server registered it as a
|
|
44
|
+
// peer (not just on socket
|
|
45
|
+
// open)
|
|
46
|
+
// server → client workspace-state { workspaceTag, revisions:
|
|
47
|
+
// [{ base, id, keyframe,
|
|
48
|
+
// nonce, ciphertext,
|
|
49
|
+
// signature }, ...] }
|
|
50
|
+
//
|
|
51
|
+
// Authentication: every signed message is checked against the
|
|
52
|
+
// `workspaceTag` (= base64url Ed25519 public key) before any
|
|
53
|
+
// state mutation. Unsigned / bad-sig messages are dropped silently
|
|
54
|
+
// — the legitimate signer will retry, and an attacker who learns
|
|
55
|
+
// the tag without holding the seed can't get past the verify.
|
|
56
|
+
//
|
|
57
|
+
// Content opacity: `nonce` and `ciphertext` are opaque to the
|
|
58
|
+
// server. We store and forward them; we never inspect.
|
|
59
|
+
//
|
|
60
|
+
// Subscriber tracking: `subscribers: Map<workspaceTag, Set<socket>>`.
|
|
61
|
+
// A socket joins the set ONLY via an explicit, signature-verified
|
|
62
|
+
// `workspace-subscribe` (sole call site of `subscribe()` is in
|
|
63
|
+
// `handleSubscribe`, gated by the per-connection challenge nonce
|
|
64
|
+
// bound into the signature and a post-await `readyState` check).
|
|
65
|
+
// It leaves on disconnect. Broadcasts go to every subscriber for
|
|
66
|
+
// the workspaceTag except the originator.
|
|
67
|
+
//
|
|
68
|
+
// `workspace-save` deliberately does NOT auto-attach the sender,
|
|
69
|
+
// even on a valid signature — see the audit note in `handleSave`
|
|
70
|
+
// (round-9 H1): auto-subscribe-on-save let a passive observer
|
|
71
|
+
// replay any captured save frame from any TCP connection to attach
|
|
72
|
+
// as a silent mirror, since the duplicate-id path returns ack-only
|
|
73
|
+
// and would not reject the attaching socket.
|
|
74
|
+
|
|
75
|
+
import { type WebSocket, WebSocketServer } from 'ws'
|
|
76
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
77
|
+
import { errMsg, errStack } from './util.ts'
|
|
78
|
+
import type { PeerRegistry } from './peer.ts'
|
|
79
|
+
import { LOOPBACK_HOSTS, createOriginGate } from '../server-common/origin.ts'
|
|
80
|
+
import { withReap } from '../server-common/reap.ts'
|
|
81
|
+
import { initializeApp } from '../server-common/initialize.ts'
|
|
82
|
+
import { createHub } from './hub.ts'
|
|
83
|
+
import { createAuth } from './auth.ts'
|
|
84
|
+
import { createSyncHandlers } from './sync-handlers.ts'
|
|
85
|
+
import { WS_UPGRADE_PATH, createHttpServer } from './http.ts'
|
|
86
|
+
import { installWsServer } from './ws-server.ts'
|
|
87
|
+
import { SSE_OPEN_PATH, installSseServer } from './sse-server.ts'
|
|
88
|
+
import type { ServerInfo } from '../common/server-info.ts'
|
|
89
|
+
import { createLifecycle } from './lifecycle.ts'
|
|
90
|
+
import { type Config, loadConfig } from './config.ts'
|
|
91
|
+
import { type Handle, openDb } from './db.ts'
|
|
92
|
+
import { openNeonDb } from './db-neon.ts'
|
|
93
|
+
import { initObjstore } from './objstore/init.ts'
|
|
94
|
+
import { type Handle as ObjstoreHandle, listLive, objectMetaWire, openObjstore } from './objstore/store.ts'
|
|
95
|
+
import { openNeonObjstore } from './objstore/store-neon.ts'
|
|
96
|
+
import { openVercelBlobBackend } from './objstore/blob-vercel.ts'
|
|
97
|
+
import {
|
|
98
|
+
type NeonClientCtor, type PubSub,
|
|
99
|
+
createNeonPubSub, createNoopPubSub,
|
|
100
|
+
} from './pubsub.ts'
|
|
101
|
+
import { createBusReceiver } from './bus-receiver.ts'
|
|
102
|
+
import { e2eStorageLines } from '../server-common/storage-log.ts'
|
|
103
|
+
|
|
104
|
+
// Each embedding gets its own stores, transports, timers and shutdown state.
|
|
105
|
+
export async function createE2eApp(config = loadConfig()) {
|
|
106
|
+
return await initializeApp(rollback => assembleE2eApp(config, rollback))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
|
|
110
|
+
// All external inputs (env vars + optional config.json) are parsed
|
|
111
|
+
// and validated in ./config.ts; destructure into the uppercase names
|
|
112
|
+
// the rest of this module uses.
|
|
113
|
+
const {
|
|
114
|
+
port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
|
|
115
|
+
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS, reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
116
|
+
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, debug: DEBUG,
|
|
117
|
+
neonUrl: NEON_URL, blobToken: BLOB_TOKEN, tokenSecret: TOKEN_SECRET,
|
|
118
|
+
password: CONFIG_PASSWORD, trustProxyEnv: TRUST_PROXY_ENV,
|
|
119
|
+
} = config
|
|
120
|
+
|
|
121
|
+
// Same-origin gate for the WS upgrade and REST data plane (see
|
|
122
|
+
// ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
|
|
123
|
+
// misconfiguration fail-fast below.
|
|
124
|
+
const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
|
|
125
|
+
|
|
126
|
+
// Per-socket buffered-bytes cap. `socket.send` returns synchronously
|
|
127
|
+
// even when the kernel/ws library can't drain to the wire fast
|
|
128
|
+
// enough; the unsent payload accumulates in `bufferedAmount`. A
|
|
129
|
+
// slow / blackholed peer on a high-volume workspace can hold many
|
|
130
|
+
// MB of fan-out broadcasts in this buffer with no backpressure on
|
|
131
|
+
// the broadcast loop. Drop the message when the buffer crosses the
|
|
132
|
+
// cap; the heartbeat will eventually close a peer that never
|
|
133
|
+
// drains. Transport audit `server-e2e/index.ts:225`.
|
|
134
|
+
const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
135
|
+
// Per-socket in-flight async-handler cap (MAX_INFLIGHT_PER_SOCKET,
|
|
136
|
+
// env-validated in config). Each inbound text frame spawns a
|
|
137
|
+
// `track(handler)` IIFE; an authorised peer firing valid frames could
|
|
138
|
+
// otherwise grow the set without bound, stretching SIGTERM drain time.
|
|
139
|
+
// Saves dropped at the cap surface as a typed `busy` NACK. Transport
|
|
140
|
+
// audit `server-e2e/index.ts:590`.
|
|
141
|
+
|
|
142
|
+
// Per-connection state registry. One `Peer` per accepted socket holds
|
|
143
|
+
// the challenge nonce, auth flag, heartbeat liveness, in-flight count,
|
|
144
|
+
// and subscribed tags (see ./peer.ts). The connection handler holds
|
|
145
|
+
// the Peer in a closure for the hot paths; cross-function call sites
|
|
146
|
+
// resolve it via `peers.get(socket)`.
|
|
147
|
+
const peers: PeerRegistry = new WeakMap()
|
|
148
|
+
|
|
149
|
+
// REST PUT idle-body timeout. A slow-loris client trickling bytes
|
|
150
|
+
// within the declared Content-Length holds the staging fd and an
|
|
151
|
+
// inFlightSids slot until the global staging TTL reaps it. Aborting
|
|
152
|
+
// the per-chunk-idle period closes that window. Transport audit
|
|
153
|
+
// `server-e2e/objstore/rest.ts:218`.
|
|
154
|
+
const REST_PUT_IDLE_TIMEOUT_MS = 30_000
|
|
155
|
+
|
|
156
|
+
// Server-driven WS heartbeat. Every `HEARTBEAT_INTERVAL_MS` we walk
|
|
157
|
+
// `wss.clients`, terminate anyone who didn't pong since the last
|
|
158
|
+
// tick, and ping the rest. The client-initiated `{type:'ping'}` /
|
|
159
|
+
// `{type:'pong'}` JSON heartbeat the protocol already had only
|
|
160
|
+
// catches the case where the CLIENT notices the socket's gone — it
|
|
161
|
+
// can't recover an FD when the client itself has wandered off
|
|
162
|
+
// (battery-killed background tab, mid-transfer NAT timeout, a
|
|
163
|
+
// hostile non-browser client that opens the socket and never
|
|
164
|
+
// speaks again). The same-origin upgrade gate allows missing
|
|
165
|
+
// Origin headers through (legitimate non-browser callers), so a
|
|
166
|
+
// hostile CLI can stack arbitrarily many idle sockets without it.
|
|
167
|
+
// Kernel TCP keepalive is hours by default; without this interval
|
|
168
|
+
// each abandoned socket pins its `wss.clients` Set entry, its `Peer`
|
|
169
|
+
// state, and an FD until the kernel reclaims it. Two ticks max
|
|
170
|
+
// from silence to termination, so the longest a dead socket
|
|
171
|
+
// survives is ~2 × HEARTBEAT_INTERVAL_MS.
|
|
172
|
+
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
173
|
+
|
|
174
|
+
// Backend selection. Both planes (workspace_revision DB + the
|
|
175
|
+
// v1.objstore byte store) are picked from config at boot. Two
|
|
176
|
+
// supported pairings:
|
|
177
|
+
// 1. DATABASE_URL or E2E_DATABASE_URL set → Neon (workspace_revision + objstore
|
|
178
|
+
// tables) + Vercel Blob Private Storage (bytes). Requires
|
|
179
|
+
// BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
|
|
180
|
+
// a local-FS byte plane can't back a multi-replica deployment
|
|
181
|
+
// (one replica's writes wouldn't be visible to another).
|
|
182
|
+
// 2. Both database URLs absent → SQLite + local FS bytes. Single-
|
|
183
|
+
// process; the only pairing the SQLite plane supports.
|
|
184
|
+
// The Neon / Vercel files import their peer deps lazily inside the
|
|
185
|
+
// open functions, so static imports here are safe even on a SQLite-
|
|
186
|
+
// only install where the optional peer deps aren't present. Explicit
|
|
187
|
+
// branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
|
|
188
|
+
// narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
|
|
189
|
+
// `openObjstore` accepts without a non-null assertion.
|
|
190
|
+
// Validate the Neon pubsub driver before opening storage. Creating the bus
|
|
191
|
+
// does not connect it; start() runs once handlers and teardown are installed.
|
|
192
|
+
let pubsub: PubSub = createNoopPubSub()
|
|
193
|
+
let handle: Handle
|
|
194
|
+
let objstoreHandle: ObjstoreHandle
|
|
195
|
+
if (NEON_URL) {
|
|
196
|
+
if (!BLOB_TOKEN) {
|
|
197
|
+
throw new Error([
|
|
198
|
+
'DATABASE_URL or E2E_DATABASE_URL is set but BLOB_READ_WRITE_TOKEN is not.',
|
|
199
|
+
'The Neon DB plane requires the Vercel Blob byte plane (local-FS bytes cannot back a multi-replica deployment).',
|
|
200
|
+
'Set BLOB_READ_WRITE_TOKEN to your Vercel Blob R/W token, or unset the e2e database URL to fall back to SQLite + local FS.',
|
|
201
|
+
].join('\n'))
|
|
202
|
+
}
|
|
203
|
+
if (!TOKEN_SECRET) {
|
|
204
|
+
throw new Error([
|
|
205
|
+
'DATABASE_URL or E2E_DATABASE_URL is set but OBJSTORE_TOKEN_SECRET is not.',
|
|
206
|
+
'Multi-replica deployments need a shared HMAC secret so REST bearer tokens minted on one replica validate on any other.',
|
|
207
|
+
'Generate one with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'',
|
|
208
|
+
].join('\n'))
|
|
209
|
+
}
|
|
210
|
+
// Multi-replica deployments behind a load balancer / TLS terminator
|
|
211
|
+
// (the typical Vercel + Neon shape) need TRUST_PROXY=1 to honour
|
|
212
|
+
// X-Forwarded-Host when computing the same-origin gate's expected
|
|
213
|
+
// origin. Otherwise the gate derives the origin from the internal
|
|
214
|
+
// container hostname and rejects every browser request as a
|
|
215
|
+
// cross-origin attempt — silently from the operator's perspective
|
|
216
|
+
// until users report 403s. Fail fast (parallels the
|
|
217
|
+
// BLOB_READ_WRITE_TOKEN / OBJSTORE_TOKEN_SECRET checks above) so
|
|
218
|
+
// a misconfigured deploy doesn't ship a 100%-403 fleet. An
|
|
219
|
+
// operator who genuinely terminates TLS in the container without
|
|
220
|
+
// X-Forwarded-* (rare) can set `TRUST_PROXY=0` to acknowledge.
|
|
221
|
+
if (!TRUST_PROXY && !LOOPBACK_HOSTS.has(HOST) && TRUST_PROXY_ENV !== '0' && TRUST_PROXY_ENV !== 'false') {
|
|
222
|
+
throw new Error([
|
|
223
|
+
`DATABASE_URL or E2E_DATABASE_URL is set and HOST=${HOST} is not loopback, but TRUST_PROXY is not enabled.`,
|
|
224
|
+
'Browser requests through a load balancer / TLS terminator will be rejected by the same-origin gate (all 403).',
|
|
225
|
+
'Set TRUST_PROXY=1 to honour X-Forwarded-Host / X-Forwarded-Proto from the upstream proxy.',
|
|
226
|
+
'Set TRUST_PROXY=0 if you really terminate TLS in the container without X-Forwarded-* headers (no proxy).',
|
|
227
|
+
].join('\n'))
|
|
228
|
+
}
|
|
229
|
+
// Dynamic import: the Client export lives in the same optional peer
|
|
230
|
+
// dep as the HTTP `neon()` callable (see ./neon-driver.ts), but the
|
|
231
|
+
// dep itself is only present on a Neon-mode deploy. The wrapper path
|
|
232
|
+
// also lets tests swap in a PGlite-backed shim (`tests/pubsub.test.js`).
|
|
233
|
+
const mod = (await import('./neon-driver.ts')) as unknown as { Client?: NeonClientCtor }
|
|
234
|
+
if (!mod.Client) {
|
|
235
|
+
throw new Error([
|
|
236
|
+
'DATABASE_URL or E2E_DATABASE_URL is set but the @neondatabase/serverless Client export is not available.',
|
|
237
|
+
'Cross-instance broadcasts require the WebSocket-based Client (the HTTP `neon()` callable cannot LISTEN).',
|
|
238
|
+
'Reinstall the peer dep: pnpm add @neondatabase/serverless',
|
|
239
|
+
].join('\n'))
|
|
240
|
+
}
|
|
241
|
+
const NeonClientImpl = mod.Client
|
|
242
|
+
pubsub = createNeonPubSub({
|
|
243
|
+
newClient: () => new NeonClientImpl(NEON_URL),
|
|
244
|
+
debug: DEBUG,
|
|
245
|
+
})
|
|
246
|
+
handle = await openNeonDb(NEON_URL)
|
|
247
|
+
rollback.defer(() => handle.close())
|
|
248
|
+
const blob = await openVercelBlobBackend({ token: BLOB_TOKEN })
|
|
249
|
+
objstoreHandle = await openNeonObjstore(NEON_URL, blob)
|
|
250
|
+
} else {
|
|
251
|
+
const sqliteHandle = openDb(DB_PATH)
|
|
252
|
+
handle = sqliteHandle
|
|
253
|
+
rollback.defer(() => handle.close())
|
|
254
|
+
objstoreHandle = openObjstore(sqliteHandle.db, OBJSTORE_DIR)
|
|
255
|
+
}
|
|
256
|
+
rollback.defer(() => pubsub.stop())
|
|
257
|
+
|
|
258
|
+
// "Workspace exists on the server" gate. The auth requirement only
|
|
259
|
+
// kicks in for the FIRST action against a never-before-seen tag —
|
|
260
|
+
// once any row lands (triage revision or objstore object), the
|
|
261
|
+
// workspace is considered established and signature-gated. Checks
|
|
262
|
+
// both planes so the gate stays consistent regardless of which
|
|
263
|
+
// action the user picks first (workspace-save in the common case;
|
|
264
|
+
// objstore-put-begin for a bundle-first flow).
|
|
265
|
+
async function workspaceExists(tag: string): Promise<boolean> {
|
|
266
|
+
if (await handle.headFor.get(tag)) return true
|
|
267
|
+
const c = await objstoreHandle.countLive.get(tag)
|
|
268
|
+
return (c?.c ?? 0) > 0
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// WS fan-out hub: subscriber registry + backpressure-aware send /
|
|
272
|
+
// broadcast (see ./hub.ts).
|
|
273
|
+
const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
|
|
274
|
+
const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
|
|
275
|
+
|
|
276
|
+
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
277
|
+
// handshake.
|
|
278
|
+
const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
|
|
279
|
+
const { requiresAuth, passwordConfigured, handleAuthenticate, sendUnauthorized } = auth
|
|
280
|
+
|
|
281
|
+
// Triage-sync protocol handlers (see ./sync-handlers.ts). `getNonce`
|
|
282
|
+
// resolves a socket's challenge nonce and is shared with the objstore
|
|
283
|
+
// wiring below; `sendSaveError` is reused by the dispatcher's `busy`
|
|
284
|
+
// inflight-cap NACK path.
|
|
285
|
+
const getNonce = (socket: WebSocket): string | undefined => peers.get(socket)?.challenge
|
|
286
|
+
const publishRevision = (tag: string, revisionId: string): void => {
|
|
287
|
+
pubsub.publish({ kind: 'rev', tag, id: revisionId })
|
|
288
|
+
}
|
|
289
|
+
const publishObjPut = (tag: string, resourceTag: string): void => {
|
|
290
|
+
pubsub.publish({ kind: 'objput', tag, res: resourceTag })
|
|
291
|
+
}
|
|
292
|
+
const publishObjDeleted = (tag: string, resourceTag: string, version: number, incarnation: string): void => {
|
|
293
|
+
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version, incarnation })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
297
|
+
handle, send, broadcast, publishRevision, subscribe, getNonce,
|
|
298
|
+
pauseBroadcasts: hub.pauseBroadcasts,
|
|
299
|
+
requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
|
|
300
|
+
// Folds the objstore inventory into the `workspace-subscribed` ack.
|
|
301
|
+
// The objstore store keeps its own richer `Handle`, so we wire the
|
|
302
|
+
// query here where both handles exist rather than coupling
|
|
303
|
+
// sync-handlers to the store type.
|
|
304
|
+
objstoreResources: async (tag) => (await listLive(objstoreHandle, tag)).map(objectMetaWire),
|
|
305
|
+
debug: DEBUG,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const { handlers: objstore, restDeps: objstoreRestDeps, reap, startupReap, stopReaper } = initObjstore({
|
|
309
|
+
handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
310
|
+
reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
311
|
+
send, broadcast, publishObjPut, publishObjDeleted,
|
|
312
|
+
getNonce, debug: DEBUG,
|
|
313
|
+
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
314
|
+
// that doesn't yet exist on the server. Mirrors handleSave's gate
|
|
315
|
+
// below; handlers.ts calls this AFTER sig verify so the
|
|
316
|
+
// `unauthorized` frame only reaches a legitimate signer. Returns
|
|
317
|
+
// `false` to allow, `true` to deny — handlers.ts emits the
|
|
318
|
+
// `unauthorized` frame and bails on `true`.
|
|
319
|
+
authGate: async (socket, tag) => requiresAuth(socket) && !await workspaceExists(tag),
|
|
320
|
+
sendUnauthorized,
|
|
321
|
+
// Socket-less analog of `authGate` for the REST put-begin mint: a REST
|
|
322
|
+
// request can never be operator-authorized, so the gate collapses to
|
|
323
|
+
// "password configured AND workspace new". A deny routes the client to
|
|
324
|
+
// its in-band WS put-begin fallback.
|
|
325
|
+
restPutGate: async (tag) => passwordConfigured && !await workspaceExists(tag),
|
|
326
|
+
// `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
|
|
327
|
+
// provided in env (see TOKEN_SECRET resolution above). Omitted
|
|
328
|
+
// → initObjstore mints a fresh per-process secret (fine for
|
|
329
|
+
// single-replica).
|
|
330
|
+
...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
|
|
331
|
+
})
|
|
332
|
+
rollback.defer(stopReaper)
|
|
333
|
+
|
|
334
|
+
// 4 MiB cap leaves headroom above MAX_CIPHERTEXT_LEN (2 MiB) for
|
|
335
|
+
// the JSON envelope + base64 overhead. `ws` defaults to 100 MiB
|
|
336
|
+
// which any unauthenticated peer could spam — every connection
|
|
337
|
+
// accepts and JSON.parses up to that before the signature-fail drops
|
|
338
|
+
// the frame.
|
|
339
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 })
|
|
340
|
+
rollback.defer(() => new Promise<void>(resolve => { wss.close(() => resolve()) }))
|
|
341
|
+
|
|
342
|
+
// Process lifecycle (see ./lifecycle.ts): `track` (in-flight request
|
|
343
|
+
// drain) and `isShuttingDown` (the new-work gate) are consumed by the
|
|
344
|
+
// HTTP + WS planes below; the teardown is `installLifecycle`d once
|
|
345
|
+
// every server object exists.
|
|
346
|
+
const { track, isShuttingDown, install: installLifecycle } = createLifecycle()
|
|
347
|
+
|
|
348
|
+
// The sync protocol this build advertises — emitted as a `server-info` frame
|
|
349
|
+
// right after the challenge on every connection. This is the e2e boot, so it
|
|
350
|
+
// always advertises e2e.
|
|
351
|
+
const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null, ...(config.deepviewScanServer ? { deepviewScanServer: config.deepviewScanServer } : {}) }
|
|
352
|
+
|
|
353
|
+
// Shared per-connection dispatch surface. Both the WS plane
|
|
354
|
+
// (installWsServer below) and the SSE+POST fallback (installSseServer
|
|
355
|
+
// below) drive `setupPeerConnection` with this; one Peer per accepted
|
|
356
|
+
// connection, one message-handler tree.
|
|
357
|
+
const peerConnectionDeps = {
|
|
358
|
+
peers, serverInfo: SERVER_INFO, send, unsubscribeAll,
|
|
359
|
+
handleSave, handleSubscribe, handleAuthenticate, sendSaveError, objstore,
|
|
360
|
+
track, isShuttingDown,
|
|
361
|
+
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET,
|
|
362
|
+
debug: DEBUG,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// SSE+POST fallback transport (see ./sse-server.ts). The WS upgrade is
|
|
366
|
+
// the preferred path; clients fall back to SSE when `new WebSocket(…)`
|
|
367
|
+
// fails to open (corporate proxies that strip the Upgrade header,
|
|
368
|
+
// hostile network middleboxes, etc.). Same protocol on the wire — only
|
|
369
|
+
// the byte transport differs.
|
|
370
|
+
const sseServer = installSseServer({
|
|
371
|
+
peerDeps: peerConnectionDeps, isShuttingDown,
|
|
372
|
+
// Same cap shape as `wss` (no explicit per-process cap; OS FD limits
|
|
373
|
+
// are the upstream bound). 1024 leaves room above the typical web
|
|
374
|
+
// session count without being a soft DoS hammer.
|
|
375
|
+
maxSessions: 1024,
|
|
376
|
+
// Mirror the WS `maxPayload` so the SSE plane can't accept frames
|
|
377
|
+
// the WS plane would reject.
|
|
378
|
+
maxBodyBytes: 4 * 1024 * 1024,
|
|
379
|
+
debug: DEBUG,
|
|
380
|
+
})
|
|
381
|
+
rollback.defer(() => clearInterval(sseServer.keepaliveTimer))
|
|
382
|
+
|
|
383
|
+
// HTTP plane: REST byte-transfer routing + SSE fallback + the WS
|
|
384
|
+
// upgrade gate (see ./http.ts). Built after the lifecycle state above
|
|
385
|
+
// because the REST shutdown gate reads `shuttingDown` and the request
|
|
386
|
+
// drain uses `track`. The WS connection handler is wired on `wss`
|
|
387
|
+
// below.
|
|
388
|
+
const httpServer = createHttpServer({
|
|
389
|
+
wss, restDeps: objstoreRestDeps, sseServer, serverInfo: SERVER_INFO, isOriginAllowed,
|
|
390
|
+
isShuttingDown, track, handleSaveRest,
|
|
391
|
+
restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
|
|
392
|
+
})
|
|
393
|
+
const requestHandler = httpServer.listeners('request')[0]!
|
|
394
|
+
// Export the domain handler so combined mode installs one top-level reap route.
|
|
395
|
+
const handleRequest = (req: IncomingMessage, res: ServerResponse): void => { requestHandler.call(httpServer, req, res) }
|
|
396
|
+
httpServer.removeListener('request', requestHandler)
|
|
397
|
+
httpServer.on('request', withReap(handleRequest, { e2e: reap }, { isShuttingDown }))
|
|
398
|
+
|
|
399
|
+
// WS runtime: per-connection handler + message dispatch + heartbeat
|
|
400
|
+
// sweep (see ./ws-server.ts). Returns the heartbeat timer so shutdown
|
|
401
|
+
// can clear it.
|
|
402
|
+
const { heartbeatTimer } = installWsServer({
|
|
403
|
+
wss, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
|
|
404
|
+
...peerConnectionDeps,
|
|
405
|
+
})
|
|
406
|
+
rollback.defer(() => clearInterval(heartbeatTimer))
|
|
407
|
+
|
|
408
|
+
httpServer.on('listening', () => {
|
|
409
|
+
// Read the actual bound port from `httpServer.address()` rather
|
|
410
|
+
// than the `PORT` env constant. Operators (and the test harness)
|
|
411
|
+
// can boot with `PORT=0` to get an OS-assigned ephemeral port;
|
|
412
|
+
// the log line then carries the real bound number, not `0`.
|
|
413
|
+
// Server-side bind failure took the error path above, so
|
|
414
|
+
// `address()` is always a populated AddressInfo here.
|
|
415
|
+
const addr = httpServer.address()
|
|
416
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : PORT
|
|
417
|
+
console.log([
|
|
418
|
+
'DeepView triage-sync server:',
|
|
419
|
+
` WebSocket: ws://${HOST}:${boundPort}${WS_UPGRADE_PATH}`,
|
|
420
|
+
` SSE fallback: http://${HOST}:${boundPort}${SSE_OPEN_PATH}`,
|
|
421
|
+
` Object API: http://${HOST}:${boundPort}/api/objstore/{workspaceTag}/{resourceTag}`,
|
|
422
|
+
...e2eStorageLines(config),
|
|
423
|
+
].join('\n'))
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
// Cross-instance bus receiver (see ./bus-receiver.ts): a remote NOTIFY
|
|
427
|
+
// lands here, we re-fetch any data the bus payload only hinted at,
|
|
428
|
+
// then broadcast to local peers via `broadcastLocalRaw`. Extracted so
|
|
429
|
+
// the rev / objput / objdel mapping (including the keyframe boolean
|
|
430
|
+
// coercion and the `objectMetaWire` shape) is testable without the
|
|
431
|
+
// full server bring-up.
|
|
432
|
+
const onBusMessage = createBusReceiver({
|
|
433
|
+
handle, objstoreHandle, broadcastLocalRaw, debug: DEBUG,
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// Kick off the LISTEN loop in the background. `pubsub.start` resolves
|
|
437
|
+
// only after the FIRST successful connect, and the internal reconnect
|
|
438
|
+
// loop retries indefinitely on transport / LISTEN failure — awaiting
|
|
439
|
+
// it here would convert a transient Neon WS hiccup at boot into a
|
|
440
|
+
// server that never binds (and a `.catch` that never runs). Instead
|
|
441
|
+
// the server binds immediately and the bus comes up async; publishes
|
|
442
|
+
// during the down window drop silently (best-effort semantics
|
|
443
|
+
// documented in pubsub.ts). The SQLite no-op resolves immediately,
|
|
444
|
+
// so the behaviour is identical in that mode.
|
|
445
|
+
void pubsub.start(onBusMessage).catch((err) => {
|
|
446
|
+
console.warn('pubsub: startup error:', errStack(err))
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// App-specific shutdown step (run after the in-flight drain), wired
|
|
450
|
+
// into the lifecycle teardown below.
|
|
451
|
+
const shutdownHooks: Array<() => Promise<void>> = []
|
|
452
|
+
// An app sharing this HTTP listener joins the same graceful shutdown.
|
|
453
|
+
function onShutdown(close: () => Promise<void>): void { shutdownHooks.push(close) }
|
|
454
|
+
|
|
455
|
+
const closeDb = async (): Promise<void> => {
|
|
456
|
+
for (const close of shutdownHooks) {
|
|
457
|
+
try { await close() } catch (err) { console.warn('Shared app close error:', errMsg(err)) }
|
|
458
|
+
}
|
|
459
|
+
// Stop the bus first so a publish from a still-draining handler
|
|
460
|
+
// can't fire into a half-closed Client. The in-flight drain runs
|
|
461
|
+
// before this (see lifecycle.ts), so by here all `broadcast` →
|
|
462
|
+
// `publish*` calls are settled.
|
|
463
|
+
try { await pubsub.stop() } catch (err) { console.warn('pubsub close error:', errMsg(err)) }
|
|
464
|
+
// objstoreHandle has no close(): SQLite shares this DatabaseSync and
|
|
465
|
+
// Neon has no persistent connection; `handle.close()` covers both.
|
|
466
|
+
try { await handle.close() } catch (err) { console.warn('DB close error:', errMsg(err)) }
|
|
467
|
+
}
|
|
468
|
+
// Install host-safe disposal once the transports and timers exist.
|
|
469
|
+
installLifecycle({
|
|
470
|
+
httpServer, wss, heartbeatTimer, stopReaper,
|
|
471
|
+
sseSessions: sseServer.sessions,
|
|
472
|
+
sseKeepaliveTimer: sseServer.keepaliveTimer,
|
|
473
|
+
closeDb,
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
// Bind only after the startup orphan sweep finishes — otherwise a
|
|
477
|
+
// fresh boot could serve traffic against tags whose on-disk state
|
|
478
|
+
// still has residue from a prior crash. `startupReap` already
|
|
479
|
+
// resolves on any error (the reaper's own catch logs the failure
|
|
480
|
+
// unconditionally and returns void), so no outer `.catch` is
|
|
481
|
+
// needed here.
|
|
482
|
+
await startupReap
|
|
483
|
+
|
|
484
|
+
return { httpServer, wss, isShuttingDown, reap, handleRequest, onShutdown }
|
|
485
|
+
}
|
|
@@ -31,6 +31,10 @@ export type AuthenticateMsg = { password?: unknown }
|
|
|
31
31
|
export type Auth = {
|
|
32
32
|
// True iff the gate is configured AND this socket hasn't authenticated.
|
|
33
33
|
requiresAuth(socket: WebSocket): boolean
|
|
34
|
+
// Static "is a server password configured" flag — the socket-less
|
|
35
|
+
// predicate the REST put-begin gate needs (a REST request can never be
|
|
36
|
+
// operator-authorized, so `requiresAuth` collapses to this there).
|
|
37
|
+
passwordConfigured: boolean
|
|
34
38
|
handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void
|
|
35
39
|
sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void
|
|
36
40
|
}
|
|
@@ -106,5 +110,5 @@ export function createAuth(deps: {
|
|
|
106
110
|
send(socket, { type: 'authenticated' })
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
return { requiresAuth, handleAuthenticate, sendUnauthorized }
|
|
113
|
+
return { requiresAuth, passwordConfigured: CONFIGURED_PASSWORD_HMAC != null, handleAuthenticate, sendUnauthorized }
|
|
110
114
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Cross-instance bus receiver: maps a `BusMessage` envelope (lookup
|
|
2
2
|
// hint + sender from the Postgres LISTEN/NOTIFY channel) into a local
|
|
3
|
-
// wire broadcast. Pulled out of `server/index.ts` so the rev / objput /
|
|
3
|
+
// wire broadcast. Pulled out of `server-e2e/index.ts` so the rev / objput /
|
|
4
4
|
// objdel branches — including the keyframe boolean coercion and the
|
|
5
5
|
// `objectMetaWire` shape — are testable without bringing up the whole
|
|
6
6
|
// server.
|
|
@@ -69,13 +69,13 @@ export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => P
|
|
|
69
69
|
if (debug) console.warn(`pubsub: objput ${msg.res.slice(0, 8)}… missing for ${msg.tag.slice(0, 12)}…`)
|
|
70
70
|
return
|
|
71
71
|
}
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
72
|
+
// `getLive` returns the CURRENT live row, which may be a strictly
|
|
73
|
+
// NEWER version than the NOTIFY referred to (two closely-spaced
|
|
74
|
+
// puts on one resourceTag arrive with the DB already showing v2
|
|
75
|
+
// for both). Broadcasting v2 twice is sound — client `putHandlers`
|
|
76
|
+
// already absorb same-instance PUT echoes (rest.ts uses
|
|
77
|
+
// `except: null`), so handlers must be idempotent on
|
|
78
|
+
// (resourceTag, version) anyway.
|
|
79
79
|
broadcastLocalRaw(msg.tag, JSON.stringify({
|
|
80
80
|
type: 'objstore-put',
|
|
81
81
|
workspaceTag: msg.tag,
|
|
@@ -90,6 +90,7 @@ export function createBusReceiver(deps: BusReceiverDeps): (msg: BusMessage) => P
|
|
|
90
90
|
workspaceTag: msg.tag,
|
|
91
91
|
resourceTag: msg.res,
|
|
92
92
|
version: msg.ver,
|
|
93
|
+
...(msg.incarnation === undefined ? {} : { incarnation: msg.incarnation }),
|
|
93
94
|
}))
|
|
94
95
|
}
|
|
95
96
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Executable entry for the `triage-server` bin.
|
|
2
|
+
// Executable entry for the `triage-e2e-server` bin.
|
|
3
3
|
//
|
|
4
4
|
// Node refuses to strip TypeScript types from files under node_modules
|
|
5
5
|
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so once this package is
|
|
6
|
-
// installed as a dependency, server/index.ts can't be executed directly.
|
|
6
|
+
// installed as a dependency, server-e2e/index.ts can't be executed directly.
|
|
7
7
|
// This launcher registers the built-in-type-stripping hook FIRST
|
|
8
8
|
// (synchronous, in-thread — see ../strip-types-loader.js), then loads the
|
|
9
9
|
// server with the hook active and starts it.
|
|
@@ -18,5 +18,10 @@
|
|
|
18
18
|
// `import.meta.main` auto-start gate stays off, so we call the exported
|
|
19
19
|
// `start()` ourselves.
|
|
20
20
|
import '../strip-types-loader.js'
|
|
21
|
-
const {
|
|
22
|
-
|
|
21
|
+
const { HELP } = await import('./config.ts')
|
|
22
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
23
|
+
console.log(HELP)
|
|
24
|
+
} else {
|
|
25
|
+
const { start } = await import('./index.ts')
|
|
26
|
+
start()
|
|
27
|
+
}
|