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/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. basePath normalized.
571
- const bpRaw = process.env.CCV_BASE_PATH || '';
572
- const bp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
573
- if (bp && url.startsWith(bp)) {
574
- url = url.slice(bp.length) || '/';
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
- const rawBase = process.env.CCV_BASE_PATH || '';
681
- // Normalize to ensure trailing slash; prevents /proxy/ws from
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 basePath = process.env.CCV_BASE_PATH || '';
732
- if (basePath && basePath !== '/') {
733
- const safeBase = basePath.replace(/\/?$/, '/');
734
- const escapedBase = safeBase.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
735
- const jsSafeBase = safeBase.replace(/"/g, '"').replace(/<\//g, '<\/');
736
- html = html.replace(/<head[^>]*>/i, m => m + `<base href="${escapedBase}"><script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`);
730
+ const injectBase = normalizeBasePath(process.env.CCV_BASE_PATH);
731
+ if (injectBase) {
732
+ const escapedBase = injectBase.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
- const url = `${serverProtocol}://127.0.0.1:${port}`;
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
- console.error(t('server.startedLocal', { protocol: serverProtocol, port }));
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
- let pathname = wsUrl.pathname;
1068
- const bpRaw = process.env.CCV_BASE_PATH || '';
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) => _bpLog('behind', 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
- if (ws.readyState === 1 && bpGate.offer()) {
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) => _bpLog('behind', 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
- if (ws.readyState === 1 && bpGate.offer()) {
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
- try { await imCore.stopAll(); } catch { }
1933
- try { await Promise.race([runParallelHook('serverStopping'), new Promise(r => setTimeout(r, 3000))]); } catch { }
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
- process.on('SIGINT', () => { stopViewer().finally(() => process.exit()); });
2104
- process.on('SIGTERM', () => { stopViewer().finally(() => process.exit()); });
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
  }