@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.
- package/out/client-sync.js +14 -10
- package/out/graph.js +4 -4
- package/out/index.html +39 -23
- package/out/view.css +1 -1
- package/out/view.js +47 -46
- package/package.json +6 -1
- package/server/auth.ts +11 -0
- package/server/bus-receiver.ts +95 -0
- package/server/db-neon.ts +11 -1
- package/server/db-revision-sql.ts +9 -0
- package/server/db.ts +18 -1
- package/server/http.ts +41 -5
- package/server/hub.ts +22 -2
- package/server/index.ts +124 -15
- package/server/lifecycle.ts +23 -1
- package/server/npm-proxy.ts +348 -0
- package/server/objstore/handlers.ts +12 -0
- package/server/objstore/init.ts +7 -1
- package/server/objstore/rest.ts +18 -0
- package/server/pubsub.ts +404 -0
- package/server/sse-server.ts +352 -0
- package/server/sse-session.ts +202 -0
- package/server/sync-handlers.ts +17 -1
- package/server/ws-server.ts +199 -174
package/server/ws-server.ts
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket,
|
|
65
|
+
peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
|
|
66
|
+
sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
|
|
49
67
|
} = deps
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|