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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +17 -10
  8. package/out/graph.js +5 -4
  9. package/out/index.html +43 -38
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -51
  14. package/package.json +70 -49
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +16 -1
  17. package/server-e2e/bus-receiver.ts +95 -0
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +41 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +15 -9
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +113 -135
  24. package/server-e2e/http.ts +266 -0
  25. package/{server → server-e2e}/hub.ts +27 -8
  26. package/{server → server-e2e}/index.ts +185 -52
  27. package/{server → server-e2e}/lifecycle.ts +36 -5
  28. package/server-e2e/npm-proxy.ts +348 -0
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +25 -15
  34. package/{server → server-e2e}/objstore/init.ts +52 -12
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +119 -84
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/server-e2e/pubsub.ts +394 -0
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/server-e2e/sse-server.ts +384 -0
  47. package/server-e2e/sse-session.ts +216 -0
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/server-e2e/sync-handlers.ts +382 -0
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/server-e2e/ws-server.ts +276 -0
  52. package/strip-types-loader.js +94 -0
  53. package/server/http.ts +0 -142
  54. package/server/sync-handlers.ts +0 -311
  55. package/server/ws-server.ts +0 -245
  56. /package/{server → server-e2e}/config.example.json +0 -0
  57. /package/{server → server-e2e}/neon-driver.ts +0 -0
  58. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  59. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,384 @@
