agent-dual-proxy 0.0.0-stage → 0.7.0

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/src/server.js ADDED
@@ -0,0 +1,532 @@
1
+ import http from 'node:http';
2
+ import { connectDirect, connectViaProxy, probeTls, probeHttp, LEFTOVER } from './paths.js';
3
+ import { createPref } from './pref.js';
4
+ import { buildCandidates } from './gateways.js';
5
+ import { hasIdempotencyKey, isIdempotent, shouldRetryAfterSend } from './retry-policy.js';
6
+
7
+ const DEFAULT_LOG = (msg) => process.stderr.write('[dual-proxy] ' + msg + '\n');
8
+
9
+ /** 合并配置:显式传入的 undefined 不应覆盖默认值。 */
10
+ export function mergeOptions(defaults, options) {
11
+ const merged = Object.assign({}, defaults);
12
+ for (const [key, value] of Object.entries(options || {})) {
13
+ if (value !== undefined) merged[key] = value;
14
+ }
15
+ return merged;
16
+ }
17
+
18
+ const HOP_BY_HOP = new Set([
19
+ 'connection', 'proxy-connection', 'keep-alive', 'proxy-authenticate',
20
+ 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade'
21
+ ]);
22
+
23
+ /** 统一 host 形态:去掉 IPv6 方括号、转小写、去掉 FQDN 末尾的点。 */
24
+ function normalizeHost(host) {
25
+ return String(host || '').replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
26
+ }
27
+
28
+ /** 按 RFC 9110 去掉逐跳标头(含 Connection 里点名的字段)。 */
29
+ function stripHopByHop(headers) {
30
+ const dropped = new Set(HOP_BY_HOP);
31
+ for (const token of String(headers.connection || '').toLowerCase().split(',')) {
32
+ const name = token.trim();
33
+ if (name) dropped.add(name);
34
+ }
35
+ const clean = {};
36
+ for (const [name, value] of Object.entries(headers)) {
37
+ if (!dropped.has(name.toLowerCase())) clean[name] = value;
38
+ }
39
+ return clean;
40
+ }
41
+
42
+ /** 请求方向:清洗后再把 Host 设成目标主机。 */
43
+ function sanitizeHeaders(headers, targetHost) {
44
+ return Object.assign(stripHopByHop(headers), { host: targetHost });
45
+ }
46
+
47
+ /** 解析健康检查地址;只接受 http/https。 */
48
+ function parseHealthUrl(value) {
49
+ if (!value) return null;
50
+ let url;
51
+ try {
52
+ url = new URL(value);
53
+ } catch (e) {
54
+ return null;
55
+ }
56
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
57
+ const secure = url.protocol === 'https:';
58
+ return {
59
+ host: url.hostname.replace(/^\[|\]$/g, ''),
60
+ port: Number(url.port || (secure ? 443 : 80)),
61
+ path: (url.pathname || '/') + (url.search || ''),
62
+ secure
63
+ };
64
+ }
65
+
66
+ /**
67
+ * 启动一个双路代理:在上游网关列表里自动选一条能到达目标的路,失败立刻换。
68
+ *
69
+ * 角色分工:
70
+ * - CONNECT(HTTPS):按偏好/健康检查的顺序试候选网关,443 上先做 TLS 探测再开隧道。
71
+ * - 普通 HTTP:同样按顺序试,失败换路。
72
+ * - 健康检查:定期用 healthUrl 验证“到应用层”的通道,优先使用验证通过的网关。
73
+ * 只在这一层选路,不向客户端暴露换路过程;全部不通才回 502。
74
+ *
75
+ * @param {object} options
76
+ * @param {string} [options.listenHost='127.0.0.1']
77
+ * @param {number} [options.listenPort=7898]
78
+ * @param {string} [options.upstreamHost='127.0.0.1']
79
+ * @param {number} [options.upstreamPort=7897]
80
+ * @param {(string|object)[]} [options.gateways] 额外网关,`host:port` 或 {host, port}
81
+ * @param {string} [options.healthUrl] 健康检查地址;为空则不做后台验证
82
+ * @param {number} [options.healthIntervalMs=60000] 健康检查间隔;0 关闭
83
+ * @param {number} [options.healthTimeoutMs] 健康检查单次超时,默认同 attemptTimeoutMs
84
+ * @param {number} [options.attemptTimeoutMs=5000]
85
+ * @param {number} [options.prefTtlMs=60000]
86
+ * @param {boolean} [options.verifyTls=true]
87
+ * @param {function} [options.log]
88
+ */
89
+ export function createDispatcher(options) {
90
+ const opts = mergeOptions({
91
+ listenHost: '127.0.0.1',
92
+ listenPort: 7898,
93
+ upstreamHost: '127.0.0.1',
94
+ upstreamPort: 7897,
95
+ gateways: [],
96
+ healthUrl: '',
97
+ healthIntervalMs: 60000,
98
+ fastFailTimeoutMs: 2000,
99
+ cooldownMs: 30000,
100
+ retryNonIdempotentAfterSend: false,
101
+ retryNonIdempotentWithIdempotencyKey: false,
102
+ attemptTimeoutMs: 5000,
103
+ responseTimeoutMs: 60000,
104
+ prefTtlMs: 60000,
105
+ prefMaxSize: 1000,
106
+ shutdownTimeoutMs: 1500,
107
+ verifyTls: true,
108
+ log: DEFAULT_LOG
109
+ }, options);
110
+
111
+ const log = opts.log;
112
+ const pref = createPref(opts.prefTtlMs, undefined, opts.prefMaxSize);
113
+ const candidates = buildCandidates(
114
+ Object.assign(
115
+ { host: opts.upstreamHost, port: opts.upstreamPort },
116
+ opts.upstreamAuth ? { auth: opts.upstreamAuth } : {}
117
+ ),
118
+ opts.gateways,
119
+ { host: opts.listenHost, port: opts.listenPort },
120
+ (subject, reason) => log('gateway warning: ' + subject + ': ' + reason)
121
+ );
122
+ const publicCandidates = candidates.map((gateway) => (gateway.direct
123
+ ? { id: gateway.id, direct: true }
124
+ : { id: gateway.id, host: gateway.host, port: gateway.port }));
125
+ const health = parseHealthUrl(opts.healthUrl);
126
+ const healthTimeoutMs = opts.healthTimeoutMs || opts.attemptTimeoutMs;
127
+
128
+ let preferred = null;
129
+ let healthTimer = null;
130
+ let lastKickAt = 0;
131
+ /** 网关冷却:失败过的候选在一段时间内排到最后,避免每个新 host 都先撞它一次。 */
132
+ const cooldowns = new Map();
133
+
134
+ function isCooling(id) {
135
+ const until = cooldowns.get(id);
136
+ if (until === undefined) return false;
137
+ if (Date.now() >= until) {
138
+ cooldowns.delete(id);
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ function cool(id) {
145
+ if (opts.cooldownMs > 0) cooldowns.set(id, Date.now() + opts.cooldownMs);
146
+ }
147
+
148
+ function warm(id) {
149
+ cooldowns.delete(id);
150
+ }
151
+
152
+ /** 一条路成功/失败时的统一记账:偏好、冷却、健康复检。 */
153
+ function noteSuccess(host, gateway) {
154
+ pref.set(host, gateway.id);
155
+ warm(gateway.id);
156
+ }
157
+
158
+ function noteFailure(gateway) {
159
+ cool(gateway.id);
160
+ kickHealth();
161
+ }
162
+
163
+ const openVia = (gateway, host, port, timeoutMs = opts.attemptTimeoutMs) => (gateway.direct
164
+ ? connectDirect(host, port, timeoutMs)
165
+ : connectViaProxy(gateway, host, port, timeoutMs));
166
+
167
+ /** 候选顺序:健康选中的网关 → 该 host 上次走通的网关 → 其余;冷却中的一律排最后。 */
168
+ function order(host) {
169
+ const hot = [];
170
+ const cold = [];
171
+ for (const gateway of candidates) (isCooling(gateway.id) ? cold : hot).push(gateway);
172
+ const move = (list, id) => {
173
+ const index = list.findIndex((gateway) => gateway.id === id);
174
+ if (index > 0) list.unshift(list.splice(index, 1)[0]);
175
+ };
176
+ if (preferred) move(hot, preferred);
177
+ const cached = pref.get(host);
178
+ if (cached) move(hot, cached);
179
+ return hot.concat(cold);
180
+ }
181
+
182
+ /**
183
+ * 依次试候选网关,返回第一个能承载 TLS(443 时)且连通的隧道。
184
+ * 已有已知路径(缓存或健康首选)时,第一条只给 fastFailTimeoutMs —— 它挂了要快点换。
185
+ */
186
+ async function openTunnel(host, port) {
187
+ const known = pref.get(host) || preferred;
188
+ const list = order(host);
189
+ for (let i = 0; i < list.length; i += 1) {
190
+ const gateway = list[i];
191
+ const timeout = i === 0 && known && opts.fastFailTimeoutMs < opts.attemptTimeoutMs
192
+ ? opts.fastFailTimeoutMs
193
+ : opts.attemptTimeoutMs;
194
+ if (port === 443 && opts.verifyTls) {
195
+ const ok = await probeTls(
196
+ (h, p) => openVia(gateway, h, p, timeout), host, port, timeout
197
+ );
198
+ if (!ok) {
199
+ log(host + ':' + port + ' ' + gateway.id + ' TLS probe failed');
200
+ noteFailure(gateway);
201
+ continue;
202
+ }
203
+ }
204
+ const sock = await openVia(gateway, host, port, timeout);
205
+ if (sock) {
206
+ noteSuccess(host, gateway);
207
+ log(host + ':' + port + ' -> ' + gateway.id);
208
+ return sock;
209
+ }
210
+ log(host + ':' + port + ' ' + gateway.id + ' failed');
211
+ noteFailure(gateway);
212
+ }
213
+ log(host + ':' + port + ' all gateways failed');
214
+ return null;
215
+ }
216
+
217
+ const MAX_BODY = 8 * 1024 * 1024; // 超过就不缓冲,也不重试
218
+
219
+ /** 所有在途 socket(入站与隧道出站),关闭时用于强制收尾。 */
220
+ const sockets = new Set();
221
+ const track = (socket) => {
222
+ sockets.add(socket);
223
+ socket.on('close', () => sockets.delete(socket));
224
+ };
225
+
226
+ const server = http.createServer((req, res) => {
227
+ let target;
228
+ try {
229
+ target = new URL(req.url);
230
+ } catch (e) {
231
+ res.writeHead(400);
232
+ res.end('bad request target');
233
+ return;
234
+ }
235
+ if (target.protocol !== 'http:') {
236
+ res.writeHead(400);
237
+ res.end('only http:// is proxied here; use CONNECT for https');
238
+ return;
239
+ }
240
+
241
+ const host = normalizeHost(target.hostname);
242
+ const port = Number(target.port || 80);
243
+ const headers = sanitizeHeaders(req.headers, target.host);
244
+ const idempotencyKey = hasIdempotencyKey(req.headers);
245
+
246
+ // 先缓冲请求体:失败换路时要能重放(模型调用多是 JSON POST)
247
+ const chunks = [];
248
+ let size = 0;
249
+ let overflow = false;
250
+ req.on('data', (chunk) => {
251
+ size += chunk.length;
252
+ if (size > MAX_BODY) { overflow = true; return; }
253
+ chunks.push(chunk);
254
+ });
255
+ req.on('end', () => {
256
+ if (overflow) {
257
+ res.writeHead(413);
258
+ res.end('request body too large to retry');
259
+ return;
260
+ }
261
+ const body = chunks.length ? Buffer.concat(chunks) : undefined;
262
+
263
+ // 两段超时:建连用 attempt/fast-fail,等响应头用 responseTimeoutMs;
264
+ // 响应体开始流之后不再计时(长回答可能很慢)。
265
+ // sent 以“到网关/目标的 TCP 连接是否建立”为界,用于决定失败后能否重放。
266
+ const attemptVia = (gateway, connectTimeoutMs) => new Promise((resolve) => {
267
+ const attemptHeaders = gateway.direct || !gateway.auth
268
+ ? headers
269
+ : Object.assign({}, headers, { 'proxy-authorization': gateway.auth });
270
+ const reqOpts = gateway.direct
271
+ ? { host, port, method: req.method, path: target.pathname + target.search, headers: attemptHeaders }
272
+ : { host: gateway.host, port: gateway.port, method: req.method, path: req.url, headers: attemptHeaders };
273
+
274
+ let sent = false;
275
+ let connectTimer;
276
+ let headerTimer;
277
+ const clearTimers = () => {
278
+ clearTimeout(connectTimer);
279
+ clearTimeout(headerTimer);
280
+ };
281
+ const fail = (message) => {
282
+ const err = new Error(message);
283
+ err.code = 'ETIMEDOUT';
284
+ clearTimers();
285
+ resolve({ err, sent });
286
+ upstreamReq.destroy(err);
287
+ };
288
+
289
+ const upstreamReq = http.request(reqOpts, (up) => {
290
+ clearTimers();
291
+ if (!gateway.direct && up.statusCode === 407) {
292
+ up.resume();
293
+ up.destroy();
294
+ resolve({ err: Object.assign(new Error('proxy authentication required'), { code: 'EPROXYAUTH' }), sent: true });
295
+ return;
296
+ }
297
+ resolve({ up });
298
+ });
299
+ connectTimer = setTimeout(() => fail('gateway connect timeout'), connectTimeoutMs);
300
+ upstreamReq.on('socket', (socket) => {
301
+ const markSent = () => {
302
+ sent = true;
303
+ clearTimeout(connectTimer);
304
+ headerTimer = setTimeout(() => fail('gateway response timeout'), opts.responseTimeoutMs);
305
+ };
306
+ if (socket.connecting) socket.once('connect', markSent);
307
+ else markSent();
308
+ });
309
+ upstreamReq.on('error', (err) => {
310
+ clearTimers();
311
+ resolve({ err, sent });
312
+ });
313
+ upstreamReq.end(body);
314
+ });
315
+
316
+ const known = pref.get(host) || preferred;
317
+ const list = order(host);
318
+ (async () => {
319
+ try {
320
+ for (let i = 0; i < list.length; i += 1) {
321
+ const gateway = list[i];
322
+ const timeout = i === 0 && known && opts.fastFailTimeoutMs < opts.attemptTimeoutMs
323
+ ? opts.fastFailTimeoutMs
324
+ : opts.attemptTimeoutMs;
325
+ const r = await attemptVia(gateway, timeout);
326
+ if (r.err) {
327
+ log(host + ' ' + gateway.id + ' failed: ' + (r.err.code || r.err.message));
328
+ noteFailure(gateway);
329
+ // 407 是代理在转发前拒绝,目标没收到请求:无论方法是否幂等都应换下一条。
330
+ if (r.err.code === 'EPROXYAUTH') continue;
331
+ if (!shouldRetryAfterSend({
332
+ method: req.method,
333
+ sent: r.sent,
334
+ retryNonIdempotentAfterSend: opts.retryNonIdempotentAfterSend,
335
+ retryNonIdempotentWithIdempotencyKey: opts.retryNonIdempotentWithIdempotencyKey,
336
+ idempotencyKey
337
+ })) {
338
+ log(host + ' ' + gateway.id + ' failed after send: ' +
339
+ (r.err.code || r.err.message) + '; not replayed (' + req.method + ')');
340
+ res.writeHead(502);
341
+ res.end('non-idempotent request may have been delivered; not replayed');
342
+ return;
343
+ }
344
+ if (idempotencyKey && !isIdempotent(req.method) &&
345
+ opts.retryNonIdempotentWithIdempotencyKey && !opts.retryNonIdempotentAfterSend) {
346
+ log(host + ' ' + gateway.id + ' failed after send: ' +
347
+ (r.err.code || r.err.message) + '; retrying (' + req.method + ', idempotency key)');
348
+ }
349
+ continue;
350
+ }
351
+ noteSuccess(host, gateway);
352
+ const up = r.up;
353
+ up.on('error', () => { try { res.destroy(); } catch (e) { /* ignore */ } });
354
+ res.on('close', () => {
355
+ if (!res.writableEnded) { try { up.destroy(); } catch (e) { /* ignore */ } }
356
+ });
357
+ res.writeHead(up.statusCode, stripHopByHop(up.headers));
358
+ up.pipe(res);
359
+ return;
360
+ }
361
+ res.writeHead(502);
362
+ res.end('all gateways failed');
363
+ } catch (err) {
364
+ log(host + ' handler error: ' + (err && err.message));
365
+ if (!res.headersSent) {
366
+ res.writeHead(502);
367
+ res.end('proxy error');
368
+ } else {
369
+ try { res.destroy(); } catch (e) { /* ignore */ }
370
+ }
371
+ }
372
+ })();
373
+ });
374
+ });
375
+
376
+ // 持久兜底:listen 之后 server 级 error 不能再变成未捕获异常;初始 EADDRINUSE 由 listen() 的
377
+ // 一次性监听负责 reject,这里只记录。
378
+ server.on('error', (err) => log('server error: ' + (err && err.message ? err.message : String(err))));
379
+
380
+ server.on('connection', track);
381
+
382
+ server.on('connect', async (req, clientSocket, head) => {
383
+ const url = String(req.url);
384
+ const idx = url.lastIndexOf(':');
385
+ const host = normalizeHost(idx === -1 ? url : url.slice(0, idx));
386
+ const port = Number(idx === -1 ? 443 : url.slice(idx + 1)) || 443;
387
+
388
+ clientSocket.on('error', () => {});
389
+ try {
390
+ const sock = await openTunnel(host, port);
391
+ if (!sock) {
392
+ clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
393
+ return;
394
+ }
395
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
396
+ track(sock);
397
+ const leftover = sock[LEFTOVER];
398
+ if (leftover && leftover.length) clientSocket.write(leftover);
399
+ if (head && head.length) sock.write(head);
400
+ sock.pipe(clientSocket);
401
+ clientSocket.pipe(sock);
402
+ sock.on('error', () => clientSocket.destroy());
403
+ clientSocket.on('error', () => sock.destroy());
404
+ } catch (err) {
405
+ log('connect handler error: ' + (err && err.message));
406
+ try { clientSocket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); } catch (e) { /* ignore */ }
407
+ }
408
+ });
409
+
410
+ server.on('clientError', (err, socket) => {
411
+ try { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } catch (e) { /* ignore */ }
412
+ });
413
+
414
+ /** 用健康检查地址验证通道:返回第一个 <500 的网关结果,全部失败回 null。 */
415
+ async function checkHealth() {
416
+ if (!health) return null;
417
+ for (const gateway of order(health.host)) {
418
+ const started = Date.now();
419
+ const status = await probeHttp(
420
+ (h, p) => openVia(gateway, h, p),
421
+ health.host, health.port, health.path, health.secure, healthTimeoutMs
422
+ );
423
+ if (typeof status === 'number' && status < 500) {
424
+ return { id: gateway.id, status, ms: Date.now() - started };
425
+ }
426
+ }
427
+ return null;
428
+ }
429
+
430
+ /**
431
+ * 验证一次通道;通过就设为全局首选并解除它的冷却,失败就清掉首选让请求重新扫描。
432
+ * 周期检查与失败踢动可能撞在一起,用重入锁保证同一时刻只有一次探测。
433
+ */
434
+ let healthRunning = false;
435
+ async function runHealth() {
436
+ if (healthRunning) return;
437
+ healthRunning = true;
438
+ try {
439
+ let result = null;
440
+ try {
441
+ result = await checkHealth();
442
+ } catch (e) {
443
+ result = null;
444
+ }
445
+ if (result) {
446
+ if (preferred !== result.id) {
447
+ log('health: model channel -> ' + result.id + ' (' + result.status + ', ' + result.ms + 'ms)');
448
+ }
449
+ preferred = result.id;
450
+ pref.set(health.host, result.id);
451
+ warm(result.id);
452
+ } else {
453
+ if (preferred !== null) log('health: no gateway reached ' + health.host + ':' + health.port);
454
+ preferred = null;
455
+ pref.delete(health.host);
456
+ }
457
+ } finally {
458
+ healthRunning = false;
459
+ }
460
+ }
461
+
462
+ /** 某条路刚失败时踢一次健康检查,不必等下一个周期;1 秒内只踢一次。 */
463
+ function kickHealth() {
464
+ if (!health || !opts.healthIntervalMs) return;
465
+ const now = Date.now();
466
+ if (now - lastKickAt < 1000) return;
467
+ lastKickAt = now;
468
+ const timer = setTimeout(runHealth, 0);
469
+ if (timer.unref) timer.unref();
470
+ }
471
+
472
+ function startHealth() {
473
+ if (!health || !opts.healthIntervalMs) return;
474
+ runHealth();
475
+ healthTimer = setInterval(runHealth, opts.healthIntervalMs);
476
+ if (healthTimer.unref) healthTimer.unref();
477
+ }
478
+
479
+ function stopHealth() {
480
+ if (healthTimer) {
481
+ clearInterval(healthTimer);
482
+ healthTimer = null;
483
+ }
484
+ }
485
+
486
+ return {
487
+ server,
488
+ pref,
489
+ candidates: publicCandidates,
490
+ listen() {
491
+ return new Promise((resolve, reject) => {
492
+ const onError = (err) => {
493
+ server.removeListener('error', onError);
494
+ reject(err);
495
+ };
496
+ server.once('error', onError);
497
+ server.listen(opts.listenPort, opts.listenHost, () => {
498
+ server.removeListener('error', onError);
499
+ resolve(server.address());
500
+ });
501
+ });
502
+ },
503
+ close() {
504
+ stopHealth();
505
+ return new Promise((resolve) => {
506
+ let settled = false;
507
+ let timer;
508
+ const finish = () => {
509
+ if (settled) return;
510
+ settled = true;
511
+ clearTimeout(timer);
512
+ resolve();
513
+ };
514
+ timer = setTimeout(() => {
515
+ for (const socket of sockets) {
516
+ try { socket.destroy(); } catch (e) { /* ignore */ }
517
+ }
518
+ sockets.clear();
519
+ finish();
520
+ }, opts.shutdownTimeoutMs);
521
+ if (timer.unref) timer.unref();
522
+ server.close(() => finish());
523
+ });
524
+ },
525
+ checkHealth,
526
+ runHealth,
527
+ startHealth,
528
+ stopHealth,
529
+ getPreferred: () => preferred,
530
+ isCooling
531
+ };
532
+ }