@preventive/triage 1.0.0-alpha.7 → 1.0.0-alpha.9
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/common/save-error-reason.ts +20 -7
- package/out/client-sync.js +14 -14
- package/out/graph.js +5 -4
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +56 -53
- package/package.json +1 -1
- package/server/sse-server.ts +57 -30
- package/server/sync-handlers.ts +8 -0
package/package.json
CHANGED
package/server/sse-server.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
//
|
|
9
9
|
// One route, POSTs only:
|
|
10
10
|
//
|
|
11
|
-
// POST /api/sync/sse
|
|
11
|
+
// POST /api/sync/sse
|
|
12
12
|
// Request body:
|
|
13
|
-
// {
|
|
13
|
+
// { id?: string, — session continuation token
|
|
14
|
+
// password?: string, — cached client password
|
|
14
15
|
// frames?: Array<protocol-frame> — WS-style JSON frames
|
|
15
16
|
// }
|
|
16
17
|
// Response:
|
|
@@ -25,14 +26,23 @@
|
|
|
25
26
|
// carrying the WS protocol's JSON envelopes.
|
|
26
27
|
//
|
|
27
28
|
// Continuation: each POST replaces the previous POST's response as the
|
|
28
|
-
// session's downstream channel. If the
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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.
|
|
36
46
|
//
|
|
37
47
|
// Why POSTs only: a long-lived GET pins the client to one replica via
|
|
38
48
|
// TCP affinity, which would mean POSTs from the same client must be
|
|
@@ -106,8 +116,10 @@ export type SseServer = {
|
|
|
106
116
|
// Inbound POST body. Every field optional — an empty-body POST is a
|
|
107
117
|
// valid "wake the session" probe (the response stream rides on every
|
|
108
118
|
// POST), and a body that carries only `password` or only `frames` is
|
|
109
|
-
// a normal partial update.
|
|
119
|
+
// a normal partial update. `id` is the session continuation token
|
|
120
|
+
// (see the header's "Continuation" note for why it rides the body).
|
|
110
121
|
type SseBody = {
|
|
122
|
+
id?: unknown
|
|
111
123
|
password?: unknown
|
|
112
124
|
frames?: unknown
|
|
113
125
|
}
|
|
@@ -116,12 +128,13 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
116
128
|
const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
|
|
117
129
|
|
|
118
130
|
// Active SSE sessions, keyed by the random session id `createSession`
|
|
119
|
-
// mints on the first POST that
|
|
120
|
-
// replica doesn't recognise) and that subsequent POSTs echo
|
|
121
|
-
// continue the session. Bounded by
|
|
122
|
-
// POSTs get a 503. POSTs against an
|
|
123
|
-
// mint a fresh session instead, so a
|
|
124
|
-
// require sticky LB routing to
|
|
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.
|
|
125
138
|
const sessions = new Map<string, SseSession>()
|
|
126
139
|
|
|
127
140
|
function dropSession(sid: string): void {
|
|
@@ -251,6 +264,12 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
251
264
|
return
|
|
252
265
|
}
|
|
253
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
|
|
254
273
|
// Look up the session by id (if the client sent one). If found
|
|
255
274
|
// and alive, attach the new response and dispatch. If not (or
|
|
256
275
|
// session is closed), mint a fresh one — the response stream
|
|
@@ -265,8 +284,8 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
265
284
|
// through to createSession with `res` still header-virgin, so
|
|
266
285
|
// the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
|
|
267
286
|
let session: SseSession | null = null
|
|
268
|
-
if (
|
|
269
|
-
const existing = sessions.get(
|
|
287
|
+
if (sid) {
|
|
288
|
+
const existing = sessions.get(sid)
|
|
270
289
|
if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
|
|
271
290
|
writeSseHeaders(res)
|
|
272
291
|
session = existing
|
|
@@ -333,25 +352,33 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
333
352
|
return { handle, sessions: () => sessions.values(), keepaliveTimer }
|
|
334
353
|
}
|
|
335
354
|
|
|
336
|
-
//
|
|
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
|
|
337
372
|
// (which decodes percent-escapes) — `randomId()` mints a 22-char
|
|
338
373
|
// base64url string echoed back unchanged, so no escapes are possible
|
|
339
|
-
// on the legitimate path.
|
|
340
|
-
// sanity gate: anything outside the base64url alphabet is rejected; an
|
|
341
|
-
// unrecognised sid just gets a fresh session from createSession (no
|
|
342
|
-
// failure mode), and the wide length window keeps a future
|
|
343
|
-
// randomId-length change from silently breaking old clients that
|
|
344
|
-
// round-trip a longer/shorter token. Returns null on missing /
|
|
345
|
-
// malformed.
|
|
374
|
+
// on the legitimate path.
|
|
346
375
|
function parseSidQuery(query: string | undefined): string | null {
|
|
347
376
|
if (typeof query !== 'string') return null
|
|
348
377
|
for (const part of query.split('&')) {
|
|
349
378
|
const eq = part.indexOf('=')
|
|
350
379
|
if (eq <= 0) continue
|
|
351
380
|
if (part.slice(0, eq) !== 'id') continue
|
|
352
|
-
|
|
353
|
-
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
|
|
354
|
-
return v
|
|
381
|
+
return parseSid(part.slice(eq + 1))
|
|
355
382
|
}
|
|
356
383
|
return null
|
|
357
384
|
}
|
package/server/sync-handlers.ts
CHANGED
|
@@ -238,6 +238,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
238
238
|
// chain in the 409 body. Either way the catch-up clears the client's
|
|
239
239
|
// pending and the error is a no-op on the now-missing pending (a
|
|
240
240
|
// recoverable race — client rebases + re-saves).
|
|
241
|
+
//
|
|
242
|
+
// The catch-up CAN be empty: a client holding a base from a chain
|
|
243
|
+
// this deployment no longer has (wiped / moved DB, SQLite→Neon
|
|
244
|
+
// migration — head=null, no keyframe, no rows) gets `revisions: []`.
|
|
245
|
+
// The client detects that shape (pending survives the catch-up) and
|
|
246
|
+
// answers with a full-state push re-anchored at base=null, which
|
|
247
|
+
// commits as the new chain root — see the client's
|
|
248
|
+
// `handleSaveError` stale-base branch.
|
|
241
249
|
const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
|
|
242
250
|
if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
|
|
243
251
|
return { kind: 'stale-base', base: baseNorm, revisions }
|