@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.11
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 +16 -13
- package/out/graph.js +5 -4
- package/out/index.html +4 -15
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +89 -52
- package/package.json +70 -54
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +8 -8
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +31 -25
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +97 -9
- package/{server → server-e2e}/hub.ts +7 -8
- package/{server → server-e2e}/index.ts +67 -43
- package/{server → server-e2e}/lifecycle.ts +14 -5
- package/{server → server-e2e}/npm-proxy.ts +1 -1
- 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 +13 -15
- package/{server → server-e2e}/objstore/init.ts +47 -13
- 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 +110 -93
- 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 → server-e2e}/pubsub.ts +21 -31
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +22 -17
- package/{server → server-e2e}/sync-handlers.ts +172 -117
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/strip-types-loader.js +94 -0
- /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
|
@@ -10,49 +10,30 @@
|
|
|
10
10
|
// `base` points at the previous revision's `id` (or null for the
|
|
11
11
|
// first revision in a workspace).
|
|
12
12
|
//
|
|
13
|
-
// `keyframe` is `1` for a revision the client emits with
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// `
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// reaches this column. Client-driven: the server only stores what
|
|
22
|
-
// the client sent and treats keyframes as catch-up roots when a
|
|
23
|
-
// from=null subscriber arrives.
|
|
13
|
+
// `keyframe` is `1` for a revision the client emits with full state
|
|
14
|
+
// baked in (rather than a delta). The wire flag is covered by the
|
|
15
|
+
// signature, so the column value MUST match the signed canonical
|
|
16
|
+
// bytes: `canonicalSave` (server-e2e/sign.ts, via `handleSave`) encodes
|
|
17
|
+
// `keyframe ? '1' : ''` into the bytes `verifyEd25519` checks, so a
|
|
18
|
+
// mismatched wire flag fails verify and never reaches this column.
|
|
19
|
+
// Client-driven: the server stores what the client sent and treats
|
|
20
|
+
// keyframes as catch-up roots when a from=null subscriber arrives.
|
|
24
21
|
//
|
|
25
22
|
// `node:sqlite` is the built-in driver (Node ≥ 22 experimental,
|
|
26
|
-
// stable in 24+)
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// off a sync `node:sqlite` call.
|
|
23
|
+
// stable in 24+), synchronous under the hood; the Handle wraps each
|
|
24
|
+
// prepared statement so call sites `await` uniformly — async-ready
|
|
25
|
+
// surface for a future async DB backend (every op resolves in the
|
|
26
|
+
// current microtask off a sync call).
|
|
31
27
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// can interleave mid-statement, and the head-check + MAX(seq) read
|
|
42
|
-
// from ONE consistent snapshot. That single-snapshot property is
|
|
43
|
-
// what makes a per-tag lock redundant: the lock formerly existed
|
|
44
|
-
// only to stop a chain fork where a racer read `head` from one
|
|
45
|
-
// snapshot but `MAX(seq)` from a LATER one (after a sibling
|
|
46
|
-
// committed) and inserted (seq=N+2, base=X) alongside the winner's
|
|
47
|
-
// (seq=N+1, base=X) — same base, different seq, no PK conflict. With
|
|
48
|
-
// both reads inside one statement that interleaving is impossible:
|
|
49
|
-
// a racer's snapshot is either before the winner's commit (→ same
|
|
50
|
-
// seq=N+1 → the UNIQUE(workspace_tag, seq) PK rejects the second →
|
|
51
|
-
// recovery → stale-base) or after it (→ head ≠ base → no insert →
|
|
52
|
-
// stale-base). Exactly one commits; the loser gets stale-base.
|
|
53
|
-
// SQLite also serialises writers internally, and the PK backstops
|
|
54
|
-
// the unsupported multi-connection case. See `commitRevisionSqlite`
|
|
55
|
-
// for the full fork-safety argument.
|
|
28
|
+
// Operations being async, two handlers can interleave across an
|
|
29
|
+
// `await`. `commitRevision` (below) takes NO in-process lock — it
|
|
30
|
+
// folds the dup recheck, base-equality check, MAX(seq) and INSERT
|
|
31
|
+
// into ONE gated INSERT (`commitRevisionSqlite`). `node:sqlite` runs
|
|
32
|
+
// that statement to completion without yielding, so its head-check +
|
|
33
|
+
// MAX(seq) read ONE snapshot, which is what makes a per-tag lock
|
|
34
|
+
// redundant. SQLite also serialises writers internally, and the PK
|
|
35
|
+
// backstops the unsupported multi-connection case. See
|
|
36
|
+
// `commitRevisionSqlite` for the full fork-safety argument.
|
|
56
37
|
|
|
57
38
|
import { DatabaseSync } from 'node:sqlite'
|
|
58
39
|
import { mkdirSync } from 'node:fs'
|
|
@@ -64,24 +45,20 @@ import {
|
|
|
64
45
|
mapRevisionRow, toSqlitePlaceholders,
|
|
65
46
|
} from './db-revision-sql.ts'
|
|
66
47
|
|
|
67
|
-
// `CHECK (keyframe IN (0, 1))` is the value-domain guard
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// 0
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// column TYPE. The CHECK closes the value-domain half, giving SQLite
|
|
77
|
-
// the protection the Neon schema's identical `CHECK (keyframe IN
|
|
78
|
-
// (0, 1))` carries (see `db-neon.ts`).
|
|
48
|
+
// `CHECK (keyframe IN (0, 1))` is the value-domain guard. STRICT
|
|
49
|
+
// (the table marker) enforces the column TYPE (an INTEGER stays an
|
|
50
|
+
// INTEGER) but NOT its value range: `keyframe = 2` is a valid integer
|
|
51
|
+
// STRICT accepts, which `mapRevisionRow`'s `=== 1` check then coerces
|
|
52
|
+
// back to 0. That divergence from the signed canonical (only ever
|
|
53
|
+
// 0 / 1) poisons chain-replay verifies for any recomputing peer — the
|
|
54
|
+
// same operator-with-direct-DB-write vector the STRICT guard in
|
|
55
|
+
// `openDbInner` catches for TYPE. The CHECK closes the value-domain
|
|
56
|
+
// half, matching the Neon schema's identical CHECK (see `db-neon.ts`).
|
|
79
57
|
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// one, and a future column edit can't drift the two apart.
|
|
58
|
+
// Parenthesised column + constraint body (plus STRICT marker), shared
|
|
59
|
+
// by the initial `CREATE TABLE` and the `migrateAddKeyframeCheck`
|
|
60
|
+
// rebuild below — so a rebuilt table is byte-identical in shape to a
|
|
61
|
+
// fresh one and a future column edit can't drift the two apart.
|
|
85
62
|
const WORKSPACE_REVISION_DEF = `(
|
|
86
63
|
workspace_tag TEXT NOT NULL,
|
|
87
64
|
seq INTEGER NOT NULL,
|
|
@@ -105,10 +82,10 @@ const SCHEMA = `
|
|
|
105
82
|
${WORKSPACE_REVISION_TAG_ID_INDEX};
|
|
106
83
|
`
|
|
107
84
|
|
|
108
|
-
// Row shape
|
|
109
|
-
//
|
|
110
|
-
// to a strict boolean before broadcasting
|
|
111
|
-
//
|
|
85
|
+
// Row shape from the chain queries. `keyframe` is stored as INTEGER
|
|
86
|
+
// (0 / 1); the raw row carries the integer — `chainForWire` in
|
|
87
|
+
// server-e2e/index.ts normalises to a strict boolean before broadcasting.
|
|
88
|
+
// `base` is nullable on the very first revision.
|
|
112
89
|
export type RevisionRow = {
|
|
113
90
|
base: string | null
|
|
114
91
|
id: string
|
|
@@ -119,9 +96,8 @@ export type RevisionRow = {
|
|
|
119
96
|
}
|
|
120
97
|
|
|
121
98
|
// Input to `commitRevision`. `keyframe` is a strict boolean here —
|
|
122
|
-
// the canonical-payload contract uses `=== true
|
|
123
|
-
//
|
|
124
|
-
// STRICT INTEGER column.
|
|
99
|
+
// the canonical-payload contract uses `=== true`; the storage path
|
|
100
|
+
// coerces to 0 / 1 before hitting the STRICT INTEGER column.
|
|
125
101
|
export type RevisionInsert = {
|
|
126
102
|
tag: string
|
|
127
103
|
id: string
|
|
@@ -142,37 +118,31 @@ export type CommitResult =
|
|
|
142
118
|
| { kind: 'duplicate' }
|
|
143
119
|
| { kind: 'stale-base'; head: string | null }
|
|
144
120
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
121
|
+
// Pre-prepared statements + the underlying connection, held for the
|
|
122
|
+
// process lifetime; `close()` runs from `shutdown()`.
|
|
147
123
|
//
|
|
148
|
-
// `db` is the raw `DatabaseSync
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
124
|
+
// `db` is the raw `DatabaseSync`, SQLite-only — the Neon backend
|
|
125
|
+
// (`./db-neon.ts`) constructs a Handle with `db` unset. Callers that
|
|
126
|
+
// reach into `db` directly (e.g. `openObjstore`, test-only fixture
|
|
127
|
+
// SQL) are SQLite-coupled by construction; passing them a Neon-backed
|
|
128
|
+
// Handle is the operator's mistake to catch at the `if (DATABASE_URL)`
|
|
129
|
+
// switch in `server-e2e/index.ts`.
|
|
154
130
|
//
|
|
155
|
-
// `tryCommit` is the backend-specific atomic-commit primitive
|
|
131
|
+
// `tryCommit` is the backend-specific atomic-commit primitive
|
|
156
132
|
// `commitRevision` dispatches through. SQLite runs one synchronous
|
|
157
|
-
// gated INSERT (
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// READ-COMMITTED single-statement snapshot (the gated INSERT's
|
|
162
|
-
// head-check and MAX(seq) read one snapshot) plus the
|
|
163
|
-
// `UNIQUE(workspace_tag, seq)` PK to keep cross-replica racers from
|
|
164
|
-
// forking the chain — see `db-neon.ts`'s `tryCommitNeon`.
|
|
133
|
+
// gated INSERT (see `commitRevisionSqlite`); Neon wraps it in a
|
|
134
|
+
// pipelined transaction (see `db-neon.ts`'s `tryCommitNeon`). Both
|
|
135
|
+
// rely on a single-statement snapshot + the `UNIQUE(workspace_tag,
|
|
136
|
+
// seq)` PK for fork-safety; see those functions for the argument.
|
|
165
137
|
//
|
|
166
138
|
// `gatedInsert` is SQLite-only (like `db`): it backs
|
|
167
|
-
// `commitRevisionSqlite`'s single gated INSERT
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
// unique-violation / non-unique failure into the commit, the same
|
|
175
|
-
// recovery paths the Neon suite stages via `failNextCommit`.
|
|
139
|
+
// `commitRevisionSqlite`'s single gated INSERT. The Neon backend
|
|
140
|
+
// leaves it unset — its gated INSERT lives inside the pipelined
|
|
141
|
+
// `sql.transaction([...])`, not a standalone statement object. Kept
|
|
142
|
+
// on the Handle (not a module-private closure) so SQLite white-box
|
|
143
|
+
// tests can wrap `.get` to inject a unique-violation / non-unique
|
|
144
|
+
// failure into the commit, exercising the same recovery paths the
|
|
145
|
+
// Neon suite stages via `failNextCommit`.
|
|
176
146
|
export type Handle = {
|
|
177
147
|
db?: DatabaseSync
|
|
178
148
|
headFor: GetStmt<[string], { id: string }>
|
|
@@ -184,7 +154,7 @@ export type Handle = {
|
|
|
184
154
|
revisionExists: GetStmt<[string, string], unknown>
|
|
185
155
|
// Single-revision fetch by content-addressed id. The cross-instance
|
|
186
156
|
// pubsub receiver uses this to assemble a `workspace-state` from a
|
|
187
|
-
// NOTIFY hint (see `server/pubsub.ts`).
|
|
157
|
+
// NOTIFY hint (see `server-e2e/pubsub.ts`).
|
|
188
158
|
revisionById: GetStmt<[string, string], RevisionRow>
|
|
189
159
|
gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
|
|
190
160
|
tryCommit: (input: RevisionInsert) => Promise<CommitResult>
|
|
@@ -192,23 +162,22 @@ export type Handle = {
|
|
|
192
162
|
}
|
|
193
163
|
|
|
194
164
|
// Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
|
|
195
|
-
//
|
|
165
|
+
// set. `openDb` returns this so call sites needing direct
|
|
196
166
|
// `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
|
|
197
|
-
// `server/index.ts`'s SQLite branch)
|
|
198
|
-
//
|
|
199
|
-
// (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape
|
|
200
|
-
//
|
|
201
|
-
//
|
|
167
|
+
// `server-e2e/index.ts`'s SQLite branch) reach `handle.db` without an
|
|
168
|
+
// optional-chain or non-null assertion. A Neon-backed Handle
|
|
169
|
+
// (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape, so routing
|
|
170
|
+
// one into a SQLite-coupled call site is a compile-time error. Mirrors
|
|
171
|
+
// `server-e2e/objstore/store.ts`.
|
|
202
172
|
export type SqliteHandle = Handle & { db: DatabaseSync }
|
|
203
173
|
|
|
204
174
|
export function openDb(path: string): SqliteHandle {
|
|
205
175
|
mkdirSync(dirname(path), { recursive: true })
|
|
206
176
|
const db = new DatabaseSync(path)
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// …) and re-run without a stale lock pinning the file.
|
|
177
|
+
// A throw between the DatabaseSync constructor and the return would
|
|
178
|
+
// leak the file / WAL / shm locks until process exit — close before
|
|
179
|
+
// re-raising so the operator can fix the cause (failed STRICT check,
|
|
180
|
+
// ALTER TABLE error, …) and re-run without a stale lock on the file.
|
|
212
181
|
try {
|
|
213
182
|
return openDbInner(db)
|
|
214
183
|
} catch (err) {
|
|
@@ -218,35 +187,30 @@ export function openDb(path: string): SqliteHandle {
|
|
|
218
187
|
}
|
|
219
188
|
|
|
220
189
|
function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
221
|
-
// WAL gives concurrent readers + faster writes and survives
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// tables later without revisiting init.
|
|
190
|
+
// WAL gives concurrent readers + faster writes and survives crashes
|
|
191
|
+
// between commits without corrupting the file. Foreign keys aren't
|
|
192
|
+
// needed here (single-table schema) but turning them on keeps the
|
|
193
|
+
// option to add referential tables later without revisiting init.
|
|
226
194
|
db.exec('PRAGMA journal_mode = WAL;')
|
|
227
195
|
// FULL (not NORMAL): the server emits `workspace-save-ack` BEFORE
|
|
228
|
-
// returning to the event loop after `commitRevision`.
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// for the protocol's edit-driven write pattern (triage edits, not
|
|
235
|
-
// streaming throughput). Audit round-9 M1.
|
|
196
|
+
// returning to the event loop after `commitRevision`. NORMAL only
|
|
197
|
+
// fsyncs at WAL checkpoint, so a power loss between ack and the next
|
|
198
|
+
// checkpoint loses a row the originator + peers were told committed.
|
|
199
|
+
// FULL fsyncs per commit, matching the durability the ack implies.
|
|
200
|
+
// Trade-off is per-commit fsync latency, acceptable for the edit-
|
|
201
|
+
// driven write pattern (triage edits, not streaming). Audit round-9 M1.
|
|
236
202
|
db.exec('PRAGMA synchronous = FULL;')
|
|
237
203
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
238
204
|
db.exec(SCHEMA)
|
|
239
205
|
// Fail-loud on a pre-existing non-STRICT table — `CREATE TABLE IF
|
|
240
|
-
// NOT EXISTS … STRICT` is a no-op when the table
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
// making every subsequent verify fail. Operator must migrate
|
|
249
|
-
// before this server boots.
|
|
206
|
+
// NOT EXISTS … STRICT` is a no-op when the table exists, so a
|
|
207
|
+
// deployment predating the STRICT marker keeps its non-STRICT shape.
|
|
208
|
+
// Without STRICT, an operator with direct DB write access could
|
|
209
|
+
// insert mis-typed rows (e.g. `keyframe = "1\nfoo"` text in the
|
|
210
|
+
// INTEGER column) and poison the chain: the signed canonical says
|
|
211
|
+
// `keyframe = 1`, but the stored text round-trips into the canonical
|
|
212
|
+
// as a different string, failing every subsequent verify. Operator
|
|
213
|
+
// must migrate before this server boots.
|
|
250
214
|
const meta = db.prepare(
|
|
251
215
|
`SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
|
|
252
216
|
).get() as { strict: number } | undefined
|
|
@@ -254,13 +218,11 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
|
|
|
254
218
|
throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
|
|
255
219
|
}
|
|
256
220
|
// Idempotent migration for DBs created before the keyframe column
|
|
257
|
-
// existed. Inspect the column list rather than
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// the ALTER itself bubbles up as an open-time crash where the
|
|
263
|
-
// operator can act on it.
|
|
221
|
+
// existed. Inspect the column list rather than `try { ALTER } catch
|
|
222
|
+
// {}`: a blanket catch swallows ANY failure (lock contention, disk
|
|
223
|
+
// full, corrupt page) as "column already exists". ALTER only when
|
|
224
|
+
// the column is genuinely missing, so an ALTER failure bubbles up as
|
|
225
|
+
// an open-time crash the operator can act on.
|
|
264
226
|
const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
|
|
265
227
|
if (!columns.some((c) => c.name === 'keyframe')) {
|
|
266
228
|
// ADD COLUMN carries the CHECK so a legacy DB migrating up lands
|
|
@@ -507,9 +469,8 @@ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<C
|
|
|
507
469
|
// base gate fails → `stale-base`.
|
|
508
470
|
// • Two retransmits with the same id: the second's dup gate fails →
|
|
509
471
|
// `duplicate`.
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
// chainFrom-during-commits) which now pass with no lock present.
|
|
472
|
+
// Covered by the no-fork concurrency tests in `tests/server-db.test.js`
|
|
473
|
+
// (two/N concurrent same-base, mixed, chainFrom-during-commits).
|
|
513
474
|
//
|
|
514
475
|
// SQLite serialises writers internally even ACROSS connections, but a
|
|
515
476
|
// multi-connection deployment is unsupported regardless. The
|
|
@@ -10,6 +10,7 @@ import { type IncomingMessage as HttpRequest, type Server, type ServerResponse,
|
|
|
10
10
|
import { Buffer } from 'node:buffer'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import type { WebSocketServer } from 'ws'
|
|
13
|
+
import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
|
|
13
14
|
import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
|
|
14
15
|
import { SSE_OPEN_PATH, type SseServer } from './sse-server.ts'
|
|
15
16
|
import { dispatchNpmAdvisories } from './npm-proxy.ts'
|
|
@@ -20,6 +21,12 @@ import { errStack } from './util.ts'
|
|
|
20
21
|
// similar) can route `/api/*` → this process and `/*` → the static UI
|
|
21
22
|
// bundle with a single location block.
|
|
22
23
|
export const WS_UPGRADE_PATH = '/api/sync'
|
|
24
|
+
// Session-independent triage-sync save plane: `POST /api/sync/save`. The
|
|
25
|
+
// SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
|
|
26
|
+
// here commits + broadcasts WITHOUT taking over the client's SSE event-stream
|
|
27
|
+
// (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
|
|
28
|
+
// the WS upgrade path so one nginx `/api/*` block routes both.
|
|
29
|
+
export const SAVE_REST_PATH = '/api/sync/save'
|
|
23
30
|
function isUpgradePath(url: string | undefined): boolean {
|
|
24
31
|
if (typeof url !== 'string') return false
|
|
25
32
|
// Strip `?…` so clients can carry build / debug tags. Exact match
|
|
@@ -34,6 +41,10 @@ type HasHeaders = { headers: HttpRequest['headers'] }
|
|
|
34
41
|
export type HttpServerDeps = {
|
|
35
42
|
wss: WebSocketServer
|
|
36
43
|
restDeps: ObjstoreRestDeps
|
|
44
|
+
// This server's `server-info` (mode advertisement), served as JSON at
|
|
45
|
+
// GET /api/config so a client can detect the protocol without a sync
|
|
46
|
+
// connection (the WS connect frame stays the source of truth).
|
|
47
|
+
serverInfo: ServerInfo
|
|
37
48
|
// SSE+POST fallback. Owns its own session map + lifecycle; we just
|
|
38
49
|
// give it first crack at requests that match `SSE_OPEN_PATH`. Same
|
|
39
50
|
// same-origin / shutdown gates run BEFORE the dispatch so the SSE
|
|
@@ -42,12 +53,68 @@ export type HttpServerDeps = {
|
|
|
42
53
|
isOriginAllowed: (req: HasHeaders) => boolean
|
|
43
54
|
isShuttingDown: () => boolean
|
|
44
55
|
track: (promise: Promise<unknown>) => void
|
|
56
|
+
// Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
|
|
57
|
+
// the save pipeline + JSON response; this module owns the gates (method,
|
|
58
|
+
// same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
|
|
59
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
45
60
|
restPutIdleTimeoutMs: number
|
|
46
61
|
debug: boolean
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
// `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
|
|
65
|
+
// the request matched the route (the caller `track`s it for graceful drain),
|
|
66
|
+
// or null when it's for a different route. The gate ladder — method →
|
|
67
|
+
// shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
|
|
68
|
+
// gate rejection writes its own response and returns an already-settled
|
|
69
|
+
// promise. Kept out of `createHttpServer` so that dispatcher stays compact.
|
|
70
|
+
function dispatchSaveRest(
|
|
71
|
+
req: HttpRequest, res: ServerResponse,
|
|
72
|
+
deps: {
|
|
73
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
74
|
+
isOriginAllowed: (req: HasHeaders) => boolean
|
|
75
|
+
isShuttingDown: () => boolean
|
|
76
|
+
restPutIdleTimeoutMs: number
|
|
77
|
+
debug: boolean
|
|
78
|
+
},
|
|
79
|
+
): Promise<void> | null {
|
|
80
|
+
if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
|
|
81
|
+
if (req.method !== 'POST') {
|
|
82
|
+
res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
|
|
83
|
+
res.end(JSON.stringify({ error: 'method-not-allowed' }))
|
|
84
|
+
return Promise.resolve()
|
|
85
|
+
}
|
|
86
|
+
// Shutdown gate — parity with the objstore REST branch (a POST on an
|
|
87
|
+
// existing keep-alive socket after SIGTERM but before close() drains).
|
|
88
|
+
if (deps.isShuttingDown()) {
|
|
89
|
+
res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
|
|
90
|
+
res.end(JSON.stringify({ error: 'shutting-down' }))
|
|
91
|
+
return Promise.resolve()
|
|
92
|
+
}
|
|
93
|
+
// Same-origin gate — a hostile cross-origin page would carry an Origin
|
|
94
|
+
// header (browser-set on fetch); same-origin XHR may omit it (allowed).
|
|
95
|
+
if (!deps.isOriginAllowed(req)) {
|
|
96
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
97
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
98
|
+
return Promise.resolve()
|
|
99
|
+
}
|
|
100
|
+
// Idle-body timeout — a slow-loris trickling the JSON body would otherwise
|
|
101
|
+
// hold the connection indefinitely.
|
|
102
|
+
req.setTimeout(deps.restPutIdleTimeoutMs, () => {
|
|
103
|
+
if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
|
|
104
|
+
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
105
|
+
})
|
|
106
|
+
// Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
|
|
107
|
+
// handler's own try/catch — logs and terminates the response so the TCP
|
|
108
|
+
// socket doesn't dangle (same policy as the objstore handleRest wrapper).
|
|
109
|
+
return deps.handleSaveRest(req, res).catch((err) => {
|
|
110
|
+
console.warn('sync-save REST handler error:', errStack(err))
|
|
111
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
112
|
+
else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
49
116
|
export function createHttpServer(deps: HttpServerDeps): Server {
|
|
50
|
-
const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
|
|
117
|
+
const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
|
|
51
118
|
// Static-file plane (see ./static.ts). The directory is the
|
|
52
119
|
// `build.js build` output sibling to this file; the loader handles
|
|
53
120
|
// enumeration, pre-compression, and ETag derivation. Plugged in after
|
|
@@ -55,6 +122,19 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
55
122
|
const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
|
|
56
123
|
|
|
57
124
|
const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
|
|
125
|
+
// Static mode probe: GET /api/config → this server's `server-info` as JSON.
|
|
126
|
+
// A client uses it to detect the protocol up front; the WS connect frame
|
|
127
|
+
// stays the source of truth and catches a later mode change. Public, no body.
|
|
128
|
+
if (typeof req.url === 'string' && req.url.split('?', 1)[0] === CONFIG_PATH) {
|
|
129
|
+
if (req.method !== 'GET') {
|
|
130
|
+
res.writeHead(405, { 'content-type': 'application/json', allow: 'GET' })
|
|
131
|
+
res.end(JSON.stringify({ error: 'method-not-allowed' }))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
135
|
+
res.end(JSON.stringify(serverInfo))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
58
138
|
// SSE+POST fallback plane. Same-origin gate first (Origin header
|
|
59
139
|
// is set by the browser on cross-origin EventSource + fetch, so
|
|
60
140
|
// a hostile origin would surface here just like it does on the
|
|
@@ -76,6 +156,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
76
156
|
// the lifecycle's sseSessions() close loop.
|
|
77
157
|
if (sseServer.handle(req, res)) return
|
|
78
158
|
}
|
|
159
|
+
// Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
|
|
160
|
+
// `POST /api/sync/save` so an SSE-mode save doesn't take over the
|
|
161
|
+
// event-stream. The dispatch helper owns the gate ladder; we `track` the
|
|
162
|
+
// returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
|
|
163
|
+
const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
|
|
164
|
+
if (saveP) { track(saveP); return }
|
|
79
165
|
// npm advisories proxy — same-origin + shutdown gates live in the
|
|
80
166
|
// helper so this dispatcher stays compact. `dispatchNpmAdvisories`
|
|
81
167
|
// returns the in-flight promise (or null when the route didn't
|
|
@@ -103,20 +189,22 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
103
189
|
// that holds a valid token (e.g. via XSS that read a freshly-minted
|
|
104
190
|
// one) would PUT with its own Origin header — caught here.
|
|
105
191
|
// Same-origin XHR/fetch may omit Origin; that path is allowed (see
|
|
106
|
-
// `isOriginAllowed`). Transport audit `server/objstore/rest.ts:103`.
|
|
192
|
+
// `isOriginAllowed`). Transport audit `server-e2e/objstore/rest.ts:103`.
|
|
107
193
|
if (!isOriginAllowed(req)) {
|
|
108
194
|
res.writeHead(403, { 'content-type': 'application/json' })
|
|
109
195
|
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
110
196
|
return
|
|
111
197
|
}
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// inFlightSids slot indefinitely.
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
198
|
+
// Idle-body timeout for the body-bearing REST methods — a slow-loris
|
|
199
|
+
// client trickling bytes holds the connection (and, for PUT, the
|
|
200
|
+
// staging fd + inFlightSids slot) indefinitely. PUT carries the raw
|
|
201
|
+
// blob within its declared Content-Length; POST carries the small
|
|
202
|
+
// fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
|
|
203
|
+
// destroy the request, aborting the body pipeline.
|
|
204
|
+
// Transport audit `server-e2e/objstore/rest.ts:218`.
|
|
205
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
118
206
|
req.setTimeout(restPutIdleTimeoutMs, () => {
|
|
119
|
-
if (debug) console.warn(`REST
|
|
207
|
+
if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
|
|
120
208
|
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
121
209
|
})
|
|
122
210
|
}
|
|
@@ -21,13 +21,13 @@ export type Hub = {
|
|
|
21
21
|
// WS-originated broadcasts pass the originator so it doesn't see its
|
|
22
22
|
// own message echoed back. Local-only: callers that ALSO want a
|
|
23
23
|
// cross-instance fan-out are expected to publish to the pubsub bus
|
|
24
|
-
// alongside this call (server/pubsub.ts). The hub deliberately stays
|
|
24
|
+
// alongside this call (server-e2e/pubsub.ts). The hub deliberately stays
|
|
25
25
|
// ignorant of the bus so its transport invariants (backpressure cap,
|
|
26
26
|
// stringify-once, terminate-on-overflow) are unchanged.
|
|
27
27
|
broadcast(tag: string, msg: object, except: WebSocket | null): void
|
|
28
28
|
// Broadcasts an ALREADY-SERIALISED payload to every local subscriber
|
|
29
29
|
// for `tag`. Used by the pubsub bus receiver
|
|
30
|
-
// (server/bus-receiver.ts, wired up from server/index.ts) to relay
|
|
30
|
+
// (server-e2e/bus-receiver.ts, wired up from server-e2e/index.ts) to relay
|
|
31
31
|
// a remote-instance event into this instance's fan-out. No `except`:
|
|
32
32
|
// the originator is on a different instance by construction.
|
|
33
33
|
broadcastLocalRaw(tag: string, payload: string): void
|
|
@@ -71,7 +71,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
71
71
|
// kernel yet — a slow / blackholed peer accumulates them unboundedly
|
|
72
72
|
// during fan-out broadcasts. Drop above the cap and terminate the
|
|
73
73
|
// socket so the heartbeat doesn't keep it alive on ping/pong while
|
|
74
|
-
// every broadcast piles up. Transport audit `server/index.ts:225`.
|
|
74
|
+
// every broadcast piles up. Transport audit `server-e2e/index.ts:225`.
|
|
75
75
|
if (socket.bufferedAmount > maxBufferedBytes) {
|
|
76
76
|
if (debug) console.warn(`drop broadcast: socket buffered ${socket.bufferedAmount}B > cap`)
|
|
77
77
|
try { socket.terminate() } catch {}
|
|
@@ -102,12 +102,11 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
|
|
105
|
-
// Snapshot before iterating —
|
|
106
|
-
// socket.send errors, but a socket transitioning to CLOSED
|
|
105
|
+
// Snapshot before iterating — a socket transitioning to CLOSED
|
|
107
106
|
// mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
107
|
+
// mutating `set` while we walk it. The snapshot also keeps a future
|
|
108
|
+
// refactor (different collection, async send) from silently skipping
|
|
109
|
+
// subscribers. Audit M4 round-3.
|
|
111
110
|
for (const s of [...set]) {
|
|
112
111
|
if (s === except) continue
|
|
113
112
|
sendRaw(s, payload)
|