@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.4

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.
@@ -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()` (which is itself a safe int), `len` is compared
116
- // against the Content-Length the REST layer parsed (which already
117
- // gates on `isSafeInteger`), and `ver` is compared against SQLite's
118
- // INTEGER (capacity 2^63). An unsafe-but-integer value in the token
119
- // round-trips through IEEE-754 and could spoof equality with a
120
- // different actual value, or — for `exp` — let an adversarial
121
- // token claim a `Number.MAX_SAFE_INTEGER + 1` expiry that compares
122
- // ambiguously near the IEEE-754 boundary. Matches the rest of the
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
- // Convenience constructors so the WS handler doesn't have to assemble
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
- // replacing the former parallel per-socket WeakMaps (challenge /
3
- // authorized / alive / inflight / tags). The connection handler
4
- // constructs one on accept and holds it in a closure, so the hot
5
- // paths (message dispatch, pong, close) touch fields directly; the
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 authorization flag. Once the `authenticate`
21
- // handshake succeeds, every subsequent first-action on this socket
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 — closes over `state.senderId` and
173
- // `state.handler`. Filters foreign channels (defensive) and our own
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
- // The promise is also tracked in `state.pendingHandlers` so `stop()`
192
- // can drain in-flight handlers BEFORE the lifecycle teardown closes
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, or
203
- // rejects on transport / LISTEN failure. Assigns `state.client = c`
204
- // EAGERLY (before `c.connect()` is awaited) for the two invariants
205
- // documented on `NeonState.client`: the error handler's equality gate
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 (a
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 — don't clobber that). The `c.end()`
227
- // below may be a redundant second call in that race (stop already
228
- // ended it); pg-style Client.end() is idempotent so the second
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` assigns `state.client` itself (see the eager-assign
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 against a race between the timer firing and
266
- // `stop()` racing the same wake-up (Promise resolves are idempotent
267
- // at the runtime layer, but oxlint's `no-multiple-resolved` rule is
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
- const fromStr = from == null ? '' : String(from)
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
 
@@ -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` sends the protocol `challenge` frame as
172
- // its first action; that re-uses the normal default-named SSE
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, and the client echoes it back unchanged, so no
333
- // escapes are possible on the legitimate path. The {1,64} bound is a
334
- // deliberately lenient sanity gate: anything outside the base64url
335
- // alphabet is rejected (and a missing id is treated as "no id"); a
336
- // client that sent a sid this regex doesn't recognise just gets a
337
- // fresh session minted by createSession, no failure mode. The wide
338
- // length window means a future randomId-length change here doesn't
339
- // silently break old clients that round-trip a longer or shorter
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('&')) {
@@ -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
- // Only the *current* response's close terminates the session. A
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
- // Same identity guard as close: drained-out previous responses
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
- // and drop the tags from the served body so the bytes ship without
174
- // the now-redundant in-body hint. ETag + compression run against
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
- // Find character ranges in `html` whose contents are NOT real HTML
277
- // content: comment bodies and script / noscript raw-text bodies. A
278
- // `<link>` whose match offset falls inside any range is preserved
279
- // verbatim (no header lift). Heuristic: a real HTML parser is
280
- // overkill here — the build emits clean, well-formed markup; this is
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 = [
@@ -13,9 +13,8 @@ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, va
13
13
  import { debugTag } from './util.ts'
14
14
  import type { UnauthorizedContext } from './auth.ts'
15
15
 
16
- // `chainForWire` accepts the row shape from `chainFrom` (where
17
- // `keyframe` is the SQLite INTEGER 0 / 1) and returns the same fields
18
- // with `keyframe` normalised to a strict boolean for the wire.
16
+ // Wire shape `chainForWire` produces from a `chainFrom` row, with
17
+ // `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
19
18
  type WireRevision = {
20
19
  base: string | null
21
20
  id: string
@@ -88,14 +87,12 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
88
87
  send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
89
88
  }
90
89
 
91
- // Normalise `keyframe` on outbound chain entries to a strict boolean.
90
+ // Normalise `keyframe` to a strict boolean on outbound chain entries.
92
91
  // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
93
- // raw rows; the wire contract (and the canonical signing payload)
94
- // uses strict `=== true` to mark keyframes. Forwarding the integer
95
- // shape works only because every shipping client coerces via
96
- // `Boolean(rev.keyframe)` before reconstructing the canonical bytes —
97
- // fragile if a future client (or test harness) ever strict-compares.
98
- // Convert once on the send side.
92
+ // raw rows; the wire contract (and canonical signing payload) uses
93
+ // strict `=== true`. Convert once on the send side — forwarding the
94
+ // integer relies on clients coercing via `Boolean()` before rebuilding
95
+ // canonical bytes, fragile against any client that strict-compares.
99
96
  function chainForWire(revisions: RevisionRow[]): WireRevision[] {
100
97
  return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
101
98
  }
@@ -174,19 +171,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
174
171
  sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
175
172
  return
176
173
  }
177
- // NOTE: Earlier revisions auto-subscribed the sending socket here.
178
- // That created a replay vector — a passive observer who captured
179
- // any single valid `workspace-save` frame could replay it from any
180
- // TCP connection forever to attach as a subscriber and silently
181
- // mirror every future encrypted broadcast for the workspace,
182
- // without ever holding the seed (the duplicate-id path returns
183
- // ack-only and doesn't reject the socket). Audit round-9 H1.
184
- //
185
- // The legitimate client always sends an explicit
186
- // `workspace-subscribe` (see `trySendSubscribe` in
187
- // `client/triage-sync.js` — fires on key derivation, on socket
188
- // open, on continuity-break recovery, on dismissError). The
189
- // subscribe path remains the only way to attach as a subscriber.
174
+ // Save does NOT subscribe the sending socket — that would be a
175
+ // replay vector: a passive observer who captured one valid
176
+ // `workspace-save` frame could replay it from any TCP connection to
177
+ // attach as a subscriber and mirror every future encrypted
178
+ // broadcast, without holding the seed (the duplicate-id path returns
179
+ // ack-only, doesn't reject the socket). Explicit `workspace-subscribe`
180
+ // (signs the per-connection challenge nonce) is the ONLY attach path.
181
+ // Audit round-9 H1.
190
182
  const baseNorm = msg.base ?? null
191
183
  // `keyframe === true` is what canonicalSave bound the signature to
192
184
  // (strict equality); the signer's intent is unambiguous here.
@@ -230,13 +222,10 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
230
222
  base: baseNorm,
231
223
  id,
232
224
  })
