@preventive/triage 1.0.0-alpha.18 → 1.0.0-alpha.19
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 +13 -75
- package/common/server-info.ts +2 -0
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +67 -56
- package/out/client-sync.js +1 -1
- package/out/graph.js +10 -10
- package/out/view.css +1 -1
- package/out/view.js +169 -162
- package/package.json +12 -2
- package/report/index.js +2 -1
- package/report/src/finding.js +12 -0
- package/report/src/security.js +63 -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-common/reap.ts +48 -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-e2e/cli.js +7 -2
- package/server-e2e/config.ts +36 -40
- package/server-e2e/db-neon.ts +1 -1
- package/server-e2e/db.ts +1 -1
- package/server-e2e/http.ts +12 -2
- package/server-e2e/index.ts +12 -488
- package/server-e2e/lifecycle.ts +59 -172
- package/server-e2e/neon-driver.ts +2 -2
- package/server-e2e/npm-proxy.ts +10 -143
- package/server-e2e/objstore/blob-vercel.ts +4 -108
- package/server-e2e/objstore/init.ts +15 -23
- package/server-e2e/objstore/store-neon.ts +5 -9
package/server-e2e/lifecycle.ts
CHANGED
|
@@ -1,207 +1,94 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// uncaught-exception handlers that drive it. Two-phase so the bits the
|
|
4
|
-
// HTTP + WS planes need (`track`, `isShuttingDown`) exist BEFORE those
|
|
5
|
-
// servers are built, while the teardown is `install()`ed afterward —
|
|
6
|
-
// once the server objects, heartbeat timer, and reaper exist.
|
|
7
|
-
|
|
1
|
+
// Per-instance request tracking and graceful disposal. Process shutdown belongs
|
|
2
|
+
// to the standalone launcher; embedded hosts retain their signals and errors.
|
|
8
3
|
import type { Server } from 'node:http'
|
|
9
4
|
import type { WebSocketServer } from 'ws'
|
|
10
5
|
import type { SseSession } from './sse-session.ts'
|
|
11
|
-
import { errStack } from './util.ts'
|
|
12
6
|
|
|
13
7
|
export type ShutdownDeps = {
|
|
14
8
|
httpServer: Server
|
|
15
9
|
wss: WebSocketServer
|
|
16
10
|
heartbeatTimer: ReturnType<typeof setInterval>
|
|
17
|
-
// The SSE keepalive-sweep timer (server-e2e/sse-server.ts). Cleared on
|
|
18
|
-
// shutdown alongside `heartbeatTimer` so a tick can't write a keepalive
|
|
19
|
-
// comment to a session the close loop below is already tearing down.
|
|
20
11
|
sseKeepaliveTimer: ReturnType<typeof setInterval>
|
|
21
|
-
// Stops the periodic reaper AND awaits any in-flight sweep.
|
|
22
12
|
stopReaper: () => Promise<void>
|
|
23
|
-
// Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
|
|
24
|
-
// fallback transport). Walked alongside `wss.clients` to send the
|
|
25
|
-
// 1001 close frame and apply the terminate-grace timer to both
|
|
26
|
-
// transports uniformly.
|
|
27
13
|
sseSessions: () => Iterable<SseSession>
|
|
28
|
-
// App-specific teardown, run AFTER the in-flight drain: close the DB.
|
|
29
|
-
// Swallows its own errors (a failed close shouldn't abort the exit).
|
|
30
14
|
closeDb: () => Promise<void>
|
|
31
15
|
}
|
|
32
16
|
|
|
33
17
|
export type Lifecycle = {
|
|
34
|
-
// Collects in-flight async handlers so `shutdown` can drain them
|
|
35
|
-
// before closing the DB. Passed to the HTTP + WS planes.
|
|
36
18
|
track: (promise: Promise<unknown>) => void
|
|
37
|
-
// Read by the REST shutdown gate + the WS message loop to drop new
|
|
38
|
-
// work once teardown began.
|
|
39
19
|
isShuttingDown: () => boolean
|
|
40
|
-
// Wire the graceful shutdown + the error / signal / process-catchall
|
|
41
|
-
// handlers. Call once, after the server objects exist.
|
|
42
20
|
install: (deps: ShutdownDeps) => void
|
|
43
21
|
}
|
|
44
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
|
+
|
|
45
58
|
export function createLifecycle(): Lifecycle {
|
|
46
|
-
// In-flight async message handlers. `shutdown` awaits this set before
|
|
47
|
-
// closing the DB so a SIGINT mid-save can't resume against a closed
|
|
48
|
-
// handle (which would throw inside the commit's gated INSERT after
|
|
49
|
-
// the client believed its save was committed).
|
|
50
59
|
const inFlight = new Set<Promise<unknown>>()
|
|
51
60
|
function track(promise: Promise<unknown>): void {
|
|
52
61
|
inFlight.add(promise)
|
|
53
|
-
// Trailing `.catch(() => {})` swallows the rejection that `.finally`
|
|
54
|
-
// propagates through its returned promise — without it, a tracked
|
|
55
|
-
// handler that rejects (or whose caller's catch handler itself
|
|
56
|
-
// throws) trips the `unhandledRejection` catchall below and crashes
|
|
57
|
-
// the process via `fireShutdown(1)`.
|
|
58
62
|
promise.finally(() => inFlight.delete(promise)).catch(() => {})
|
|
59
63
|
}
|
|
60
64
|
let shuttingDown = false
|
|
61
|
-
// Live exit code the in-progress shutdown will pass to `process.exit`.
|
|
62
|
-
// Re-entry can ESCALATE it from 0 → 1 (e.g. a `wss.error` firing
|
|
63
|
-
// during a SIGTERM-driven graceful shutdown shouldn't leave the
|
|
64
|
-
// launcher seeing a clean exit code) but can never DE-escalate.
|
|
65
|
-
// Audit round-13.
|
|
66
|
-
let pendingExitCode = 0
|
|
67
65
|
|
|
68
66
|
function install(deps: ShutdownDeps): void {
|
|
69
|
-
const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
console.log('Shutting down…')
|
|
84
|
-
// Stop both periodic timers (WS heartbeat + SSE keepalive) so neither
|
|
85
|
-
// fires mid-shutdown against a peer the close-loop is tearing down.
|
|
86
|
-
for (const timer of [heartbeatTimer, sseKeepaliveTimer]) clearInterval(timer)
|
|
87
|
-
// Send a 1001 (going away) close frame to every open socket BEFORE
|
|
88
|
-
// shutting the listener. Lets clients distinguish a server-initiated
|
|
89
|
-
// graceful shutdown from a network drop, so they can skip their
|
|
90
|
-
// reconnect backoff. Fire-and-forget — `process.exit` below would
|
|
91
|
-
// force-kill any in-progress flush anyway. `try/catch` shrugs at
|
|
92
|
-
// sockets already in CLOSING / CLOSED.
|
|
93
|
-
for (const socket of wss.clients) {
|
|
94
|
-
try { socket.close(1001, 'Server shutting down') } catch {}
|
|
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)
|
|
95
81
|
}
|
|
96
|
-
|
|
97
|
-
// translates `close(1001, …)` into a structured `event: close`
|
|
98
|
-
// record on the SSE stream so the client transport can read the
|
|
99
|
-
// code and skip its reconnect backoff (parity with the WS path).
|
|
100
|
-
for (const session of sseSessions()) {
|
|
101
|
-
try { session.close(1001, 'Server shutting down') } catch {}
|
|
102
|
-
}
|
|
103
|
-
// Force-terminate any client that doesn't ack the close frame
|
|
104
|
-
// within a short grace window. `wss.close()` waits for every client
|
|
105
|
-
// to emit `'close'`, and `ws` only TCP-RSTs unresponsive peers
|
|
106
|
-
// after its own ~30 s `closeTimeout`. A single dead/blackholed peer
|
|
107
|
-
// would otherwise stretch SIGTERM/SIGINT response by that full
|
|
108
|
-
// timeout. Audit round-11.
|
|
109
|
-
const TERMINATE_GRACE_MS = 1_000
|
|
110
|
-
const terminateTimer = setTimeout(() => {
|
|
111
|
-
for (const socket of wss.clients) {
|
|
112
|
-
const rs = socket.readyState
|
|
113
|
-
if (rs === socket.OPEN || rs === socket.CLOSING) {
|
|
114
|
-
try { socket.terminate() } catch {}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// SSE sessions are tracked separately from `wss.clients`; apply
|
|
118
|
-
// the same terminate-grace policy so a stranded SSE response
|
|
119
|
-
// can't pin the HTTP keep-alive shutdown branch below.
|
|
120
|
-
for (const session of sseSessions()) {
|
|
121
|
-
const rs = session.readyState
|
|
122
|
-
if (rs === session.OPEN || rs === session.CLOSING) {
|
|
123
|
-
try { session.terminate() } catch {}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Same grace for HTTP keep-alive sockets that didn't respect the
|
|
127
|
-
// `Connection: close` hint, which would otherwise hold
|
|
128
|
-
// `httpServer.close()` until their TCP timeout.
|
|
129
|
-
try { httpServer.closeAllConnections() } catch {}
|
|
130
|
-
}, TERMINATE_GRACE_MS)
|
|
131
|
-
// Don't keep the event loop alive solely for the grace timer.
|
|
132
|
-
terminateTimer.unref?.()
|
|
133
|
-
// Stop the periodic reaper AND wait for any in-flight sweep (incl.
|
|
134
|
-
// the startup sweep) before the DB close — otherwise a
|
|
135
|
-
// readdir / unlink would race a closed DB.
|
|
136
|
-
await stopReaper()
|
|
137
|
-
// Free idle HTTP keep-alive sockets up front so close() below
|
|
138
|
-
// doesn't wait on them. Active in-flight requests still finish.
|
|
139
|
-
try { httpServer.closeIdleConnections() } catch {}
|
|
140
|
-
// Close http.Server first to stop accepting new upgrades + HTTP
|
|
141
|
-
// requests. Guard with `.listening` because `close()` throws
|
|
142
|
-
// ERR_SERVER_NOT_RUNNING when bind never succeeded (the http error
|
|
143
|
-
// handler is the path that invoked shutdown in that case).
|
|
144
|
-
if (httpServer.listening) {
|
|
145
|
-
await new Promise<void>((resolve) => { httpServer.close(() => resolve()) })
|
|
146
|
-
}
|
|
147
|
-
await new Promise<void>((resolve) => { wss.close(() => resolve()) })
|
|
148
|
-
clearTimeout(terminateTimer)
|
|
149
|
-
// Drain in-flight handlers so a save that's mid-pipeline finishes
|
|
150
|
-
// its commit before the DB closes. `handleSave` splits its
|
|
151
|
-
// canonical/id/dup-precheck/verify/commit work across awaits, so
|
|
152
|
-
// the window spans several yield points. `Promise.allSettled` so a
|
|
153
|
-
// single handler rejection doesn't abort the drain.
|
|
154
|
-
if (inFlight.size > 0) await Promise.allSettled([...inFlight])
|
|
155
|
-
// App teardown AFTER the drain: close the DB.
|
|
156
|
-
await closeDb()
|
|
157
|
-
// Read `pendingExitCode` (not the parameter) so a re-entrant
|
|
158
|
-
// `shutdown(1)` that landed during the drain wins over the original
|
|
159
|
-
// `shutdown(0)`. See round-13 escalation note.
|
|
160
|
-
process.exit(pendingExitCode)
|
|
82
|
+
return disposal
|
|
161
83
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// `shutdown`: an unhandled rejection there would skip the non-zero
|
|
165
|
-
// exit the launcher relies on.
|
|
166
|
-
function fireShutdown(code: number): void {
|
|
167
|
-
shutdown(code).catch((err) => {
|
|
168
|
-
console.warn('shutdown error:', errStack(err))
|
|
169
|
-
process.exit(code === 0 ? 1 : code)
|
|
170
|
-
})
|
|
84
|
+
function onClose(): void {
|
|
85
|
+
void dispose().catch(err => { console.error('Server cleanup failed:', err) })
|
|
171
86
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
httpServer.on('error', (err: Error) => {
|
|
178
|
-
console.error('Server error:', errStack(err))
|
|
179
|
-
fireShutdown(1)
|
|
180
|
-
})
|
|
181
|
-
// Symmetric with the http.Server error handler — route through
|
|
182
|
-
// `fireShutdown(1)` so the launcher sees a non-zero exit, and the
|
|
183
|
-
// re-entry escalation bumps `pendingExitCode` 0 → 1 for a `wss.error`
|
|
184
|
-
// arriving mid-graceful-SIGTERM.
|
|
185
|
-
wss.on('error', (err: Error) => {
|
|
186
|
-
console.error('WS server error:', errStack(err))
|
|
187
|
-
fireShutdown(1)
|
|
188
|
-
})
|
|
189
|
-
// Wrap signal handlers so the signal name (the listener's first arg)
|
|
190
|
-
// doesn't bleed into shutdown's `exitCode`.
|
|
191
|
-
process.on('SIGINT', () => fireShutdown(0))
|
|
192
|
-
process.on('SIGTERM', () => fireShutdown(0))
|
|
193
|
-
// Process-level catchalls so a stray rejection / uncaught exception
|
|
194
|
-
// doesn't bypass `shutdown()` — Node 20+ exits on unhandled
|
|
195
|
-
// rejection, which would skip the drain + DB close. Log forensically
|
|
196
|
-
// and route through `fireShutdown(1)`. Audit round-11 observability.
|
|
197
|
-
process.on('unhandledRejection', (reason) => {
|
|
198
|
-
console.error('Unhandled rejection:', errStack(reason))
|
|
199
|
-
fireShutdown(1)
|
|
200
|
-
})
|
|
201
|
-
process.on('uncaughtException', (err) => {
|
|
202
|
-
console.error('Uncaught exception:', errStack(err))
|
|
203
|
-
fireShutdown(1)
|
|
204
|
-
})
|
|
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)
|
|
205
92
|
}
|
|
206
93
|
|
|
207
94
|
return { track, isShuttingDown: () => shuttingDown, install }
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
// so the absent peer dep is never loaded under test.
|
|
14
14
|
//
|
|
15
15
|
// In production the wrapper is only ever imported on the Neon path
|
|
16
|
-
// (
|
|
16
|
+
// (an e2e database URL set), where the operator has installed the peer dep
|
|
17
17
|
// (`pnpm add @neondatabase/serverless`) so the re-export resolves. A
|
|
18
18
|
// SQLite-only deploy never imports this module — the dynamic `import()`
|
|
19
|
-
// sites are gated behind the
|
|
19
|
+
// sites are gated behind the configured database URL branch in `index.ts`.
|
|
20
20
|
//
|
|
21
21
|
// `@ts-ignore` rather than `@ts-expect-error`: when the peer dep IS
|
|
22
22
|
// installed the specifier resolves and tsc sees a real type, which
|
package/server-e2e/npm-proxy.ts
CHANGED
|
@@ -25,37 +25,23 @@
|
|
|
25
25
|
//
|
|
26
26
|
// Outbound fetch carries an `AbortController` so a hung upstream
|
|
27
27
|
// (slow-loris response, TLS stall) tears down after
|
|
28
|
-
// `
|
|
28
|
+
// `NPM_ADVISORIES_TIMEOUT_MS`, and a client that closes its connection
|
|
29
29
|
// mid-fetch propagates an abort through the same controller — so a
|
|
30
30
|
// stranded in-flight call doesn't block SIGTERM drain.
|
|
31
31
|
|
|
32
32
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
33
33
|
import { Buffer } from 'node:buffer'
|
|
34
34
|
import { errStack } from './util.ts'
|
|
35
|
+
import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
|
|
35
36
|
|
|
36
37
|
type HasHeaders = { headers: IncomingMessage['headers'] }
|
|
37
38
|
|
|
38
39
|
export const NPM_ADVISORIES_PATH = '/api/npm-advisories'
|
|
39
|
-
const UPSTREAM_URL = 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'
|
|
40
|
-
|
|
41
40
|
// 1 MiB is generous: the bulk endpoint accepts `{ packageName:
|
|
42
41
|
// [versions] }` maps, and even a bundle with thousands of pinned
|
|
43
42
|
// versions serialises to well under this. Anything bigger is almost
|
|
44
43
|
// certainly malformed input or a probe.
|
|
45
44
|
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
45
|
export type NpmProxyDeps = {
|
|
60
46
|
debug: boolean
|
|
61
47
|
}
|
|
@@ -129,11 +115,12 @@ function deny(res: ServerResponse, status: number, reason: string): void {
|
|
|
129
115
|
// Same shape as `deny` but for the richer multi-field envelopes
|
|
130
116
|
// (upstream-not-json / upstream-too-large) — these can't reuse
|
|
131
117
|
// `deny` because the body carries more than `{ error }`.
|
|
132
|
-
function writeJsonEnvelope(res: ServerResponse, status: number, body:
|
|
118
|
+
function writeJsonEnvelope(res: ServerResponse, status: number, body: unknown): void {
|
|
133
119
|
if (!canWrite(res)) return
|
|
134
120
|
try {
|
|
135
|
-
|
|
136
|
-
res.
|
|
121
|
+
const out = JSON.stringify(body)
|
|
122
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(out) })
|
|
123
|
+
res.end(out)
|
|
137
124
|
} catch {}
|
|
138
125
|
}
|
|
139
126
|
|
|
@@ -162,7 +149,7 @@ export async function handleNpmAdvisories(
|
|
|
162
149
|
if (req.method !== 'POST') { deny(res, 405, 'method-not-allowed'); return }
|
|
163
150
|
// Single controller drives both the upstream deadline timer AND
|
|
164
151
|
// the client-disconnect propagation: a fetch hung past
|
|
165
|
-
//
|
|
152
|
+
// NPM_ADVISORIES_TIMEOUT_MS and a browser tab closed mid-fetch both end
|
|
166
153
|
// up aborting the same signal, which undici threads through into
|
|
167
154
|
// the body reader. Without this the inflight slot pinned by
|
|
168
155
|
// `track()` could outlive both a dead client and a wedged
|
|
@@ -187,7 +174,7 @@ export async function handleNpmAdvisories(
|
|
|
187
174
|
// doesn't fire a no-op abort — itself a no-op on a settled
|
|
188
175
|
// controller, but skipping the log noise.)
|
|
189
176
|
const controller = new AbortController()
|
|
190
|
-
const timer = setTimeout(() => { try { controller.abort() } catch {} },
|
|
177
|
+
const timer = setTimeout(() => { try { controller.abort() } catch {} }, NPM_ADVISORIES_TIMEOUT_MS)
|
|
191
178
|
const onResClose = (): void => {
|
|
192
179
|
if (!res.writableEnded) controller.abort()
|
|
193
180
|
}
|
|
@@ -219,130 +206,10 @@ export async function handleNpmAdvisories(
|
|
|
219
206
|
try { req.destroy() } catch {}
|
|
220
207
|
return
|
|
221
208
|
}
|
|
222
|
-
await
|
|
209
|
+
const result = await fetchNpmAdvisories(body, controller.signal, deps.debug)
|
|
210
|
+
writeJsonEnvelope(res, result.status, result.body)
|
|
223
211
|
} finally {
|
|
224
212
|
clearTimeout(timer)
|
|
225
213
|
res.off('close', onResClose)
|
|
226
214
|
}
|
|
227
215
|
}
|
|
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
|
-
}
|
|
@@ -35,108 +35,10 @@
|
|
|
35
35
|
// any single resource), and the stale-staging TTL sweep drops orphan
|
|
36
36
|
// staging blobs.
|
|
37
37
|
|
|
38
|
-
import { PassThrough
|
|
39
|
-
import { Buffer } from 'node:buffer'
|
|
38
|
+
import { PassThrough } from 'node:stream'
|
|
40
39
|
import type { BlobBackend, OpenLiveResult, StagingWriter } from './blob.ts'
|
|
41
40
|
import { errMsg } from '../util.ts'
|
|
42
|
-
|
|
43
|
-
// Minimal structural shape of the bits of `@vercel/blob` we use.
|
|
44
|
-
// Kept local so the optional peer dep doesn't have to type-resolve
|
|
45
|
-
// for SQLite-only deployments. Real type details live in the
|
|
46
|
-
// installed package; the fields/parameters we touch here are stable
|
|
47
|
-
// per the SDK v2 public surface.
|
|
48
|
-
//
|
|
49
|
-
// `put` accepts the SDK's full `PutBody` union (string | Readable |
|
|
50
|
-
// Buffer | Blob | ArrayBuffer | ReadableStream | File). We only ever
|
|
51
|
-
// pass a Node `PassThrough` (a Readable), but the wider type lets
|
|
52
|
-
// callers reuse this signature for future buffer/blob bodies without
|
|
53
|
-
// type gymnastics. `copy` accepts `allowOverwrite` — REQUIRED for
|
|
54
|
-
// version bumps since the live pathname is reused on re-upload.
|
|
55
|
-
type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
|
|
56
|
-
type VercelBlobSdk = {
|
|
57
|
-
put: (
|
|
58
|
-
pathname: string,
|
|
59
|
-
body: VercelBlobBody,
|
|
60
|
-
options: {
|
|
61
|
-
access: 'private' | 'public'
|
|
62
|
-
allowOverwrite?: boolean
|
|
63
|
-
contentType?: string
|
|
64
|
-
token?: string
|
|
65
|
-
multipart?: boolean
|
|
66
|
-
abortSignal?: AbortSignal
|
|
67
|
-
cacheControlMaxAge?: number
|
|
68
|
-
},
|
|
69
|
-
) => Promise<{ url: string; pathname: string }>
|
|
70
|
-
head: (
|
|
71
|
-
pathname: string,
|
|
72
|
-
options?: { token?: string; abortSignal?: AbortSignal },
|
|
73
|
-
) => Promise<{ size: number; pathname: string; url: string }>
|
|
74
|
-
get: (
|
|
75
|
-
pathname: string,
|
|
76
|
-
options: {
|
|
77
|
-
access: 'private' | 'public'
|
|
78
|
-
token?: string
|
|
79
|
-
useCache?: boolean
|
|
80
|
-
abortSignal?: AbortSignal
|
|
81
|
-
},
|
|
82
|
-
) => Promise<{
|
|
83
|
-
statusCode: 200 | 304
|
|
84
|
-
stream: ReadableStream<Uint8Array> | null
|
|
85
|
-
blob: { size: number | null }
|
|
86
|
-
} | null>
|
|
87
|
-
copy: (
|
|
88
|
-
fromPathname: string,
|
|
89
|
-
toPathname: string,
|
|
90
|
-
options: {
|
|
91
|
-
access: 'private' | 'public'
|
|
92
|
-
allowOverwrite?: boolean
|
|
93
|
-
token?: string
|
|
94
|
-
contentType?: string
|
|
95
|
-
cacheControlMaxAge?: number
|
|
96
|
-
},
|
|
97
|
-
) => Promise<{ url: string; pathname: string }>
|
|
98
|
-
del: (
|
|
99
|
-
urlOrPathname: string | string[],
|
|
100
|
-
options?: { token?: string; abortSignal?: AbortSignal },
|
|
101
|
-
) => Promise<void>
|
|
102
|
-
list: (options: {
|
|
103
|
-
prefix?: string
|
|
104
|
-
cursor?: string
|
|
105
|
-
limit?: number
|
|
106
|
-
mode?: 'expanded' | 'folded'
|
|
107
|
-
token?: string
|
|
108
|
-
}) => Promise<{
|
|
109
|
-
// `uploadedAt` (a Date per the SDK v2 surface) is the blob's
|
|
110
|
-
// creation time — used by the reaper's GC grace window. Optional
|
|
111
|
-
// in the type because the `folded` listing path ignores it (only
|
|
112
|
-
// `listLiveBlobs` reads it, and it uses the default expanded mode).
|
|
113
|
-
blobs: Array<{ pathname: string; size: number; uploadedAt?: Date | string | number }>
|
|
114
|
-
folders?: string[]
|
|
115
|
-
cursor?: string
|
|
116
|
-
hasMore: boolean
|
|
117
|
-
}>
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Recognise "blob is gone" errors uniformly across read/write/delete
|
|
121
|
-
// paths so callers can treat them as success (delete) or
|
|
122
|
-
// not-found (read). The SDK exposes BlobNotFoundError as a class with
|
|
123
|
-
// `.name === 'BlobNotFoundError'`; checking the name string avoids
|
|
124
|
-
// importing the class at the top level (which would force the optional
|
|
125
|
-
// peer dep to resolve).
|
|
126
|
-
//
|
|
127
|
-
// Class-name check ONLY — the SDK's internal mapper turns every API
|
|
128
|
-
// `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
|
|
129
|
-
// leak doesn't reach here. A broader `/does not exist|\b404\b/` match
|
|
130
|
-
// is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
|
|
131
|
-
// does not exist.", so a config fault (revoked token, deleted store)
|
|
132
|
-
// would silently surface as every-blob-missing across reads/unlinks,
|
|
133
|
-
// masking the fatal misconfiguration. The tight name check lets
|
|
134
|
-
// BlobStoreNotFoundError / other classes propagate as real exceptions.
|
|
135
|
-
function isNotFound(err: unknown): boolean {
|
|
136
|
-
if (err == null || typeof err !== 'object') return false
|
|
137
|
-
const name = (err as { name?: unknown }).name
|
|
138
|
-
return typeof name === 'string' && name === 'BlobNotFoundError'
|
|
139
|
-
}
|
|
41
|
+
import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../../server-common/vercel-blob.ts'
|
|
140
42
|
|
|
141
43
|
function liveBlobPath(tag: string, contentHash: string): string {
|
|
142
44
|
return `${tag}/${contentHash}.bin`
|
|
@@ -493,7 +395,7 @@ export type VercelBlobBackendOptions = {
|
|
|
493
395
|
// Vercel Blob R/W token, typically from BLOB_READ_WRITE_TOKEN.
|
|
494
396
|
// The SDK also reads it from process.env, but passing it
|
|
495
397
|
// explicitly here keeps the env-var → boot config path single-
|
|
496
|
-
// sourced through server-e2e/index.ts (matches the Neon
|
|
398
|
+
// sourced through server-e2e/index.ts (matches the Neon database URL
|
|
497
399
|
// handling — env-read at boot, threaded as a parameter).
|
|
498
400
|
token: string
|
|
499
401
|
// Test seam: inject a stub of the @vercel/blob module to avoid
|
|
@@ -507,7 +409,7 @@ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Pro
|
|
|
507
409
|
// dep. `@ts-ignore` (not `@ts-expect-error`) so an operator who
|
|
508
410
|
// DOES install `@vercel/blob` doesn't trip TS2578 "unused
|
|
509
411
|
// directive" — same pattern as db-neon.ts.
|
|
510
|
-
const sdk: VercelBlobSdk = opts.sdk ?? (await
|
|
412
|
+
const sdk: VercelBlobSdk = opts.sdk ?? (await loadVercelBlobSdk())
|
|
511
413
|
const token = opts.token
|
|
512
414
|
return {
|
|
513
415
|
// No-op: Vercel Blob has no folder concept. The pathname's
|
|
@@ -533,9 +435,3 @@ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Pro
|
|
|
533
435
|
listStagingIds: buildListStagingIds(sdk, token, (tag) => `${tag}/.staging/`),
|
|
534
436
|
}
|
|
535
437
|
}
|
|
536
|
-
|
|
537
|
-
async function loadSdk(): Promise<VercelBlobSdk> {
|
|
538
|
-
// @ts-ignore optional peer dep: '@vercel/blob'
|
|
539
|
-
const mod = (await import('@vercel/blob')) as VercelBlobSdk
|
|
540
|
-
return mod
|
|
541
|
-
}
|