mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
fix(baoyu-post-to-wechat): make remote-api work under Bun & validate config strictly
The initial remote-api implementation (3b29f3c) relied on
`https.request({ agent: SocksProxyAgent })` to route token/upload/draft
calls through the SSH tunnel. Bun's `https.request` does not honor
Node's `http.Agent` contract, so the agent was silently bypassed and
requests still originated from the local IP — defeating the entire
IP-allowlist purpose. Two follow-on issues compounded it: tests read
the real `~/.baoyu-skills/.env` because Bun's `os.homedir()` ignores
test-time `process.env.HOME` mutations, and invalid config values were
silently coerced to defaults.
P1 — Bun-portable SOCKS routing:
- Drop `socks-proxy-agent` dependency. Add `socks` direct dep.
- New `wechat-socks-http.ts`: raw TCP via `SocksClient.createConnection`
+ `tls.connect({ socket, servername })` + hand-built HTTP/1.1 (status
line parser, case-insensitive headers, chunked & content-length body
framing). Works identically under Node and Bun because it avoids
`http.Agent` entirely.
- Rewrite `wechat-http.ts` as a fetch-based local client and expose
a `WechatClient = (url, init?) => Promise<WechatHttpResponse>`
functional abstraction.
- `wechat-api.ts`: replace `agent?: http.Agent` with
`client: WechatClient = wechatHttp` on the five HTTP-touching
functions; `withSshTunnel` now yields a `WechatClient`.
- New `wechat-socks-http.test.ts` stands up a real SOCKS5 server
stub + HTTP echo server and asserts `connectionCount === 1`,
proving bytes actually traverse the proxy under both runtimes.
P2 — `HOME` honored under Bun:
- `homeDir()` reads `process.env.HOME` / `USERPROFILE` first, falling
back to `os.homedir()`. `loadWechatExtendConfig` and `loadCredentials`
use it, restoring test isolation.
P3 — Strict config validation:
- Replace lenient `toOptional*` helpers with `parsePort` /
`parsePositiveInt` / `parseStrictHostKeyChecking` that throw with
the key name. `loadWechatExtendConfig` only catches file-read
errors so parse errors surface to the caller. Flip the corresponding
test cases.
Verification:
- `npm test`: 261/261 pass.
- `bun test` in `scripts/`: 39/39 pass.
Co-authored-by: Dame5211 <1079825614@qq.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-md": "^0.1.0",
|
||||
"jimp": "^1.6.0",
|
||||
"socks-proxy-agent": "^10.0.0",
|
||||
"socks": "^2.8.9",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,8 +88,6 @@
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
|
||||
@@ -286,8 +284,6 @@
|
||||
|
||||
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
|
||||
|
||||
"socks-proxy-agent": ["socks-proxy-agent@10.0.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-md": "^0.1.0",
|
||||
"jimp": "^1.6.0",
|
||||
"socks-proxy-agent": "^10.0.0"
|
||||
"socks": "^2.8.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type http from "node:http";
|
||||
import {
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
needsWechatBodyImageProcessing,
|
||||
} from "./wechat-image-processor.ts";
|
||||
import { loadUploadAsset } from "./wechat-image-loader.ts";
|
||||
import { wechatHttp, buildMultipart } from "./wechat-http.ts";
|
||||
import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";
|
||||
import {
|
||||
type RemotePublishConfig,
|
||||
normalizeRemoteConfig,
|
||||
@@ -75,9 +74,13 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
|
||||
async function fetchAccessToken(appId: string, appSecret: string, agent?: http.Agent): Promise<string> {
|
||||
async function fetchAccessToken(
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<string> {
|
||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
const res = await wechatHttp(url, { agent });
|
||||
const res = await client(url);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token: ${res.status}`);
|
||||
}
|
||||
@@ -101,7 +104,7 @@ async function uploadImage(
|
||||
accessToken: string,
|
||||
baseDir?: string,
|
||||
uploadType: "body" | "material" = "body",
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||
let uploadAsset = asset;
|
||||
@@ -126,7 +129,7 @@ async function uploadImage(
|
||||
uploadAsset.contentType,
|
||||
accessToken,
|
||||
uploadType,
|
||||
agent,
|
||||
client,
|
||||
);
|
||||
|
||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||
@@ -147,7 +150,7 @@ async function uploadToWechat(
|
||||
contentType: string,
|
||||
accessToken: string,
|
||||
uploadType: "body" | "material",
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename, contentType, data: fileBuffer },
|
||||
@@ -155,11 +158,10 @@ async function uploadToWechat(
|
||||
|
||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||
const res = await wechatHttp(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
agent,
|
||||
});
|
||||
|
||||
const data = await res.json<UploadResponse>();
|
||||
@@ -177,7 +179,7 @@ async function uploadImagesInHtml(
|
||||
contentImages: ImageInfo[] = [],
|
||||
articleType: ArticleType = "news",
|
||||
collectNewsCoverFallback: boolean = false,
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
@@ -198,7 +200,7 @@ async function uploadImagesInHtml(
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||
try {
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", agent);
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", client);
|
||||
firstCoverMediaId = coverResp.media_id;
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||
@@ -214,7 +216,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent);
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
const newTag = fullTag
|
||||
@@ -225,7 +227,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent);
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -249,7 +251,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent);
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
|
||||
@@ -259,7 +261,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent);
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -280,7 +282,7 @@ async function uploadImagesInHtml(
|
||||
async function publishToDraft(
|
||||
options: ArticleOptions,
|
||||
accessToken: string,
|
||||
agent?: http.Agent,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
|
||||
@@ -317,11 +319,10 @@ async function publishToDraft(
|
||||
if (options.digest) article.digest = options.digest;
|
||||
}
|
||||
|
||||
const res = await wechatHttp(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
agent,
|
||||
});
|
||||
|
||||
const data = await res.json<PublishResponse>();
|
||||
@@ -732,9 +733,9 @@ async function main(): Promise<void> {
|
||||
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
|
||||
const method = useRemote ? "remote-api" : "api";
|
||||
|
||||
const publishWithAgent = async (agent?: http.Agent): Promise<void> => {
|
||||
const publishWith = async (client: WechatClient): Promise<void> => {
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, agent);
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
|
||||
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
@@ -744,7 +745,7 @@ async function main(): Promise<void> {
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
agent,
|
||||
client,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
@@ -752,7 +753,7 @@ async function main(): Promise<void> {
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", agent);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
@@ -779,7 +780,7 @@ async function main(): Promise<void> {
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken, agent);
|
||||
}, accessToken, client);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
@@ -797,11 +798,11 @@ async function main(): Promise<void> {
|
||||
console.error(
|
||||
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
|
||||
);
|
||||
await withSshTunnel(remoteConfig, async (agent) => {
|
||||
await publishWithAgent(agent);
|
||||
await withSshTunnel(remoteConfig, async (client) => {
|
||||
await publishWith(client);
|
||||
});
|
||||
} else {
|
||||
await publishWithAgent();
|
||||
await publishWith(wechatHttp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ test("resolveAccount lets account-level remote_publish_* override globals", asyn
|
||||
assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com");
|
||||
});
|
||||
|
||||
test("resolveAccount drops invalid remote_publish_port and strict_host_key_checking values", async (t) => {
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_port", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
@@ -241,17 +241,51 @@ test("resolveAccount drops invalid remote_publish_port and strict_host_key_check
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_port: 99999",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_port: 99999/);
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_connect_timeout", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_connect_timeout: 0",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_connect_timeout: 0/);
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_strict_host_key_checking", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_strict_host_key_checking: maybe",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const config = loadWechatExtendConfig();
|
||||
const resolved = resolveAccount(config);
|
||||
assert.equal(resolved.remote_publish_host, "example.com");
|
||||
assert.equal(resolved.remote_publish_port, undefined);
|
||||
assert.equal(resolved.remote_publish_connect_timeout, undefined);
|
||||
assert.equal(resolved.remote_publish_strict_host_key_checking, undefined);
|
||||
assert.throws(
|
||||
() => loadWechatExtendConfig(),
|
||||
/Invalid remote_publish_strict_host_key_checking: maybe/,
|
||||
);
|
||||
});
|
||||
|
||||
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
||||
|
||||
@@ -72,24 +72,32 @@ function toBool01(v: string): number {
|
||||
return v === "1" || v === "true" ? 1 : 0;
|
||||
}
|
||||
|
||||
function toOptionalPort(v: string): number | undefined {
|
||||
function homeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||
}
|
||||
|
||||
function parsePort(key: string, v: string): number {
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined;
|
||||
if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 65535) {
|
||||
throw new Error(`Invalid ${key}: ${v} (expected integer 1-65535)`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function toOptionalPositiveInt(v: string): number | undefined {
|
||||
function parsePositiveInt(key: string, v: string): number {
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||
if (!Number.isFinite(n) || String(n) !== v.trim() || n <= 0) {
|
||||
throw new Error(`Invalid ${key}: ${v} (expected positive integer)`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function toStrictHostKeyChecking(v: string): StrictHostKeyChecking | undefined {
|
||||
function parseStrictHostKeyChecking(key: string, v: string): StrictHostKeyChecking {
|
||||
const lower = v.toLowerCase();
|
||||
if (lower === "yes" || lower === "no" || lower === "accept-new") {
|
||||
return lower;
|
||||
}
|
||||
return undefined;
|
||||
throw new Error(`Invalid ${key}: ${v} (expected yes|no|accept-new)`);
|
||||
}
|
||||
|
||||
function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
@@ -154,11 +162,11 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
case "chrome_profile_path": config.chrome_profile_path = val; break;
|
||||
case "remote_publish_host": config.remote_publish_host = val; break;
|
||||
case "remote_publish_user": config.remote_publish_user = val; break;
|
||||
case "remote_publish_port": config.remote_publish_port = toOptionalPort(val); break;
|
||||
case "remote_publish_port": config.remote_publish_port = parsePort("remote_publish_port", val); break;
|
||||
case "remote_publish_identity_file": config.remote_publish_identity_file = val; break;
|
||||
case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break;
|
||||
case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = toStrictHostKeyChecking(val); break;
|
||||
case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = toOptionalPositiveInt(val); break;
|
||||
case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", val); break;
|
||||
case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = parsePositiveInt("remote_publish_connect_timeout", val); break;
|
||||
case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break;
|
||||
}
|
||||
}
|
||||
@@ -179,14 +187,14 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
chrome_profile_path: a.chrome_profile_path || undefined,
|
||||
remote_publish_host: a.remote_publish_host || undefined,
|
||||
remote_publish_user: a.remote_publish_user || undefined,
|
||||
remote_publish_port: a.remote_publish_port ? toOptionalPort(a.remote_publish_port) : undefined,
|
||||
remote_publish_port: a.remote_publish_port ? parsePort("remote_publish_port", a.remote_publish_port) : undefined,
|
||||
remote_publish_identity_file: a.remote_publish_identity_file || undefined,
|
||||
remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined,
|
||||
remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking
|
||||
? toStrictHostKeyChecking(a.remote_publish_strict_host_key_checking)
|
||||
? parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", a.remote_publish_strict_host_key_checking)
|
||||
: undefined,
|
||||
remote_publish_connect_timeout: a.remote_publish_connect_timeout
|
||||
? toOptionalPositiveInt(a.remote_publish_connect_timeout)
|
||||
? parsePositiveInt("remote_publish_connect_timeout", a.remote_publish_connect_timeout)
|
||||
: undefined,
|
||||
remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined,
|
||||
}));
|
||||
@@ -199,18 +207,19 @@ export function loadWechatExtendConfig(): WechatExtendConfig {
|
||||
const paths = [
|
||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
path.join(
|
||||
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
||||
process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"),
|
||||
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
|
||||
),
|
||||
path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
path.join(homeDir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
];
|
||||
for (const p of paths) {
|
||||
let content: string;
|
||||
try {
|
||||
const content = fs.readFileSync(p, "utf-8");
|
||||
return parseWechatExtend(content);
|
||||
content = fs.readFileSync(p, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
return parseWechatExtend(content);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -348,7 +357,7 @@ function resolveCredentialSource(
|
||||
|
||||
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
||||
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(homeDir(), ".baoyu-skills", ".env");
|
||||
const cwdEnv = loadEnvFile(cwdEnvPath);
|
||||
const homeEnv = loadEnvFile(homeEnvPath);
|
||||
|
||||
|
||||
@@ -136,17 +136,3 @@ test("wechatHttp accepts a multipart body produced by buildMultipart", async (t)
|
||||
assert.ok(received[0]!.body.includes(Buffer.from("HELLO")));
|
||||
});
|
||||
|
||||
test("wechatHttp forwards the agent option to http.request", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const baseAgent = new http.Agent({ keepAlive: false });
|
||||
let addRequestCalls = 0;
|
||||
const originalAddRequest = baseAgent.addRequest.bind(baseAgent);
|
||||
baseAgent.addRequest = function (...callArgs: Parameters<typeof originalAddRequest>) {
|
||||
addRequestCalls++;
|
||||
return originalAddRequest(...callArgs);
|
||||
};
|
||||
|
||||
await wechatHttp(`${baseUrl}/`, { agent: baseAgent });
|
||||
assert.ok(addRequestCalls > 0, "expected the supplied agent to handle the request");
|
||||
baseAgent.destroy();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { URL } from "node:url";
|
||||
|
||||
export interface WechatHttpInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | Buffer;
|
||||
agent?: http.Agent | https.Agent | false;
|
||||
}
|
||||
|
||||
export interface WechatHttpResponse {
|
||||
@@ -18,6 +13,11 @@ export interface WechatHttpResponse {
|
||||
json<T = unknown>(): Promise<T>;
|
||||
}
|
||||
|
||||
export type WechatClient = (
|
||||
url: string,
|
||||
init?: WechatHttpInit,
|
||||
) => Promise<WechatHttpResponse>;
|
||||
|
||||
export interface MultipartFilePart {
|
||||
name: string;
|
||||
filename: string;
|
||||
@@ -51,56 +51,47 @@ export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
|
||||
};
|
||||
}
|
||||
|
||||
export async function wechatHttp(url: string, init: WechatHttpInit = {}): Promise<WechatHttpResponse> {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const body = init.body == null
|
||||
? undefined
|
||||
: Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body, "utf-8");
|
||||
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
if (body && headers["Content-Length"] === undefined && headers["content-length"] === undefined) {
|
||||
headers["Content-Length"] = String(body.length);
|
||||
}
|
||||
|
||||
const requestOptions: https.RequestOptions = {
|
||||
method: init.method ?? (body ? "POST" : "GET"),
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
headers,
|
||||
};
|
||||
if (init.agent !== undefined) {
|
||||
requestOptions.agent = init.agent;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = transport.request(requestOptions, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
const bodyBuffer = Buffer.concat(chunks);
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
statusText: res.statusMessage ?? "",
|
||||
headers: res.headers as Record<string, string | string[] | undefined>,
|
||||
async buffer() {
|
||||
return bodyBuffer;
|
||||
},
|
||||
async text() {
|
||||
return bodyBuffer.toString("utf-8");
|
||||
},
|
||||
async json<T = unknown>() {
|
||||
return JSON.parse(bodyBuffer.toString("utf-8")) as T;
|
||||
},
|
||||
});
|
||||
});
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
|
||||
const out: Record<string, string | string[] | undefined> = {};
|
||||
headers.forEach((value, key) => {
|
||||
const existing = out[key];
|
||||
if (existing === undefined) {
|
||||
out[key] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
out[key] = [existing, value];
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const wechatHttp: WechatClient = async (url, init = {}) => {
|
||||
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (init.body !== undefined) {
|
||||
body = Buffer.isBuffer(init.body)
|
||||
? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength)
|
||||
: init.body;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method, headers, body });
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: headersToRecord(res.headers),
|
||||
async buffer() {
|
||||
return buf;
|
||||
},
|
||||
async text() {
|
||||
return buf.toString("utf-8");
|
||||
},
|
||||
async json<T = unknown>() {
|
||||
return JSON.parse(buf.toString("utf-8")) as T;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import net from "node:net";
|
||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";
|
||||
import type { WechatClient } from "./wechat-http.ts";
|
||||
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||
|
||||
export interface RemotePublishConfig {
|
||||
host: string;
|
||||
@@ -29,7 +30,7 @@ export interface NormalizedRemotePublishConfig {
|
||||
|
||||
export interface SshTunnel {
|
||||
port: number;
|
||||
agent: SocksProxyAgent;
|
||||
client: WechatClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ export async function startSshTunnel(
|
||||
throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`);
|
||||
}
|
||||
|
||||
const agent = new SocksProxyAgent(`socks5h://${SSH_LOOPBACK_HOST}:${port}`);
|
||||
const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });
|
||||
|
||||
const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
|
||||
let closed = false;
|
||||
@@ -228,7 +229,7 @@ export async function startSshTunnel(
|
||||
signalHandlers.push({ signal, handler });
|
||||
}
|
||||
|
||||
return { port, agent, close };
|
||||
return { port, client, close };
|
||||
}
|
||||
|
||||
async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {
|
||||
@@ -261,12 +262,12 @@ async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, k
|
||||
|
||||
export async function withSshTunnel<T>(
|
||||
config: NormalizedRemotePublishConfig,
|
||||
fn: (agent: SocksProxyAgent) => Promise<T>,
|
||||
fn: (client: WechatClient) => Promise<T>,
|
||||
options?: StartSshTunnelOptions,
|
||||
): Promise<T> {
|
||||
const tunnel = await startSshTunnel(config, options);
|
||||
try {
|
||||
return await fn(tunnel.agent);
|
||||
return await fn(tunnel.client);
|
||||
} finally {
|
||||
await tunnel.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||
|
||||
interface EchoServer {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
received: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: Buffer }>;
|
||||
}
|
||||
|
||||
async function startEchoServer(t: TestContext): Promise<EchoServer> {
|
||||
const received: EchoServer["received"] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, url: req.url }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("echo server bind failed");
|
||||
const port = address.port;
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
return { baseUrl: `http://127.0.0.1:${port}`, port, received };
|
||||
}
|
||||
|
||||
interface FakeSocks5 {
|
||||
port: number;
|
||||
connectionCount: () => number;
|
||||
destinations: () => Array<{ host: string; port: number }>;
|
||||
}
|
||||
|
||||
async function startFakeSocks5(t: TestContext): Promise<FakeSocks5> {
|
||||
let connectionCount = 0;
|
||||
const destinations: Array<{ host: string; port: number }> = [];
|
||||
|
||||
const server = net.createServer((client) => {
|
||||
connectionCount++;
|
||||
let phase: "greeting" | "request" | "tunnel" = "greeting";
|
||||
let buf = Buffer.alloc(0);
|
||||
let upstream: net.Socket | undefined;
|
||||
|
||||
const tryParse = () => {
|
||||
if (phase === "greeting") {
|
||||
if (buf.length < 2) return;
|
||||
const nMethods = buf[1]!;
|
||||
if (buf.length < 2 + nMethods) return;
|
||||
buf = buf.subarray(2 + nMethods);
|
||||
client.write(Buffer.from([0x05, 0x00]));
|
||||
phase = "request";
|
||||
}
|
||||
if (phase === "request") {
|
||||
if (buf.length < 5) return;
|
||||
if (buf[0] !== 0x05 || buf[1] !== 0x01) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const atyp = buf[3];
|
||||
let addrEnd: number;
|
||||
let host: string;
|
||||
if (atyp === 0x01) {
|
||||
if (buf.length < 4 + 4 + 2) return;
|
||||
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
|
||||
addrEnd = 4 + 4;
|
||||
} else if (atyp === 0x03) {
|
||||
const dlen = buf[4]!;
|
||||
if (buf.length < 4 + 1 + dlen + 2) return;
|
||||
host = buf.subarray(5, 5 + dlen).toString("ascii");
|
||||
addrEnd = 4 + 1 + dlen;
|
||||
} else {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const port = (buf[addrEnd]! << 8) | buf[addrEnd + 1]!;
|
||||
destinations.push({ host, port });
|
||||
const totalLen = addrEnd + 2;
|
||||
const remaining = buf.subarray(totalLen);
|
||||
buf = Buffer.alloc(0);
|
||||
|
||||
upstream = net.connect({ host, port }, () => {
|
||||
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||
phase = "tunnel";
|
||||
if (remaining.length > 0) {
|
||||
upstream!.write(remaining);
|
||||
}
|
||||
});
|
||||
upstream.on("data", (data: Buffer) => {
|
||||
client.write(data);
|
||||
});
|
||||
upstream.on("end", () => {
|
||||
try { client.end(); } catch { /* noop */ }
|
||||
});
|
||||
upstream.on("error", () => {
|
||||
try { client.destroy(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
client.on("data", (chunk: Buffer) => {
|
||||
if (phase === "tunnel") {
|
||||
upstream?.write(chunk);
|
||||
return;
|
||||
}
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
tryParse();
|
||||
});
|
||||
client.on("end", () => {
|
||||
try { upstream?.end(); } catch { /* noop */ }
|
||||
});
|
||||
client.on("error", () => {
|
||||
try { upstream?.destroy(); } catch { /* noop */ }
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("socks server bind failed");
|
||||
const port = address.port;
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
return {
|
||||
port,
|
||||
connectionCount: () => connectionCount,
|
||||
destinations: () => destinations,
|
||||
};
|
||||
}
|
||||
|
||||
test("createSocksClient routes plain HTTP through the SOCKS5 proxy", async (t) => {
|
||||
const echo = await startEchoServer(t);
|
||||
const socks = await startFakeSocks5(t);
|
||||
|
||||
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||
|
||||
const res = await client(`${echo.baseUrl}/cgi-bin/token?appid=AID`);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json<{ ok: boolean; url: string }>();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(data.url, "/cgi-bin/token?appid=AID");
|
||||
|
||||
assert.equal(
|
||||
socks.connectionCount(),
|
||||
1,
|
||||
"SOCKS5 proxy must have received exactly one connection (proves bytes were routed through it)",
|
||||
);
|
||||
|
||||
const dests = socks.destinations();
|
||||
assert.equal(dests.length, 1);
|
||||
assert.equal(dests[0]!.host, "127.0.0.1");
|
||||
assert.equal(dests[0]!.port, echo.port);
|
||||
|
||||
assert.equal(echo.received.length, 1);
|
||||
assert.equal(echo.received[0]!.method, "GET");
|
||||
assert.equal(echo.received[0]!.url, "/cgi-bin/token?appid=AID");
|
||||
});
|
||||
|
||||
test("createSocksClient sends POST body through the SOCKS5 proxy", async (t) => {
|
||||
const echo = await startEchoServer(t);
|
||||
const socks = await startFakeSocks5(t);
|
||||
|
||||
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||
|
||||
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||
const res = await client(`${echo.baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(socks.connectionCount(), 1);
|
||||
assert.equal(echo.received.length, 1);
|
||||
assert.equal(echo.received[0]!.method, "POST");
|
||||
assert.equal(echo.received[0]!.headers["content-type"], "application/json");
|
||||
assert.equal(echo.received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||
assert.equal(echo.received[0]!.body.toString("utf-8"), body);
|
||||
});
|
||||
|
||||
test("createSocksClient rejects invalid proxy ports", () => {
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "127.0.0.1", port: 0 }),
|
||||
/Invalid SOCKS proxy port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "127.0.0.1", port: 70_000 }),
|
||||
/Invalid SOCKS proxy port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "", port: 1080 }),
|
||||
/SOCKS proxy host required/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user