cc-viewer 1.9.4 → 1.9.5

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.
@@ -27,6 +27,7 @@ import { join } from 'node:path';
27
27
  import { reportSwallowed } from '@ccv/core/error-report';
28
28
  import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
29
29
  import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
+ import { filterClientsByInstance } from './view-router.js';
30
31
  import { SessionSynthesizer } from './adapter.js';
31
32
  import { isDiscardableSession } from './session-select.js';
32
33
  import { isForeignLiveOwned } from './session-owner.js';
@@ -214,14 +215,14 @@ export class V2LiveFeed {
214
215
  * Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
215
216
  * single-instance view) sees every session regardless of `sessionInstance`; a session with
216
217
  * no stamped instance (legacy/external) is likewise visible to all.
218
+ *
219
+ * The project half keeps going through `_clientsFor()` (the server's filter
220
+ * closure — not necessarily the pure predicate); only the instance narrowing
221
+ * is delegated to the shared rule (view-router.filterClientsByInstance) so it
222
+ * can never drift from the server-side scoped broadcasts.
217
223
  */
218
224
  _clientsForInstance(sessionInstance) {
219
- const base = this._clientsFor();
220
- if (!sessionInstance) return base; // session carries no instance → visible to all viewers
221
- let anyInstanceViewer = false;
222
- for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
223
- if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
224
- return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
225
+ return filterClientsByInstance(this._clientsFor(), sessionInstance || null);
225
226
  }
226
227
 
227
228
  /** Begin (or switch to) live-following a project's sessions/ root. */
@@ -38,6 +38,54 @@ export function filterClientsByViewProject(clientList, project, boundProject) {
38
38
  return clientList.filter((c) => c && ((c._ccvViewProject || bound) === project));
39
39
  }
40
40
 
41
+ /**
42
+ * Instance-narrowed variant of filterClientsByViewProject (multi-instance,
43
+ * 2026-10-06 rule lifted from live-feed._clientsForInstance): a frame stamped
44
+ * with an instance is visible to every viewer of the project EXCEPT clients
45
+ * stamped with a DIFFERENT instance. Unset on either side ⇒ visible (a client
46
+ * with no `_ccvViewInstance` stamp sees every instance; a frame with no
47
+ * instance reaches all of the project's viewers).
48
+ *
49
+ * @param {Array} clientList - SSE response objects.
50
+ * @param {string} project - the frame's project ('' ⇒ bound).
51
+ * @param {string|null} instance - the frame's instance, when known.
52
+ * @param {string} boundProject - the server's bound project.
53
+ * @returns {Array} the subset of clients that should receive the frame.
54
+ */
55
+ export function filterClientsByScope(clientList, project, instance, boundProject) {
56
+ // Composition over duplication: project half, then the shared instance rule —
57
+ // the two can never drift apart.
58
+ return filterClientsByInstance(filterClientsByViewProject(clientList, project, boundProject), instance);
59
+ }
60
+
61
+ /**
62
+ * Instance-only narrowing over an ALREADY project-filtered client list — the
63
+ * live-feed composes its own project filter (`opts.clientFilter`, which may be
64
+ * a server-supplied closure rather than the pure predicate) with this rule.
65
+ * Same semantics as the instance half of filterClientsByScope: unset on either
66
+ * side ⇒ visible; nobody instance-stamped ⇒ the input array (same reference).
67
+ *
68
+ * @param {Array} clientList - already project-filtered SSE responses.
69
+ * @param {string|null} instance - the frame's instance, when known.
70
+ * @returns {Array}
71
+ */
72
+ export function filterClientsByInstance(clientList, instance) {
73
+ if (!instance) return clientList;
74
+ // Exclude ONLY clients explicitly stamped with a DIFFERENT instance. An
75
+ // UNSTAMPED (project-level) viewer of the same project must also receive the
76
+ // frame: if an earlier `active:true` frame reached it while no instance viewer
77
+ // was online, the matching `active:false` must reach it too or its spinner
78
+ // never clears (the clear would be narrowed away the moment a parallel
79
+ // instance chip connects). Keeping active/clear on the SAME recipient set
80
+ // avoids that split delivery.
81
+ // Fast path: nobody is excluded (no differently-stamped client) ⇒ return the
82
+ // SAME array reference (no filter alloc), preserving legacy identity.
83
+ let anyExcluded = false;
84
+ for (const c of clientList) { if (c && c._ccvViewInstance && c._ccvViewInstance !== instance) { anyExcluded = true; break; } }
85
+ if (!anyExcluded) return clientList;
86
+ return clientList.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === instance);
87
+ }
88
+
41
89
  /**
42
90
  * Extract the owning project dir name from a session dir path of the form
43
91
  * `<LOG_DIR>/<projectSan>/sessions/<dirName>`. Returns the on-disk `<projectSan>`
@@ -39,7 +39,7 @@ function _signature(p) {
39
39
  function _isJournalFile(f) { return f.startsWith('wf_') && f.endsWith('.json'); }
40
40
 
41
41
  function _scan(state) {
42
- const { workflowsDir, sessionId, project, clients, seen } = state;
42
+ const { workflowsDir, sessionId, project, clients, clientFilter, seen } = state;
43
43
  let files;
44
44
  try { files = readdirSync(workflowsDir); } catch { return; }
45
45
  for (const f of files) {
@@ -51,7 +51,11 @@ function _scan(state) {
51
51
  seen.set(f, sig);
52
52
  const data = readNormalizedJournal(p);
53
53
  if (!data) continue;
54
- sendEventToClients(clients, 'workflow_update', {
54
+ // Per-workspace: a journal belongs to one project; when a client filter is
55
+ // installed the frame reaches only that project's viewers (legacy default:
56
+ // broadcast to all).
57
+ const target = typeof clientFilter === 'function' ? clientFilter(clients, project) : clients;
58
+ sendEventToClients(target, 'workflow_update', {
55
59
  sessionId,
56
60
  project: project || null,
57
61
  runId: data.runId,
@@ -99,7 +103,7 @@ function _baseline(state) {
99
103
  * 武装对某 workflows 目录的监视。同目录重复调用只刷新 clients 引用。
100
104
  * @param {{ workflowsDir: string, sessionId?: string, project?: string, clients: Array }} opts
101
105
  */