1
+ // SSE+POST fallback for clients that can't establish a WebSocket
2
+ // upgrade — corporate proxies that strip `Upgrade: websocket`, legacy
3
+ // HTTP/1.0 intermediaries, or environments where `new WebSocket(…)`
4
+ // errors before `open`. Wire protocol on the SSE downstream is
5
+ // identical to the WS plane (same JSON message taxonomy, same Ed25519-
6
+ // signed canonicals, same `challenge` / `pong` / `authenticate` flow);
7
+ // the upstream direction batches frames into HTTP POSTs.
8
+ //
9
+ // One route, POSTs only:
10
+ //
11
+ // POST /api/sync/sse
12
+ // Request body:
13
+ // { id?: string, — session continuation token
14
+ // password?: string, — cached client password
15
+ // frames?: Array<protocol-frame> — WS-style JSON frames
16
+ // }
17
+ // Response:
18
+ // 200 OK, content-type: text/event-stream
19
+ // First frame on a *fresh* session is an SSE event named
20
+ // `session` whose data is the raw continuation-token string
21
+ // (22-char base64url, from `randomId()`). The per-session
22
+ // challenge nonce is delivered separately on the next default-
23
+ // named `data:` event as the standard protocol `challenge`
24
+ // frame, so the SSE plane uses the SAME nonce-handshake the WS
25
+ // plane does. Subsequent frames are default-named SSE messages
26
+ // carrying the WS protocol's JSON envelopes.
27
+ //
28
+ // Continuation: each POST replaces the previous POST's response as the
29
+ // session's downstream channel. If the body's `id` matches a session
30
+ // this replica knows, the session continues (new outbound stream
31
+ // attached, old one end()ed). If the id is unknown (different replica
32
+ // picked up the POST, or session expired), a fresh session with a new
33
+ // id is created and announced via the first `session` event; the
34
+ // client uses the new id on all subsequent POSTs and re-sends its
35
+ // subscribe frames on the next POST (its `frames` carry the signed
36
+ // subscribes — they always do, see client/sync/sse-transport.ts).
37
+ //
38
+ // The id rides the JSON body, NOT the URL: the sid is a live bearer
39
+ // capability (whoever presents it attaches to the session's downstream
40
+ // and inherits its operator-auth flag), and a query-string token leaks
41
+ // into proxy / LB access logs — the same reason the objstore bearer
42
+ // tokens ride the Authorization header, never the URL. A legacy
43
+ // `?id=<sid>` query form is still ACCEPTED (older client bundles sent
44
+ // it; rejecting would churn them through a fresh session per POST),
45
+ // but current clients never emit it.
46
+ //
47
+ // Why POSTs only: a long-lived GET pins the client to one replica via
48
+ // TCP affinity, which would mean POSTs from the same client must be
49
+ // sticky-routed to the same replica. POSTs-only makes the protocol
50
+ // stateless across replicas — any replica can pick up the next POST
51
+ // from any client.
52
+
53
+ import type { IncomingMessage as HttpRequest, ServerResponse } from 'node:http'
54
+ import { Buffer } from 'node:buffer'
55
+ import type { WebSocket } from 'ws'
56
+ import { type PeerConnectionDeps, setupPeerConnection } from './ws-server.ts'
57
+ import { SseSession } from './sse-session.ts'
58
+ import { errMsg, randomId } from './util.ts'
59
+
60
+ // Same prefix the WS upgrade lives under (server-e2e/http.ts WS_UPGRADE_PATH
61
+ // = '/api/sync'); subroute keeps the SSE plane sibling to the upgrade
62
+ // path so the same `location` block routes both.
63
+ export const SSE_OPEN_PATH = '/api/sync/sse'
64
+
65
+ // Cadence of the server-driven keepalive sweep: every tick we write a `:`
66
+ // comment to each open session's downstream so intermediary proxies don't
67
+ // idle-close it (nginx et al. default to a ~60s read timeout). This is the
68
+ // server's own liveness upkeep — the client no longer POSTs a periodic ping
69
+ // (which forced a stream takeover every tick).
70
+ //
71
+ // Reaping model (replaces the old POST-driven idle timer): a session is
72
+ // dropped on its downstream response `close` — clean disconnect, or a
73
+ // half-open socket the per-session TCP keepalive forces closed (see
74
+ // SseSession.SOCKET_KEEPALIVE_MS) — NOT by this sweep. We intentionally
75
+ // trust connection-level liveness. The one topology this can't see is a
76
+ // buffering / TLS-terminating proxy that holds the upstream open after the
77
+ // real client vanished (keepalive then probes the proxy hop, not the
78
+ // client); such a session lingers until `maxSessions`, the hard backstop.
79
+ // This is the same exposure the WS heartbeat already has (its ping only
80
+ // proves the proxy↔server hop too), not a new class of leak.
81
+ const KEEPALIVE_SWEEP_MS = 30_000
82
+
83
+ export type SseServerDeps = {
84
+ // The WS dispatch is the cohesive unit; SSE just provides another
85
+ // transport into it. Closure over the same handler / hub / objstore
86
+ // / track / debug surface the WS path uses.
87
+ peerDeps: PeerConnectionDeps
88
+ // Shutdown gate. Mirror of the REST + WS branches in server-e2e/http.ts:
89
+ // an SSE POST arriving on an existing keep-alive socket after
90
+ // SIGTERM should be rejected, not dispatched against a draining DB.
91
+ isShuttingDown: () => boolean
92
+ // Max number of concurrent SSE sessions per process. Above this we
93
+ // 503 the open request. Caps the SSE-side equivalent of `wss.clients`.
94
+ maxSessions: number
95
+ // Max body size for one POST. Matches the WS `maxPayload` in
96
+ // server-e2e/index.ts so the SSE plane can't accept frames the WS plane
97
+ // would reject. The POST body envelope can hold multiple frames so
98
+ // the per-frame budget is the same as the WS plane after the
99
+ // dispatcher splits them.
100
+ maxBodyBytes: number
101
+ debug: boolean
102
+ }
103
+
104
+ export type SseServer = {
105
+ // Returns true if the request matched an SSE route (caller should
106
+ // not fall through to other handlers). false otherwise.
107
+ handle: (req: HttpRequest, res: ServerResponse) => boolean
108
+ // Iterates active sessions. Lifecycle's graceful-shutdown loop
109
+ // reads this to close SSE sessions alongside WS clients.
110
+ sessions: () => Iterable<SseSession>
111
+ // The keepalive-sweep timer. Lifecycle clears it on shutdown (parity
112
+ // with the WS heartbeat timer) so a tick can't fire mid-teardown.
113
+ keepaliveTimer: ReturnType<typeof setInterval>
114
+ }
115
+
116
+ // Inbound POST body. Every field optional — an empty-body POST is a
117
+ // valid "wake the session" probe (the response stream rides on every
118
+ // POST), and a body that carries only `password` or only `frames` is
119
+ // a normal partial update. `id` is the session continuation token
120
+ // (see the header's "Continuation" note for why it rides the body).
121
+ type SseBody = {
122
+ id?: unknown
123
+ password?: unknown
124
+ frames?: unknown
125
+ }
126
+
127
+ export function installSseServer(deps: SseServerDeps): SseServer {
128
+ const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
129
+
130
+ // Active SSE sessions, keyed by the random session id `createSession`
131
+ // mints on the first POST that carries no continuation id (or whose
132
+ // id this replica doesn't recognise) and that subsequent POSTs echo
133
+ // back (body `id` field) to continue the session. Bounded by
134
+ // `maxSessions` — over the cap, new POSTs get a 503. POSTs against an
135
+ // unknown id are NOT 404'd — they mint a fresh session instead, so a
136
+ // multi-replica deployment doesn't require sticky LB routing to
137
+ // recover.
138
+ const sessions = new Map<string, SseSession>()
139
+
140
+ function dropSession(sid: string): void {
141
+ sessions.delete(sid)
142
+ }
143
+
144
+ function writeSseHeaders(res: ServerResponse): void {
145
+ // `no-store` keeps stale event copies out of intermediate caches;
146
+ // `x-accel-buffering: no` is the nginx-specific opt-out from
147
+ // response buffering (without it nginx queues up to its
148
+ // `proxy_buffer_size` of events before flushing, breaking the
149
+ // realtime contract).
150
+ res.writeHead(200, {
151
+ 'content-type': 'text/event-stream; charset=utf-8',
152
+ 'cache-control': 'no-cache, no-store, no-transform',
153
+ 'connection': 'keep-alive',
154
+ 'x-accel-buffering': 'no',
155
+ })
156
+ // Retry hint for any auto-reconnect machinery on the client side.
157
+ // The production client (client/sync/sse-transport.ts) does its own
158
+ // reconnect via the outer socket-transport loop, so this is just
159
+ // defence in depth.
160
+ res.write('retry: 1000\n\n')
161
+ }
162
+
163
+ function createSession(res: ServerResponse, req: HttpRequest): SseSession | null {
164
+ if (sessions.size >= maxSessions) {
165
+ if (debug) console.warn(`sse: refused open — sessions ${sessions.size} >= ${maxSessions}`)
166
+ return null
167
+ }
168
+ writeSseHeaders(res)
169
+ const sid = randomId()
170
+ const session = new SseSession(res)
171
+ sessions.set(sid, session)
172
+ session.on('close', () => { dropSession(sid) })
173
+ // Announce the continuation token BEFORE the dispatcher emits its
174
+ // `challenge` frame so the client latches the id first and the
175
+ // protocol-level challenge lands on a stream the client is already
176
+ // tracking. Both ride the same response — order is a wire-shape
177
+ // nicety, not a correctness requirement (events are independent).
178
+ session.writeEvent('session', sid)
179
+ // Hand the session to the shared WS connection setup so it joins
180
+ // the same Peer / dispatcher / hub lifecycle as a real WebSocket.
181
+ // `setupPeerConnection`'s first action is the protocol `challenge`
182
+ // frame, on the default-named SSE channel.
183
+ setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
184
+ return session
185
+ }
186
+
187
+ // Drives a POST body's `password` and `frames` through the shared
188
+ // dispatcher. The dispatcher's `ping` / `authenticate` fast paths
189
+ // handle the trivial cases inline; everything else spawns a tracked
190
+ // handler. We synthesise one synthetic frame per inbound bit so the
191
+ // dispatcher reads identically whether it's reading WS frames or
192
+ // SSE POST batches.
193
+ function dispatchBody(session: SseSession, body: SseBody): void {
194
+ // Password → synthetic `authenticate` frame so the shared
195
+ // dispatcher's existing fast path runs unchanged. Client caches
196
+ // the password and re-sends on every POST, so the first POST
197
+ // after a session takeover re-authenticates silently on the new
198
+ // replica without an extra round-trip.
199
+ if (typeof body.password === 'string' && body.password.length > 0) {
200
+ const buf = Buffer.from(JSON.stringify({ type: 'authenticate', password: body.password }), 'utf8')
201
+ session.receiveMessage(buf)
202
+ }
203
+ if (Array.isArray(body.frames)) {
204
+ for (const frame of body.frames) {
205
+ if (!frame || typeof frame !== 'object') continue
206
+ const buf = Buffer.from(JSON.stringify(frame), 'utf8')
207
+ session.receiveMessage(buf)
208
+ }
209
+ }
210
+ }
211
+
212
+ function handlePost(req: HttpRequest, res: ServerResponse, sidFromUrl: string | null): void {
213
+ if (isShuttingDown()) {
214
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
215
+ res.end(JSON.stringify({ error: 'shutting-down' }))
216
+ return
217
+ }
218
+ // Bound the body the same way `WebSocketServer({ maxPayload })`
219
+ // bounds a WS frame. Slurp into a single buffer rather than
220
+ // streaming — every protocol frame fits comfortably under
221
+ // `maxBodyBytes` (4 MiB) and the dispatcher takes one buffer at a
222
+ // time anyway. `content-length` may be missing on chunked-encoded
223
+ // requests; rely on the accumulating size check.
224
+ const chunks: Buffer[] = []
225
+ let total = 0
226
+ let aborted = false
227
+ req.on('data', (chunk: Buffer) => {
228
+ if (aborted) return
229
+ total += chunk.length
230
+ if (total > maxBodyBytes) {
231
+ aborted = true
232
+ if (debug) console.warn(`sse: POST body too large (${total} > ${maxBodyBytes})`)
233
+ res.writeHead(413, { 'content-type': 'application/json' })
234
+ res.end(JSON.stringify({ error: 'too-large' }))
235
+ try { req.destroy() } catch {}
236
+ return
237
+ }
238
+ chunks.push(chunk)
239
+ })
240
+ req.on('end', () => {
241
+ if (aborted) return
242
+ // Re-check shutdown: the entry gate at handlePost runs at request
243
+ // arrival, but the body read is async — a slow upload that
244
+ // started pre-SIGTERM can fire 'end' AFTER the lifecycle's
245
+ // sseSessions() close-loop has already iterated, and createSession
246
+ // would otherwise add a NEW session post-iteration that only
247
+ // gets force-killed by the terminate-grace timer (no graceful
248
+ // event:close frame). Bail with 503 so the client distinguishes
249
+ // shutdown from a transport error and short-circuits backoff.
250
+ if (isShuttingDown()) {
251
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
252
+ res.end(JSON.stringify({ error: 'shutting-down' }))
253
+ return
254
+ }
255
+ let body: SseBody = {}
256
+ if (chunks.length > 0) {
257
+ try {
258
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks, total).toString('utf8'))
259
+ if (parsed && typeof parsed === 'object') body = parsed as SseBody
260
+ } catch (err) {
261
+ if (debug) console.warn('sse: malformed POST body:', errMsg(err))
262
+ res.writeHead(400, { 'content-type': 'application/json' })
263
+ res.end(JSON.stringify({ error: 'bad-json' }))
264
+ return
265
+ }
266
+ }
267
+ // Resolve the continuation token: the JSON body's `id` is the
268
+ // canonical carrier; the `?id=` query form is the legacy
269
+ // fallback for older client bundles (see the header note — a
270
+ // query sid leaks into access logs). Body wins when both are
271
+ // present (current clients only ever send one).
272
+ const sid = parseSid(body.id) ?? sidFromUrl
273
+ // Look up the session by id (if the client sent one). If found
274
+ // and alive, attach the new response and dispatch. If not (or
275
+ // session is closed), mint a fresh one — the response stream
276
+ // carries the new id as the first `session` event so the client
277
+ // switches over on the next POST.
278
+ //
279
+ // Attach-then-write ordering: attachResponse runs FIRST and only
280
+ // on success do we writeSseHeaders. If attachResponse returns
281
+ // false (today only when the session transitions out of OPEN
282
+ // between the lookup and the attach — a future backpressure /
283
+ // takeover-rate path could surface that legitimately) we fall
284
+ // through to createSession with `res` still header-virgin, so
285
+ // the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
286
+ let session: SseSession | null = null
287
+ if (sid) {
288
+ const existing = sessions.get(sid)
289
+ if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
290
+ writeSseHeaders(res)
291
+ session = existing
292
+ }
293
+ }
294
+ if (!session) {
295
+ session = createSession(res, req)
296
+ if (!session) {
297
+ // Cap exceeded; createSession already logged. Response
298
+ // headers not yet written by writeSseHeaders, so send a
299
+ // 503 JSON instead.
300
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
301
+ res.end(JSON.stringify({ error: 'too-many-sessions' }))
302
+ return
303
+ }
304
+ }
305
+ dispatchBody(session, body)
306
+ // Do NOT res.end() — the response stays open as the session's
307
+ // downstream channel until the next POST takes over (or the
308
+ // client disconnects).
309
+ })
310
+ req.on('error', (err) => {
311
+ if (debug) console.warn('sse: POST stream error:', errMsg(err))
312
+ if (res.headersSent) {
313
+ try { res.destroy() } catch {}
314
+ } else {
315
+ try { res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'bad-request' })) } catch {}
316
+ }
317
+ })
318
+ }
319
+
320
+ function handle(req: HttpRequest, res: ServerResponse): boolean {
321
+ if (typeof req.url !== 'string') return false
322
+ const [path, query] = req.url.split('?', 2)
323
+ if (path !== SSE_OPEN_PATH) return false
324
+ if (req.method !== 'POST') {
325
+ // `connection: close` so a probing client (e.g. accidental GET)
326
+ // can't pipeline N more 405s on the same keep-alive socket.
327
+ // Sibling 503 paths set this for the same reason.
328
+ res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
329
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
330
+ return true
331
+ }
332
+ const sid = parseSidQuery(query)
333
+ handlePost(req, res, sid)
334
+ return true
335
+ }
336
+
337
+ // Server-driven keepalive sweep. Writes a `:` comment to every open
338
+ // session's downstream so proxies don't idle-close it. `unref` so it can't
339
+ // by itself hold the event loop open (parity with the WS heartbeat timer);
340
+ // skipped during shutdown so a tick can't write to a session the close
341
+ // loop is tearing down. Dead-session reaping is the response `close` event
342
+ // (see SseSession.wireResponse + the per-session TCP keepalive), NOT this
343
+ // sweep — so a half-open client is dropped without ever POSTing.
344
+ const keepaliveTimer = setInterval(() => {
345
+ if (isShuttingDown()) return
346
+ for (const session of sessions.values()) {
347
+ try { session.ping() } catch {}
348
+ }
349
+ }, KEEPALIVE_SWEEP_MS)
350
+ keepaliveTimer.unref?.()
351
+
352
+ return { handle, sessions: () => sessions.values(), keepaliveTimer }
353
+ }
354
+
355
+ // Shape gate for a continuation token from either carrier (body `id`
356
+ // field or the legacy `?id=` query). The {1,64} bound is a
357
+ // deliberately lenient sanity gate: anything outside the base64url
358
+ // alphabet is rejected; an unrecognised sid just gets a fresh session
359
+ // from createSession (no failure mode), and the wide length window
360
+ // keeps a future randomId-length change from silently breaking old
361
+ // clients that round-trip a longer/shorter token. Returns null on
362
+ // missing / non-string / malformed.
363
+ function parseSid(v: unknown): string | null {
364
+ if (typeof v !== 'string') return null
365
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
366
+ return v
367
+ }
368
+
369
+ // Bare-bones query parse for the LEGACY `id=<base64url>` carrier
370
+ // (older client bundles; current clients send the sid in the POST
371
+ // body — see the header's "Continuation" note). Avoids URLSearchParams
372
+ // (which decodes percent-escapes) — `randomId()` mints a 22-char
373
+ // base64url string echoed back unchanged, so no escapes are possible
374
+ // on the legitimate path.
375
+ function parseSidQuery(query: string | undefined): string | null {
376
+ if (typeof query !== 'string') return null
377
+ for (const part of query.split('&')) {
378
+ const eq = part.indexOf('=')
379
+ if (eq <= 0) continue
380
+ if (part.slice(0, eq) !== 'id') continue
381
+ return parseSid(part.slice(eq + 1))
382
+ }
383
+ return null
384
+ }
@@ -0,0 +1,216 @@
1
+ // Per-connection state for the SSE+POST fallback. Each session pins to
2
+ // the *latest* POST's response stream — the previous POST's response
3
+ // is closed when a new POST takes over. This shape sidesteps sticky-
4
+ // routing in multi-replica deployments: a POST that lands on a
5
+ // replica that doesn't know the session id mints a fresh session with
6
+ // a new id (returned via the first `session` event); the client then
7
+ // continues against the new id with all subsequent POSTs. Subscriptions
8
+ // and auth state ride re-sendable signed frames + a client-cached
9
+ // password, so the new replica reconstructs locally.
10
+ //
11
+ // Mimics the subset of `ws.WebSocket` that `setupPeerConnection` (in
12
+ // ./ws-server.ts), the hub (./hub.ts) and the lifecycle shutdown loop
13
+ // (./lifecycle.ts) read: `readyState` + `OPEN`/`CLOSING` constants,
14
+ // `send` / `close` / `terminate` / `ping`, the `bufferedAmount`
15
+ // backpressure signal, and the `message` / `close` / `error` / `pong`
16
+ // EventEmitter surface. The per-connection dispatcher is single-
17
+ // sourced — it doesn't know whether it's talking to a real WebSocket
18
+ // or this adapter.
19
+ //
20
+ // Wire shape: each outbound frame becomes a single SSE `data:` field.
21
+ // The dispatcher emits JSON via `JSON.stringify` (no embedded newlines
22
+ // after that round-trip), so a single-line `data:` is enough; we still
23
+ // split-on-newline defensively for any future caller that hands raw
24
+ // multi-line text. Inbound frames are injected via `receiveMessage`
25
+ // after the POST plane reads the body.
26
+
27
+ import { EventEmitter } from 'node:events'
28
+ import type { Buffer } from 'node:buffer'
29
+ import type { ServerResponse } from 'node:http'
30
+
31
+ // TCP keepalive idle delay for the downstream socket. With the client no
32
+ // longer POSTing a periodic ping (see client/sync/socket-transport.ts), a
33
+ // QUIET session whose client vanished without a FIN (crash, NAT/idle drop)
34
+ // has no application-level liveness signal — so we lean on the kernel:
35
+ // after this much idle the kernel starts probing, and a dead half-open
36
+ // socket surfaces as a `close`/`error` here. Node's `setKeepAlive` sets
37
+ // only TCP_KEEPIDLE (the delay to the FIRST probe), not the probe
38
+ // interval/count — so full teardown is this delay PLUS the OS's
39
+ // TCP_KEEPINTVL × TCP_KEEPCNT (~minutes on Linux defaults), still bounded
40
+ // and far below the kernel's hours-long default-off behaviour. `maxSessions`
41
+ // is the hard backstop. Behind a TLS-terminating / buffering proxy this
42
+ // probes the proxy hop, not the client — see the reaping note in sse-server.ts.
43
+ const SOCKET_KEEPALIVE_MS = 30_000
44
+
45
+ export class SseSession extends EventEmitter {
46
+ static readonly CONNECTING = 0
47
+ static readonly OPEN = 1
48
+ static readonly CLOSING = 2
49
+ static readonly CLOSED = 3
50
+ // Per-instance constants so `socket.OPEN` reads identically to the
51
+ // `ws.WebSocket` shape the dispatcher and hub strict-compare against.
52
+ readonly CONNECTING = SseSession.CONNECTING
53
+ readonly OPEN = SseSession.OPEN
54
+ readonly CLOSING = SseSession.CLOSING
55
+ readonly CLOSED = SseSession.CLOSED
56
+
57
+ readyState: number = SseSession.OPEN
58
+ // The current downstream stream the session writes to. Each POST
59
+ // replaces this; broadcasts and ack frames flow on the latest one.
60
+ // Null between POSTs is *only* a transient state during swap — the
61
+ // session is created with a response in hand and the swap is
62
+ // synchronous from the dispatcher's perspective.
63
+ private currentRes: ServerResponse | null
64
+
65
+ constructor(res: ServerResponse) {
66
+ super()
67
+ this.currentRes = res
68
+ this.wireResponse(res)
69
+ }
70
+
71
+ // Wire the close / error events on a freshly-attached response. The
72
+ // close event only matters for the *current* response — a previous
73
+ // response's close (after it was swapped out) is not a session-level
74
+ // signal. Same guard applies to error: a TCP-level error during
75
+ // res.end() flush on a swapped-out response would otherwise emit a
76
+ // spurious session 'error' that operators read as a real transport
77
+ // failure on a healthy session.
78
+ private wireResponse(res: ServerResponse): void {
79
+ // Probe half-open downstreams at the kernel level (see SOCKET_KEEPALIVE_MS).
80
+ // A failed probe trips the `close`/`error` handlers below, which is how a
81
+ // dead-but-quiet SSE client is reaped now that there's no client ping.
82
+ try { res.socket?.setKeepAlive(true, SOCKET_KEEPALIVE_MS) } catch {}
83
+ res.on('close', () => {
84
+ if (res !== this.currentRes) return // current-response guard (see above)
85
+ if (this.readyState === SseSession.CLOSED) return
86
+ this.readyState = SseSession.CLOSED
87
+ this.currentRes = null
88
+ this.emit('close')
89
+ })
90
+ res.on('error', (err: Error) => {
91
+ if (res !== this.currentRes) return // current-response guard (see above)
92
+ this.emit('error', err)
93
+ })
94
+ }
95
+
96
+ // Swap in a new response (the latest POST's body). The previous
97
+ // response is end()ed cleanly so the client's reader sees EOF and
98
+ // stops draining; the takeover-grace handling for in-flight buffered
99
+ // bytes is on the client side (it keeps reading the old stream until
100
+ // EOF before switching). Returns true when the session was alive and
101
+ // the swap happened; false when the session is closed and the new
102
+ // response should be ended by the caller.
103
+ attachResponse(res: ServerResponse): boolean {
104
+ if (this.readyState !== SseSession.OPEN) return false
105
+ const prev = this.currentRes
106
+ this.currentRes = res
107
+ this.wireResponse(res)
108
+ // End the previous stream AFTER the new one is wired so any
109
+ // broadcast racing the swap lands on the new response, not on the
110
+ // half-closed old one. `end()` flushes Node's send buffer before
111
+ // sending FIN — frames already written drain to the client.
112
+ if (prev) { try { prev.end() } catch {} }
113
+ return true
114
+ }
115
+
116
+ // `hub.sendRaw` consults this before each send to decide whether to
117
+ // terminate (slow / blackholed peer). `writableLength` is the count
118
+ // of bytes queued in Node's HTTP stream that haven't drained to the
119
+ // kernel yet — the SSE-channel equivalent of `ws`'s `bufferedAmount`.
120
+ get bufferedAmount(): number {
121
+ return this.currentRes?.writableLength ?? 0
122
+ }
123
+
124
+ // Encodes one JSON frame as a single SSE event. Splits on `\n` so a
125
+ // multi-line payload still produces a well-formed event (each `data:`
126
+ // line is concatenated with `\n` by the SSE parser on the client),
127
+ // but `JSON.stringify` output won't trigger that branch.
128
+ send(payload: string | Buffer): void {
129
+ if (this.readyState !== SseSession.OPEN) return
130
+ const res = this.currentRes
131
+ if (!res) return
132
+ const text = typeof payload === 'string' ? payload : payload.toString('utf8')
133
+ const lines = text.split('\n')
134
+ const wire = `${lines.map((l) => `data: ${l}`).join('\n')}\n\n`
135
+ try { res.write(wire) } catch {}
136
+ }
137
+
138
+ // Writes a named SSE event (e.g. `session` for the continuation token
139
+ // handshake). Used by sse-server.ts on session creation; not part of
140
+ // the WebSocket-shaped surface and never called by the shared
141
+ // dispatcher.
142
+ writeEvent(event: string, data: string): void {
143
+ if (this.readyState !== SseSession.OPEN) return
144
+ const res = this.currentRes
145
+ if (!res) return
146
+ const lines = data.split('\n')
147
+ const body = `${lines.map((l) => `data: ${l}`).join('\n')}`
148
+ try { res.write(`event: ${event}\n${body}\n\n`) } catch {}
149
+ }
150
+
151
+ // Lifecycle's graceful-shutdown loop calls this with `(1001, '…')` to
152
+ // signal a server-initiated close. SSE has no native close code, so
153
+ // we emit a structured `close` event with the WS-style `{ code,
154
+ // reason }` payload — the client's transport reads it and bypasses
155
+ // its reconnect backoff (parity with the WS 1001 path).
156
+ //
157
+ // emit('close') is fired EXPLICITLY here, not via the res.on('close')
158
+ // listener: that listener short-circuits on `readyState === CLOSED`
159
+ // (so a later async res-close after we've flipped state doesn't
160
+ // double-emit), which means without the explicit emit here neither
161
+ // sse-server's dropSession cleanup nor setupPeerConnection's
162
+ // unsubscribeAll/peers.delete would run on any server-initiated
163
+ // teardown — the sessions map / hub.subscribers would leak per close.
164
+ // The wireResponse guard then ensures the later async fire is a no-op.
165
+ close(code?: number, reason?: string): void {
166
+ if (this.readyState === SseSession.CLOSED) return
167
+ const res = this.currentRes
168
+ if (this.readyState === SseSession.OPEN && code != null && res) {
169
+ try { res.write(`event: close\ndata: ${JSON.stringify({ code, reason: reason ?? '' })}\n\n`) } catch {}
170
+ }
171
+ this.readyState = SseSession.CLOSING
172
+ if (res) { try { res.end() } catch {} }
173
+ this.currentRes = null
174
+ this.readyState = SseSession.CLOSED
175
+ this.emit('close')
176
+ }
177
+
178
+ // Force-tear without flushing — mirrors `ws.terminate()`. Hub's
179
+ // backpressure path and the lifecycle's terminate-grace timer call
180
+ // this to drop unresponsive peers. Same explicit `emit('close')`
181
+ // story as close() above — without it, the wireResponse guard would
182
+ // swallow the later async res-close and the cleanup callbacks
183
+ // (dropSession, peers.delete, unsubscribeAll) would never run.
184
+ terminate(): void {
185
+ if (this.readyState === SseSession.CLOSED) return
186
+ this.readyState = SseSession.CLOSED
187
+ const res = this.currentRes
188
+ this.currentRes = null
189
+ if (res) { try { res.destroy() } catch {} }
190
+ this.emit('close')
191
+ }
192
+
193
+ // Server-driven keepalive, called on the SSE keepalive sweep in
194
+ // sse-server.ts. SSE has no `pong` equivalent, so we write a `:` comment
195
+ // line — ignored by the client parser — that keeps the downstream from
196
+ // being idle-closed by intermediary proxies (nginx et al. default to a
197
+ // ~60s read timeout). Dead-client detection is the response `close` event
198
+ // (clean disconnect, or a half-open socket the TCP keepalive in
199
+ // `wireResponse` forces closed), NOT this write: a `:`-comment write to a
200
+ // half-open socket just buffers, it doesn't synchronously throw.
201
+ ping(): void {
202
+ if (this.readyState !== SseSession.OPEN) return
203
+ const res = this.currentRes
204
+ if (!res) return
205
+ try { res.write(':\n\n') } catch {}
206
+ }
207
+
208
+ // Injects a client-to-server frame from the POST plane into the same
209
+ // `message` event the dispatcher already listens for on real WSs.
210
+ // `isBinary=false` because the SSE+POST plane is JSON-only by
211
+ // contract — the binary-frame drop in the dispatcher reads this.
212
+ receiveMessage(data: Buffer): void {
213
+ if (this.readyState !== SseSession.OPEN) return
214
+ this.emit('message', data, false)
215
+ }
216
+ }
@@ -64,15 +64,24 @@ const COMPRESSIBLE = new Set(['.html', '.css', '.js', '.svg', '.webmanifest'])
64
64
  // navigated `image/svg+xml` icon into something script-executable.
65
65
  // `no-referrer` keeps URLs of this (potentially sensitive) viewer out
66
66
  // of outbound Referer headers, and `same-origin` CORP stops other
67
- // origins embedding these bytes. CSP is deliberately NOT here: the
68
- // HTML documents carry their own per-page `<meta http-equiv>` policy
69
- // (and the three pages differ), so a blanket header CSP would just AND
70
- // against the meta one and risk breaking a page — non-document assets
71
- // get their own flat CSP via `StaticEntry.csp` instead.
67
+ // origins embedding these bytes. COOP `same-origin` severs the
68
+ // window.opener link to any cross-origin opener/popup (so an external
69
+ // `target=_blank` GitHub/Claude link can't reach back into this context),
70
+ // and COEP `require-corp` bars the document from loading any cross-origin
71
+ // subresource that doesn't opt in via CORP/CORS. Together they also make
72
+ // the page cross-origin-isolated. Safe here because the bundle is fully
73
+ // same-origin (no external scripts/images/fonts/iframes) — switch COEP to
74
+ // `credentialless` if a cross-origin resource is ever added. CSP is
75
+ // deliberately NOT here: the HTML documents carry their own per-page
76
+ // `<meta http-equiv>` policy (and the three pages differ), so a blanket
77
+ // header CSP would just AND against the meta one and risk breaking a page
78
+ // — non-document assets get their own flat CSP via `StaticEntry.csp`.
72
79
  const SECURITY_HEADERS = {
73
80
  'x-content-type-options': 'nosniff',
74
81
  'referrer-policy': 'no-referrer',
75
82
  'cross-origin-resource-policy': 'same-origin',
83
+ 'cross-origin-opener-policy': 'same-origin',
84
+ 'cross-origin-embedder-policy': 'require-corp',
76
85
  } as const
