@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.1

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.
@@ -3,9 +3,17 @@
3
3
  // once at boot onto the shared `wss` with the protocol handlers +
4
4
  // lifecycle hooks it dispatches to. Kept out of index.ts so the WS
5
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.
6
13
 
7
14
  import type { WebSocket, WebSocketServer } from 'ws'
8
15
  import { Buffer } from 'node:buffer'
16
+ import type { IncomingMessage as HttpRequest } from 'node:http'
9
17
  import { decodeUtf8 } from '../common/utf8.js'
10
18
  import type { SaveErrorReason } from '../common/save-error-reason.ts'
11
19
  import { Peer, type PeerRegistry } from './peer.ts'
@@ -23,8 +31,10 @@ type IncomingMessage = {
23
31
  [k: string]: unknown
24
32
  }
25
33
 
26
- export type WsServerDeps = {
27
- wss: WebSocketServer
34
+ // Subset of `WsServerDeps` consumed by `setupPeerConnection`. The
35
+ // heartbeat sweep + the `wss` reference stay in `WsServerDeps`; the
36
+ // SSE path doesn't need them.
37
+ export type PeerConnectionDeps = {
28
38
  peers: PeerRegistry
29
39
  send: (socket: WebSocket, msg: object) => void
30
40
  unsubscribeAll: (socket: WebSocket) => void
@@ -36,186 +46,201 @@ export type WsServerDeps = {
36
46
  track: (promise: Promise<unknown>) => void
37
47
  isShuttingDown: () => boolean
38
48
  maxInflightPerSocket: number
39
- heartbeatIntervalMs: number
40
49
  debug: boolean
41
50
  }
42
51
 
43
- // Wires `wss.on('connection')` + starts the heartbeat. Returns the
44
- // timer so shutdown can clear it.
45
- export function installWsServer(deps: WsServerDeps): { heartbeatTimer: ReturnType<typeof setInterval> } {
52
+ export type WsServerDeps = PeerConnectionDeps & {
53
+ wss: WebSocketServer
54
+ heartbeatIntervalMs: number
55
+ }
56
+
57
+ // Per-connection setup: create Peer, send challenge, wire message /
58
+ // close / error / pong listeners. Shared between the WS connection
59
+ // handler below and the SSE+POST adapter in ./sse-server.ts. The
60
+ // `socket` parameter is the WebSocket-shaped surface (a real `ws`
61
+ // `WebSocket`, or an `SseSession` cast through `as unknown as
62
+ // WebSocket`); the function only uses the subset both expose.
63
+ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
46
64
  const {
47
- wss, peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
48
- sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, heartbeatIntervalMs, debug,
65
+ peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
66
+ sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
49
67
  } = deps
50
-
51
- wss.on('connection', (socket: WebSocket, req) => {
52
- if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
53
- // One Peer holds this connection's state (challenge / authorized /
54
- // alive / inflight / tags). Created before any client frame can
55
- // arrive (`socket.on('message')` is wired below). The heartbeat
56
- // sweep flips `alive` false after each `ping()`; the `pong` listener
57
- // flips it back, and a socket still false on the next sweep is
58
- // terminated — the only thing closing FDs for an idle peer.
59
- const peer = new Peer(randomId())
60
- peers.set(socket, peer)
61
- socket.on('pong', () => { peer.alive = true })
62
- // Issue the per-connection challenge nonce BEFORE the client can
63
- // send anything that needs it. The client signs it into every
64
- // `workspace-subscribe` (see canonicalSubscribe in server/sign.ts);
65
- // a captured subscribe frame can't be replayed from a different
66
- // connection because that connection's nonce differs and the
67
- // signature won't verify against the new canonical bytes. Round-9 H2.
68
- send(socket, { type: 'challenge', nonce: peer.challenge })
69
- // Per-socket handlers are DELIBERATELY NOT serialized (vs the
70
- // client-side `messageQueue = messageQueue.then(...)` Promise
71
- // chain inside `client/triage-sync.ts:onTransportMessage`).
72
- // Each inbound frame spawns its own tracked async IIFE; two
73
- // frames from the same socket can interleave across `await`
74
- // boundaries inside the handlers. Per-resource correctness needs
75
- // no in-process lock: `commitRevision` resolves concurrent saves
76
- // via its single gated INSERT (one snapshot + the
77
- // `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
78
- // objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
79
- // via the version compare-and-set + content-addressing (see
80
- // `server/objstore/store.ts`), backed by post-await
81
- // `readyState === OPEN` rechecks in every objstore handler. The
82
- // unbounded fan-out is capped by `maxInflightPerSocket` (see
83
- // also the `'busy'` NACK at the cap below). Concurrent dispatch
84
- // is intentional: it lets multi-workspace clients multiplex
85
- // saves + subscribes over one socket without HOL blocking.
86
- // Audit follow-up to round-15 concurrency review.
87
- socket.on('message', (data: Buffer, isBinary: boolean) => {
88
- // Drop new work once shutdown started — `wss.close()` stops new
89
- // CONNECTIONS but already-open sockets can still send messages.
90
- // Without this gate a message arriving between `wss.close()`
91
- // resolving and the `inFlight` snapshot would spawn a handler
92
- // that's NOT in the snapshot, then resume against the just-
93
- // closed DB and throw inside the commit's gated INSERT. Audit
94
- // round-9.
95
- if (isShuttingDown()) return
96
- // Wire protocol is JSON over text frames. A binary frame is
97
- // either a buggy client or someone probing — drop without
98
- // attempting to interpret it as text.
99
- if (isBinary) return
100
- let msg: IncomingMessage | null = null
101
- try {
102
- // `decodeUtf8` is fatal on invalid UTF-8 (vs `Buffer.toString`
103
- // which silently substitutes U+FFFD). The substitution path
104
- // would let mangled bytes pass JSON.parse only to fail
105
- // signature verification deeper in the handler — wasted work
106
- // and noisier logs. Fail at the gate.
107
- msg = JSON.parse(decodeUtf8(data)) as IncomingMessage
108
- } catch { return }
109
- if (!msg || typeof msg !== 'object') return
110
- const parsed: IncomingMessage = msg
111
- // Per-socket inflight cap. Each tracked handler keeps the socket
112
- // alive in `inFlight`, and a peer who keeps firing valid frames
113
- // can spawn unbounded handlers — growing SIGTERM drain time and
114
- // memory. Drop new work above the cap. Heartbeat ping is the
115
- // exception: it's stateless, synchronous, and we want to KEEP
116
- // responding so the peer doesn't drop the socket while we're
117
- // shedding load. Pings go through a fast inline `send(pong)`
118
- // path BELOW that doesn't bump the per-socket inflight counter,
119
- // so a ping-spam at the cap can't outrun the gate. Transport
120
- // audit `server/index.ts:590` + post-#58 audit follow-up.
121
- if (parsed.type === 'ping') {
122
- send(socket, { type: 'pong' })
123
- return
124
- }
125
- // `authenticate` runs synchronously (constant-time bytes compare
126
- // — no DB, no I/O), so it shares the same fast-inline path as
127
- // `ping` and bypasses the per-socket inflight counter. Keeping
128
- // it out of the IIFE pool means an unauthenticated client can
129
- // still complete the handshake when the socket is otherwise
130
- // saturated (matching the philosophy of the `busy` NACK path
131
- // for `workspace-save`: don't strand a recoverable handshake
132
- // behind the cap).
133
- if (parsed.type === 'authenticate') {
134
- handleAuthenticate(socket, parsed as AuthenticateMsg)
135
- return
136
- }
137
- if (peer.inflight >= maxInflightPerSocket) {
138
- if (debug) console.warn(`drop message: socket inflight ${peer.inflight} >= ${maxInflightPerSocket}`)
139
- // For workspace-save specifically, send a typed NACK so the
140
- // client's `pending` slot clears IMMEDIATELY instead of
141
- // hanging until the next heartbeat (~15–30s). Reason `busy`
142
- // is server-side overload; safe to retry. Same wire envelope
143
- // as the existing `too-large` save-error path.
144
- //
145
- // Wire-order interaction with `'stale-base'`: the cap-path
146
- // send is SYNCHRONOUS in this message callback and lands on
147
- // the wire BEFORE any handler IIFE's `await`-completed reply.
148
- // If an earlier in-flight `workspace-save` (frame F1) ends up
149
- // emitting a catch-up `workspace-state` + `'stale-base'` while
150
- // a later frame F2 hits the cap, the order is `'busy'`(F2) →
151
- // `workspace-state`(F1) → `'stale-base'`(F1). The client's
152
- // `handleSaveError` correlates on `base`, and the wire-order
153
- // trick documented in `common/save-error-reason.ts` (catch-up
154
- // clears `pending` before the stale-base frame's
155
- // `handleSaveError` runs) still holds.
156
- const rawBase = (parsed as SaveMsg).base
157
- const baseField: string | null = typeof rawBase === 'string' ? rawBase : null
158
- const rawTag = (parsed as SaveMsg).workspaceTag
159
- const tagField = typeof rawTag === 'string' ? rawTag : null
160
- if (parsed.type === 'workspace-save' && tagField != null) {
161
- // `sendSaveError` runs its taxonomy-guard before the wire
162
- // send and throws on an unknown reason. Every OTHER emit
163
- // site lives inside the `handler` IIFE's try/catch — this
164
- // cap path is the only one outside it. Mirror the same
165
- // forensic envelope so a future bad reason here surfaces
166
- // as `Handler error (type=workspace-save): …` rather than
167
- // escaping to ws's emitter as an uncaught.
168
- try {
169
- sendSaveError(socket, tagField, baseField, 'busy')
170
- } catch (err) {
171
- console.warn('Handler error (type=workspace-save):', errStack(err))
172
- }
173
- }
174
- return
175
- }
176
- peer.inflight += 1
177
- const handler = (async () => {
68
+ if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
69
+ // One Peer holds this connection's state (challenge / authorized /
70
+ // alive / inflight / tags). Created before any client frame can
71
+ // arrive (`socket.on('message')` is wired below). The heartbeat
72
+ // sweep flips `alive` false after each `ping()`; the `pong` listener
73
+ // flips it back, and a socket still false on the next sweep is
74
+ // terminated — the only thing closing FDs for an 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 BEFORE the client can
79
+ // send anything that needs it. The client signs it into every
80
+ // `workspace-subscribe` (see canonicalSubscribe in server/sign.ts);
81
+ // a captured subscribe frame can't be replayed from a different
82
+ // connection because that connection's nonce differs and the
83
+ // signature won't verify against the new canonical bytes. Round-9 H2.
84
+ send(socket, { type: 'challenge', nonce: peer.challenge })
85
+ // Per-socket handlers are DELIBERATELY NOT serialized (vs the
86
+ // client-side `messageQueue = messageQueue.then(...)` Promise
87
+ // chain inside `client/triage-sync.ts:onTransportMessage`).
88
+ // Each inbound frame spawns its own tracked async IIFE; two
89
+ // frames from the same socket can interleave across `await`
90
+ // boundaries inside the handlers. Per-resource correctness needs
91
+ // no in-process lock: `commitRevision` resolves concurrent saves
92
+ // via its single gated INSERT (one snapshot + the
93
+ // `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
94
+ // objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
95
+ // via the version compare-and-set + content-addressing (see
96
+ // `server/objstore/store.ts`), backed by post-await
97
+ // `readyState === OPEN` rechecks in every objstore handler. The
98
+ // unbounded fan-out is capped by `maxInflightPerSocket` (see
99
+ // also the `'busy'` NACK at the cap below). Concurrent dispatch
100
+ // is intentional: it lets multi-workspace clients multiplex
101
+ // saves + subscribes over one socket without HOL blocking.
102
+ // Audit follow-up to round-15 concurrency review.
103
+ socket.on('message', (data: Buffer, isBinary: boolean) => {
104
+ // Drop new work once shutdown started — `wss.close()` stops new
105
+ // CONNECTIONS but already-open sockets can still send messages.
106
+ // Without this gate a message arriving between `wss.close()`
107
+ // resolving and the `inFlight` snapshot would spawn a handler
108
+ // that's NOT in the snapshot, then resume against the just-
109
+ // closed DB and throw inside the commit's gated INSERT. Audit
110
+ // round-9.
111
+ if (isShuttingDown()) return
112
+ // Wire protocol is JSON over text frames. A binary frame is
113
+ // either a buggy client or someone probing — drop without
114
+ // attempting to interpret it as text.
115
+ if (isBinary) return
116
+ let msg: IncomingMessage | null = null
117
+ try {
118
+ // `decodeUtf8` is fatal on invalid UTF-8 (vs `Buffer.toString`
119
+ // which silently substitutes U+FFFD). The substitution path
120
+ // would let mangled bytes pass JSON.parse only to fail
121
+ // signature verification deeper in the handler — wasted work
122
+ // and noisier logs. Fail at the gate.
123
+ msg = JSON.parse(decodeUtf8(data)) as IncomingMessage
124
+ } catch { return }
125
+ if (!msg || typeof msg !== 'object') return
126
+ const parsed: IncomingMessage = msg
127
+ // Per-socket inflight cap. Each tracked handler keeps the socket
128
+ // alive in `inFlight`, and a peer who keeps firing valid frames
129
+ // can spawn unbounded handlers — growing SIGTERM drain time and
130
+ // memory. Drop new work above the cap. Heartbeat ping is the
131
+ // exception: it's stateless, synchronous, and we want to KEEP
132
+ // responding so the peer doesn't drop the socket while we're
133
+ // shedding load. Pings go through a fast inline `send(pong)`
134
+ // path BELOW that doesn't bump the per-socket inflight counter,
135
+ // so a ping-spam at the cap can't outrun the gate. Transport
136
+ // audit `server/index.ts:590` + post-#58 audit follow-up.
137
+ if (parsed.type === 'ping') {
138
+ send(socket, { type: 'pong' })
139
+ return
140
+ }
141
+ // `authenticate` runs synchronously (constant-time bytes compare
142
+ // — no DB, no I/O), so it shares the same fast-inline path as
143
+ // `ping` and bypasses the per-socket inflight counter. Keeping
144
+ // it out of the IIFE pool means an unauthenticated client can
145
+ // still complete the handshake when the socket is otherwise
146
+ // saturated (matching the philosophy of the `busy` NACK path
147
+ // for `workspace-save`: don't strand a recoverable handshake
148
+ // behind the cap).
149
+ if (parsed.type === 'authenticate') {
150
+ handleAuthenticate(socket, parsed as AuthenticateMsg)
151
+ return
152
+ }
153
+ if (peer.inflight >= maxInflightPerSocket) {
154
+ if (debug) console.warn(`drop message: socket inflight ${peer.inflight} >= ${maxInflightPerSocket}`)
155
+ // For workspace-save specifically, send a typed NACK so the
156
+ // client's `pending` slot clears IMMEDIATELY instead of
157
+ // hanging until the next heartbeat (~15–30s). Reason `busy`
158
+ // is server-side overload; safe to retry. Same wire envelope
159
+ // as the existing `too-large` save-error path.
160
+ //
161
+ // Wire-order interaction with `'stale-base'`: the cap-path
162
+ // send is SYNCHRONOUS in this message callback and lands on
163
+ // the wire BEFORE any handler IIFE's `await`-completed reply.
164
+ // If an earlier in-flight `workspace-save` (frame F1) ends up
165
+ // emitting a catch-up `workspace-state` + `'stale-base'` while
166
+ // a later frame F2 hits the cap, the order is `'busy'`(F2) →
167
+ // `workspace-state`(F1) → `'stale-base'`(F1). The client's
168
+ // `handleSaveError` correlates on `base`, and the wire-order
169
+ // trick documented in `common/save-error-reason.ts` (catch-up
170
+ // clears `pending` before the stale-base frame's
171
+ // `handleSaveError` runs) still holds.
172
+ const rawBase = (parsed as SaveMsg).base
173
+ const baseField: string | null = typeof rawBase === 'string' ? rawBase : null
174
+ const rawTag = (parsed as SaveMsg).workspaceTag
175
+ const tagField = typeof rawTag === 'string' ? rawTag : null
176
+ if (parsed.type === 'workspace-save' && tagField != null) {
177
+ // `sendSaveError` runs its taxonomy-guard before the wire
178
+ // send and throws on an unknown reason. Every OTHER emit
179
+ // site lives inside the `handler` IIFE's try/catch — this
180
+ // cap path is the only one outside it. Mirror the same
181
+ // forensic envelope so a future bad reason here surfaces
182
+ // as `Handler error (type=workspace-save): …` rather than
183
+ // escaping to ws's emitter as an uncaught.
178
184
  try {
179
- if (parsed.type === 'workspace-save') await handleSave(socket, parsed as SaveMsg)
180
- else if (parsed.type === 'workspace-subscribe') await handleSubscribe(socket, parsed as SubscribeMsg)
181
- // Objstore control plane — bytes ride the REST plane via
182
- // tokens these handlers mint. The Objstore*Msg types are
183
- // weak shapes (every field `unknown`); the handlers narrow
184
- // each field through their own validators on entry.
185
- else if (parsed.type === 'objstore-put-begin') await objstore.handlePutBegin(socket, parsed as ObjstorePutBeginMsg)
186
- else if (parsed.type === 'objstore-delete') await objstore.handleDelete(socket, parsed as ObjstoreDeleteMsg)
187
- else if (parsed.type === 'objstore-fetch') await objstore.handleFetch(socket, parsed as ObjstoreFetchMsg)
185
+ sendSaveError(socket, tagField, baseField, 'busy')
188
186
  } catch (err) {
189
- // Forensic logging for unexpected throws — the handlers all
190
- // have internal narrow catches (e.g. signature reject paths);
191
- // anything reaching here is unexpected. Include the wire
192
- // `type` so an operator can correlate to a specific code
193
- // path, and prefer `.stack` over `.message` so the post-
194
- // mortem has the throw site.
195
- const typeStr = typeof parsed.type === 'string' ? parsed.type : '<unknown>'
196
- console.warn(`Handler error (type=${typeStr}):`, errStack(err))
197
- } finally {
198
- peer.inflight -= 1
187
+ console.warn('Handler error (type=workspace-save):', errStack(err))
199
188
  }
200
- })()
201
- track(handler)
202
- })
203
- socket.on('close', () => {
204
- // `unsubscribeAll` reads `peer.tags` (peer still registered), then
205
- // we drop the Peer. The Peer's state would GC once the socket is
206
- // unreachable, but `wss.clients` / `ws` internals hold the socket
207
- // strongly well past `close`, so the explicit delete frees it
208
- // immediately. Audit round-10 + round-13.
209
- unsubscribeAll(socket)
210
- peers.delete(socket)
211
- })
212
- // Surface socket-level errors instead of swallowing — these are
213
- // the signals operators want under abuse / network flakiness
214
- // (TLS handshake failures, frame-decode errors, ws-protocol
215
- // violations). The previous `() => {}` left every per-connection
216
- // failure invisible. `close` fires after `error` and runs the
217
- // unsubscribe cleanup, so logging here doesn't risk leaking.
218
- socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
189
+ }
190
+ return
191
+ }
192
+ peer.inflight += 1
193
+ const handler = (async () => {
194
+ try {
195
+ if (parsed.type === 'workspace-save') await handleSave(socket, parsed as SaveMsg)
196
+ else if (parsed.type === 'workspace-subscribe') await handleSubscribe(socket, parsed as SubscribeMsg)
197
+ // Objstore control plane — bytes ride the REST plane via
198
+ // tokens these handlers mint. The Objstore*Msg types are
199
+ // weak shapes (every field `unknown`); the handlers narrow
200
+ // each field through their own validators on entry.
201
+ else if (parsed.type === 'objstore-put-begin') await objstore.handlePutBegin(socket, parsed as ObjstorePutBeginMsg)
202
+ else if (parsed.type === 'objstore-delete') await objstore.handleDelete(socket, parsed as ObjstoreDeleteMsg)
203
+ else if (parsed.type === 'objstore-fetch') await objstore.handleFetch(socket, parsed as ObjstoreFetchMsg)
204
+ } catch (err) {
205
+ // Forensic logging for unexpected throws — the handlers all
206
+ // have internal narrow catches (e.g. signature reject paths);
207
+ // anything reaching here is unexpected. Include the wire
208
+ // `type` so an operator can correlate to a specific code
209
+ // path, and prefer `.stack` over `.message` so the post-
210
+ // mortem has the throw site.
211
+ const typeStr = typeof parsed.type === 'string' ? parsed.type : '<unknown>'
212
+ console.warn(`Handler error (type=${typeStr}):`, errStack(err))
213
+ } finally {
214
+ peer.inflight -= 1
215
+ }
216
+ })()
217
+ track(handler)
218
+ })
219
+ socket.on('close', () => {
220
+ // `unsubscribeAll` reads `peer.tags` (peer still registered), then
221
+ // we drop the Peer. The Peer's state would GC once the socket is
222
+ // unreachable, but `wss.clients` / `ws` internals hold the socket
223
+ // strongly well past `close`, so the explicit delete frees it
224
+ // immediately. Audit round-10 + round-13.
225
+ unsubscribeAll(socket)
226
+ peers.delete(socket)
227
+ })
228
+ // Surface socket-level errors instead of swallowing — these are
229
+ // the signals operators want under abuse / network flakiness
230
+ // (TLS handshake failures, frame-decode errors, ws-protocol
231
+ // violations). The previous `() => {}` left every per-connection
232
+ // failure invisible. `close` fires after `error` and runs the
233
+ // unsubscribe cleanup, so logging here doesn't risk leaking.
234
+ socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
235
+ }
236
+
237
+ // Wires `wss.on('connection')` + starts the heartbeat. Returns the
238
+ // timer so shutdown can clear it.
239
+ export function installWsServer(deps: WsServerDeps): { heartbeatTimer: ReturnType<typeof setInterval> } {
240
+ const { wss, peers, heartbeatIntervalMs } = deps
241
+
242
+ wss.on('connection', (socket: WebSocket, req) => {
243
+ setupPeerConnection(socket, req, deps)
219
244
  })
220
245
 
221
246
  // Periodic heartbeat sweep. Two-tick liveness window: a socket that