233
- // Carry `keyframe` as a strict boolean on the broadcast wire —
234
- // peers strict-compare `=== true` (matching the canonical-payload
235
- // contract). The previous shape emitted `keyframe ? 1 : 0` which a
236
- // strict check would treat as non-keyframe, making a replayed
237
- // keyframe look like a regular delta on broadcast paths even though
238
- // the chain-fetch path (chainFrom → SQLite integer) DID round-trip
239
- // correctly.
225
+ // Carry `keyframe` as a strict boolean on the broadcast wire — peers
226
+ // strict-compare `=== true` (matching the canonical-payload
227
+ // contract). An integer 0/1 here would make a keyframe look like a
228
+ // regular delta on broadcast paths.
240
229
  broadcast(tag, {
241
230
  type: 'workspace-state',
242
231
  workspaceTag: tag,
@@ -261,15 +250,19 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
261
250
  }
262
251
 
263
252
  async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
264
- if (typeof msg.workspaceTag !== 'string') return
265
- // Same `string | null` contract as `base` in handleSave. The signed
266
- // canonical uses `String(from)`, but the chain-lookup path
267
- // (`typeof msg.from === 'string' ? msg.from : null`) treats every
268
- // non-string as null — so a legit signer sending `from: { … }`
269
- // would silently take the keyframe-fallback path even though the
270
- // signature was over a different canonical shape. Reject at the
271
- // wire gate.
272
- if (msg.from != null && typeof msg.from !== 'string') return
253
+ // Mirror handleSave's wire gate: length-cap + base64url-alphabet on
254
+ // workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
255
+ // encodes them and verifyEd25519 base64-decodes the signature.
256
+ // Without it a peer can submit up-to-maxPayload strings and force
257
+ // O(n) encode + decode per subscribe before the 32/64-byte length
258
+ // gates reject — a CPU-DoS surface handleSave is already hardened
259
+ // against. `from` keeps the `string | null` contract (null = full-
260
+ // chain catch-up); a legit signer's `from` is a base64url revision
261
+ // id, so any non-string / over-long / wrong-alphabet value can't be
262
+ // one the signature was computed over, and the chain-lookup path
263
+ // (`typeof msg.from === 'string' ? msg.from : null`) still treats a
264
+ // null / absent `from` as the keyframe-fallback.
265
+ if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
273
266
  // The challenge nonce we issued on this socket is bound into the
274
267
  // signed canonical, blocking cross-connection replay of a captured
275
268
  // subscribe frame. A subscribe arriving before we sent the
package/server/util.ts CHANGED
@@ -8,6 +8,15 @@ import { randomBytes } from 'node:crypto'
8
8
  // is an Ed25519 public key; operator logs shouldn't carry it verbatim.
9
9
  export function debugTag(s: string): string { return `${s.slice(0, 12)}…` }
10
10
 
11
+ // Truncated view of a content hash / staging id for logs (objstore GC +
12
+ // 503 diagnostics). Same 12-char prefix convention as `debugTag`; named
13
+ // separately so call sites read as "this is a blob id, not a workspace
14
+ // tag". Tolerates a non-string (logs a placeholder) so a diagnostic
15
+ // path can't itself throw on bad input.
16
+ export function debugId(s: unknown): string {
17
+ return typeof s === 'string' ? `${s.slice(0, 12)}…` : '<no-id>'
18
+ }
19
+
11
20
  // 16 random bytes → 22 base64url chars (no padding). The shared shape
12
21
  // for per-socket challenge nonces and staging ids — collision is
13
22
  // 1/2^128. `isValidStagingId` in objstore/store.ts validates exactly
@@ -55,23 +55,19 @@ export type WsServerDeps = PeerConnectionDeps & {
55
55
  }
56
56
 
57
57
  // Per-connection setup: create Peer, send challenge, wire message /
58
- // close / error / pong listeners. Shared between the WS connection
59
- // handler below and the SSE+POST adapter in ./sse-server.ts. The
60
- // `socket` parameter is the WebSocket-shaped surface (a real `ws`
61
- // `WebSocket`, or an `SseSession` cast through `as unknown as
62
- // WebSocket`); the function only uses the subset both expose.
58
+ // close / error / pong listeners. `socket` is the WebSocket-shaped
59
+ // surface — a real `ws` `WebSocket`, or an `SseSession` cast through
60
+ // `as unknown as WebSocket`; only the subset both expose is used.
63
61
  export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
64
62
  const {
65
63
  peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
66
64
  sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
67
65
  } = deps
68
66
  if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
69
- // One Peer holds this connection's state (challenge / authorized /
70
- // alive / inflight / tags). Created before any client frame can
71
- // arrive (`socket.on('message')` is wired below). The heartbeat
72
- // sweep flips `alive` false after each `ping()`; the `pong` listener
73
- // flips it back, and a socket still false on the next sweep is
74
- // terminated — the only thing closing FDs for an idle peer.
67
+ // One Peer holds this connection's state, created before any client
68
+ // frame can arrive (`socket.on('message')` is wired below). The
69
+ // heartbeat sweep's two-tick liveness check (see below) is the only
70
+ // thing closing FDs for an otherwise-idle peer.
75
71
  const peer = new Peer(randomId())
76
72
  peers.set(socket, peer)
77
73
  socket.on('pong', () => { peer.alive = true })
@@ -228,9 +224,8 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
228
224
  // Surface socket-level errors instead of swallowing — these are
229
225
  // the signals operators want under abuse / network flakiness
230
226
  // (TLS handshake failures, frame-decode errors, ws-protocol
231
- // violations). The previous `() => {}` left every per-connection
232
- // failure invisible. `close` fires after `error` and runs the
233
- // unsubscribe cleanup, so logging here doesn't risk leaking.
227
+ // violations). `close` fires after `error` and runs the unsubscribe
228
+ // cleanup, so logging here doesn't risk leaking.
234
229
  socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
235
230
  }
236
231