@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.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,276 @@
1
+ // WebSocket runtime: the per-connection handler (Peer setup, message
2
+ // dispatch, close/error) and the server-driven heartbeat sweep. Wired
3
+ // once at boot onto the shared `wss` with the protocol handlers +
4
+ // lifecycle hooks it dispatches to. Kept out of index.ts so the WS
5
+ // message loop — the most concurrency-sensitive surface — is one unit.
6
+ //
7
+ // `setupPeerConnection` is the per-connection handler in isolation; the
8
+ // SSE+POST fallback (./sse-server.ts) calls it with an `SseSession`
9
+ // adapter so both transports share one dispatcher and one Peer
10
+ // lifecycle. The adapter is structurally compatible — `setupPeerConnection`
11
+ // only touches the `EventEmitter` + `readyState`/`OPEN`/`send`/`close`
12
+ // subset that both real WSs and the adapter expose.
13
+
14
+ import type { WebSocket, WebSocketServer } from 'ws'
15
+ import { Buffer } from 'node:buffer'
16
+ import type { IncomingMessage as HttpRequest } from 'node:http'
17
+ import { decodeUtf8 } from '../common/utf8.js'
18
+ import type { SaveErrorReason } from '../common/save-error-reason.ts'
19
+ import type { ServerInfo } from '../common/server-info.ts'
20
+ import { Peer, type PeerRegistry } from './peer.ts'
21
+ import { errMsg, errStack, randomId } from './util.ts'
22
+ import type { SaveMsg, SubscribeMsg } from './sign.ts'
23
+ import type { AuthenticateMsg } from './auth.ts'
24
+ import type { ObjstoreHandlers } from './objstore/handlers.ts'
25
+ import type { ObjstoreDeleteMsg, ObjstoreFetchMsg, ObjstorePutBeginMsg } from './objstore/sign.ts'
26
+
27
+ // Wire-message envelope as it lands post-`JSON.parse`. Every field is
28
+ // `unknown` until a handler narrows it; the type just documents the
29
+ // dispatch surface so call sites can pattern-match on `msg.type`.
30
+ type IncomingMessage = {
31
+ type?: unknown
32
+ [k: string]: unknown
33
+ }
34
+
35
+ // Subset of `WsServerDeps` consumed by `setupPeerConnection`. The
36
+ // heartbeat sweep + the `wss` reference stay in `WsServerDeps`; the
37
+ // SSE path doesn't need them.
38
+ export type PeerConnectionDeps = {
39
+ peers: PeerRegistry
40
+ // The mode this server advertises, emitted as the first `server-info`
41
+ // frame on every connection (see setupPeerConnection).
42
+ serverInfo: ServerInfo
43
+ send: (socket: WebSocket, msg: object) => void
44
+ unsubscribeAll: (socket: WebSocket) => void
45
+ handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
46
+ handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
47
+ handleAuthenticate: (socket: WebSocket, msg: AuthenticateMsg) => void
48
+ sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
49
+ objstore: ObjstoreHandlers
50
+ track: (promise: Promise<unknown>) => void
51
+ isShuttingDown: () => boolean
52
+ maxInflightPerSocket: number
53
+ debug: boolean
54
+ }
55
+
56
+ export type WsServerDeps = PeerConnectionDeps & {
57
+ wss: WebSocketServer
58
+ heartbeatIntervalMs: number
59
+ }
60
+
61
+ // Per-connection setup: create Peer, send challenge, wire message /
62
+ // close / error / pong listeners. `socket` is the WebSocket-shaped
63
+ // surface — a real `ws` `WebSocket`, or an `SseSession` cast through
64
+ // `as unknown as WebSocket`; only the subset both expose is used.
65
+ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
66
+ const {
67
+ peers, serverInfo, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
68
+ sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
69
+ } = deps
70
+ if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
71
+ // One Peer holds this connection's state, created before any client
72
+ // frame can arrive (`socket.on('message')` is wired below). The
73
+ // heartbeat sweep's two-tick liveness check (see below) is the only
74
+ // thing closing FDs for an otherwise-idle peer.
75
+ const peer = new Peer(randomId())
76
+ peers.set(socket, peer)
77
+ socket.on('pong', () => { peer.alive = true })
78
+ // Issue the per-connection challenge nonce FIRST — before the client can
79
+ // send anything that needs it, AND as frame #0 so clients/tests that
80
+ // positionally read the first frame as the challenge keep working. The
81
+ // client signs it into every `workspace-subscribe` (see canonicalSubscribe
82
+ // in server-e2e/sign.ts); a captured subscribe frame can't be replayed from
83
+ // a different connection because that connection's nonce differs and the
84
+ // signature won't verify against the new canonical bytes. Round-9 H2.
85
+ send(socket, { type: 'challenge', nonce: peer.challenge })
86
+ // Then advertise the sync protocol (the mode probe), right after the
87
+ // challenge — the WS plane and the SSE fallback share this path. A client
88
+ // uses it to detect the mode, cache it, and refuse a cross-mode switch.
89
+ // Unauthenticated + mode-agnostic; an older client ignores it (predicate
90
+ // readers skip it; the workspaceTag demux drops it).
91
+ send(socket, { type: 'server-info', ...serverInfo })
92
+ // Per-socket handlers are DELIBERATELY NOT serialized (vs the
93
+ // client-side `messageQueue = messageQueue.then(...)` Promise
94
+ // chain inside `client/triage-sync.ts:onTransportMessage`).
95
+ // Each inbound frame spawns its own tracked async IIFE; two
96
+ // frames from the same socket can interleave across `await`
97
+ // boundaries inside the handlers. Per-resource correctness needs
98
+ // no in-process lock: `commitRevision` resolves concurrent saves
99
+ // via its single gated INSERT (one snapshot + the
100
+ // `UNIQUE(workspace_tag, seq)` PK — see `server-e2e/db.ts`), and the
101
+ // objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
102
+ // via the version compare-and-set + content-addressing (see
103
+ // `server-e2e/objstore/store.ts`), backed by post-await
104
+ // `readyState === OPEN` rechecks in every objstore handler. The
105
+ // unbounded fan-out is capped by `maxInflightPerSocket` (see
106
+ // also the `'busy'` NACK at the cap below). Concurrent dispatch
107
+ // is intentional: it lets multi-workspace clients multiplex
108
+ // saves + subscribes over one socket without HOL blocking.
109
+ // Audit follow-up to round-15 concurrency review.
110
+ socket.on('message', (data: Buffer, isBinary: boolean) => {
111
+ // Drop new work once shutdown started — `wss.close()` stops new
112
+ // CONNECTIONS but already-open sockets can still send messages.
113
+ // Without this gate a message arriving between `wss.close()`
114
+ // resolving and the `inFlight` snapshot would spawn a handler
115
+ // that's NOT in the snapshot, then resume against the just-
116
+ // closed DB and throw inside the commit's gated INSERT. Audit
117
+ // round-9.
118
+ if (isShuttingDown()) return
119
+ // Wire protocol is JSON over text frames. A binary frame is
120
+ // either a buggy client or someone probing — drop without
121
+ // attempting to interpret it as text.
122
+ if (isBinary) return
123
+ let msg: IncomingMessage | null = null
124
+ try {
125
+ // `decodeUtf8` is fatal on invalid UTF-8 (vs `Buffer.toString`
126
+ // which silently substitutes U+FFFD). The substitution path
127
+ // would let mangled bytes pass JSON.parse only to fail
128
+ // signature verification deeper in the handler — wasted work
129
+ // and noisier logs. Fail at the gate.
130
+ msg = JSON.parse(decodeUtf8(data)) as IncomingMessage
131
+ } catch { return }
132
+ if (!msg || typeof msg !== 'object') return
133
+ const parsed: IncomingMessage = msg
134
+ // Per-socket inflight cap. Each tracked handler keeps the socket
135
+ // alive in `inFlight`, and a peer who keeps firing valid frames
136
+ // can spawn unbounded handlers — growing SIGTERM drain time and
137
+ // memory. Drop new work above the cap. Heartbeat ping is the
138
+ // exception: it's stateless, synchronous, and we want to KEEP
139
+ // responding so the peer doesn't drop the socket while we're
140
+ // shedding load. Pings go through a fast inline `send(pong)`
141
+ // path BELOW that doesn't bump the per-socket inflight counter,
142
+ // so a ping-spam at the cap can't outrun the gate. Transport
143
+ // audit `server-e2e/index.ts:590` + post-#58 audit follow-up.
144
+ if (parsed.type === 'ping') {
145
+ send(socket, { type: 'pong' })
146
+ return
147
+ }
148
+ // `authenticate` runs synchronously (constant-time bytes compare
149
+ // — no DB, no I/O), so it shares the same fast-inline path as
150
+ // `ping` and bypasses the per-socket inflight counter. Keeping
151
+ // it out of the IIFE pool means an unauthenticated client can
152
+ // still complete the handshake when the socket is otherwise
153
+ // saturated (matching the philosophy of the `busy` NACK path
154
+ // for `workspace-save`: don't strand a recoverable handshake
155
+ // behind the cap).
156
+ if (parsed.type === 'authenticate') {
157
+ handleAuthenticate(socket, parsed as AuthenticateMsg)
158
+ return
159
+ }
160
+ if (peer.inflight >= maxInflightPerSocket) {
161
+ if (debug) console.warn(`drop message: socket inflight ${peer.inflight} >= ${maxInflightPerSocket}`)
162
+ // For workspace-save specifically, send a typed NACK so the
163
+ // client's `pending` slot clears IMMEDIATELY instead of
164
+ // hanging until the next heartbeat (~15–30s). Reason `busy`
165
+ // is server-side overload; safe to retry. Same wire envelope
166
+ // as the existing `too-large` save-error path.
167
+ //
168
+ // Wire-order interaction with `'stale-base'`: the cap-path
169
+ // send is SYNCHRONOUS in this message callback and lands on
170
+ // the wire BEFORE any handler IIFE's `await`-completed reply.
171
+ // If an earlier in-flight `workspace-save` (frame F1) ends up
172
+ // emitting a catch-up `workspace-state` + `'stale-base'` while
173
+ // a later frame F2 hits the cap, the order is `'busy'`(F2) →
174
+ // `workspace-state`(F1) → `'stale-base'`(F1). The client's
175
+ // `handleSaveError` correlates on `base`, and the wire-order
176
+ // trick documented in `common/save-error-reason.ts` (catch-up
177
+ // clears `pending` before the stale-base frame's
178
+ // `handleSaveError` runs) still holds.
179
+ const rawBase = (parsed as SaveMsg).base
180
+ const baseField: string | null = typeof rawBase === 'string' ? rawBase : null
181
+ const rawTag = (parsed as SaveMsg).workspaceTag
182
+ const tagField = typeof rawTag === 'string' ? rawTag : null
183
+ if (parsed.type === 'workspace-save' && tagField != null) {
184
+ // `sendSaveError` runs its taxonomy-guard before the wire
185
+ // send and throws on an unknown reason. Every OTHER emit
186
+ // site lives inside the `handler` IIFE's try/catch — this
187
+ // cap path is the only one outside it. Mirror the same
188
+ // forensic envelope so a future bad reason here surfaces
189
+ // as `Handler error (type=workspace-save): …` rather than
190
+ // escaping to ws's emitter as an uncaught.
191
+ try {
192
+ sendSaveError(socket, tagField, baseField, 'busy')
193
+ } catch (err) {
194
+ console.warn('Handler error (type=workspace-save):', errStack(err))
195
+ }
196
+ }
197
+ return
198
+ }
199
+ peer.inflight += 1
200
+ const handler = (async () => {
201
+ try {
202
+ if (parsed.type === 'workspace-save') await handleSave(socket, parsed as SaveMsg)
203
+ else if (parsed.type === 'workspace-subscribe') await handleSubscribe(socket, parsed as SubscribeMsg)
204
+ // Objstore control plane — bytes ride the REST plane via
205
+ // tokens these handlers mint. The Objstore*Msg types are
206
+ // weak shapes (every field `unknown`); the handlers narrow
207
+ // each field through their own validators on entry.
208
+ else if (parsed.type === 'objstore-put-begin') await objstore.handlePutBegin(socket, parsed as ObjstorePutBeginMsg)
209
+ else if (parsed.type === 'objstore-delete') await objstore.handleDelete(socket, parsed as ObjstoreDeleteMsg)
210
+ else if (parsed.type === 'objstore-fetch') await objstore.handleFetch(socket, parsed as ObjstoreFetchMsg)
211
+ } catch (err) {
212
+ // Forensic logging for unexpected throws — the handlers all
213
+ // have internal narrow catches (e.g. signature reject paths);
214
+ // anything reaching here is unexpected. Include the wire
215
+ // `type` so an operator can correlate to a specific code
216
+ // path, and prefer `.stack` over `.message` so the post-
217
+ // mortem has the throw site.
218
+ const typeStr = typeof parsed.type === 'string' ? parsed.type : '<unknown>'
219
+ console.warn(`Handler error (type=${typeStr}):`, errStack(err))
220
+ } finally {
221
+ peer.inflight -= 1
222
+ }
223
+ })()
224
+ track(handler)
225
+ })
226
+ socket.on('close', () => {
227
+ // `unsubscribeAll` reads `peer.tags` (peer still registered), then
228
+ // we drop the Peer. The Peer's state would GC once the socket is
229
+ // unreachable, but `wss.clients` / `ws` internals hold the socket
230
+ // strongly well past `close`, so the explicit delete frees it
231
+ // immediately. Audit round-10 + round-13.
232
+ unsubscribeAll(socket)
233
+ peers.delete(socket)
234
+ })
235
+ // Surface socket-level errors instead of swallowing — these are
236
+ // the signals operators want under abuse / network flakiness
237
+ // (TLS handshake failures, frame-decode errors, ws-protocol
238
+ // violations). `close` fires after `error` and runs the unsubscribe
239
+ // cleanup, so logging here doesn't risk leaking.
240
+ socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
241
+ }
242
+
243
+ // Wires `wss.on('connection')` + starts the heartbeat. Returns the
244
+ // timer so shutdown can clear it.
245
+ export function installWsServer(deps: WsServerDeps): { heartbeatTimer: ReturnType<typeof setInterval> } {
246
+ const { wss, peers, heartbeatIntervalMs } = deps
247
+
248
+ wss.on('connection', (socket: WebSocket, req) => {
249
+ setupPeerConnection(socket, req, deps)
250
+ })
251
+
252
+ // Periodic heartbeat sweep. Two-tick liveness window: a socket that
253
+ // doesn't `pong` within `heartbeatIntervalMs` of our `ping` sees its
254
+ // tracker flip to `false`; on the NEXT tick we terminate. `try/catch`
255
+ // shrugs at any peer that races us into CLOSING / CLOSED (`ws.ping` /
256
+ // `ws.terminate` throw in that state) — the close event handles
257
+ // cleanup either way.
258
+ //
259
+ // `unref` so the timer alone doesn't keep the event loop alive
260
+ // (parity with `terminateTimer` in shutdown). Cleared in shutdown so
261
+ // a graceful SIGTERM doesn't fire one last ping race after wss.close.
262
+ const heartbeatTimer = setInterval(() => {
263
+ for (const ws of wss.clients) {
264
+ const peer = peers.get(ws)
265
+ if (peer?.alive === false) {
266
+ try { ws.terminate() } catch {}
267
+ continue
268
+ }
269
+ if (peer) peer.alive = false
270
+ try { ws.ping() } catch {}
271
+ }
272
+ }, heartbeatIntervalMs)
273
+ heartbeatTimer.unref?.()
274
+
275
+ return { heartbeatTimer }
276
+ }
@@ -0,0 +1,94 @@
1
+ // Module customization hook that erases TypeScript types using Node's
2
+ // built-in `module.stripTypeScriptTypes()` — the same eraser that powers
3
+ // Node's unflagged `.ts` execution, exposed here as an explicit loader so
4
+ // this package can run its own `.ts` source after it's installed as a
5
+ // dependency. Built on Node's bundled eraser, it needs no third-party dep
6
+ // (cf. `amaro`, which is just the library Node already bundles for this).
7
+ //
8
+ // Why this is needed at all — Node DISABLES built-in `.ts` stripping for
9
+ // any file under `node_modules` (it throws ERR_UNSUPPORTED_NODE_MODULES_
10
+ // TYPE_STRIPPING), which is exactly where this package's `.ts` files live
11
+ // once it's a dependency. A registered `load` hook is not bound by that
12
+ // rule, so it restores `.ts` execution from inside `node_modules`.
13
+ //
14
+ // Registered as an entry point — `@preventive/triage/strip-types-loader` —
15
+ // so consumers can run the server (or import `@preventive/triage/server`)
16
+ // with the hook active:
17
+ // node --import @preventive/triage/strip-types-loader <entry>
18
+ //
19
+ // `registerHooks` (synchronous, in-thread) is used over the async,
20
+ // worker-thread `module.register`: stripping is a pure synchronous string
21
+ // transform, so there's no reason to round-trip every load through a
22
+ // worker — and the read below is synchronous too.
23
+ //
24
+ // We must read + strip the source OURSELVES rather than delegate to
25
+ // `nextLoad`: under `node_modules`, `nextLoad`'s default format detection
26
+ // is what throws ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, before our
27
+ // hook ever sees the source. Reading the bytes and short-circuiting with
28
+ // an explicit format sidesteps that gate.
29
+ //
30
+ // `mode: 'strip'` (types elided, no enum/namespace transform) suffices —
31
+ // the repo is `erasableSyntaxOnly` (tsconfig.json). Strip mode replaces
32
+ // each elided type with equal-width whitespace, so line AND column
33
+ // positions survive 1:1 and stack traces stay exact without a source map.
34
+
35
+ import { isUtf8 } from 'node:buffer'
36
+ import { readFileSync } from 'node:fs'
37
+ import { registerHooks, stripTypeScriptTypes } from 'node:module'
38
+ import { fileURLToPath } from 'node:url'
39
+
40
+ // `.ts` / `.mts` -> ESM, `.cts` -> CommonJS. The `kind` group is the
41
+ // optional `m`/`c` infix; absent (plain `.ts`) defaults to ESM, which is
42
+ // correct for this `"type": "module"` package.
43
+ const TS_RE = /\.(?<kind>[cm])?ts$/u
44
+
45
+ // Defence-in-depth: assert strip mode only ERASED — every output character
46
+ // is either unchanged, replaced by whitespace, or replaced by a semicolon
47
+ // (swc's ASI guard — swc-project/swc#9331). This enforces at runtime what
48
+ // tsconfig's `erasableSyntaxOnly` asserts at type-check time: nothing but
49
+ // type syntax was removed, so the bytes Node runs can't silently diverge
50
+ // from the bytes we wrote.
51
+ //
52
+ // The comparison is per-CODE-POINT, not per-byte. swc preserves byte
53
+ // offsets by swapping each elided multi-byte character for a *same-width*
54
+ // Unicode whitespace char — e.g. an em-dash (U+2014) in a type-level
55
+ // comment becomes U+2002 EN SPACE, not ASCII 0x20 — so a raw-byte check
56
+ // would false-positive on the many such comments in this codebase. `\s`
57
+ // matches those Unicode spaces, and strip preserves code-unit length too,
58
+ // so string indices stay aligned.
59
+ const WHITESPACE = /\s/u
60
+
61
+ function assertOnlyErased(url, source, stripped) {
62
+ if (source.length !== stripped.length) {
63
+ throw new Error(`strip-types-loader: ${url} changed length ${source.length} -> ${stripped.length}; not a pure type erasure`)
64
+ }
65
+ for (let i = 0; i < stripped.length; i++) {
66
+ const ch = stripped[i]
67
+ if (ch === source[i] || ch === ';' || WHITESPACE.test(ch)) continue
68
+ throw new Error(`strip-types-loader: ${url} char ${i} changed ${JSON.stringify(source[i])} -> ${JSON.stringify(ch)}; not a pure type erasure`)
69
+ }
70
+ }
71
+
72
+ registerHooks({
73
+ load(url, context, nextLoad) {
74
+ const match = TS_RE.exec(new URL(url).pathname)
75
+ if (match) {
76
+ const bytes = readFileSync(fileURLToPath(url))
77
+ // Assert the source is valid UTF-8 before decoding: a mis-encoded
78
+ // file (latin-1, UTF-16, a stray binary) would otherwise decode with
79
+ // U+FFFD replacement chars and we'd strip / execute silent garbage.
80
+ if (!isUtf8(bytes)) {
81
+ throw new Error(`strip-types-loader: ${url} is not valid UTF-8`)
82
+ }
83
+ const raw = bytes.toString('utf8')
84
+ const stripped = stripTypeScriptTypes(raw, { mode: 'strip' })
85
+ assertOnlyErased(url, raw, stripped)
86
+ return {
87
+ format: match.groups.kind === 'c' ? 'commonjs' : 'module',
88
+ source: stripped,
89
+ shortCircuit: true,
90
+ }
91
+ }
92
+ return nextLoad(url, context)
93
+ },
94
+ })
package/server/http.ts DELETED
@@ -1,142 +0,0 @@
1
- // HTTP plane: the REST byte-transfer routing (`/api/objstore/...`), the
2
- // static UI bundle, and the WebSocket upgrade gate. Built once at boot
3
- // with the WS server plus the lifecycle hooks it needs (`track` to
4
- // drain in-flight requests on shutdown, `isShuttingDown` to gate new
5
- // ones). The WS *connection* handler is wired on `wss` separately in
6
- // index.ts; this module only owns the upgrade handshake.
7
-
8
- import { type IncomingMessage as HttpRequest, type Server, type ServerResponse, createServer } from 'node:http'
9
- import { Buffer } from 'node:buffer'
10
- import { fileURLToPath } from 'node:url'
11
- import type { WebSocketServer } from 'ws'
12
- import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
13
- import { loadStatic } from './static.ts'
14
- import { errStack } from './util.ts'
15
-
16
- // `/api/*` is reserved for backend traffic so a fronting nginx (or
17
- // similar) can route `/api/*` → this process and `/*` → the static UI
18
- // bundle with a single location block.
19
- export const WS_UPGRADE_PATH = '/api/sync'
20
- function isUpgradePath(url: string | undefined): boolean {
21
- if (typeof url !== 'string') return false
22
- // Strip `?…` so clients can carry build / debug tags. Exact match
23
- // otherwise — `/api/sync/` (trailing slash) doesn't pass.
24
- return url.split('?', 1)[0] === WS_UPGRADE_PATH
25
- }
26
-
27
- const NOT_FOUND_BODY = JSON.stringify({ error: 'not-found' })
28
-
29
- type HasHeaders = { headers: HttpRequest['headers'] }
30
-
31
- export type HttpServerDeps = {
32
- wss: WebSocketServer
33
- restDeps: ObjstoreRestDeps
34
- isOriginAllowed: (req: HasHeaders) => boolean
35
- isShuttingDown: () => boolean
36
- track: (promise: Promise<unknown>) => void
37
- restPutIdleTimeoutMs: number
38
- debug: boolean
39
- }
40
-
41
- export function createHttpServer(deps: HttpServerDeps): Server {
42
- const { wss, restDeps, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
43
- // Static-file plane (see ./static.ts). The directory is the
44
- // `build.js build` output sibling to this file; the loader handles
45
- // enumeration, pre-compression, and ETag derivation. Plugged in after
46
- // the `/api/objstore/...` REST branch.
47
- const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
48
-
49
- const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
50
- if (matchRoute(req.url) != null) {
51
- // Shutdown gate. The WS plane gates new messages on `shuttingDown`;
52
- // REST handlers go through a separate path and must mirror it.
53
- // Without this, a REST PUT arriving on an existing keep-alive
54
- // socket AFTER SIGTERM but BEFORE `httpServer.close()` finishes
55
- // draining could land in `withCommitLock`, acquire a lease, and
56
- // finish its `finally { release() }` AFTER the shutdown's
57
- // `heldLeaseCount` snapshot — leaving an orphan lock row that pins
58
- // the key until TTL expiry. The 503 + `shutting-down` reason tells
59
- // the client to retry against a different replica. Transport
60
- // audit + multi-replica shutdown ordering review.
61
- if (isShuttingDown()) {
62
- res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
63
- res.end(JSON.stringify({ error: 'shutting-down' }))
64
- return
65
- }
66
- // Same-origin gate. Token IS the auth on REST, but a hostile origin
67
- // that holds a valid token (e.g. via XSS that read a freshly-minted
68
- // one) would PUT with its own Origin header — caught here.
69
- // Same-origin XHR/fetch may omit Origin; that path is allowed (see
70
- // `isOriginAllowed`). Transport audit `server/objstore/rest.ts:103`.
71
- if (!isOriginAllowed(req)) {
72
- res.writeHead(403, { 'content-type': 'application/json' })
73
- res.end(JSON.stringify({ error: 'origin-denied' }))
74
- return
75
- }
76
- // PUT idle-body timeout — a slow-loris client trickling bytes
77
- // within the declared Content-Length holds the staging fd + an
78
- // inFlightSids slot indefinitely. `req.setTimeout` fires on
79
- // inactivity; we destroy the request, aborting the body pipeline.
80
- // Transport audit `server/objstore/rest.ts:218`.
81
- if (req.method === 'PUT') {
82
- req.setTimeout(restPutIdleTimeoutMs, () => {
83
- if (debug) console.warn(`REST PUT idle ${restPutIdleTimeoutMs}ms → abort`)
84
- try { req.destroy(new Error('idle-timeout')) } catch {}
85
- })
86
- }
87
- // Track so SIGTERM mid-upload/download awaits handleRest before the
88
- // DB close. The outer `.catch` is the unhandled-rejection guard for
89
- // a stray throw OUTSIDE handleRest's internal try/catch blocks —
90
- // Node 20+ defaults `--unhandled-rejections=throw`, which would
91
- // crash the server. Logs and terminates the response so the TCP
92
- // socket doesn't dangle.
93
- const p = handleRest(restDeps, req, res).catch((err) => {
94
- console.warn('REST handler error:', errStack(err))
95
- if (res.headersSent) { try { res.destroy() } catch {} }
96
- else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
97
- })
98
- track(p)
99
- return
100
- }
101
- if (handleStatic(req, res)) return
102
- // `Connection: close` so an HTTP/1.1 keep-alive client doesn't hold
103
- // the socket open expecting more requests on a server that only
104
- // serves a small REST surface.
105
- res.writeHead(404, { 'content-type': 'application/json', 'connection': 'close' })
106
- res.end(NOT_FOUND_BODY)
107
- })
108
-
109
- httpServer.on('upgrade', (req, socket, head) => {
110
- // RFC 6455: the WS upgrade IS an HTTP request; reject with a normal
111
- // HTTP response so a misconfigured client sees the JSON body instead
112
- // of ECONNRESET. `socket.end(body)` flushes before sending FIN.
113
- if (!isUpgradePath(req.url)) {
114
- socket.end(
115
- 'HTTP/1.1 404 Not Found\r\n' +
116
- 'Content-Type: application/json\r\n' +
117
- `Content-Length: ${Buffer.byteLength(NOT_FOUND_BODY)}\r\n` +
118
- 'Connection: close\r\n\r\n' +
119
- NOT_FOUND_BODY,
120
- )
121
- return
122
- }
123
- // Same-origin gate. The WS upgrade IS a cross-origin-reachable
124
- // surface in the browser; without this any tab can open a session to
125
- // a 127.0.0.1 relay and probe handler shape / burn verify CPU.
126
- // Browser WS handshakes always carry Origin (RFC 6455); non-browser
127
- // clients omit it and are allowed (network is their trust boundary).
128
- if (!isOriginAllowed(req)) {
129
- socket.end(
130
- 'HTTP/1.1 403 Forbidden\r\n' +
131
- 'Content-Type: application/json\r\n' +
132
- 'Content-Length: 26\r\n' +
133
- 'Connection: close\r\n\r\n' +
134
- '{"error":"origin-denied"}\n',
135
- )
136
- return
137
- }
138
- wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) })
139
- })
140
-
141
- return httpServer
142
- }