dsh-proxy-routing 0.4.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.
@@ -0,0 +1,133 @@
1
+ // lib/proxy/http11.js — 经已建立 socket 的 HTTP/1.1 请求:状态行 + 响应头解析,
2
+ // 流式响应体(chunked / content-length / 无长度读至 EOF)+ 解压,对齐 fetch 语义。
3
+ import { proxyError, abortError } from "./errors.js";
4
+ import { parseStatusLine, statusTextOf } from "./parse.js";
5
+ import { ByteStream } from "./stream.js";
6
+ import { makeBodyController } from "./decode.js";
7
+
8
+ /** 响应头数量上限(防畸形响应撑爆内存)。 */
9
+ export const MAX_HEADERS = 256;
10
+ /** 单个 chunk 上限 64MB。 */
11
+ export const MAX_CHUNK = 64 * 1024 * 1024;
12
+
13
+ /** 无响应体的状态码(对齐 fetch:204/205/304 及 HEAD 请求)。 */
14
+ function noBody(status, method) {
15
+ return status === 204 || status === 205 || status === 304 || method === "HEAD";
16
+ }
17
+
18
+ /**
19
+ * 发送 HTTP/1.1 请求并返回流式 Response(解压 + 无 body 短路)。
20
+ * @param {import("node:net").Socket} sock 已连到目标(HTTP)或隧道+TLS(HTTPS)的 socket
21
+ * @param {string} url 完整目标 URL(用于 Response.url)
22
+ * @param {string} head 已组装的请求头块(含 \r\n\r\n 结尾)
23
+ * @param {*} body string/Buffer/Readable 或 null
24
+ * @param {{signal?:AbortSignal, idleMs?:number}} [opts] idleMs:响应读空闲超时(每次收包重置;大下载不被绝对超时误杀)
25
+ */
26
+ export async function sendViaSocket(sock, url, head, body, { signal, idleMs } = {}) {
27
+ // 请求包 = 头 + 体;体非空且未声明 content-length 时补上(HEAD 等场景体为空)。
28
+ let packet = Buffer.from(head, "latin1");
29
+ if (body != null) {
30
+ const buf = Buffer.isBuffer(body) ? body : typeof body === "string" ? Buffer.from(body) : null;
31
+ if (buf) {
32
+ if (!/content-length:/i.test(head)) {
33
+ packet = Buffer.concat([Buffer.from(head.replace(/\r\n\r\n$/, `\r\nContent-Length: ${buf.length}\r\n\r\n`), "latin1"), buf]);
34
+ } else {
35
+ packet = Buffer.concat([packet, buf]);
36
+ }
37
+ }
38
+ }
39
+ const stream = new ByteStream(sock, { idleMs });
40
+
41
+ const onAbort = () => {
42
+ try { sock.destroy(); } catch {}
43
+ stream.error = abortError();
44
+ stream.ended = true;
45
+ stream.detach();
46
+ };
47
+ if (signal) {
48
+ if (signal.aborted) {
49
+ try { sock.destroy(); } catch {}
50
+ throw abortError();
51
+ }
52
+ signal.addEventListener("abort", onAbort, { once: true });
53
+ }
54
+
55
+ sock.write(packet);
56
+ if (body != null && typeof body.pipe === "function") body.pipe(sock);
57
+
58
+ try {
59
+ const statusLine = await stream.readLine();
60
+ const status = parseStatusLine(statusLine);
61
+ if (status === 0) throw proxyError("EPARSE", `malformed status line: ${statusLine.slice(0, 80)}`);
62
+
63
+ const headers = {};
64
+ for (let n = 0; ; n++) {
65
+ const line = await stream.readLine();
66
+ if (line === "") break;
67
+ if (n >= MAX_HEADERS) throw proxyError("EHEADER", "too many response headers");
68
+ const ci = line.indexOf(":");
69
+ if (ci === -1) continue;
70
+ const k = line.slice(0, ci).trim().toLowerCase();
71
+ headers[k] = headers[k] !== undefined ? `${headers[k]}, ${line.slice(ci + 1).trim()}` : line.slice(ci + 1).trim();
72
+ }
73
+
74
+ const ce = (headers["content-encoding"] || "").toLowerCase();
75
+ const doDecode = ce === "gzip" || ce === "deflate" || ce === "br";
76
+ if (doDecode) delete headers["content-length"]; // 本地解压后长度不再匹配
77
+
78
+ const method = head.split(" ")[0].toUpperCase();
79
+ if (noBody(status, method)) {
80
+ try { sock.destroy(); } catch {}
81
+ const resp = new Response(null, { status, statusText: statusTextOf(statusLine), headers: new Headers(headers) });
82
+ Object.defineProperty(resp, "url", { value: url });
83
+ return resp;
84
+ }
85
+
86
+ const streamBody = new ReadableStream({
87
+ start(controller) {
88
+ (async () => {
89
+ const sink = makeBodyController(controller, ce);
90
+ try {
91
+ const te = (headers["transfer-encoding"] || "").toLowerCase();
92
+ if (te.includes("chunked")) {
93
+ for (;;) {
94
+ const line = await stream.readLine();
95
+ const size = parseInt(line.trim().split(";")[0], 16);
96
+ if (!Number.isFinite(size) || size <= 0) break;
97
+ if (size > MAX_CHUNK) throw proxyError("ECHUNK", "chunk too large");
98
+ await sink.write(await stream.readExactly(size));
99
+ await stream.readLine(); // chunk 尾部 CRLF
100
+ }
101
+ } else {
102
+ const hasCl = /^\d+$/.test((headers["content-length"] || "").trim());
103
+ let rem = hasCl ? Number(headers["content-length"]) : -1;
104
+ for (;;) {
105
+ const d = await stream.nextData();
106
+ if (d === null) break;
107
+ if (rem >= 0) {
108
+ if (d.length >= rem) { await sink.write(d.subarray(0, rem)); rem = 0; }
109
+ else { rem -= d.length; await sink.write(d); }
110
+ } else {
111
+ await sink.write(d);
112
+ }
113
+ if (rem === 0) break;
114
+ }
115
+ }
116
+ await sink.finish();
117
+ } catch (e) {
118
+ sink.destroy();
119
+ try { controller.error(e); } catch {}
120
+ } finally {
121
+ try { sock.destroy(); } catch {}
122
+ }
123
+ })();
124
+ },
125
+ });
126
+
127
+ const resp = new Response(streamBody, { status, statusText: statusTextOf(statusLine), headers: new Headers(headers) });
128
+ Object.defineProperty(resp, "url", { value: url });
129
+ return resp;
130
+ } finally {
131
+ if (signal) signal.removeEventListener("abort", onAbort);
132
+ }
133
+ }
@@ -0,0 +1,96 @@
1
+ // lib/proxy/http2.js — 经 TLS socket 已协商出 h2(ALPN)时发起 HTTP/2 请求
2
+ //
3
+ // 用 node:http2 的 createConnection 挂到已建立的 TLS socket 上,避免重复
4
+ // 握手;响应流式读 + 解压,对齐 fetch 语义。
5
+ import http2 from "node:http2";
6
+ import { proxyError, abortError } from "./errors.js";
7
+ import { makeBodyController } from "./decode.js";
8
+
9
+ /**
10
+ * @param {import("node:tls").TLSSocket} tlsSock 已与目标完成 TLS 且 ALPN=h2
11
+ * @param {string} authority Host[:port]
12
+ * @param {URL} urlObj
13
+ * @param {string} method
14
+ * @param {Record<string,string>} headers 普通头(不含伪头)
15
+ * @param {*} body string/Buffer 或 null
16
+ * @param {AbortSignal} [signal]
17
+ */
18
+ export async function sendViaHttp2(tlsSock, authority, urlObj, method, headers, body, signal) {
19
+ const client = http2.connect(`https://${authority}`, { createConnection: () => tlsSock });
20
+ const reqHeaders = {
21
+ ":method": method,
22
+ ":path": urlObj.pathname + urlObj.search,
23
+ ":scheme": "https",
24
+ ":authority": authority,
25
+ ...headers,
26
+ };
27
+ if (body != null && (Buffer.isBuffer(body) || typeof body === "string")) {
28
+ reqHeaders["content-length"] = Buffer.byteLength(body);
29
+ }
30
+ const req = client.request(reqHeaders);
31
+ let onAbort = () => {};
32
+ try {
33
+ return await new Promise((resolve, reject) => {
34
+ let done = false;
35
+ const fin = (err, resp) => {
36
+ if (done) return;
37
+ done = true;
38
+ err ? reject(err) : resolve(resp);
39
+ };
40
+ onAbort = () => {
41
+ fin(abortError());
42
+ try { client.destroy(); } catch {}
43
+ };
44
+ if (signal) {
45
+ if (signal.aborted) return onAbort();
46
+ signal.addEventListener("abort", onAbort, { once: true });
47
+ }
48
+ req.on("error", (e) => fin(proxyError("EHTTP2", `HTTP/2 request failed: ${e.message}`, e)));
49
+ req.on("response", (h) => {
50
+ try {
51
+ const status = Number(h[":status"]) || 0;
52
+ const headers = {};
53
+ for (const k of Object.keys(h)) {
54
+ if (k === ":status") continue;
55
+ const lk = k.toLowerCase();
56
+ headers[lk] = Array.isArray(h[k]) ? h[k].join(", ") : String(h[k]);
57
+ }
58
+ const ce = (headers["content-encoding"] || "").toLowerCase();
59
+ const doDecode = ce === "gzip" || ce === "deflate" || ce === "br";
60
+ if (doDecode) delete headers["content-length"];
61
+ if (status === 204 || status === 205 || status === 304) {
62
+ const empty = new Response(null, { status, headers: new Headers(headers) });
63
+ Object.defineProperty(empty, "url", { value: urlObj.href });
64
+ try { client.close(); } catch {}
65
+ return fin(null, empty);
66
+ }
67
+ const streamBody = new ReadableStream({
68
+ start(c) {
69
+ const sink = makeBodyController(c, ce);
70
+ if (sink.dec) {
71
+ req.on("data", (d) => { if (!sink.dec.write(d)) req.pause(); });
72
+ sink.dec.on("drain", () => req.resume());
73
+ } else {
74
+ req.on("data", (d) => { try { c.enqueue(new Uint8Array(d)); } catch {} });
75
+ }
76
+ req.on("end", () => { sink.finish(); try { client.close(); } catch {} });
77
+ req.on("error", (e) => { sink.destroy(); try { c.error(e); } catch {} });
78
+ },
79
+ });
80
+ const resp = new Response(streamBody, { status, headers: new Headers(headers) });
81
+ Object.defineProperty(resp, "url", { value: urlObj.href });
82
+ fin(null, resp);
83
+ } catch (e) {
84
+ fin(proxyError("EHTTP2", `HTTP/2 response parse failed: ${e.message}`, e));
85
+ }
86
+ });
87
+ if (body != null && (Buffer.isBuffer(body) || typeof body === "string")) {
88
+ req.end(Buffer.isBuffer(body) ? body : Buffer.from(body));
89
+ } else {
90
+ req.end();
91
+ }
92
+ });
93
+ } finally {
94
+ if (signal) signal.removeEventListener("abort", onAbort);
95
+ }
96
+ }
@@ -0,0 +1,92 @@
1
+ // lib/proxy/noproxy.js — NO_PROXY 命中判定(纯函数,零依赖)
2
+ //
3
+ // 支持的条目形式(大小写不敏感,条目可带端口):
4
+ // - `*` :全部命中
5
+ // - `<local>` :回环与 localhost 系列
6
+ // - `example.com` / `.example.com`:host 精确匹配或域名后缀
7
+ // - `example.com:443` :host + 端口同时匹配
8
+ // - `10.0.0.0/8` :IPv4 CIDR
9
+
10
+ /** IPv4 字面量转 32 位整数;非 IPv4 返回 null。 */
11
+ function ipv4ToInt(s) {
12
+ const p = String(s).split(".");
13
+ if (p.length !== 4) return null;
14
+ let n = 0;
15
+ for (const part of p) {
16
+ const v = Number(part);
17
+ if (!Number.isInteger(v) || v < 0 || v > 255) return null;
18
+ n = n * 256 + v;
19
+ }
20
+ return n;
21
+ }
22
+
23
+ /** 取 IPv4-mapped IPv6(::ffff:a.b.c.d / ::ffff:7f00:1)的低 32 位;非映射返回 null。 */
24
+ function mappedIpv4(bare) {
25
+ const m = /^::ffff:(.+)$/i.exec(bare);
26
+ if (!m) return null;
27
+ const rest = m[1].toLowerCase();
28
+ if (rest.includes(".")) return ipv4ToInt(rest); // 点分十进制
29
+ const parts = rest.split(":").map((p) => parseInt(p, 16)); // 十六进制 1-4 段
30
+ if (parts.some((p) => !Number.isInteger(p) || p < 0 || p > 0xffff)) return null;
31
+ let n = 0;
32
+ for (const p of parts) n = n * 0x10000 + p;
33
+ return n;
34
+ }
35
+
36
+ /** 判定 rawUrl 是否命中 noProxy 列表(命中则直连)。 */
37
+ export function isNoProxy(rawUrl, noProxy) {
38
+ if (!Array.isArray(noProxy) || noProxy.length === 0) return false;
39
+ let host;
40
+ let port = null;
41
+ try {
42
+ const u = new URL(rawUrl);
43
+ host = u.hostname.toLowerCase();
44
+ port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
45
+ } catch {
46
+ host = String(rawUrl).toLowerCase();
47
+ }
48
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
49
+
50
+ for (const raw of noProxy) {
51
+ const e = String(raw || "").trim().toLowerCase();
52
+ if (!e) continue;
53
+ if (e === "*") return true;
54
+ if (e === "<local>") {
55
+ // 回环系列:localhost 子域、::1、127.0.0.0/8、IPv4-mapped 回环(::ffff:127.* 两种写法)
56
+ const mapped = mappedIpv4(bare);
57
+ if (host === "localhost" || host.endsWith(".localhost") || host === "::1" ||
58
+ bare.startsWith("127.") || (mapped != null && (mapped >>> 24) === 127)) return true;
59
+ continue;
60
+ }
61
+ // host:port 形式:端口不匹配则跳过该条;支持方括号 IPv6([::1]:8080)
62
+ let onlyHost = e;
63
+ if (port != null) {
64
+ const bracket = /^\[([^\]]+)\]:(\d+)$/.exec(e);
65
+ if (bracket) {
66
+ if (Number(bracket[2]) !== port) continue;
67
+ onlyHost = bracket[1];
68
+ } else if (/^[^:]+:\d+$/.test(e)) {
69
+ const ci = e.lastIndexOf(":");
70
+ if (Number(e.slice(ci + 1)) !== port) continue;
71
+ onlyHost = e.slice(0, ci);
72
+ }
73
+ }
74
+ // IPv4 CIDR
75
+ if (onlyHost.includes("/")) {
76
+ const [cidrHost, cidrStr] = onlyHost.split("/");
77
+ const cidr = parseInt(cidrStr, 10);
78
+ const ip = ipv4ToInt(bare);
79
+ const base = ipv4ToInt(cidrHost);
80
+ if (ip != null && base != null && Number.isInteger(cidr) && cidr >= 0 && cidr <= 32) {
81
+ if (cidr === 0) return true;
82
+ const mask = cidr === 32 ? 0xffffffff : ~((1 << (32 - cidr)) - 1) >>> 0;
83
+ if ((ip & mask) === (base & mask)) return true;
84
+ }
85
+ continue;
86
+ }
87
+ // 支持 `.example.com` 与 `example.com` 两种写法(都匹配自身与子域);bare 覆盖 IPv6 无括号写法
88
+ const domain = onlyHost.startsWith(".") ? onlyHost.slice(1) : onlyHost;
89
+ if (host === domain || bare === domain || host.endsWith("." + domain)) return true;
90
+ }
91
+ return false;
92
+ }
@@ -0,0 +1,22 @@
1
+ // lib/proxy/parse.js — 协议解析小工具(纯函数)
2
+ import zlib from "node:zlib";
3
+
4
+ /** 按 content-encoding 创建解压流;不支持或无编码时返回 null。 */
5
+ export function createDecoder(ce) {
6
+ if (ce === "gzip") return zlib.createGunzip();
7
+ if (ce === "deflate") return zlib.createInflate();
8
+ if (ce === "br") return zlib.createBrotliDecompress();
9
+ return null;
10
+ }
11
+
12
+ /** 解析 HTTP 状态行(如 `HTTP/1.1 200 OK`),返回状态码;不匹配返回 0。 */
13
+ export function parseStatusLine(line) {
14
+ const m = /^HTTP\/\d\.\d\s+(\d{3})/.exec(String(line || ""));
15
+ return m ? Number(m[1]) : 0;
16
+ }
17
+
18
+ /** 从状态行提取状态文本(OK / Not Found …),无则空串。 */
19
+ export function statusTextOf(line) {
20
+ const m = /^HTTP\/\d\.\d\s+\d{3}\s*(.*)$/.exec(String(line || ""));
21
+ return m ? m[1].trim() : "";
22
+ }
@@ -0,0 +1,206 @@
1
+ // lib/proxy/request.js — proxiedFetch 入口:输入规范化、NO_PROXY 直连判定、
2
+ // 请求头组装、http/https 协议分发、TLS(SNI/ALPN)、重定向跟随(对齐 fetch 语义)。
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ import { proxyError } from "./errors.js";
6
+ import { connectProxy, httpConnect, socksConnect } from "./connect.js";
7
+ import { sendViaSocket } from "./http11.js";
8
+ import { sendViaHttp2 } from "./http2.js";
9
+ import { isNoProxy } from "./noproxy.js";
10
+
11
+ /** hop-by-hop 头:转发时移除(代理语义),Host 以计算值为准。 */
12
+ const HOP_BY_HOP = ["proxy-connection", "keep-alive", "connection", "upgrade", "transfer-encoding", "host", "content-length", "accept-encoding"];
13
+
14
+ /** 从 fetch init 提取 header 映射(支持 Headers 实例 / 数组 / 普通对象)。 */
15
+ function collectHeaders(init) {
16
+ const out = {};
17
+ const src = init && init.headers;
18
+ if (src == null || typeof src !== "object") return out;
19
+ if (typeof Headers !== "undefined" && src instanceof Headers) {
20
+ src.forEach((v, k) => (out[k] = v));
21
+ } else if (Array.isArray(src)) {
22
+ for (const [k, v] of src) out[k] = v;
23
+ } else {
24
+ Object.assign(out, src);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** 从 fetch init 读取单个头(不区分大小写)。 */
30
+ function headerValue(init, name) {
31
+ const src = init && init.headers;
32
+ if (src == null || typeof src !== "object") return null;
33
+ if (typeof Headers !== "undefined" && src instanceof Headers) return src.get(name);
34
+ if (Array.isArray(src)) {
35
+ const lname = name.toLowerCase();
36
+ const f = src.find((x) => String(x[0]).toLowerCase() === lname);
37
+ return f ? f[1] : null;
38
+ }
39
+ for (const k of Object.keys(src)) {
40
+ if (k.toLowerCase() === name.toLowerCase()) return src[k];
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** 规范化请求体:string/Buffer 原样;URLSearchParams 转 Buffer;ReadableStream 有上限缓冲;
46
+ * Blob 等 arrayBuffer 可转者读取;FormData 与未知类型显式拒绝(绝不静默发送空体)。 */
47
+ const MAX_REQUEST_BODY = 512 * 1024 * 1024;
48
+
49
+ async function normalizeBody(body) {
50
+ if (body == null) return null;
51
+ if (Buffer.isBuffer(body)) return body;
52
+ if (typeof body === "string") return Buffer.from(body);
53
+ if (body instanceof URLSearchParams) return Buffer.from(body.toString());
54
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
55
+ throw proxyError("EBODY", "FormData request bodies are not supported through the proxy transport");
56
+ }
57
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
58
+ // 无 Content-Length 的裸流无法可靠分帧;改为有上限缓冲后带 CL 发送(保留语义,拒绝超大)
59
+ const reader = body.getReader();
60
+ const parts = [];
61
+ let total = 0;
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+ total += value.length;
66
+ if (total > MAX_REQUEST_BODY) throw proxyError("EBODY", "request body exceeds 512MB cap");
67
+ parts.push(Buffer.from(value));
68
+ }
69
+ return Buffer.concat(parts);
70
+ }
71
+ if (typeof body.arrayBuffer === "function") {
72
+ return Buffer.from(await body.arrayBuffer()); // Blob/ArrayBufferView;转换失败直接抛出,不吞错
73
+ }
74
+ throw proxyError("EBODY", "unsupported request body type");
75
+ }
76
+
77
+ /** 校验头名/值不含 CR/LF(防注入;含则拒绝)。 */
78
+ function assertHeaderSafe(name, value) {
79
+ if (/[\r\n]/.test(name) || /[\r\n]/.test(String(value))) {
80
+ throw proxyError("EPROTO", "request header name/value contains CR or LF");
81
+ }
82
+ }
83
+
84
+ /** 解析输入为 { url, method }(支持 string / URL / Request-like)。 */
85
+ function parseInput(input, init) {
86
+ if (typeof input === "string" || input instanceof URL) return { url: String(input), method: (init && init.method) || "GET" };
87
+ if (input && typeof input === "object" && input.url) {
88
+ return { url: String(input.url), method: (init && init.method) || input.method || "GET" };
89
+ }
90
+ throw new Error("proxiedFetch: invalid input");
91
+ }
92
+
93
+ /** 发起单次请求(不跟随重定向)。 */
94
+ async function proxiedOnce(url, init, proxy, originalFetch) {
95
+ const urlObj = new URL(url);
96
+ const method = ((init && init.method) || "GET").toUpperCase();
97
+ const body = await normalizeBody(init && init.body);
98
+ const signal = init && init.signal;
99
+
100
+ if (isNoProxy(url, proxy.noProxy)) {
101
+ return originalFetch(url, init);
102
+ }
103
+
104
+ const targetHostRaw = urlObj.hostname; // IPv6 形如 [::1]
105
+ const targetHost = targetHostRaw.startsWith("[") && targetHostRaw.endsWith("]") ? targetHostRaw.slice(1, -1) : targetHostRaw;
106
+ const defaultPort = urlObj.protocol === "https:" ? 443 : 80;
107
+ const targetPort = urlObj.port ? Number(urlObj.port) : defaultPort;
108
+ const hostHeader = targetPort === defaultPort ? targetHostRaw : `${targetHostRaw}:${targetPort}`;
109
+ const isSocks = proxy.protocol === "socks5";
110
+
111
+ // 组装头:去掉 hop-by-hop;accept-encoding 尊重调用方显式声明,否则 identity
112
+ const headers = collectHeaders(init);
113
+ for (const h of HOP_BY_HOP) delete headers[h];
114
+ const declaredAE = headerValue(init, "accept-encoding");
115
+ headers["accept-encoding"] = declaredAE != null && String(declaredAE).trim() !== "" ? String(declaredAE) : "identity";
116
+
117
+ if (urlObj.protocol === "https:") {
118
+ // HTTPS 目标:先建隧道,再 TLS(IP 目标不发 SNI;ALPN 协商 h2/http1.1)
119
+ let raw;
120
+ if (isSocks) raw = await socksConnect(proxy, targetHost, targetPort, signal, { handshakeTimeoutMs: proxy.timeout });
121
+ else raw = await httpConnect(proxy, targetHost, targetPort, signal, { handshakeTimeoutMs: proxy.timeout });
122
+ try {
123
+ const isIP = net.isIP(targetHost);
124
+ const tlsSock = tls.connect({
125
+ socket: raw,
126
+ servername: isIP ? undefined : targetHost,
127
+ host: targetHost,
128
+ port: targetPort,
129
+ ALPNProtocols: ["h2", "http/1.1"],
130
+ });
131
+ await new Promise((resolve, reject) => {
132
+ tlsSock.once("secureConnect", () => { tlsSock.removeAllListeners("error"); resolve(); });
133
+ tlsSock.once("error", (e) => reject(proxyError("ETLS", `TLS to ${targetHost} failed: ${e.message}`, e)));
134
+ });
135
+ if (tlsSock.alpnProtocol === "h2") {
136
+ return sendViaHttp2(tlsSock, hostHeader, urlObj, method, headers, body, signal);
137
+ }
138
+ const path = urlObj.pathname + urlObj.search;
139
+ const h = { Host: hostHeader, Connection: "close", ...headers };
140
+ for (const [k, v] of Object.entries(h)) assertHeaderSafe(k, v);
141
+ const head = `${method} ${path} HTTP/1.1\r\n${Object.entries(h).map(([k, v]) => `${k}: ${v}`).join("\r\n")}\r\n\r\n`;
142
+ return await sendViaSocket(tlsSock, url, head, body, { signal, idleMs: proxy.timeout });
143
+ } catch (e) {
144
+ try { raw.destroy(); } catch {}
145
+ throw e;
146
+ }
147
+ }
148
+
149
+ if (urlObj.protocol === "http:") {
150
+ // HTTP 目标:绝对 URL 形式发往代理
151
+ const sock = await connectProxy(proxy, signal, { connectTimeoutMs: proxy.timeout });
152
+ try {
153
+ const absUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}${urlObj.search}`;
154
+ const proxyAuth = proxy.username || proxy.password
155
+ ? { "Proxy-Authorization": "Basic " + Buffer.from(`${proxy.username || ""}:${proxy.password || ""}`).toString("base64") }
156
+ : {};
157
+ const h = { Host: urlObj.host, Connection: "close", ...proxyAuth, ...headers };
158
+ for (const [k, v] of Object.entries(h)) assertHeaderSafe(k, v);
159
+ const head = `${method} ${absUrl} HTTP/1.1\r\n${Object.entries(h).map(([k, v]) => `${k}: ${v}`).join("\r\n")}\r\n\r\n`;
160
+ return await sendViaSocket(sock, url, head, body, { signal, idleMs: proxy.timeout });
161
+ } catch (e) {
162
+ try { sock.destroy(); } catch {}
163
+ throw e;
164
+ }
165
+ }
166
+
167
+ throw proxyError("EPROTO", `unsupported protocol ${urlObj.protocol}`);
168
+ }
169
+
170
+ /**
171
+ * 把一次 fetch 请求经代理发出(含重定向跟随,最多 5 跳;命中 NO_PROXY 直连)。
172
+ * @param {string|URL|{url:string}} input
173
+ * @param {RequestInit} [init]
174
+ * @param {{protocol:string,host:string,port:number,username?:string,password?:string,noProxy?:string[],timeout?:number}} proxy
175
+ * @param {typeof fetch} originalFetch 直连路径使用的原始 fetch
176
+ */
177
+ export async function proxiedFetch(input, init = {}, proxy, originalFetch) {
178
+ const { url, method } = parseInput(input, init);
179
+ let curUrl = url;
180
+ let curInit = { ...init };
181
+ for (let hop = 0; hop <= 5; hop++) {
182
+ const resp = await proxiedOnce(curUrl, curInit, proxy, originalFetch);
183
+ const status = resp.status;
184
+ if (status === 301 || status === 302 || status === 303 || status === 307 || status === 308) {
185
+ const loc = resp.headers ? resp.headers.get("location") : null;
186
+ if (loc) {
187
+ const next = new URL(loc, curUrl).href;
188
+ // 303 → GET;301/302 对 POST 也降级为 GET(对齐 fetch 行为)
189
+ let nextInit = { ...curInit };
190
+ if (status === 303 || ((status === 301 || status === 302) && method === "POST")) {
191
+ nextInit = { ...nextInit, method: "GET", body: undefined, headers: { ...(curInit.headers || {}) } };
192
+ if (typeof Headers !== "undefined" && curInit.headers instanceof Headers) {
193
+ const h = new Headers();
194
+ curInit.headers.forEach((v, k) => { if (!/^content-/i.test(k)) h.set(k, v); });
195
+ nextInit.headers = h;
196
+ }
197
+ }
198
+ curUrl = next;
199
+ curInit = nextInit;
200
+ continue;
201
+ }
202
+ }
203
+ return resp;
204
+ }
205
+ throw proxyError("EREDIRECT", "too many redirects");
206
+ }