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

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.
@@ -0,0 +1,352 @@
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[?id=<sid>]
12
+ // Request body:
13
+ // { password?: string, — cached client password
14
+ // frames?: Array<protocol-frame> — WS-style JSON frames
15
+ // }
16
+ // Response:
17
+ // 200 OK, content-type: text/event-stream
18
+ // First frame on a *fresh* session is an SSE event named
19
+ // `session` whose data is the raw continuation-token string
20
+ // (22-char base64url, from `randomId()`). The per-session
21
+ // challenge nonce is delivered separately on the next default-
22
+ // named `data:` event as the standard protocol `challenge`
23
+ // frame, so the SSE plane uses the SAME nonce-handshake the WS
24
+ // plane does. Subsequent frames are default-named SSE messages
25
+ // carrying the WS protocol's JSON envelopes.
26
+ //
27
+ // Continuation: each POST replaces the previous POST's response as the
28
+ // session's downstream channel. If the `?id=<sid>` in the URL matches
29
+ // a session this replica knows, the session continues (new outbound
30
+ // stream attached, old one end()ed). If the id is unknown (different
31
+ // replica picked up the POST, or session expired), a fresh session
32
+ // with a new id is created and announced via the first `session`
33
+ // event; the client uses the new id on all subsequent POSTs and re-
34
+ // sends its subscribe frames on the next POST (its `frames` carry the
35
+ // signed subscribes — they always do, see client/sync/sse-transport.ts).
36
+ //
37
+ // Why POSTs only: a long-lived GET pins the client to one replica via
38
+ // TCP affinity, which would mean POSTs from the same client must be
39
+ // sticky-routed to the same replica. POSTs-only makes the protocol
40
+ // stateless across replicas — any replica can pick up the next POST
41
+ // from any client.
42
+
43
+ import type { IncomingMessage as HttpRequest, ServerResponse } from 'node:http'
44
+ import { Buffer } from 'node:buffer'
45
+ import type { WebSocket } from 'ws'
46
+ import { type PeerConnectionDeps, setupPeerConnection } from './ws-server.ts'
47
+ import { SseSession } from './sse-session.ts'
48
+ import { errMsg, randomId } from './util.ts'
49
+
50
+ // Same prefix the WS upgrade lives under (server/http.ts WS_UPGRADE_PATH
51
+ // = '/api/sync'); subroute keeps the SSE plane sibling to the upgrade
52
+ // path so the same `location` block routes both.
53
+ export const SSE_OPEN_PATH = '/api/sync/sse'
54
+
55
+ export type SseServerDeps = {
56
+ // The WS dispatch is the cohesive unit; SSE just provides another
57
+ // transport into it. Closure over the same handler / hub / objstore
58
+ // / track / debug surface the WS path uses.
59
+ peerDeps: PeerConnectionDeps
60
+ // Shutdown gate. Mirror of the REST + WS branches in server/http.ts:
61
+ // an SSE POST arriving on an existing keep-alive socket after
62
+ // SIGTERM should be rejected, not dispatched against a draining DB.
63
+ isShuttingDown: () => boolean
64
+ // Max number of concurrent SSE sessions per process. Above this we
65
+ // 503 the open request. Caps the SSE-side equivalent of `wss.clients`.
66
+ maxSessions: number
67
+ // Max body size for one POST. Matches the WS `maxPayload` in
68
+ // server/index.ts so the SSE plane can't accept frames the WS plane
69
+ // would reject. The POST body envelope can hold multiple frames so
70
+ // the per-frame budget is the same as the WS plane after the
71
+ // dispatcher splits them.
72
+ maxBodyBytes: number
73
+ // Idle timeout for a session with no inbound POSTs. Detects the
74
+ // wandered-off browser tab the WS heartbeat sweep handles via
75
+ // ping/pong on real sockets. The client's JSON ping/pong heartbeat
76
+ // (every 15s) is the steady-state liveness signal; this is the
77
+ // hard ceiling.
78
+ sessionIdleMs: number
79
+ debug: boolean
80
+ }
81
+
82
+ export type SseServer = {
83
+ // Returns true if the request matched an SSE route (caller should
84
+ // not fall through to other handlers). false otherwise.
85
+ handle: (req: HttpRequest, res: ServerResponse) => boolean
86
+ // Iterates active sessions. Lifecycle's graceful-shutdown loop
87
+ // reads this to close SSE sessions alongside WS clients.
88
+ sessions: () => Iterable<SseSession>
89
+ }
90
+
91
+ // Inbound POST body. Every field optional — an empty-body POST is a
92
+ // valid "wake the session" probe (the response stream rides on every
93
+ // POST), and a body that carries only `password` or only `frames` is
94
+ // a normal partial update.
95
+ type SseBody = {
96
+ password?: unknown
97
+ frames?: unknown
98
+ }
99
+
100
+ export function installSseServer(deps: SseServerDeps): SseServer {
101
+ const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, sessionIdleMs, debug } = deps
102
+
103
+ // Active SSE sessions, keyed by the random session id `createSession`
104
+ // mints on the first POST that lacks a `?id=` (or whose id this
105
+ // replica doesn't recognise) and that subsequent POSTs echo back to
106
+ // continue the session. Bounded by `maxSessions` — over the cap, new
107
+ // POSTs get a 503. POSTs against an unknown id are NOT 404'd — they
108
+ // mint a fresh session instead, so a multi-replica deployment doesn't
109
+ // require sticky LB routing to recover.
110
+ const sessions = new Map<string, SseSession>()
111
+ // Per-session idle timer handle. Reset on every inbound POST; fires
112
+ // after `sessionIdleMs` of silence to close a stranded session.
113
+ const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
114
+
115
+ function armIdleTimer(sid: string, session: SseSession): void {
116
+ clearTimeout(idleTimers.get(sid))
117
+ if (sessionIdleMs <= 0) return
118
+ const t = setTimeout(() => {
119
+ if (debug) console.warn(`sse: session ${sid.slice(0, 8)}… idle ${sessionIdleMs}ms → close`)
120
+ try { session.terminate() } catch {}
121
+ }, sessionIdleMs)
122
+ t.unref?.()
123
+ idleTimers.set(sid, t)
124
+ }
125
+
126
+ function dropSession(sid: string): void {
127
+ sessions.delete(sid)
128
+ const t = idleTimers.get(sid)
129
+ if (t) clearTimeout(t)
130
+ idleTimers.delete(sid)
131
+ }
132
+
133
+ function writeSseHeaders(res: ServerResponse): void {
134
+ // `no-store` keeps stale event copies out of intermediate caches;
135
+ // `x-accel-buffering: no` is the nginx-specific opt-out from
136
+ // response buffering (without it nginx queues up to its
137
+ // `proxy_buffer_size` of events before flushing, breaking the
138
+ // realtime contract).
139
+ res.writeHead(200, {
140
+ 'content-type': 'text/event-stream; charset=utf-8',
141
+ 'cache-control': 'no-cache, no-store, no-transform',
142
+ 'connection': 'keep-alive',
143
+ 'x-accel-buffering': 'no',
144
+ })
145
+ // Retry hint for any auto-reconnect machinery on the client side.
146
+ // The production client (client/sync/sse-transport.ts) does its own
147
+ // reconnect via the outer socket-transport loop, so this is just
148
+ // defence in depth.
149
+ res.write('retry: 1000\n\n')
150
+ }
151
+
152
+ function createSession(res: ServerResponse, req: HttpRequest): { sid: string; session: SseSession } | null {
153
+ if (sessions.size >= maxSessions) {
154
+ if (debug) console.warn(`sse: refused open — sessions ${sessions.size} >= ${maxSessions}`)
155
+ return null
156
+ }
157
+ writeSseHeaders(res)
158
+ const sid = randomId()
159
+ const session = new SseSession(res)
160
+ sessions.set(sid, session)
161
+ armIdleTimer(sid, session)
162
+ session.on('close', () => { dropSession(sid) })
163
+ // Announce the continuation token BEFORE the dispatcher emits its
164
+ // `challenge` frame so the client latches the id first and the
165
+ // protocol-level challenge lands on a stream the client is already
166
+ // tracking. Both ride the same response — order is a wire-shape
167
+ // nicety, not a correctness requirement (events are independent).
168
+ session.writeEvent('session', sid)
169
+ // Hand the session to the shared WS connection setup so it joins
170
+ // the same Peer / dispatcher / hub lifecycle as a real WebSocket.
171
+ // `setupPeerConnection` sends the protocol `challenge` frame as
172
+ // its first action; that re-uses the normal default-named SSE
173
+ // message channel.
174
+ setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
175
+ return { sid, session }
176
+ }
177
+
178
+ // Drives a POST body's `password` and `frames` through the shared
179
+ // dispatcher. The dispatcher's `ping` / `authenticate` fast paths
180
+ // handle the trivial cases inline; everything else spawns a tracked
181
+ // handler. We synthesise one synthetic frame per inbound bit so the
182
+ // dispatcher reads identically whether it's reading WS frames or
183
+ // SSE POST batches.
184
+ function dispatchBody(session: SseSession, body: SseBody): void {
185
+ // Password → synthetic `authenticate` frame so the shared
186
+ // dispatcher's existing fast path runs unchanged. Client caches
187
+ // the password and re-sends on every POST, so the first POST
188
+ // after a session takeover re-authenticates silently on the new
189
+ // replica without an extra round-trip.
190
+ if (typeof body.password === 'string' && body.password.length > 0) {
191
+ const buf = Buffer.from(JSON.stringify({ type: 'authenticate', password: body.password }), 'utf8')
192
+ session.receiveMessage(buf)
193
+ }
194
+ if (Array.isArray(body.frames)) {
195
+ for (const frame of body.frames) {
196
+ if (!frame || typeof frame !== 'object') continue
197
+ const buf = Buffer.from(JSON.stringify(frame), 'utf8')
198
+ session.receiveMessage(buf)
199
+ }
200
+ }
201
+ }
202
+
203
+ function handlePost(req: HttpRequest, res: ServerResponse, sidFromUrl: string | null): void {
204
+ if (isShuttingDown()) {
205
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
206
+ res.end(JSON.stringify({ error: 'shutting-down' }))
207
+ return
208
+ }
209
+ // Bound the body the same way `WebSocketServer({ maxPayload })`
210
+ // bounds a WS frame. Slurp into a single buffer rather than
211
+ // streaming — every protocol frame fits comfortably under
212
+ // `maxBodyBytes` (4 MiB) and the dispatcher takes one buffer at a
213
+ // time anyway. `content-length` may be missing on chunked-encoded
214
+ // requests; rely on the accumulating size check.
215
+ const chunks: Buffer[] = []
216
+ let total = 0
217
+ let aborted = false
218
+ req.on('data', (chunk: Buffer) => {
219
+ if (aborted) return
220
+ total += chunk.length
221
+ if (total > maxBodyBytes) {
222
+ aborted = true
223
+ if (debug) console.warn(`sse: POST body too large (${total} > ${maxBodyBytes})`)
224
+ res.writeHead(413, { 'content-type': 'application/json' })
225
+ res.end(JSON.stringify({ error: 'too-large' }))
226
+ try { req.destroy() } catch {}
227
+ return
228
+ }
229
+ chunks.push(chunk)
230
+ })
231
+ req.on('end', () => {
232
+ if (aborted) return
233
+ // Re-check shutdown: the entry gate at handlePost runs at request
234
+ // arrival, but the body read is async — a slow upload that
235
+ // started pre-SIGTERM can fire 'end' AFTER the lifecycle's
236
+ // sseSessions() close-loop has already iterated, and createSession
237
+ // would otherwise add a NEW session post-iteration that only
238
+ // gets force-killed by the terminate-grace timer (no graceful
239
+ // event:close frame). Bail with 503 so the client distinguishes
240
+ // shutdown from a transport error and short-circuits backoff.
241
+ if (isShuttingDown()) {
242
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
243
+ res.end(JSON.stringify({ error: 'shutting-down' }))
244
+ return
245
+ }
246
+ let body: SseBody = {}
247
+ if (chunks.length > 0) {
248
+ try {
249
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks, total).toString('utf8'))
250
+ if (parsed && typeof parsed === 'object') body = parsed as SseBody
251
+ } catch (err) {
252
+ if (debug) console.warn('sse: malformed POST body:', errMsg(err))
253
+ res.writeHead(400, { 'content-type': 'application/json' })
254
+ res.end(JSON.stringify({ error: 'bad-json' }))
255
+ return
256
+ }
257
+ }
258
+ // Look up the session by id (if the client sent one). If found
259
+ // and alive, attach the new response and dispatch. If not (or
260
+ // session is closed), mint a fresh one — the response stream
261
+ // carries the new id as the first `session` event so the client
262
+ // switches over on the next POST.
263
+ //
264
+ // Attach-then-write ordering: attachResponse runs FIRST and only
265
+ // on success do we writeSseHeaders. If attachResponse returns
266
+ // false (today only when the session transitions out of OPEN
267
+ // between the lookup and the attach — a future backpressure /
268
+ // takeover-rate path could surface that legitimately) we fall
269
+ // through to createSession with `res` still header-virgin, so
270
+ // the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
271
+ let session: SseSession | null = null
272
+ let sid: string | null = null
273
+ if (sidFromUrl) {
274
+ const existing = sessions.get(sidFromUrl)
275
+ if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
276
+ writeSseHeaders(res)
277
+ session = existing
278
+ sid = sidFromUrl
279
+ armIdleTimer(sid, session)
280
+ }
281
+ }
282
+ if (!session) {
283
+ const created = createSession(res, req)
284
+ if (!created) {
285
+ // Cap exceeded; createSession already logged. Response
286
+ // headers not yet written by writeSseHeaders, so send a
287
+ // 503 JSON instead.
288
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
289
+ res.end(JSON.stringify({ error: 'too-many-sessions' }))
290
+ return
291
+ }
292
+ session = created.session
293
+ sid = created.sid
294
+ }
295
+ dispatchBody(session, body)
296
+ // Do NOT res.end() — the response stays open as the session's
297
+ // downstream channel until the next POST takes over (or the
298
+ // client disconnects).
299
+ })
300
+ req.on('error', (err) => {
301
+ if (debug) console.warn('sse: POST stream error:', errMsg(err))
302
+ if (res.headersSent) {
303
+ try { res.destroy() } catch {}
304
+ } else {
305
+ try { res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'bad-request' })) } catch {}
306
+ }
307
+ })
308
+ }
309
+
310
+ function handle(req: HttpRequest, res: ServerResponse): boolean {
311
+ if (typeof req.url !== 'string') return false
312
+ const [path, query] = req.url.split('?', 2)
313
+ if (path !== SSE_OPEN_PATH) return false
314
+ if (req.method !== 'POST') {
315
+ // `connection: close` so a probing client (e.g. accidental GET)
316
+ // can't pipeline N more 405s on the same keep-alive socket.
317
+ // Sibling 503 paths set this for the same reason.
318
+ res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
319
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
320
+ return true
321
+ }
322
+ const sid = parseSidQuery(query)
323
+ handlePost(req, res, sid)
324
+ return true
325
+ }
326
+
327
+ return { handle, sessions: () => sessions.values() }
328
+ }
329
+
330
+ // Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
331
+ // (which decodes percent-escapes) — `randomId()` mints a 22-char
332
+ // base64url string, and the client echoes it back unchanged, so no
333
+ // escapes are possible on the legitimate path. The {1,64} bound is a
334
+ // deliberately lenient sanity gate: anything outside the base64url
335
+ // alphabet is rejected (and a missing id is treated as "no id"); a
336
+ // client that sent a sid this regex doesn't recognise just gets a
337
+ // fresh session minted by createSession, no failure mode. The wide
338
+ // length window means a future randomId-length change here doesn't
339
+ // silently break old clients that round-trip a longer or shorter
340
+ // token. Returns null on missing / malformed.
341
+ function parseSidQuery(query: string | undefined): string | null {
342
+ if (typeof query !== 'string') return null
343
+ for (const part of query.split('&')) {
344
+ const eq = part.indexOf('=')
345
+ if (eq <= 0) continue
346
+ if (part.slice(0, eq) !== 'id') continue
347
+ const v = part.slice(eq + 1)
348
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
349
+ return v
350
+ }
351
+ return null
352
+ }
@@ -0,0 +1,202 @@
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
+ export class SseSession extends EventEmitter {
32
+ static readonly CONNECTING = 0
33
+ static readonly OPEN = 1
34
+ static readonly CLOSING = 2
35
+ static readonly CLOSED = 3
36
+ // Per-instance constants so `socket.OPEN` reads identically to the
37
+ // `ws.WebSocket` shape the dispatcher and hub strict-compare against.
38
+ readonly CONNECTING = SseSession.CONNECTING
39
+ readonly OPEN = SseSession.OPEN
40
+ readonly CLOSING = SseSession.CLOSING
41
+ readonly CLOSED = SseSession.CLOSED
42
+
43
+ readyState: number = SseSession.OPEN
44
+ // The current downstream stream the session writes to. Each POST
45
+ // replaces this; broadcasts and ack frames flow on the latest one.
46
+ // Null between POSTs is *only* a transient state during swap — the
47
+ // session is created with a response in hand and the swap is
48
+ // synchronous from the dispatcher's perspective.
49
+ private currentRes: ServerResponse | null
50
+
51
+ constructor(res: ServerResponse) {
52
+ super()
53
+ this.currentRes = res
54
+ this.wireResponse(res)
55
+ }
56
+
57
+ // Wire the close / error events on a freshly-attached response. The
58
+ // close event only matters for the *current* response — a previous
59
+ // response's close (after it was swapped out) is not a session-level
60
+ // signal. Same guard applies to error: a TCP-level error during
61
+ // res.end() flush on a swapped-out response would otherwise emit a
62
+ // spurious session 'error' that operators read as a real transport
63
+ // failure on a healthy session.
64
+ private wireResponse(res: ServerResponse): void {
65
+ res.on('close', () => {
66
+ // Only the *current* response's close terminates the session. A
67
+ // swapped-out previous response closes naturally during takeover
68
+ // and must not knock the session offline.
69
+ if (res !== this.currentRes) return
70
+ if (this.readyState === SseSession.CLOSED) return
71
+ this.readyState = SseSession.CLOSED
72
+ this.currentRes = null
73
+ this.emit('close')
74
+ })
75
+ res.on('error', (err: Error) => {
76
+ // Same identity guard as close: drained-out previous responses
77
+ // may emit RST/EPIPE during flush and we don't want those to
78
+ // pseudo-fail the healthy session that's now on a new response.
79
+ if (res !== this.currentRes) return
80
+ this.emit('error', err)
81
+ })
82
+ }
83
+
84
+ // Swap in a new response (the latest POST's body). The previous
85
+ // response is end()ed cleanly so the client's reader sees EOF and
86
+ // stops draining; the takeover-grace handling for in-flight buffered
87
+ // bytes is on the client side (it keeps reading the old stream until
88
+ // EOF before switching). Returns true when the session was alive and
89
+ // the swap happened; false when the session is closed and the new
90
+ // response should be ended by the caller.
91
+ attachResponse(res: ServerResponse): boolean {
92
+ if (this.readyState !== SseSession.OPEN) return false
93
+ const prev = this.currentRes
94
+ this.currentRes = res
95
+ this.wireResponse(res)
96
+ // End the previous stream AFTER the new one is wired so any
97
+ // broadcast racing the swap lands on the new response, not on the
98
+ // half-closed old one. `end()` flushes Node's send buffer before
99
+ // sending FIN — frames already written drain to the client.
100
+ if (prev) { try { prev.end() } catch {} }
101
+ return true
102
+ }
103
+
104
+ // `hub.sendRaw` consults this before each send to decide whether to
105
+ // terminate (slow / blackholed peer). `writableLength` is the count
106
+ // of bytes queued in Node's HTTP stream that haven't drained to the
107
+ // kernel yet — the SSE-channel equivalent of `ws`'s `bufferedAmount`.
108
+ get bufferedAmount(): number {
109
+ return this.currentRes?.writableLength ?? 0
110
+ }
111
+
112
+ // Encodes one JSON frame as a single SSE event. Splits on `\n` so a
113
+ // multi-line payload still produces a well-formed event (each `data:`
114
+ // line is concatenated with `\n` by the SSE parser on the client),
115
+ // but `JSON.stringify` output won't trigger that branch.
116
+ send(payload: string | Buffer): void {
117
+ if (this.readyState !== SseSession.OPEN) return
118
+ const res = this.currentRes
119
+ if (!res) return
120
+ const text = typeof payload === 'string' ? payload : payload.toString('utf8')
121
+ const lines = text.split('\n')
122
+ const wire = `${lines.map((l) => `data: ${l}`).join('\n')}\n\n`
123
+ try { res.write(wire) } catch {}
124
+ }
125
+
126
+ // Writes a named SSE event (e.g. `session` for the continuation token
127
+ // handshake). Used by sse-server.ts on session creation; not part of
128
+ // the WebSocket-shaped surface and never called by the shared
129
+ // dispatcher.
130
+ writeEvent(event: string, data: string): void {
131
+ if (this.readyState !== SseSession.OPEN) return
132
+ const res = this.currentRes
133
+ if (!res) return
134
+ const lines = data.split('\n')
135
+ const body = `${lines.map((l) => `data: ${l}`).join('\n')}`
136
+ try { res.write(`event: ${event}\n${body}\n\n`) } catch {}
137
+ }
138
+
139
+ // Lifecycle's graceful-shutdown loop calls this with `(1001, '…')` to
140
+ // signal a server-initiated close. SSE has no native close code, so
141
+ // we emit a structured `close` event with the WS-style `{ code,
142
+ // reason }` payload — the client's transport reads it and bypasses
143
+ // its reconnect backoff (parity with the WS 1001 path).
144
+ //
145
+ // emit('close') is fired EXPLICITLY here, not via the res.on('close')
146
+ // listener: that listener short-circuits on `readyState === CLOSED`
147
+ // (so a later async res-close after we've flipped state doesn't
148
+ // double-emit), which means without the explicit emit here neither
149
+ // sse-server's dropSession cleanup nor setupPeerConnection's
150
+ // unsubscribeAll/peers.delete would run on any server-initiated
151
+ // teardown — sessions / hub.subscribers / idleTimers would leak per
152
+ // close. The wireResponse guard then ensures the later async fire
153
+ // is a no-op.
154
+ close(code?: number, reason?: string): void {
155
+ if (this.readyState === SseSession.CLOSED) return
156
+ const res = this.currentRes
157
+ if (this.readyState === SseSession.OPEN && code != null && res) {
158
+ try { res.write(`event: close\ndata: ${JSON.stringify({ code, reason: reason ?? '' })}\n\n`) } catch {}
159
+ }
160
+ this.readyState = SseSession.CLOSING
161
+ if (res) { try { res.end() } catch {} }
162
+ this.currentRes = null
163
+ this.readyState = SseSession.CLOSED
164
+ this.emit('close')
165
+ }
166
+
167
+ // Force-tear without flushing — mirrors `ws.terminate()`. Hub's
168
+ // backpressure path and the lifecycle's terminate-grace timer call
169
+ // this to drop unresponsive peers. Same explicit `emit('close')`
170
+ // story as close() above — without it, the wireResponse guard would
171
+ // swallow the later async res-close and the cleanup callbacks
172
+ // (dropSession, peers.delete, unsubscribeAll) would never run.
173
+ terminate(): void {
174
+ if (this.readyState === SseSession.CLOSED) return
175
+ this.readyState = SseSession.CLOSED
176
+ const res = this.currentRes
177
+ this.currentRes = null
178
+ if (res) { try { res.destroy() } catch {} }
179
+ this.emit('close')
180
+ }
181
+
182
+ // The heartbeat sweep ping()s every WS client to detect dead sockets
183
+ // via the unanswered-pong path. SSE has no `pong` equivalent, so we
184
+ // write a comment line that keeps the channel alive across proxies
185
+ // without expecting a reply. The per-session idle timeout in
186
+ // sse-server.ts owns the dead-client detection.
187
+ ping(): void {
188
+ if (this.readyState !== SseSession.OPEN) return
189
+ const res = this.currentRes
190
+ if (!res) return
191
+ try { res.write(':\n\n') } catch {}
192
+ }
193
+
194
+ // Injects a client-to-server frame from the POST plane into the same
195
+ // `message` event the dispatcher already listens for on real WSs.
196
+ // `isBinary=false` because the SSE+POST plane is JSON-only by
197
+ // contract — the binary-frame drop in the dispatcher reads this.
198
+ receiveMessage(data: Buffer): void {
199
+ if (this.readyState !== SseSession.OPEN) return
200
+ this.emit('message', data, false)
201
+ }
202
+ }
@@ -29,6 +29,13 @@ export type SyncHandlersDeps = {
29
29
  handle: Handle
30
30
  send: (socket: WebSocket, msg: object) => void
31
31
  broadcast: (tag: string, msg: object, except: WebSocket | null) => void
32
+ // Cross-instance pub/sub for live broadcasts. Fired alongside the
33
+ // local `broadcast` after a successful commit so peers on OTHER
34
+ // server instances see the new revision in real time. Carries only
35
+ // `(tag, revisionId)` — the receiver re-fetches the row from
36
+ // workspace_revision because the ciphertext can exceed the bus's
37
+ // payload budget. Optional: a SQLite deployment passes a no-op.
38
+ publishRevision: (tag: string, revisionId: string) => void
32
39
  subscribe: (socket: WebSocket, tag: string) => void
33
40
  getNonce: (socket: WebSocket) => string | undefined
34
41
  requiresAuth: (socket: WebSocket) => boolean
@@ -53,7 +60,7 @@ export type SyncHandlers = {
53
60
  }
54
61
 
55
62
  export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
56
- const { handle, send, broadcast, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
63
+ const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
57
64
 
58
65
  // Typed wrapper for the three `workspace-save-error` emit sites
59
66
  // (too-large at handleSave, stale-base after the catch-up, busy at
@@ -242,6 +249,15 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
242
249
  signature: msg.signature,
243
250
  }],
244
251
  }, socket)
252
+ // Cross-instance fan-out. The bus payload carries only the revision
253
+ // id — peers on OTHER instances re-fetch the row from
254
+ // workspace_revision to compose their local `workspace-state`. Sized
255
+ // for the bus's 8 KB payload budget, which can't carry a 2 MiB
256
+ // ciphertext. SQLite mode passes a no-op; Neon mode publishes via
257
+ // pg_notify. Best-effort: a dropped publish only means peers on
258
+ // other instances miss the live push, but they still catch up via
259
+ // the shared DB on their next subscribe / reconnect.
260
+ publishRevision(tag, id)
245
261
  }
246
262
 
247
263
  async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {