@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.2
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/out/client-sync.js +14 -10
- package/out/graph.js +4 -4
- package/out/index.html +42 -38
- package/out/view.css +1 -1
- package/out/view.js +50 -47
- package/package.json +15 -3
- package/server/auth.ts +11 -0
- package/server/bus-receiver.ts +95 -0
- package/server/cli.js +22 -0
- package/server/db-neon.ts +39 -23
- package/server/db-revision-sql.ts +9 -0
- package/server/db.ts +18 -1
- package/server/http.ts +41 -5
- package/server/hub.ts +22 -2
- package/server/index.ts +150 -22
- package/server/lifecycle.ts +29 -2
- package/server/npm-proxy.ts +348 -0
- package/server/objstore/blob-vercel.ts +23 -2
- package/server/objstore/handlers.ts +12 -0
- package/server/objstore/init.ts +7 -1
- package/server/objstore/rest.ts +18 -0
- package/server/objstore/store-neon.ts +12 -12
- package/server/pubsub.ts +404 -0
- package/server/sse-server.ts +352 -0
- package/server/sse-session.ts +202 -0
- package/server/sync-handlers.ts +17 -1
- package/server/ws-server.ts +199 -174
- package/strip-types-loader.js +94 -0
package/server/index.ts
CHANGED
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
// and would not reject the attaching socket.
|
|
74
74
|
|
|
75
75
|
import { type WebSocket, WebSocketServer } from 'ws'
|
|
76
|
-
import { errMsg } from './util.ts'
|
|
76
|
+
import { errMsg, errStack } from './util.ts'
|
|
77
77
|
import type { PeerRegistry } from './peer.ts'
|
|
78
78
|
import { LOOPBACK_HOSTS, createOriginGate } from './origin.ts'
|
|
79
79
|
import { createHub } from './hub.ts'
|
|
@@ -81,6 +81,7 @@ import { createAuth } from './auth.ts'
|
|
|
81
81
|
import { createSyncHandlers } from './sync-handlers.ts'
|
|
82
82
|
import { WS_UPGRADE_PATH, createHttpServer } from './http.ts'
|
|
83
83
|
import { installWsServer } from './ws-server.ts'
|
|
84
|
+
import { SSE_OPEN_PATH, installSseServer } from './sse-server.ts'
|
|
84
85
|
import { createLifecycle } from './lifecycle.ts'
|
|
85
86
|
import { loadConfig } from './config.ts'
|
|
86
87
|
import { type Handle, openDb } from './db.ts'
|
|
@@ -89,6 +90,11 @@ import { initObjstore } from './objstore/init.ts'
|
|
|
89
90
|
import { type Handle as ObjstoreHandle, listLive, objectMetaWire, openObjstore } from './objstore/store.ts'
|
|
90
91
|
import { openNeonObjstore } from './objstore/store-neon.ts'
|
|
91
92
|
import { openVercelBlobBackend } from './objstore/blob-vercel.ts'
|
|
93
|
+
import {
|
|
94
|
+
type NeonClientCtor, type PubSub,
|
|
95
|
+
createNeonPubSub, createNoopPubSub,
|
|
96
|
+
} from './pubsub.ts'
|
|
97
|
+
import { createBusReceiver } from './bus-receiver.ts'
|
|
92
98
|
|
|
93
99
|
// All external inputs (env vars + optional config.json) are parsed
|
|
94
100
|
// and validated in ./config.ts. Destructure into the existing
|
|
@@ -235,7 +241,39 @@ async function workspaceExists(tag: string): Promise<boolean> {
|
|
|
235
241
|
// broadcast (see ./hub.ts). Destructure into the existing names so the
|
|
236
242
|
// handlers / dispatcher / objstore wiring below read unchanged.
|
|
237
243
|
const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
|
|
238
|
-
const { send, broadcast, subscribe, unsubscribeAll } = hub
|
|
244
|
+
const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
|
|
245
|
+
|
|
246
|
+
// Cross-instance pub/sub for live broadcasts (see ./pubsub.ts). SQLite
|
|
247
|
+
// mode is single-process by construction so it gets a no-op; Neon mode
|
|
248
|
+
// uses Postgres LISTEN/NOTIFY on a dedicated long-lived Client
|
|
249
|
+
// connection (separate from the HTTP `neon()` callable the queries flow
|
|
250
|
+
// over — LISTEN needs a session-bound socket, the HTTP path is
|
|
251
|
+
// stateless). The bus carries lookup hints, not the full wire payload:
|
|
252
|
+
// the `workspace-state` broadcast's ciphertext alone can exceed the
|
|
253
|
+
// ~8 KB NOTIFY payload cap, so the receiver re-fetches from the shared
|
|
254
|
+
// DB to construct the wire frame. The bus is best-effort fan-out, not a
|
|
255
|
+
// durability layer — the DB itself is the source of truth, and a
|
|
256
|
+
// dropped publish only means peers on other instances miss the live
|
|
257
|
+
// push (they still catch up via the chain on their next subscribe).
|
|
258
|
+
let pubsub: PubSub = createNoopPubSub()
|
|
259
|
+
if (NEON_URL) {
|
|
260
|
+
// Dynamic import: the Client export lives in the same optional peer
|
|
261
|
+
// dep as the HTTP `neon()` callable (see ./neon-driver.ts), but the
|
|
262
|
+
// dep itself is only present on a Neon-mode deploy. The wrapper path
|
|
263
|
+
// also lets tests swap in a PGlite-backed shim (`tests/pubsub.test.js`).
|
|
264
|
+
const mod = (await import('./neon-driver.ts')) as unknown as { Client?: NeonClientCtor }
|
|
265
|
+
if (!mod.Client) {
|
|
266
|
+
console.error('DATABASE_URL is set but the @neondatabase/serverless Client export is not available.')
|
|
267
|
+
console.error('Cross-instance broadcasts require the WebSocket-based Client (the HTTP `neon()` callable cannot LISTEN).')
|
|
268
|
+
console.error('Reinstall the peer dep: pnpm add @neondatabase/serverless')
|
|
269
|
+
process.exit(1)
|
|
270
|
+
}
|
|
271
|
+
const NeonClientImpl = mod.Client
|
|
272
|
+
pubsub = createNeonPubSub({
|
|
273
|
+
newClient: () => new NeonClientImpl(NEON_URL),
|
|
274
|
+
debug: DEBUG,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
239
277
|
|
|
240
278
|
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
241
279
|
// handshake. Destructure into the existing names for the wiring below.
|
|
@@ -247,8 +285,18 @@ const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
|
|
|
247
285
|
// wiring below; `sendSaveError` is reused by the dispatcher's `busy`
|
|
248
286
|
// inflight-cap NACK path.
|
|
249
287
|
const getNonce = (socket: WebSocket): string | undefined => peers.get(socket)?.challenge
|
|
288
|
+
const publishRevision = (tag: string, revisionId: string): void => {
|
|
289
|
+
pubsub.publish({ kind: 'rev', tag, id: revisionId })
|
|
290
|
+
}
|
|
291
|
+
const publishObjPut = (tag: string, resourceTag: string): void => {
|
|
292
|
+
pubsub.publish({ kind: 'objput', tag, res: resourceTag })
|
|
293
|
+
}
|
|
294
|
+
const publishObjDeleted = (tag: string, resourceTag: string, version: number): void => {
|
|
295
|
+
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
|
|
296
|
+
}
|
|
297
|
+
|
|
250
298
|
const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
251
|
-
handle, send, broadcast, subscribe, getNonce,
|
|
299
|
+
handle, send, broadcast, publishRevision, subscribe, getNonce,
|
|
252
300
|
requiresAuth, sendUnauthorized, workspaceExists,
|
|
253
301
|
// Folds the objstore inventory into the `workspace-subscribed` ack.
|
|
254
302
|
// The objstore store keeps its own richer `Handle`, so we wire the
|
|
@@ -260,7 +308,8 @@ const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
|
260
308
|
|
|
261
309
|
const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper } = initObjstore({
|
|
262
310
|
handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
263
|
-
send, broadcast,
|
|
311
|
+
send, broadcast, publishObjPut, publishObjDeleted,
|
|
312
|
+
getNonce, debug: DEBUG,
|
|
264
313
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
265
314
|
// that doesn't yet exist on the server. Mirrors handleSave's gate
|
|
266
315
|
// below; handlers.ts calls this AFTER sig verify so the
|
|
@@ -289,12 +338,46 @@ const wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 })
|
|
|
289
338
|
// every server object exists.
|
|
290
339
|
const { track, isShuttingDown, install: installLifecycle } = createLifecycle()
|
|
291
340
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
341
|
+
// Shared per-connection dispatch surface. Both the WS plane
|
|
342
|
+
// (installWsServer below) and the SSE+POST fallback (installSseServer
|
|
343
|
+
// below) drive `setupPeerConnection` with this; one Peer per accepted
|
|
344
|
+
// connection, one message-handler tree.
|
|
345
|
+
const peerConnectionDeps = {
|
|
346
|
+
peers, send, unsubscribeAll,
|
|
347
|
+
handleSave, handleSubscribe, handleAuthenticate, sendSaveError, objstore,
|
|
348
|
+
track, isShuttingDown,
|
|
349
|
+
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET,
|
|
350
|
+
debug: DEBUG,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// SSE+POST fallback transport (see ./sse-server.ts). The WS upgrade is
|
|
354
|
+
// the preferred path; clients fall back to SSE when `new WebSocket(…)`
|
|
355
|
+
// fails to open (corporate proxies that strip the Upgrade header,
|
|
356
|
+
// hostile network middleboxes, etc.). Same protocol on the wire — only
|
|
357
|
+
// the byte transport differs.
|
|
358
|
+
const sseServer = installSseServer({
|
|
359
|
+
peerDeps: peerConnectionDeps, isShuttingDown,
|
|
360
|
+
// Same cap shape as `wss` (no explicit per-process cap; OS FD limits
|
|
361
|
+
// are the upstream bound). 1024 leaves room above the typical web
|
|
362
|
+
// session count without being a soft DoS hammer.
|
|
363
|
+
maxSessions: 1024,
|
|
364
|
+
// Mirror the WS `maxPayload` so the SSE plane can't accept frames
|
|
365
|
+
// the WS plane would reject.
|
|
366
|
+
maxBodyBytes: 4 * 1024 * 1024,
|
|
367
|
+
// 90s idle ceiling. The client's JSON ping/pong is 15s; this is ~6
|
|
368
|
+
// missed pings before we close — well past any transient network
|
|
369
|
+
// hiccup, much shorter than the kernel's hours-long TCP keepalive.
|
|
370
|
+
sessionIdleMs: 90_000,
|
|
371
|
+
debug: DEBUG,
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// HTTP plane: REST byte-transfer routing + SSE fallback + the WS
|
|
375
|
+
// upgrade gate (see ./http.ts). Built after the lifecycle state above
|
|
376
|
+
// because the REST shutdown gate reads `shuttingDown` and the request
|
|
377
|
+
// drain uses `track`. The WS connection handler is wired on `wss`
|
|
378
|
+
// below.
|
|
296
379
|
const httpServer = createHttpServer({
|
|
297
|
-
wss, restDeps: objstoreRestDeps, isOriginAllowed,
|
|
380
|
+
wss, restDeps: objstoreRestDeps, sseServer, isOriginAllowed,
|
|
298
381
|
isShuttingDown, track,
|
|
299
382
|
restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
|
|
300
383
|
})
|
|
@@ -303,11 +386,8 @@ const httpServer = createHttpServer({
|
|
|
303
386
|
// sweep (see ./ws-server.ts). Returns the heartbeat timer so shutdown
|
|
304
387
|
// can clear it.
|
|
305
388
|
const { heartbeatTimer } = installWsServer({
|
|
306
|
-
wss,
|
|
307
|
-
|
|
308
|
-
track, isShuttingDown,
|
|
309
|
-
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
|
|
310
|
-
debug: DEBUG,
|
|
389
|
+
wss, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
|
|
390
|
+
...peerConnectionDeps,
|
|
311
391
|
})
|
|
312
392
|
|
|
313
393
|
httpServer.on('listening', () => {
|
|
@@ -323,12 +403,40 @@ httpServer.on('listening', () => {
|
|
|
323
403
|
// doesn't claim a misleading DB_PATH under Neon, or a misleading
|
|
324
404
|
// OBJSTORE_DIR under Vercel Blob.
|
|
325
405
|
const dbBanner = NEON_URL ? 'db: neon-postgres' : `db: ${DB_PATH}`
|
|
326
|
-
console.log(`DeepView triage-sync server: ws://${HOST}:${boundPort}${WS_UPGRADE_PATH} http://${HOST}:${boundPort}/api/objstore/{workspaceTag}/{resourceTag} (${dbBanner}, ${objstoreBanner})`)
|
|
406
|
+
console.log(`DeepView triage-sync server: ws://${HOST}:${boundPort}${WS_UPGRADE_PATH} (sse fallback http://${HOST}:${boundPort}${SSE_OPEN_PATH}) http://${HOST}:${boundPort}/api/objstore/{workspaceTag}/{resourceTag} (${dbBanner}, ${objstoreBanner})`)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// Cross-instance bus receiver (see ./bus-receiver.ts): a remote NOTIFY
|
|
410
|
+
// lands here, we re-fetch any data the bus payload only hinted at,
|
|
411
|
+
// then broadcast to local peers via `broadcastLocalRaw`. Extracted so
|
|
412
|
+
// the rev / objput / objdel mapping (including the keyframe boolean
|
|
413
|
+
// coercion and the `objectMetaWire` shape) is testable without the
|
|
414
|
+
// full server bring-up.
|
|
415
|
+
const onBusMessage = createBusReceiver({
|
|
416
|
+
handle, objstoreHandle, broadcastLocalRaw, debug: DEBUG,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// Kick off the LISTEN loop in the background. `pubsub.start` resolves
|
|
420
|
+
// only after the FIRST successful connect, and the internal reconnect
|
|
421
|
+
// loop retries indefinitely on transport / LISTEN failure — awaiting
|
|
422
|
+
// it here would convert a transient Neon WS hiccup at boot into a
|
|
423
|
+
// server that never binds (and a `.catch` that never runs). Instead
|
|
424
|
+
// the server binds immediately and the bus comes up async; publishes
|
|
425
|
+
// during the down window drop silently (best-effort semantics
|
|
426
|
+
// documented in pubsub.ts). The SQLite no-op resolves immediately,
|
|
427
|
+
// so the behaviour is identical in that mode.
|
|
428
|
+
void pubsub.start(onBusMessage).catch((err) => {
|
|
429
|
+
console.warn('pubsub: startup error:', errStack(err))
|
|
327
430
|
})
|
|
328
431
|
|
|
329
432
|
// App-specific shutdown step (run after the in-flight drain), wired
|
|
330
433
|
// into the lifecycle teardown below.
|
|
331
434
|
const closeDb = async (): Promise<void> => {
|
|
435
|
+
// Stop the bus first so a publish from a still-draining handler
|
|
436
|
+
// can't fire into a half-closed Client. The in-flight drain runs
|
|
437
|
+
// before this (see lifecycle.ts), so by here all `broadcast` →
|
|
438
|
+
// `publish*` calls are settled.
|
|
439
|
+
try { await pubsub.stop() } catch (err) { console.warn('pubsub close error:', errMsg(err)) }
|
|
332
440
|
// objstoreHandle has no close(): SQLite shares this DatabaseSync and
|
|
333
441
|
// Neon has no persistent connection; `handle.close()` covers both.
|
|
334
442
|
try { await handle.close() } catch (err) { console.warn('DB close error:', errMsg(err)) }
|
|
@@ -338,16 +446,36 @@ const closeDb = async (): Promise<void> => {
|
|
|
338
446
|
// and the heartbeat timer all exist.
|
|
339
447
|
installLifecycle({
|
|
340
448
|
httpServer, wss, heartbeatTimer, stopReaper,
|
|
449
|
+
sseSessions: sseServer.sessions,
|
|
341
450
|
closeDb,
|
|
342
451
|
})
|
|
343
452
|
|
|
344
453
|
// Bind only after the startup orphan sweep finishes — otherwise a
|
|
345
454
|
// fresh boot could serve traffic against tags whose on-disk state
|
|
346
|
-
// still has residue from a prior crash.
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// failure unconditionally and returns void), so no outer `.catch`
|
|
351
|
-
// is needed here.
|
|
455
|
+
// still has residue from a prior crash. `startupReap` already
|
|
456
|
+
// resolves on any error (the reaper's own catch logs the failure
|
|
457
|
+
// unconditionally and returns void), so no outer `.catch` is
|
|
458
|
+
// needed here.
|
|
352
459
|
await startupReap
|
|
353
|
-
|
|
460
|
+
|
|
461
|
+
// Start serving: bind the HTTP/WS plane on the configured PORT/HOST.
|
|
462
|
+
// Exported as `start()` so a launcher (server/cli.js — the triage-server
|
|
463
|
+
// bin) or any consumer that `import`ed this module can begin serving once
|
|
464
|
+
// the top-level `await startupReap` above has settled (the server is fully
|
|
465
|
+
// ready — DB open, DDL bootstrapped, objstore reaper swept — by the time
|
|
466
|
+
// the import resolves).
|
|
467
|
+
export function start(): void {
|
|
468
|
+
httpServer.listen(PORT, HOST)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export { httpServer, wss }
|
|
472
|
+
|
|
473
|
+
// Library mode: when this module is `import`ed (rather than run as the
|
|
474
|
+
// entry script) skip the auto-start so consumers can own the bind — e.g.
|
|
475
|
+
// wrap CF Access / framework-preset shims around the assembled
|
|
476
|
+
// `httpServer`, or just call `start()` when ready. Direct invocation via
|
|
477
|
+
// `node server/index.ts` (or the `triage-server` bin) still listens.
|
|
478
|
+
if (import.meta.main) {
|
|
479
|
+
start()
|
|
480
|
+
}
|
|
481
|
+
|
package/server/lifecycle.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Server } from 'node:http'
|
|
9
9
|
import type { WebSocketServer } from 'ws'
|
|
10
|
+
import type { SseSession } from './sse-session.ts'
|
|
10
11
|
import { errStack } from './util.ts'
|
|
11
12
|
|
|
12
13
|
export type ShutdownDeps = {
|
|
@@ -15,6 +16,11 @@ export type ShutdownDeps = {
|
|
|
15
16
|
heartbeatTimer: ReturnType<typeof setInterval>
|
|
16
17
|
// Stops the periodic reaper AND awaits any in-flight sweep.
|
|
17
18
|
stopReaper: () => Promise<void>
|
|
19
|
+
// Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
|
|
20
|
+
// fallback transport). Walked alongside `wss.clients` to send the
|
|
21
|
+
// 1001 close frame and apply the terminate-grace timer to both
|
|
22
|
+
// transports uniformly.
|
|
23
|
+
sseSessions: () => Iterable<SseSession>
|
|
18
24
|
// App-specific teardown, run AFTER the in-flight drain: close the DB.
|
|
19
25
|
// Swallows its own errors (a failed close shouldn't abort the exit).
|
|
20
26
|
closeDb: () => Promise<void>
|
|
@@ -40,7 +46,12 @@ export function createLifecycle(): Lifecycle {
|
|
|
40
46
|
const inFlight = new Set<Promise<unknown>>()
|
|
41
47
|
function track(promise: Promise<unknown>): void {
|
|
42
48
|
inFlight.add(promise)
|
|
43
|
-
|
|
49
|
+
// Trailing `.catch(() => {})` swallows the rejection that `.finally`
|
|
50
|
+
// propagates through its returned promise — without it, a tracked
|
|
51
|
+
// handler that rejects (or whose caller's catch handler itself
|
|
52
|
+
// throws) trips the `unhandledRejection` catchall below and crashes
|
|
53
|
+
// the process via `fireShutdown(1)`.
|
|
54
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {})
|
|
44
55
|
}
|
|
45
56
|
let shuttingDown = false
|
|
46
57
|
// Live exit code the in-progress shutdown will pass to `process.exit`.
|
|
@@ -51,7 +62,7 @@ export function createLifecycle(): Lifecycle {
|
|
|
51
62
|
let pendingExitCode = 0
|
|
52
63
|
|
|
53
64
|
function install(deps: ShutdownDeps): void {
|
|
54
|
-
const { httpServer, wss, heartbeatTimer, stopReaper, closeDb } = deps
|
|
65
|
+
const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
|
|
55
66
|
|
|
56
67
|
async function shutdown(exitCode: number = 0): Promise<void> {
|
|
57
68
|
// Re-entry: don't restart the teardown, but escalate the pending
|
|
@@ -78,6 +89,13 @@ export function createLifecycle(): Lifecycle {
|
|
|
78
89
|
for (const socket of wss.clients) {
|
|
79
90
|
try { socket.close(1001, 'Server shutting down') } catch {}
|
|
80
91
|
}
|
|
92
|
+
// Same close frame on every SSE+POST session — the adapter
|
|
93
|
+
// translates `close(1001, …)` into a structured `event: close`
|
|
94
|
+
// record on the SSE stream so the client transport can read the
|
|
95
|
+
// code and skip its reconnect backoff (parity with the WS path).
|
|
96
|
+
for (const session of sseSessions()) {
|
|
97
|
+
try { session.close(1001, 'Server shutting down') } catch {}
|
|
98
|
+
}
|
|
81
99
|
// Force-terminate any client that doesn't ack the close frame
|
|
82
100
|
// within a short grace window. `wss.close()` waits for every client
|
|
83
101
|
// to emit `'close'`, and `ws` only TCP-RSTs unresponsive peers
|
|
@@ -92,6 +110,15 @@ export function createLifecycle(): Lifecycle {
|
|
|
92
110
|
try { socket.terminate() } catch {}
|
|
93
111
|
}
|
|
94
112
|
}
|
|
113
|
+
// SSE sessions are tracked separately from `wss.clients`; apply
|
|
114
|
+
// the same terminate-grace policy so a stranded SSE response
|
|
115
|
+
// can't pin the HTTP keep-alive shutdown branch below.
|
|
116
|
+
for (const session of sseSessions()) {
|
|
117
|
+
const rs = session.readyState
|
|
118
|
+
if (rs === session.OPEN || rs === session.CLOSING) {
|
|
119
|
+
try { session.terminate() } catch {}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
95
122
|
// Same grace for HTTP keep-alive sockets that didn't respect the
|
|
96
123
|
// `Connection: close` hint, which would otherwise hold
|
|
97
124
|
// `httpServer.close()` until their TCP timeout.
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Same-origin proxy for the npm registry's bulk advisories endpoint
|
|
2
|
+
// (`POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk`).
|
|
3
|
+
// The UI's Advisories tab on stasis bundles fans out the bundle's
|
|
4
|
+
// (package → versions) map through this endpoint to enrich the view
|
|
5
|
+
// with upstream vulnerability data; calling the registry directly
|
|
6
|
+
// from the browser would cross-origin (the npm registry doesn't
|
|
7
|
+
// emit CORS headers for arbitrary callers), so the relay forwards
|
|
8
|
+
// the request body and answers with the upstream's JSON.
|
|
9
|
+
//
|
|
10
|
+
// The route is mounted at `/api/npm-advisories`. The same-origin
|
|
11
|
+
// gate runs inside `dispatchNpmAdvisories` below (server/http.ts
|
|
12
|
+
// calls the dispatcher directly without a pre-check), so any
|
|
13
|
+
// browser request from a foreign origin is rejected with 403 before
|
|
14
|
+
// we ever issue a fetch — non-browser callers that omit Origin are
|
|
15
|
+
// allowed (their trust boundary is the network, same posture as
|
|
16
|
+
// every other /api/* route).
|
|
17
|
+
//
|
|
18
|
+
// Request body is capped at `REQUEST_BODY_LIMIT` (the registry caps
|
|
19
|
+
// a single bulk lookup well under that); upstream response is
|
|
20
|
+
// buffered up to `RESPONSE_BODY_LIMIT` and then `JSON.parse`-asserted
|
|
21
|
+
// before we writeHead, so the client's `await res.json()` never
|
|
22
|
+
// chokes on a Cloudflare HTML 503 page or a captive-portal banner.
|
|
23
|
+
// A non-parseable upstream collapses to a 502 with a documented
|
|
24
|
+
// `{ error, upstreamStatus, upstreamContentType }` envelope.
|
|
25
|
+
//
|
|
26
|
+
// Outbound fetch carries an `AbortController` so a hung upstream
|
|
27
|
+
// (slow-loris response, TLS stall) tears down after
|
|
28
|
+
// `UPSTREAM_TIMEOUT_MS`, and a client that closes its connection
|
|
29
|
+
// mid-fetch propagates an abort through the same controller — so a
|
|
30
|
+
// stranded in-flight call doesn't block SIGTERM drain.
|
|
31
|
+
|
|
32
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
33
|
+
import { Buffer } from 'node:buffer'
|
|
34
|
+
import { errStack } from './util.ts'
|
|
35
|
+
|
|
36
|
+
type HasHeaders = { headers: IncomingMessage['headers'] }
|
|
37
|
+
|
|
38
|
+
export const NPM_ADVISORIES_PATH = '/api/npm-advisories'
|
|
39
|
+
const UPSTREAM_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
|
|
40
|
+
|
|
41
|
+
// 1 MiB is generous: the bulk endpoint accepts `{ packageName:
|
|
42
|
+
// [versions] }` maps, and even a bundle with thousands of pinned
|
|
43
|
+
// versions serialises to well under this. Anything bigger is almost
|
|
44
|
+
// certainly malformed input or a probe.
|
|
45
|
+
const REQUEST_BODY_LIMIT = 1 * 1024 * 1024
|
|
46
|
+
// 4 MiB caps the upstream response. The advisories endpoint
|
|
47
|
+
// returns at most a few CVEs per package, so even a bundle with
|
|
48
|
+
// hundreds of affected packages comes in well under this — a
|
|
49
|
+
// runaway / hostile upstream gets cut off before we burn arbitrary
|
|
50
|
+
// memory buffering it.
|
|
51
|
+
const RESPONSE_BODY_LIMIT = 4 * 1024 * 1024
|
|
52
|
+
// Hard deadline on the upstream call. The bulk endpoint typically
|
|
53
|
+
// answers in well under a second; 30 s leaves plenty of headroom
|
|
54
|
+
// for a slow path but caps a hung TLS / slow-loris connection so
|
|
55
|
+
// a stranded fetch can't pin the inflight slot through SIGTERM
|
|
56
|
+
// drain. Triggered via AbortController.
|
|
57
|
+
const UPSTREAM_TIMEOUT_MS = 30_000
|
|
58
|
+
|
|
59
|
+
export type NpmProxyDeps = {
|
|
60
|
+
debug: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function matchNpmAdvisoriesRoute(url: string | undefined): boolean {
|
|
64
|
+
if (typeof url !== 'string') return false
|
|
65
|
+
return url.split('?', 1)[0] === NPM_ADVISORIES_PATH
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DispatchDeps = {
|
|
69
|
+
isOriginAllowed: (req: HasHeaders) => boolean
|
|
70
|
+
isShuttingDown: () => boolean
|
|
71
|
+
debug: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Combined route-match + shutdown-gate + same-origin-gate + handler
|
|
75
|
+
// dispatch. Returns the in-flight Promise when the request matched
|
|
76
|
+
// (caller `track`s it so SIGTERM awaits drainage), or null when the
|
|
77
|
+
// route wasn't ours (caller falls through to the next branch).
|
|
78
|
+
export function dispatchNpmAdvisories(
|
|
79
|
+
req: IncomingMessage,
|
|
80
|
+
res: ServerResponse,
|
|
81
|
+
deps: DispatchDeps,
|
|
82
|
+
): Promise<void> | null {
|
|
83
|
+
if (!matchNpmAdvisoriesRoute(req.url)) return null
|
|
84
|
+
if (deps.isShuttingDown()) {
|
|
85
|
+
res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
|
|
86
|
+
res.end(JSON.stringify({ error: 'shutting-down' }))
|
|
87
|
+
return Promise.resolve()
|
|
88
|
+
}
|
|
89
|
+
if (!deps.isOriginAllowed(req)) {
|
|
90
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
92
|
+
return Promise.resolve()
|
|
93
|
+
}
|
|
94
|
+
return handleNpmAdvisories({ debug: deps.debug }, req, res).catch((err) => {
|
|
95
|
+
console.warn('npm-advisories handler error:', errStack(err))
|
|
96
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
97
|
+
else {
|
|
98
|
+
try {
|
|
99
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
100
|
+
res.end(JSON.stringify({ error: 'internal' }))
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// True iff the response is still in a writable state. `writableEnded`
|
|
107
|
+
// flips only after `res.end()` resolves; a client-disconnect tears
|
|
108
|
+
// the socket down asynchronously, leaving a brief window where
|
|
109
|
+
// `writableEnded` is still false but `destroyed` is true and any
|
|
110
|
+
// write throws ERR_STREAM_DESTROYED. Checking both keeps the
|
|
111
|
+
// after-disconnect log path quiet.
|
|
112
|
+
function canWrite(res: ServerResponse): boolean {
|
|
113
|
+
return !res.writableEnded && !res.destroyed
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Write a JSON error envelope, swallowing throws if the socket
|
|
117
|
+
// already closed mid-write. The outer try/catch is the belt to
|
|
118
|
+
// `canWrite`'s suspenders — a `destroyed` flip between the gate
|
|
119
|
+
// check and `res.end()` is rare but possible on a busy connection,
|
|
120
|
+
// and there's nothing useful to do besides drop the write.
|
|
121
|
+
function deny(res: ServerResponse, status: number, reason: string): void {
|
|
122
|
+
if (!canWrite(res)) return
|
|
123
|
+
try {
|
|
124
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
125
|
+
res.end(JSON.stringify({ error: reason }))
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Same shape as `deny` but for the richer multi-field envelopes
|
|
130
|
+
// (upstream-not-json / upstream-too-large) — these can't reuse
|
|
131
|
+
// `deny` because the body carries more than `{ error }`.
|
|
132
|
+
function writeJsonEnvelope(res: ServerResponse, status: number, body: object): void {
|
|
133
|
+
if (!canWrite(res)) return
|
|
134
|
+
try {
|
|
135
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
136
|
+
res.end(JSON.stringify(body))
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Drain the incoming request body into a single Buffer, rejecting
|
|
141
|
+
// once REQUEST_BODY_LIMIT is exceeded. We don't try to parse here —
|
|
142
|
+
// we forward the bytes verbatim so the registry sees exactly what
|
|
143
|
+
// the client sent (preserving key order / whitespace doesn't matter
|
|
144
|
+
// to the upstream, but parse-then-restringify is wasted work).
|
|
145
|
+
async function readRequestBody(req: IncomingMessage): Promise<Buffer | null> {
|
|
146
|
+
const chunks: Buffer[] = []
|
|
147
|
+
let received = 0
|
|
148
|
+
for await (const chunk of req) {
|
|
149
|
+
const buf = chunk as Buffer
|
|
150
|
+
received += buf.byteLength
|
|
151
|
+
if (received > REQUEST_BODY_LIMIT) return null
|
|
152
|
+
chunks.push(buf)
|
|
153
|
+
}
|
|
154
|
+
return Buffer.concat(chunks)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function handleNpmAdvisories(
|
|
158
|
+
deps: NpmProxyDeps,
|
|
159
|
+
req: IncomingMessage,
|
|
160
|
+
res: ServerResponse,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
if (req.method !== 'POST') { deny(res, 405, 'method-not-allowed'); return }
|
|
163
|
+
// Single controller drives both the upstream deadline timer AND
|
|
164
|
+
// the client-disconnect propagation: a fetch hung past
|
|
165
|
+
// UPSTREAM_TIMEOUT_MS and a browser tab closed mid-fetch both end
|
|
166
|
+
// up aborting the same signal, which undici threads through into
|
|
167
|
+
// the body reader. Without this the inflight slot pinned by
|
|
168
|
+
// `track()` could outlive both a dead client and a wedged
|
|
169
|
+
// upstream, holding SIGTERM drain open.
|
|
170
|
+
//
|
|
171
|
+
// Install the listener + timer BEFORE the body read so a client
|
|
172
|
+
// disconnect during the upload window also flips the controller
|
|
173
|
+
// (and so a tail-end disconnect doesn't race the listener
|
|
174
|
+
// registration). The timer is overall budget — running across the
|
|
175
|
+
// body read + upstream call together is fine; the body read is
|
|
176
|
+
// bounded by REQUEST_BODY_LIMIT and finishes in ms.
|
|
177
|
+
//
|
|
178
|
+
// `res.on('close')` (NOT `req.on('close')`) is the right
|
|
179
|
+
// disconnect signal here: IncomingMessage's `close` fires when
|
|
180
|
+
// the REQUEST is fully drained — even on a clean POST that's
|
|
181
|
+
// followed by a healthy response — and would always trigger an
|
|
182
|
+
// abort the instant we finished reading the body. The
|
|
183
|
+
// ServerResponse's `close` event only fires when the underlying
|
|
184
|
+
// socket gets destroyed before `res.end()` completes, which is
|
|
185
|
+
// exactly the "browser tab closed mid-fetch" case we want to
|
|
186
|
+
// propagate. (Gate on `writableEnded` so a post-success close
|
|
187
|
+
// doesn't fire a no-op abort — itself a no-op on a settled
|
|
188
|
+
// controller, but skipping the log noise.)
|
|
189
|
+
const controller = new AbortController()
|
|
190
|
+
const timer = setTimeout(() => { try { controller.abort() } catch {} }, UPSTREAM_TIMEOUT_MS)
|
|
191
|
+
const onResClose = (): void => {
|
|
192
|
+
if (!res.writableEnded) controller.abort()
|
|
193
|
+
}
|
|
194
|
+
res.on('close', onResClose)
|
|
195
|
+
try {
|
|
196
|
+
let body: Buffer | null
|
|
197
|
+
try {
|
|
198
|
+
body = await readRequestBody(req)
|
|
199
|
+
} catch (err: unknown) {
|
|
200
|
+
// `for await (chunk of req)` throws on mid-upload connection
|
|
201
|
+
// drops (ECONNRESET / aborted). The client is already gone,
|
|
202
|
+
// so there's no useful response to write — and bubbling up
|
|
203
|
+
// to the dispatcher's catch would just emit a misleading
|
|
204
|
+
// "handler error" log. Same posture other handlers take for
|
|
205
|
+
// mid-body aborts: swallow + return.
|
|
206
|
+
if (deps.debug) console.warn('npm-advisories request body error:', errStack(err))
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (body === null) {
|
|
210
|
+
// Respond BEFORE destroying so the client sees the 413
|
|
211
|
+
// envelope (writeHead on a destroyed socket would silently
|
|
212
|
+
// drop). Then destroy: a bare `return` leaves the unread
|
|
213
|
+
// tail of the request body in the kernel buffer, which on
|
|
214
|
+
// an HTTP/1.1 keep-alive connection becomes the
|
|
215
|
+
// start-of-line for the NEXT request and corrupts request
|
|
216
|
+
// framing. Matches the sse-server.ts pattern
|
|
217
|
+
// (`{ error: 'too-large' }` then `req.destroy()`).
|
|
218
|
+
deny(res, 413, 'payload-too-large')
|
|
219
|
+
try { req.destroy() } catch {}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
await handleNpmAdvisoriesInner(deps, body, res, controller.signal)
|
|
223
|
+
} finally {
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
res.off('close', onResClose)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function handleNpmAdvisoriesInner(
|
|
230
|
+
deps: NpmProxyDeps,
|
|
231
|
+
body: Buffer,
|
|
232
|
+
res: ServerResponse,
|
|
233
|
+
signal: AbortSignal,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
let upstream: Response
|
|
236
|
+
try {
|
|
237
|
+
upstream = await fetch(UPSTREAM_URL, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
// Force JSON — the bulk endpoint requires it. Drop every
|
|
240
|
+
// client-supplied header to keep an upstream fingerprint from
|
|
241
|
+
// leaking through (cookies, auth, custom UA, ...). The
|
|
242
|
+
// registry's bulk endpoint doesn't need any of them for a
|
|
243
|
+
// public lookup.
|
|
244
|
+
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
245
|
+
// Re-wrap as a plain Uint8Array — Buffer's underlying
|
|
246
|
+
// ArrayBufferLike type doesn't satisfy fetch's BodyInit
|
|
247
|
+
// narrowing (it can't statically rule out SharedArrayBuffer),
|
|
248
|
+
// but a copy through Uint8Array is zero-cost in practice and
|
|
249
|
+
// unambiguously typed.
|
|
250
|
+
body: new Uint8Array(body),
|
|
251
|
+
signal,
|
|
252
|
+
})
|
|
253
|
+
} catch (err: unknown) {
|
|
254
|
+
if (deps.debug) console.warn('npm-advisories upstream error:', errStack(err))
|
|
255
|
+
// Client already gone — `canWrite` (inside `deny`) gates the
|
|
256
|
+
// write so a destroyed / writableEnded socket doesn't trip
|
|
257
|
+
// ERR_STREAM_DESTROYED on the way out.
|
|
258
|
+
deny(res, 502, 'upstream-unreachable')
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
// Assert JSON on the upstream body. The Content-Type header is
|
|
262
|
+
// unreliable (Cloudflare in front of registry.npmjs.org strips it
|
|
263
|
+
// from some responses; a captive portal / WAF can declare HTML on
|
|
264
|
+
// a body that's actually JSON or vice-versa), so we don't lean on
|
|
265
|
+
// it — instead we buffer the body and parse. A successful
|
|
266
|
+
// JSON.parse is the strongest guarantee we can hand the UI's
|
|
267
|
+
// `await res.json()`. Buffering is bounded by
|
|
268
|
+
// `RESPONSE_BODY_LIMIT`; the advisories endpoint's payloads sit
|
|
269
|
+
// well under that.
|
|
270
|
+
const upstreamContentType = upstream.headers.get('content-type') ?? ''
|
|
271
|
+
let buffered: Buffer | null
|
|
272
|
+
try {
|
|
273
|
+
buffered = await readUpstreamBody(upstream)
|
|
274
|
+
} catch (err: unknown) {
|
|
275
|
+
if (deps.debug) console.warn('npm-advisories upstream body error:', errStack(err))
|
|
276
|
+
deny(res, 502, 'upstream-unreachable')
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (buffered === null) {
|
|
280
|
+
if (deps.debug) console.warn(`npm-advisories upstream too large: status=${upstream.status}`)
|
|
281
|
+
writeJsonEnvelope(res, 502, { error: 'upstream-too-large', upstreamStatus: upstream.status })
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
// Treat the body as UTF-8 — `JSON.parse` operates on a string and
|
|
285
|
+
// the registry's responses are always UTF-8 in practice. A
|
|
286
|
+
// non-UTF-8 byte sequence still decodes (with U+FFFD
|
|
287
|
+
// substitution); the subsequent JSON.parse fails and routes
|
|
288
|
+
// through the error branch.
|
|
289
|
+
const text = buffered.toString('utf8')
|
|
290
|
+
let parsed: unknown
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(text)
|
|
293
|
+
} catch {
|
|
294
|
+
if (deps.debug) console.warn(`npm-advisories upstream non-JSON: status=${upstream.status} ct=${upstreamContentType || '<none>'} bytes=${buffered.byteLength}`)
|
|
295
|
+
writeJsonEnvelope(res, 502, {
|
|
296
|
+
error: 'upstream-not-json',
|
|
297
|
+
upstreamStatus: upstream.status,
|
|
298
|
+
upstreamContentType: upstreamContentType || null,
|
|
299
|
+
})
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (!canWrite(res)) return
|
|
303
|
+
// Re-stringify rather than echoing `text` so the wire shape we
|
|
304
|
+
// emit is canonical (no upstream whitespace / BOM / trailing
|
|
305
|
+
// junk after the parsed value rides along), and so the client
|
|
306
|
+
// can rely on a single JSON document per response.
|
|
307
|
+
const out = JSON.stringify(parsed)
|
|
308
|
+
try {
|
|
309
|
+
res.writeHead(upstream.status, {
|
|
310
|
+
'content-type': 'application/json',
|
|
311
|
+
'cache-control': 'no-store',
|
|
312
|
+
'content-length': Buffer.byteLength(out),
|
|
313
|
+
})
|
|
314
|
+
res.end(out)
|
|
315
|
+
} catch {}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Buffer the upstream response body up to RESPONSE_BODY_LIMIT.
|
|
319
|
+
// Returns null if the cap is exceeded (caller maps to a 502
|
|
320
|
+
// `upstream-too-large`), or the cumulative Buffer otherwise. A
|
|
321
|
+
// transport error mid-read (e.g. AbortSignal fired by the deadline
|
|
322
|
+
// timer or by `req` close) throws — the caller catches it.
|
|
323
|
+
//
|
|
324
|
+
// `finally { reader.cancel() }` is load-bearing on the error and
|
|
325
|
+
// cap-exceeded paths: leaving the reader locked to the body holds
|
|
326
|
+
// the underlying undici TCP socket out of the connection pool until
|
|
327
|
+
// GC, and the cap-exceeded path explicitly needs to tear the
|
|
328
|
+
// transfer down so we don't keep buffering bytes we'll never use.
|
|
329
|
+
// On the clean-drain path (done:true), cancel() is a no-op.
|
|
330
|
+
async function readUpstreamBody(upstream: Response): Promise<Buffer | null> {
|
|
331
|
+
if (!upstream.body) return Buffer.alloc(0)
|
|
332
|
+
const reader = upstream.body.getReader()
|
|
333
|
+
const chunks: Uint8Array[] = []
|
|
334
|
+
let received = 0
|
|
335
|
+
try {
|
|
336
|
+
for (;;) {
|
|
337
|
+
const { done, value } = await reader.read()
|
|
338
|
+
if (done) break
|
|
339
|
+
if (!value) continue
|
|
340
|
+
received += value.byteLength
|
|
341
|
+
if (received > RESPONSE_BODY_LIMIT) return null
|
|
342
|
+
chunks.push(value)
|
|
343
|
+
}
|
|
344
|
+
return Buffer.concat(chunks)
|
|
345
|
+
} finally {
|
|
346
|
+
try { await reader.cancel() } catch {}
|
|
347
|
+
}
|
|
348
|
+
}
|