@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/client-sync.js +14 -11
- package/out/graph.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +52 -50
- package/package.json +1 -1
- package/server/auth.ts +5 -1
- package/server/config.ts +14 -1
- package/server/http.ts +77 -7
- package/server/index.ts +12 -9
- package/server/lifecycle.ts +8 -4
- package/server/objstore/blob-fs.ts +2 -2
- package/server/objstore/blob-vercel.ts +5 -5
- package/server/objstore/blob.ts +8 -1
- package/server/objstore/init.ts +26 -1
- package/server/objstore/reaper.ts +30 -10
- package/server/objstore/rest.ts +57 -32
- package/server/objstore/sign.ts +105 -0
- package/server/sse-server.ts +42 -35
- package/server/sse-session.ts +28 -8
- package/server/sync-handlers.ts +145 -91
- package/server/util.ts +9 -0
package/package.json
CHANGED
package/server/auth.ts
CHANGED
|
@@ -31,6 +31,10 @@ export type AuthenticateMsg = { password?: unknown }
|
|
|
31
31
|
export type Auth = {
|
|
32
32
|
// True iff the gate is configured AND this socket hasn't authenticated.
|
|
33
33
|
requiresAuth(socket: WebSocket): boolean
|
|
34
|
+
// Static "is a server password configured" flag — the socket-less
|
|
35
|
+
// predicate the REST put-begin gate needs (a REST request can never be
|
|
36
|
+
// operator-authorized, so `requiresAuth` collapses to this there).
|
|
37
|
+
passwordConfigured: boolean
|
|
34
38
|
handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void
|
|
35
39
|
sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void
|
|
36
40
|
}
|
|
@@ -106,5 +110,5 @@ export function createAuth(deps: {
|
|
|
106
110
|
send(socket, { type: 'authenticated' })
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
return { requiresAuth, handleAuthenticate, sendUnauthorized }
|
|
113
|
+
return { requiresAuth, passwordConfigured: CONFIGURED_PASSWORD_HMAC != null, handleAuthenticate, sendUnauthorized }
|
|
110
114
|
}
|
package/server/config.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Config = {
|
|
|
18
18
|
dbPath: string
|
|
19
19
|
objstoreDir: string
|
|
20
20
|
reapIntervalMs: number
|
|
21
|
+
reapDisabled: boolean
|
|
21
22
|
maxInflightPerSocket: number
|
|
22
23
|
debug: boolean
|
|
23
24
|
neonUrl: string | null
|
|
@@ -78,6 +79,13 @@ Environment:
|
|
|
78
79
|
DATABASE_URL + BLOB_READ_WRITE_TOKEN
|
|
79
80
|
are set (bytes live in Vercel Blob).
|
|
80
81
|
OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
|
|
82
|
+
OBJSTORE_REAP_DISABLED set '1' / 'true' to disable the orphan
|
|
83
|
+
reaper ENTIRELY — no boot sweep, no
|
|
84
|
+
periodic GC. Orphaned/superseded blobs and
|
|
85
|
+
stale staging rows then accumulate
|
|
86
|
+
unbounded; only set this if an external job
|
|
87
|
+
handles GC (e.g. a cron calling reapOrphans).
|
|
88
|
+
Default OFF (reaper runs).
|
|
81
89
|
TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
|
|
82
90
|
Host / X-Forwarded-Proto when computing
|
|
83
91
|
the same-origin gate's expected origin.
|
|
@@ -163,6 +171,11 @@ export function loadConfig(): Config {
|
|
|
163
171
|
const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
|
|
164
172
|
// No practical upper bound beyond the safe-integer range.
|
|
165
173
|
const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
|
|
174
|
+
// Hard off-switch for the orphan reaper (both the boot sweep AND the
|
|
175
|
+
// periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
|
|
176
|
+
// else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
|
|
177
|
+
const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
|
|
178
|
+
const reapDisabled = reapDisabledEnv === '1' || reapDisabledEnv?.toLowerCase() === 'true'
|
|
166
179
|
const debug = env['DEBUG'] === '1'
|
|
167
180
|
|
|
168
181
|
const configPath = env['CONFIG_PATH'] ?? fileURLToPath(new URL('./config.json', import.meta.url))
|
|
@@ -189,7 +202,7 @@ export function loadConfig(): Config {
|
|
|
189
202
|
const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
|
|
190
203
|
|
|
191
204
|
return {
|
|
192
|
-
port, host, dbPath, objstoreDir, reapIntervalMs, maxInflightPerSocket,
|
|
205
|
+
port, host, dbPath, objstoreDir, reapIntervalMs, reapDisabled, maxInflightPerSocket,
|
|
193
206
|
debug, neonUrl, blobToken, tokenSecret, password,
|
|
194
207
|
trustProxyEnv: env['TRUST_PROXY'],
|
|
195
208
|
}
|
package/server/http.ts
CHANGED
|
@@ -20,6 +20,12 @@ import { errStack } from './util.ts'
|
|
|
20
20
|
// similar) can route `/api/*` → this process and `/*` → the static UI
|
|
21
21
|
// bundle with a single location block.
|
|
22
22
|
export const WS_UPGRADE_PATH = '/api/sync'
|
|
23
|
+
// Session-independent triage-sync save plane: `POST /api/sync/save`. The
|
|
24
|
+
// SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
|
|
25
|
+
// here commits + broadcasts WITHOUT taking over the client's SSE event-stream
|
|
26
|
+
// (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
|
|
27
|
+
// the WS upgrade path so one nginx `/api/*` block routes both.
|
|
28
|
+
export const SAVE_REST_PATH = '/api/sync/save'
|
|
23
29
|
function isUpgradePath(url: string | undefined): boolean {
|
|
24
30
|
if (typeof url !== 'string') return false
|
|
25
31
|
// Strip `?…` so clients can carry build / debug tags. Exact match
|
|
@@ -42,12 +48,68 @@ export type HttpServerDeps = {
|
|
|
42
48
|
isOriginAllowed: (req: HasHeaders) => boolean
|
|
43
49
|
isShuttingDown: () => boolean
|
|
44
50
|
track: (promise: Promise<unknown>) => void
|
|
51
|
+
// Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
|
|
52
|
+
// the save pipeline + JSON response; this module owns the gates (method,
|
|
53
|
+
// same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
|
|
54
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
45
55
|
restPutIdleTimeoutMs: number
|
|
46
56
|
debug: boolean
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
// `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
|
|
60
|
+
// the request matched the route (the caller `track`s it for graceful drain),
|
|
61
|
+
// or null when it's for a different route. The gate ladder — method →
|
|
62
|
+
// shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
|
|
63
|
+
// gate rejection writes its own response and returns an already-settled
|
|
64
|
+
// promise. Kept out of `createHttpServer` so that dispatcher stays compact.
|
|
65
|
+
function dispatchSaveRest(
|
|
66
|
+
req: HttpRequest, res: ServerResponse,
|
|
67
|
+
deps: {
|
|
68
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
69
|
+
isOriginAllowed: (req: HasHeaders) => boolean
|
|
70
|
+
isShuttingDown: () => boolean
|
|
71
|
+
restPutIdleTimeoutMs: number
|
|
72
|
+
debug: boolean
|
|
73
|
+
},
|
|
74
|
+
): Promise<void> | null {
|
|
75
|
+
if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
|
|
76
|
+
if (req.method !== 'POST') {
|
|
77
|
+
res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
|
|
78
|
+
res.end(JSON.stringify({ error: 'method-not-allowed' }))
|
|
79
|
+
return Promise.resolve()
|
|
80
|
+
}
|
|
81
|
+
// Shutdown gate — parity with the objstore REST branch (a POST on an
|
|
82
|
+
// existing keep-alive socket after SIGTERM but before close() drains).
|
|
83
|
+
if (deps.isShuttingDown()) {
|
|
84
|
+
res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
|
|
85
|
+
res.end(JSON.stringify({ error: 'shutting-down' }))
|
|
86
|
+
return Promise.resolve()
|
|
87
|
+
}
|
|
88
|
+
// Same-origin gate — a hostile cross-origin page would carry an Origin
|
|
89
|
+
// header (browser-set on fetch); same-origin XHR may omit it (allowed).
|
|
90
|
+
if (!deps.isOriginAllowed(req)) {
|
|
91
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
92
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
93
|
+
return Promise.resolve()
|
|
94
|
+
}
|
|
95
|
+
// Idle-body timeout — a slow-loris trickling the JSON body would otherwise
|
|
96
|
+
// hold the connection indefinitely.
|
|
97
|
+
req.setTimeout(deps.restPutIdleTimeoutMs, () => {
|
|
98
|
+
if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
|
|
99
|
+
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
100
|
+
})
|
|
101
|
+
// Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
|
|
102
|
+
// handler's own try/catch — logs and terminates the response so the TCP
|
|
103
|
+
// socket doesn't dangle (same policy as the objstore handleRest wrapper).
|
|
104
|
+
return deps.handleSaveRest(req, res).catch((err) => {
|
|
105
|
+
console.warn('sync-save REST handler error:', errStack(err))
|
|
106
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
107
|
+
else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
49
111
|
export function createHttpServer(deps: HttpServerDeps): Server {
|
|
50
|
-
const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
|
|
112
|
+
const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
|
|
51
113
|
// Static-file plane (see ./static.ts). The directory is the
|
|
52
114
|
// `build.js build` output sibling to this file; the loader handles
|
|
53
115
|
// enumeration, pre-compression, and ETag derivation. Plugged in after
|
|
@@ -76,6 +138,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
76
138
|
// the lifecycle's sseSessions() close loop.
|
|
77
139
|
if (sseServer.handle(req, res)) return
|
|
78
140
|
}
|
|
141
|
+
// Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
|
|
142
|
+
// `POST /api/sync/save` so an SSE-mode save doesn't take over the
|
|
143
|
+
// event-stream. The dispatch helper owns the gate ladder; we `track` the
|
|
144
|
+
// returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
|
|
145
|
+
const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
|
|
146
|
+
if (saveP) { track(saveP); return }
|
|
79
147
|
// npm advisories proxy — same-origin + shutdown gates live in the
|
|
80
148
|
// helper so this dispatcher stays compact. `dispatchNpmAdvisories`
|
|
81
149
|
// returns the in-flight promise (or null when the route didn't
|
|
@@ -109,14 +177,16 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
109
177
|
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
110
178
|
return
|
|
111
179
|
}
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// inFlightSids slot indefinitely.
|
|
115
|
-
//
|
|
180
|
+
// Idle-body timeout for the body-bearing REST methods — a slow-loris
|
|
181
|
+
// client trickling bytes holds the connection (and, for PUT, the
|
|
182
|
+
// staging fd + inFlightSids slot) indefinitely. PUT carries the raw
|
|
183
|
+
// blob within its declared Content-Length; POST carries the small
|
|
184
|
+
// fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
|
|
185
|
+
// destroy the request, aborting the body pipeline.
|
|
116
186
|
// Transport audit `server/objstore/rest.ts:218`.
|
|
117
|
-
if (req.method === 'PUT') {
|
|
187
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
118
188
|
req.setTimeout(restPutIdleTimeoutMs, () => {
|
|
119
|
-
if (debug) console.warn(`REST
|
|
189
|
+
if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
|
|
120
190
|
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
121
191
|
})
|
|
122
192
|
}
|
package/server/index.ts
CHANGED
|
@@ -102,7 +102,7 @@ import { createBusReceiver } from './bus-receiver.ts'
|
|
|
102
102
|
const config = loadConfig()
|
|
103
103
|
const {
|
|
104
104
|
port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
|
|
105
|
-
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
105
|
+
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS, reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
106
106
|
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, debug: DEBUG,
|
|
107
107
|
neonUrl: NEON_URL, blobToken: BLOB_TOKEN, tokenSecret: TOKEN_SECRET,
|
|
108
108
|
password: CONFIG_PASSWORD, trustProxyEnv: TRUST_PROXY_ENV,
|
|
@@ -275,7 +275,7 @@ if (NEON_URL) {
|
|
|
275
275
|
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
276
276
|
// handshake.
|
|
277
277
|
const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
|
|
278
|
-
const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
|
|
278
|
+
const { requiresAuth, passwordConfigured, handleAuthenticate, sendUnauthorized } = auth
|
|
279
279
|
|
|
280
280
|
// Triage-sync protocol handlers (see ./sync-handlers.ts). `getNonce`
|
|
281
281
|
// resolves a socket's challenge nonce and is shared with the objstore
|
|
@@ -292,9 +292,9 @@ const publishObjDeleted = (tag: string, resourceTag: string, version: number): v
|
|
|
292
292
|
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
295
|
+
const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
296
296
|
handle, send, broadcast, publishRevision, subscribe, getNonce,
|
|
297
|
-
requiresAuth, sendUnauthorized, workspaceExists,
|
|
297
|
+
requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
|
|
298
298
|
// Folds the objstore inventory into the `workspace-subscribed` ack.
|
|
299
299
|
// The objstore store keeps its own richer `Handle`, so we wire the
|
|
300
300
|
// query here where both handles exist rather than coupling
|
|
@@ -305,6 +305,7 @@ const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
|
305
305
|
|
|
306
306
|
const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper } = initObjstore({
|
|
307
307
|
handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
308
|
+
reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
308
309
|
send, broadcast, publishObjPut, publishObjDeleted,
|
|
309
310
|
getNonce, debug: DEBUG,
|
|
310
311
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
@@ -315,6 +316,11 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
|
|
|
315
316
|
// `unauthorized` frame and bails on `true`.
|
|
316
317
|
authGate: async (socket, tag) => requiresAuth(socket) && !await workspaceExists(tag),
|
|
317
318
|
sendUnauthorized,
|
|
319
|
+
// Socket-less analog of `authGate` for the REST put-begin mint: a REST
|
|
320
|
+
// request can never be operator-authorized, so the gate collapses to
|
|
321
|
+
// "password configured AND workspace new". A deny routes the client to
|
|
322
|
+
// its in-band WS put-begin fallback.
|
|
323
|
+
restPutGate: async (tag) => passwordConfigured && !await workspaceExists(tag),
|
|
318
324
|
// `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
|
|
319
325
|
// provided in env (see TOKEN_SECRET resolution above). Omitted
|
|
320
326
|
// → initObjstore mints a fresh per-process secret (fine for
|
|
@@ -361,10 +367,6 @@ const sseServer = installSseServer({
|
|
|
361
367
|
// Mirror the WS `maxPayload` so the SSE plane can't accept frames
|
|
362
368
|
// the WS plane would reject.
|
|
363
369
|
maxBodyBytes: 4 * 1024 * 1024,
|
|
364
|
-
// 90s idle ceiling. The client's JSON ping/pong is 15s; this is ~6
|
|
365
|
-
// missed pings before we close — well past any transient network
|
|
366
|
-
// hiccup, much shorter than the kernel's hours-long TCP keepalive.
|
|
367
|
-
sessionIdleMs: 90_000,
|
|
368
370
|
debug: DEBUG,
|
|
369
371
|
})
|
|
370
372
|
|
|
@@ -375,7 +377,7 @@ const sseServer = installSseServer({
|
|
|
375
377
|
// below.
|
|
376
378
|
const httpServer = createHttpServer({
|
|
377
379
|
wss, restDeps: objstoreRestDeps, sseServer, isOriginAllowed,
|
|
378
|
-
isShuttingDown, track,
|
|
380
|
+
isShuttingDown, track, handleSaveRest,
|
|
379
381
|
restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
|
|
380
382
|
})
|
|
381
383
|
|
|
@@ -444,6 +446,7 @@ const closeDb = async (): Promise<void> => {
|
|
|
444
446
|
installLifecycle({
|
|
445
447
|
httpServer, wss, heartbeatTimer, stopReaper,
|
|
446
448
|
sseSessions: sseServer.sessions,
|
|
449
|
+
sseKeepaliveTimer: sseServer.keepaliveTimer,
|
|
447
450
|
closeDb,
|
|
448
451
|
})
|
|
449
452
|
|
package/server/lifecycle.ts
CHANGED
|
@@ -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/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
|
|
@@ -62,7 +66,7 @@ export function createLifecycle(): Lifecycle {
|
|
|
62
66
|
let pendingExitCode = 0
|
|
63
67
|
|
|
64
68
|
function install(deps: ShutdownDeps): void {
|
|
65
|
-
const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
|
|
69
|
+
const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer, stopReaper, sseSessions, closeDb } = deps
|
|
66
70
|
|
|
67
71
|
async function shutdown(exitCode: number = 0): Promise<void> {
|
|
68
72
|
// Re-entry: don't restart the teardown, but escalate the pending
|
|
@@ -77,9 +81,9 @@ export function createLifecycle(): Lifecycle {
|
|
|
77
81
|
shuttingDown = true
|
|
78
82
|
pendingExitCode = exitCode
|
|
79
83
|
console.log('Shutting down…')
|
|
80
|
-
// Stop
|
|
81
|
-
//
|
|
82
|
-
|
|
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)
|
|
83
87
|
// Send a 1001 (going away) close frame to every open socket BEFORE
|
|
84
88
|
// shutting the listener. Lets clients distinguish a server-initiated
|
|
85
89
|
// graceful shutdown from a network drop, so they can skip their
|
|
@@ -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
|
|
@@ -364,19 +364,19 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
364
364
|
// same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
|
|
365
365
|
// the client the resource is gone for good when it should refetch.
|
|
366
366
|
// See server/README.md's GET status table.
|
|
367
|
-
if (isNotFound(err)) return { ok: false, reason: 'unavailable' }
|
|
367
|
+
if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
|
|
368
368
|
throw err
|
|
369
369
|
}
|
|
370
370
|
// SDK returned null (no blob) — same "bytes missing for a live row"
|
|
371
371
|
// transient as the BlobNotFoundError branch above → `unavailable`, not
|
|
372
372
|
// `not-found`.
|
|
373
|
-
if (res == null) return { ok: false, reason: 'unavailable' }
|
|
373
|
+
if (res == null) return { ok: false, reason: 'unavailable', detail: 'vercel-get-null' }
|
|
374
374
|
// statusCode 304 doesn't reach here in practice — the REST
|
|
375
375
|
// GET layer doesn't pass If-None-Match — but a future call
|
|
376
376
|
// site could. Treat as unavailable rather than streaming a
|
|
377
377
|
// null body.
|
|
378
378
|
if (res.statusCode !== 200 || res.stream == null) {
|
|
379
|
-
return { ok: false, reason: 'unavailable' }
|
|
379
|
+
return { ok: false, reason: 'unavailable', detail: `vercel-get-status-${res.statusCode}` }
|
|
380
380
|
}
|
|
381
381
|
// `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
|
|
382
382
|
// the body but does NOT populate `blob.size` nor pass a
|
|
@@ -395,11 +395,11 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
395
395
|
// Blob vanished between get() and the head() size fallback (a
|
|
396
396
|
// racing reaper GC) — still the "live row present, bytes gone"
|
|
397
397
|
// transient, so `unavailable` (503), matching the get() path above.
|
|
398
|
-
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable' }
|
|
398
|
+
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
|
|
399
399
|
throw headErr
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
|
-
if (size == null) return { ok: false, reason: 'unavailable' }
|
|
402
|
+
if (size == null) return { ok: false, reason: 'unavailable', detail: 'vercel-no-size' }
|
|
403
403
|
// SDK returns a web ReadableStream<Uint8Array>; the REST layer
|
|
404
404
|
// expects a Node Readable for pipeline(). Convert via
|
|
405
405
|
// Readable.fromWeb — built-in and zero-copy where possible.
|
package/server/objstore/blob.ts
CHANGED
|
@@ -94,9 +94,16 @@ export type LiveReader = {
|
|
|
94
94
|
// `unavailable`/503 contract, which the client retries. Both backends MUST
|
|
95
95
|
// map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
|
|
96
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.
|
|
97
104
|
export type OpenLiveResult =
|
|
98
105
|
| { ok: true; reader: LiveReader }
|
|
99
|
-
| { ok: false; reason: 'unavailable' }
|
|
106
|
+
| { ok: false; reason: 'unavailable'; detail?: string }
|
|
100
107
|
|
|
101
108
|
export type BlobBackend = {
|
|
102
109
|
// Per-workspace setup. FS creates the on-disk staging directory;
|
package/server/objstore/init.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type ObjstoreInitDeps = {
|
|
|
18
18
|
// the workspace_revision handle in server/db.ts.
|
|
19
19
|
handle: Handle
|
|
20
20
|
reapIntervalMs: number
|
|
21
|
+
// Hard off-switch (OBJSTORE_REAP_DISABLED). When true, NEITHER the boot
|
|
22
|
+
// sweep nor the periodic timer runs: `startupReap` resolves immediately
|
|
23
|
+
// and `stopReaper` is a no-op. Orphaned/superseded blobs and stale
|
|
24
|
+
// staging rows then accumulate unbounded — only safe if an external job
|
|
25
|
+
// handles GC. Omitted/false → reaper runs (the default). See config.ts.
|
|
26
|
+
reapDisabled?: boolean
|
|
21
27
|
send: (socket: WebSocket, msg: object) => void
|
|
22
28
|
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
23
29
|
// Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
|
|
@@ -42,6 +48,12 @@ export type ObjstoreInitDeps = {
|
|
|
42
48
|
// is not load-balancer-pinned). See server/index.ts boot logic
|
|
43
49
|
// for the env var (`OBJSTORE_TOKEN_SECRET`).
|
|
44
50
|
tokenSecret?: TokenSecret
|
|
51
|
+
// New-workspace operator gate for the REST put-begin mint — returns
|
|
52
|
+
// `true` to DENY (password configured AND workspace new). The
|
|
53
|
+
// connection-independent analog of `authGate`; the client falls back to
|
|
54
|
+
// the in-band WS put-begin on a deny. Omitted → open (never deny),
|
|
55
|
+
// matching `authGate`'s no-config default.
|
|
56
|
+
restPutGate?: (workspaceTag: string) => Promise<boolean>
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
export type ObjstoreInit = {
|
|
@@ -77,7 +89,10 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
77
89
|
...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
|
|
78
90
|
})
|
|
79
91
|
const restDeps: ObjstoreRestDeps = {
|
|
80
|
-
handle, secret, broadcast: deps.broadcast,
|
|
92
|
+
handle, secret, broadcast: deps.broadcast,
|
|
93
|
+
publishObjPut: deps.publishObjPut, publishObjDeleted: deps.publishObjDeleted,
|
|
94
|
+
restPutGate: deps.restPutGate ?? (() => Promise.resolve(false)),
|
|
95
|
+
debug: deps.debug,
|
|
81
96
|
}
|
|
82
97
|
// Re-entrancy guard for periodic + startup sweeps. Kicking the
|
|
83
98
|
// startup sweep through the same `enqueueSweep` path means the
|
|
@@ -98,6 +113,16 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
98
113
|
inFlight = p
|
|
99
114
|
return p
|
|
100
115
|
}
|
|
116
|
+
// Hard off-switch (OBJSTORE_REAP_DISABLED). Skip the boot sweep AND the
|
|
117
|
+
// periodic timer entirely: `startupReap` resolves immediately so the
|
|
118
|
+
// index.ts `await startupReap` gate is a no-op, and `stopReaper` has
|
|
119
|
+
// nothing to clear or drain. Loud, unconditional warning — with GC off,
|
|
120
|
+
// orphaned/superseded blobs and stale staging rows are never reclaimed
|
|
121
|
+
// (they accumulate until an external job, if any, collects them).
|
|
122
|
+
if (deps.reapDisabled) {
|
|
123
|
+
console.warn('objstore: reaper DISABLED (OBJSTORE_REAP_DISABLED) — orphaned/superseded blobs and stale staging will NOT be reclaimed')
|
|
124
|
+
return { handlers, restDeps, startupReap: Promise.resolve(), stopReaper: async () => {} }
|
|
125
|
+
}
|
|
101
126
|
// Caller awaits this before accepting traffic so a fresh boot
|
|
102
127
|
// can't hand out list / fetch / put-begin against a tag whose
|
|
103
128
|
// on-disk state still has stranded files from a prior crash.
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// predicate can't match a row a concurrent upload just refreshed.
|
|
28
28
|
|
|
29
29
|
import { type Handle, STAGING_TTL_MS_DEFAULT, isValidContentHash, isValidStagingId, isValidTag } from './store.ts'
|
|
30
|
+
import { debugId, debugTag } from '../util.ts'
|
|
30
31
|
|
|
31
32
|
type StagingRow = {
|
|
32
33
|
workspace_tag: string
|
|
@@ -67,12 +68,19 @@ type StagingRow = {
|
|
|
67
68
|
// live-set re-read, not via mutual exclusion.
|
|
68
69
|
async function gcBlobIfUnreferenced(
|
|
69
70
|
handle: Handle, tag: string, hash: string, modifiedMs: number, now: number, grace: number,
|
|
70
|
-
): Promise<
|
|
71
|
-
if (!isValidContentHash(hash)) return
|
|
72
|
-
if (now - modifiedMs < grace) return
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
if (!isValidContentHash(hash)) return false
|
|
73
|
+
if (now - modifiedMs < grace) return false
|
|
73
74
|
const refs = await liveHashSet(handle, tag)
|
|
74
|
-
if (refs.has(hash)) return
|
|
75
|
+
if (refs.has(hash)) return false
|
|
75
76
|
await handle.blob.unlinkLive(tag, hash)
|
|
77
|
+
// Log EVERY live-blob deletion unconditionally (not behind `debug`):
|
|
78
|
+
// this is the only record that the GC removed bytes. A handful per
|
|
79
|
+
// sweep is normal (superseded versions aging out); a burst across many
|
|
80
|
+
// workspaces is the smoking gun for the "all uploaded data went
|
|
81
|
+
// missing" failure — pair it with the sweep summary in `reapOrphans`.
|
|
82
|
+
console.warn(`objstore-reaper: GC live blob ${debugTag(tag)}/${debugId(hash)} (unreferenced, age ${Math.round((now - modifiedMs) / 1000)}s ≥ grace ${Math.round(grace / 1000)}s)`)
|
|
83
|
+
return true
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// The set of content hashes referenced by the workspace's live rows.
|
|
@@ -86,18 +94,22 @@ async function liveHashSet(handle: Handle, tag: string): Promise<Set<string>> {
|
|
|
86
94
|
// snapshot we read up front can race a concurrent commit; the
|
|
87
95
|
// reference re-read inside `gcBlobIfUnreferenced` (plus the grace
|
|
88
96
|
// window) ensures we never unlink a blob a live row names.
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
// Returns the number of live blobs GC'd for this tag, so `reapOrphans`
|
|
98
|
+
// can surface a sweep-wide total (the headline signal for mass loss).
|
|
99
|
+
async function reapUnreferencedForTag(handle: Handle, tag: string, now: number, grace: number): Promise<number> {
|
|
100
|
+
if (!isValidTag(tag)) return 0
|
|
91
101
|
const blobs = await handle.blob.listLiveBlobs(tag)
|
|
92
|
-
if (blobs.length === 0) return
|
|
102
|
+
if (blobs.length === 0) return 0
|
|
93
103
|
const referenced = await liveHashSet(handle, tag)
|
|
104
|
+
let gc = 0
|
|
94
105
|
for (const { hash, modifiedMs } of blobs) {
|
|
95
106
|
if (!isValidContentHash(hash)) continue
|
|
96
107
|
// Referenced in our snapshot → skip the grace + re-read path
|
|
97
108
|
// entirely; only unreferenced blobs need it.
|
|
98
109
|
if (referenced.has(hash)) continue
|
|
99
|
-
await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)
|
|
110
|
+
if (await gcBlobIfUnreferenced(handle, tag, hash, modifiedMs, now, grace)) gc++
|
|
100
111
|
}
|
|
112
|
+
return gc
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
// Drop staging rows older than the TTL and unlink their on-storage
|
|
@@ -172,9 +184,10 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
|
|
|
172
184
|
const grace = stagingTtlMs
|
|
173
185
|
// Pass 1: tags the live table knows about — GC unreferenced live
|
|
174
186
|
// blobs (past the grace window) against the referenced-hash set.
|
|
187
|
+
let liveGc = 0
|
|
175
188
|
const liveTagsRows = await handle.listLiveTags.all()
|
|
176
189
|
const liveTags = liveTagsRows.map((r) => r.workspace_tag)
|
|
177
|
-
for (const tag of liveTags) await reapUnreferencedForTag(handle, tag, now, grace)
|
|
190
|
+
for (const tag of liveTags) liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
|
|
178
191
|
// Whole-workspace deletes leave residue (dirs / blob-prefixes) the
|
|
179
192
|
// live table no longer lists. Walk the backend's top-level workspace
|
|
180
193
|
// listing to find them; for each straggler tag, GC its unreferenced
|
|
@@ -186,7 +199,14 @@ export async function reapOrphans(handle: Handle, stagingTtlMs: number = STAGING
|
|
|
186
199
|
const liveSet = new Set(liveTags)
|
|
187
200
|
for (const tag of topLevel) {
|
|
188
201
|
if (liveSet.has(tag) || !isValidTag(tag)) continue
|
|
189
|
-
await reapUnreferencedForTag(handle, tag, now, grace)
|
|
202
|
+
liveGc += await reapUnreferencedForTag(handle, tag, now, grace)
|
|
203
|
+
}
|
|
204
|
+
// Sweep-wide total. A nonzero count means the GC deleted live bytes
|
|
205
|
+
// this pass — logged unconditionally so "all data went missing"
|
|
206
|
+
// leaves an obvious server-side trail (a large count over a short
|
|
207
|
+
// window is the signature). Per-blob lines above carry which/why.
|
|
208
|
+
if (liveGc > 0) {
|
|
209
|
+
console.warn(`objstore-reaper: swept ${liveTags.length} live + ${topLevel.length} store tag(s); GC'd ${liveGc} live blob(s)`)
|
|
190
210
|
}
|
|
191
211
|
// Pass 2: stale staging rows + orphan staging blobs. The orphan
|
|
192
212
|
// sweep does per-blob row lookups (no caller-side snapshot), so a
|