102
- export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } = {}) {
106
+ export function armWorkflowWatch({ workflowsDir, sessionId, project, clients, clientFilter } = {}) {
103
107
  if (!workflowsDir || !Array.isArray(clients)) return null;
104
108
 
105
109
  const existing = _armed.get(workflowsDir);
@@ -107,6 +111,7 @@ export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } =
107
111
 
108
112
  const state = {
109
113
  workflowsDir, sessionId, project, clients,
114
+ clientFilter: typeof clientFilter === 'function' ? clientFilter : null,
110
115
  seen: new Map(), watcher: null,
111
116
  debounceTimer: null, safetyTimer: null, presenceTimer: null,
112
117
  };
@@ -177,13 +182,14 @@ function _authoritativeJournalExists(runDir, runId) {
177
182
  }
178
183
 
179
184
  function _scanLive(state) {
180
- const { runDir, runId, sessionId, project, clients } = state;
185
+ const { runDir, runId, sessionId, project, clients, clientFilter } = state;
181
186
  const data = deriveLiveJournal(runDir, runId);
182
187
  if (data) {
183
188
  const sig = _liveSignature(data);
184
189
  if (sig !== state.lastSig) { // 有实质变化才广播
185
190
  state.lastSig = sig;
186
- sendEventToClients(clients, 'workflow_update', {
191
+ const target = typeof clientFilter === 'function' ? clientFilter(clients, project) : clients;
192
+ sendEventToClients(target, 'workflow_update', {
187
193
  sessionId,
188
194
  project: project || null,
189
195
  runId: data.runId,
@@ -228,7 +234,7 @@ function _startLiveWatch(state) {
228
234
  * 同 runDir 重复调用只刷新 clients。arm 时立即广播一次当前推导态(闭合 REST/arm 竞态)。
229
235
  * @param {{ runDir: string, runId: string, sessionId?: string, project?: string, clients: Array }} opts
230
236
  */
231
- export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients } = {}) {
237
+ export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients, clientFilter } = {}) {
232
238
  if (!runDir || !runId || !Array.isArray(clients)) return null;
233
239
 
234
240
  const existing = _armedLive.get(runDir);
@@ -236,6 +242,7 @@ export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, client
236
242
 
237
243
  const state = {
238
244
  runDir, runId, sessionId, project, clients,
245
+ clientFilter: typeof clientFilter === 'function' ? clientFilter : null,
239
246
  lastSig: null, watcher: null,
240
247
  debounceTimer: null, safetyTimer: null, presenceTimer: null,
241
248
  };
@@ -0,0 +1,473 @@
1
+ /**
2
+ * WebSocket stream mux for the dialogue channel (`/ws/mux`).
3
+ *
4
+ * Carries N logical /events streams over ONE shared WebSocket, so the browser's
5
+ * HTTP/1.1 ~6-connections-per-origin ceiling (which pins SSE to maxChannels=3)
6
+ * no longer bounds how many workspaces stream live concurrently. The SSE path
7
+ * is kept intact as the automatic fallback: when the mux cannot serve (no
8
+ * route, handshake refused, capability absent) the client reopens the SAME
9
+ * scope URL over a native EventSource with its since/cc cursor — no data loss.
10
+ *
11
+ * Frame protocol (text JSON, graphql-ws style envelope):
12
+ * c→s { type:'open', streamId, qs } qs = the RAW ?query string a native
13
+ * EventSource URL would carry (passthrough;
14
+ * we never re-derive scope field-by-field,
15
+ * so `?limit` absence≠0 and sid/instance
16
+ * regexes stay byte-identical to /events)
17
+ * c→s { type:'close', streamId }
18
+ * c→s { type:'pong', ts }
19
+ * s→c { type:'open-ack', streamId, epoch, seq } ack precedes every frame of the stream
20
+ * s→c { type:'frame', streamId, seq, name, data } name:null ⇔ bare `data:`; data is the
21
+ * verbatim JSON string (never re-stringified)
22
+ * s→c { type:'error', streamId, code, message } per-stream; terminates only that stream
23
+ * s→c { type:'ping', ts } 25s app-level keepalive (browsers cannot send
24
+ * WS control-frame pings, so liveness is app-level)
25
+ *
26
+ * Reliability: a stream's lifetime == its socket's lifetime (mirrors SSE: one
27
+ * EventSource ⇔ one connection, reconnect = new connection + since/cc). There is
28
+ * NO server-side frame replay across sockets; resume semantics live entirely in
29
+ * the existing since/cc cursor. seq is per-stream monotonic, epoch is per-process;
30
+ * the client dedups on the per-stream seq alone (streamId is re-minted on every reconnect, so a
31
+ * replayed frame from a prior incarnation can never collide); epoch is diagnostic only.
32
+ *
33
+ * The KEY design move is the sink shim: each logical stream masquerades as an SSE
34
+ * `res` inside the shared `clients` array, so the broadcast hot path
35
+ * (log-watcher's send*ToClients / _safeSseWrite / filterClientsByScope) needs ZERO
36
+ * changes. The shim is a real EventEmitter because `awaitWireDrain`/`_safeSseWrite`
37
+ * use on/once/off('drain'|'close'|'error') on it.
38
+ *
39
+ * Pure logic: no `ws` module dependency (the WebSocketServer class is injected) and no
40
+ * routes/ dependency (serveStream is injected by server.js), so this stays L1 and
41
+ * unit-testable with fakes — same house style as lib/ws-backpressure.js.
42
+ */
43
+ import { randomBytes } from 'node:crypto';
44
+ import { EventEmitter } from 'node:events';
45
+ import { reportSwallowed as defaultReport } from '@ccv/core/error-report';
46
+ import { createBackpressureGate } from './ws-backpressure.js';
47
+
48
+ const STREAM_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
49
+ const HEARTBEAT_SEND_MS = 25_000; // app-level ping cadence (chisel: proxies kill idle conns)
50
+ const HEARTBEAT_REAP_MS = 60_000; // no inbound frame for this long → terminate the socket
51
+ const MAX_STREAMS_PER_SOCKET = 8;
52
+ // Per-socket queue is a pure MEMORY SAFETY NET (the primary slow-consumer eviction is the
53
+ // per-stream 30s backpressure rule inherited from _safeSseWrite). Sized well above any
54
+ // legitimate burst so it only trips on a wedged socket, never on a fast-producer burst.
55
+ const QUEUE_MAX_BYTES = 8 * 1024 * 1024;
56
+ const HIGH_WATER = 1024 * 1024; // mirrors terminal's hysteresis band
57
+ const LOW_WATER = 256 * 1024;
58
+
59
+ /** Per-stream application error codes (WS close-code range, graphql-ws 44xx semantics). */
60
+ export const MUX_CODES = {
61
+ BAD_REQUEST: 4400, // open frame failed validation
62
+ DUPLICATE_STREAM: 4409, // streamId already live on this socket
63
+ TOO_MANY_STREAMS: 4429, // socket stream cap reached
64
+ SCOPE_UNAVAILABLE: 4503, // this scope cannot be served over mux → client falls back (this stream only)
65
+ };
66
+
67
+ export function encodeFrame(obj) {
68
+ return JSON.stringify(obj);
69
+ }
70
+
71
+ /** Parse one inbound text frame. Returns null on anything that is not a {type:string} object. */
72
+ export function decodeFrame(raw) {
73
+ try {
74
+ const o = JSON.parse(typeof raw === 'string' ? raw : String(raw));
75
+ return (o && typeof o === 'object' && typeof o.type === 'string') ? o : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Parse ONE complete SSE frame body (no trailing blank line) into {name, data}.
83
+ * Implements the SSE field rules rather than assuming a single data line: strip one
84
+ * leading space after the colon, join multiple `data:` lines with '\n', drop unknown
85
+ * field lines. `name` is null for a bare `data:` frame. Returns null when there is no
86
+ * data field at all (a frame this codebase never legitimately emits).
87
+ *
88
+ * NOTE: only the two shapes this codebase emits are exercised in production —
89
+ * `data: <json>` and `event: <name>\ndata: <json>` (JSON.stringify escapes newlines,
90
+ * so data is always single-line). The multi-line tolerance is forward defense; an
91
+ * unexpected non-data/event field line is reported as a protocol-drift signal.
92
+ */
93
+ export function parseSseFrame(body, report = defaultReport) {
94
+ const lines = body.split('\n');
95
+ let name = null;
96
+ const dataLines = [];
97
+ for (const line of lines) {
98
+ if (line.startsWith('data:')) {
99
+ dataLines.push(line.startsWith('data: ') ? line.slice(6) : line.slice(5));
100
+ } else if (line.startsWith('event:')) {
101
+ name = line.startsWith('event: ') ? line.slice(7) : line.slice(6);
102
+ } else if (line === '' || line.startsWith(':')) {
103
+ // blank / comment line — ignore
104
+ } else {
105
+ report('ws.mux.parse', new Error('unexpected SSE field line'), { line: line.slice(0, 16) });
106
+ }
107
+ }
108
+ if (dataLines.length === 0) return null;
109
+ return { name, data: dataLines.join('\n') };
110
+ }
111
+
112
+ /**
113
+ * Validate an inbound `open` frame. Returns { ok:true, streamId, qs } or
114
+ * { ok:false, code, message }. qs is passed through verbatim (only shape-checked to be
115
+ * a string without a leading '?'); the scope/since/cc/limit semantics are re-parsed by
116
+ * the SAME paramsFromParsedUrl the SSE route uses, so the two transports never drift.
117
+ */
118
+ export function validateOpenParams(msg) {
119
+ const streamId = msg.streamId;
120
+ if (typeof streamId !== 'string' || !STREAM_ID_RE.test(streamId)) {
121
+ return { ok: false, code: MUX_CODES.BAD_REQUEST, message: 'invalid streamId' };
122
+ }
123
+ const qs = msg.qs;
124
+ if (qs !== null && qs !== undefined && (typeof qs !== 'string' || qs.startsWith('?') || qs.length > 4096)) {
125
+ return { ok: false, code: MUX_CODES.BAD_REQUEST, message: 'invalid qs' };
126
+ }
127
+ return { ok: true, streamId, qs: qs || '' };
128
+ }
129
+
130
+ /**
131
+ * The sink shim: one logical stream presented to the broadcast hot path as an SSE `res`.
132
+ * A REAL EventEmitter because _safeSseWrite / awaitWireDrain attach on/once/off('drain'|
133
+ * 'close'|'error') to it; a hand-rolled once() would TypeError inside awaitWireDrain and
134
+ * (with no unhandledRejection backstop in the dispatcher) kill the process.
135
+ */
136
+ class MuxSink extends EventEmitter {
137
+ constructor(st, streamId) {
138
+ super();
139
+ this.streamId = streamId;
140
+ this._st = st;
141
+ // The registrable IS the shim itself (it is the object pushed into deps.clients, so the
142
+ // per-stream _ccvView* stamps land on it — never on the shared socket).
143
+ this.registrable = this;
144
+ // SSE-res surface the hot path reads (see log-watcher._safeSseWrite / wire-compress):
145
+ this.destroyed = false; // flipped only when THIS stream ends (never by socket congestion)
146
+ this.writable = true;
147
+ this._wireEnc = undefined; // MUST stay falsy: sseWrite/awaitWireDrain branch on it
148
+ this._sseBackpressureSince = 0;
149
+ this._ccvViewProject = undefined; // stamped by the serve core at push time (per-stream)
150
+ this._ccvViewInstance = undefined;
151
+ this.writableNeedDrain = false;
152
+ this._buf = ''; // cross-call SSE byte reassembly buffer
153
+ this._seq = 0; // per-stream monotonic frame sequence
154
+ this._drainWaiters = [];
155
+ // EventEmitter throws on an unlistened 'error'; the shim only ever reports transport
156
+ // death via 'close', but keep a noop so a stray emit('error') can never crash the process.
157
+ this.on('error', () => {});
158
+ // The serve core registers its dispose hook here at entry.
159
+ this.once('close', () => { this._disposed?.(); });
160
+ }
161
+
162
+ /** serve core entry hook: register the dispose callback (idempotent by the core). */
163
+ onClose(fn) { this._disposed = fn; }
164
+
165
+ alive() { return !this.destroyed && !this._st.closed; }
166
+ needsDrain() { return this._st.behind(); }
167
+
168
+ /** Mirrors wire-compress.awaitWireDrain: resolve on drain / close / 30s timeout. The SAME
169
+ * `done` reference is registered and removed (the previous version off()'d a different
170
+ * identity, leaking one stale listener per backpressure wait → MaxListenersExceededWarning). */
171
+ awaitDrain() {
172
+ return new Promise((resolve) => {
173
+ let settled = false;
174
+ const done = () => {
175
+ if (settled) return;
176
+ settled = true;
177
+ clearTimeout(t);
178
+ this.off('drain', done);
179
+ this.off('close', done);
180
+ resolve();
181
+ };
182
+ const t = setTimeout(done, 30000);
183
+ this.once('drain', done);
184
+ this.once('close', done);
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Opaque byte-append (the serve core splits one load_chunk across 3 write() calls).
190
+ * Reassembles complete SSE frames across calls and forwards each as a {type:'frame'}.
191
+ * Returns false to signal per-stream backpressure (frames are QUEUED, never dropped) —
192
+ * the same threshold semantics _safeSseWrite applies to an SSE res. Never throws.
193
+ */
194
+ write(text) {
195
+ if (this.destroyed) return false;
196
+ this._buf += text;
197
+ let start = 0;
198
+ let idx;
199
+ let ok = true;
200
+ while ((idx = this._buf.indexOf('\n\n', start)) !== -1) {
201
+ const body = this._buf.slice(start, idx);
202
+ start = idx + 2;
203
+ const parsed = parseSseFrame(body, this._st.report);
204
+ if (!parsed) continue;
205
+ this._seq += 1;
206
+ if (!this._st.send({ type: 'frame', streamId: this.streamId, seq: this._seq, name: parsed.name, data: parsed.data })) {
207
+ ok = false;
208
+ }
209
+ }
210
+ if (start > 0) this._buf = this._buf.slice(start);
211
+ this.writableNeedDrain = this._st.behind();
212
+ return ok;
213
+ }
214
+
215
+ /**
216
+ * End THIS logical stream only — never the socket (one socket carries N streams; a single
217
+ * slow stream's 30s eviction must not take its siblings down, unlike SSE where end() closed
218
+ * exactly one EventSource). Emits 'close' asynchronously to mirror Node res semantics (so a
219
+ * `for (const c of clients) c.end()` loop does not skip siblings mid-iteration).
220
+ */
221
+ end() {
222
+ if (this.destroyed) return;
223
+ this.destroyed = true;
224
+ this.writable = false;
225
+ this.writableNeedDrain = false;
226
+ queueMicrotask(() => {
227
+ this.emit('close');
228
+ this._st.closeStream(this.streamId, 'sink-end');
229
+ });
230
+ }
231
+ }
232
+
233
+ export function createStreamMuxServer({
234
+ WebSocketServer,
235
+ serveStream,
236
+ reportSwallowed = defaultReport,
237
+ epoch = randomBytes(6).toString('hex'),
238
+ setInterval: setI = setInterval,
239
+ clearInterval: clearI = clearInterval,
240
+ setTimeout: setT = setTimeout,
241
+ clearTimeout: clearT = clearTimeout,
242
+ now = Date.now,
243
+ } = {}) {
244
+ // perMessageDeflate: the dialogue channel carries the app's largest transfers (cold-load
245
+ // windows); without it the mux would send verbatim JSON where the SSE path gets per-connection
246
+ // brotli (measured 21-88x on real sessions). ws's built-in permessage-deflate (zlib) recovers
247
+ // most of that with no new dependency. (SSE keeps its stronger brotli; this closes the gap.)
248
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: true });
249
+ const sockets = new Set();
250
+ wss.on('error', (err) => reportSwallowed('ws.mux.server', err));
251
+ wss.on('connection', (ws) => handleConnection(ws));
252
+
253
+ function handleConnection(ws) {
254
+ const st = {
255
+ ws,
256
+ streams: new Map(), // streamId -> { sink, handle, disposed }
257
+ lastInboundAt: now(),
258
+ closed: false,
259
+ queue: [], // FIFO of encoded frames held while behind
260
+ queuedBytes: 0,
261
+ _behind: false,
262
+ gate: null,
263
+ pingTimer: null,
264
+ flushTimer: null,
265
+ report: reportSwallowed,
266
+ send: null, behind: null, closeStream: null,
267
+ };
268
+ sockets.add(st);
269
+
270
+ // Drain the queue as a BOUNDED trickle, not a single-tick flood: after each frame, re-check
271
+ // bufferedAmount and stop the moment it climbs back over highWater (a slow consumer's socket
272
+ // buffer would otherwise spike to many times the high-water mark in one resume tick). The
273
+ // trickle self-reschedules (the gate's poll stops once it fires onResume, so we can't rely on
274
+ // it to drive the next trickle); teardown clears the timer.
275
+ const flushQueue = () => {
276
+ if (st.flushTimer) { clearT(st.flushTimer); st.flushTimer = null; }
277
+ while (st.queue.length && !st.closed && st.ws.readyState === 1) {
278
+ if ((st.ws.bufferedAmount || 0) > HIGH_WATER) {
279
+ st._behind = true;
280
+ st.flushTimer = setT(flushQueue, 100);
281
+ st.flushTimer.unref?.();
282
+ return;
283
+ }
284
+ const text = st.queue[0];
285
+ try { st.ws.send(text); } catch (err) {
286
+ reportSwallowed('ws.mux.send', err);
287
+ closeSocket(st, 1011, 'send-failed');
288
+ return;
289
+ }
290
+ st.queue.shift();
291
+ st.queuedBytes -= text.length;
292
+ }
293
+ };
294
+ const fireAllDrain = () => {
295
+ for (const rec of st.streams.values()) rec.sink.emit('drain');
296
+ };
297
+ st.gate = createBackpressureGate({
298
+ getBufferedAmount: () => st.ws.bufferedAmount || 0,
299
+ onResume: () => { st._behind = false; flushQueue(); fireAllDrain(); },
300
+ onBehind: () => { st._behind = true; },
301
+ onTimeout: () => {
302
+ reportSwallowed('ws.mux.backpressure', new Error('timeout'));
303
+ closeSocket(st, 1011, 'backpressure-timeout');
304
+ },
305
+ highWater: HIGH_WATER,
306
+ lowWater: LOW_WATER,
307
+ timeoutMs: 30000,
308
+ });
309
+ st.behind = () => st._behind || st.queuedBytes > 0;
310
+
311
+ // Send-or-enqueue. NEVER drops: while behind, frames accumulate in the bounded queue
312
+ // (order preserved). A wedged client dies by the 30s gate timeout / queue cap, then the
313
+ // client reconnects and resumes via its since/cc cursor. The gate's offer() return is
314
+ // ONLY a signal (its native contract is "skip this send" — the terminal path compensates
315
+ // with data-resync; mux must NOT skip, so we ignore the return for the send decision).
316
+ st.send = (msg) => {
317
+ if (st.closed || st.ws.readyState !== 1) return false;
318
+ const text = encodeFrame(msg);
319
+ st.gate.offer(); // signal only; result deliberately unused
320
+ if (st._behind) {
321
+ if (st.queuedBytes + text.length > QUEUE_MAX_BYTES) {
322
+ closeSocket(st, 1011, 'queue-overflow');
323
+ return false;
324
+ }
325
+ st.queue.push(text);
326
+ st.queuedBytes += text.length;
327
+ return false;
328
+ }
329
+ try { st.ws.send(text); } catch (err) {
330
+ reportSwallowed('ws.mux.send', err);
331
+ closeSocket(st, 1011, 'send-failed');
332
+ return false;
333
+ }
334
+ return true;
335
+ };
336
+
337
+ st.closeStream = (streamId, reason) => {
338
+ const rec = st.streams.get(streamId);
339
+ if (!rec || rec.disposed) return;
340
+ rec.disposed = true;
341
+ st.streams.delete(streamId);
342
+ // Wake any in-flight sink.awaitDrain() waiters (SSE's awaitWireDrain wakes on res
343
+ // 'close'; a mux stream must too, or a serve coroutine hangs the full 30s timeout).
344
+ // end() is the idempotent public path that emits 'close' + notifies us back (guarded
345
+ // by rec.disposed above, so the re-entry is a no-op).
346
+ try { rec.sink.end(); } catch (err) { reportSwallowed('ws.mux.dispose', err); }
347
+ try { rec.handle?.dispose?.(); } catch (err) { reportSwallowed('ws.mux.dispose', err); }
348
+ // Make a server-initiated stream close VISIBLE to the client (a SSE res.end() makes the
349
+ // browser EventSource error+reconnect; a silent mux close would leave the facade OPEN and
350
+ // the channel frozen until its heartbeat). Only when the socket is up and the close did
351
+ // not originate from the client's own close frame.
352
+ if (reason !== 'client-close' && reason !== 'socket-close' && reason !== 'server-stopping' && !st.closed) {
353
+ st.send({ type: 'close', streamId });
354
+ }
355
+ };
356
+
357
+ st.pingTimer = setI(() => {
358
+ if (st.closed) return;
359
+ if (now() - st.lastInboundAt > HEARTBEAT_REAP_MS) {
360
+ closeSocket(st, 1001, 'heartbeat-reap');
361
+ return;
362
+ }
363
+ st.send({ type: 'ping', ts: now() });
364
+ }, HEARTBEAT_SEND_MS);
365
+ st.pingTimer.unref?.();
366
+
367
+ ws.on('message', (raw) => { st.lastInboundAt = now(); onMessage(st, raw); });
368
+ ws.on('close', () => teardown(st, 'socket-close'));
369
+ ws.on('error', (err) => { reportSwallowed('ws.mux.socket', err); teardown(st, 'socket-error'); });
370
+ }
371
+
372
+ function onMessage(st, raw) {
373
+ const msg = decodeFrame(raw);
374
+ if (!msg) {
375
+ st.send({ type: 'error', streamId: null, code: MUX_CODES.BAD_REQUEST, message: 'malformed frame' });
376
+ return;
377
+ }
378
+ if (msg.type === 'pong') return;
379
+ if (msg.type === 'ping') { st.send({ type: 'pong', ts: now() }); return; }
380
+ if (msg.type === 'close') {
381
+ st.closeStream(msg.streamId, 'client-close'); // unknown id: ignore (graphql-ws)
382
+ return;
383
+ }
384
+ if (msg.type === 'open') onOpen(st, msg);
385
+ }
386
+
387
+ function onOpen(st, msg) {
388
+ const v = validateOpenParams(msg);
389
+ if (!v.ok) {
390
+ st.send({ type: 'error', streamId: typeof msg.streamId === 'string' ? msg.streamId : null, code: v.code, message: v.message });
391
+ return;
392
+ }
393
+ if (st.streams.has(v.streamId)) {
394
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.DUPLICATE_STREAM, message: 'duplicate streamId' });
395
+ return;
396
+ }
397
+ if (st.streams.size >= MAX_STREAMS_PER_SOCKET) {
398
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.TOO_MANY_STREAMS, message: 'too many streams' });
399
+ return;
400
+ }
401
+ const sink = new MuxSink(st, v.streamId);
402
+ const rec = { sink, handle: null, disposed: false };
403
+ st.streams.set(v.streamId, rec);
404
+ sink.on('close', () => st.closeStream(v.streamId, 'sink-close'));
405
+ // ack precedes every frame of this stream (history frames included).
406
+ st.send({ type: 'open-ack', streamId: v.streamId, epoch, seq: 0 });
407
+ let handleP;
408
+ try {
409
+ handleP = serveStream(sink, v.qs);
410
+ } catch (err) {
411
+ reportSwallowed('ws.mux.serve', err);
412
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.SCOPE_UNAVAILABLE, message: 'serve failed' });
413
+ st.closeStream(v.streamId, 'serve-throw');
414
+ return;
415
+ }
416
+ // serveStream may return a handle synchronously or a Promise of one (the SSE serve core
417
+ // resolves after the history push). Capture a sync (non-thenable) handle IMMEDIATELY so a
418
+ // close arriving in the same tick still disposes it; a Promise handle lands in the microtask.
419
+ if (handleP && typeof handleP.then !== 'function') {
420
+ rec.handle = handleP;
421
+ if (rec.disposed) { try { handleP?.dispose?.(); } catch (err) { reportSwallowed('ws.mux.dispose', err); } }
422
+ } else {
423
+ Promise.resolve(handleP).then((resolved) => {
424
+ rec.handle = resolved;
425
+ if (rec.disposed) { // socket/stream died while history was streaming
426
+ try { resolved?.dispose?.(); } catch (err) { reportSwallowed('ws.mux.dispose', err); }
427
+ }
428
+ }).catch((err) => {
429
+ reportSwallowed('ws.mux.serve', err);
430
+ st.send({ type: 'error', streamId: v.streamId, code: MUX_CODES.SCOPE_UNAVAILABLE, message: 'serve failed' });
431
+ st.closeStream(v.streamId, 'serve-failed');
432
+ });
433
+ }
434
+ }
435
+
436
+ function closeSocket(st, code, reason) {
437
+ if (st.closed) return;
438
+ for (const streamId of [...st.streams.keys()]) st.closeStream(streamId, 'socket-close');
439
+ try { st.ws.close(code, reason); } catch { /* already gone */ }
440
+ teardown(st, reason);
441
+ }
442
+
443
+ function teardown(st, _reason) {
444
+ if (st.closed) return;
445
+ st.closed = true;
446
+ for (const streamId of [...st.streams.keys()]) st.closeStream(streamId, _reason);
447
+ try { st.gate.dispose(); } catch { /* best-effort */ }
448
+ if (st.pingTimer) { clearI(st.pingTimer); st.pingTimer = null; }
449
+ if (st.flushTimer) { clearT(st.flushTimer); st.flushTimer = null; }
450
+ sockets.delete(st);
451
+ }
452
+
453
+ return {
454
+ handleUpgrade(req, socket, head) {
455
+ try {
456
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
457
+ } catch (err) {
458
+ reportSwallowed('ws.mux.upgrade', err);
459
+ try { socket.destroy(); } catch { /* raced */ }
460
+ }
461
+ },
462
+ /** Terminate every socket (server.close() does NOT reach upgraded sockets) + close wss. */
463
+ close() {
464
+ for (const st of [...sockets]) {
465
+ try { closeSocket(st, 1001, 'server-stopping'); } catch { /* best-effort */ }
466
+ try { st.ws.terminate?.(); } catch { /* already gone */ }
467
+ }
468
+ try { wss.close(); } catch { /* stop/start cycles */ }
469
+ },
470
+ _sockets: sockets,
471
+ _server: wss,
472
+ };
473
+ }