@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/reap.ts +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +17 -10
- package/out/graph.js +5 -4
- package/out/index.html +43 -38
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -51
- package/package.json +70 -49
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +16 -1
- package/server-e2e/bus-receiver.ts +95 -0
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +41 -25
- package/{server → server-e2e}/db-revision-sql.ts +15 -9
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +113 -135
- package/server-e2e/http.ts +266 -0
- package/{server → server-e2e}/hub.ts +27 -8
- package/{server → server-e2e}/index.ts +185 -52
- package/{server → server-e2e}/lifecycle.ts +36 -5
- package/server-e2e/npm-proxy.ts +348 -0
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +25 -15
- package/{server → server-e2e}/objstore/init.ts +52 -12
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +119 -84
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/server-e2e/pubsub.ts +394 -0
- package/{server → server-e2e}/sign.ts +12 -14
- package/server-e2e/sse-server.ts +384 -0
- package/server-e2e/sse-session.ts +216 -0
- package/{server → server-e2e}/static.ts +22 -17
- package/server-e2e/sync-handlers.ts +382 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/server-e2e/ws-server.ts +276 -0
- package/strip-types-loader.js +94 -0
- package/server/http.ts +0 -142
- package/server/sync-handlers.ts +0 -311
- package/server/ws-server.ts +0 -245
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,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
|
-
}
|