cc-viewer 1.6.300 → 1.6.302
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 +26 -14
- package/dist/assets/{App-DO_8O7_4.js → App-COezHOeh.js} +1 -1
- package/dist/assets/{MdxEditorPanel-kMkQ3-Hd.js → MdxEditorPanel-BkJKua3-.js} +1 -1
- package/dist/assets/{Mobile-DYGGuCPh.js → Mobile-BtNceCJC.js} +1 -1
- package/dist/assets/index-DSz9bNGm.js +2 -0
- package/dist/assets/seqResourceLoaders-C8gBbhlC.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/i18n.js +56 -36
- package/server/lib/base-path.js +34 -0
- package/server/lib/log-watcher.js +7 -1
- package/server/lib/pty-flood-coalescer.js +191 -0
- package/server/lib/term-signals.js +80 -0
- package/server/pty-manager.js +13 -2
- package/server/routes/files-fs.js +6 -2
- package/server/routes/misc.js +5 -1
- package/server/routes/preferences.js +29 -9
- package/server/scratch-pty-manager.js +6 -1
- package/server/server.js +113 -41
- package/dist/assets/index-BXb1GO0R.js +0 -2
- package/dist/assets/seqResourceLoaders-BJ_EK_tf.js +0 -2
package/server/server.js
CHANGED
|
@@ -78,7 +78,10 @@ import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher
|
|
|
78
78
|
import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
|
|
79
79
|
import { cleanupExtractCache } from './lib/jsonl-archive.js';
|
|
80
80
|
import { backupConfigs } from './lib/config-backup.js';
|
|
81
|
+
import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
|
|
82
|
+
import { createHardenedCleanup } from './lib/term-signals.js';
|
|
81
83
|
import { createBackpressureGate } from './lib/ws-backpressure.js';
|
|
84
|
+
import { createFloodCoalescer } from './lib/pty-flood-coalescer.js';
|
|
82
85
|
|
|
83
86
|
|
|
84
87
|
// 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
@@ -567,12 +570,13 @@ async function handleRequest(req, res) {
|
|
|
567
570
|
let url = parsedUrl.pathname;
|
|
568
571
|
|
|
569
572
|
// CCV_BASE_PATH reverse proxy: strip prefix at TOP so API/WS/static/SPA
|
|
570
|
-
// all work with original unprefixed paths.
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
573
|
+
// all work with original unprefixed paths. 剥离后必须写回 parsedUrl.pathname ——
|
|
574
|
+
// dispatch()(routes/_dispatch.js)与多个 handler(files-content/ask-perm/im)直读
|
|
575
|
+
// parsedUrl.pathname 做路由匹配和偏移 slice,不写回则前缀下全部 /api/* 与 SSE /events
|
|
576
|
+
// 命不中、落 SPA fallback(PR #108 遗留 P0)。searchParams 不受 pathname 赋值影响。
|
|
577
|
+
const bp = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
578
|
+
url = stripBasePath(url, bp);
|
|
579
|
+
parsedUrl.pathname = url;
|
|
576
580
|
const method = req.method;
|
|
577
581
|
|
|
578
582
|
// WebSocket 路径不处理,交给 upgrade 事件
|
|
@@ -677,14 +681,9 @@ async function handleRequest(req, res) {
|
|
|
677
681
|
|
|
678
682
|
// 静态文件服务
|
|
679
683
|
if (method === 'GET') {
|
|
680
|
-
|
|
681
|
-
//
|
|
682
|
-
// incorrectly matching /proxy/ws-other due to startsWith ambiguity.
|
|
683
|
-
const basePath = rawBase && rawBase !== '/' ? rawBase.replace(/\/?$/, '/') : '';
|
|
684
|
+
// basePath 已在 handleRequest 顶部统一剥离,这里不可再剥——否则 /proxy/proxy/x
|
|
685
|
+
// 这类路径会被双重剥离。
|
|
684
686
|
let filePath = url;
|
|
685
|
-
if (basePath && url.startsWith(basePath)) {
|
|
686
|
-
filePath = url.slice(basePath.length) || '/';
|
|
687
|
-
}
|
|
688
687
|
if (filePath === '/') filePath = '/index.html';
|
|
689
688
|
// 去掉 query string
|
|
690
689
|
filePath = filePath.split('?')[0];
|
|
@@ -728,12 +727,12 @@ async function handleRequest(req, res) {
|
|
|
728
727
|
html = html.replace(/<html([^>]*?)data-theme="[^"]*"/, `<html$1data-theme="${themeColor}"`);
|
|
729
728
|
// 运行时注入 <base> 标签:当 CCV_BASE_PATH 设置为非空非根路径时,
|
|
730
729
|
// 使浏览器将所有相对 URL 解析到代理子路径下。配合 Vite base='' 输出相对路径。
|
|
731
|
-
const
|
|
732
|
-
if (
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
730
|
+
const injectBase = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
731
|
+
if (injectBase) {
|
|
732
|
+
const escapedBase = injectBase.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
733
|
+
// JS 双引号字符串转义:\ → \\、" → \"、</ → <\/(防 </script> 提前闭合)
|
|
734
|
+
const jsSafeBase = injectBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/<\//g, '<\\/');
|
|
735
|
+
html = html.replace(/<head[^>]*>/i, m => m + `<base href="${escapedBase}"><script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`);
|
|
737
736
|
}
|
|
738
737
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
739
738
|
res.end(html);
|
|
@@ -877,17 +876,25 @@ export async function startViewer() {
|
|
|
877
876
|
if (_prefs.lang) setLang(_prefs.lang);
|
|
878
877
|
}
|
|
879
878
|
} catch { /* 读 prefs 失败就保持默认语言 */ }
|
|
879
|
+
// CCV_BASE_PATH 配置校验:缺前导 '/' 时剥离静默失效(startsWith 永不命中),
|
|
880
|
+
// 启动期告警一次。放在 setLang 之后,告警语言才跟随用户配置。
|
|
881
|
+
{
|
|
882
|
+
const _bpCheck = validateBasePath(process.env.CCV_BASE_PATH);
|
|
883
|
+
if (_bpCheck.warning) console.warn(t(_bpCheck.warning, { value: process.env.CCV_BASE_PATH }));
|
|
884
|
+
}
|
|
880
885
|
// interceptor.js runs in this same process (via proxy.js → setupInterceptor).
|
|
881
886
|
// Inject live-port via module-level setter instead of process.env to avoid
|
|
882
887
|
// polluting env of child_process.spawn descendants (Bash tools / MCP / Electron tabs).
|
|
883
888
|
setLivePort(port, serverProtocol);
|
|
884
|
-
|
|
889
|
+
// 自动打开/serverStarted hook 用的 URL 也要带反代前缀(与启动打印一致)
|
|
890
|
+
const url = `${serverProtocol}://127.0.0.1:${port}${normalizeBasePath(process.env.CCV_BASE_PATH)}`;
|
|
885
891
|
if (!isCliMode) {
|
|
886
892
|
console.error(t('server.started'));
|
|
887
|
-
|
|
893
|
+
const _bp = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
894
|
+
console.error(t('server.startedLocal', { protocol: serverProtocol, port, basePath: _bp }));
|
|
888
895
|
const _ips = getAllLocalIps();
|
|
889
896
|
for (const _ip of _ips) {
|
|
890
|
-
console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, token: ACCESS_TOKEN }));
|
|
897
|
+
console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, basePath: _bp, token: ACCESS_TOKEN }));
|
|
891
898
|
}
|
|
892
899
|
if (authConfig.enabled) {
|
|
893
900
|
if (authConfig.password === '') console.error(t('server.passwordEmptyWarn'));
|
|
@@ -1021,7 +1028,7 @@ export async function startViewer() {
|
|
|
1021
1028
|
async function setupTerminalWebSocket(httpServer) {
|
|
1022
1029
|
try {
|
|
1023
1030
|
const { WebSocketServer } = await import('ws');
|
|
1024
|
-
const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
|
|
1031
|
+
const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart } = await import('./pty-manager.js');
|
|
1025
1032
|
const {
|
|
1026
1033
|
spawnScratch,
|
|
1027
1034
|
writeScratch,
|
|
@@ -1064,12 +1071,8 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1064
1071
|
|
|
1065
1072
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
1066
1073
|
const wsUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
const wsBp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
|
|
1070
|
-
if (wsBp && pathname.startsWith(wsBp)) {
|
|
1071
|
-
pathname = '/' + pathname.slice(wsBp.length);
|
|
1072
|
-
}
|
|
1074
|
+
// upgrade 不经 handleRequest,basePath 需独立剥离(与 HTTP 段同走统一函数)
|
|
1075
|
+
let pathname = stripBasePath(wsUrl.pathname, normalizeBasePath(process.env.CCV_BASE_PATH));
|
|
1073
1076
|
// 与 HTTP 一致的鉴权(此前 WS upgrade 完全不校验 token,远程终端实为无门禁——本次堵洞)。
|
|
1074
1077
|
// 在此显式计算 isLocal(与 handleRequest 同款三态判断),WS 视作非 HTML 请求。
|
|
1075
1078
|
const wsRemoteIp = req.socket.remoteAddress;
|
|
@@ -1130,6 +1133,20 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1130
1133
|
};
|
|
1131
1134
|
};
|
|
1132
1135
|
|
|
1136
|
+
// 洪泛限流器状态日志(与 makeBpLogger 同款 5s 节流,独立实例不共享计数)。
|
|
1137
|
+
// Windows 实机排"切主题/大流量卡死"时据此确认 ConPTY 洪泛是否触发、几次、量级。
|
|
1138
|
+
const makeFloodLogger = (label, ws) => {
|
|
1139
|
+
let floodCount = 0;
|
|
1140
|
+
let lastLogAt = 0;
|
|
1141
|
+
return (event, bytes) => {
|
|
1142
|
+
if (event === 'start') floodCount++;
|
|
1143
|
+
const now = Date.now();
|
|
1144
|
+
if (now - lastLogAt < 5000) return;
|
|
1145
|
+
lastLogAt = now;
|
|
1146
|
+
console.warn(`[${label}] pty flood ${event}: client=${ws._socket?.remoteAddress || '?'} winBytes=${bytes} floodTotal=${floodCount}`);
|
|
1147
|
+
};
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1133
1150
|
// scratch 终端 WS:极简版,仅承载 input/resize/data/exit + 显式 kill;不掺杂 hook/SDK/preset
|
|
1134
1151
|
wssScratch.on('connection', async (ws, req) => {
|
|
1135
1152
|
const id = req.ccvScratchId;
|
|
@@ -1155,11 +1172,18 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1155
1172
|
// 快照自身有界:scratch outputBuffer 50KB 滚动截断(scratch-pty-manager.js MAX_BUFFER),
|
|
1156
1173
|
// behind 期间继续灌也不会撑爆 resync 响应。
|
|
1157
1174
|
const _bpLog = makeBpLogger('scratch-ws', ws);
|
|
1175
|
+
// floodGate 在 bpGate 之后构造(send 闭包依赖 bpGate),onBehind/onResume 经 let 前向引用 reset:
|
|
1176
|
+
// resync 快照是唯一真相源,coalescer 残留 pending 不清会把早于快照的旧字节回灌导致画面回退。
|
|
1177
|
+
let floodGate = null;
|
|
1158
1178
|
const bpGate = createBackpressureGate({
|
|
1159
1179
|
getBufferedAmount: () => ws.bufferedAmount,
|
|
1160
|
-
onBehind: (buffered) =>
|
|
1180
|
+
onBehind: (buffered) => {
|
|
1181
|
+
_bpLog('behind', buffered);
|
|
1182
|
+
floodGate?.reset();
|
|
1183
|
+
},
|
|
1161
1184
|
onResume: (buffered) => {
|
|
1162
1185
|
_bpLog('resume', buffered);
|
|
1186
|
+
floodGate?.reset();
|
|
1163
1187
|
if (ws.readyState !== 1) return;
|
|
1164
1188
|
try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id) })); } catch {}
|
|
1165
1189
|
},
|
|
@@ -1169,10 +1193,22 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1169
1193
|
},
|
|
1170
1194
|
});
|
|
1171
1195
|
|
|
1196
|
+
// 洪泛限流器:字节率超阈值时按窗口合并 + last-wins 截断(ConPTY 全屏重绘洪泛防卡死,
|
|
1197
|
+
// 与 bpGate 互补——bpGate 管慢网络写缓冲,floodGate 管快 LAN 字节率,详见 lib/pty-flood-coalescer.js)
|
|
1198
|
+
const _floodLog = makeFloodLogger('scratch-ws', ws);
|
|
1199
|
+
floodGate = createFloodCoalescer({
|
|
1200
|
+
send: (data) => {
|
|
1201
|
+
if (ws.readyState === 1 && bpGate.offer()) {
|
|
1202
|
+
try { ws.send(JSON.stringify({ type: 'data', data })); } catch {}
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
findSafeSliceStart,
|
|
1206
|
+
onFloodStart: (bytes) => _floodLog('start', bytes),
|
|
1207
|
+
onFloodEnd: () => _floodLog('end', 0),
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1172
1210
|
const removeDataListener = onScratchData(id, (data) => {
|
|
1173
|
-
|
|
1174
|
-
try { ws.send(JSON.stringify({ type: 'data', data })); } catch {}
|
|
1175
|
-
}
|
|
1211
|
+
floodGate.offer(data);
|
|
1176
1212
|
});
|
|
1177
1213
|
|
|
1178
1214
|
const removeExitListener = onScratchExit(id, (exitCode) => {
|
|
@@ -1201,6 +1237,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1201
1237
|
|
|
1202
1238
|
ws.on('close', () => {
|
|
1203
1239
|
bpGate.dispose();
|
|
1240
|
+
floodGate.dispose();
|
|
1204
1241
|
removeDataListener();
|
|
1205
1242
|
removeExitListener();
|
|
1206
1243
|
// pty 本身**不杀**(保留以支持刷新重连),由 kill 消息或 /api/workspaces/stop 触发;
|
|
@@ -1253,11 +1290,18 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1253
1290
|
// 快照自身有界:outputBuffer 200KB 滚动截断(pty-manager.js MAX_BUFFER + findSafeSliceStart
|
|
1254
1291
|
// ANSI 安全起点),behind 期间 PTY 继续灌也不会撑爆 resync 响应。
|
|
1255
1292
|
const _bpLog = makeBpLogger('terminal-ws', ws);
|
|
1293
|
+
// floodGate 前向引用(构造顺序同 scratch 路径):onBehind/onResume 必清 coalescer
|
|
1294
|
+
// pending——resync 快照是唯一真相源,旧 pending 回灌会导致画面回退。
|
|
1295
|
+
let floodGate = null;
|
|
1256
1296
|
const bpGate = createBackpressureGate({
|
|
1257
1297
|
getBufferedAmount: () => ws.bufferedAmount,
|
|
1258
|
-
onBehind: (buffered) =>
|
|
1298
|
+
onBehind: (buffered) => {
|
|
1299
|
+
_bpLog('behind', buffered);
|
|
1300
|
+
floodGate?.reset();
|
|
1301
|
+
},
|
|
1259
1302
|
onResume: (buffered) => {
|
|
1260
1303
|
_bpLog('resume', buffered);
|
|
1304
|
+
floodGate?.reset();
|
|
1261
1305
|
if (ws.readyState !== 1) return;
|
|
1262
1306
|
try { ws.send(JSON.stringify({ type: 'data-resync', data: getOutputBuffer() })); } catch {}
|
|
1263
1307
|
try {
|
|
@@ -1286,11 +1330,23 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1286
1330
|
},
|
|
1287
1331
|
});
|
|
1288
1332
|
|
|
1333
|
+
// 洪泛限流器:字节率超阈值时按窗口合并 + last-wins 截断(ConPTY 全屏重绘洪泛防卡死,
|
|
1334
|
+
// 与 bpGate 互补——bpGate 管慢网络写缓冲,floodGate 管快 LAN 字节率,详见 lib/pty-flood-coalescer.js)
|
|
1335
|
+
const _floodLog = makeFloodLogger('terminal-ws', ws);
|
|
1336
|
+
floodGate = createFloodCoalescer({
|
|
1337
|
+
send: (data) => {
|
|
1338
|
+
if (ws.readyState === 1 && bpGate.offer()) {
|
|
1339
|
+
try { ws.send(JSON.stringify({ type: 'data', data })); } catch {}
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
findSafeSliceStart,
|
|
1343
|
+
onFloodStart: (bytes) => _floodLog('start', bytes),
|
|
1344
|
+
onFloodEnd: () => _floodLog('end', 0),
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1289
1347
|
// PTY 输出 → WebSocket(合并 ws 后客户端自行按 msg.type 分发,server 端不再 role 过滤)
|
|
1290
1348
|
const removeDataListener = onPtyData((data) => {
|
|
1291
|
-
|
|
1292
|
-
ws.send(JSON.stringify({ type: 'data', data }));
|
|
1293
|
-
}
|
|
1349
|
+
floodGate.offer(data);
|
|
1294
1350
|
});
|
|
1295
1351
|
|
|
1296
1352
|
// PTY 退出 → WebSocket
|
|
@@ -1668,6 +1724,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1668
1724
|
|
|
1669
1725
|
ws.on('close', () => {
|
|
1670
1726
|
bpGate.dispose();
|
|
1727
|
+
floodGate.dispose();
|
|
1671
1728
|
removeDataListener();
|
|
1672
1729
|
removeExitListener();
|
|
1673
1730
|
clientSizes.delete(ws);
|
|
@@ -1929,8 +1986,20 @@ async function _doStop() {
|
|
|
1929
1986
|
_lastCliActive = false;
|
|
1930
1987
|
// Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
|
|
1931
1988
|
// leaks a second WS to the same app. Idempotent + swallows errors.
|
|
1932
|
-
|
|
1933
|
-
|
|
1989
|
+
// IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
|
|
1990
|
+
// Windows 上 IM bridge WS teardown 挂住会卡死整条退出链(原本裸 await 是
|
|
1991
|
+
// "Ctrl+C 完全无反应"的 B 类成因);两段若各自 3s race 串行最坏 6s,会越过
|
|
1992
|
+
// cleanup watchdog(5s) 截断其后的 temp jsonl rename(用户数据)——合并为单预算
|
|
1993
|
+
// 保证 teardown ≤3s,watchdog 前始终留出 rename 余量。超时后控制流顺序继续。
|
|
1994
|
+
try {
|
|
1995
|
+
await Promise.race([
|
|
1996
|
+
(async () => {
|
|
1997
|
+
try { await imCore.stopAll(); } catch { }
|
|
1998
|
+
await runParallelHook('serverStopping');
|
|
1999
|
+
})(),
|
|
2000
|
+
new Promise(r => setTimeout(r, 3000)),
|
|
2001
|
+
]);
|
|
2002
|
+
} catch { }
|
|
1934
2003
|
// 如果用户未做选择,将临时文件转为正式文件
|
|
1935
2004
|
if (_resumeState && _resumeState.tempFile) {
|
|
1936
2005
|
try {
|
|
@@ -2100,6 +2169,9 @@ function handleExit() {
|
|
|
2100
2169
|
if (!globalThis._ccvServerSignalsRegistered) {
|
|
2101
2170
|
globalThis._ccvServerSignalsRegistered = true;
|
|
2102
2171
|
process.on('exit', handleExit);
|
|
2103
|
-
|
|
2104
|
-
|
|
2172
|
+
// hardened:watchdog 5s 强退 + 重复触发立退(防 Windows 上 stopViewer 内部
|
|
2173
|
+
// await 挂住导致 .finally(exit) 永不执行 = Ctrl+C 完全无反应)。
|
|
2174
|
+
const _hardenedStop = createHardenedCleanup({ doCleanup: () => stopViewer() });
|
|
2175
|
+
process.on('SIGINT', _hardenedStop);
|
|
2176
|
+
process.on('SIGTERM', _hardenedStop);
|
|
2105
2177
|
}
|