@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,266 @@
|
|
|
1
|
+
// HTTP plane: the REST byte-transfer routing (`/api/objstore/...`), the
|
|
2
|
+
// static UI bundle, the SSE+POST fallback transport for the sync
|
|
3
|
+
// protocol, and the WebSocket upgrade gate. Built once at boot with
|
|
4
|
+
// the WS server plus the lifecycle hooks it needs (`track` to drain
|
|
5
|
+
// in-flight requests on shutdown, `isShuttingDown` to gate new ones).
|
|
6
|
+
// The WS *connection* handler is wired on `wss` separately in
|
|
7
|
+
// index.ts; this module only owns the upgrade handshake.
|
|
8
|
+
|
|
9
|
+
import { type IncomingMessage as HttpRequest, type Server, type ServerResponse, createServer } from 'node:http'
|
|
10
|
+
import { Buffer } from 'node:buffer'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import type { WebSocketServer } from 'ws'
|
|
13
|
+
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
14
|
+
import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
|
|
15
|
+
import { SSE_OPEN_PATH, type SseServer } from './sse-server.ts'
|
|
16
|
+
import { dispatchNpmAdvisories } from './npm-proxy.ts'
|
|
17
|
+
import { loadStatic } from './static.ts'
|
|
18
|
+
import { errStack } from './util.ts'
|
|
19
|
+
|
|
20
|
+
// `/api/*` is reserved for backend traffic so a fronting nginx (or
|
|
21
|
+
// similar) can route `/api/*` → this process and `/*` → the static UI
|
|
22
|
+
// bundle with a single location block.
|
|
23
|
+
export const WS_UPGRADE_PATH = '/api/sync'
|
|
24
|
+
// Session-independent triage-sync save plane: `POST /api/sync/save`. The
|
|
25
|
+
// SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
|
|
26
|
+
// here commits + broadcasts WITHOUT taking over the client's SSE event-stream
|
|
27
|
+
// (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
|
|
28
|
+
// the WS upgrade path so one nginx `/api/*` block routes both.
|
|
29
|
+
export const SAVE_REST_PATH = '/api/sync/save'
|
|
30
|
+
function isUpgradePath(url: string | undefined): boolean {
|
|
31
|
+
if (typeof url !== 'string') return false
|
|
32
|
+
// Strip `?…` so clients can carry build / debug tags. Exact match
|
|
33
|
+
// otherwise — `/api/sync/` (trailing slash) doesn't pass.
|
|
34
|
+
return url.split('?', 1)[0] === WS_UPGRADE_PATH
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NOT_FOUND_BODY = JSON.stringify({ error: 'not-found' })
|
|
38
|
+
|
|
39
|
+
type HasHeaders = { headers: HttpRequest['headers'] }
|
|
40
|
+
|
|
41
|
+
export type HttpServerDeps = {
|
|
42
|
+
wss: WebSocketServer
|
|
43
|
+
restDeps: ObjstoreRestDeps
|
|
44
|
+
// This server's `server-info` (mode advertisement), served as JSON at
|
|
45
|
+
// GET /api/config so a client can detect the protocol without a sync
|
|
46
|
+
// connection (the WS connect frame stays the source of truth).
|
|
47
|
+
serverInfo: ServerInfo
|
|
48
|
+
// SSE+POST fallback. Owns its own session map + lifecycle; we just
|
|
49
|
+
// give it first crack at requests that match `SSE_OPEN_PATH`. Same
|
|
50
|
+
// same-origin / shutdown gates run BEFORE the dispatch so the SSE
|
|
51
|
+
// plane inherits them.
|
|
52
|
+
sseServer: SseServer
|
|
53
|
+
isOriginAllowed: (req: HasHeaders) => boolean
|
|
54
|
+
isShuttingDown: () => boolean
|
|
55
|
+
track: (promise: Promise<unknown>) => void
|
|
56
|
+
// Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
|
|
57
|
+
// the save pipeline + JSON response; this module owns the gates (method,
|
|
58
|
+
// same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
|
|
59
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
60
|
+
restPutIdleTimeoutMs: number
|
|
61
|
+
debug: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
|
|
65
|
+
// the request matched the route (the caller `track`s it for graceful drain),
|
|
66
|
+
// or null when it's for a different route. The gate ladder — method →
|
|
67
|
+
// shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
|
|
68
|
+
// gate rejection writes its own response and returns an already-settled
|
|
69
|
+
// promise. Kept out of `createHttpServer` so that dispatcher stays compact.
|
|
70
|
+
function dispatchSaveRest(
|
|
71
|
+
req: HttpRequest, res: ServerResponse,
|
|
72
|
+
deps: {
|
|
73
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
74
|
+
isOriginAllowed: (req: HasHeaders) => boolean
|
|
75
|
+
isShuttingDown: () => boolean
|
|
76
|
+
restPutIdleTimeoutMs: number
|
|
77
|
+
debug: boolean
|
|
78
|
+
},
|
|
79
|
+
): Promise<void> | null {
|
|
80
|
+
if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
|
|
81
|
+
if (req.method !== 'POST') {
|
|
82
|
+
res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
|
|
83
|
+
res.end(JSON.stringify({ error: 'method-not-allowed' }))
|
|
84
|
+
return Promise.resolve()
|
|
85
|
+
}
|
|
86
|
+
// Shutdown gate — parity with the objstore REST branch (a POST on an
|
|
87
|
+
// existing keep-alive socket after SIGTERM but before close() drains).
|
|
88
|
+
if (deps.isShuttingDown()) {
|
|
89
|
+
res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
|
|
90
|
+
res.end(JSON.stringify({ error: 'shutting-down' }))
|
|
91
|
+
return Promise.resolve()
|
|
92
|
+
}
|
|
93
|
+
// Same-origin gate — a hostile cross-origin page would carry an Origin
|
|
94
|
+
// header (browser-set on fetch); same-origin XHR may omit it (allowed).
|
|
95
|
+
if (!deps.isOriginAllowed(req)) {
|
|
96
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
97
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
98
|
+
return Promise.resolve()
|
|
99
|
+
}
|
|
100
|
+
// Idle-body timeout — a slow-loris trickling the JSON body would otherwise
|
|
101
|
+
// hold the connection indefinitely.
|
|
102
|
+
req.setTimeout(deps.restPutIdleTimeoutMs, () => {
|
|
103
|
+
if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
|
|
104
|
+
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
105
|
+
})
|
|
106
|
+
// Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
|
|
107
|
+
// handler's own try/catch — logs and terminates the response so the TCP
|
|
108
|
+
// socket doesn't dangle (same policy as the objstore handleRest wrapper).
|
|
109
|
+
return deps.handleSaveRest(req, res).catch((err) => {
|
|
110
|
+
console.warn('sync-save REST handler error:', errStack(err))
|
|
111
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
112
|
+
else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createHttpServer(deps: HttpServerDeps): Server {
|
|
117
|
+
const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
|
|
118
|
+
// Static-file plane (see ./static.ts). The directory is the
|
|
119
|
+
// `build.js build` output sibling to this file; the loader handles
|
|
120
|
+
// enumeration, pre-compression, and ETag derivation. Plugged in after
|
|
121
|
+
// the `/api/objstore/...` REST branch.
|
|
122
|
+
const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
|
|
123
|
+
|
|
124
|
+
const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
|
|
125
|
+
// Static mode probe: GET /api/config → this server's `server-info` as JSON.
|
|
126
|
+
// A client uses it to detect the protocol up front; the WS connect frame
|
|
127
|
+
// stays the source of truth and catches a later mode change. Public, no body.
|
|
128
|
+
if (typeof req.url === 'string' && req.url.split('?', 1)[0] === CONFIG_PATH) {
|
|
129
|
+
if (req.method !== 'GET') {
|
|
130
|
+
res.writeHead(405, { 'content-type': 'application/json', allow: 'GET' })
|
|
131
|
+
res.end(JSON.stringify({ error: 'method-not-allowed' }))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
135
|
+
res.end(JSON.stringify(serverInfo))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// SSE+POST fallback plane. Same-origin gate first (Origin header
|
|
139
|
+
// is set by the browser on cross-origin EventSource + fetch, so
|
|
140
|
+
// a hostile origin would surface here just like it does on the
|
|
141
|
+
// WS upgrade and REST plane). The sseServer.handle() function
|
|
142
|
+
// returns true iff it matched and consumed the request; on false
|
|
143
|
+
// we fall through to the REST / static / 404 ladder below.
|
|
144
|
+
if (typeof req.url === 'string' && req.url.split('?', 1)[0] === SSE_OPEN_PATH) {
|
|
145
|
+
if (!isOriginAllowed(req)) {
|
|
146
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
147
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
// sseServer.handle owns its own shutdown / cap / 503 ladder.
|
|
151
|
+
// Each POST's response stays open as the session's downstream
|
|
152
|
+
// channel until the next POST takes over — the response is
|
|
153
|
+
// long-lived even though the request is one-shot — and the
|
|
154
|
+
// shutdown gate inside sseServer prevents accepting new POSTs
|
|
155
|
+
// once shuttingDown latches, so outstanding sessions drain via
|
|
156
|
+
// the lifecycle's sseSessions() close loop.
|
|
157
|
+
if (sseServer.handle(req, res)) return
|
|
158
|
+
}
|
|
159
|
+
// Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
|
|
160
|
+
// `POST /api/sync/save` so an SSE-mode save doesn't take over the
|
|
161
|
+
// event-stream. The dispatch helper owns the gate ladder; we `track` the
|
|
162
|
+
// returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
|
|
163
|
+
const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
|
|
164
|
+
if (saveP) { track(saveP); return }
|
|
165
|
+
// npm advisories proxy — same-origin + shutdown gates live in the
|
|
166
|
+
// helper so this dispatcher stays compact. `dispatchNpmAdvisories`
|
|
167
|
+
// returns the in-flight promise (or null when the route didn't
|
|
168
|
+
// match); the lifecycle's `track` awaits it so SIGTERM drains
|
|
169
|
+
// outstanding upstream fetches.
|
|
170
|
+
const npmP = dispatchNpmAdvisories(req, res, { isOriginAllowed, isShuttingDown, debug })
|
|
171
|
+
if (npmP) { track(npmP); return }
|
|
172
|
+
if (matchRoute(req.url) != null) {
|
|
173
|
+
// Shutdown gate. The WS plane gates new messages on `shuttingDown`;
|
|
174
|
+
// REST handlers go through a separate path and must mirror it.
|
|
175
|
+
// Without this, a REST PUT arriving on an existing keep-alive
|
|
176
|
+
// socket AFTER SIGTERM but BEFORE `httpServer.close()` finishes
|
|
177
|
+
// draining could land in `withCommitLock`, acquire a lease, and
|
|
178
|
+
// finish its `finally { release() }` AFTER the shutdown's
|
|
179
|
+
// `heldLeaseCount` snapshot — leaving an orphan lock row that pins
|
|
180
|
+
// the key until TTL expiry. The 503 + `shutting-down` reason tells
|
|
181
|
+
// the client to retry against a different replica. Transport
|
|
182
|
+
// audit + multi-replica shutdown ordering review.
|
|
183
|
+
if (isShuttingDown()) {
|
|
184
|
+
res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
|
|
185
|
+
res.end(JSON.stringify({ error: 'shutting-down' }))
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
// Same-origin gate. Token IS the auth on REST, but a hostile origin
|
|
189
|
+
// that holds a valid token (e.g. via XSS that read a freshly-minted
|
|
190
|
+
// one) would PUT with its own Origin header — caught here.
|
|
191
|
+
// Same-origin XHR/fetch may omit Origin; that path is allowed (see
|
|
192
|
+
// `isOriginAllowed`). Transport audit `server-e2e/objstore/rest.ts:103`.
|
|
193
|
+
if (!isOriginAllowed(req)) {
|
|
194
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
195
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
// Idle-body timeout for the body-bearing REST methods — a slow-loris
|
|
199
|
+
// client trickling bytes holds the connection (and, for PUT, the
|
|
200
|
+
// staging fd + inFlightSids slot) indefinitely. PUT carries the raw
|
|
201
|
+
// blob within its declared Content-Length; POST carries the small
|
|
202
|
+
// fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
|
|
203
|
+
// destroy the request, aborting the body pipeline.
|
|
204
|
+
// Transport audit `server-e2e/objstore/rest.ts:218`.
|
|
205
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
206
|
+
req.setTimeout(restPutIdleTimeoutMs, () => {
|
|
207
|
+
if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
|
|
208
|
+
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
// Track so SIGTERM mid-upload/download awaits handleRest before the
|
|
212
|
+
// DB close. The outer `.catch` is the unhandled-rejection guard for
|
|
213
|
+
// a stray throw OUTSIDE handleRest's internal try/catch blocks —
|
|
214
|
+
// Node 20+ defaults `--unhandled-rejections=throw`, which would
|
|
215
|
+
// crash the server. Logs and terminates the response so the TCP
|
|
216
|
+
// socket doesn't dangle.
|
|
217
|
+
const p = handleRest(restDeps, req, res).catch((err) => {
|
|
218
|
+
console.warn('REST handler error:', errStack(err))
|
|
219
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
220
|
+
else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
|
|
221
|
+
})
|
|
222
|
+
track(p)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (handleStatic(req, res)) return
|
|
226
|
+
// `Connection: close` so an HTTP/1.1 keep-alive client doesn't hold
|
|
227
|
+
// the socket open expecting more requests on a server that only
|
|
228
|
+
// serves a small REST surface.
|
|
229
|
+
res.writeHead(404, { 'content-type': 'application/json', 'connection': 'close' })
|
|
230
|
+
res.end(NOT_FOUND_BODY)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
234
|
+
// RFC 6455: the WS upgrade IS an HTTP request; reject with a normal
|
|
235
|
+
// HTTP response so a misconfigured client sees the JSON body instead
|
|
236
|
+
// of ECONNRESET. `socket.end(body)` flushes before sending FIN.
|
|
237
|
+
if (!isUpgradePath(req.url)) {
|
|
238
|
+
socket.end(
|
|
239
|
+
'HTTP/1.1 404 Not Found\r\n' +
|
|
240
|
+
'Content-Type: application/json\r\n' +
|
|
241
|
+
`Content-Length: ${Buffer.byteLength(NOT_FOUND_BODY)}\r\n` +
|
|
242
|
+
'Connection: close\r\n\r\n' +
|
|
243
|
+
NOT_FOUND_BODY,
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
// Same-origin gate. The WS upgrade IS a cross-origin-reachable
|
|
248
|
+
// surface in the browser; without this any tab can open a session to
|
|
249
|
+
// a 127.0.0.1 relay and probe handler shape / burn verify CPU.
|
|
250
|
+
// Browser WS handshakes always carry Origin (RFC 6455); non-browser
|
|
251
|
+
// clients omit it and are allowed (network is their trust boundary).
|
|
252
|
+
if (!isOriginAllowed(req)) {
|
|
253
|
+
socket.end(
|
|
254
|
+
'HTTP/1.1 403 Forbidden\r\n' +
|
|
255
|
+
'Content-Type: application/json\r\n' +
|
|
256
|
+
'Content-Length: 26\r\n' +
|
|
257
|
+
'Connection: close\r\n\r\n' +
|
|
258
|
+
'{"error":"origin-denied"}\n',
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) })
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
return httpServer
|
|
266
|
+
}
|
|
@@ -19,8 +19,18 @@ export type Hub = {
|
|
|
19
19
|
// `except: null` is the REST-originated path — byte transfer landed
|
|
20
20
|
// via HTTP, not a particular WS socket, so it hits every subscriber.
|
|
21
21
|
// WS-originated broadcasts pass the originator so it doesn't see its
|
|
22
|
-
// own message echoed back.
|
|
22
|
+
// own message echoed back. Local-only: callers that ALSO want a
|
|
23
|
+
// cross-instance fan-out are expected to publish to the pubsub bus
|
|
24
|
+
// alongside this call (server-e2e/pubsub.ts). The hub deliberately stays
|
|
25
|
+
// ignorant of the bus so its transport invariants (backpressure cap,
|
|
26
|
+
// stringify-once, terminate-on-overflow) are unchanged.
|
|
23
27
|
broadcast(tag: string, msg: object, except: WebSocket | null): void
|
|
28
|
+
// Broadcasts an ALREADY-SERIALISED payload to every local subscriber
|
|
29
|
+
// for `tag`. Used by the pubsub bus receiver
|
|
30
|
+
// (server-e2e/bus-receiver.ts, wired up from server-e2e/index.ts) to relay
|
|
31
|
+
// a remote-instance event into this instance's fan-out. No `except`:
|
|
32
|
+
// the originator is on a different instance by construction.
|
|
33
|
+
broadcastLocalRaw(tag: string, payload: string): void
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number; debug: boolean }): Hub {
|
|
@@ -61,7 +71,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
61
71
|
// kernel yet — a slow / blackholed peer accumulates them unboundedly
|
|
62
72
|
// during fan-out broadcasts. Drop above the cap and terminate the
|
|
63
73
|
// socket so the heartbeat doesn't keep it alive on ping/pong while
|
|
64
|
-
// every broadcast piles up. Transport audit `server/index.ts:225`.
|
|
74
|
+
// every broadcast piles up. Transport audit `server-e2e/index.ts:225`.
|
|
65
75
|
if (socket.bufferedAmount > maxBufferedBytes) {
|
|
66
76
|
if (debug) console.warn(`drop broadcast: socket buffered ${socket.bufferedAmount}B > cap`)
|
|
67
77
|
try { socket.terminate() } catch {}
|
|
@@ -82,17 +92,26 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
82
92
|
// catch-up with a multi-MB ciphertext × N subscribers, per-recipient
|
|
83
93
|
// JSON.stringify would dominate CPU; this is the cheap win.
|
|
84
94
|
const payload = JSON.stringify(msg)
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
fanOut(set, payload, except)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function broadcastLocalRaw(tag: string, payload: string): void {
|
|
99
|
+
const set = subscribers.get(tag)
|
|
100
|
+
if (!set) return
|
|
101
|
+
fanOut(set, payload, null)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
|
|
105
|
+
// Snapshot before iterating — a socket transitioning to CLOSED
|
|
87
106
|
// mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
107
|
+
// mutating `set` while we walk it. The snapshot also keeps a future
|
|
108
|
+
// refactor (different collection, async send) from silently skipping
|
|
109
|
+
// subscribers. Audit M4 round-3.
|
|
91
110
|
for (const s of [...set]) {
|
|
92
111
|
if (s === except) continue
|
|
93
112
|
sendRaw(s, payload)
|
|
94
113
|
}
|
|
95
114
|
}
|
|
96
115
|
|
|
97
|
-
return { subscribe, unsubscribeAll, send, sendRaw, broadcast }
|
|
116
|
+
return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
|
|
98
117
|
}
|