llm-session-proxy 0.1.0 → 0.1.1
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.en.md +16 -0
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/cli.js +41 -0
- package/src/logger.js +7 -1
- package/src/proxy.js +451 -209
package/README.en.md
CHANGED
|
@@ -401,6 +401,22 @@ You can also take just the parts you need: `createProxyServer` (own the lifecycl
|
|
|
401
401
|
|
|
402
402
|
---
|
|
403
403
|
|
|
404
|
+
## Stability
|
|
405
|
+
|
|
406
|
+
- **A single malformed request cannot take the process down.** Synchronous throws along the
|
|
407
|
+
request path are intercepted: an invalid `Host` header, a malformed request line, or an upstream
|
|
408
|
+
status line / header containing illegal characters all become ordinary 4xx / 5xx responses, and
|
|
409
|
+
the proxy keeps serving.
|
|
410
|
+
- **Uncaught errors land in the log file.** A last-resort handler writes the full stack of
|
|
411
|
+
`uncaughtException` and `unhandledRejection` into the file given by `--log-file` (and to stderr).
|
|
412
|
+
Node's default is to print to stderr and terminate immediately — leaving nothing at all in the
|
|
413
|
+
log file, which looks exactly like "the logs are fine, the process just vanished".
|
|
414
|
+
**Always pass `--log-file`**, otherwise process-level clues disappear with the terminal window.
|
|
415
|
+
- 20 uncaught errors within 60 seconds are treated as a persistent fault and the process exits on
|
|
416
|
+
purpose, rather than spinning in a broken state.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
404
420
|
## FAQ
|
|
405
421
|
|
|
406
422
|
**The client cannot connect, and the proxy logs nothing**
|
package/README.md
CHANGED
|
@@ -398,6 +398,18 @@ await proxy.stop();
|
|
|
398
398
|
|
|
399
399
|
---
|
|
400
400
|
|
|
401
|
+
## 稳定性
|
|
402
|
+
|
|
403
|
+
- **单个畸形请求不会让进程退出。** 代理拦截了请求处理路径上所有同步抛出:非法的 `Host` 头、
|
|
404
|
+
畸形请求行、上游返回含非法字符的状态行或响应头,都会被转成对应的 4xx / 5xx 响应,进程继续服务。
|
|
405
|
+
- **未捕获异常会写进日志文件。** 兜底处理器把 `uncaughtException` 与 `unhandledRejection`
|
|
406
|
+
的完整栈写入 `--log-file` 指定的文件(同时输出到 stderr);Node 默认只打 stderr 然后直接终止进程,
|
|
407
|
+
日志文件里一个字都不会有,现场看起来就是「日志一切正常,进程凭空消失」。
|
|
408
|
+
**建议始终带上 `--log-file`**,否则进程级问题的线索会随终端窗口一起消失。
|
|
409
|
+
- 60 秒内连续出现 20 次未捕获错误会判定为持续故障并主动退出,避免带着坏状态空转。
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
401
413
|
## 常见问题
|
|
402
414
|
|
|
403
415
|
**客户端报连接失败 / 代理日志里没有请求记录**
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -345,6 +345,46 @@ export function printBanner(logger, config, proxy, url) {
|
|
|
345
345
|
if (config.log.file) logger.info(` 日志文件 ${path.resolve(config.log.file)}`);
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* 安装进程级兜底。
|
|
350
|
+
*
|
|
351
|
+
* 默认行为下,一个未捕获异常或未处理的 Promise 拒绝会直接终止进程,
|
|
352
|
+
* 而栈信息只打到 stderr —— 日志文件里一个字都不会有,
|
|
353
|
+
* 表现出来就是「日志一切正常,进程却凭空消失」,极难排查。
|
|
354
|
+
* 这里改成:写进日志文件 + stderr,然后**继续运行**。
|
|
355
|
+
* 只有短时间内反复出错(判定为持续故障,再跑下去也没意义)才主动退出。
|
|
356
|
+
*/
|
|
357
|
+
export function installProcessGuards(logger) {
|
|
358
|
+
const recent = [];
|
|
359
|
+
const WINDOW_MS = 60_000;
|
|
360
|
+
const LIMIT = 20;
|
|
361
|
+
|
|
362
|
+
const record = (kind, error) => {
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
while (recent.length && now - recent[0] > WINDOW_MS) recent.shift();
|
|
365
|
+
recent.push(now);
|
|
366
|
+
|
|
367
|
+
const detail = error?.stack || error?.message || String(error);
|
|
368
|
+
logger.error(`[${kind}] 未捕获的错误(进程继续运行): ${detail}`);
|
|
369
|
+
try {
|
|
370
|
+
// logger 的文件写入可能因磁盘/权限静默降级,stderr 是最后一道线索
|
|
371
|
+
process.stderr.write(`${kind}: ${detail}\n`);
|
|
372
|
+
} catch {
|
|
373
|
+
/* 连 stderr 都写不进去就只能放弃 */
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (recent.length > LIMIT) {
|
|
377
|
+
logger.error(`[${kind}] ${WINDOW_MS / 1000} 秒内已发生 ${recent.length} 次,判定为持续故障,主动退出`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
process.on('uncaughtException', (error) => record('uncaughtException', error));
|
|
383
|
+
process.on('unhandledRejection', (reason) =>
|
|
384
|
+
record('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
348
388
|
/**
|
|
349
389
|
* CLI 主入口。返回时服务器已在后台运行(进程不会退出)。
|
|
350
390
|
* 错误通过 process.exitCode 表达,避免在测试中强杀进程。
|
|
@@ -396,6 +436,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
396
436
|
}
|
|
397
437
|
|
|
398
438
|
const logger = new Logger({ ...config.log, console: config.log.level !== 'silent' });
|
|
439
|
+
installProcessGuards(logger);
|
|
399
440
|
const proxy = createProxyServer({ config, logger });
|
|
400
441
|
|
|
401
442
|
let address;
|
package/src/logger.js
CHANGED
|
@@ -89,7 +89,13 @@ export class Logger {
|
|
|
89
89
|
if (this.toConsole) {
|
|
90
90
|
const color = COLORS[level] || 0;
|
|
91
91
|
const out = process.stderr.isTTY ? `\u001b[${color}m${line}\u001b[0m` : line;
|
|
92
|
-
|
|
92
|
+
try {
|
|
93
|
+
// stderr 被关掉(管道断开)时 write 会抛 EPIPE;
|
|
94
|
+
// 日志器绝不能因为写日志而把代理搞挂
|
|
95
|
+
process.stderr.write(`${out}\n`);
|
|
96
|
+
} catch {
|
|
97
|
+
/* 放弃控制台输出,文件通道继续 */
|
|
98
|
+
}
|
|
93
99
|
}
|
|
94
100
|
this.#writeFile(line);
|
|
95
101
|
}
|
package/src/proxy.js
CHANGED
|
@@ -22,37 +22,74 @@ const HOP_BY_HOP = new Set([
|
|
|
22
22
|
const LOCAL_PREFIX = '/__llm_session_proxy__';
|
|
23
23
|
const ERROR_CAPTURE_BYTES = 4096;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Node 校验 header / statusMessage 用的是 `/[^\t\x20-\x7e\x80-\xff]/`。
|
|
27
|
+
* 这里复刻同一套规则:返回值合法就返回 true。
|
|
28
|
+
*/
|
|
29
|
+
const INVALID_HEADER_CHAR = /[^\t\x20-\x7e\x80-\xff]/;
|
|
30
|
+
|
|
31
|
+
function isValidHeaderValue(value) {
|
|
32
|
+
return typeof value === 'string' && !INVALID_HEADER_CHAR.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 上游的 reason phrase 不合法时返回 null,交给 Node 用默认短语。 */
|
|
36
|
+
function sanitizeStatusMessage(message) {
|
|
37
|
+
if (typeof message !== 'string' || message === '') return null;
|
|
38
|
+
return INVALID_HEADER_CHAR.test(message) ? null : message;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 丢掉含非法字符的响应头。
|
|
43
|
+
*
|
|
44
|
+
* 这些头来自上游,Node 的**入站**解析比**出站**写入宽松,所以完全可能出现
|
|
45
|
+
* "能收进来、写不出去"的值;而 writeHead 抛的是同步异常,抛在事件回调里
|
|
46
|
+
* 就是进程级崩溃。宁可丢掉个别头,也不能让进程死。
|
|
47
|
+
*/
|
|
48
|
+
function sanitizeResponseHeaders(headers, onDrop) {
|
|
49
|
+
const clean = {};
|
|
50
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
const kept = value.filter((item) => isValidHeaderValue(item));
|
|
53
|
+
if (kept.length) clean[key] = kept;
|
|
54
|
+
else if (kept.length !== value.length) onDrop(key);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (isValidHeaderValue(value)) clean[key] = value;
|
|
58
|
+
else onDrop(key);
|
|
59
|
+
}
|
|
60
|
+
return clean;
|
|
61
|
+
}
|
|
62
|
+
|
|
25
63
|
function readBody(req, maxBytes) {
|
|
26
64
|
return new Promise((resolve, reject) => {
|
|
27
65
|
const chunks = [];
|
|
28
66
|
let size = 0;
|
|
67
|
+
let settled = false;
|
|
68
|
+
const fail = (error) => {
|
|
69
|
+
if (settled) return;
|
|
70
|
+
settled = true;
|
|
71
|
+
reject(error);
|
|
72
|
+
};
|
|
29
73
|
req.on('data', (chunk) => {
|
|
74
|
+
if (settled) return;
|
|
30
75
|
size += chunk.length;
|
|
31
76
|
if (size > maxBytes) {
|
|
32
77
|
const error = new Error(`请求体超过上限 ${maxBytes} 字节`);
|
|
33
78
|
error.code = 'E_TOO_LARGE';
|
|
34
|
-
|
|
79
|
+
fail(error);
|
|
35
80
|
req.destroy();
|
|
36
81
|
return;
|
|
37
82
|
}
|
|
38
83
|
chunks.push(chunk);
|
|
39
84
|
});
|
|
40
|
-
req.on('end', () =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
res.end();
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
51
|
-
res.writeHead(status, {
|
|
52
|
-
'content-type': 'application/json; charset=utf-8',
|
|
53
|
-
'content-length': String(body.length),
|
|
85
|
+
req.on('end', () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
resolve(Buffer.concat(chunks));
|
|
89
|
+
});
|
|
90
|
+
req.on('error', fail);
|
|
91
|
+
req.on('aborted', () => fail(Object.assign(new Error('客户端在请求体读完前断开'), { code: 'E_ABORTED' })));
|
|
54
92
|
});
|
|
55
|
-
res.end(body);
|
|
56
93
|
}
|
|
57
94
|
|
|
58
95
|
function formatBytes(bytes) {
|
|
@@ -66,11 +103,16 @@ function formatBytes(bytes) {
|
|
|
66
103
|
*
|
|
67
104
|
* 每个请求的处理顺序:
|
|
68
105
|
* 读体 -> 解析 JSON -> 解析会话 -> 重写路径/模型 -> 注入头与体 -> 转发 -> 回传响应
|
|
106
|
+
*
|
|
107
|
+
* 稳定性约定(这里是踩过坑的地方):
|
|
108
|
+
* handle() 是同步入口,任何抛出都必须被捕获。http 服务器的事件回调里
|
|
109
|
+
* 冒出来的同步异常没有任何人接得住,Node 会直接终止整个进程——
|
|
110
|
+
* 而且栈只打到 stderr,不进日志文件,表现就是「日志一切正常,进程凭空消失」。
|
|
69
111
|
*/
|
|
70
112
|
export function createProxyServer({ config, logger }) {
|
|
71
113
|
const store = new SessionStore(config.session);
|
|
72
114
|
const upstream = resolveUpstream(config);
|
|
73
|
-
const stats = { startedAt: Date.now(), requests: 0, errors: 0, bytesIn: 0, bytesOut: 0 };
|
|
115
|
+
const stats = { startedAt: Date.now(), requests: 0, errors: 0, handledErrors: 0, bytesIn: 0, bytesOut: 0 };
|
|
74
116
|
|
|
75
117
|
const agent =
|
|
76
118
|
upstream.protocol === 'https'
|
|
@@ -86,9 +128,69 @@ export function createProxyServer({ config, logger }) {
|
|
|
86
128
|
|
|
87
129
|
const guard = (req) => req.socket?.remoteAddress;
|
|
88
130
|
|
|
131
|
+
/** 回写 JSON。客户端可能已经断开,写失败只能吞掉,绝不能因此抛出去。 */
|
|
132
|
+
function respondJson(res, status, payload) {
|
|
133
|
+
try {
|
|
134
|
+
if (res.writableEnded || res.destroyed) return;
|
|
135
|
+
if (res.headersSent) {
|
|
136
|
+
res.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
140
|
+
res.writeHead(status, {
|
|
141
|
+
'content-type': 'application/json; charset=utf-8',
|
|
142
|
+
'content-length': String(body.length),
|
|
143
|
+
});
|
|
144
|
+
res.end(body);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
log.debug(`[client] 回写响应失败(客户端可能已断开): ${error.message}`);
|
|
147
|
+
try {
|
|
148
|
+
res.destroy();
|
|
149
|
+
} catch {
|
|
150
|
+
/* 已经没了就算了 */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 写响应头。上游的 reason phrase / 响应头可能触发 writeHead 同步抛错,这里兜住。 */
|
|
156
|
+
function writeResponseHead(res, status, statusMessage, headers) {
|
|
157
|
+
const safeMessage = sanitizeStatusMessage(statusMessage);
|
|
158
|
+
if (safeMessage === null && statusMessage) {
|
|
159
|
+
log.debug(`[res] 上游 reason phrase 含非法字符,已改用默认短语: ${JSON.stringify(statusMessage)}`);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
if (safeMessage) res.writeHead(status, safeMessage, headers);
|
|
163
|
+
else res.writeHead(status, headers);
|
|
164
|
+
return true;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
stats.errors += 1;
|
|
167
|
+
log.warn(`[res] 写响应头失败,丢弃可疑头后重试: ${error.message}`);
|
|
168
|
+
const fallback = sanitizeResponseHeaders(headers, (key) => log.warn(`[res] 丢弃非法响应头: ${key}`));
|
|
169
|
+
try {
|
|
170
|
+
if (res.headersSent) {
|
|
171
|
+
res.end();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const retryMessage = sanitizeStatusMessage(statusMessage);
|
|
175
|
+
if (retryMessage) res.writeHead(status, retryMessage, fallback);
|
|
176
|
+
else res.writeHead(status, fallback);
|
|
177
|
+
return true;
|
|
178
|
+
} catch (retryError) {
|
|
179
|
+
stats.errors += 1;
|
|
180
|
+
log.error(`[res] 响应头仍无法写出,放弃本次响应: ${retryError.message}`);
|
|
181
|
+
try {
|
|
182
|
+
res.destroy();
|
|
183
|
+
} catch {
|
|
184
|
+
/* ignore */
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
89
191
|
function handleLocal(req, res, url) {
|
|
90
192
|
if (url.pathname === `${LOCAL_PREFIX}/health` || url.pathname === `${LOCAL_PREFIX}/status`) {
|
|
91
|
-
|
|
193
|
+
respondJson(res, 200, {
|
|
92
194
|
ok: true,
|
|
93
195
|
uptimeSeconds: Math.round((Date.now() - stats.startedAt) / 1000),
|
|
94
196
|
listen: `${config.listen.host}:${config.listen.port}`,
|
|
@@ -111,25 +213,64 @@ export function createProxyServer({ config, logger }) {
|
|
|
111
213
|
createdAt: record.createdAt,
|
|
112
214
|
lastUsed: record.lastUsed,
|
|
113
215
|
}));
|
|
114
|
-
|
|
216
|
+
respondJson(res, 200, { count: items.length, items });
|
|
115
217
|
return true;
|
|
116
218
|
}
|
|
117
219
|
return false;
|
|
118
220
|
}
|
|
119
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 同步入口:把所有同步抛出挡在这里,转成 500 而不是进程退出。
|
|
224
|
+
*/
|
|
120
225
|
function handle(req, res) {
|
|
226
|
+
try {
|
|
227
|
+
dispatch(req, res);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
stats.errors += 1;
|
|
230
|
+
stats.handledErrors += 1;
|
|
231
|
+
log.error(
|
|
232
|
+
`[request-failed] ${req.method} ${req.url} 处理请求时抛错(已拦截,进程继续): ` +
|
|
233
|
+
`${error?.stack || error?.message || error}`,
|
|
234
|
+
);
|
|
235
|
+
respondJson(res, 500, {
|
|
236
|
+
error: {
|
|
237
|
+
type: 'proxy_internal_error',
|
|
238
|
+
message: `代理处理请求时出错: ${error?.message || error}`,
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function dispatch(req, res) {
|
|
121
245
|
const startedAt = Date.now();
|
|
122
|
-
|
|
246
|
+
|
|
247
|
+
// 先挂错误监听:客户端可能在任何时刻断开,晚挂一步就是一个未捕获的 error 事件
|
|
248
|
+
res.on('error', (error) => log.debug('[client] 响应流出错(客户端可能已断开):', error.message));
|
|
249
|
+
req.on('error', (error) => log.debug('[client] 请求流出错:', error.message));
|
|
250
|
+
|
|
251
|
+
let url = null;
|
|
252
|
+
try {
|
|
253
|
+
url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
254
|
+
} catch {
|
|
255
|
+
// Host 头非法时 new URL 会抛 ERR_INVALID_URL,而它抛在 http 服务器的回调里
|
|
256
|
+
stats.errors += 1;
|
|
257
|
+
stats.handledErrors += 1;
|
|
258
|
+
log.warn(`[req] 无法解析请求目标 ${JSON.stringify(req.url)}(Host=${JSON.stringify(req.headers.host)})`);
|
|
259
|
+
respondJson(res, 400, {
|
|
260
|
+
error: {
|
|
261
|
+
type: 'bad_request_target',
|
|
262
|
+
message: `无法解析请求目标: ${req.url}(Host 头为 ${JSON.stringify(req.headers.host)})`,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
123
267
|
|
|
124
268
|
if (url.pathname.startsWith(LOCAL_PREFIX)) {
|
|
125
269
|
if (handleLocal(req, res, url)) return;
|
|
126
|
-
|
|
270
|
+
respondJson(res, 404, { error: { message: `未知的本地端点: ${url.pathname}` } });
|
|
127
271
|
return;
|
|
128
272
|
}
|
|
129
273
|
|
|
130
|
-
res.on('error', (error) => log.debug('[client] 响应流出错(客户端可能已断开):', error.message));
|
|
131
|
-
req.on('error', (error) => log.debug('[client] 请求流出错:', error.message));
|
|
132
|
-
|
|
133
274
|
const headersLower = new Map();
|
|
134
275
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
135
276
|
headersLower.set(key.toLowerCase(), Array.isArray(value) ? value.join(', ') : value);
|
|
@@ -142,214 +283,284 @@ export function createProxyServer({ config, logger }) {
|
|
|
142
283
|
)
|
|
143
284
|
: Promise.resolve({ buffer: null });
|
|
144
285
|
|
|
145
|
-
bodyPromise
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
286
|
+
bodyPromise
|
|
287
|
+
.then(({ buffer, error }) => {
|
|
288
|
+
if (error) {
|
|
289
|
+
stats.errors += 1;
|
|
290
|
+
const tooLarge = error.code === 'E_TOO_LARGE';
|
|
291
|
+
log.warn(`[req] ${req.method} ${req.url} 读取请求体失败: ${error.message}`);
|
|
292
|
+
respondJson(res, tooLarge ? 413 : 400, {
|
|
293
|
+
error: {
|
|
294
|
+
type: tooLarge ? 'request_too_large' : 'request_body_incomplete',
|
|
295
|
+
message: error.message,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
152
300
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
301
|
+
// ---- 解析请求体 ----
|
|
302
|
+
const contentType = (headersLower.get('content-type') || '').toLowerCase();
|
|
303
|
+
let parsedBody = null;
|
|
304
|
+
if (buffer && buffer.length && contentType.includes('json')) {
|
|
305
|
+
try {
|
|
306
|
+
const candidate = JSON.parse(buffer.toString('utf8'));
|
|
307
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) parsedBody = candidate;
|
|
308
|
+
} catch {
|
|
309
|
+
parsedBody = null;
|
|
310
|
+
}
|
|
162
311
|
}
|
|
163
|
-
}
|
|
164
312
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
313
|
+
// ---- 会话解析 ----
|
|
314
|
+
const headersObject = Object.fromEntries(headersLower);
|
|
315
|
+
const session = resolveSession({ headers: headersObject, body: parsedBody, config, store });
|
|
316
|
+
|
|
317
|
+
// ---- 组装模板上下文 ----
|
|
318
|
+
const context = createContext({
|
|
319
|
+
session: {
|
|
320
|
+
id: session.id,
|
|
321
|
+
count: session.count,
|
|
322
|
+
requestId: session.requestId,
|
|
323
|
+
key: session.key,
|
|
324
|
+
source: session.source,
|
|
325
|
+
},
|
|
326
|
+
model: typeof parsedBody?.model === 'string' ? parsedBody.model : '',
|
|
327
|
+
path: req.url,
|
|
328
|
+
method: req.method,
|
|
329
|
+
header: headersObject,
|
|
330
|
+
query: Object.fromEntries(url.searchParams),
|
|
331
|
+
});
|
|
184
332
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
333
|
+
// ---- 重写路径 ----
|
|
334
|
+
let targetPath = rewritePath(req.url, config.request.pathRewrite);
|
|
335
|
+
if (upstream.basePath) {
|
|
336
|
+
targetPath = `${upstream.basePath.replace(/\/+$/, '')}${
|
|
337
|
+
targetPath.startsWith('/') ? targetPath : `/${targetPath}`
|
|
338
|
+
}`;
|
|
339
|
+
}
|
|
192
340
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
341
|
+
// ---- 重写模型名 / 注入请求体参数 ----
|
|
342
|
+
let outBuffer = buffer;
|
|
343
|
+
let modelNote = '';
|
|
344
|
+
const bodyChanges = [];
|
|
345
|
+
if (parsedBody) {
|
|
346
|
+
const modelResult = rewriteModel(parsedBody, config.model, { logger: log });
|
|
347
|
+
if (modelResult.changed) {
|
|
348
|
+
modelNote = ` model=${modelResult.from}->${modelResult.to}`;
|
|
349
|
+
context.model = modelResult.to;
|
|
350
|
+
}
|
|
351
|
+
const bodyResult = applyBodyInject(parsedBody, config.inject, context);
|
|
352
|
+
if (bodyResult.changed) bodyChanges.push(...bodyResult.changes);
|
|
353
|
+
if (modelResult.changed || bodyResult.changed) {
|
|
354
|
+
// 改了 body 就必须重算长度,交给 http 模块自动处理
|
|
355
|
+
outBuffer = Buffer.from(JSON.stringify(parsedBody), 'utf8');
|
|
356
|
+
}
|
|
202
357
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
358
|
+
|
|
359
|
+
// ---- 组装上游请求头 ----
|
|
360
|
+
const outHeaders = {};
|
|
361
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
362
|
+
const lower = key.toLowerCase();
|
|
363
|
+
if (HOP_BY_HOP.has(lower) || lower === 'host' || lower === 'content-length') continue;
|
|
364
|
+
if ((config.request.dropHeaders || []).includes(lower)) continue;
|
|
365
|
+
if (!config.request.forwardClientSessionHeaders && config.session.headerNames.includes(lower)) continue;
|
|
366
|
+
outHeaders[key] = value;
|
|
208
367
|
}
|
|
209
|
-
}
|
|
210
368
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (!config.request.forwardClientSessionHeaders && config.session.headerNames.includes(lower)) continue;
|
|
218
|
-
outHeaders[key] = value;
|
|
219
|
-
}
|
|
369
|
+
const incomingUA = headersLower.get('user-agent');
|
|
370
|
+
if (shouldReplaceUserAgent(incomingUA, config)) {
|
|
371
|
+
if (config.userAgent) outHeaders['user-agent'] = config.userAgent;
|
|
372
|
+
} else if (incomingUA) {
|
|
373
|
+
outHeaders['user-agent'] = incomingUA;
|
|
374
|
+
}
|
|
220
375
|
|
|
221
|
-
|
|
222
|
-
if (shouldReplaceUserAgent(incomingUA, config)) {
|
|
223
|
-
if (config.userAgent) outHeaders['user-agent'] = config.userAgent;
|
|
224
|
-
} else if (incomingUA) {
|
|
225
|
-
outHeaders['user-agent'] = incomingUA;
|
|
226
|
-
}
|
|
376
|
+
Object.assign(outHeaders, buildInjectHeaders(config.inject, context, headersLower));
|
|
227
377
|
|
|
228
|
-
|
|
378
|
+
const hostValue =
|
|
379
|
+
config.upstream.rewriteHost === false ? req.headers.host || upstream.hostHeader : upstream.hostHeader;
|
|
380
|
+
outHeaders.host = hostValue;
|
|
229
381
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
382
|
+
log.info(
|
|
383
|
+
`[req] ${req.method} ${req.url} | session=${session.id} req=${session.requestId} source=${session.source} ` +
|
|
384
|
+
`sessions=${store.size} auth=${headersLower.has('authorization') ? 'present' : 'MISSING'}` +
|
|
385
|
+
`${modelNote}${bodyChanges.length ? ` body=${bodyChanges.join('|')}` : ''}`,
|
|
386
|
+
);
|
|
387
|
+
log.debug(`[req-headers] ${JSON.stringify(outHeaders, null, 0)}`);
|
|
233
388
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
res.destroy();
|
|
281
|
-
});
|
|
282
|
-
proxyRes.pipe(res);
|
|
283
|
-
res.on('close', () => {
|
|
284
|
-
if (!res.writableEnded) {
|
|
285
|
-
proxyRes.destroy();
|
|
286
|
-
}
|
|
389
|
+
// ---- 发起上游请求 ----
|
|
390
|
+
const transport = upstream.protocol === 'https' ? https : http;
|
|
391
|
+
let proxyReq;
|
|
392
|
+
try {
|
|
393
|
+
proxyReq = transport.request(
|
|
394
|
+
{
|
|
395
|
+
protocol: `${upstream.protocol}:`,
|
|
396
|
+
host: upstream.host,
|
|
397
|
+
port: upstream.port,
|
|
398
|
+
method: req.method,
|
|
399
|
+
path: targetPath,
|
|
400
|
+
headers: outHeaders,
|
|
401
|
+
agent,
|
|
402
|
+
},
|
|
403
|
+
(proxyRes) => handleUpstreamResponse({ proxyRes, res, req, session, startedAt, targetPath }),
|
|
404
|
+
);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
// 请求头含非法字符、目标路径未转义等情况会让 transport.request 同步抛错
|
|
407
|
+
stats.errors += 1;
|
|
408
|
+
log.error(`[proxy-error] 组装上游请求失败 ${req.method} ${req.url} -> ${targetPath}: ${error.message}`);
|
|
409
|
+
respondJson(res, 502, {
|
|
410
|
+
error: {
|
|
411
|
+
type: 'proxy_request_build_error',
|
|
412
|
+
message: `无法构造上游请求: ${error.message}`,
|
|
413
|
+
upstream: `${upstream.protocol}://${upstream.hostHeader}${targetPath}`,
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
stats.bytesIn += outBuffer ? outBuffer.length : 0;
|
|
420
|
+
|
|
421
|
+
proxyReq.setTimeout(config.request.timeoutMs, () => {
|
|
422
|
+
proxyReq.destroy(new Error(`上游 ${config.request.timeoutMs}ms 未响应,已超时`));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
proxyReq.on('error', (proxyError) => {
|
|
426
|
+
stats.errors += 1;
|
|
427
|
+
log.error(`[proxy-error] ${req.method} ${req.url} -> ${targetPath}: ${proxyError.message}`);
|
|
428
|
+
if (!res.headersSent) {
|
|
429
|
+
respondJson(res, 502, {
|
|
430
|
+
error: {
|
|
431
|
+
type: 'proxy_upstream_error',
|
|
432
|
+
message: `无法连接上游 ${upstream.protocol}://${upstream.hostHeader}: ${proxyError.message}`,
|
|
433
|
+
upstream: `${upstream.protocol}://${upstream.hostHeader}${targetPath}`,
|
|
434
|
+
},
|
|
287
435
|
});
|
|
288
436
|
} else {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
294
|
-
proxyRes.on('end', () => {
|
|
295
|
-
const payload = Buffer.concat(chunks);
|
|
296
|
-
// 读到的就是压缩后的完整字节,所以只修正长度,content-encoding 必须保留
|
|
297
|
-
resHeaders['content-length'] = String(payload.length);
|
|
298
|
-
res.writeHead(status, proxyRes.statusMessage, resHeaders);
|
|
299
|
-
res.end(payload);
|
|
300
|
-
});
|
|
301
|
-
proxyRes.on('error', (streamError) => {
|
|
302
|
-
log.warn(`[res] 上游响应读取失败: ${streamError.message}`);
|
|
303
|
-
if (!res.headersSent) sendJson(res, 502, { error: { message: streamError.message } });
|
|
304
|
-
else res.destroy();
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
proxyRes.on('end', () => {
|
|
309
|
-
stats.requests += 1;
|
|
310
|
-
if (status >= 400) {
|
|
311
|
-
stats.errors += 1;
|
|
312
|
-
log.error(
|
|
313
|
-
`[upstream-error] ${status} ${req.method} ${targetPath} | ${captured
|
|
314
|
-
.toString('utf8')
|
|
315
|
-
.replace(/\s+/g, ' ')
|
|
316
|
-
.slice(0, 800)}`,
|
|
317
|
-
);
|
|
437
|
+
try {
|
|
438
|
+
res.destroy();
|
|
439
|
+
} catch {
|
|
440
|
+
/* ignore */
|
|
318
441
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
`${Date.now() - startedAt}ms stream=${config.response.stream}`,
|
|
322
|
-
);
|
|
323
|
-
});
|
|
324
|
-
},
|
|
325
|
-
);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
326
444
|
|
|
327
|
-
|
|
445
|
+
req.on('aborted', () => proxyReq.destroy(new Error('客户端中断了请求')));
|
|
328
446
|
|
|
329
|
-
|
|
330
|
-
|
|
447
|
+
try {
|
|
448
|
+
if (outBuffer && outBuffer.length) proxyReq.write(outBuffer);
|
|
449
|
+
proxyReq.end();
|
|
450
|
+
} catch (error) {
|
|
451
|
+
stats.errors += 1;
|
|
452
|
+
log.error(`[proxy-error] 写入上游请求失败: ${error.message}`);
|
|
453
|
+
proxyReq.destroy(error);
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
.catch((error) => {
|
|
457
|
+
// 兜住回调里任何没被内层 try 覆盖的抛出,避免变成 unhandledRejection
|
|
458
|
+
stats.errors += 1;
|
|
459
|
+
stats.handledErrors += 1;
|
|
460
|
+
log.error(`[request-failed] ${req.method} ${req.url} 处理请求时抛错(已拦截,进程继续): ${error?.stack || error}`);
|
|
461
|
+
respondJson(res, 500, {
|
|
462
|
+
error: { type: 'proxy_internal_error', message: `代理处理请求时出错: ${error?.message || error}` },
|
|
463
|
+
});
|
|
331
464
|
});
|
|
465
|
+
}
|
|
332
466
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
467
|
+
/** 上游响应回来之后的处理,单独成函数以便整体兜错。 */
|
|
468
|
+
function handleUpstreamResponse({ proxyRes, res, req, session, startedAt, targetPath }) {
|
|
469
|
+
const status = proxyRes.statusCode || 502;
|
|
470
|
+
const rawHeaders = {};
|
|
471
|
+
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
472
|
+
const lower = key.toLowerCase();
|
|
473
|
+
if (HOP_BY_HOP.has(lower)) continue;
|
|
474
|
+
rawHeaders[key] = value;
|
|
475
|
+
}
|
|
476
|
+
rawHeaders['x-llm-session-proxy-session'] = session.id || 'disabled';
|
|
477
|
+
if (session.requestId) rawHeaders['x-llm-session-proxy-request'] = session.requestId;
|
|
478
|
+
|
|
479
|
+
const resHeaders = sanitizeResponseHeaders(rawHeaders, (key) =>
|
|
480
|
+
log.warn(`[res] 上游响应头含非法字符,已丢弃: ${key}`),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
let captured = Buffer.alloc(0);
|
|
484
|
+
let received = 0;
|
|
485
|
+
const capture = (chunk) => {
|
|
486
|
+
received += chunk.length;
|
|
487
|
+
stats.bytesOut += chunk.length;
|
|
488
|
+
if (captured.length < ERROR_CAPTURE_BYTES) {
|
|
489
|
+
captured = Buffer.concat([captured, chunk.subarray(0, ERROR_CAPTURE_BYTES - captured.length)]);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
proxyRes.on('error', (streamError) => {
|
|
494
|
+
log.warn(`[res] 上游响应流出错: ${streamError.message}`);
|
|
495
|
+
try {
|
|
496
|
+
if (res.writableEnded) return;
|
|
336
497
|
if (!res.headersSent) {
|
|
337
|
-
|
|
338
|
-
error: {
|
|
339
|
-
type: 'proxy_upstream_error',
|
|
340
|
-
message: `无法连接上游 ${upstream.protocol}://${upstream.hostHeader}: ${proxyError.message}`,
|
|
341
|
-
upstream: `${upstream.protocol}://${upstream.hostHeader}${targetPath}`,
|
|
342
|
-
},
|
|
343
|
-
});
|
|
498
|
+
respondJson(res, 502, { error: { type: 'proxy_upstream_stream_error', message: streamError.message } });
|
|
344
499
|
} else {
|
|
345
500
|
res.destroy();
|
|
346
501
|
}
|
|
347
|
-
}
|
|
502
|
+
} catch {
|
|
503
|
+
/* ignore */
|
|
504
|
+
}
|
|
505
|
+
});
|
|
348
506
|
|
|
349
|
-
|
|
507
|
+
if (config.response.stream) {
|
|
508
|
+
if (!writeResponseHead(res, status, proxyRes.statusMessage, resHeaders)) {
|
|
509
|
+
proxyRes.destroy();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (res.socket) res.socket.setNoDelay(true);
|
|
513
|
+
proxyRes.on('data', capture);
|
|
514
|
+
try {
|
|
515
|
+
proxyRes.pipe(res);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
log.warn(`[res] 管道连接失败: ${error.message}`);
|
|
518
|
+
proxyRes.destroy();
|
|
519
|
+
res.destroy();
|
|
520
|
+
}
|
|
521
|
+
res.on('close', () => {
|
|
522
|
+
if (!res.writableEnded) proxyRes.destroy();
|
|
523
|
+
});
|
|
524
|
+
} else {
|
|
525
|
+
const chunks = [];
|
|
526
|
+
proxyRes.on('data', (chunk) => {
|
|
527
|
+
capture(chunk);
|
|
528
|
+
chunks.push(chunk);
|
|
529
|
+
});
|
|
530
|
+
proxyRes.on('end', () => {
|
|
531
|
+
try {
|
|
532
|
+
const payload = Buffer.concat(chunks);
|
|
533
|
+
// 读到的就是压缩后的完整字节,所以只修正长度,content-encoding 必须保留
|
|
534
|
+
resHeaders['content-length'] = String(payload.length);
|
|
535
|
+
if (!writeResponseHead(res, status, proxyRes.statusMessage, resHeaders)) return;
|
|
536
|
+
res.end(payload);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
stats.errors += 1;
|
|
539
|
+
log.error(`[res] 回写缓冲响应失败: ${error.message}`);
|
|
540
|
+
try {
|
|
541
|
+
res.destroy();
|
|
542
|
+
} catch {
|
|
543
|
+
/* ignore */
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
350
548
|
|
|
351
|
-
|
|
352
|
-
|
|
549
|
+
proxyRes.on('end', () => {
|
|
550
|
+
stats.requests += 1;
|
|
551
|
+
if (status >= 400) {
|
|
552
|
+
stats.errors += 1;
|
|
553
|
+
log.error(
|
|
554
|
+
`[upstream-error] ${status} ${req.method} ${targetPath} | ${captured
|
|
555
|
+
.toString('utf8')
|
|
556
|
+
.replace(/\s+/g, ' ')
|
|
557
|
+
.slice(0, 800)}`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
log.info(
|
|
561
|
+
`[res] ${status} ${req.method} ${req.url} | session=${session.id} ${formatBytes(received)} ` +
|
|
562
|
+
`${Date.now() - startedAt}ms stream=${config.response.stream}`,
|
|
563
|
+
);
|
|
353
564
|
});
|
|
354
565
|
}
|
|
355
566
|
|
|
@@ -358,6 +569,27 @@ export function createProxyServer({ config, logger }) {
|
|
|
358
569
|
server.headersTimeout = 70000;
|
|
359
570
|
server.requestTimeout = 0;
|
|
360
571
|
|
|
572
|
+
// 客户端发了畸形请求(非法请求行/头)时 Node 会发 clientError。
|
|
573
|
+
// 没有监听者时它会用自己的默认处理,这里显式接管并保证 socket 一定被关掉。
|
|
574
|
+
server.on('clientError', (error, socket) => {
|
|
575
|
+
log.debug(`[client] 解析请求失败: ${error?.code || error?.message}`);
|
|
576
|
+
// 复刻 Node 默认处理的语义:头太大回 431,其余畸形请求回 400
|
|
577
|
+
const overflow = error?.code === 'HPE_HEADER_OVERFLOW';
|
|
578
|
+
const status = overflow
|
|
579
|
+
? 'HTTP/1.1 431 Request Header Fields Too Large'
|
|
580
|
+
: 'HTTP/1.1 400 Bad Request';
|
|
581
|
+
try {
|
|
582
|
+
if (socket.destroyed || !socket.writable) return;
|
|
583
|
+
socket.end(`${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
584
|
+
} catch {
|
|
585
|
+
try {
|
|
586
|
+
socket.destroy();
|
|
587
|
+
} catch {
|
|
588
|
+
/* ignore */
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
361
593
|
return {
|
|
362
594
|
server,
|
|
363
595
|
store,
|
|
@@ -367,9 +599,19 @@ export function createProxyServer({ config, logger }) {
|
|
|
367
599
|
agent,
|
|
368
600
|
listen(port = config.listen.port, host = config.listen.host) {
|
|
369
601
|
return new Promise((resolve, reject) => {
|
|
370
|
-
|
|
602
|
+
const onStartupError = (error) => {
|
|
603
|
+
server.removeListener('error', onStartupError);
|
|
604
|
+
reject(error);
|
|
605
|
+
};
|
|
606
|
+
server.once('error', onStartupError);
|
|
371
607
|
server.listen(port, host, () => {
|
|
372
|
-
server.removeListener('error',
|
|
608
|
+
server.removeListener('error', onStartupError);
|
|
609
|
+
// 启动成功后挂常驻错误处理:监听之后再冒出来的 error 事件
|
|
610
|
+
// 若无人接管,一样会直接终止进程
|
|
611
|
+
server.on('error', (error) => {
|
|
612
|
+
stats.errors += 1;
|
|
613
|
+
log.error(`[server] 服务器错误(进程继续): ${error?.stack || error?.message || error}`);
|
|
614
|
+
});
|
|
373
615
|
resolve(server.address());
|
|
374
616
|
});
|
|
375
617
|
});
|