230 lines
7.0 KiB
TypeScript
230 lines
7.0 KiB
TypeScript
import net from "node:net";
|
|
import tls from "node:tls";
|
|
import { URL } from "node:url";
|
|
import { SocksClient } from "socks";
|
|
|
|
import type {
|
|
WechatClient,
|
|
WechatHttpInit,
|
|
WechatHttpResponse,
|
|
} from "./wechat-http.ts";
|
|
|
|
export interface SocksProxyEndpoint {
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient {
|
|
if (!proxy.host) throw new Error("SOCKS proxy host required");
|
|
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
|
|
throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`);
|
|
}
|
|
|
|
return async (url, init = {}) => {
|
|
return wechatHttpViaSocks(url, init, proxy);
|
|
};
|
|
}
|
|
|
|
async function wechatHttpViaSocks(
|
|
rawUrl: string,
|
|
init: WechatHttpInit,
|
|
proxy: SocksProxyEndpoint,
|
|
): Promise<WechatHttpResponse> {
|
|
const url = new URL(rawUrl);
|
|
const isHttps = url.protocol === "https:";
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`);
|
|
}
|
|
const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80;
|
|
|
|
const { socket: tcpSocket } = await SocksClient.createConnection({
|
|
proxy: { host: proxy.host, port: proxy.port, type: 5 },
|
|
command: "connect",
|
|
destination: { host: url.hostname, port: targetPort },
|
|
});
|
|
|
|
let stream: net.Socket | tls.TLSSocket;
|
|
if (isHttps) {
|
|
const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname });
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onSecure = () => {
|
|
tlsSocket.removeListener("error", onError);
|
|
resolve();
|
|
};
|
|
const onError = (err: Error) => {
|
|
tlsSocket.removeListener("secureConnect", onSecure);
|
|
try {
|
|
tlsSocket.destroy();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
try {
|
|
tcpSocket.destroy();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
reject(err);
|
|
};
|
|
tlsSocket.once("secureConnect", onSecure);
|
|
tlsSocket.once("error", onError);
|
|
});
|
|
stream = tlsSocket;
|
|
} else {
|
|
stream = tcpSocket;
|
|
}
|
|
|
|
try {
|
|
return await sendRequestAndReadResponse(stream, url, init);
|
|
} finally {
|
|
try {
|
|
stream.destroy();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendRequestAndReadResponse(
|
|
stream: net.Socket | tls.TLSSocket,
|
|
url: URL,
|
|
init: WechatHttpInit,
|
|
): Promise<WechatHttpResponse> {
|
|
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
|
const body =
|
|
init.body === undefined
|
|
? undefined
|
|
: Buffer.isBuffer(init.body)
|
|
? init.body
|
|
: Buffer.from(init.body, "utf-8");
|
|
|
|
const userHeaders = init.headers ?? {};
|
|
const headerMap = new Map<string, string>();
|
|
for (const [k, v] of Object.entries(userHeaders)) {
|
|
headerMap.set(k.toLowerCase(), `${k}: ${v}`);
|
|
}
|
|
if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`);
|
|
if (!headerMap.has("user-agent")) {
|
|
headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api");
|
|
}
|
|
headerMap.set("connection", "Connection: close");
|
|
if (body && !headerMap.has("content-length")) {
|
|
headerMap.set("content-length", `Content-Length: ${body.length}`);
|
|
}
|
|
|
|
const path = `${url.pathname || "/"}${url.search}`;
|
|
const requestHeader = Buffer.from(
|
|
`${method} ${path} HTTP/1.1\r\n` +
|
|
Array.from(headerMap.values()).join("\r\n") +
|
|
"\r\n\r\n",
|
|
"utf-8",
|
|
);
|
|
|
|
await writeAll(stream, requestHeader);
|
|
if (body) await writeAll(stream, body);
|
|
|
|
const raw = await readToEnd(stream);
|
|
return parseHttpResponse(raw);
|
|
}
|
|
|
|
function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
stream.write(data, (err) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function readToEnd(stream: net.Socket | tls.TLSSocket): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
stream.once("end", () => resolve(Buffer.concat(chunks)));
|
|
stream.once("error", reject);
|
|
});
|
|
}
|
|
|
|
function parseHttpResponse(raw: Buffer): WechatHttpResponse {
|
|
const headerEnd = raw.indexOf("\r\n\r\n");
|
|
if (headerEnd < 0) {
|
|
throw new Error("Malformed HTTP response: missing header terminator");
|
|
}
|
|
const headerText = raw.subarray(0, headerEnd).toString("utf-8");
|
|
let bodyBytes = raw.subarray(headerEnd + 4);
|
|
|
|
const lines = headerText.split("\r\n");
|
|
const statusLine = lines.shift() ?? "";
|
|
const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/);
|
|
if (!statusMatch) {
|
|
throw new Error(`Malformed HTTP status line: ${statusLine}`);
|
|
}
|
|
const status = Number.parseInt(statusMatch[1]!, 10);
|
|
const statusText = statusMatch[2] ?? "";
|
|
|
|
const headers: Record<string, string | string[] | undefined> = {};
|
|
const lowercaseHeaders: Record<string, string> = {};
|
|
for (const line of lines) {
|
|
const colon = line.indexOf(":");
|
|
if (colon < 0) continue;
|
|
const key = line.slice(0, colon).trim();
|
|
const value = line.slice(colon + 1).trim();
|
|
const lower = key.toLowerCase();
|
|
const existing = headers[lower];
|
|
if (existing === undefined) {
|
|
headers[lower] = value;
|
|
} else if (Array.isArray(existing)) {
|
|
existing.push(value);
|
|
} else {
|
|
headers[lower] = [existing, value];
|
|
}
|
|
lowercaseHeaders[lower] = value;
|
|
}
|
|
|
|
const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase();
|
|
if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) {
|
|
bodyBytes = dechunk(bodyBytes);
|
|
} else if (lowercaseHeaders["content-length"] !== undefined) {
|
|
const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10);
|
|
if (Number.isFinite(length) && length >= 0) {
|
|
bodyBytes = bodyBytes.subarray(0, length);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status,
|
|
statusText,
|
|
headers,
|
|
async buffer() {
|
|
return bodyBytes;
|
|
},
|
|
async text() {
|
|
return bodyBytes.toString("utf-8");
|
|
},
|
|
async json<T = unknown>() {
|
|
return JSON.parse(bodyBytes.toString("utf-8")) as T;
|
|
},
|
|
};
|
|
}
|
|
|
|
function dechunk(raw: Buffer): Buffer {
|
|
const parts: Buffer[] = [];
|
|
let offset = 0;
|
|
while (offset < raw.length) {
|
|
const lineEnd = raw.indexOf("\r\n", offset);
|
|
if (lineEnd < 0) break;
|
|
const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim();
|
|
const size = Number.parseInt(sizeText, 16);
|
|
if (!Number.isFinite(size) || size < 0) {
|
|
throw new Error(`Invalid chunked-encoding size: ${sizeText}`);
|
|
}
|
|
offset = lineEnd + 2;
|
|
if (size === 0) break;
|
|
if (offset + size > raw.length) {
|
|
throw new Error("Chunked-encoding body truncated");
|
|
}
|
|
parts.push(raw.subarray(offset, offset + size));
|
|
offset += size + 2;
|
|
}
|
|
return Buffer.concat(parts);
|
|
}
|