@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +17 -10
- package/out/graph.js +5 -4
- package/out/index.html +43 -38
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -51
- package/package.json +70 -49
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +16 -1
- package/server-e2e/bus-receiver.ts +95 -0
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +41 -25
- package/{server → server-e2e}/db-revision-sql.ts +15 -9
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +113 -135
- package/server-e2e/http.ts +266 -0
- package/{server → server-e2e}/hub.ts +27 -8
- package/{server → server-e2e}/index.ts +185 -52
- package/{server → server-e2e}/lifecycle.ts +36 -5
- package/server-e2e/npm-proxy.ts +348 -0
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- 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 +25 -15
- package/{server → server-e2e}/objstore/init.ts +52 -12
- 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 +119 -84
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- 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-e2e/pubsub.ts +394 -0
- package/{server → server-e2e}/sign.ts +12 -14
- package/server-e2e/sse-server.ts +384 -0
- package/server-e2e/sse-session.ts +216 -0
- package/{server → server-e2e}/static.ts +22 -17
- package/server-e2e/sync-handlers.ts +382 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/server-e2e/ws-server.ts +276 -0
- package/strip-types-loader.js +94 -0
- package/server/http.ts +0 -142
- package/server/sync-handlers.ts +0 -311
- package/server/ws-server.ts +0 -245
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -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-e2e/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
|
+
}
|
|
@@ -48,13 +48,13 @@ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string):
|
|
|
48
48
|
const path = liveFilePath(dir, tag, contentHash)
|
|
49
49
|
let fh
|
|
50
50
|
try { fh = await open(path, 'r') } catch (err: unknown) {
|
|
51
|
-
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
|
|
51
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable', detail: 'fs-enoent' }
|
|
52
52
|
throw err
|
|
53
53
|
}
|
|
54
54
|
let size: number
|
|
55
55
|
try { size = (await fh.stat()).size } catch {
|
|
56
56
|
await fh.close().catch(() => {})
|
|
57
|
-
return { ok: false, reason: 'unavailable' }
|
|
57
|
+
return { ok: false, reason: 'unavailable', detail: 'fs-stat-failed' }
|
|
58
58
|
}
|
|
59
59
|
const stream = fh.createReadStream()
|
|
60
60
|
let closed = false
|
|
@@ -90,12 +90,10 @@ export function openFsBlobBackend(dir: string): BlobBackend {
|
|
|
90
90
|
// closed by Node's stream machinery on 'finish'.
|
|
91
91
|
finalize: async () => {},
|
|
92
92
|
// `destroy(err)` synchronously starts tearing the stream
|
|
93
|
-
// down; the WriteStream emits 'close' on the next tick.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// the no-await-in-async — the function signature is
|
|
98
|
-
// dictated by the BlobBackend contract.
|
|
93
|
+
// down; the WriteStream emits 'close' on the next tick. No
|
|
94
|
+
// remote upload to wait for on FS, so we resolve immediately
|
|
95
|
+
// — the REST layer awaits but doesn't block on anything real.
|
|
96
|
+
// Async signature is dictated by the BlobBackend contract.
|
|
99
97
|
// eslint-disable-next-line require-await
|
|
100
98
|
abort: async (err) => { writable.destroy(err as Error) },
|
|
101
99
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Vercel Blob Private Storage BlobBackend. Paired with the Neon DB
|
|
2
|
-
// plane in `server/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
|
|
2
|
+
// plane in `server-e2e/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
|
|
3
3
|
// the combination is the supported multi-replica deployment shape
|
|
4
4
|
// (Neon for metadata, Vercel Blob for bytes — both serverless, both
|
|
5
5
|
// HTTP-backed, no shared filesystem required).
|
|
@@ -119,21 +119,19 @@ type VercelBlobSdk = {
|
|
|
119
119
|
|
|
120
120
|
// Recognise "blob is gone" errors uniformly across read/write/delete
|
|
121
121
|
// paths so callers can treat them as success (delete) or
|
|
122
|
-
// not-found (read). The SDK exposes BlobNotFoundError as a class
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
126
|
//
|
|
127
|
-
// Class-name check ONLY
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// lets BlobStoreNotFoundError / other classes propagate as real
|
|
136
|
-
// exceptions.
|
|
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.
|
|
137
135
|
function isNotFound(err: unknown): boolean {
|
|
138
136
|
if (err == null || typeof err !== 'object') return false
|
|
139
137
|
const name = (err as { name?: unknown }).name
|
|
@@ -242,20 +240,17 @@ function buildOpenStagingWriter(sdk: VercelBlobSdk, token: string): BlobBackend[
|
|
|
242
240
|
abortSignal: ac.signal,
|
|
243
241
|
})
|
|
244
242
|
// Defuse a possible unhandled-rejection if abort() is called
|
|
245
|
-
// BEFORE finalize() (the REST
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// (the .catch returns a separate chain that doesn't replace
|
|
250
|
-
// putPromise's state).
|
|
243
|
+
// BEFORE finalize() (the REST error path). The detached `.catch`
|
|
244
|
+
// gives an early rejection a handler; finalize() still awaits
|
|
245
|
+
// `putPromise` directly and re-throws the original rejection (the
|
|
246
|
+
// .catch is a separate chain, not a replacement of putPromise).
|
|
251
247
|
putPromise.catch(() => {})
|
|
252
248
|
return {
|
|
253
249
|
writable: pt,
|
|
254
250
|
// Await the upload's completion. After pipeline(req, counter,
|
|
255
|
-
// pt) resolves, pt has emitted 'end'
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// pipeline-to-WriteStream gives the FS backend.
|
|
251
|
+
// pt) resolves, pt has emitted 'end' and `put` is finalising the
|
|
252
|
+
// last multipart part. Awaiting here gives the same "bytes
|
|
253
|
+
// durable" guarantee pipeline-to-WriteStream gives the FS backend.
|
|
259
254
|
finalize: async () => { await putPromise },
|
|
260
255
|
// Await the SDK's put-promise settlement (rejected via the
|
|
261
256
|
// AbortController). Without this await, a slow upload that
|
|
@@ -315,11 +310,11 @@ function buildPromoteStagingToLive(sdk: VercelBlobSdk, token: string): BlobBacke
|
|
|
315
310
|
//
|
|
316
311
|
// `allowOverwrite: true` because the content-addressed live
|
|
317
312
|
// pathname `${tag}/${contentHash}.bin` can be (re)written by a
|
|
318
|
-
// retried or racing promote of the same blob.
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
313
|
+
// retried or racing promote of the same blob. Destination bytes
|
|
314
|
+
// are identical by construction (the path IS the hash), so the
|
|
315
|
+
// overwrite is idempotent — never a clobber of DIFFERENT bytes.
|
|
316
|
+
// Without the flag the SDK sends `x-allow-overwrite: 0` and
|
|
317
|
+
// Vercel rejects the re-promote with BlobAccessError.
|
|
323
318
|
await sdk.copy(from, to, {
|
|
324
319
|
access: 'private',
|
|
325
320
|
allowOverwrite: true,
|
|
@@ -356,17 +351,55 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
356
351
|
// origin truth. Origin fetch is the right default for a store
|
|
357
352
|
// where freshness > latency.
|
|
358
353
|
try { res = await sdk.get(path, { access: 'private', useCache: false, token }) } catch (err) {
|
|
359
|
-
|
|
354
|
+
// A missing blob HERE is never "the resource doesn't exist" — the
|
|
355
|
+
// REST layer (rest.ts openLiveSnapshot) already confirmed a live row
|
|
356
|
+
// whose (version, incarnation) matches the GET token before calling
|
|
357
|
+
// us. So BlobNotFoundError means the bytes for a still-live row are
|
|
358
|
+
// momentarily gone: the reaper GC'd a hash a racing version-bump just
|
|
359
|
+
// unreferenced, or Vercel's read-after-write / edge propagation hasn't
|
|
360
|
+
// caught up to a freshly-promoted private blob. That is the documented
|
|
361
|
+
// `unavailable` (HTTP 503) transient — reconciled by reaper /
|
|
362
|
+
// propagation, retried by the client — NOT a 404. Returning
|
|
363
|
+
// `not-found` would emit a 404 the FS backend never emits for the
|
|
364
|
+
// same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
|
|
365
|
+
// the client the resource is gone for good when it should refetch.
|
|
366
|
+
// See server-e2e/README.md's GET status table.
|
|
367
|
+
if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
|
|
360
368
|
throw err
|
|
361
369
|
}
|
|
362
|
-
|
|
370
|
+
// SDK returned null (no blob) — same "bytes missing for a live row"
|
|
371
|
+
// transient as the BlobNotFoundError branch above → `unavailable`, not
|
|
372
|
+
// `not-found`.
|
|
373
|
+
if (res == null) return { ok: false, reason: 'unavailable', detail: 'vercel-get-null' }
|
|
363
374
|
// statusCode 304 doesn't reach here in practice — the REST
|
|
364
375
|
// GET layer doesn't pass If-None-Match — but a future call
|
|
365
376
|
// site could. Treat as unavailable rather than streaming a
|
|
366
377
|
// null body.
|
|
367
|
-
if (res.statusCode !== 200 || res.stream == null
|
|
368
|
-
return { ok: false, reason: 'unavailable' }
|
|
378
|
+
if (res.statusCode !== 200 || res.stream == null) {
|
|
379
|
+
return { ok: false, reason: 'unavailable', detail: `vercel-get-status-${res.statusCode}` }
|
|
369
380
|
}
|
|
381
|
+
// `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
|
|
382
|
+
// the body but does NOT populate `blob.size` nor pass a
|
|
383
|
+
// `content-length` header (verified empirically: get.size=0,
|
|
384
|
+
// content-length-hdr=null, while head.size reports the true count).
|
|
385
|
+
// The REST layer needs a size to set `content-length` and for the
|
|
386
|
+
// integrity check against the DB row, so on 0/null we fall back to
|
|
387
|
+
// head(). Two round-trips per private read until the SDK is fixed —
|
|
388
|
+
// small price vs. 503ing every read.
|
|
389
|
+
let size: number | null | undefined = res.blob?.size
|
|
390
|
+
if (size == null || size === 0) {
|
|
391
|
+
try {
|
|
392
|
+
const h = await sdk.head(path, { token })
|
|
393
|
+
size = (h as { size?: number })?.size
|
|
394
|
+
} catch (headErr) {
|
|
395
|
+
// Blob vanished between get() and the head() size fallback (a
|
|
396
|
+
// racing reaper GC) — still the "live row present, bytes gone"
|
|
397
|
+
// transient, so `unavailable` (503), matching the get() path above.
|
|
398
|
+
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
|
|
399
|
+
throw headErr
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (size == null) return { ok: false, reason: 'unavailable', detail: 'vercel-no-size' }
|
|
370
403
|
// SDK returns a web ReadableStream<Uint8Array>; the REST layer
|
|
371
404
|
// expects a Node Readable for pipeline(). Convert via
|
|
372
405
|
// Readable.fromWeb — built-in and zero-copy where possible.
|
|
@@ -376,7 +409,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
376
409
|
ok: true,
|
|
377
410
|
reader: {
|
|
378
411
|
stream: nodeStream,
|
|
379
|
-
size
|
|
412
|
+
size,
|
|
380
413
|
// eslint-disable-next-line require-await
|
|
381
414
|
close: async () => {
|
|
382
415
|
// Destroying the Node wrapper also cancels the underlying
|
|
@@ -460,7 +493,7 @@ export type VercelBlobBackendOptions = {
|
|
|
460
493
|
// Vercel Blob R/W token, typically from BLOB_READ_WRITE_TOKEN.
|
|
461
494
|
// The SDK also reads it from process.env, but passing it
|
|
462
495
|
// explicitly here keeps the env-var → boot config path single-
|
|
463
|
-
// sourced through server/index.ts (matches the Neon DATABASE_URL
|
|
496
|
+
// sourced through server-e2e/index.ts (matches the Neon DATABASE_URL
|
|
464
497
|
// handling — env-read at boot, threaded as a parameter).
|
|
465
498
|
token: string
|
|
466
499
|
// Test seam: inject a stub of the @vercel/blob module to avoid
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the Neon DB plane for multi-replica
|
|
12
12
|
// deployments)
|
|
13
13
|
//
|
|
14
|
-
// Selected at boot in `server/index.ts` and passed to `openObjstore`
|
|
14
|
+
// Selected at boot in `server-e2e/index.ts` and passed to `openObjstore`
|
|
15
15
|
// / `openNeonObjstore`. The DB-plane code in ./store.ts, ./rest.ts,
|
|
16
16
|
// and ./reaper.ts goes through `handle.blob.*` and is backend-
|
|
17
17
|
// agnostic — no `if (vercel) … else` branching in consumers.
|
|
@@ -83,13 +83,27 @@ export type LiveReader = {
|
|
|
83
83
|
close(): Promise<void>
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
86
|
+
// A missing blob is always `unavailable` (→ HTTP 503), never a 404. The
|
|
87
|
+
// byte plane has NO view of the metadata row, so it can't decide whether
|
|
88
|
+
// a resource "doesn't exist" — only whether specific bytes are present
|
|
89
|
+
// right now. The authoritative "this resource/version is gone" 404 is the
|
|
90
|
+
// REST layer's call, made from the live row BEFORE it opens a reader
|
|
91
|
+
// (rest.ts openLiveSnapshot). By the time `openLiveReader` runs the row is
|
|
92
|
+
// already confirmed, so an absent blob means a transient bytes/metadata
|
|
93
|
+
// desync the reaper (or store propagation) reconciles — exactly the
|
|
94
|
+
// `unavailable`/503 contract, which the client retries. Both backends MUST
|
|
95
|
+
// map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
|
|
96
|
+
// / null get()). No 404-mapping variant exists here so that bug can't recur.
|
|
97
|
+
//
|
|
98
|
+
// `detail` is a short, NON-SENSITIVE machine tag for the specific cause
|
|
99
|
+
// (e.g. 'vercel-get-not-found', 'fs-enoent', 'vercel-no-size'). Every
|
|
100
|
+
// byte-side failure collapses to the same 503 on the wire, so a permanent
|
|
101
|
+
// loss (reaper GC'd the bytes) and a transient read fault are otherwise
|
|
102
|
+
// indistinguishable — the REST layer logs `detail` so an operator can tell
|
|
103
|
+
// them apart. Purely diagnostic; the REST status is unchanged.
|
|
90
104
|
export type OpenLiveResult =
|
|
91
105
|
| { ok: true; reader: LiveReader }
|
|
92
|
-
| { ok: false; reason: '
|
|
106
|
+
| { ok: false; reason: 'unavailable'; detail?: string }
|
|
93
107
|
|
|
94
108
|
export type BlobBackend = {
|
|
95
109
|
// Per-workspace setup. FS creates the on-disk staging directory;
|
|
@@ -128,9 +142,10 @@ export type BlobBackend = {
|
|
|
128
142
|
promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
|
|
129
143
|
|
|
130
144
|
// Open a streaming reader for the content-addressed live blob.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
145
|
+
// Called only after the REST layer has confirmed the live row, so a
|
|
146
|
+
// missing blob is the transient "row present, bytes gone" state →
|
|
147
|
+
// `unavailable` (HTTP 503), which the reaper reconciles and the client
|
|
148
|
+
// retries. Never a 404 from here — see OpenLiveResult above.
|
|
134
149
|
openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
|
|
135
150
|
|
|
136
151
|
// Idempotent deletes. Backends MUST tolerate "already gone" as
|