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.
- package/cli.js +5 -2
- package/dist/assets/App-Bj_-wfrB.js +2 -0
- package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
- package/dist/assets/{Mobile-OKCVi8un.js → Mobile-BQBQkr2V.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-8VYl6n39.js} +1 -1
- package/dist/assets/{index-Clt4dw8Y.js → index-CloXv0sm.js} +2 -2
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +165 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +4 -1
- package/server/lib/task-state.js +74 -34
- package/server/lib/turn-end-bridge.js +6 -1
- package/server/lib/v2/live-feed.js +7 -6
- package/server/lib/v2/view-router.js +48 -0
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +473 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +180 -84
- package/server/routes/resume.js +17 -0
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +7 -1
- package/server/server.js +335 -112
- package/dist/assets/App-DtBVNYE7.js +0 -2
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +0 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|