@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,394 @@
|
|
|
1
|
+
// Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
|
|
2
|
+
// fan-out is an in-memory subscriber map (server-e2e/hub.ts) by design — it
|
|
3
|
+
// routes a commit only to peers on the SAME instance. A multi-instance
|
|
4
|
+
// deployment behind a load balancer needs commit-landed-on-A to reach
|
|
5
|
+
// peers-on-B with the same latency the hub gives same-instance peers.
|
|
6
|
+
//
|
|
7
|
+
// SQLite mode is single-process by construction (the local FS objstore
|
|
8
|
+
// can't back two writers), so it ships a no-op PubSub.
|
|
9
|
+
//
|
|
10
|
+
// Neon mode uses Postgres LISTEN/NOTIFY on a dedicated long-lived
|
|
11
|
+
// WebSocket connection (the `Client` form of `@neondatabase/serverless`,
|
|
12
|
+
// session-bound and notification-aware — the HTTP `neon()` callable used
|
|
13
|
+
// for normal queries is stateless and can't LISTEN). Each instance:
|
|
14
|
+
// - At start: opens a Client, LISTENs on the bus channel, dispatches
|
|
15
|
+
// notifications. Reconnects on transport failure with backoff.
|
|
16
|
+
// - On publish*: fire-and-forget `SELECT pg_notify(channel, payload)`.
|
|
17
|
+
// - Filters its own notifications by a per-process random sender id —
|
|
18
|
+
// Postgres delivers NOTIFY back to publishers that LISTEN on the
|
|
19
|
+
// same channel, and a local broadcast already happened before the
|
|
20
|
+
// bus publish, so re-broadcasting our own would echo.
|
|
21
|
+
//
|
|
22
|
+
// Postgres NOTIFY caps the payload at ~8 KB by default (NAMEDATALEN-
|
|
23
|
+
// derived; can't be raised on a managed endpoint). The triage
|
|
24
|
+
// `workspace-state` envelope carries a ciphertext up to MAX_CIPHERTEXT_LEN
|
|
25
|
+
// (2 MiB), so the workspace-revision channel ships only `(tag, revisionId)`
|
|
26
|
+
// and the receiver re-fetches the row from the shared workspace_revision
|
|
27
|
+
// table to construct the wire broadcast. Objstore-put broadcasts likewise
|
|
28
|
+
// ship `(tag, resourceTag)` and the receiver re-fetches from
|
|
29
|
+
// workspace_object. Objstore-deleted broadcasts inline the (tag,
|
|
30
|
+
// resourceTag, version) tuple — the row is gone from the DB, so the
|
|
31
|
+
// payload IS the wire data.
|
|
32
|
+
|
|
33
|
+
import { randomBytes } from 'node:crypto'
|
|
34
|
+
import { errStack } from './util.ts'
|
|
35
|
+
|
|
36
|
+
// One bus channel for all three message kinds; the receiver discriminates
|
|
37
|
+
// on the `kind` field. Single LISTEN keeps the Client wiring trivial and
|
|
38
|
+
// avoids a `kind`-per-channel decision tree. Channel name doubles as the
|
|
39
|
+
// SQL identifier we LISTEN on, so it MUST stay a valid Postgres
|
|
40
|
+
// identifier (no quoting / special chars). Exported so tests stay in
|
|
41
|
+
// sync with the production channel name (one constant, one source).
|
|
42
|
+
export const CHANNEL = 'triage_bus'
|
|
43
|
+
|
|
44
|
+
// Sender id is a per-process random value stamped into every outbound
|
|
45
|
+
// payload so the LISTENing connection on the SAME process can skip its
|
|
46
|
+
// own notifications. 12 bytes / 16 chars base64url — collision odds
|
|
47
|
+
// across any realistic cluster size are astronomical.
|
|
48
|
+
function newSenderId(): string {
|
|
49
|
+
return randomBytes(12).toString('base64url')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// JSON-encoded NOTIFY payloads. Each kind documents the minimum info
|
|
53
|
+
// the receiver needs:
|
|
54
|
+
// - 'rev': workspace-state broadcast. `id` is the revision id; the
|
|
55
|
+
// receiver SELECTs the full row by (tag, id) — the payload size
|
|
56
|
+
// budget can't carry the ciphertext.
|
|
57
|
+
// - 'objput': objstore-put broadcast. `res` is the resource tag; the
|
|
58
|
+
// receiver SELECTs the live row by (tag, res) for the rest of the
|
|
59
|
+
// metadata fields.
|
|
60
|
+
// - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
|
|
61
|
+
// — inline because the row is gone from workspace_object after the
|
|
62
|
+
// delete commit.
|
|
63
|
+
export type BusMessage =
|
|
64
|
+
| { kind: 'rev'; tag: string; id: string }
|
|
65
|
+
| { kind: 'objput'; tag: string; res: string }
|
|
66
|
+
| { kind: 'objdel'; tag: string; res: string; ver: number }
|
|
67
|
+
|
|
68
|
+
// Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
|
|
69
|
+
// runs once per remote message; failures are logged but don't crash the
|
|
70
|
+
// LISTEN loop — a missed broadcast surfaces to clients on reconnect
|
|
71
|
+
// (chain re-pull). Async because the workspace-revision handler does a
|
|
72
|
+
// DB lookup before broadcasting.
|
|
73
|
+
export type BusHandler = (msg: BusMessage) => Promise<void>
|
|
74
|
+
|
|
75
|
+
export type PubSub = {
|
|
76
|
+
// Resolves once LISTEN is active (Client connected + LISTEN
|
|
77
|
+
// acknowledged). Implementations should auto-reconnect on transport
|
|
78
|
+
// failure — publishes during the down window drop on the floor.
|
|
79
|
+
start: (onMessage: BusHandler) => Promise<void>
|
|
80
|
+
publish: (msg: BusMessage) => void
|
|
81
|
+
stop: () => Promise<void>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createNoopPubSub(): PubSub {
|
|
85
|
+
return {
|
|
86
|
+
// eslint-disable-next-line require-await
|
|
87
|
+
start: async () => {},
|
|
88
|
+
publish: () => {},
|
|
89
|
+
// eslint-disable-next-line require-await
|
|
90
|
+
stop: async () => {},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Minimal structural shape of the `Client` form of
|
|
95
|
+
// `@neondatabase/serverless` — pg-compatible, with `notification` events
|
|
96
|
+
// and a connect/end lifecycle. We declare only the surface we touch so
|
|
97
|
+
// the optional peer dep stays optional (no top-level static type
|
|
98
|
+
// imports). The full driver type set is much larger; this slice is what
|
|
99
|
+
// the LISTEN loop relies on.
|
|
100
|
+
export type NeonClient = {
|
|
101
|
+
connect: () => Promise<void>
|
|
102
|
+
query: (text: string, params?: readonly unknown[]) => Promise<unknown>
|
|
103
|
+
end: () => Promise<void>
|
|
104
|
+
on: (event: 'notification', listener: (msg: { channel: string; payload?: string }) => void) => void
|
|
105
|
+
// The driver also emits 'error' on transport failures we need to
|
|
106
|
+
// observe to drive reconnection.
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
|
+
once?: (event: string, listener: (...args: any[]) => void) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type NeonClientCtor = new (connectionString: string) => NeonClient
|
|
112
|
+
|
|
113
|
+
export type NeonPubSubDeps = {
|
|
114
|
+
// Factory returning a fresh Client. Pulled out as a dep so:
|
|
115
|
+
// (a) the optional peer dep stays optional (callers import lazily),
|
|
116
|
+
// (b) tests can swap in a PGlite-backed shim that exercises the
|
|
117
|
+
// publish + LISTEN loop on a single connection.
|
|
118
|
+
newClient: () => NeonClient
|
|
119
|
+
debug: boolean
|
|
120
|
+
// Initial-connect backoff seed and cap. Defaults are reasonable for
|
|
121
|
+
// production; tests override with small values to keep the suite fast.
|
|
122
|
+
reconnectBaseMs?: number
|
|
123
|
+
reconnectCapMs?: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Mutable state of one `createNeonPubSub` instance. Held in a single
|
|
127
|
+
// object so the LISTEN-loop helpers (`tryConnect` / `connectAndListen` /
|
|
128
|
+
// `reconnect`) can be defined at module scope rather than nested inside
|
|
129
|
+
// the factory — keeps `createNeonPubSub` itself under the 80-line cap.
|
|
130
|
+
type NeonState = {
|
|
131
|
+
newClient: () => NeonClient
|
|
132
|
+
debug: boolean
|
|
133
|
+
baseMs: number
|
|
134
|
+
capMs: number
|
|
135
|
+
senderId: string
|
|
136
|
+
// The current Client. Assigned EARLY in `tryConnect` (before
|
|
137
|
+
// `c.connect()` is awaited) so two invariants hold:
|
|
138
|
+
// (a) `c.once('error', ...)`'s `state.client === c` gate passes
|
|
139
|
+
// for errors that fire between `c.connect()` resolving and
|
|
140
|
+
// `c.query('LISTEN …')` resolving (the error listener can
|
|
141
|
+
// only attach AFTER connect returns, so errors strictly
|
|
142
|
+
// DURING connect are still observed via the connect Promise
|
|
143
|
+
// rejecting — the gate matters for the LISTEN window).
|
|
144
|
+
// (b) `stop()` can read `state.client` and call `c.end()` to abort
|
|
145
|
+
// an in-flight `await c.connect()` / `await c.query('LISTEN …')`
|
|
146
|
+
// — without this, a network blackhole during handshake makes
|
|
147
|
+
// SIGTERM hang indefinitely on the connect await.
|
|
148
|
+
// Cleared by `tryConnect`'s catch on failure, and by `stop()` /
|
|
149
|
+
// `reconnect()` when they replace the client.
|
|
150
|
+
client: NeonClient | null
|
|
151
|
+
handler: BusHandler | null
|
|
152
|
+
stopped: boolean
|
|
153
|
+
// Tracks the in-flight (re)connect attempt so `stop()` can await it —
|
|
154
|
+
// otherwise a SIGTERM mid-reconnect would race the Client teardown
|
|
155
|
+
// and leak the underlying WebSocket.
|
|
156
|
+
connectAttempt: Promise<void> | null
|
|
157
|
+
// Reconnect retry counter, reset to 0 after a successful LISTEN.
|
|
158
|
+
attempt: number
|
|
159
|
+
// Set while the loop is parked in `await sleep(delay)` during a
|
|
160
|
+
// reconnect backoff. `stop()` calls it (if present) to kick the
|
|
161
|
+
// loop out IMMEDIATELY rather than waiting up to `capMs` (30 s
|
|
162
|
+
// default) for the timer to fire. Cleared when the sleep returns.
|
|
163
|
+
cancelSleep: (() => void) | null
|
|
164
|
+
// In-flight bus-message handler promises. `dispatchNotification`
|
|
165
|
+
// fires handlers fire-and-forget, but `stop()` awaits this set before
|
|
166
|
+
// returning so the lifecycle's `handle.close()` (which runs after
|
|
167
|
+
// `pubsub.stop()` — see closeDb in server-e2e/index.ts) can't race a
|
|
168
|
+
// handler mid-`handle.revisionById.get` / `getLive`.
|
|
169
|
+
pendingHandlers: Set<Promise<void>>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Notification dispatch. Filters foreign channels (defensive) and our
|
|
173
|
+
// own publish round-trip (Postgres NOTIFY delivers to publishers too).
|
|
174
|
+
function dispatchNotification(state: NeonState, n: { channel: string; payload?: string }): void {
|
|
175
|
+
if (n.channel !== CHANNEL) return
|
|
176
|
+
if (typeof n.payload !== 'string') return
|
|
177
|
+
let parsed: { sender?: unknown; kind?: unknown } & Record<string, unknown>
|
|
178
|
+
try { parsed = JSON.parse(n.payload) as typeof parsed }
|
|
179
|
+
catch { return }
|
|
180
|
+
if (parsed.sender === state.senderId) return
|
|
181
|
+
const msg = parseBusMessage(parsed)
|
|
182
|
+
if (!msg) return
|
|
183
|
+
const fn = state.handler
|
|
184
|
+
if (!fn) return
|
|
185
|
+
// Fire-and-forget: a slow handler can't block the Client's
|
|
186
|
+
// notification dispatch (which would queue further notifications
|
|
187
|
+
// behind it). Errors are logged but don't kill the loop — a missed
|
|
188
|
+
// broadcast surfaces to clients on reconnect via the chain re-pull.
|
|
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).
|
|
192
|
+
const promise: Promise<void> = fn(msg).catch((err) => {
|
|
193
|
+
console.warn('pubsub: handler error:', errStack(err))
|
|
194
|
+
}).finally(() => { state.pendingHandlers.delete(promise) })
|
|
195
|
+
state.pendingHandlers.add(promise)
|
|
196
|
+
}
|
|
197
|
+
|
|
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`.
|
|
202
|
+
async function tryConnect(state: NeonState): Promise<void> {
|
|
203
|
+
const c = state.newClient()
|
|
204
|
+
state.client = c
|
|
205
|
+
try {
|
|
206
|
+
await c.connect()
|
|
207
|
+
c.on('notification', (n) => dispatchNotification(state, n))
|
|
208
|
+
// Transport-level error → reconnect trigger. Defer to a microtask
|
|
209
|
+
// so the current notification (if any) finishes before we replace
|
|
210
|
+
// the client. The `state.client === c` gate skips stale error
|
|
211
|
+
// events from PREVIOUS clients we've already torn down.
|
|
212
|
+
c.once?.('error', (err: Error) => {
|
|
213
|
+
if (state.debug) console.warn('pubsub: client error:', errStack(err))
|
|
214
|
+
queueMicrotask(() => { if (state.client === c) void reconnect(state) })
|
|
215
|
+
})
|
|
216
|
+
await c.query(`LISTEN ${CHANNEL}`)
|
|
217
|
+
} catch (err) {
|
|
218
|
+
// Clear `state.client` only if it still points at OUR client — a
|
|
219
|
+
// racing `stop()` may have already null'd it and ended the
|
|
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).
|
|
223
|
+
if (state.client === c) state.client = null
|
|
224
|
+
try { await c.end() } catch {}
|
|
225
|
+
throw err
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Outer (re)connect loop. Exits silently on `state.stopped`; otherwise
|
|
230
|
+
// retries with exponential backoff after a connect / LISTEN failure.
|
|
231
|
+
// `tryConnect` owns `state.client`, so this loop only counts attempts
|
|
232
|
+
// and runs the backoff sleep.
|
|
233
|
+
async function connectAndListen(state: NeonState): Promise<void> {
|
|
234
|
+
// oxlint-disable-next-line no-unmodified-loop-condition
|
|
235
|
+
while (!state.stopped) {
|
|
236
|
+
try {
|
|
237
|
+
await tryConnect(state)
|
|
238
|
+
state.attempt = 0
|
|
239
|
+
if (state.debug) console.log(`pubsub: LISTEN ${CHANNEL} (sender ${state.senderId})`)
|
|
240
|
+
return
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (state.stopped) return
|
|
243
|
+
state.attempt += 1
|
|
244
|
+
const delay = Math.min(state.capMs, state.baseMs * 2 ** Math.min(state.attempt - 1, 8))
|
|
245
|
+
console.warn(`pubsub: connect failed (attempt ${state.attempt}), retrying in ${delay}ms:`, errStack(err))
|
|
246
|
+
await cancellableSleep(state, delay)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Sleep that `stop()` can wake up. Without the cancel hook, a SIGTERM
|
|
252
|
+
// landing mid-backoff would stall shutdown for up to `capMs` (30 s
|
|
253
|
+
// default) waiting on the timer. We stash the cancel callback on the
|
|
254
|
+
// shared state object so `stop()` can fire it; the loop's
|
|
255
|
+
// `if (state.stopped) return` then exits on the next turn. The
|
|
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.
|
|
259
|
+
function cancellableSleep(state: NeonState, ms: number): Promise<void> {
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
let settled = false
|
|
262
|
+
const finish = (): void => {
|
|
263
|
+
if (settled) return
|
|
264
|
+
settled = true
|
|
265
|
+
state.cancelSleep = null
|
|
266
|
+
resolve()
|
|
267
|
+
}
|
|
268
|
+
const timer = setTimeout(finish, ms)
|
|
269
|
+
timer.unref?.()
|
|
270
|
+
state.cancelSleep = () => { clearTimeout(timer); finish() }
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function reconnect(state: NeonState): Promise<void> {
|
|
275
|
+
if (state.stopped) return
|
|
276
|
+
const old = state.client
|
|
277
|
+
state.client = null
|
|
278
|
+
if (old) { try { await old.end() } catch {} }
|
|
279
|
+
if (state.connectAttempt) return // a connect is already running
|
|
280
|
+
state.connectAttempt = connectAndListen(state).finally(() => { state.connectAttempt = null })
|
|
281
|
+
await state.connectAttempt
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Neon-mode PubSub. Holds ONE Client for LISTEN (long-lived WebSocket)
|
|
285
|
+
// and reuses it for publish (`pg_notify` from the same session — no
|
|
286
|
+
// need for a separate write connection, and binds the publish-vs-notify
|
|
287
|
+
// ordering: a NOTIFY a peer publishes RIGHT AFTER a save commit is
|
|
288
|
+
// guaranteed to follow the commit in WAL order from the peer's POV).
|
|
289
|
+
//
|
|
290
|
+
// Reconnection: on transport error the loop retries with exponential
|
|
291
|
+
// backoff. Publishes during the down window are dropped — they're a
|
|
292
|
+
// best-effort fan-out, not a durability mechanism. The DB is the
|
|
293
|
+
// source of truth; a client whose peer missed a live broadcast catches
|
|
294
|
+
// up via the chain on its next subscribe.
|
|
295
|
+
export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
|
|
296
|
+
const state: NeonState = {
|
|
297
|
+
newClient: deps.newClient, debug: deps.debug,
|
|
298
|
+
baseMs: deps.reconnectBaseMs ?? 1_000,
|
|
299
|
+
capMs: deps.reconnectCapMs ?? 30_000,
|
|
300
|
+
senderId: newSenderId(),
|
|
301
|
+
client: null, handler: null, stopped: false,
|
|
302
|
+
connectAttempt: null, attempt: 0, cancelSleep: null,
|
|
303
|
+
pendingHandlers: new Set(),
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
start: async (onMessage) => {
|
|
307
|
+
state.handler = onMessage
|
|
308
|
+
state.connectAttempt = connectAndListen(state)
|
|
309
|
+
await state.connectAttempt.finally(() => { state.connectAttempt = null })
|
|
310
|
+
},
|
|
311
|
+
publish: (msg) => publish(state, msg),
|
|
312
|
+
stop: async () => {
|
|
313
|
+
state.stopped = true
|
|
314
|
+
// Null `handler` BEFORE the awaits so any notification that
|
|
315
|
+
// sneaks in (between `c.end()` and the socket actually closing)
|
|
316
|
+
// sees no handler and drops in `dispatchNotification`.
|
|
317
|
+
state.handler = null
|
|
318
|
+
// Kick the loop out of its backoff sleep IMMEDIATELY rather than
|
|
319
|
+
// letting `stop()` block for up to `capMs` (30 s default) on the
|
|
320
|
+
// timer. The loop's `if (state.stopped) return` runs on the next
|
|
321
|
+
// turn and exits cleanly.
|
|
322
|
+
state.cancelSleep?.()
|
|
323
|
+
// End the current client to abort an in-flight handshake (cancels
|
|
324
|
+
// a hung `await c.connect()` / `await c.query('LISTEN …')` so
|
|
325
|
+
// `stop()` doesn't hang on a Neon WS blackhole) OR close an
|
|
326
|
+
// established LISTEN session. With `tryConnect`'s eager assign,
|
|
327
|
+
// `state.client` covers both cases via the same field.
|
|
328
|
+
const c = state.client
|
|
329
|
+
state.client = null
|
|
330
|
+
if (c) { try { await c.end() } catch {} }
|
|
331
|
+
// Now wait for the (now-aborted-if-applicable) connect attempt to
|
|
332
|
+
// unwind through its catch and resolve.
|
|
333
|
+
if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
|
|
334
|
+
// Drain in-flight bus-message handlers BEFORE returning. The
|
|
335
|
+
// lifecycle teardown runs `pubsub.stop()` and THEN
|
|
336
|
+
// `handle.close()` (see closeDb in server-e2e/index.ts); a handler
|
|
337
|
+
// still in `handle.revisionById.get` / `getLive` would otherwise
|
|
338
|
+
// throw against a closed DB. `allSettled` so one handler's
|
|
339
|
+
// rejection doesn't abort the drain.
|
|
340
|
+
if (state.pendingHandlers.size > 0) {
|
|
341
|
+
await Promise.allSettled([...state.pendingHandlers])
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function publish(state: NeonState, msg: BusMessage): void {
|
|
348
|
+
const c = state.client
|
|
349
|
+
if (!c) {
|
|
350
|
+
if (state.debug) console.warn('pubsub: publish dropped (no client):', msg.kind, msg.tag.slice(0, 12))
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
// `pg_notify(text, text)` is the parameter-bound form of NOTIFY —
|
|
354
|
+
// the bare `NOTIFY` statement doesn't accept params. The envelope
|
|
355
|
+
// carries the sender id so the LISTENing connection on this same
|
|
356
|
+
// process filters its own publishes (Postgres delivers NOTIFY back
|
|
357
|
+
// to publishers too).
|
|
358
|
+
const envelope = JSON.stringify({ sender: state.senderId, ...msg })
|
|
359
|
+
// Fire-and-forget. A failed publish only means peers on OTHER
|
|
360
|
+
// instances miss THIS event — local fan-out already happened before
|
|
361
|
+
// this call. Log + continue (the bus is a best-effort accelerator,
|
|
362
|
+
// not a durability layer).
|
|
363
|
+
c.query(`SELECT pg_notify($1, $2)`, [CHANNEL, envelope]).catch((err) => {
|
|
364
|
+
if (state.debug) console.warn('pubsub: publish error:', errStack(err))
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Narrow a parsed JSON object into a typed BusMessage. Rejects anything
|
|
369
|
+
// missing required fields, with non-string tag/id/res, or a non-integer
|
|
370
|
+
// version. Defensive against bus poisoning by a peer running an older /
|
|
371
|
+
// custom build.
|
|
372
|
+
function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
|
|
373
|
+
const tag = raw['tag']
|
|
374
|
+
const kind = raw['kind']
|
|
375
|
+
if (typeof tag !== 'string') return null
|
|
376
|
+
if (kind === 'rev') {
|
|
377
|
+
const id = raw['id']
|
|
378
|
+
if (typeof id !== 'string') return null
|
|
379
|
+
return { kind: 'rev', tag, id }
|
|
380
|
+
}
|
|
381
|
+
if (kind === 'objput') {
|
|
382
|
+
const res = raw['res']
|
|
383
|
+
if (typeof res !== 'string') return null
|
|
384
|
+
return { kind: 'objput', tag, res }
|
|
385
|
+
}
|
|
386
|
+
if (kind === 'objdel') {
|
|
387
|
+
const res = raw['res']
|
|
388
|
+
const ver = raw['ver']
|
|
389
|
+
if (typeof res !== 'string') return null
|
|
390
|
+
if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
|
|
391
|
+
return { kind: 'objdel', tag, res, ver }
|
|
392
|
+
}
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
@@ -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 }
|
|
@@ -101,7 +92,7 @@ export function canonicalSave(
|
|
|
101
92
|
// confusing diff in the canonical bytes rather than a clean drop.
|
|
102
93
|
// Mirrors the `isSafeNonNegativeInt` rigor that the objstore
|
|
103
94
|
// canonical builders apply. Input-validation audit
|
|
104
|
-
// `server/sign.ts:88`.
|
|
95
|
+
// `server-e2e/sign.ts:88`.
|
|
105
96
|
if (typeof workspaceTag !== 'string') throw new TypeError('canonicalSave: workspaceTag must be string')
|
|
106
97
|
if (typeof nonce !== 'string') throw new TypeError('canonicalSave: nonce must be string')
|
|
107
98
|
if (typeof ciphertext !== 'string') throw new TypeError('canonicalSave: ciphertext must be string')
|
|
@@ -120,11 +111,18 @@ 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
|
|
|
127
|
-
// Exported because the v1.objstore signing module (server/objstore/sign.ts)
|
|
125
|
+
// Exported because the v1.objstore signing module (server-e2e/objstore/sign.ts)
|
|
128
126
|
// reuses it for its four verifiers — same workspaceTag-as-pubkey contract,
|
|
129
127
|
// same domain-separated canonical bytes. Keeping the WebCrypto plumbing
|
|
130
128
|
// in one place avoids drift between the triage-sync and objstore verify
|
|
@@ -159,7 +157,7 @@ export async function verifyEd25519(
|
|
|
159
157
|
// `{ ok: false, canonical: null }` on bad shape / bad sig;
|
|
160
158
|
// `{ ok: true, canonical: <bytes> }` on success.
|
|
161
159
|
//
|
|
162
|
-
// NOT used by `server/index.ts handleSave`. Production composes
|
|
160
|
+
// NOT used by `server-e2e/index.ts handleSave`. Production composes
|
|
163
161
|
// the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
|
|
164
162
|
// `verifyEd25519`) separately so the dup-precheck via
|
|
165
163
|
// `revisionExists` can fire BEFORE Ed25519-verify and skip the
|
|
@@ -206,7 +204,7 @@ export async function computeRevisionIdFromCanonical(canonical: Uint8Array<Array
|
|
|
206
204
|
|
|
207
205
|
// `connectionNonce` is the per-socket challenge the server issued
|
|
208
206
|
// to the originating connection (see `Peer.challenge` in
|
|
209
|
-
// server/peer.ts). The client signs a canonical that includes the
|
|
207
|
+
// server-e2e/peer.ts). The client signs a canonical that includes the
|
|
210
208
|
// nonce; verifying against the SAME nonce here is what blocks
|
|
211
209
|
// cross-connection replay of a captured subscribe frame. Audit
|
|
212
210
|
// round-9 H2.
|