cc-viewer 1.7.0 → 1.7.2
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/dist/assets/App-B224r-8M.js +2 -0
- package/dist/assets/{MdxEditorPanel-C5Q6uUyH.js → MdxEditorPanel-BUubiE6Y.js} +1 -1
- package/dist/assets/{Mobile-MoBp9c8k.js → Mobile-CKmfRRvo.js} +1 -1
- package/dist/assets/index-CjsQEQc2.js +2 -0
- package/dist/assets/seqResourceLoaders-C-T_EMiO.js +2 -0
- package/dist/assets/{seqResourceLoaders-CxuD1CYl.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/server/lib/log-management.js +1 -0
- package/server/lib/log-watcher.js +29 -3
- package/server/lib/stats-worker.js +21 -2
- package/server/lib/v2/adapter.js +123 -18
- package/server/lib/v2/journal.js +9 -5
- package/server/lib/v2/jsonl-read.js +127 -0
- package/server/lib/v2/live-feed.js +222 -20
- package/server/lib/v2/meta-rows.js +361 -0
- package/server/lib/v2/replay.js +5 -5
- package/server/lib/v2/session-select.js +69 -2
- package/server/lib/v2/v2-writer.js +15 -0
- package/server/lib/wire-compress.js +167 -0
- package/server/routes/events.js +96 -26
- package/server/routes/im.js +1 -1
- package/server/routes/logs.js +15 -12
- package/server/routes/v2.js +60 -0
- package/server/routes/workspaces.js +7 -6
- package/server/server.js +23 -1
- package/server/workspace-registry.js +5 -0
- package/src/utils/apiUrl.js +26 -0
- package/src/utils/askFallback.js +21 -0
- package/src/utils/askOptionDesc.js +55 -0
- package/src/utils/askPortalMatcher.js +54 -0
- package/src/utils/autoApproveOptions.js +27 -0
- package/src/utils/avatarAnimationPostPass.js +49 -0
- package/src/utils/builtinPresets.js +18 -0
- package/src/utils/clearCheckpoint.js +7 -0
- package/src/utils/commandValidator.js +45 -0
- package/src/utils/contentFilter.js +620 -0
- package/src/utils/contextRaw.js +18 -0
- package/src/utils/contextSidebarNavigation.js +16 -0
- package/src/utils/contextTurns.js +117 -0
- package/src/utils/displayScaleHelper.js +47 -0
- package/src/utils/effectiveModel.js +13 -0
- package/src/utils/entry-slim.js +563 -0
- package/src/utils/entryCache.js +144 -0
- package/src/utils/errorReport.js +6 -0
- package/src/utils/fileExpandedPathsStorage.js +70 -0
- package/src/utils/fileIcons.jsx +36 -0
- package/src/utils/fileOpen.js +37 -0
- package/src/utils/formatters.js +48 -0
- package/src/utils/gitApi.js +50 -0
- package/src/utils/gitTreeBuilder.js +19 -0
- package/src/utils/helpers.js +846 -0
- package/src/utils/identityHeal.js +99 -0
- package/src/utils/imConnState.js +59 -0
- package/src/utils/imOrigin.js +24 -0
- package/src/utils/imTr.js +7 -0
- package/src/utils/imageCompress.js +83 -0
- package/src/utils/imageDownscale.js +28 -0
- package/src/utils/imageResize.js +112 -0
- package/src/utils/ingestPipeline.js +47 -0
- package/src/utils/lazyWithReload.js +53 -0
- package/src/utils/markdown.js +30 -0
- package/src/utils/markdownIncremental.js +39 -0
- package/src/utils/markdownProfiler.js +113 -0
- package/src/utils/mdExtensionDetect.js +64 -0
- package/src/utils/memoryLinkParser.js +44 -0
- package/src/utils/modalMask.js +15 -0
- package/src/utils/monotime.js +6 -0
- package/src/utils/pinnedMenu.js +55 -0
- package/src/utils/presetShortcuts.js +14 -0
- package/src/utils/projectAlias.js +135 -0
- package/src/utils/promptClassifier.js +112 -0
- package/src/utils/promptDetect.js +196 -0
- package/src/utils/promptNav.js +60 -0
- package/src/utils/ptyChunkBuilder.js +251 -0
- package/src/utils/quickMenuHoverIntent.js +35 -0
- package/src/utils/rateLimitParser.js +119 -0
- package/src/utils/readResultPool.js +106 -0
- package/src/utils/refreshCachedItemProp.js +30 -0
- package/src/utils/requestType.js +208 -0
- package/src/utils/resizeCalc.js +21 -0
- package/src/utils/resolveLocalized.js +17 -0
- package/src/utils/searchApi.js +47 -0
- package/src/utils/searchReplace.js +51 -0
- package/src/utils/seqResourceLoaders.js +79 -0
- package/src/utils/sessionManager.js +471 -0
- package/src/utils/sessionMerge.js +180 -0
- package/src/utils/skillModalController.js +111 -0
- package/src/utils/skillsParser.js +116 -0
- package/src/utils/slashCommandLabels.js +80 -0
- package/src/utils/splitDragCalc.js +73 -0
- package/src/utils/stickyBottomController.js +643 -0
- package/src/utils/svgSanitize.js +48 -0
- package/src/utils/systemTags.js +47 -0
- package/src/utils/tClaude.js +20 -0
- package/src/utils/teamModalBuilder.js +351 -0
- package/src/utils/teamSessionParser.js +190 -0
- package/src/utils/teammateAvatars.js +194 -0
- package/src/utils/teammateDetector.js +121 -0
- package/src/utils/termDiag.js +114 -0
- package/src/utils/terminalClipboard.js +117 -0
- package/src/utils/terminalWriteQueue.js +398 -0
- package/src/utils/thinkingMerge.js +49 -0
- package/src/utils/toolCatalog.js +29 -0
- package/src/utils/toolResultBuilder.js +393 -0
- package/src/utils/toolResultClassifier.js +16 -0
- package/src/utils/toolResultCore.js +167 -0
- package/src/utils/toolsDiff.js +44 -0
- package/src/utils/toolsXmlFormatter.js +3 -0
- package/src/utils/ultraplanController.js +109 -0
- package/src/utils/ultraplanExperts.js +86 -0
- package/src/utils/ultraplanTemplates.js +154 -0
- package/src/utils/userImageRefs.js +49 -0
- package/src/utils/v3Assembler.js +112 -0
- package/src/utils/v3Rows.js +48 -0
- package/src/utils/voicePackPlayer.js +233 -0
- package/src/utils/webSearchGrouping.js +111 -0
- package/src/utils/workflowFormat.js +52 -0
- package/src/utils/workflowRuns.js +118 -0
- package/src/utils/workflowStore.js +94 -0
- package/dist/assets/App-DTkacfRW.js +0 -2
- package/dist/assets/index-Ac8vnV3v.js +0 -2
- package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +0 -2
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire v3 (V3.S2) — request-list metadata rows.
|
|
3
|
+
*
|
|
4
|
+
* A row is a journal fold (req + done by seq) plus two body-derived fields
|
|
5
|
+
* (typeTag, cacheLoss) computed in a bounded streaming Pass B over exactly the
|
|
6
|
+
* windowed members: bodies are materialized one at a time, classified, and
|
|
7
|
+
* dropped — the wire carries rows only (~1.3KB each vs full entries).
|
|
8
|
+
*
|
|
9
|
+
* Two DELIBERATE classification divergences from the legacy full-entry list
|
|
10
|
+
* (both more correct; pinned in test/v2-meta-rows.test.js, declared in
|
|
11
|
+
* history.md):
|
|
12
|
+
* - membership is the journal fold — a SUPERSET of the synthesizer's output
|
|
13
|
+
* (conv-gapped crash-orphans get a row; their detail fetch answers 404 and
|
|
14
|
+
* typeTag stays journal-derived);
|
|
15
|
+
* - mainAgent is `kind==='main' && !meta.leader` — the isMain semantics the
|
|
16
|
+
* mainAgentRing already pinned, dropping "main-looking" sub/misc bodies.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join, basename } from 'node:path';
|
|
20
|
+
import { readJsonlTolerant } from './replay.js';
|
|
21
|
+
import { iterateJsonlLines } from './jsonl-read.js';
|
|
22
|
+
import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
|
|
23
|
+
import { SingleFlight } from './singleflight.js';
|
|
24
|
+
import { reportSwallowed } from '../error-report.js';
|
|
25
|
+
import { classifyRequest } from '../../../src/utils/requestType.js';
|
|
26
|
+
|
|
27
|
+
// KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
|
|
28
|
+
const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
|
|
29
|
+
|
|
30
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** KEEP IN SYNC: src/utils/helpers.js stripPrivateKeys. */
|
|
33
|
+
function stripPrivateKeys(obj) {
|
|
34
|
+
if (Array.isArray(obj)) return obj.map(stripPrivateKeys);
|
|
35
|
+
if (obj && typeof obj === 'object') {
|
|
36
|
+
const result = {};
|
|
37
|
+
for (const key of Object.keys(obj)) {
|
|
38
|
+
if (key.startsWith('_')) continue;
|
|
39
|
+
result[key] = stripPrivateKeys(obj[key]);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
return obj;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* KEEP IN SYNC: src/utils/helpers.js _computeCacheLoss (that module cannot be
|
|
48
|
+
* imported server-side — SVG/i18n dependency chain). One deliberate fix: the
|
|
49
|
+
* client computes the ttl gap as `stringTs - stringTs` (NaN, so its ttl branch
|
|
50
|
+
* never fires); here the timestamps are parsed — pinned in the unit test.
|
|
51
|
+
*/
|
|
52
|
+
export function computeCacheLoss(prevEntry, currEntry) {
|
|
53
|
+
const gap = Date.parse(currEntry.timestamp) - Date.parse(prevEntry.timestamp);
|
|
54
|
+
if (gap > CACHE_TTL_MS) return { reason: 'ttl', reasons: ['ttl'] };
|
|
55
|
+
const prev = stripPrivateKeys(prevEntry.body);
|
|
56
|
+
const curr = stripPrivateKeys(currEntry.body);
|
|
57
|
+
if (!prev || !curr) return { reason: 'key_change', reasons: ['key_change'] };
|
|
58
|
+
const reasons = [];
|
|
59
|
+
if (prev.model !== curr.model) reasons.push('model_change');
|
|
60
|
+
if (JSON.stringify(prev.system) !== JSON.stringify(curr.system)) reasons.push('system_change');
|
|
61
|
+
if (JSON.stringify(prev.tools) !== JSON.stringify(curr.tools)) reasons.push('tools_change');
|
|
62
|
+
const prevMsgs = prev.messages || [];
|
|
63
|
+
const currMsgs = curr.messages || [];
|
|
64
|
+
if (currMsgs.length < prevMsgs.length) {
|
|
65
|
+
reasons.push('msg_truncated');
|
|
66
|
+
} else {
|
|
67
|
+
const prefixLen = Math.min(prevMsgs.length, currMsgs.length);
|
|
68
|
+
let prefixMatch = true;
|
|
69
|
+
for (let j = 0; j < prefixLen; j++) {
|
|
70
|
+
if (JSON.stringify(prevMsgs[j]) !== JSON.stringify(currMsgs[j])) { prefixMatch = false; break; }
|
|
71
|
+
}
|
|
72
|
+
if (!prefixMatch) reasons.push('msg_modified');
|
|
73
|
+
}
|
|
74
|
+
if (reasons.length === 0) reasons.push('key_change');
|
|
75
|
+
return { reason: reasons[0], reasons };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readMeta(dir) {
|
|
79
|
+
try { return JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Fold one session dir's journal into row skeletons (no bodies touched). */
|
|
83
|
+
function foldDir(dir) {
|
|
84
|
+
const meta = readMeta(dir);
|
|
85
|
+
const isTeammateDir = !!(meta && meta.leader);
|
|
86
|
+
const sessionId = (meta && meta.sessionId) || basename(dir);
|
|
87
|
+
const reqs = new Map();
|
|
88
|
+
const dones = new Map();
|
|
89
|
+
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
90
|
+
if (line.ph === 'req' && !reqs.has(line.seq)) reqs.set(line.seq, line);
|
|
91
|
+
else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
|
|
92
|
+
}
|
|
93
|
+
const rows = [];
|
|
94
|
+
for (const [seq, r] of reqs) {
|
|
95
|
+
const d = dones.get(seq);
|
|
96
|
+
const usage = d && d.usage ? {
|
|
97
|
+
input_tokens: d.usage.in || 0,
|
|
98
|
+
output_tokens: d.usage.out || 0,
|
|
99
|
+
cache_read_input_tokens: d.usage.cr || 0,
|
|
100
|
+
cache_creation_input_tokens: d.usage.cw || 0,
|
|
101
|
+
} : null;
|
|
102
|
+
rows.push({
|
|
103
|
+
seq,
|
|
104
|
+
sessionId,
|
|
105
|
+
timestamp: r.ts || '',
|
|
106
|
+
url: r.url || '',
|
|
107
|
+
method: r.method || 'POST',
|
|
108
|
+
// V3.S5 assembler inputs: which conv channel this request's messages
|
|
109
|
+
// live in, and whether its event was a snapshot (checkpoint marker).
|
|
110
|
+
conv: r.conv,
|
|
111
|
+
evt: r.evt,
|
|
112
|
+
kind: r.kind || 'misc',
|
|
113
|
+
mainAgent: r.kind === 'main' && !isTeammateDir,
|
|
114
|
+
// v1 entry contract (adapter.js:427): teammate = agentName string | true
|
|
115
|
+
teammate: isTeammateDir ? ((meta.leader && meta.leader.agentName) || true) : undefined,
|
|
116
|
+
model: r.model,
|
|
117
|
+
proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
|
|
118
|
+
status: d && typeof d.http === 'number' ? d.http : undefined,
|
|
119
|
+
duration: d && typeof d.dur === 'number' ? d.dur : undefined,
|
|
120
|
+
usage,
|
|
121
|
+
inProgress: !d,
|
|
122
|
+
typeTag: null, // Pass B
|
|
123
|
+
cacheLoss: null, // Pass B
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return rows;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read the request-list metadata rows for a session (leader + folded
|
|
131
|
+
* teammates), windowed like the entry list: dedup timestamp|url last-wins →
|
|
132
|
+
* `before` filter → tail-`limit`. Pass B streams the window members' bodies
|
|
133
|
+
* once to attach typeTag/cacheLoss.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} sessionDir
|
|
136
|
+
* @param {{limit?: number, before?: string|null, since?: string|null, passB?: boolean}} [opts] -
|
|
137
|
+
* passB:false skips body classification (live increments send rows first
|
|
138
|
+
* and re-send corrections once Pass B / the next request lands);
|
|
139
|
+
* `since` (review P1-2) scopes an incremental reconnect to the delta window
|
|
140
|
+
* — without it every mobile reconnect re-transmits the full session
|
|
141
|
+
* @returns {Promise<{rows: object[], totalCount: number, hasMore: boolean, oldestTimestamp: string}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function readV2RequestsMeta(sessionDir, opts = {}) {
|
|
144
|
+
const limit = opts.limit || 0;
|
|
145
|
+
const before = opts.before || null;
|
|
146
|
+
const since = opts.since || null;
|
|
147
|
+
const meta = readMeta(sessionDir);
|
|
148
|
+
const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
|
|
149
|
+
const dirs = [sessionDir];
|
|
150
|
+
try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
|
|
151
|
+
|
|
152
|
+
// k-way ordering parity: same (ts, sessionId, seq) comparator as iterateV2Items
|
|
153
|
+
const all = dirs.flatMap(foldDir);
|
|
154
|
+
all.sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.seq - b.seq));
|
|
155
|
+
|
|
156
|
+
// dedup timestamp|url last-wins (nokey rows key on seq — stable, unlike array index)
|
|
157
|
+
const dedup = new Map();
|
|
158
|
+
for (const row of all) {
|
|
159
|
+
const key = row.timestamp && row.url ? `${row.timestamp}|${row.url}` : `__nokey_${row.sessionId}_${row.seq}`;
|
|
160
|
+
dedup.set(key, row);
|
|
161
|
+
}
|
|
162
|
+
let rows = [...dedup.values()];
|
|
163
|
+
if (before) rows = rows.filter((r) => r.timestamp && r.timestamp < before);
|
|
164
|
+
const totalCount = rows.length;
|
|
165
|
+
let hasMore = false;
|
|
166
|
+
if (limit > 0 && rows.length > limit) {
|
|
167
|
+
hasMore = true;
|
|
168
|
+
rows = rows.slice(rows.length - limit);
|
|
169
|
+
}
|
|
170
|
+
const oldestTimestamp = rows.length > 0 ? rows[0].timestamp : '';
|
|
171
|
+
// Emission-time since filter (mirrors selectV2Window: applied after the
|
|
172
|
+
// window selection; nokey rows always resend — the legacy contract).
|
|
173
|
+
if (since) rows = rows.filter((r) => !r.timestamp || r.timestamp >= since);
|
|
174
|
+
|
|
175
|
+
if (opts.passB !== false && rows.length > 0) {
|
|
176
|
+
await attachBodyFields(sessionDir, rows);
|
|
177
|
+
}
|
|
178
|
+
return { rows, totalCount, hasMore, oldestTimestamp };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// In-flight coalescing for the v3 cold reads (review P2-b): a reconnect storm
|
|
182
|
+
// is N tabs hitting the same sessionDir at once — without this each ran its
|
|
183
|
+
// own full journal fold + Pass B + conv-file reads. cached:false — /events is
|
|
184
|
+
// a live-attach caller (S10b F5b: a stale TTL window would widen the
|
|
185
|
+
// cold-load→attach broadcast-loss gap), so callers only JOIN in-flight runs.
|
|
186
|
+
const _coldBundleFlight = new SingleFlight({ ttlMs: 500 });
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The complete v3 cold read for one /events connection: windowed rows +
|
|
190
|
+
* native conv/responses payloads, single-flighted per (dir, window).
|
|
191
|
+
*/
|
|
192
|
+
export function readV2ColdBundle(sessionDir, { limit = 0, since = null } = {}) {
|
|
193
|
+
const key = `bundle:${sessionDir}|${limit}|${since || ''}`;
|
|
194
|
+
return _coldBundleFlight.run(key, async () => {
|
|
195
|
+
const meta = await readV2RequestsMeta(sessionDir, { limit, since });
|
|
196
|
+
const native = await readV2NativeCold(sessionDir, meta.rows);
|
|
197
|
+
return { meta, native };
|
|
198
|
+
}, { cached: false });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Wire v3 (V3.S4) cold native lines for the flagged client's assembler:
|
|
203
|
+
* per conv channel, raw lines from the LAST snapshot at-or-before the
|
|
204
|
+
* window's oldest seq (per session) through the end; responses lines for
|
|
205
|
+
* exactly the window member seqs. Lines are forwarded verbatim (string
|
|
206
|
+
* concat, no parse/stringify round trip beyond seq/t sniffing).
|
|
207
|
+
*
|
|
208
|
+
* ASYNC with event-loop yields (review P1-4): a large session's conv files
|
|
209
|
+
* total tens of MB — reading and snapshot-scanning them in one synchronous
|
|
210
|
+
* stretch blocks every connected client for the duration of the cold load.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} sessionDir - leader dir (teammate dirs folded like the rows)
|
|
213
|
+
* @param {object[]} rows - the window rows (readV2RequestsMeta output)
|
|
214
|
+
* @returns {Promise<{convPayloads: string[], respPayloads: string[]}>}
|
|
215
|
+
* pre-serialized JSON payloads for `v3_conv` / `v3_resp` SSE frames
|
|
216
|
+
*/
|
|
217
|
+
export async function readV2NativeCold(sessionDir, rows) {
|
|
218
|
+
const yieldLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
219
|
+
const minSeqBySession = new Map();
|
|
220
|
+
const memberSeqsBySession = new Map();
|
|
221
|
+
for (const r of rows) {
|
|
222
|
+
const cur = minSeqBySession.get(r.sessionId);
|
|
223
|
+
if (cur === undefined || r.seq < cur) minSeqBySession.set(r.sessionId, r.seq);
|
|
224
|
+
let set = memberSeqsBySession.get(r.sessionId);
|
|
225
|
+
if (!set) { set = new Set(); memberSeqsBySession.set(r.sessionId, set); }
|
|
226
|
+
set.add(r.seq);
|
|
227
|
+
}
|
|
228
|
+
const meta = readMeta(sessionDir);
|
|
229
|
+
const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
|
|
230
|
+
const dirs = [sessionDir];
|
|
231
|
+
try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
|
|
232
|
+
|
|
233
|
+
const convPayloads = [];
|
|
234
|
+
const respPayloads = [];
|
|
235
|
+
for (const dir of dirs) {
|
|
236
|
+
const dirMeta = readMeta(dir);
|
|
237
|
+
const sessionId = (dirMeta && dirMeta.sessionId) || basename(dir);
|
|
238
|
+
const minSeq = minSeqBySession.get(sessionId);
|
|
239
|
+
const memberSeqs = memberSeqsBySession.get(sessionId);
|
|
240
|
+
if (minSeq === undefined) continue; // no window rows from this session
|
|
241
|
+
// conv channels: raw lines from the channel's last snapshot ≤ minSeq
|
|
242
|
+
const convRoot = join(dir, 'conversations');
|
|
243
|
+
let keys = [];
|
|
244
|
+
try { keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* no conv dir */ }
|
|
245
|
+
for (const key of keys) {
|
|
246
|
+
let files = [];
|
|
247
|
+
try { files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); } catch { continue; }
|
|
248
|
+
const raws = [];
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
// streamed line-by-line (issue #129): a conv epoch past Node's string
|
|
251
|
+
// cap must degrade to skipped lines, not lose the whole file silently
|
|
252
|
+
try { for (const trimmed of iterateJsonlLines(join(convRoot, key, f))) raws.push(trimmed); }
|
|
253
|
+
catch (err) { reportSwallowed('v2-native.conv-read-failed', err); }
|
|
254
|
+
await yieldLoop(); // one yield per conv file read (error path included)
|
|
255
|
+
}
|
|
256
|
+
// last snapshot at-or-before the window start for this session
|
|
257
|
+
let start = 0;
|
|
258
|
+
for (let i = 0; i < raws.length; i++) {
|
|
259
|
+
try {
|
|
260
|
+
const ev = JSON.parse(raws[i]);
|
|
261
|
+
if (ev.t === 'snapshot' && ev.seq <= minSeq) start = i;
|
|
262
|
+
if (ev.seq > minSeq) break;
|
|
263
|
+
} catch { /* tolerate torn line */ }
|
|
264
|
+
if (i % 2000 === 1999) await yieldLoop();
|
|
265
|
+
}
|
|
266
|
+
const windowRaws = raws.slice(start);
|
|
267
|
+
// Cap each frame at ~512KB of lines: a multi-MB single SSE event forces
|
|
268
|
+
// one giant synchronous JSON.parse on the client's main thread (no
|
|
269
|
+
// paint, no rAF — the loading UI freezes). Small frames are macrotask
|
|
270
|
+
// boundaries: the browser paints and the byte meter ticks between them.
|
|
271
|
+
pushChunked(convPayloads, windowRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"channel":${JSON.stringify(key)},"lines":[${linesJson}]}`);
|
|
272
|
+
}
|
|
273
|
+
// responses: exactly the window member seqs — streamed (issue #129)
|
|
274
|
+
const respRaws = [];
|
|
275
|
+
try {
|
|
276
|
+
for (const trimmed of iterateJsonlLines(join(dir, 'responses.jsonl'))) {
|
|
277
|
+
const m = /"seq":\s*(\d+)/.exec(trimmed);
|
|
278
|
+
if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
|
|
279
|
+
}
|
|
280
|
+
} catch (err) { reportSwallowed('v2-native.responses-read-failed', err); }
|
|
281
|
+
await yieldLoop();
|
|
282
|
+
pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
|
|
283
|
+
}
|
|
284
|
+
return { convPayloads, respPayloads };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const NATIVE_FRAME_BUDGET = 512 * 1024;
|
|
288
|
+
|
|
289
|
+
/** Split raw lines into ≤~512KB payloads (see comment at the conv call site). */
|
|
290
|
+
function pushChunked(out, raws, wrap) {
|
|
291
|
+
let buf = [];
|
|
292
|
+
let bytes = 0;
|
|
293
|
+
const flush = () => {
|
|
294
|
+
if (buf.length > 0) out.push(wrap(buf.join(',')));
|
|
295
|
+
buf = [];
|
|
296
|
+
bytes = 0;
|
|
297
|
+
};
|
|
298
|
+
for (const raw of raws) {
|
|
299
|
+
buf.push(raw);
|
|
300
|
+
bytes += raw.length + 1;
|
|
301
|
+
if (bytes >= NATIVE_FRAME_BUDGET) flush();
|
|
302
|
+
}
|
|
303
|
+
flush();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Bounded streaming Pass B: materialize exactly the rows' bodies (one at a
|
|
308
|
+
* time), classify typeTag with one-entry lookahead (classifyRequest's
|
|
309
|
+
* Preflight/Plan detection reads nextReq), compute cacheLoss against the
|
|
310
|
+
* retained previous mainAgent body, then drop the body.
|
|
311
|
+
*/
|
|
312
|
+
async function attachBodyFields(sessionDir, rows) {
|
|
313
|
+
const rowByKey = new Map(rows.map((r) => [itemKey(r.sessionId, r.seq), r]));
|
|
314
|
+
const materialize = (sessionId, seq) => rowByKey.has(itemKey(sessionId, seq));
|
|
315
|
+
let pending = null; // { row, entry, isMain } awaiting its nextReq for classify
|
|
316
|
+
let prevMainFull = null; // previous mainAgent full-body entry (cacheLoss)
|
|
317
|
+
let n = 0;
|
|
318
|
+
const finish = (slot, nextEntry) => {
|
|
319
|
+
const { row, entry } = slot;
|
|
320
|
+
try {
|
|
321
|
+
const tag = classifyRequest(entry, nextEntry);
|
|
322
|
+
row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
|
|
323
|
+
} catch (err) {
|
|
324
|
+
// Diagnostic-worthy (a throw silently mis-tags the row; the live path
|
|
325
|
+
// reports the same failure) — CLAUDE.md swallow rule.
|
|
326
|
+
reportSwallowed('v2-meta.row-classify', err);
|
|
327
|
+
row.typeTag = null; // journal-derived kind still renders
|
|
328
|
+
}
|
|
329
|
+
// Authoritative synthesis-level markers. mainAgent stays KIND-derived
|
|
330
|
+
// (item.isMain = journal kind==='main' && !leader) like foldDir and the
|
|
331
|
+
// live rows — entry.mainAgent re-derives from the blob-backfilled body
|
|
332
|
+
// and mis-tags a main-shaped countTokens probe as true, which would merge
|
|
333
|
+
// its turn into the chat after a cold reload (2026-07-16 review P1).
|
|
334
|
+
row.mainAgent = slot.isMain === true;
|
|
335
|
+
if (entry.teammate) row.teammate = entry.teammate;
|
|
336
|
+
if (entry.body && entry.body.model) row.model = entry.body.model;
|
|
337
|
+
if (row.mainAgent) {
|
|
338
|
+
const usage = entry.response?.body?.usage;
|
|
339
|
+
const cacheCreate = usage?.cache_creation_input_tokens || 0;
|
|
340
|
+
const cacheRead = usage?.cache_read_input_tokens || 0;
|
|
341
|
+
if (prevMainFull && cacheCreate > 0 && cacheCreate > cacheRead) {
|
|
342
|
+
try { row.cacheLoss = computeCacheLoss(prevMainFull, entry); } catch (err) { reportSwallowed('v2-meta.row-cacheloss', err); }
|
|
343
|
+
}
|
|
344
|
+
prevMainFull = entry;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
for (const item of iterateV2Items(sessionDir, { materialize })) {
|
|
348
|
+
const row = rowByKey.get(itemKey(item.sessionId, item.seq));
|
|
349
|
+
if (!row) continue;
|
|
350
|
+
// main deltas carry only their appended slice — classification and
|
|
351
|
+
// cacheLoss need the full replayed state (stateRef aliases it transiently)
|
|
352
|
+
let entry = item.entry;
|
|
353
|
+
if (row.mainAgent && item.stateRef && entry.body) {
|
|
354
|
+
entry = { ...entry, body: { ...entry.body, messages: item.stateRef } };
|
|
355
|
+
}
|
|
356
|
+
if (pending) finish(pending, entry);
|
|
357
|
+
pending = { row, entry, isMain: item.isMain };
|
|
358
|
+
if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
359
|
+
}
|
|
360
|
+
if (pending) finish(pending, null);
|
|
361
|
+
}
|
package/server/lib/v2/replay.js
CHANGED
|
@@ -14,14 +14,14 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
|
|
16
16
|
import { isSupportedWireFormat } from './layout.js';
|
|
17
|
+
import { iterateJsonlLines } from './jsonl-read.js';
|
|
17
18
|
|
|
18
|
-
/** Parse a JSONL file tolerating a truncated tail line (spec §14).
|
|
19
|
+
/** Parse a JSONL file tolerating a truncated tail line (spec §14). Streams
|
|
20
|
+
* line-by-line (issue #129): a file past Node's ~512MiB string cap must not
|
|
21
|
+
* throw ERR_STRING_TOO_LONG out of the whole read path. */
|
|
19
22
|
export function readJsonlTolerant(path) {
|
|
20
|
-
if (!existsSync(path)) return [];
|
|
21
23
|
const out = [];
|
|
22
|
-
for (const
|
|
23
|
-
const t = line.trim();
|
|
24
|
-
if (!t) continue;
|
|
24
|
+
for (const t of iterateJsonlLines(path)) {
|
|
25
25
|
try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
|
|
26
26
|
}
|
|
27
27
|
return out;
|
|
@@ -75,9 +75,13 @@ const SCAN_CHUNK = 64 * 1024;
|
|
|
75
75
|
* @param {string} dir - absolute session dir
|
|
76
76
|
* @param {number} budget - max bytes to read before giving up
|
|
77
77
|
* @param {(line:string) => boolean} onLine
|
|
78
|
+
* @param {boolean} [ioErrorResult=false] - returned when the journal EXISTS
|
|
79
|
+
* but reading it throws (transient lock / fd exhaustion). The cold-load
|
|
80
|
+
* scanners keep the false default ("not activated"); the discard predicate
|
|
81
|
+
* passes true so an I/O hiccup can never hide a REAL session.
|
|
78
82
|
* @returns {boolean}
|
|
79
83
|
*/
|
|
80
|
-
function scanJournal(dir, budget, onLine) {
|
|
84
|
+
function scanJournal(dir, budget, onLine, ioErrorResult = false) {
|
|
81
85
|
const journal = join(dir, 'journal.jsonl');
|
|
82
86
|
if (!existsSync(journal)) return false;
|
|
83
87
|
let fd;
|
|
@@ -105,7 +109,7 @@ function scanJournal(dir, budget, onLine) {
|
|
|
105
109
|
const last = (carry + decoder.end()).trim();
|
|
106
110
|
return !!(last && onLine(last));
|
|
107
111
|
} catch {
|
|
108
|
-
return
|
|
112
|
+
return ioErrorResult;
|
|
109
113
|
} finally {
|
|
110
114
|
if (fd !== undefined) { try { closeSync(fd); } catch {} }
|
|
111
115
|
}
|
|
@@ -167,6 +171,69 @@ export function sessionHasCompletedMainTurn(dir) {
|
|
|
167
171
|
});
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Does this session dir have at least one req of kind 'main' OR 'teammate'?
|
|
176
|
+
* The positive half of the discardable-session predicate. Budgeted with the
|
|
177
|
+
* WIDE budget (not MAIN_REQ_SCAN_BUDGET): journal req lines run ~1.1-1.3KB
|
|
178
|
+
* (headers + params), and time-driven heartbeat/countTokens lines can pile up
|
|
179
|
+
* before a real session's first main — a 256KB budget could give up early and
|
|
180
|
+
* misjudge a REAL session as discardable. Sessions that have a main still
|
|
181
|
+
* early-exit at its line; only genuinely main-less journals pay the budget.
|
|
182
|
+
* @param {string} dir - absolute session dir
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
export function sessionHasMainOrTeammateReq(dir) {
|
|
186
|
+
// ioErrorResult=true: a transient read error must KEEP the session (treat
|
|
187
|
+
// as main-bearing) — hiding a real session is the worse failure direction;
|
|
188
|
+
// a missing journal still returns false (discard is correct there).
|
|
189
|
+
return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
|
|
190
|
+
if (!line.includes('"ph":"req"')) return false;
|
|
191
|
+
if (!line.includes('"kind":"main"') && !line.includes('"kind":"teammate"')) return false;
|
|
192
|
+
try {
|
|
193
|
+
const o = JSON.parse(line);
|
|
194
|
+
return !!(o && o.ph === 'req' && (o.kind === 'main' || o.kind === 'teammate'));
|
|
195
|
+
} catch { return false; } // torn line — keep scanning
|
|
196
|
+
}, true);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Discardable-session predicate (2026-07-16): a session dir that must be
|
|
201
|
+
* DISCARDED by every read surface — never listed, counted, followed, or used
|
|
202
|
+
* as a candidate in any logic. These are orphan dirs minted by Claude Code's
|
|
203
|
+
* quota probes (max_tokens:1, one `'quota'` user message, a throwaway
|
|
204
|
+
* session_id per probe — fired at launches and agent-team spawns), plus any
|
|
205
|
+
* torn/empty dir with no renderable identity.
|
|
206
|
+
*
|
|
207
|
+
* discard ⟺ meta.leader ABSENT (not a teammate session)
|
|
208
|
+
* AND no journal req line of kind 'main' or 'teammate'
|
|
209
|
+
*
|
|
210
|
+
* The 'teammate' kind clause is the safety net for a torn meta.json. Every
|
|
211
|
+
* real session carries a main req at/near the journal head (verified across
|
|
212
|
+
* the full real dataset: 18/18 main-less dirs were single quota probes), so a
|
|
213
|
+
* kept session early-exits its scan cheaply. Read-side only and self-healing:
|
|
214
|
+
* the moment a dir gains its first main req, the predicate flips and every
|
|
215
|
+
* surface picks it up on its next scan/poll.
|
|
216
|
+
*
|
|
217
|
+
* KEEP IN SYNC: adapter.js listV2Sessions pre-computes the same verdict
|
|
218
|
+
* inline (hasMainOrTeammate inside its existing full journal fold — unbounded,
|
|
219
|
+
* vs this predicate's 8MB budget; intentional asymmetry) and then CONFIRMS a
|
|
220
|
+
* discard through this predicate so the error→keep direction is shared.
|
|
221
|
+
* Change the rule here and there together.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} dir - absolute session dir
|
|
224
|
+
* @param {object|null} [meta] - pre-parsed meta.json (avoids a re-read);
|
|
225
|
+
* omitted → read here; unreadable → treated as leaderless (journal decides)
|
|
226
|
+
* @returns {boolean} true = discard everywhere
|
|
227
|
+
*/
|
|
228
|
+
export function isDiscardableSession(dir, meta) {
|
|
229
|
+
let m = meta;
|
|
230
|
+
if (m === undefined) {
|
|
231
|
+
try { m = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { m = null; }
|
|
232
|
+
}
|
|
233
|
+
if (m && m.leader) return false;
|
|
234
|
+
return !sessionHasMainOrTeammateReq(dir);
|
|
235
|
+
}
|
|
236
|
+
|
|
170
237
|
/**
|
|
171
238
|
* The newest readable, non-teammate session that HAS a main turn, as
|
|
172
239
|
* `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
|
|
@@ -332,6 +332,19 @@ export class V2Writer {
|
|
|
332
332
|
convResult = s.convs.ingest(convKey, msgs, { seq, rid });
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Residual body params — every top-level body field except the three that
|
|
336
|
+
// already have dedicated stores (messages → conv, system/tools → blobs).
|
|
337
|
+
// Inlined whole on the req line like headers, so the adapter can rebuild
|
|
338
|
+
// the full v1 body (max_tokens, temperature, thinking, metadata, …).
|
|
339
|
+
let params = null;
|
|
340
|
+
if (entry.body && typeof entry.body === 'object' && !Array.isArray(entry.body)) {
|
|
341
|
+
const rest = { ...entry.body };
|
|
342
|
+
delete rest.messages;
|
|
343
|
+
delete rest.system;
|
|
344
|
+
delete rest.tools;
|
|
345
|
+
if (Object.keys(rest).length > 0) params = rest;
|
|
346
|
+
}
|
|
347
|
+
|
|
335
348
|
// 3. Journal req line — LAST, so it never references missing content.
|
|
336
349
|
s.journal.writeReq({
|
|
337
350
|
seq,
|
|
@@ -345,6 +358,7 @@ export class V2Writer {
|
|
|
345
358
|
...(entry.body && entry.body.model && { model: entry.body.model }),
|
|
346
359
|
...(entry.isStream && { isStream: true }),
|
|
347
360
|
...(entry.headers && { headers: entry.headers }),
|
|
361
|
+
...(params && { params }),
|
|
348
362
|
...((toolsRef || sysRef) && { blobs: { ...(toolsRef && { tools: toolsRef }), ...(sysRef && { sys: sysRef }) } }),
|
|
349
363
|
...(convResult && { msgFrom: convResult.msgFrom, msgTo: convResult.msgTo }),
|
|
350
364
|
...(convResult && convResult.evt && { evt: convResult.evt }),
|
|
@@ -431,6 +445,7 @@ export class V2Writer {
|
|
|
431
445
|
rid,
|
|
432
446
|
body: respBody,
|
|
433
447
|
...(resp && resp.headers && { headers: resp.headers }),
|
|
448
|
+
...(resp && resp.statusText && { statusText: resp.statusText }),
|
|
434
449
|
}) + '\n'
|
|
435
450
|
);
|
|
436
451
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire compression for client-facing SSE / streaming JSON responses.
|
|
3
|
+
*
|
|
4
|
+
* Negotiation is br|identity only: every real browser sends `br` in
|
|
5
|
+
* Accept-Encoding over plain http, while non-browser clients (curl, tests)
|
|
6
|
+
* that omit it keep today's plaintext bytes untouched. gzip is deliberately
|
|
7
|
+
* not offered — its 32KB window cannot back-reference the repeated multi-KB
|
|
8
|
+
* tools/system blocks or the growing conversation history, so it measured
|
|
9
|
+
* 1.8-3x against brotli's 20-80x on real sessions.
|
|
10
|
+
*
|
|
11
|
+
* CONVENTION: on a response that went through sseHead(), every subsequent
|
|
12
|
+
* byte MUST be written via sseWrite()/wireEnd() — a bare res.write() would
|
|
13
|
+
* interleave plaintext into the brotli stream and corrupt it for the client.
|
|
14
|
+
* All SSE broadcast paths (log-watcher send*ToClients, workspaces reload,
|
|
15
|
+
* server.js update badge) route through sseWrite for this reason.
|
|
16
|
+
*
|
|
17
|
+
* Escape hatches: CCV_WIRE_COMPRESSION=off disables negotiation entirely;
|
|
18
|
+
* CCV_BROTLI_QUALITY overrides the encoder quality (default 9 — measured
|
|
19
|
+
* knee on large sessions; q5 collapses to ~2.5x there).
|
|
20
|
+
*/
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import { reportSwallowed } from './error-report.js';
|
|
23
|
+
import { awaitDrainOrClose } from './sse-backpressure.js';
|
|
24
|
+
|
|
25
|
+
const C = zlib.constants;
|
|
26
|
+
const DEFAULT_QUALITY = 9;
|
|
27
|
+
const LGWIN = 24; // 16MB window: must cover the repeat distance of multi-MB live frames
|
|
28
|
+
|
|
29
|
+
/** Wire v3 flag parsing (default ON; '0' or 'off' is the escape hatch).
|
|
30
|
+
* Pure so the env contract is unit-testable — the const in server.js is
|
|
31
|
+
* evaluated once at import and can't be toggled in-process. */
|
|
32
|
+
export function isWireV3Enabled(v) {
|
|
33
|
+
return v !== '0' && v !== 'off';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function negotiatedEncoding(req) {
|
|
37
|
+
if (process.env.CCV_WIRE_COMPRESSION === 'off') return null;
|
|
38
|
+
const accept = req.headers && req.headers['accept-encoding'];
|
|
39
|
+
if (typeof accept !== 'string') return null;
|
|
40
|
+
// RFC 9110: a qvalue of 0 means "not acceptable" — `br;q=0` is a refusal.
|
|
41
|
+
for (const token of accept.split(',')) {
|
|
42
|
+
const [name, ...params] = token.trim().split(';');
|
|
43
|
+
if (name.trim() !== 'br') continue;
|
|
44
|
+
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
|
|
45
|
+
if (q && !(parseFloat(q.slice(2)) > 0)) return null;
|
|
46
|
+
return 'br';
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeEncoder() {
|
|
52
|
+
let quality = Number(process.env.CCV_BROTLI_QUALITY);
|
|
53
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 11) quality = DEFAULT_QUALITY;
|
|
54
|
+
return zlib.createBrotliCompress({
|
|
55
|
+
params: {
|
|
56
|
+
[C.BROTLI_PARAM_QUALITY]: quality,
|
|
57
|
+
[C.BROTLI_PARAM_LGWIN]: LGWIN,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Negotiate Content-Encoding and send the response head. On a `br` hit the
|
|
64
|
+
* encoder is piped into `res` and attached as `res._wireEnc`; otherwise this
|
|
65
|
+
* is exactly `res.writeHead(status, headers)` (plaintext path unchanged).
|
|
66
|
+
* The encoder is destroyed on response close, freeing its 16MB window.
|
|
67
|
+
*
|
|
68
|
+
* @param {import('node:http').IncomingMessage} req
|
|
69
|
+
* @param {import('node:http').ServerResponse} res
|
|
70
|
+
* @param {number} status
|
|
71
|
+
* @param {Record<string, string>} headers
|
|
72
|
+
* @param {{flush?: boolean}} [opts] - flush:false = whole-stream response (no
|
|
73
|
+
* per-macrotask flush; wireEnd's FINISH trailer is the only boundary —
|
|
74
|
+
* better ratio for bulk JSON like /api/requests that has no liveness need)
|
|
75
|
+
*/
|
|
76
|
+
export function sseHead(req, res, status, headers, opts) {
|
|
77
|
+
const enc = negotiatedEncoding(req);
|
|
78
|
+
if (!enc) {
|
|
79
|
+
// Vary on BOTH branches: the resource is negotiated either way, and a
|
|
80
|
+
// caching intermediary must key on Accept-Encoding for it.
|
|
81
|
+
res.writeHead(status, { ...headers, 'Vary': 'Accept-Encoding' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const encoder = makeEncoder();
|
|
85
|
+
if (opts && opts.flush === false) encoder._wireNoFlush = true;
|
|
86
|
+
encoder.on('error', (err) => {
|
|
87
|
+
// A compressor error mid-stream is unrecoverable for this client —
|
|
88
|
+
// surface it (silent loss here means a wedged UI) and drop the socket.
|
|
89
|
+
reportSwallowed('sse.compress', err);
|
|
90
|
+
try { res.destroy(); } catch { /* already gone */ }
|
|
91
|
+
});
|
|
92
|
+
encoder.pipe(res);
|
|
93
|
+
res._wireEnc = encoder;
|
|
94
|
+
res.on('close', () => {
|
|
95
|
+
res._wireEnc = null;
|
|
96
|
+
try { encoder.destroy(); } catch { /* already destroyed */ }
|
|
97
|
+
});
|
|
98
|
+
res.writeHead(status, { ...headers, 'Content-Encoding': 'br', 'Vary': 'Accept-Encoding' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleFlush(encoder) {
|
|
102
|
+
if (encoder._wireNoFlush || encoder._wireFlushScheduled) return;
|
|
103
|
+
encoder._wireFlushScheduled = true;
|
|
104
|
+
setImmediate(() => {
|
|
105
|
+
encoder._wireFlushScheduled = false;
|
|
106
|
+
if (encoder.destroyed || encoder.writableEnded) return;
|
|
107
|
+
// SYNC flush per macrotask: bytes of every event written this tick leave
|
|
108
|
+
// immediately (SSE liveness) while the window is retained (compression
|
|
109
|
+
// context takeover across events — the source of the measured 20-80x).
|
|
110
|
+
encoder.flush(C.BROTLI_OPERATION_FLUSH);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Write one SSE chunk through the response's negotiated encoding.
|
|
116
|
+
* Returns the write() boolean of the stream actually written to; combine
|
|
117
|
+
* with needsDrain(res) for socket-level backpressure on compressed paths.
|
|
118
|
+
*/
|
|
119
|
+
export function sseWrite(res, str) {
|
|
120
|
+
const encoder = res._wireEnc;
|
|
121
|
+
if (!encoder) return res.write(str);
|
|
122
|
+
if (encoder.destroyed || encoder.writableEnded) return false;
|
|
123
|
+
const ok = encoder.write(str);
|
|
124
|
+
scheduleFlush(encoder);
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Socket-level backpressure signal (true on both plain and compressed paths). */
|
|
129
|
+
export function needsDrain(res) {
|
|
130
|
+
return res.writableNeedDrain === true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Wait until the response's write target can accept more data (or the
|
|
135
|
+
* connection dies, or timeoutMs elapses). On the plain path this is exactly
|
|
136
|
+
* awaitDrainOrClose(res). On the compressed path the pressured stream is the
|
|
137
|
+
* ENCODER (its 'drain' fires when compression consumes the input backlog) —
|
|
138
|
+
* waiting on res 'drain' there can stall the full timeout, because compressed
|
|
139
|
+
* output is 20-80x smaller and may never backpressure the socket at all.
|
|
140
|
+
*/
|
|
141
|
+
export function awaitWireDrain(res, timeoutMs) {
|
|
142
|
+
const encoder = res._wireEnc;
|
|
143
|
+
if (!encoder) return awaitDrainOrClose(res, timeoutMs);
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
let t;
|
|
146
|
+
const done = () => {
|
|
147
|
+
clearTimeout(t);
|
|
148
|
+
encoder.off('drain', done);
|
|
149
|
+
encoder.off('close', done);
|
|
150
|
+
res.off('close', done);
|
|
151
|
+
res.off('error', done);
|
|
152
|
+
resolve();
|
|
153
|
+
};
|
|
154
|
+
t = setTimeout(done, timeoutMs);
|
|
155
|
+
encoder.once('drain', done);
|
|
156
|
+
encoder.once('close', done); // encoder is destroyed on res close
|
|
157
|
+
res.once('close', done);
|
|
158
|
+
res.once('error', done);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** End the response, flushing the encoder trailer first on compressed paths. */
|
|
163
|
+
export function wireEnd(res) {
|
|
164
|
+
const encoder = res._wireEnc;
|
|
165
|
+
if (!encoder) { res.end(); return; }
|
|
166
|
+
if (!encoder.destroyed && !encoder.writableEnded) encoder.end(); // pipe ends res
|
|
167
|
+
}
|