@preventive/triage 1.0.0-alpha.1 → 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 +16 -13
- package/out/graph.js +5 -4
- package/out/index.html +4 -15
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -52
- package/package.json +70 -54
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +8 -8
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +31 -25
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +97 -9
- package/{server → server-e2e}/hub.ts +7 -8
- package/{server → server-e2e}/index.ts +67 -43
- package/{server → server-e2e}/lifecycle.ts +14 -5
- package/{server → server-e2e}/npm-proxy.ts +1 -1
- 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 +13 -15
- package/{server → server-e2e}/objstore/init.ts +47 -13
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +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 → server-e2e}/pubsub.ts +21 -31
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +22 -17
- package/{server → server-e2e}/sync-handlers.ts +172 -117
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/strip-types-loader.js +94 -0
- /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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// DeepView triage-sync relay server. WebSocket front-end, SQLite
|
|
3
3
|
// backing store. Implements the protocol described in
|
|
4
|
-
// `client/triage-sync.js` (and `server/sign.ts` for the canonical
|
|
4
|
+
// `client/triage-sync.js` (and `server-e2e/sign.ts` for the canonical
|
|
5
5
|
// signature payloads):
|
|
6
6
|
//
|
|
7
7
|
// server → client challenge { nonce } — emitted on every
|
|
@@ -75,13 +75,14 @@
|
|
|
75
75
|
import { type WebSocket, WebSocketServer } from 'ws'
|
|
76
76
|
import { errMsg, errStack } from './util.ts'
|
|
77
77
|
import type { PeerRegistry } from './peer.ts'
|
|
78
|
-
import { LOOPBACK_HOSTS, createOriginGate } from '
|
|
78
|
+
import { LOOPBACK_HOSTS, createOriginGate } from '../server-common/origin.ts'
|
|
79
79
|
import { createHub } from './hub.ts'
|
|
80
80
|
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
84
|
import { SSE_OPEN_PATH, installSseServer } from './sse-server.ts'
|
|
85
|
+
import type { ServerInfo } from '../common/server-info.ts'
|
|
85
86
|
import { createLifecycle } from './lifecycle.ts'
|
|
86
87
|
import { loadConfig } from './config.ts'
|
|
87
88
|
import { type Handle, openDb } from './db.ts'
|
|
@@ -97,20 +98,20 @@ import {
|
|
|
97
98
|
import { createBusReceiver } from './bus-receiver.ts'
|
|
98
99
|
|
|
99
100
|
// All external inputs (env vars + optional config.json) are parsed
|
|
100
|
-
// and validated in ./config.ts
|
|
101
|
-
//
|
|
101
|
+
// and validated in ./config.ts; destructure into the uppercase names
|
|
102
|
+
// the rest of this module uses.
|
|
102
103
|
const config = loadConfig()
|
|
103
104
|
const {
|
|
104
105
|
port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
|
|
105
|
-
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
106
|
+
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS, reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
106
107
|
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, debug: DEBUG,
|
|
107
108
|
neonUrl: NEON_URL, blobToken: BLOB_TOKEN, tokenSecret: TOKEN_SECRET,
|
|
108
109
|
password: CONFIG_PASSWORD, trustProxyEnv: TRUST_PROXY_ENV,
|
|
109
110
|
} = config
|
|
110
111
|
|
|
111
112
|
// Same-origin gate for the WS upgrade and REST data plane (see
|
|
112
|
-
// ./origin.ts). `TRUST_PROXY_ENV`
|
|
113
|
-
//
|
|
113
|
+
// ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
|
|
114
|
+
// misconfiguration fail-fast below.
|
|
114
115
|
const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
|
|
115
116
|
|
|
116
117
|
// Per-socket buffered-bytes cap. `socket.send` returns synchronously
|
|
@@ -120,28 +121,27 @@ const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUS
|
|
|
120
121
|
// MB of fan-out broadcasts in this buffer with no backpressure on
|
|
121
122
|
// the broadcast loop. Drop the message when the buffer crosses the
|
|
122
123
|
// cap; the heartbeat will eventually close a peer that never
|
|
123
|
-
// drains. Transport audit `server/index.ts:225`.
|
|
124
|
+
// drains. Transport audit `server-e2e/index.ts:225`.
|
|
124
125
|
const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
125
126
|
// Per-socket in-flight async-handler cap (MAX_INFLIGHT_PER_SOCKET,
|
|
126
127
|
// env-validated in config). Each inbound text frame spawns a
|
|
127
128
|
// `track(handler)` IIFE; an authorised peer firing valid frames could
|
|
128
129
|
// otherwise grow the set without bound, stretching SIGTERM drain time.
|
|
129
130
|
// Saves dropped at the cap surface as a typed `busy` NACK. Transport
|
|
130
|
-
// audit `server/index.ts:590`.
|
|
131
|
+
// audit `server-e2e/index.ts:590`.
|
|
131
132
|
|
|
132
133
|
// Per-connection state registry. One `Peer` per accepted socket holds
|
|
133
134
|
// the challenge nonce, auth flag, heartbeat liveness, in-flight count,
|
|
134
|
-
// and subscribed tags (see ./peer.ts)
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// via `peers.get(socket)`.
|
|
135
|
+
// and subscribed tags (see ./peer.ts). The connection handler holds
|
|
136
|
+
// the Peer in a closure for the hot paths; cross-function call sites
|
|
137
|
+
// resolve it via `peers.get(socket)`.
|
|
138
138
|
const peers: PeerRegistry = new WeakMap()
|
|
139
139
|
|
|
140
140
|
// REST PUT idle-body timeout. A slow-loris client trickling bytes
|
|
141
141
|
// within the declared Content-Length holds the staging fd and an
|
|
142
142
|
// inFlightSids slot until the global staging TTL reaps it. Aborting
|
|
143
143
|
// the per-chunk-idle period closes that window. Transport audit
|
|
144
|
-
// `server/objstore/rest.ts:218`.
|
|
144
|
+
// `server-e2e/objstore/rest.ts:218`.
|
|
145
145
|
const REST_PUT_IDLE_TIMEOUT_MS = 30_000
|
|
146
146
|
|
|
147
147
|
// Server-driven WS heartbeat. Every `HEARTBEAT_INTERVAL_MS` we walk
|
|
@@ -163,8 +163,8 @@ const REST_PUT_IDLE_TIMEOUT_MS = 30_000
|
|
|
163
163
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
164
164
|
|
|
165
165
|
// Backend selection. Both planes (workspace_revision DB + the
|
|
166
|
-
// v1.objstore byte store) are picked from config at boot. Two
|
|
167
|
-
// pairings:
|
|
166
|
+
// v1.objstore byte store) are picked from config at boot. Two
|
|
167
|
+
// supported pairings:
|
|
168
168
|
// 1. DATABASE_URL set → Neon (workspace_revision + objstore
|
|
169
169
|
// tables) + Vercel Blob Private Storage (bytes). Requires
|
|
170
170
|
// BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
|
|
@@ -174,11 +174,10 @@ const HEARTBEAT_INTERVAL_MS = 30_000
|
|
|
174
174
|
// process; the only pairing the SQLite plane supports.
|
|
175
175
|
// The Neon / Vercel files import their peer deps lazily inside the
|
|
176
176
|
// open functions, so static imports here are safe even on a SQLite-
|
|
177
|
-
// only install where the optional peer deps aren't present.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// without a non-null assertion.
|
|
177
|
+
// only install where the optional peer deps aren't present. Explicit
|
|
178
|
+
// branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
|
|
179
|
+
// narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
|
|
180
|
+
// `openObjstore` accepts without a non-null assertion.
|
|
182
181
|
let handle: Handle
|
|
183
182
|
let objstoreHandle: ObjstoreHandle
|
|
184
183
|
let objstoreBanner: string
|
|
@@ -238,8 +237,7 @@ async function workspaceExists(tag: string): Promise<boolean> {
|
|
|
238
237
|
}
|
|
239
238
|
|
|
240
239
|
// WS fan-out hub: subscriber registry + backpressure-aware send /
|
|
241
|
-
// broadcast (see ./hub.ts).
|
|
242
|
-
// handlers / dispatcher / objstore wiring below read unchanged.
|
|
240
|
+
// broadcast (see ./hub.ts).
|
|
243
241
|
const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
|
|
244
242
|
const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
|
|
245
243
|
|
|
@@ -276,9 +274,9 @@ if (NEON_URL) {
|
|
|
276
274
|
}
|
|
277
275
|
|
|
278
276
|
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
279
|
-
// handshake.
|
|
277
|
+
// handshake.
|
|
280
278
|
const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
|
|
281
|
-
const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
|
|
279
|
+
const { requiresAuth, passwordConfigured, handleAuthenticate, sendUnauthorized } = auth
|
|
282
280
|
|
|
283
281
|
// Triage-sync protocol handlers (see ./sync-handlers.ts). `getNonce`
|
|
284
282
|
// resolves a socket's challenge nonce and is shared with the objstore
|
|
@@ -295,9 +293,9 @@ const publishObjDeleted = (tag: string, resourceTag: string, version: number): v
|
|
|
295
293
|
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
|
|
296
294
|
}
|
|
297
295
|
|
|
298
|
-
const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
296
|
+
const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
299
297
|
handle, send, broadcast, publishRevision, subscribe, getNonce,
|
|
300
|
-
requiresAuth, sendUnauthorized, workspaceExists,
|
|
298
|
+
requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
|
|
301
299
|
// Folds the objstore inventory into the `workspace-subscribed` ack.
|
|
302
300
|
// The objstore store keeps its own richer `Handle`, so we wire the
|
|
303
301
|
// query here where both handles exist rather than coupling
|
|
@@ -308,6 +306,7 @@ const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
|
308
306
|
|
|
309
307
|
const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper } = initObjstore({
|
|
310
308
|
handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
309
|
+
reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
311
310
|
send, broadcast, publishObjPut, publishObjDeleted,
|
|
312
311
|
getNonce, debug: DEBUG,
|
|
313
312
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
@@ -318,10 +317,15 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
|
|
|
318
317
|
// `unauthorized` frame and bails on `true`.
|
|
319
318
|
authGate: async (socket, tag) => requiresAuth(socket) && !await workspaceExists(tag),
|
|
320
319
|
sendUnauthorized,
|
|
320
|
+
// Socket-less analog of `authGate` for the REST put-begin mint: a REST
|
|
321
|
+
// request can never be operator-authorized, so the gate collapses to
|
|
322
|
+
// "password configured AND workspace new". A deny routes the client to
|
|
323
|
+
// its in-band WS put-begin fallback.
|
|
324
|
+
restPutGate: async (tag) => passwordConfigured && !await workspaceExists(tag),
|
|
321
325
|
// `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
|
|
322
326
|
// provided in env (see TOKEN_SECRET resolution above). Omitted
|
|
323
|
-
// → initObjstore mints a fresh per-process secret (
|
|
324
|
-
//
|
|
327
|
+
// → initObjstore mints a fresh per-process secret (fine for
|
|
328
|
+
// single-replica).
|
|
325
329
|
...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
|
|
326
330
|
})
|
|
327
331
|
|
|
@@ -338,12 +342,17 @@ const wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 })
|
|
|
338
342
|
// every server object exists.
|
|
339
343
|
const { track, isShuttingDown, install: installLifecycle } = createLifecycle()
|
|
340
344
|
|
|
345
|
+
// The sync protocol this build advertises — emitted as a `server-info` frame
|
|
346
|
+
// right after the challenge on every connection. This is the e2e boot, so it
|
|
347
|
+
// always advertises e2e.
|
|
348
|
+
const SERVER_INFO: ServerInfo = { mode: 'e2e', managed: null }
|
|
349
|
+
|
|
341
350
|
// Shared per-connection dispatch surface. Both the WS plane
|
|
342
351
|
// (installWsServer below) and the SSE+POST fallback (installSseServer
|
|
343
352
|
// below) drive `setupPeerConnection` with this; one Peer per accepted
|
|
344
353
|
// connection, one message-handler tree.
|
|
345
354
|
const peerConnectionDeps = {
|
|
346
|
-
peers, send, unsubscribeAll,
|
|
355
|
+
peers, serverInfo: SERVER_INFO, send, unsubscribeAll,
|
|
347
356
|
handleSave, handleSubscribe, handleAuthenticate, sendSaveError, objstore,
|
|
348
357
|
track, isShuttingDown,
|
|
349
358
|
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET,
|
|
@@ -364,10 +373,6 @@ const sseServer = installSseServer({
|
|
|
364
373
|
// Mirror the WS `maxPayload` so the SSE plane can't accept frames
|
|
365
374
|
// the WS plane would reject.
|
|
366
375
|
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
376
|
debug: DEBUG,
|
|
372
377
|
})
|
|
373
378
|
|
|
@@ -377,8 +382,8 @@ const sseServer = installSseServer({
|
|
|
377
382
|
// drain uses `track`. The WS connection handler is wired on `wss`
|
|
378
383
|
// below.
|
|
379
384
|
const httpServer = createHttpServer({
|
|
380
|
-
wss, restDeps: objstoreRestDeps, sseServer, isOriginAllowed,
|
|
381
|
-
isShuttingDown, track,
|
|
385
|
+
wss, restDeps: objstoreRestDeps, sseServer, serverInfo: SERVER_INFO, isOriginAllowed,
|
|
386
|
+
isShuttingDown, track, handleSaveRest,
|
|
382
387
|
restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
|
|
383
388
|
})
|
|
384
389
|
|
|
@@ -447,16 +452,35 @@ const closeDb = async (): Promise<void> => {
|
|
|
447
452
|
installLifecycle({
|
|
448
453
|
httpServer, wss, heartbeatTimer, stopReaper,
|
|
449
454
|
sseSessions: sseServer.sessions,
|
|
455
|
+
sseKeepaliveTimer: sseServer.keepaliveTimer,
|
|
450
456
|
closeDb,
|
|
451
457
|
})
|
|
452
458
|
|
|
453
459
|
// Bind only after the startup orphan sweep finishes — otherwise a
|
|
454
460
|
// fresh boot could serve traffic against tags whose on-disk state
|
|
455
|
-
// still has residue from a prior crash.
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
// failure unconditionally and returns void), so no outer `.catch`
|
|
460
|
-
// is needed here.
|
|
461
|
+
// still has residue from a prior crash. `startupReap` already
|
|
462
|
+
// resolves on any error (the reaper's own catch logs the failure
|
|
463
|
+
// unconditionally and returns void), so no outer `.catch` is
|
|
464
|
+
// needed here.
|
|
461
465
|
await startupReap
|
|
462
|
-
|
|
466
|
+
|
|
467
|
+
// Bind the HTTP/WS plane on the configured PORT/HOST. Exported so a
|
|
468
|
+
// launcher (server-e2e/cli.js — the triage-server bin) or any `import`er can
|
|
469
|
+
// start serving. The top-level `await startupReap` above means the server
|
|
470
|
+
// is fully ready — DB open, DDL bootstrapped, objstore reaper swept — by
|
|
471
|
+
// the time the import resolves.
|
|
472
|
+
export function start(): void {
|
|
473
|
+
httpServer.listen(PORT, HOST)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export { httpServer, wss }
|
|
477
|
+
|
|
478
|
+
// Library mode: when this module is `import`ed (rather than run as the
|
|
479
|
+
// entry script) skip the auto-start so consumers can own the bind — e.g.
|
|
480
|
+
// wrap CF Access / framework-preset shims around the assembled
|
|
481
|
+
// `httpServer`, or just call `start()` when ready. Direct invocation via
|
|
482
|
+
// `node server-e2e/index.ts` (or the `triage-server` bin) still listens.
|
|
483
|
+
if (import.meta.main) {
|
|
484
|
+
start()
|
|
485
|
+
}
|
|
486
|
+
|
|
@@ -14,6 +14,10 @@ export type ShutdownDeps = {
|
|
|
14
14
|
httpServer: Server
|
|
15
15
|
wss: WebSocketServer
|
|
16
16
|
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
|
+
sseKeepaliveTimer: ReturnType<typeof setInterval>
|
|
17
21
|
// Stops the periodic reaper AND awaits any in-flight sweep.
|
|
18
22
|
stopReaper: () => Promise<void>
|
|
19
23
|
// Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
|
|
@@ -46,7 +50,12 @@ export function createLifecycle(): Lifecycle {
|
|
|
46
50
|
const inFlight = new Set<Promise<unknown>>()
|
|
47
51
|
function track(promise: Promise<unknown>): void {
|
|
48
52
|
inFlight.add(promise)
|
|
49
|
-
|
|
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
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {})
|
|
50
59
|
}
|
|
51
60
|
let shuttingDown = false
|
|
52
61
|
// Live exit code the in-progress shutdown will pass to `process.exit`.
|
|
@@ -57,7 +66,7 @@ export function createLifecycle(): Lifecycle {
|
|
|
57
66
|
let pendingExitCode = 0
|
|
58
67
|
|
|
59
68
|
function install(deps: ShutdownDeps): void {
|
|
60
|
-
const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
|
|
69
|
+
const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer, stopReaper, sseSessions, closeDb } = deps
|
|
61
70
|
|
|
62
71
|
async function shutdown(exitCode: number = 0): Promise<void> {
|
|
63
72
|
// Re-entry: don't restart the teardown, but escalate the pending
|
|
@@ -72,9 +81,9 @@ export function createLifecycle(): Lifecycle {
|
|
|
72
81
|
shuttingDown = true
|
|
73
82
|
pendingExitCode = exitCode
|
|
74
83
|
console.log('Shutting down…')
|
|
75
|
-
// Stop
|
|
76
|
-
//
|
|
77
|
-
|
|
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)
|
|
78
87
|
// Send a 1001 (going away) close frame to every open socket BEFORE
|
|
79
88
|
// shutting the listener. Lets clients distinguish a server-initiated
|
|
80
89
|
// graceful shutdown from a network drop, so they can skip their
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// the request body and answers with the upstream's JSON.
|
|
9
9
|
//
|
|
10
10
|
// The route is mounted at `/api/npm-advisories`. The same-origin
|
|
11
|
-
// gate runs inside `dispatchNpmAdvisories` below (server/http.ts
|
|
11
|
+
// gate runs inside `dispatchNpmAdvisories` below (server-e2e/http.ts
|
|
12
12
|
// calls the dispatcher directly without a pre-check), so any
|
|
13
13
|
// browser request from a foreign origin is rejected with 403 before
|
|
14
14
|
// we ever issue a fetch — non-browser callers that omit Origin are
|
|
@@ -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
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Anti-replay guard for the REST fetch-mint endpoint (POST
|
|
2
|
+
// /api/objstore/{tag}/{res}, see ./rest.ts). The WS fetch handshake binds
|
|
3
|
+
// the per-connection challenge nonce so a captured frame can't be
|
|
4
|
+
// replayed; the REST mint has no connection, so it binds a client
|
|
5
|
+
// timestamp instead and this guard supplies the matching freshness +
|
|
6
|
+
// dedup the nonce gave for free:
|
|
7
|
+
//
|
|
8
|
+
// - FRESHNESS: reject a request whose `ts` is outside ±`windowMs` of
|
|
9
|
+
// server time (an old captured request, or a future-dated one).
|
|
10
|
+
// - DEDUP: within the window, a signature is accepted at most once — a
|
|
11
|
+
// captured-and-replayed request (same `ts` ⇒ same signature) is
|
|
12
|
+
// rejected on the second presentation.
|
|
13
|
+
//
|
|
14
|
+
// Memory: each accepted signature is held for `windowMs` then pruned.
|
|
15
|
+
// Entries share a uniform TTL and the Map preserves insertion order, so
|
|
16
|
+
// the oldest entries expire first and a cheap front-prune (amortised
|
|
17
|
+
// O(1) per entry) keeps the set bounded; a hard `maxEntries` cap drops
|
|
18
|
+
// the oldest beyond it as a flood backstop.
|
|
19
|
+
//
|
|
20
|
+
// Scope: per-process. In a multi-replica deployment a captured request
|
|
21
|
+
// could be replayed once per replica that hasn't seen its signature yet
|
|
22
|
+
// (bounded by the replica count, within the window) — acceptable because
|
|
23
|
+
// the mint only ever yields a short-TTL GET token over AEAD ciphertext
|
|
24
|
+
// the relay can't read. A shared-store dedup (Redis/Neon) would close
|
|
25
|
+
// that residual gap if ever needed.
|
|
26
|
+
|
|
27
|
+
export type FetchMintVerdict = 'ok' | 'stale' | 'replay'
|
|
28
|
+
|
|
29
|
+
export type FetchMintGuard = {
|
|
30
|
+
// `signature` is the request's Ed25519 signature (unique per
|
|
31
|
+
// (tag, res, ts) tuple, so it doubles as the dedup key). `ts` is the
|
|
32
|
+
// client epoch-ms timestamp the signature commits to; `now` is the
|
|
33
|
+
// server clock (injectable for tests).
|
|
34
|
+
admit: (signature: string, ts: number, now?: number) => FetchMintVerdict
|
|
35
|
+
size: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
|
|
39
|
+
export const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
|
|
40
|
+
|
|
41
|
+
export function createFetchMintGuard(
|
|
42
|
+
{ windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
|
|
43
|
+
{ windowMs?: number; maxEntries?: number } = {},
|
|
44
|
+
): FetchMintGuard {
|
|
45
|
+
// signature → expiry (ms). Insertion-ordered; uniform TTL ⇒ the head is
|
|
46
|
+
// always the soonest to expire.
|
|
47
|
+
const seen = new Map<string, number>()
|
|
48
|
+
|
|
49
|
+
function admit(signature: string, ts: number, now: number = Date.now()): FetchMintVerdict {
|
|
50
|
+
// Freshness first — a stale/future request never touches the cache, so
|
|
51
|
+
// it can't be used to grow the set.
|
|
52
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return 'stale'
|
|
53
|
+
// Front-prune expired entries (contiguous at the head under the
|
|
54
|
+
// uniform TTL). Breaks at the first live entry.
|
|
55
|
+
for (const [key, exp] of seen) {
|
|
56
|
+
if (exp > now) break
|
|
57
|
+
seen.delete(key)
|
|
58
|
+
}
|
|
59
|
+
// Post-prune, any remaining entry is live, so a hit is a genuine replay.
|
|
60
|
+
if (seen.has(signature)) return 'replay'
|
|
61
|
+
seen.set(signature, now + windowMs)
|
|
62
|
+
// Flood backstop: drop the oldest beyond the cap. Those are the
|
|
63
|
+
// closest to expiry anyway; dropping them only shortens their dedup
|
|
64
|
+
// window (still freshness-gated).
|
|
65
|
+
while (seen.size > maxEntries) {
|
|
66
|
+
const oldest = seen.keys().next().value
|
|
67
|
+
if (oldest === undefined) break
|
|
68
|
+
seen.delete(oldest)
|
|
69
|
+
}
|
|
70
|
+
return 'ok'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { admit, size: () => seen.size }
|
|
74
|
+
}
|