@zhushanwen/pi-network-proxy 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/index.ts +1 -0
- package/package.json +66 -3
- package/skills/network-proxy-ext-config/SKILL.md +76 -0
- package/src/__tests__/config.test.ts +234 -0
- package/src/__tests__/dispatcher-construction.test.ts +110 -0
- package/src/__tests__/dispatcher.test.ts +37 -0
- package/src/__tests__/fetch-wrapper.test.ts +324 -0
- package/src/__tests__/index.test.ts +182 -0
- package/src/__tests__/match.test.ts +86 -0
- package/src/__tests__/startup-config-declaration.test.ts +38 -0
- package/src/__tests__/stats.test.ts +171 -0
- package/src/config.ts +176 -0
- package/src/dispatcher.ts +79 -0
- package/src/fetch-wrapper.ts +244 -0
- package/src/index.ts +106 -0
- package/src/match.ts +33 -0
- package/src/stats.ts +127 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stats 增量行格式 + 单行 write 原子性(任务书验收②)。
|
|
3
|
+
* 单行原子性 = 每次 appendFileSync 调用的 payload 恰好是一行完整 JSON + \n(O_APPEND 单次
|
|
4
|
+
* write 是多 pi 进程并发追加不交错的实现前提,防改为分片 write 时静默交错)。
|
|
5
|
+
* fs-guard 白名单:写入目标 = PI_CODING_AGENT_DIR 注入的 mkdtemp tmp 目录。
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
const { appendCalls } = vi.hoisted(() => ({
|
|
14
|
+
appendCalls: [] as Array<{ path: string; data: string }>,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制,llm-shared 同款)——
|
|
18
|
+
// 用 vi.mock 包装 appendFileSync(默认透传原始实现并记录调用),其余 fs 操作原样透传。
|
|
19
|
+
// 注意:本文件级 mock 覆盖 fs-guard 全局 mock(fs-guard 头注已登记的既有形态)——本文件
|
|
20
|
+
// 写入目标全部为 mkdtemp tmp,自守白名单纪律。
|
|
21
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal() as typeof import("node:fs");
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
appendFileSync: vi.fn((path: string, data: string) => {
|
|
26
|
+
appendCalls.push({ path, data });
|
|
27
|
+
return actual.appendFileSync(path, data);
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
33
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
36
|
+
getLogger: () => loggerMock,
|
|
37
|
+
createLogger: () => loggerMock,
|
|
38
|
+
setPiHandle: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
flushStats,
|
|
43
|
+
recordDispatch,
|
|
44
|
+
resetCounters,
|
|
45
|
+
snapshotCounters,
|
|
46
|
+
statsFilePath,
|
|
47
|
+
} from "../stats.ts";
|
|
48
|
+
|
|
49
|
+
let agentDir: string;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
agentDir = mkdtempSync(join(tmpdir(), "network-proxy-stats-"));
|
|
53
|
+
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
54
|
+
resetCounters();
|
|
55
|
+
appendCalls.length = 0;
|
|
56
|
+
loggerMock.warn.mockClear();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.unstubAllEnvs();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("stats 增量行格式", () => {
|
|
64
|
+
it("flush 后每行完整 JSON:字段 {ts, host, proxied, direct},ts 为 flush 时刻 epoch ms(runtime 读侧 number 契约)", () => {
|
|
65
|
+
const before = Date.now();
|
|
66
|
+
recordDispatch("api.anthropic.com", true);
|
|
67
|
+
recordDispatch("open.bigmodel.cn", false);
|
|
68
|
+
flushStats();
|
|
69
|
+
|
|
70
|
+
const lines = readFileSync(statsFilePath(), "utf-8").split("\n").filter((l) => l !== "");
|
|
71
|
+
expect(lines).toHaveLength(2);
|
|
72
|
+
const rows = lines.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
73
|
+
expect(rows).toContainEqual({
|
|
74
|
+
ts: expect.any(Number),
|
|
75
|
+
host: "api.anthropic.com",
|
|
76
|
+
proxied: 1,
|
|
77
|
+
direct: 0,
|
|
78
|
+
});
|
|
79
|
+
expect(rows).toContainEqual({
|
|
80
|
+
ts: expect.any(Number),
|
|
81
|
+
host: "open.bigmodel.cn",
|
|
82
|
+
proxied: 0,
|
|
83
|
+
direct: 1,
|
|
84
|
+
});
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
// epoch ms 合理区间(≥ before 且 ≤ now + 1s 时钟偏移容差)——与 runtime
|
|
87
|
+
// parseStatsLine typeof ts === 'number' 契约互证
|
|
88
|
+
expect(row.ts as number).toBeGreaterThanOrEqual(before);
|
|
89
|
+
expect(row.ts as number).toBeLessThanOrEqual(Date.now() + 1000);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("flush 清空已写增量,下次 flush 只写新增量(追加式增量行语义)", () => {
|
|
94
|
+
recordDispatch("a.test", true);
|
|
95
|
+
flushStats();
|
|
96
|
+
expect(readFileSync(statsFilePath(), "utf-8").trim().split("\n")).toHaveLength(1);
|
|
97
|
+
|
|
98
|
+
recordDispatch("b.test", false);
|
|
99
|
+
flushStats();
|
|
100
|
+
const lines = readFileSync(statsFilePath(), "utf-8").trim().split("\n");
|
|
101
|
+
expect(lines).toHaveLength(2);
|
|
102
|
+
expect(JSON.parse(lines[1]).host).toBe("b.test");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("同主机名聚合:多次同向计数累加为一行", () => {
|
|
106
|
+
recordDispatch("api.anthropic.com", true);
|
|
107
|
+
recordDispatch("api.anthropic.com", true);
|
|
108
|
+
recordDispatch("api.anthropic.com", false);
|
|
109
|
+
flushStats();
|
|
110
|
+
const rows = readFileSync(statsFilePath(), "utf-8")
|
|
111
|
+
.trim()
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((l) => JSON.parse(l));
|
|
114
|
+
expect(rows).toEqual([
|
|
115
|
+
{ ts: expect.any(Number), host: "api.anthropic.com", proxied: 2, direct: 1 },
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("空增量 flush 零 IO(不建目录不写文件)", () => {
|
|
120
|
+
flushStats();
|
|
121
|
+
expect(appendCalls).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("snapshotCounters / resetCounters(内存视图)", () => {
|
|
125
|
+
recordDispatch("a.test", true);
|
|
126
|
+
expect(snapshotCounters().get("a.test")).toEqual({ proxied: 1, direct: 0 });
|
|
127
|
+
resetCounters();
|
|
128
|
+
expect(snapshotCounters().size).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("单行 write 原子性", () => {
|
|
133
|
+
it("每行恰好一次 appendFileSync 调用,payload 为单行完整 JSON + \\n(禁止分片 write)", () => {
|
|
134
|
+
recordDispatch("api.anthropic.com", true);
|
|
135
|
+
recordDispatch("open.bigmodel.cn", false);
|
|
136
|
+
flushStats();
|
|
137
|
+
|
|
138
|
+
expect(appendCalls).toHaveLength(2);
|
|
139
|
+
for (const call of appendCalls) {
|
|
140
|
+
expect(call.path).toBe(statsFilePath());
|
|
141
|
+
expect(call.data.endsWith("\n")).toBe(true);
|
|
142
|
+
expect(call.data.split("\n")).toHaveLength(2); // 单行内容 + 尾换行
|
|
143
|
+
const row = JSON.parse(call.data);
|
|
144
|
+
expect(Object.keys(row).sort()).toEqual(["direct", "host", "proxied", "ts"]);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("写失败不阻断(log + 增量保留重试)", () => {
|
|
150
|
+
it("appendFileSync 抛错 → warn 留痕 + 增量保留;恢复后重试成功且不丢行", () => {
|
|
151
|
+
recordDispatch("a.test", true);
|
|
152
|
+
recordDispatch("b.test", false);
|
|
153
|
+
|
|
154
|
+
vi.mocked(fs.appendFileSync).mockImplementationOnce(() => {
|
|
155
|
+
throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" });
|
|
156
|
+
});
|
|
157
|
+
expect(() => flushStats()).not.toThrow();
|
|
158
|
+
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
159
|
+
// 失败行的增量保留在内存
|
|
160
|
+
expect(snapshotCounters().size).toBe(2);
|
|
161
|
+
|
|
162
|
+
// 恢复:重试把保留的增量完整落盘
|
|
163
|
+
flushStats();
|
|
164
|
+
const hosts = readFileSync(statsFilePath(), "utf-8")
|
|
165
|
+
.trim()
|
|
166
|
+
.split("\n")
|
|
167
|
+
.map((l) => JSON.parse(l).host);
|
|
168
|
+
expect(hosts).toEqual(["a.test", "b.test"]);
|
|
169
|
+
expect(snapshotCounters().size).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* network-proxy 配置读取与指纹。
|
|
3
|
+
*
|
|
4
|
+
* 类型与校验语义 SSOT = packages/shared/src/network-proxy.ts(u-foundation 契约)——
|
|
5
|
+
* extension bundle 自包含不 import shared 运行时代码(extensions 对 shared 的既有惯例:
|
|
6
|
+
* 结构类型本地声明 + 行为镜像锁定测试),本文件是复制体;漂移时以 shared 为准,
|
|
7
|
+
* __tests__/config.test.ts 锁两侧行为一致。
|
|
8
|
+
*
|
|
9
|
+
* 配置文件 = <agentDir>/config/network-proxy-ext-config.json(llm-shared getConfigPath
|
|
10
|
+
* 推导;runtime 写侧与 settings 页经 RPC 打通,设计 .tmp/tech-design/llm-network-proxy.md
|
|
11
|
+
* §3.1 配置 schema)。热重载 = loadConfig 读时刷新(mtime+size),调用方每次直接调
|
|
12
|
+
* loadNetworkProxyConfig,禁止上层闭包缓存(llm-shared 头注热重载契约)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadConfig } from "@zhushanwen/pi-llm-shared";
|
|
16
|
+
|
|
17
|
+
import { matchesHostPattern } from "./match.js";
|
|
18
|
+
|
|
19
|
+
/** 代理协议词表:http / https 走 undici ProxyAgent,socks5 走 Socks5ProxyAgent。 */
|
|
20
|
+
export type NetworkProxyProtocol = "http" | "https" | "socks5";
|
|
21
|
+
|
|
22
|
+
/** 代理服务器连接参数。username/password 成对(非空时 percent-encode 拼入代理 URL)。 */
|
|
23
|
+
export interface NetworkProxyServer { // oe-exempt:20261019:framework:ext-config 契约的 bundle 自包含复制体声明(SSOT=shared,镜像锁定测试锚定)
|
|
24
|
+
|
|
25
|
+
protocol: NetworkProxyProtocol;
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
username: string;
|
|
29
|
+
password: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 网络代理配置(ext-config 文件单一权威 schema;noProxy 优先级高于 rules)。 */
|
|
33
|
+
export interface NetworkProxyConfig { // oe-exempt:20261019:framework:ext-config 契约的 bundle 自包含复制体声明(SSOT=shared,镜像锁定测试锚定)
|
|
34
|
+
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
proxy: NetworkProxyServer;
|
|
37
|
+
/** 直连排除清单(主机名条目),命中即不经代理 */
|
|
38
|
+
noProxy: string[];
|
|
39
|
+
/** 代理规则(主机名条目),命中即经代理 */
|
|
40
|
+
rules: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 默认配置:禁用态 + 常见本地代理地址占位 + 保护本地回环(与 shared DEFAULT 深相等,镜像锁定测试锁两侧)。 */
|
|
44
|
+
export const DEFAULT_NETWORK_PROXY_CONFIG: NetworkProxyConfig = {
|
|
45
|
+
enabled: false,
|
|
46
|
+
proxy: { protocol: "http", host: "127.0.0.1", port: 7890, username: "", password: "" },
|
|
47
|
+
noProxy: ["localhost", "127.0.0.1"],
|
|
48
|
+
rules: [],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** llm-shared loadConfig 的包名(决定文件名 network-proxy-ext-config.json)。 */
|
|
52
|
+
const CONFIG_PKG = "network-proxy";
|
|
53
|
+
|
|
54
|
+
const PROTOCOLS: readonly string[] = ["http", "https", "socks5"];
|
|
55
|
+
|
|
56
|
+
/** 端口合法区间(语义镜像 shared validateNetworkProxyConfig 字面量)。 */
|
|
57
|
+
const PORT_MIN = 1;
|
|
58
|
+
const PORT_MAX = 65535;
|
|
59
|
+
|
|
60
|
+
/** 校验结果信封:ok 或面向用户的错误清单(每条含修复动作,逐项收集不短路)。 */
|
|
61
|
+
export type NetworkProxyValidationResult = { ok: true } | { ok: false; errors: string[] };
|
|
62
|
+
|
|
63
|
+
/** noProxy/rules 条目校验:数组 + 每条为非空且不含空白字符的主机名字符串;重复条目不报错(匹配天然幂等)。 */
|
|
64
|
+
function collectHostEntryErrors(entries: unknown, label: string): string[] {
|
|
65
|
+
if (!Array.isArray(entries)) {
|
|
66
|
+
return [`${label} 必须是主机名列表(当前不是列表),请恢复为每行一个主机名的形式`];
|
|
67
|
+
}
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
for (let i = 0; i < entries.length; i++) {
|
|
70
|
+
const entry = entries[i];
|
|
71
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
72
|
+
errors.push(`${label} 第 ${i + 1} 条为空,请填写主机名或删除该条`);
|
|
73
|
+
} else if (/\s/.test(entry)) {
|
|
74
|
+
// 归一化不 trim,含空白字符的条目(首尾或内部)对真实主机名恒不匹配(静默不生效)——校验层显式拒绝并给出修复动作
|
|
75
|
+
errors.push(`${label} 第 ${i + 1} 条含空白字符,请删除空白后保存`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** proxy 对象字段校验(validateNetworkProxyConfig 的子步骤,语义镜像 shared collectProxyServerErrors)。 */
|
|
82
|
+
function collectProxyServerErrors(proxy: unknown): string[] {
|
|
83
|
+
if (proxy === null || typeof proxy !== "object" || Array.isArray(proxy)) {
|
|
84
|
+
return ["代理服务器配置必须是对象(协议 + 地址 + 端口),请重新填写代理服务器信息"];
|
|
85
|
+
}
|
|
86
|
+
const errors: string[] = [];
|
|
87
|
+
const { protocol, host, port, username, password } = proxy as Record<string, unknown>;
|
|
88
|
+
if (typeof protocol !== "string" || !PROTOCOLS.includes(protocol)) {
|
|
89
|
+
errors.push(`代理协议必须是 http / https / socks5 之一: ${String(protocol)},请重新选择协议`);
|
|
90
|
+
}
|
|
91
|
+
if (typeof host !== "string" || host.trim() === "") {
|
|
92
|
+
errors.push("代理服务器地址不能为空,请填写代理地址(如 127.0.0.1)");
|
|
93
|
+
}
|
|
94
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < PORT_MIN || port > PORT_MAX) {
|
|
95
|
+
errors.push(`代理端口必须是 ${PORT_MIN}-${PORT_MAX} 的整数: ${String(port)},请填写正确的端口`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof username !== "string" || typeof password !== "string") {
|
|
98
|
+
errors.push("认证用户名与密码必须是文本,请检查认证输入框内容");
|
|
99
|
+
} else if ((username !== "") !== (password !== "")) {
|
|
100
|
+
errors.push("认证用户名与密码必须同时填写或同时留空(代理认证要求两者成对),请补全另一项或清空两项");
|
|
101
|
+
}
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 网络代理配置校验(语义复制自 shared validateNetworkProxyConfig):
|
|
107
|
+
* 协议白名单、端口 1-65535 整数、凭据成对、noProxy/rules 条目非空主机名且不含空白字符
|
|
108
|
+
* (归一化不 trim,带空白恒不匹配故显式拒绝)。
|
|
109
|
+
* 错误逐项收集(errors 复数,不短路),文案面向用户且含修复动作。
|
|
110
|
+
* 对象性守卫:null/数组解构会抛 TypeError 或静默得 undefined,读取路径须以错误信封逃逸。
|
|
111
|
+
*/
|
|
112
|
+
export function validateNetworkProxyConfig(config: unknown): NetworkProxyValidationResult {
|
|
113
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
114
|
+
return { ok: false, errors: [`网络代理配置必须是对象: ${String(config)}`] };
|
|
115
|
+
}
|
|
116
|
+
const errors: string[] = [];
|
|
117
|
+
const { enabled, proxy, noProxy, rules } = config as Record<string, unknown>;
|
|
118
|
+
if (typeof enabled !== "boolean") {
|
|
119
|
+
errors.push(
|
|
120
|
+
`网络代理开关必须是开或关: ${JSON.stringify(enabled) ?? String(enabled)},请重新切换一次开关后保存`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
errors.push(...collectProxyServerErrors(proxy));
|
|
124
|
+
errors.push(...collectHostEntryErrors(noProxy, "直连排除清单(noProxy)"));
|
|
125
|
+
errors.push(...collectHostEntryErrors(rules, "代理规则(rules)"));
|
|
126
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* loadConfig 的 normalize:校验通过的 raw 即合法配置(校验 = 运行时 guard,断言有兜底)。
|
|
131
|
+
* 非法配置 throw(含逐项错误清单)→ loadConfig 回落 defaults 并把消息交给 onWarning——
|
|
132
|
+
* 非法配置不静默修正(设计验收场景 10:坏配置不装分流器且 error 可归因),回落 defaults
|
|
133
|
+
* 即「全部直连」的安全态。
|
|
134
|
+
*/
|
|
135
|
+
export function normalizeNetworkProxyConfig(raw: unknown): NetworkProxyConfig {
|
|
136
|
+
const result = validateNetworkProxyConfig(raw);
|
|
137
|
+
if (!result.ok) {
|
|
138
|
+
throw new Error(result.errors.join(";"));
|
|
139
|
+
}
|
|
140
|
+
return raw as NetworkProxyConfig;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 条目去重 + 归一化视图(分流匹配用:匹配函数自带归一化,这里只去重保序)。 */
|
|
144
|
+
function dedupeEntries(entries: string[]): string[] {
|
|
145
|
+
return [...new Set(entries)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 读取配置(llm-shared mtime+size 读时刷新热重载)。 */
|
|
149
|
+
export function loadNetworkProxyConfig(onWarning?: (msg: string) => void): NetworkProxyConfig {
|
|
150
|
+
return loadConfig(CONFIG_PKG, DEFAULT_NETWORK_PROXY_CONFIG, normalizeNetworkProxyConfig, onWarning);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 保存配置入口暂无(写入侧 = taiji runtime RMW);pi CLI 用户直接手编文件即可。 */
|
|
154
|
+
|
|
155
|
+
/** noProxy 命中判定(去重视图 + suffix 匹配)。 */
|
|
156
|
+
export function hitsNoProxy(host: string, noProxy: string[]): boolean {
|
|
157
|
+
return dedupeEntries(noProxy).some((pattern) => matchesHostPattern(host, pattern));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 规则命中判定(去重视图 + suffix 匹配)。 */
|
|
161
|
+
export function hitsRules(host: string, rules: string[]): boolean {
|
|
162
|
+
return dedupeEntries(rules).some((pattern) => matchesHostPattern(host, pattern));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 配置指纹:enabled+proxy+noProxy+rules 序列化比对(行为序列 2——指纹变化才重建分发器)。
|
|
167
|
+
* normalize 后结构固定,JSON.stringify 字段序稳定(字面量构造序)。
|
|
168
|
+
*/
|
|
169
|
+
export function networkProxyFingerprint(config: NetworkProxyConfig): string {
|
|
170
|
+
return JSON.stringify({
|
|
171
|
+
enabled: config.enabled,
|
|
172
|
+
proxy: config.proxy,
|
|
173
|
+
noProxy: dedupeEntries(config.noProxy),
|
|
174
|
+
rules: dedupeEntries(config.rules),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 代理分发器构造(供 fetch-wrapper 在规则命中时按请求注入)。
|
|
3
|
+
*
|
|
4
|
+
* 拦截点已从 undici 全局分发器改为 `globalThis.fetch` 包装(见 fetch-wrapper.ts 头注:
|
|
5
|
+
* 打包版 pi 内置 undici 与扩展 bundle 内联的 undici 是两套副本,全局槽互不相通)。本模块
|
|
6
|
+
* 只负责按协议构造代理分发器,规则判定 / 计数 / 直连委托都在 fetch-wrapper 完成。
|
|
7
|
+
*
|
|
8
|
+
* 两协议构造契约(undici 7.28.0 源码读码核实,单测冒烟防回归):
|
|
9
|
+
* - http/https 走 ProxyAgent,调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`
|
|
10
|
+
* (构造内部 buildConnector({...opts.proxyTls}) 透传到「到代理」连接);禁传 connect 选项
|
|
11
|
+
* ——ProxyAgent 不消费该字段,传入被静默忽略、调优失效且不报错。
|
|
12
|
+
* - socks5 走 Socks5ProxyAgent(实验性 API),调优经 `connect: buildConnector({...})` 的
|
|
13
|
+
* connector 函数形态——该构造选项只收函数,对象形态首个请求建隧道时 TypeError;
|
|
14
|
+
* `proxyTls` 是「代理连接走 TLS」开关(truthy 即按 TLS 连代理),不可当调优参数袋。
|
|
15
|
+
* - 高延迟路由语义:到代理服务器的连接不回落 autoSelectFamilyAttemptTimeout 默认 250ms,
|
|
16
|
+
* 对齐 pi 原生调优同值 2000(pi dist/core/http-dispatcher.js:5
|
|
17
|
+
* DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 2_000;其 :4 注释即 250ms 默认会
|
|
18
|
+
* 终止高延迟路由有效连接尝试的动机)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { EventEmitter } from "node:events";
|
|
22
|
+
|
|
23
|
+
import { buildConnector, ProxyAgent, Socks5ProxyAgent } from "undici";
|
|
24
|
+
import type { Dispatcher as IDispatcher } from "undici";
|
|
25
|
+
|
|
26
|
+
import type { NetworkProxyConfig, NetworkProxyServer } from "./config.js";
|
|
27
|
+
|
|
28
|
+
/** 到代理服务器连接的 autoSelectFamilyAttemptTimeout(对齐 pi 原生调优同值)。 */
|
|
29
|
+
export const PROXY_CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 调优选项(BuildOptions 类型走 TcpNetConnectOpts 分支,port 必填 → 0 占位):
|
|
33
|
+
* 构造 connector 时的 options.port 是默认值,实际建连时被目标端口后置覆盖
|
|
34
|
+
* (undici connect.js 的 `...options` 前置展开 + connector 参数 port 后置,✅ 读码核实),
|
|
35
|
+
* 0 不会落到真实连接参数。
|
|
36
|
+
*/
|
|
37
|
+
const PROXY_TLS_TUNING: buildConnector.BuildOptions = {
|
|
38
|
+
port: 0,
|
|
39
|
+
autoSelectFamilyAttemptTimeout: PROXY_CONNECT_ATTEMPT_TIMEOUT_MS,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 防崩溃 error listener(对齐 pi withUndiciErrorListener 形态):undici 内部 Client 在
|
|
44
|
+
* 中途终止 fetch body 时可能 emit "error"——EventEmitter 的 unhandled "error" 特例会炸掉
|
|
45
|
+
* pi 进程,listener 只吞事件不掩盖请求本身的错误(body 流照常经 reader.read() reject)。
|
|
46
|
+
*/
|
|
47
|
+
function withUndiciErrorListener<T extends IDispatcher>(dispatcher: T): T {
|
|
48
|
+
if (dispatcher instanceof EventEmitter) {
|
|
49
|
+
EventEmitter.prototype.on.call(dispatcher, "error", () => {});
|
|
50
|
+
}
|
|
51
|
+
return dispatcher;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 代理服务器 URL:凭据非空时 percent-encode 拼入(undici 构造内部 decodeURIComponent 还原)。 */
|
|
55
|
+
export function buildProxyUrl(proxy: NetworkProxyServer): string {
|
|
56
|
+
const auth =
|
|
57
|
+
proxy.username !== ""
|
|
58
|
+
? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password)}@`
|
|
59
|
+
: "";
|
|
60
|
+
return `${proxy.protocol}://${auth}${proxy.host}:${proxy.port}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 按协议构造代理分发器(协议词表校验在配置层已完成;未知协议不可达)。 */
|
|
64
|
+
export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
|
|
65
|
+
const uri = buildProxyUrl(config.proxy);
|
|
66
|
+
if (config.proxy.protocol === "socks5") {
|
|
67
|
+
return withUndiciErrorListener(
|
|
68
|
+
new Socks5ProxyAgent(uri, {
|
|
69
|
+
connect: buildConnector(PROXY_TLS_TUNING),
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return withUndiciErrorListener(
|
|
74
|
+
new ProxyAgent({
|
|
75
|
+
uri,
|
|
76
|
+
proxyTls: PROXY_TLS_TUNING,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网络代理请求拦截层:包装 pi 进程的 `globalThis.fetch`(打包版兼容设计)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不是 undici 全局分发器:打包版 pi 是 Bun 单文件二进制,HTTP 栈用二进制内置的
|
|
5
|
+
* undici(`builtin://thirdparty/undici`);扩展 bundle 由 esbuild 内联自己的 undici 副本
|
|
6
|
+
* (`scripts/bundle-extensions.mjs` 的 EXTERNAL 不含 undici)。undici 的
|
|
7
|
+
* `setGlobalDispatcher` 槽是模块级变量,两套副本互不相通——扩展改的全局槽 pi 根本不用,
|
|
8
|
+
* LLM 请求照常直连(2026-10-20 打包版实测:请求直连 Cloudflare,统计文件零行)。
|
|
9
|
+
* dev 用 `node_modules/.bin/pi`,pi 与扩展源码解析到同一份 undici,所以旧方案在 dev 正常、
|
|
10
|
+
* 打包版静默失效。
|
|
11
|
+
*
|
|
12
|
+
* 稳定接缝 = `globalThis.fetch`:pi-ai 的 OpenAI 兼容请求在每次构造 client 时经
|
|
13
|
+
* `Shims.getDefaultFetch()` 动态取当前全局 fetch(node_modules/openai/internal/shims.js),
|
|
14
|
+
* 包装它不依赖 pi 与扩展是否共享 undici 副本。命中规则的请求用扩展自己的 undici fetch +
|
|
15
|
+
* 代理分发器发出;未命中的请求原样调用包装前的 fetch,保留 pi 原生 EnvHttpProxyAgent /
|
|
16
|
+
* 连接调优 / 错误处理语义。
|
|
17
|
+
*
|
|
18
|
+
* 生命周期与旧安装状态机同构:
|
|
19
|
+
* - 加载 / 每 turn 开始:重读配置推进状态(禁用 = 摘除包装恢复原 fetch);
|
|
20
|
+
* - 指纹未变且包装仍在进程槽位:零动作(不重建);
|
|
21
|
+
* - 槽位被第三方整体覆盖(其他 extension 包装 / 手动改写;pi 的 configureHttpDispatcher
|
|
22
|
+
* 有 shouldInstallGlobals 守卫(pi dist/core/http-dispatcher.js:88-99)——检测到 fetch
|
|
23
|
+
* 已被第三方覆盖即保留不重装,本扩展的包装在同进程内持续生效)→ 以覆盖者为新 original
|
|
24
|
+
* 重新包装(防御路径);
|
|
25
|
+
* - 构造失败:onError + 恢复原 fetch(全部直连),同指纹锁定不重试(首败即停)。
|
|
26
|
+
*
|
|
27
|
+
* reload 残留:jiti 全量 reload 后旧模块环境的包装可能仍挂在 globalThis.fetch 上;包装函数
|
|
28
|
+
* 带 `Symbol.for` 标记(跨模块环境共享),新环境推进前先摘除,防嵌套双计。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Dispatcher } from "undici";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
hitsNoProxy,
|
|
35
|
+
hitsRules,
|
|
36
|
+
networkProxyFingerprint,
|
|
37
|
+
type NetworkProxyConfig,
|
|
38
|
+
} from "./config.js";
|
|
39
|
+
import { normalizeHostname } from "./match.js";
|
|
40
|
+
import { recordDispatch } from "./stats.js";
|
|
41
|
+
|
|
42
|
+
/** pi 进程的全局 fetch 形态(Node 类型由 @types/node 的 web-globals 提供)。 */
|
|
43
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
44
|
+
|
|
45
|
+
type FetchInput = Parameters<FetchLike>[0];
|
|
46
|
+
type FetchInit = Parameters<FetchLike>[1];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 经代理发出时的 init:保留调用方全部选项,附上本次请求的代理分发器。
|
|
50
|
+
* Omit 掉 Node 全局 RequestInit 自带的 dispatcher(undici-types 类型身份),避免与 npm
|
|
51
|
+
* undici 的 Dispatcher 形成 `Dispatcher & Dispatcher` 冲突;运行时由本字段整体覆盖。
|
|
52
|
+
*/
|
|
53
|
+
export type ProxyFetchInit = Omit<NonNullable<FetchInit>, "dispatcher"> & { dispatcher: Dispatcher };
|
|
54
|
+
|
|
55
|
+
/** 代理 fetch 注入接缝:生产环境 = undici.fetch;单测用 fake 避免真实网络。 */
|
|
56
|
+
export type ProxyFetch = (input: FetchInput, init: ProxyFetchInit) => Promise<Response>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 跨模块环境身份标记(Symbol.for 进程级注册表,jiti reload 后新旧环境共享):
|
|
60
|
+
* 记录包装前捕获的 original fetch,供新环境摘除旧包装。
|
|
61
|
+
*/
|
|
62
|
+
const FETCH_WRAPPER_MARK = Symbol.for("@zhushanwen/pi-network-proxy:fetch-wrapper");
|
|
63
|
+
|
|
64
|
+
interface FetchWrapperMark { // oe-exempt:20261020:framework:包装函数标记形状(跨模块环境身份契约,单实现为常态)
|
|
65
|
+
|
|
66
|
+
originalFetch: FetchLike;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 当前包装状态;null = 未包装(globalThis.fetch 是 pi 原生实现)。 */
|
|
70
|
+
export interface FetchWrapperState { // oe-exempt:20261020:framework:包装状态机载体形状(状态机唯一状态类型,单实现为常态)
|
|
71
|
+
|
|
72
|
+
/** 包装前捕获的原 fetch(首次捕获后跨重建保留——恢复与直连委托都用它)。 */
|
|
73
|
+
originalFetch: FetchLike;
|
|
74
|
+
/** 已应用(或已锁定失败)配置的指纹。 */
|
|
75
|
+
fingerprint: string;
|
|
76
|
+
/** 当前包装函数;null = 构造失败锁定态(未包装)。 */
|
|
77
|
+
wrapper: FetchLike | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FetchWrapperDeps { // oe-exempt:20261020:framework:状态机依赖注入接缝(单测注入 fake fetch / buildProxy / proxyFetch)
|
|
81
|
+
|
|
82
|
+
getFetch: () => FetchLike;
|
|
83
|
+
setFetch: (fetch: FetchLike) => void;
|
|
84
|
+
/** 代理分发器构造(可注入抛错——「构造失败 → 不包装 → 全直连」单测入口)。 */
|
|
85
|
+
buildProxy: (config: NetworkProxyConfig) => Dispatcher;
|
|
86
|
+
proxyFetch: ProxyFetch;
|
|
87
|
+
/** extension error 呈现(logger.error;文案含恢复动作)。 */
|
|
88
|
+
onError: (message: string) => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 从 URL 字符串取归一化主机名;非法 URL 返回空串(不计数、原样放行)。 */
|
|
92
|
+
function hostnameOfUrl(raw: string): string {
|
|
93
|
+
try {
|
|
94
|
+
return normalizeHostname(new URL(raw).hostname);
|
|
95
|
+
} catch {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 从 fetch 入参取主机名:string / URL / Request(含 url 属性的对象)。
|
|
102
|
+
* 空串 = 无法判定目标(相对 URL / 非法入参),调用方原样放行且不计数。
|
|
103
|
+
*/
|
|
104
|
+
function hostnameOfInput(input: FetchInput): string {
|
|
105
|
+
if (typeof input === "string") return hostnameOfUrl(input);
|
|
106
|
+
if (input instanceof URL) return normalizeHostname(input.hostname);
|
|
107
|
+
if (typeof input === "object" && input !== null && "url" in input) {
|
|
108
|
+
const url: unknown = input.url;
|
|
109
|
+
if (typeof url === "string") return hostnameOfUrl(url);
|
|
110
|
+
}
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 给包装函数打标记(不可枚举,避免暴露给业务代码的序列化/枚举)。 */
|
|
115
|
+
function markWrapper(wrapper: FetchLike, originalFetch: FetchLike): void {
|
|
116
|
+
Object.defineProperty(wrapper, FETCH_WRAPPER_MARK, {
|
|
117
|
+
value: { originalFetch } satisfies FetchWrapperMark,
|
|
118
|
+
enumerable: false,
|
|
119
|
+
configurable: false,
|
|
120
|
+
writable: false,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 函数形态守卫(标记值的 originalFetch 校验,避免断言到 FetchLike)。 */
|
|
125
|
+
function isFetchLike(value: unknown): value is FetchLike {
|
|
126
|
+
return typeof value === "function";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 读取包装标记;非本扩展包装返回 null。 */
|
|
130
|
+
function wrapperMarkOf(fetch: FetchLike): FetchWrapperMark | null {
|
|
131
|
+
const descriptor = Object.getOwnPropertyDescriptor(fetch, FETCH_WRAPPER_MARK);
|
|
132
|
+
if (descriptor === undefined || !("value" in descriptor)) return null;
|
|
133
|
+
const value: unknown = descriptor.value;
|
|
134
|
+
if (value === null || typeof value !== "object") return null;
|
|
135
|
+
const original: unknown = Reflect.get(value, "originalFetch");
|
|
136
|
+
return isFetchLike(original) ? { originalFetch: original } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 构造包装函数:每次调用现算 host → 计数 → 命中规则走代理 fetch,未命中委托原 fetch。
|
|
141
|
+
* 计数时点 = 请求发起时(与旧分流器一致;不等待完成,成败由 pi 原生错误链路呈现)。
|
|
142
|
+
*/
|
|
143
|
+
function createWrappedFetch(
|
|
144
|
+
originalFetch: FetchLike,
|
|
145
|
+
config: NetworkProxyConfig,
|
|
146
|
+
proxy: Dispatcher,
|
|
147
|
+
proxyFetch: ProxyFetch,
|
|
148
|
+
): FetchLike {
|
|
149
|
+
const wrapped: FetchLike = async (input, init) => {
|
|
150
|
+
const host = hostnameOfInput(input);
|
|
151
|
+
if (host === "") return originalFetch(input, init);
|
|
152
|
+
const proxied = !hitsNoProxy(host, config.noProxy) && hitsRules(host, config.rules);
|
|
153
|
+
recordDispatch(host, proxied);
|
|
154
|
+
if (!proxied) return originalFetch(input, init);
|
|
155
|
+
const rest: Omit<NonNullable<FetchInit>, "dispatcher"> = { ...(init ?? {}) };
|
|
156
|
+
return proxyFetch(input, { ...rest, dispatcher: proxy });
|
|
157
|
+
};
|
|
158
|
+
markWrapper(wrapped, originalFetch);
|
|
159
|
+
return wrapped;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 禁用态收口:仅当槽位仍归本扩展时恢复原 fetch(槽位被外部覆盖时不改写别人的实现)。 */
|
|
163
|
+
function restoreOriginalIfOwned(
|
|
164
|
+
state: FetchWrapperState | null,
|
|
165
|
+
deps: Pick<FetchWrapperDeps, "getFetch" | "setFetch">,
|
|
166
|
+
): void {
|
|
167
|
+
if (state?.wrapper !== null && state?.wrapper !== undefined && deps.getFetch() === state.wrapper) {
|
|
168
|
+
deps.setFetch(state.originalFetch);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* jiti reload 残留摘除:新模块环境 installed=null 但旧环境的包装仍挂在 globalThis.fetch 上。
|
|
174
|
+
* 不摘除则 enabled=false 时判「未包装」零动作(旧包装继续生效),或 enabled=true 时把旧包装
|
|
175
|
+
* 当作 original 再包一层(同一请求两次计数、两级代理判定)。
|
|
176
|
+
*/
|
|
177
|
+
export function detachResidualFetchWrapper(
|
|
178
|
+
state: FetchWrapperState | null,
|
|
179
|
+
deps: Pick<FetchWrapperDeps, "getFetch" | "setFetch">,
|
|
180
|
+
): void {
|
|
181
|
+
if (state !== null) return;
|
|
182
|
+
const current = deps.getFetch();
|
|
183
|
+
const mark = wrapperMarkOf(current);
|
|
184
|
+
if (mark !== null) deps.setFetch(mark.originalFetch);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 构造失败收口:error 上报 + 恢复原 fetch(全部直连),返回记指纹的锁定态。 */
|
|
188
|
+
function lockOnBuildFailure(
|
|
189
|
+
config: NetworkProxyConfig,
|
|
190
|
+
state: FetchWrapperState | null,
|
|
191
|
+
originalFetch: FetchLike,
|
|
192
|
+
fingerprint: string,
|
|
193
|
+
deps: FetchWrapperDeps,
|
|
194
|
+
error: unknown,
|
|
195
|
+
): FetchWrapperState {
|
|
196
|
+
deps.onError(
|
|
197
|
+
`网络代理转发器构造失败(协议 ${config.proxy.protocol}),已保持全部直连,不影响 pi 运行;` +
|
|
198
|
+
`可核对代理配置后保存重试,或关闭网络代理开关。构造错误: ${error instanceof Error ? error.message : String(error)}`,
|
|
199
|
+
);
|
|
200
|
+
if (state?.wrapper !== null && state?.wrapper !== undefined && deps.getFetch() === state.wrapper) {
|
|
201
|
+
deps.setFetch(state.originalFetch);
|
|
202
|
+
}
|
|
203
|
+
return { originalFetch, fingerprint, wrapper: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 按新配置推进包装状态机(转移表见模块头)。config 契约 = loadNetworkProxyConfig 产物
|
|
208
|
+
* (normalize 已校验合法——非法配置在读取层回落 defaults+error,构造性到达本函数恒合法)。
|
|
209
|
+
*/
|
|
210
|
+
export function applyNetworkProxyConfig(
|
|
211
|
+
config: NetworkProxyConfig,
|
|
212
|
+
state: FetchWrapperState | null,
|
|
213
|
+
deps: FetchWrapperDeps,
|
|
214
|
+
): FetchWrapperState | null {
|
|
215
|
+
if (!config.enabled) {
|
|
216
|
+
restoreOriginalIfOwned(state, deps);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fingerprint = networkProxyFingerprint(config);
|
|
221
|
+
if (state && state.fingerprint === fingerprint && state.wrapper === null) {
|
|
222
|
+
return state; // 锁定态:同指纹不重试构造(首败即停)
|
|
223
|
+
}
|
|
224
|
+
const currentFetch = deps.getFetch();
|
|
225
|
+
const owned =
|
|
226
|
+
state?.wrapper !== null &&
|
|
227
|
+
state?.wrapper !== undefined &&
|
|
228
|
+
currentFetch === state.wrapper;
|
|
229
|
+
if (state && state.fingerprint === fingerprint && owned) {
|
|
230
|
+
return state; // 同指纹且包装仍在槽位:零动作
|
|
231
|
+
}
|
|
232
|
+
const originalFetch = owned && state ? state.originalFetch : currentFetch;
|
|
233
|
+
|
|
234
|
+
let proxy: Dispatcher;
|
|
235
|
+
try {
|
|
236
|
+
proxy = deps.buildProxy(config);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return lockOnBuildFailure(config, state, originalFetch, fingerprint, deps, error);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const wrapper = createWrappedFetch(originalFetch, config, proxy, deps.proxyFetch);
|
|
242
|
+
deps.setFetch(wrapper);
|
|
243
|
+
return { originalFetch, fingerprint, wrapper };
|
|
244
|
+
}
|