@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,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.
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
//
|
|
174
|
-
// the
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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 = [
|