cc-viewer 1.9.8 → 1.9.10
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/README.md +1 -1
- package/cli.js +5 -3
- package/dist/assets/App-Ci9GYs24.js +2 -0
- package/dist/assets/App-D06hBGII.css +1 -0
- package/dist/assets/{MdxEditorPanel-BnbZpgDp.js → MdxEditorPanel-8fwlNHMz.js} +1 -1
- package/dist/assets/Mobile-DngSOqYr.js +1 -0
- package/dist/assets/ProxyStatsModal-CWSIELLj.js +1 -0
- package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-6ZLcdMmL.js} +1 -1
- package/dist/assets/{arc-EdUdBxtG.js → arc-Dirf6EZP.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-BfIhmu_I.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-D0y4ALi1.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-Dlfp4SUK.js} +1 -1
- package/dist/assets/{channel-DYEktNap.js → channel-CaNcU8SI.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-ki0vHNS-.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-D8yO8Iuv.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-BvIgaNPO.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-DF2Of_VO.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-BIw7SXs-.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-AB76s0A0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-CuN2LCFc.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-D4DLBzBN.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DLJzWgYm.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DLJzWgYm.js +1 -0
- package/dist/assets/clone-H_VGsHn9.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-Bn485PX9.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-e4QVCf17.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-_SBFGgnf.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-Bz1ovxLs.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-B-oojW8K.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-CTAyNaOj.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-BfvKAqq2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-BpTEZEAe.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-DUD_vkyE.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DnlXcTud.js} +1 -1
- package/dist/assets/{graph-BSB9fDSP.js → graph-D4xsUKSn.js} +1 -1
- package/dist/assets/{index-DKufsAzx.js → index-84-BjpGl.js} +1 -1
- package/dist/assets/index-BDKwOnE3.js +2 -0
- package/dist/assets/{index-Bo-12bug.js → index-BniBLRtZ.js} +1 -1
- package/dist/assets/{index-BRw4Iz2O.js → index-C2Zo3KOg.js} +1 -1
- package/dist/assets/{index-BuVComcY.js → index-CWZoKT7e.js} +1 -1
- package/dist/assets/{index-eM2ih5n7.js → index-D-4EsFub.js} +1 -1
- package/dist/assets/{index-CNNAYjkF.js → index-D3KzRmkP.js} +1 -1
- package/dist/assets/{index-DXBfwPYD.js → index-NGgdum2i.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-BbdvmCDt.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-oZBOPoTO.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-DWoH0hs9.js} +1 -1
- package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-I5y9RZhq.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-BLHtjKsS.js} +1 -1
- package/dist/assets/{layout-DTmHj0Wm.js → layout-C116SNcQ.js} +1 -1
- package/dist/assets/{linear-BOA3a9FE.js → linear-Br08WCkF.js} +1 -1
- package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-D-4dTrvI.js} +2 -2
- package/dist/assets/{min-CpjFTEec.js → min-khs74HbH.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-C3_yItFF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-ByCwM2_P.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-CjV65X1r.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-DFJ_dbtV.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-BBvXoBfj.js} +1 -1
- package/dist/assets/seqResourceLoaders-C34z9uOa.css +41 -0
- package/dist/assets/seqResourceLoaders-JJzgZZzU.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-BJM04Y3N.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-DI2q_Kkn.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-CrGC6kEY.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-JcfRUsjD.js} +1 -1
- package/dist/assets/vendor-antd-DxEVqVhD.js +17 -0
- package/dist/assets/vendor-codemirror-CgNlkZva.js +1 -0
- package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-DoreQf4A.js} +2 -2
- package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-q1CKtOyX.js} +1 -1
- package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-BXGe9q7E.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-mAHR1Ugj.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-mlTq_P-4.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-BDGfajRb.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Cf2pR1mV.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/plugins/README.md +9 -1
- package/server/interceptor.js +1 -1
- package/server/lib/builtin-model-prompts.js +1 -1
- package/server/lib/chat-queue.js +1 -1
- package/server/lib/credential-access.js +1 -1
- package/server/lib/credential-migrate.js +1 -1
- package/server/lib/error-report.js +26 -0
- package/server/lib/json-store.js +1 -1
- package/server/lib/launch-config.js +1 -1
- package/server/lib/log-watcher.js +1 -1
- package/server/lib/plugin-host.mjs +198 -0
- package/server/lib/plugin-loader.js +346 -90
- package/server/lib/plugin-manager.js +46 -2
- package/server/lib/plugin-manifest.js +329 -0
- package/server/lib/plugin-panels.js +183 -0
- package/server/lib/plugin-proxy.js +408 -0
- package/server/lib/plugin-runtime.js +775 -0
- package/server/lib/plugin-service-manager.js +463 -0
- package/server/lib/proxy/proxy-retry.js +1 -1
- package/server/lib/proxy/proxy-stats.js +1 -1
- package/server/lib/resume-watchdog.js +1 -1
- package/server/lib/same-origin.js +13 -3
- package/server/lib/sdk-manager.js +117 -23
- package/server/lib/sdk-session-index.js +1 -1
- package/server/lib/stats-worker.js +1 -1
- package/server/lib/system-prompt-files.js +1 -1
- package/server/lib/system-prompt-live.js +1 -1
- package/server/lib/system-prompt-snapshots.js +1 -1
- package/server/lib/v2/adapter.js +1 -1
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/jsonl-read.js +1 -1
- package/server/lib/v2/live-feed.js +1 -1
- package/server/lib/v2/meta-rows.js +1 -1
- package/server/lib/v2/resume-list.js +1 -1
- package/server/lib/v2/session-list.js +1 -1
- package/server/lib/v2/session-owner.js +1 -1
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/lib/wire-compress.js +1 -1
- package/server/lib/ws-mux.js +1 -1
- package/server/proxy.js +1 -1
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +1 -1
- package/server/routes/events.js +1 -1
- package/server/routes/expert.js +1 -1
- package/server/routes/files-content.js +1 -1
- package/server/routes/files-fs.js +4 -2
- package/server/routes/misc.js +11 -0
- package/server/routes/plugin-services.js +104 -0
- package/server/routes/plugins.js +145 -17
- package/server/routes/preferences.js +1 -1
- package/server/routes/resume.js +1 -1
- package/server/routes/sdk-context.js +1 -1
- package/server/routes/sdk-mcp-status.js +1 -1
- package/server/routes/workspaces.js +1 -1
- package/server/server.js +370 -173
- package/dist/assets/App-D2zrtS4P.js +0 -2
- package/dist/assets/App-DuDeDOuX.css +0 -1
- package/dist/assets/Mobile-CU-g7cTn.js +0 -1
- package/dist/assets/ProxyStatsModal-CavWQfBA.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
- package/dist/assets/clone-CFj55h49.js +0 -1
- package/dist/assets/index-D2maXxkC.js +0 -2
- package/dist/assets/seqResourceLoaders-DZF6ofB7.css +0 -41
- package/dist/assets/seqResourceLoaders-jOSz1x7F.js +0 -2
- package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
- package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// Reverse proxy for service plugins: forwards /plugins/<name>/* (HTTP + WebSocket)
|
|
2
|
+
// to the plugin's child process on 127.0.0.1:<port>, streaming in both directions.
|
|
3
|
+
//
|
|
4
|
+
// Contract notes (hard-won, do not regress):
|
|
5
|
+
// - Never buffer: request/response bodies are piped. SSE keeps flowing because we
|
|
6
|
+
// flushHeaders() as soon as upstream responds and never set an upstream response timeout.
|
|
7
|
+
// - Client-abort detection uses res.on('close') + !res.writableFinished — NOT
|
|
8
|
+
// req.on('close'), which fires as soon as the request body finishes and would cut
|
|
9
|
+
// every POST/PUT mid-flight.
|
|
10
|
+
// - The `token` query param is stripped before forwarding (it is the host's ACCESS_TOKEN;
|
|
11
|
+
// plugins get their own secret via x-ccv-plugin-secret instead).
|
|
12
|
+
// - Handlers never throw: dispatch() has no try/catch, so an async throw would become an
|
|
13
|
+
// unhandled rejection and kill the process. Everything is caught + reportSwallowed.
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import { reportSwallowed } from './error-report.js';
|
|
16
|
+
|
|
17
|
+
const HOP_BY_HOP = new Set([
|
|
18
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
19
|
+
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
20
|
+
]);
|
|
21
|
+
const SENSITIVE_REQUEST_HEADERS = new Set(['cookie', 'authorization', 'x-ccviewer-internal']);
|
|
22
|
+
const MAX_ACTIVE_SOCKETS_PER_PLUGIN = 256;
|
|
23
|
+
const UPSTREAM_HEADERS_TIMEOUT_MS = 30000;
|
|
24
|
+
const WS_HANDSHAKE_TIMEOUT_MS = 10000;
|
|
25
|
+
|
|
26
|
+
export function createProxyTracker() {
|
|
27
|
+
// name -> Set<socket|ClientRequest|Agent>
|
|
28
|
+
const byPlugin = new Map();
|
|
29
|
+
return {
|
|
30
|
+
add(name, item) {
|
|
31
|
+
let set = byPlugin.get(name);
|
|
32
|
+
if (!set) { set = new Set(); byPlugin.set(name, set); }
|
|
33
|
+
set.add(item);
|
|
34
|
+
},
|
|
35
|
+
remove(name, item) {
|
|
36
|
+
const set = byPlugin.get(name);
|
|
37
|
+
if (set) { set.delete(item); if (set.size === 0) byPlugin.delete(name); }
|
|
38
|
+
},
|
|
39
|
+
count(name) {
|
|
40
|
+
return byPlugin.get(name)?.size ?? 0;
|
|
41
|
+
},
|
|
42
|
+
destroyAll(name) {
|
|
43
|
+
const set = byPlugin.get(name);
|
|
44
|
+
if (!set) return;
|
|
45
|
+
byPlugin.delete(name);
|
|
46
|
+
for (const item of set) {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof item.destroy === 'function') item.destroy();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
reportSwallowed('plugin-proxy.tracker-destroy', err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createPluginAgent() {
|
|
58
|
+
return http.Agent({ keepAlive: true, maxSockets: 64, maxFreeSockets: 8 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripTokenFromSearch(search) {
|
|
62
|
+
if (!search) return '';
|
|
63
|
+
const params = new URLSearchParams(search);
|
|
64
|
+
params.delete('token');
|
|
65
|
+
const s = params.toString();
|
|
66
|
+
return s ? `?${s}` : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildForwardHeaders(req, target, extra = {}) {
|
|
70
|
+
const headers = {};
|
|
71
|
+
const connectionTokens = new Set();
|
|
72
|
+
const conn = req.headers.connection;
|
|
73
|
+
if (typeof conn === 'string') {
|
|
74
|
+
for (const t of conn.split(',')) connectionTokens.add(t.trim().toLowerCase());
|
|
75
|
+
}
|
|
76
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
77
|
+
const k = key.toLowerCase();
|
|
78
|
+
if (HOP_BY_HOP.has(k) || connectionTokens.has(k)) continue;
|
|
79
|
+
if (SENSITIVE_REQUEST_HEADERS.has(k)) continue;
|
|
80
|
+
if (k === 'expect') continue; // do not forward 100-continue
|
|
81
|
+
if (k === 'host') continue; // rewritten below
|
|
82
|
+
if (k === 'x-ccv-plugin-secret') continue; // always overwrite client-supplied values
|
|
83
|
+
headers[k] = value;
|
|
84
|
+
}
|
|
85
|
+
headers.host = `127.0.0.1:${target.port}`;
|
|
86
|
+
headers['x-forwarded-host'] = req.headers.host || '';
|
|
87
|
+
headers['x-forwarded-proto'] = target.protocol || 'http';
|
|
88
|
+
headers['x-forwarded-prefix'] = target.externalPrefix || '';
|
|
89
|
+
const priorFor = req.headers['x-forwarded-for'];
|
|
90
|
+
const clientIp = req.socket?.remoteAddress;
|
|
91
|
+
headers['x-forwarded-for'] = priorFor ? `${priorFor}, ${clientIp}` : clientIp;
|
|
92
|
+
headers['x-ccv-plugin-secret'] = target.secret;
|
|
93
|
+
return { ...headers, ...extra };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rewriteResponseHeaders(upstreamHeaders, target) {
|
|
97
|
+
const headers = {};
|
|
98
|
+
for (const [key, value] of Object.entries(upstreamHeaders)) {
|
|
99
|
+
const k = key.toLowerCase();
|
|
100
|
+
if (k === 'location' && typeof value === 'string') {
|
|
101
|
+
headers[k] = rewriteLocation(value, target);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (k === 'set-cookie') {
|
|
105
|
+
const cookies = Array.isArray(value) ? value : [value];
|
|
106
|
+
const rewritten = [];
|
|
107
|
+
for (const c of cookies) {
|
|
108
|
+
const rc = rewriteSetCookie(String(c), target);
|
|
109
|
+
if (rc) rewritten.push(rc);
|
|
110
|
+
}
|
|
111
|
+
if (rewritten.length) headers[k] = rewritten;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
headers[k] = value;
|
|
115
|
+
}
|
|
116
|
+
return headers;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rewriteLocation(location, target) {
|
|
120
|
+
// relative path or an absolute URL pointing at the upstream -> add the plugin prefix; external redirects untouched
|
|
121
|
+
const prefix = target.externalPrefix || '';
|
|
122
|
+
if (location.startsWith('/')) {
|
|
123
|
+
return `${prefix}${location}`;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const u = new URL(location);
|
|
127
|
+
if ((u.hostname === '127.0.0.1' || u.hostname === 'localhost') && Number(u.port) === target.port) {
|
|
128
|
+
return `${prefix}${u.pathname}${u.search}${u.hash}`;
|
|
129
|
+
}
|
|
130
|
+
} catch { /* invalid URL: forward as-is */ }
|
|
131
|
+
return location;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rewriteSetCookie(cookie, target) {
|
|
135
|
+
// prevent shadowing the host auth cookie (parseCookies takes the first match)
|
|
136
|
+
const namePart = cookie.split(';', 1)[0];
|
|
137
|
+
const eq = namePart.indexOf('=');
|
|
138
|
+
const cookieName = (eq >= 0 ? namePart.slice(0, eq) : namePart).trim().toLowerCase();
|
|
139
|
+
if (cookieName === 'ccv_auth') return null;
|
|
140
|
+
const parts = cookie.split(';').map(p => p.trim());
|
|
141
|
+
const out = [parts[0]];
|
|
142
|
+
let hasPath = false;
|
|
143
|
+
for (const attr of parts.slice(1)) {
|
|
144
|
+
const lower = attr.toLowerCase();
|
|
145
|
+
if (lower.startsWith('domain=')) continue; // drop Domain
|
|
146
|
+
if (lower.startsWith('path=')) {
|
|
147
|
+
hasPath = true;
|
|
148
|
+
out.push(`Path=${target.cookiePath || target.externalPrefix || '/'}`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
out.push(attr);
|
|
152
|
+
}
|
|
153
|
+
if (!hasPath) out.push(`Path=${target.cookiePath || target.externalPrefix || '/'}`);
|
|
154
|
+
return out.join('; ');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 101 responses go through the same Set-Cookie/Location policy as plain HTTP, but only
|
|
158
|
+
// handshake headers may reach the client: forwarding arbitrary headers can leak plugin
|
|
159
|
+
// cookies and framing headers would corrupt the byte tunnel.
|
|
160
|
+
const WS_HANDSHAKE_HEADERS = new Set([
|
|
161
|
+
'upgrade', 'connection',
|
|
162
|
+
'sec-websocket-accept', 'sec-websocket-protocol', 'sec-websocket-extensions',
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
function rewriteUpgradeHeaders(upstreamHeaders, target) {
|
|
166
|
+
const rewritten = rewriteResponseHeaders(upstreamHeaders, target);
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const [key, value] of Object.entries(rewritten)) {
|
|
169
|
+
if (WS_HANDSHAKE_HEADERS.has(key.toLowerCase())) out[key] = value;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sendJson(res, status, body) {
|
|
175
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
176
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify(body));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Main HTTP proxy entry. target: { port, secret, name, externalPrefix, protocol, agent }.
|
|
182
|
+
* match: { forwardPath, search } (plugin prefix already stripped).
|
|
183
|
+
*/
|
|
184
|
+
export function proxyHttpRequest(req, res, { target, match, tracker }) {
|
|
185
|
+
try {
|
|
186
|
+
if (tracker && tracker.count(target.name) >= MAX_ACTIVE_SOCKETS_PER_PLUGIN) {
|
|
187
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '2' });
|
|
188
|
+
res.end(JSON.stringify({ error: `plugin "${target.name}" is overloaded` }));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const forwardPath = `${match.forwardPath || '/'}${stripTokenFromSearch(match.search)}`;
|
|
192
|
+
const upstreamReq = http.request({
|
|
193
|
+
host: '127.0.0.1',
|
|
194
|
+
port: target.port,
|
|
195
|
+
method: req.method,
|
|
196
|
+
path: forwardPath,
|
|
197
|
+
headers: buildForwardHeaders(req, target),
|
|
198
|
+
agent: target.agent || false,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let finished = false;
|
|
202
|
+
const cleanup = () => {
|
|
203
|
+
if (tracker) tracker.remove(target.name, upstreamReq);
|
|
204
|
+
};
|
|
205
|
+
if (tracker) tracker.add(target.name, upstreamReq);
|
|
206
|
+
|
|
207
|
+
// 30s headers timeout (SSE responses are no longer limited after that)
|
|
208
|
+
upstreamReq.setTimeout(UPSTREAM_HEADERS_TIMEOUT_MS, () => {
|
|
209
|
+
upstreamReq.destroy(new Error('upstream headers timeout'));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
upstreamReq.on('response', (upstreamRes) => {
|
|
213
|
+
upstreamReq.setTimeout(0);
|
|
214
|
+
const headers = rewriteResponseHeaders(upstreamRes.headers, target);
|
|
215
|
+
try {
|
|
216
|
+
res.writeHead(upstreamRes.statusCode, headers);
|
|
217
|
+
res.flushHeaders?.();
|
|
218
|
+
upstreamRes.pipe(res);
|
|
219
|
+
upstreamRes.on('error', (err) => {
|
|
220
|
+
reportSwallowed('plugin-proxy.upstream-res', err);
|
|
221
|
+
res.destroy();
|
|
222
|
+
});
|
|
223
|
+
res.on('error', (err) => {
|
|
224
|
+
reportSwallowed('plugin-proxy.client-res', err);
|
|
225
|
+
upstreamRes.destroy();
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
reportSwallowed('plugin-proxy.write-head', err);
|
|
229
|
+
res.destroy();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
upstreamReq.on('error', (err) => {
|
|
234
|
+
reportSwallowed('plugin-proxy.upstream-req', err);
|
|
235
|
+
if (!res.headersSent) {
|
|
236
|
+
sendJson(res, 502, { error: `plugin "${target.name}" upstream error`, detail: String(err && err.message || err) });
|
|
237
|
+
} else {
|
|
238
|
+
res.destroy();
|
|
239
|
+
}
|
|
240
|
+
cleanup();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// client-abort signal (never req.on('close') - it fires when the request body ends)
|
|
244
|
+
res.on('close', () => {
|
|
245
|
+
if (!res.writableFinished && !finished) {
|
|
246
|
+
upstreamReq.destroy();
|
|
247
|
+
}
|
|
248
|
+
cleanup();
|
|
249
|
+
});
|
|
250
|
+
req.on('error', (err) => {
|
|
251
|
+
reportSwallowed('plugin-proxy.client-req', err);
|
|
252
|
+
upstreamReq.destroy();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
req.pipe(upstreamReq);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
reportSwallowed('plugin-proxy.http', err);
|
|
258
|
+
sendJson(res, 502, { error: `plugin proxy failure` });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* WebSocket proxy (http.request upgrade/response events, raw byte tunnel).
|
|
264
|
+
* The caller must attach client socket error/close handlers synchronously before entering.
|
|
265
|
+
*/
|
|
266
|
+
export function proxyUpgrade(req, socket, head, { target, match, tracker }) {
|
|
267
|
+
try {
|
|
268
|
+
const forwardPath = `${match.forwardPath || '/'}${stripTokenFromSearch(match.search)}`;
|
|
269
|
+
const upstreamReq = http.request({
|
|
270
|
+
host: '127.0.0.1',
|
|
271
|
+
port: target.port,
|
|
272
|
+
method: 'GET',
|
|
273
|
+
path: forwardPath,
|
|
274
|
+
headers: buildForwardHeaders(req, target, {
|
|
275
|
+
connection: 'Upgrade',
|
|
276
|
+
upgrade: req.headers.upgrade || 'websocket',
|
|
277
|
+
}),
|
|
278
|
+
agent: false, // never globalAgent: destroying it kills every pooled connection in the process
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
let upstreamSocket = null;
|
|
282
|
+
let settled = false;
|
|
283
|
+
const destroyPair = () => {
|
|
284
|
+
try { socket.destroy(); } catch { /* noop */ }
|
|
285
|
+
try { upstreamSocket?.destroy(); } catch { /* noop */ }
|
|
286
|
+
if (tracker) {
|
|
287
|
+
tracker.remove(target.name, upstreamReq);
|
|
288
|
+
if (upstreamSocket) tracker.remove(target.name, upstreamSocket);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
if (tracker) tracker.add(target.name, upstreamReq);
|
|
292
|
+
|
|
293
|
+
const handshakeTimer = setTimeout(() => {
|
|
294
|
+
if (!settled) {
|
|
295
|
+
settled = true;
|
|
296
|
+
try {
|
|
297
|
+
socket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n');
|
|
298
|
+
} catch { /* noop */ }
|
|
299
|
+
destroyPair();
|
|
300
|
+
}
|
|
301
|
+
}, WS_HANDSHAKE_TIMEOUT_MS);
|
|
302
|
+
handshakeTimer.unref?.();
|
|
303
|
+
|
|
304
|
+
upstreamReq.on('upgrade', (upstreamRes, upSocket, upHead) => {
|
|
305
|
+
settled = true;
|
|
306
|
+
clearTimeout(handshakeTimer);
|
|
307
|
+
upstreamSocket = upSocket;
|
|
308
|
+
upSocket.setNoDelay(true);
|
|
309
|
+
socket.setNoDelay(true);
|
|
310
|
+
if (tracker) tracker.add(target.name, upSocket);
|
|
311
|
+
|
|
312
|
+
// 101 status line + rewritten handshake headers (see rewriteUpgradeHeaders).
|
|
313
|
+
let headLines = `HTTP/1.1 101 Switching Protocols\r\n`;
|
|
314
|
+
for (const [key, value] of Object.entries(rewriteUpgradeHeaders(upstreamRes.headers, target))) {
|
|
315
|
+
if (Array.isArray(value)) {
|
|
316
|
+
for (const v of value) headLines += `${key}: ${v}\r\n`;
|
|
317
|
+
} else {
|
|
318
|
+
headLines += `${key}: ${value}\r\n`;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
headLines += '\r\n';
|
|
322
|
+
try {
|
|
323
|
+
socket.write(headLines);
|
|
324
|
+
if (upHead && upHead.length) socket.write(upHead);
|
|
325
|
+
upSocket.pipe(socket);
|
|
326
|
+
socket.pipe(upSocket);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
reportSwallowed('plugin-proxy.ws-pipe', err);
|
|
329
|
+
destroyPair();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
upSocket.on('error', (err) => { reportSwallowed('plugin-proxy.ws-upstream', err); destroyPair(); });
|
|
333
|
+
socket.on('error', (err) => { reportSwallowed('plugin-proxy.ws-client', err); destroyPair(); });
|
|
334
|
+
upSocket.on('close', () => destroyPair());
|
|
335
|
+
socket.on('close', () => destroyPair());
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
upstreamReq.on('response', (upstreamRes) => {
|
|
339
|
+
// Non-101: forward status line + headers + body, then close both ends.
|
|
340
|
+
// Headers must be rebuilt, not echoed: the Node client already decoded chunked/gzip, so
|
|
341
|
+
// echoing transfer-encoding makes the downstream parse plaintext as chunked (parse error).
|
|
342
|
+
settled = true;
|
|
343
|
+
clearTimeout(handshakeTimer);
|
|
344
|
+
try {
|
|
345
|
+
const bodyChunks = [];
|
|
346
|
+
upstreamRes.on('data', (c) => bodyChunks.push(c));
|
|
347
|
+
upstreamRes.on('end', () => {
|
|
348
|
+
const body = Buffer.concat(bodyChunks);
|
|
349
|
+
const headers = {};
|
|
350
|
+
for (const [key, value] of Object.entries(upstreamRes.headers)) {
|
|
351
|
+
const k = key.toLowerCase();
|
|
352
|
+
if (HOP_BY_HOP.has(k) || k === 'content-length') continue;
|
|
353
|
+
headers[key] = value;
|
|
354
|
+
}
|
|
355
|
+
headers['content-length'] = String(body.length);
|
|
356
|
+
let headLines = `HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage || ''}\r\n`;
|
|
357
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
358
|
+
if (Array.isArray(value)) {
|
|
359
|
+
for (const v of value) headLines += `${key}: ${v}\r\n`;
|
|
360
|
+
} else {
|
|
361
|
+
headLines += `${key}: ${value}\r\n`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
headLines += '\r\n';
|
|
365
|
+
try {
|
|
366
|
+
socket.write(headLines);
|
|
367
|
+
socket.write(body);
|
|
368
|
+
} catch { /* noop */ }
|
|
369
|
+
destroyPair();
|
|
370
|
+
});
|
|
371
|
+
upstreamRes.on('error', () => destroyPair());
|
|
372
|
+
} catch (err) {
|
|
373
|
+
reportSwallowed('plugin-proxy.ws-non101', err);
|
|
374
|
+
destroyPair();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
upstreamReq.on('error', (err) => {
|
|
379
|
+
reportSwallowed('plugin-proxy.ws-connect', err);
|
|
380
|
+
if (!settled) {
|
|
381
|
+
settled = true;
|
|
382
|
+
clearTimeout(handshakeTimer);
|
|
383
|
+
try {
|
|
384
|
+
socket.write(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n${JSON.stringify({ error: `plugin "${target.name}" upstream error` })}`);
|
|
385
|
+
} catch { /* noop */ }
|
|
386
|
+
}
|
|
387
|
+
destroyPair();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// The caller attached error/close before the async section; add a close fallback here
|
|
391
|
+
socket.on('close', () => {
|
|
392
|
+
if (!settled) {
|
|
393
|
+
settled = true;
|
|
394
|
+
clearTimeout(handshakeTimer);
|
|
395
|
+
upstreamReq.destroy();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
if (head && head.length) {
|
|
400
|
+
// The upgrade event's head is the client's first packet - write it upstream right after the headers
|
|
401
|
+
upstreamReq.write(head);
|
|
402
|
+
}
|
|
403
|
+
upstreamReq.end();
|
|
404
|
+
} catch (err) {
|
|
405
|
+
reportSwallowed('plugin-proxy.upgrade', err);
|
|
406
|
+
try { socket.destroy(); } catch { /* noop */ }
|
|
407
|
+
}
|
|
408
|
+
}
|