77
86
 
78
87
  type StaticEntry = {
@@ -169,11 +178,9 @@ function buildEntry(staticDir: string, name: string): StaticEntry {
169
178
  const ext = extname(name)
170
179
  const raw = readFileSync(join(staticDir, name))
171
180
  const type = CONTENT_TYPE[ext] ?? 'application/octet-stream'
172
- // HTML: lift `<link rel="(module)preload" …>` into a Link header
173
- // and drop the tags from the served body so the bytes ship without
174
- // the now-redundant in-body hint. ETag + compression run against
175
- // the stripped body — the on-disk file and the served body diverge
176
- // by exactly the lifted tags.
181
+ // HTML: lift `<link rel="(module)preload" …>` into a Link header and
182
+ // drop the tags from the served body. ETag + compression run against
183
+ // the stripped body (see the header note on the divergence).
177
184
  let identity = raw
178
185
  let link: string | null = null
179
186
  if (ext === '.html') {
@@ -273,13 +280,11 @@ function isUnsafeAttr(s: string): boolean {
273
280
  return /[\r\n",;<>]/u.test(s)
274
281
  }
275
282
 
276
- // Find character ranges in `html` whose contents are NOT real HTML
277
- // content: comment bodies and script / noscript raw-text bodies. A
278
- // `<link>` whose match offset falls inside any range is preserved
279
- // verbatim (no header lift). Heuristic: a real HTML parser is
280
- // overkill here — the build emits clean, well-formed markup; this is
281
- // defence against the build (or a hand edit) accidentally mentioning
282
- // `<link rel="preload">` somewhere it isn't meant to fire.
283
+ // Char ranges in `html` whose contents are NOT real HTML content:
284
+ // comment bodies and script / noscript raw-text bodies. A `<link>`
285
+ // whose match offset falls in any range is preserved verbatim (no
286
+ // header lift — see the call site for why). Heuristic, not a real
287
+ // parser: the build emits clean, well-formed markup.
283
288
  function findSkipRanges(html: string): Array<[number, number]> {
284
289
  const ranges: Array<[number, number]> = []
285
290
  const patterns = [