@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.3
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/out/client-sync.js +11 -11
- package/out/view.js +49 -48
- package/package.json +2 -2
- package/server/bus-receiver.ts +7 -7
- package/server/config.ts +4 -4
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +22 -26
- package/server/objstore/blob-fs.ts +4 -6
- package/server/objstore/blob-vercel.ts +54 -42
- package/server/objstore/blob.ts +16 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/rest.ts +60 -68
- package/server/objstore/store-neon.ts +4 -4
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/ws-server.ts +9 -14
package/server/objstore/store.ts
CHANGED
|
@@ -79,9 +79,8 @@ const SCHEMA = `
|
|
|
79
79
|
|
|
80
80
|
// One LIVE row, exactly the shape the `workspace-subscribed` ack's
|
|
81
81
|
// `resources` array carries on the wire, minus `keyframe`-style
|
|
82
|
-
// server-only flags. `put_at` is a
|
|
83
|
-
//
|
|
84
|
-
// it via the DB but the server never volunteers it.
|
|
82
|
+
// server-only flags. `put_at` is a debug aid the wire format never
|
|
83
|
+
// includes — inspectable via the DB but the server never volunteers it.
|
|
85
84
|
export type ObjectRow = {
|
|
86
85
|
resourceTag: string
|
|
87
86
|
version: number
|
|
@@ -118,13 +117,11 @@ export type BeginPutInput = {
|
|
|
118
117
|
// PUT will reference. `workspace-full` is the per-workspace resource-
|
|
119
118
|
// count cap rejection — see `MAX_RESOURCES_PER_WORKSPACE`.
|
|
120
119
|
//
|
|
121
|
-
// `filePath` is
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// Tests for the FS path use it as a convenience to write fixture
|
|
127
|
-
// bytes directly to the staging slot.
|
|
120
|
+
// `filePath` is the absolute on-disk staging path, set only for the
|
|
121
|
+
// FS handle; the Vercel handle omits it ("path" is meaningless against
|
|
122
|
+
// a remote store). Production (rest.ts) never reads it — it goes
|
|
123
|
+
// through `handle.blob.openStagingWriter(tag, sid)`. FS-path tests use
|
|
124
|
+
// it as a convenience to write fixture bytes to the staging slot.
|
|
128
125
|
export type BeginPutResult =
|
|
129
126
|
| { ok: true; stagingId: string; filePath?: string }
|
|
130
127
|
| { ok: false; reason: 'conflict'; conflict: ObjectRow | null }
|
|
@@ -134,17 +131,14 @@ export type CommitPutInput = {
|
|
|
134
131
|
workspaceTag: string
|
|
135
132
|
resourceTag: string
|
|
136
133
|
stagingId: string
|
|
137
|
-
// Optional
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// (this PUT) has already completed. Tests that drive commitPut
|
|
146
|
-
// directly without the REST layer should omit this and let
|
|
147
|
-
// commitPut stat for itself.
|
|
134
|
+
// Optional storage-side byte count the caller already verified
|
|
135
|
+
// post-upload. When provided, commitPut skips the redundant
|
|
136
|
+
// `statStaging` round-trip — one fewer Vercel HEAD per PUT. Caller
|
|
137
|
+
// must ONLY pass a value it observed for THIS stagingId after its
|
|
138
|
+
// upload finished (the REST PUT path's post-upload `statStaging`).
|
|
139
|
+
// Safe without a lock: staging ids are random (no other request
|
|
140
|
+
// writes this blob) and the sole writer (this PUT) has finished.
|
|
141
|
+
// Tests driving commitPut directly omit this and let it stat.
|
|
148
142
|
observedSize?: number
|
|
149
143
|
}
|
|
150
144
|
|
|
@@ -181,9 +175,9 @@ type DbRow = {
|
|
|
181
175
|
// - the reaper's age grace window + an atomic conditional staging
|
|
182
176
|
// delete (`deleteStagingIfStale`) so the stale-staging sweep
|
|
183
177
|
// can't race an upload that just finished.
|
|
184
|
-
// These hold both within a single process AND across replicas
|
|
185
|
-
//
|
|
186
|
-
// content-addressing
|
|
178
|
+
// These hold both within a single process AND across replicas, so no
|
|
179
|
+
// per-(tag, resourceTag) in-process mutex is needed — the CAS +
|
|
180
|
+
// content-addressing already cover everything one would.
|
|
187
181
|
export type Handle = {
|
|
188
182
|
// SQLite-only: the underlying `DatabaseSync`. Unset on the Neon
|
|
189
183
|
// backend (see ./store-neon.ts). Test-only fixture SQL routes
|
|
@@ -212,15 +206,14 @@ export type Handle = {
|
|
|
212
206
|
selectStagingByWsSid: GetStmt<[string, string], unknown>
|
|
213
207
|
refreshStagingBegunAt: RunStmt<[number, string, string, string]>
|
|
214
208
|
deleteStaging: RunStmt<[string, string, string]>
|
|
215
|
-
// Atomic conditional staging delete
|
|
209
|
+
// Atomic conditional staging delete for the reaper's stale-row
|
|
216
210
|
// sweep. `deleteStagingIfStale(tag, res, sid, staleBefore)` deletes
|
|
217
211
|
// the row IFF its `begun_at < staleBefore`, returning `{ ok: 1 }`
|
|
218
|
-
// when a row was actually removed and `undefined` otherwise.
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// fails atomically) and the row survives for the commit. Mirrors the
|
|
212
|
+
// when a row was actually removed and `undefined` otherwise. Lock-
|
|
213
|
+
// free race guard (PR #4 "F1"): a slow PUT that finishes calls
|
|
214
|
+
// `refreshStagingBegunAt` to bump `begun_at` fresh, so a concurrent
|
|
215
|
+
// reaper's conditional delete doesn't match (predicate fails
|
|
216
|
+
// atomically) and the row survives for the commit. Mirrors the
|
|
224
217
|
// `insertLiveIfAbsent` RETURNING pattern.
|
|
225
218
|
deleteStagingIfStale: GetStmt<[string, string, string, number], { ok: number }>
|
|
226
219
|
selectLive: AllStmt<[string], DbRow>
|
|
@@ -279,9 +272,6 @@ export type SqliteHandle = Handle & { db: DatabaseSync }
|
|
|
279
272
|
// transient over-shoot under high concurrency across DIFFERENT
|
|
280
273
|
// resources is bounded by `(parallel new-resource begins - 1)` and is
|
|
281
274
|
// accepted (the cap is a soft policy bound, not a security invariant).
|
|
282
|
-
// This was already the contract under the old per-resource lock —
|
|
283
|
-
// that lock keyed on (tag, resourceTag), so concurrent NEW-resource
|
|
284
|
-
// begins held DIFFERENT locks and raced the count anyway.
|
|
285
275
|
export const MAX_RESOURCES_PER_WORKSPACE = 100
|
|
286
276
|
|
|
287
277
|
// Per-upload byte cap, shared by the WS plane (rejects oversize
|
|
@@ -372,12 +362,10 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
|
|
|
372
362
|
const blob = openFsBlobBackend(dir)
|
|
373
363
|
// No `close` method on the returned Handle: the underlying
|
|
374
364
|
// `DatabaseSync` is owned by the caller (in production, the
|
|
375
|
-
// workspace_revision handle in `server/db.ts`,
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
// either no-op'd (production) or left the connection open
|
|
380
|
-
// (tests construct their own DB and `db.close()` separately).
|
|
365
|
+
// workspace_revision handle in `server/db.ts`, closed from
|
|
366
|
+
// `shutdown()`; tests close their own DB). A `close()` here would
|
|
367
|
+
// mislead — it could only no-op or leak, never close the caller-
|
|
368
|
+
// owned connection.
|
|
381
369
|
return {
|
|
382
370
|
db,
|
|
383
371
|
blob,
|
|
@@ -522,11 +510,10 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
|
|
|
522
510
|
if (liveVersion !== input.prevVersion || liveIncarnation !== input.prevIncarnation) {
|
|
523
511
|
return { ok: false, reason: 'conflict', conflict: live }
|
|
524
512
|
}
|
|
525
|
-
// Per-workspace resource cap
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// bound, accepted to over-shoot under concurrent NEW-resource begins.
|
|
513
|
+
// Per-workspace resource cap, NEW resources only — re-uploads
|
|
514
|
+
// (live != null) don't change the count, so they're always allowed.
|
|
515
|
+
// Not atomic with the insert below; soft bound, see
|
|
516
|
+
// MAX_RESOURCES_PER_WORKSPACE.
|
|
530
517
|
if (!live) {
|
|
531
518
|
const count = await handle.countLive.get(input.workspaceTag) as { c: number } | undefined
|
|
532
519
|
if ((count?.c ?? 0) >= MAX_RESOURCES_PER_WORKSPACE) {
|
|
@@ -557,51 +544,48 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
|
|
|
557
544
|
// IF ABSENT; a re-upload bumps the version IFF it still matches the
|
|
558
545
|
// precondition we read. Exactly one of N racing commits wins the CAS;
|
|
559
546
|
// the losers get `conflict` (with the current live row) and rebase.
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
// reaper-cleaned, never row-pointing-at-nothing" crash-safety contract.
|
|
547
|
+
// Each PUT is content-addressed at its OWN hash (distinct PUTs get
|
|
548
|
+
// distinct hashes — random nonce per encrypt), so N racers promote to
|
|
549
|
+
// N DIFFERENT immutable paths: no promote clobbers another's bytes, a
|
|
550
|
+
// loser's blob is just left unreferenced for the GC, and there's no
|
|
551
|
+
// metadata-vs-bytes desync to guard. The CAS gives atomicity both
|
|
552
|
+
// within a process and across replicas, so NO in-process lock is taken.
|
|
553
|
+
// A crash between the promote and the CAS leaves the staging blob/row
|
|
554
|
+
// intact alongside (at most) a stranded, unreferenced live blob — the
|
|
555
|
+
// stale-staging sweep cleans the row, the GC reaps the blob once past
|
|
556
|
+
// the grace window: the "stranded state, reaper-cleaned, never row-
|
|
557
|
+
// pointing-at-nothing" crash-safety contract.
|
|
572
558
|
//
|
|
573
|
-
// ACCEPTED TRADEOFF (lock removal):
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
// staging
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// the TTL
|
|
583
|
-
//
|
|
584
|
-
// line").
|
|
559
|
+
// ACCEPTED TRADEOFF (lock removal): without a lock the reaper no longer
|
|
560
|
+
// waits for an in-flight upload on this key (the old lock blocked it on
|
|
561
|
+
// any in-flight upload, unbounded). An upload taking >1h FROM BEGIN
|
|
562
|
+
// (exceeding the staging TTL during the body) can have its staging row
|
|
563
|
+
// reaped mid-flight by `deleteStagingIfStale` → this commit sees no
|
|
564
|
+
// staging row → `no-staging` → REST 410. Sub-1h uploads are unaffected:
|
|
565
|
+
// `begun_at` (set at begin) stays within the TTL through the body, so
|
|
566
|
+
// the conditional delete can't match, and the after-body
|
|
567
|
+
// `refreshStagingBegunAt` re-extends the TTL to cover this commit step —
|
|
568
|
+
// matching the staging TTL's intent ("1h, comfortably over a 50 MiB
|
|
569
|
+
// upload on a slow line").
|
|
585
570
|
export async function commitPut(handle: Handle, input: CommitPutInput): Promise<CommitPutResult> {
|
|
586
571
|
const staging = await handle.selectStaging.get(input.workspaceTag, input.resourceTag, input.stagingId)
|
|
587
572
|
if (!staging) return { ok: false, reason: 'no-staging' }
|
|
588
573
|
let stagedSize: number | null
|
|
589
574
|
// statStaging failure here is a server-side issue (staging file
|
|
590
|
-
//
|
|
575
|
+
// unlinked by a racing abort / reaper, EACCES, EIO, backend
|
|
591
576
|
// unreachable, …) — not a client length-mismatch. Route through
|
|
592
577
|
// `io-error` so the REST layer returns 5xx, not 400. PR #4 review.
|
|
593
578
|
//
|
|
594
579
|
// The REST PUT layer already statted the staging blob post-upload
|
|
595
580
|
// and threads the result in via `observedSize` — skipping the
|
|
596
|
-
// round-trip saves one Vercel HEAD per PUT. The
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
// `observedSize` fall through to the explicit stat.
|
|
581
|
+
// round-trip saves one Vercel HEAD per PUT. The blob can't have been
|
|
582
|
+
// resized between that stat and here: staging ids are 16-byte random
|
|
583
|
+
// (no other request targets this blob) and the sole writer (this
|
|
584
|
+
// PUT's upload pipeline) has finished. The only other actor on a
|
|
585
|
+
// staging blob is the reaper, which UNLINKS (never resizes); a racing
|
|
586
|
+
// reaper unlink surfaces below as statStaging→io-error or a promote
|
|
587
|
+
// failure, not a wrong size. WS / test paths that omit `observedSize`
|
|
588
|
+
// fall through to the explicit stat.
|
|
605
589
|
if (input.observedSize === undefined) {
|
|
606
590
|
try { stagedSize = await handle.blob.statStaging(input.workspaceTag, input.stagingId) }
|
|
607
591
|
catch { return { ok: false, reason: 'io-error' } }
|
|
@@ -637,15 +621,14 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
|
|
|
637
621
|
}
|
|
638
622
|
// Promote to the CONTENT-ADDRESSED live path `${tag}/${hash}.bin`.
|
|
639
623
|
// `promoteStagingToLive` returns false on any backend error (FS:
|
|
640
|
-
// EACCES / ENOSPC / EIO / a racing abort that already unlinked
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
// concurrent commits to the same resource write to DIFFERENT paths.)
|
|
624
|
+
// EACCES / ENOSPC / EIO / a racing abort that already unlinked the
|
|
625
|
+
// staging file; Vercel: copy failure) → 'io-error' → REST HTTP 500
|
|
626
|
+
// (server-side fault, not client-fixable). The destination path IS
|
|
627
|
+
// the content hash, so any write to it is byte-identical BY
|
|
628
|
+
// CONSTRUCTION: a retried or racing promote to the same path is an
|
|
629
|
+
// idempotent rewrite, never a clobber. (Distinct PUTs get distinct
|
|
630
|
+
// hashes — fresh random nonce per encrypt — so concurrent commits to
|
|
631
|
+
// the same resource write to DIFFERENT paths.)
|
|
649
632
|
if (!await handle.blob.promoteStagingToLive(input.workspaceTag, input.stagingId, staging.content_hash)) {
|
|
650
633
|
return { ok: false, reason: 'io-error' }
|
|
651
634
|
}
|
|
@@ -658,13 +641,12 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
|
|
|
658
641
|
const committedIncarnation = staging.prev_version == null ? freshIncarnation : staging.prev_incarnation!
|
|
659
642
|
const putAt = Date.now()
|
|
660
643
|
// Version-CAS in try/catch so a Neon transient (5xx, network
|
|
661
|
-
// hiccup,
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// GC) but the surface is a 500 the caller can retry.
|
|
644
|
+
// hiccup, pool exhaustion) doesn't throw out of commitPut and bypass
|
|
645
|
+
// the abortPut ladder. A thrown rejection would skip the REST layer's
|
|
646
|
+
// `if (!r.ok) abortPut` branch and bubble to handleRest's outer catch
|
|
647
|
+
// — live blob already promoted, staging blob + row left behind. The
|
|
648
|
+
// reaper reconciles (stale-staging sweep + unreferenced-blob GC); the
|
|
649
|
+
// surface is a 500 the caller can retry.
|
|
668
650
|
let won: { ok: number } | undefined
|
|
669
651
|
try {
|
|
670
652
|
if (staging.prev_version == null) {
|
|
@@ -753,30 +735,28 @@ export async function abortPut(handle: Handle, tag: string, resourceTag: string,
|
|
|
753
735
|
// `deletedVersion = 0` sentinel tells the broadcast path to skip.
|
|
754
736
|
//
|
|
755
737
|
// No lock: the drop is a version-CAS (`deleteLiveCAS` — DELETE WHERE
|
|
756
|
-
// version = prev), so every race resolves to exactly one winner
|
|
757
|
-
//
|
|
738
|
+
// version = prev), so every race resolves to exactly one winner with
|
|
739
|
+
// no lost update:
|
|
758
740
|
// - delete vs. a concurrent COMMIT on the same resource: whichever
|
|
759
741
|
// CAS lands first wins. A stale delete can NOT remove a row the
|
|
760
|
-
// commit just bumped —
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
// `conflict`. (
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// version-CAS closes that.)
|
|
742
|
+
// commit just bumped — `WHERE version = prev` no longer matches, so
|
|
743
|
+
// the delete gets `conflict` (re-read sees the bumped version). If
|
|
744
|
+
// the delete wins, the commit's `updateLiveCAS` matches no row →
|
|
745
|
+
// `conflict`. (An UNCONDITIONAL delete here would let `getLive`
|
|
746
|
+
// read v1, a commit bump to v2 slip in, then the delete destroy v2:
|
|
747
|
+
// a lost update. The version-CAS closes that.)
|
|
767
748
|
// - two concurrent deletes with the same prevVersion: one CAS removes
|
|
768
749
|
// the row; the other matches no row → re-read → `not-found`.
|
|
769
750
|
//
|
|
770
751
|
// On success we ONLY drop the live row — we do NOT unlink the live
|
|
771
|
-
// blob.
|
|
752
|
+
// blob. Reclamation is deferred to the reaper's grace-window GC
|
|
772
753
|
// (unlinks once no live row references the hash AND it's older than the
|
|
773
|
-
// grace window)
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
// effectively 1:1; this delete simply orphans the blob for the GC.
|
|
754
|
+
// grace window) so the drop stays lock-free and can't race a concurrent
|
|
755
|
+
// commit's promote→CAS window (a just-promoted blob isn't referenced
|
|
756
|
+
// yet — the age grace protects it) or an in-flight GET still streaming
|
|
757
|
+
// the bytes. NOT because the hash might be shared — distinct PUTs get
|
|
758
|
+
// distinct hashes (random nonce → unique ciphertext), so the hash↔row
|
|
759
|
+
// mapping is effectively 1:1; this delete simply orphans the blob.
|
|
780
760
|
export async function deleteObject(
|
|
781
761
|
handle: Handle, tag: string, resourceTag: string, prevVersion: number | null, prevIncarnation: string | null,
|
|
782
762
|
): Promise<DeleteResult> {
|
|
@@ -112,16 +112,14 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
112
112
|
const o = v as Record<string, unknown>
|
|
113
113
|
if (typeof o['tag'] !== 'string' || typeof o['res'] !== 'string') return false
|
|
114
114
|
// `Number.isSafeInteger` over `Number.isInteger`: `exp` is compared
|
|
115
|
-
// to `Date.now()` (
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// codebase's safe-int gates (server/objstore/sign.ts,
|
|
124
|
-
// server/objstore/rest.ts, server/objstore/handlers.ts).
|
|
115
|
+
// to `Date.now()` (a safe int), `len` against the Content-Length the
|
|
116
|
+
// REST layer parsed (itself `isSafeInteger`-gated), and `ver` against
|
|
117
|
+
// SQLite's INTEGER (capacity 2^63). An unsafe-but-integer value in the
|
|
118
|
+
// token round-trips through IEEE-754 and could spoof equality with a
|
|
119
|
+
// different actual value, or — for `exp` — claim a
|
|
120
|
+
// `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
|
|
121
|
+
// the IEEE-754 boundary. Matches the codebase's other safe-int gates
|
|
122
|
+
// (server/objstore/sign.ts, rest.ts, handlers.ts).
|
|
125
123
|
if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
|
|
126
124
|
if (o['op'] === 'put') {
|
|
127
125
|
return typeof o['sid'] === 'string'
|
|
@@ -138,8 +136,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
|
|
|
138
136
|
return false
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
//
|
|
142
|
-
// the payload shape inline.
|
|
139
|
+
// Constructors so the WS handler doesn't assemble the payload inline.
|
|
143
140
|
export function mintPutToken(
|
|
144
141
|
secret: TokenSecret,
|
|
145
142
|
tag: string, res: string, sid: string, len: number,
|
package/server/peer.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// Per-connection server state. One `Peer` per accepted WebSocket
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// few cross-function call sites look it up via `peers.get(socket)`.
|
|
1
|
+
// Per-connection server state. One `Peer` per accepted WebSocket. The
|
|
2
|
+
// connection handler constructs one on accept and holds it in a
|
|
3
|
+
// closure, so the hot paths (message dispatch, pong, close) touch
|
|
4
|
+
// fields directly; the few cross-function call sites look it up via
|
|
5
|
+
// `peers.get(socket)`.
|
|
7
6
|
//
|
|
8
7
|
// Held in a `WeakMap<WebSocket, Peer>` so a closed socket's state GCs
|
|
9
8
|
// with the socket; the close handler also `delete`s it explicitly
|
|
@@ -17,9 +16,8 @@ export class Peer {
|
|
|
17
16
|
// `workspace-subscribe` signature — blocks cross-connection replay
|
|
18
17
|
// of a captured subscribe frame.
|
|
19
18
|
readonly challenge: string
|
|
20
|
-
// Password-gate
|
|
21
|
-
//
|
|
22
|
-
// bypasses the new-workspace gate. `false` until then.
|
|
19
|
+
// Password-gate flag. Once the `authenticate` handshake succeeds,
|
|
20
|
+
// first-actions on this socket bypass the new-workspace gate.
|
|
23
21
|
authorized = false
|
|
24
22
|
// Heartbeat liveness. The sweep flips it `false` after each `ping()`;
|
|
25
23
|
// the `pong` listener flips it back. A socket still `false` on the
|
package/server/pubsub.ts
CHANGED
|
@@ -169,9 +169,8 @@ type NeonState = {
|
|
|
169
169
|
pendingHandlers: Set<Promise<void>>
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
// Notification dispatch
|
|
173
|
-
//
|
|
174
|
-
// publish round-trip (Postgres NOTIFY delivers to publishers too).
|
|
172
|
+
// Notification dispatch. Filters foreign channels (defensive) and our
|
|
173
|
+
// own publish round-trip (Postgres NOTIFY delivers to publishers too).
|
|
175
174
|
function dispatchNotification(state: NeonState, n: { channel: string; payload?: string }): void {
|
|
176
175
|
if (n.channel !== CHANNEL) return
|
|
177
176
|
if (typeof n.payload !== 'string') return
|
|
@@ -187,24 +186,19 @@ function dispatchNotification(state: NeonState, n: { channel: string; payload?:
|
|
|
187
186
|
// notification dispatch (which would queue further notifications
|
|
188
187
|
// behind it). Errors are logged but don't kill the loop — a missed
|
|
189
188
|
// broadcast surfaces to clients on reconnect via the chain re-pull.
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// the DB handle. Without the tracking, `onBusMessage`'s DB queries
|
|
194
|
-
// (`handle.revisionById.get` / `getLive`) could race
|
|
195
|
-
// `handle.close()` and throw inside a half-settled handler.
|
|
189
|
+
// Tracked in `state.pendingHandlers` so `stop()` drains in-flight
|
|
190
|
+
// handlers before the lifecycle closes the DB handle (see that
|
|
191
|
+
// field's doc).
|
|
196
192
|
const promise: Promise<void> = fn(msg).catch((err) => {
|
|
197
193
|
console.warn('pubsub: handler error:', errStack(err))
|
|
198
194
|
}).finally(() => { state.pendingHandlers.delete(promise) })
|
|
199
195
|
state.pendingHandlers.add(promise)
|
|
200
196
|
}
|
|
201
197
|
|
|
202
|
-
// Single connect attempt. Resolves once LISTEN is registered,
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// must work mid-handshake, and `stop()` must be able to abort a hung
|
|
207
|
-
// connect by reading `state.client?.end()`.
|
|
198
|
+
// Single connect attempt. Resolves once LISTEN is registered, rejects
|
|
199
|
+
// on transport / LISTEN failure. Assigns `state.client = c` EAGERLY
|
|
200
|
+
// (before awaiting `c.connect()`) for the invariants on
|
|
201
|
+
// `NeonState.client`.
|
|
208
202
|
async function tryConnect(state: NeonState): Promise<void> {
|
|
209
203
|
const c = state.newClient()
|
|
210
204
|
state.client = c
|
|
@@ -221,13 +215,11 @@ async function tryConnect(state: NeonState): Promise<void> {
|
|
|
221
215
|
})
|
|
222
216
|
await c.query(`LISTEN ${CHANNEL}`)
|
|
223
217
|
} catch (err) {
|
|
224
|
-
// Clear `state.client` only if it still points at OUR client
|
|
218
|
+
// Clear `state.client` only if it still points at OUR client — a
|
|
225
219
|
// racing `stop()` may have already null'd it and ended the
|
|
226
|
-
// half-connected socket
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// call is a harmless no-op. The try/catch additionally absorbs
|
|
230
|
-
// any rejection from end() itself.
|
|
220
|
+
// half-connected socket; don't clobber that. The `c.end()` below
|
|
221
|
+
// may then be a redundant second call, but pg-style Client.end()
|
|
222
|
+
// is idempotent (and the try/catch absorbs any rejection).
|
|
231
223
|
if (state.client === c) state.client = null
|
|
232
224
|
try { await c.end() } catch {}
|
|
233
225
|
throw err
|
|
@@ -236,8 +228,7 @@ async function tryConnect(state: NeonState): Promise<void> {
|
|
|
236
228
|
|
|
237
229
|
// Outer (re)connect loop. Exits silently on `state.stopped`; otherwise
|
|
238
230
|
// retries with exponential backoff after a connect / LISTEN failure.
|
|
239
|
-
// `tryConnect`
|
|
240
|
-
// rationale on `NeonState.client`), so this loop only counts attempts
|
|
231
|
+
// `tryConnect` owns `state.client`, so this loop only counts attempts
|
|
241
232
|
// and runs the backoff sleep.
|
|
242
233
|
async function connectAndListen(state: NeonState): Promise<void> {
|
|
243
234
|
// oxlint-disable-next-line no-unmodified-loop-condition
|
|
@@ -262,10 +253,9 @@ async function connectAndListen(state: NeonState): Promise<void> {
|
|
|
262
253
|
// default) waiting on the timer. We stash the cancel callback on the
|
|
263
254
|
// shared state object so `stop()` can fire it; the loop's
|
|
264
255
|
// `if (state.stopped) return` then exits on the next turn. The
|
|
265
|
-
// `settled` flag guards
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// stricter and the guard documents the mutual exclusion explicitly).
|
|
256
|
+
// `settled` flag guards the timer-vs-`stop()` wake-up race: resolves
|
|
257
|
+
// are idempotent at runtime, but it also satisfies oxlint's stricter
|
|
258
|
+
// `no-multiple-resolved` and documents the mutual exclusion.
|
|
269
259
|
function cancellableSleep(state: NeonState, ms: number): Promise<void> {
|
|
270
260
|
return new Promise((resolve) => {
|
|
271
261
|
let settled = false
|
package/server/sign.ts
CHANGED
|
@@ -55,15 +55,6 @@ export type SubscribeMsg = {
|
|
|
55
55
|
// `Uint8Array<ArrayBuffer>` (not `<ArrayBufferLike>`) so the bytes
|
|
56
56
|
// thread directly into `crypto.subtle.digest` — `BufferSource`
|
|
57
57
|
// rejects SharedArrayBuffer-backed views.
|
|
58
|
-
//
|
|
59
|
-
// NOTE: `verifySaveSigAndCanonical` is a test-friendly composition
|
|
60
|
-
// helper. Production `handleSave` in `server/index.ts` does NOT
|
|
61
|
-
// call it — it composes `canonicalSave` +
|
|
62
|
-
// `computeRevisionIdFromCanonical` + `verifyEd25519` separately so
|
|
63
|
-
// the dup-precheck (`revisionExists`) can fire BEFORE the Ed25519
|
|
64
|
-
// verify, closing the round-9 H1 CPU-DoS vector where a passive
|
|
65
|
-
// observer floods captured saves. The wrapper exists for unit
|
|
66
|
-
// tests that don't need the precheck ordering.
|
|
67
58
|
export type VerifyResult =
|
|
68
59
|
| { ok: true; canonical: Uint8Array<ArrayBuffer> }
|
|
69
60
|
| { ok: false; canonical: null }
|
|
@@ -120,7 +111,14 @@ function canonicalSubscribe(
|
|
|
120
111
|
{ workspaceTag, from }: SubscribeMsg,
|
|
121
112
|
connectionNonce: string,
|
|
122
113
|
): Uint8Array<ArrayBuffer> {
|
|
123
|
-
|
|
114
|
+
// Strict `from` typing, mirroring canonicalSave's `base` check: `from`
|
|
115
|
+
// is `string | null`. A non-string non-null value would otherwise
|
|
116
|
+
// coerce via `String(...)` to canonical bytes the client could never
|
|
117
|
+
// reproduce (123 → "123", {} → "[object Object]"), so a signature
|
|
118
|
+
// computed over that coercion would verify against a malformed wire
|
|
119
|
+
// shape. verifySubscribeSig wraps this call in try/catch → clean drop.
|
|
120
|
+
if (from != null && typeof from !== 'string') throw new TypeError('canonicalSubscribe: from must be string or null')
|
|
121
|
+
const fromStr = from == null ? '' : from
|
|
124
122
|
return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
|
|
125
123
|
}
|
|
126
124
|
|
package/server/sse-server.ts
CHANGED
|
@@ -168,9 +168,8 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
168
168
|
session.writeEvent('session', sid)
|
|
169
169
|
// Hand the session to the shared WS connection setup so it joins
|
|
170
170
|
// the same Peer / dispatcher / hub lifecycle as a real WebSocket.
|
|
171
|
-
// `setupPeerConnection`
|
|
172
|
-
//
|
|
173
|
-
// message channel.
|
|
171
|
+
// `setupPeerConnection`'s first action is the protocol `challenge`
|
|
172
|
+
// frame, on the default-named SSE channel.
|
|
174
173
|
setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
|
|
175
174
|
return { sid, session }
|
|
176
175
|
}
|
|
@@ -329,15 +328,14 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
329
328
|
|
|
330
329
|
// Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
|
|
331
330
|
// (which decodes percent-escapes) — `randomId()` mints a 22-char
|
|
332
|
-
// base64url string
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// token. Returns null on missing / malformed.
|
|
331
|
+
// base64url string echoed back unchanged, so no escapes are possible
|
|
332
|
+
// on the legitimate path. The {1,64} bound is a deliberately lenient
|
|
333
|
+
// sanity gate: anything outside the base64url alphabet is rejected; an
|
|
334
|
+
// unrecognised sid just gets a fresh session from createSession (no
|
|
335
|
+
// failure mode), and the wide length window keeps a future
|
|
336
|
+
// randomId-length change from silently breaking old clients that
|
|
337
|
+
// round-trip a longer/shorter token. Returns null on missing /
|
|
338
|
+
// malformed.
|
|
341
339
|
function parseSidQuery(query: string | undefined): string | null {
|
|
342
340
|
if (typeof query !== 'string') return null
|
|
343
341
|
for (const part of query.split('&')) {
|
package/server/sse-session.ts
CHANGED
|
@@ -63,20 +63,14 @@ export class SseSession extends EventEmitter {
|
|
|
63
63
|
// failure on a healthy session.
|
|
64
64
|
private wireResponse(res: ServerResponse): void {
|
|
65
65
|
res.on('close', () => {
|
|
66
|
-
|
|
67
|
-
// swapped-out previous response closes naturally during takeover
|
|
68
|
-
// and must not knock the session offline.
|
|
69
|
-
if (res !== this.currentRes) return
|
|
66
|
+
if (res !== this.currentRes) return // current-response guard (see above)
|
|
70
67
|
if (this.readyState === SseSession.CLOSED) return
|
|
71
68
|
this.readyState = SseSession.CLOSED
|
|
72
69
|
this.currentRes = null
|
|
73
70
|
this.emit('close')
|
|
74
71
|
})
|
|
75
72
|
res.on('error', (err: Error) => {
|
|
76
|
-
|
|
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
|
|
73
|
+
if (res !== this.currentRes) return // current-response guard (see above)
|
|
80
74
|
this.emit('error', err)
|
|
81
75
|
})
|
|
82
76
|
}
|
package/server/static.ts
CHANGED
|
@@ -169,11 +169,9 @@ function buildEntry(staticDir: string, name: string): StaticEntry {
|
|
|
169
169
|
const ext = extname(name)
|
|
170
170
|
const raw = readFileSync(join(staticDir, name))
|
|
171
171
|
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.
|
|
172
|
+
// HTML: lift `<link rel="(module)preload" …>` into a Link header and
|
|
173
|
+
// drop the tags from the served body. ETag + compression run against
|
|
174
|
+
// the stripped body (see the header note on the divergence).
|
|
177
175
|
let identity = raw
|
|
178
176
|
let link: string | null = null
|
|
179
177
|
if (ext === '.html') {
|
|
@@ -273,13 +271,11 @@ function isUnsafeAttr(s: string): boolean {
|
|
|
273
271
|
return /[\r\n",;<>]/u.test(s)
|
|
274
272
|
}
|
|
275
273
|
|
|
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.
|
|
274
|
+
// Char ranges in `html` whose contents are NOT real HTML content:
|
|
275
|
+
// comment bodies and script / noscript raw-text bodies. A `<link>`
|
|
276
|
+
// whose match offset falls in any range is preserved verbatim (no
|
|
277
|
+
// header lift — see the call site for why). Heuristic, not a real
|
|
278
|
+
// parser: the build emits clean, well-formed markup.
|
|
283
279
|
function findSkipRanges(html: string): Array<[number, number]> {
|
|
284
280
|
const ranges: Array<[number, number]> = []
|
|
285
281
|
const patterns = [
|