@zhushanwen/pi-network-proxy 0.2.0 → 0.2.2
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 +16 -6
- package/package.json +3 -3
- package/skills/network-proxy-ext-config/SKILL.md +7 -6
- package/src/__tests__/dispatcher.test.ts +5 -124
- package/src/__tests__/fetch-wrapper.test.ts +324 -0
- package/src/__tests__/index.test.ts +77 -57
- package/src/dispatcher.ts +10 -100
- package/src/fetch-wrapper.ts +245 -0
- package/src/index.ts +45 -25
- package/src/stats.ts +2 -2
- package/src/__tests__/installer.test.ts +0 -313
- package/src/installer.ts +0 -165
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* extension 入口行为单测(真实组件 + tmp agentDir,不 mock
|
|
3
|
-
* 工厂加载序列(校验过 →
|
|
2
|
+
* extension 入口行为单测(真实组件 + tmp agentDir,不 mock 配置读取链):
|
|
3
|
+
* 工厂加载序列(校验过 → 包装 globalThis.fetch / 非法 → error + 全直连)、turn 粒度热生效
|
|
4
4
|
* (turn_start 重读配置、turn_end flush 计数)。vi.resetModules 隔离模块级 installed 状态。
|
|
5
5
|
* fs-guard 白名单:写入目标 = PI_CODING_AGENT_DIR 注入的 mkdtemp tmp 目录。
|
|
6
6
|
*/
|
|
@@ -8,19 +8,17 @@ import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
-
import { getGlobalDispatcher, setGlobalDispatcher, type Dispatcher } from "undici";
|
|
12
11
|
|
|
12
|
+
// mock 只覆盖本包模块图实际消费的导出(getLogger / setPiHandle;createLogger 无消费方,
|
|
13
|
+
// 推测性 mock 会在 logger 包改表面时静默失配——缺导出是显式 undefined, loud fail)
|
|
13
14
|
const { loggerMock } = vi.hoisted(() => ({
|
|
14
15
|
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
15
16
|
}));
|
|
16
17
|
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
17
18
|
getLogger: () => loggerMock,
|
|
18
|
-
createLogger: () => loggerMock,
|
|
19
19
|
setPiHandle: vi.fn(),
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
-
import { isRuleDispatchDispatcher, RuleDispatchDispatcher } from "../dispatcher.ts";
|
|
23
|
-
|
|
24
22
|
/** fake pi ExtensionAPI:收集 on 注册的 handler,emit 触发。 */
|
|
25
23
|
function createFakePi() {
|
|
26
24
|
const handlers = new Map<string, Array<() => void>>();
|
|
@@ -46,92 +44,117 @@ const ENABLED_CONFIG = {
|
|
|
46
44
|
rules: ["api.anthropic.com"],
|
|
47
45
|
};
|
|
48
46
|
|
|
47
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
48
|
+
|
|
49
49
|
let agentDir: string;
|
|
50
|
-
let
|
|
50
|
+
let savedFetch: FetchLike;
|
|
51
|
+
let fakeFetch: FetchLike;
|
|
52
|
+
let fakeCalls: number;
|
|
51
53
|
|
|
52
54
|
function writeConfigFile(content: string): void {
|
|
53
55
|
mkdirSync(join(agentDir, "config"), { recursive: true });
|
|
54
56
|
writeFileSync(join(agentDir, "config", "network-proxy-ext-config.json"), content);
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 标准前导:写配置文件 → 加载 extension(afterEach 的 resetModules 后每次 import
|
|
61
|
+
* 都是新模块图实例)→ 装配 fake pi 并执行工厂。config = 配置文件内容(默认 enabled)。
|
|
62
|
+
* 工厂在 helper 内执行:非法配置用例的「不抛」契约由 await 拒绝等价保证(抛即用例红)。
|
|
63
|
+
*/
|
|
64
|
+
async function loadExtension(content = JSON.stringify(ENABLED_CONFIG)): Promise<{ emit: (event: "turn_start" | "turn_end") => void }> {
|
|
65
|
+
writeConfigFile(content);
|
|
66
|
+
const { default: networkProxyExtension } = await import("../index.ts");
|
|
67
|
+
const { pi, emit } = createFakePi();
|
|
68
|
+
networkProxyExtension(pi);
|
|
69
|
+
return { emit };
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
beforeEach(() => {
|
|
58
73
|
agentDir = mkdtempSync(join(tmpdir(), "network-proxy-index-"));
|
|
59
74
|
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
savedFetch = globalThis.fetch;
|
|
76
|
+
fakeCalls = 0;
|
|
77
|
+
fakeFetch = async () => {
|
|
78
|
+
fakeCalls += 1;
|
|
79
|
+
return new Response("fake-original");
|
|
80
|
+
};
|
|
81
|
+
globalThis.fetch = fakeFetch;
|
|
62
82
|
loggerMock.error.mockClear();
|
|
63
83
|
});
|
|
64
84
|
|
|
65
85
|
afterEach(() => {
|
|
66
|
-
|
|
86
|
+
globalThis.fetch = savedFetch;
|
|
67
87
|
vi.unstubAllEnvs();
|
|
68
88
|
vi.resetModules();
|
|
69
89
|
});
|
|
70
90
|
|
|
71
91
|
describe("networkProxyExtension 工厂加载序列", () => {
|
|
72
|
-
it("enabled
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
92
|
+
it("enabled 且校验过:包装 globalThis.fetch(未命中规则时委托 fake 原 fetch)", async () => {
|
|
93
|
+
await loadExtension();
|
|
94
|
+
expect(globalThis.fetch).not.toBe(fakeFetch);
|
|
95
|
+
const response = await globalThis.fetch("https://open.bigmodel.cn/api");
|
|
96
|
+
expect(await response.text()).toBe("fake-original");
|
|
97
|
+
expect(fakeCalls).toBe(1);
|
|
78
98
|
expect(loggerMock.error).not.toHaveBeenCalled();
|
|
79
99
|
});
|
|
80
100
|
|
|
81
|
-
it("enabled=false
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
networkProxyExtension(createFakePi().pi);
|
|
85
|
-
expect(getGlobalDispatcher()).toBe(savedDispatcher);
|
|
101
|
+
it("enabled=false:不包装(globalThis.fetch 保持原实现),零 error", async () => {
|
|
102
|
+
await loadExtension(JSON.stringify({ ...ENABLED_CONFIG, enabled: false }));
|
|
103
|
+
expect(globalThis.fetch).toBe(fakeFetch);
|
|
86
104
|
expect(loggerMock.error).not.toHaveBeenCalled();
|
|
87
105
|
});
|
|
88
106
|
|
|
89
|
-
it("非法配置(坏 JSON
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(
|
|
93
|
-
expect(getGlobalDispatcher()).toBe(savedDispatcher);
|
|
107
|
+
it("非法配置(坏 JSON):不抛、不包装(全部直连),error 含恢复动作(配置文件路径)", async () => {
|
|
108
|
+
// 不抛契约由 loadExtension 的 await 保证(工厂抛错即本用例红)
|
|
109
|
+
await loadExtension("{ not valid json");
|
|
110
|
+
expect(globalThis.fetch).toBe(fakeFetch);
|
|
94
111
|
const messages = loggerMock.error.mock.calls.map((c) => String(c[0]));
|
|
95
112
|
expect(messages.some((m) => m.includes("network-proxy-ext-config.json"))).toBe(true);
|
|
96
113
|
});
|
|
97
114
|
|
|
98
|
-
it("非法字段(端口越界):loadConfig 回落 defaults(enabled=false →
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(() => networkProxyExtension(createFakePi().pi)).not.toThrow();
|
|
102
|
-
expect(getGlobalDispatcher()).toBe(savedDispatcher);
|
|
115
|
+
it("非法字段(端口越界):loadConfig 回落 defaults(enabled=false → 不包装)+ error 可归因", async () => {
|
|
116
|
+
await loadExtension(JSON.stringify({ ...ENABLED_CONFIG, proxy: { ...ENABLED_CONFIG.proxy, port: 99999 } }));
|
|
117
|
+
expect(globalThis.fetch).toBe(fakeFetch);
|
|
103
118
|
expect(loggerMock.error.mock.calls.length).toBeGreaterThan(0);
|
|
104
119
|
});
|
|
105
120
|
});
|
|
106
121
|
|
|
107
122
|
describe("turn 粒度热生效", () => {
|
|
108
|
-
it("turn_start
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const first = getGlobalDispatcher();
|
|
114
|
-
|
|
115
|
-
// 未变配置:同分发器(不重建)
|
|
123
|
+
it("turn_start 重读配置:指纹变化换新包装,未变零动作", async () => {
|
|
124
|
+
const { emit } = await loadExtension();
|
|
125
|
+
const first = globalThis.fetch;
|
|
126
|
+
|
|
127
|
+
// 未变配置:同包装函数(不重建)
|
|
116
128
|
emit("turn_start");
|
|
117
|
-
expect(
|
|
129
|
+
expect(globalThis.fetch).toBe(first);
|
|
118
130
|
|
|
119
|
-
// 改写配置(size 变化 → mtime+size
|
|
131
|
+
// 改写配置(size 变化 → mtime+size 缓存失效):指纹变化换新包装
|
|
120
132
|
writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, rules: ["api.openai.com"] }));
|
|
121
133
|
emit("turn_start");
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
expect(globalThis.fetch).not.toBe(first);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("pi 重装 globalThis.fetch(configureHttpDispatcher)后 turn_start 重新包装覆盖者", async () => {
|
|
138
|
+
const { emit } = await loadExtension();
|
|
139
|
+
|
|
140
|
+
let piReinstalledCalls = 0;
|
|
141
|
+
const piReinstalled: FetchLike = async () => {
|
|
142
|
+
piReinstalledCalls += 1;
|
|
143
|
+
return new Response("pi-native");
|
|
144
|
+
};
|
|
145
|
+
globalThis.fetch = piReinstalled;
|
|
146
|
+
emit("turn_start");
|
|
147
|
+
|
|
148
|
+
expect(globalThis.fetch).not.toBe(piReinstalled);
|
|
149
|
+
const response = await globalThis.fetch("https://open.bigmodel.cn/api");
|
|
150
|
+
expect(await response.text()).toBe("pi-native");
|
|
151
|
+
expect(piReinstalledCalls).toBe(1);
|
|
125
152
|
});
|
|
126
153
|
|
|
127
154
|
it("turn_end flush 计数增量到 stats JSONL(recordDispatch → turn_end → 落盘行)", async () => {
|
|
128
|
-
|
|
129
|
-
const { default: networkProxyExtension } = await import("../index.ts");
|
|
155
|
+
const { emit } = await loadExtension();
|
|
130
156
|
// resetModules 后 index.ts 与此处 import 的 stats.ts 是同一模块图刷新的同一实例
|
|
131
|
-
const { recordDispatch } = await import("../stats.ts");
|
|
132
|
-
const { statsFilePath } = await import("../stats.ts");
|
|
133
|
-
const { pi, emit } = createFakePi();
|
|
134
|
-
networkProxyExtension(pi);
|
|
157
|
+
const { recordDispatch, statsFilePath } = await import("../stats.ts");
|
|
135
158
|
|
|
136
159
|
recordDispatch("api.anthropic.com", true);
|
|
137
160
|
recordDispatch("open.bigmodel.cn", false);
|
|
@@ -143,15 +166,12 @@ describe("turn 粒度热生效", () => {
|
|
|
143
166
|
expect(hosts).toEqual(["api.anthropic.com", "open.bigmodel.cn"]);
|
|
144
167
|
});
|
|
145
168
|
|
|
146
|
-
it("enabled true→false:turn_start
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const { pi, emit } = createFakePi();
|
|
150
|
-
networkProxyExtension(pi);
|
|
151
|
-
expect(isRuleDispatchDispatcher(getGlobalDispatcher())).toBe(true);
|
|
169
|
+
it("enabled true→false:turn_start 后恢复原 fetch(禁用生效)", async () => {
|
|
170
|
+
const { emit } = await loadExtension();
|
|
171
|
+
expect(globalThis.fetch).not.toBe(fakeFetch);
|
|
152
172
|
|
|
153
173
|
writeConfigFile(JSON.stringify({ ...ENABLED_CONFIG, enabled: false }));
|
|
154
174
|
emit("turn_start");
|
|
155
|
-
expect(
|
|
175
|
+
expect(globalThis.fetch).toBe(fakeFetch);
|
|
156
176
|
});
|
|
157
177
|
});
|
package/src/dispatcher.ts
CHANGED
|
@@ -1,40 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 代理分发器构造(供 fetch-wrapper 在规则命中时按请求注入)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* destroy 不 override,见类尾注释)。
|
|
11
|
-
* - 两协议构造契约(✅ undici 7.28.0 源码读码核实,单测冒烟防回归):
|
|
12
|
-
* http/https 走 ProxyAgent,调优经 `proxyTls: { autoSelectFamilyAttemptTimeout: 2000 }`
|
|
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 }`
|
|
13
10
|
* (构造内部 buildConnector({...opts.proxyTls}) 透传到「到代理」连接);禁传 connect 选项
|
|
14
11
|
* ——ProxyAgent 不消费该字段,传入被静默忽略、调优失效且不报错。
|
|
15
|
-
*
|
|
12
|
+
* - socks5 走 Socks5ProxyAgent(实验性 API),调优经 `connect: buildConnector({...})` 的
|
|
16
13
|
* connector 函数形态——该构造选项只收函数,对象形态首个请求建隧道时 TypeError;
|
|
17
14
|
* `proxyTls` 是「代理连接走 TLS」开关(truthy 即按 TLS 连代理),不可当调优参数袋。
|
|
18
15
|
* - 高延迟路由语义:到代理服务器的连接不回落 autoSelectFamilyAttemptTimeout 默认 250ms,
|
|
19
16
|
* 对齐 pi 原生调优同值 2000(pi dist/core/http-dispatcher.js:5
|
|
20
17
|
* DEFAULT_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_MS = 2_000;其 :4 注释即 250ms 默认会
|
|
21
18
|
* 终止高延迟路由有效连接尝试的动机)。
|
|
22
|
-
* - 计数时点 = 请求发起时(不等待完成——统计观测「按规则尝试的出站方式」,成败由 pi 原生
|
|
23
|
-
* 错误链路呈现)。
|
|
24
19
|
*/
|
|
25
20
|
|
|
26
21
|
import { EventEmitter } from "node:events";
|
|
27
22
|
|
|
28
|
-
import {
|
|
29
|
-
buildConnector,
|
|
30
|
-
Dispatcher,
|
|
31
|
-
ProxyAgent,
|
|
32
|
-
Socks5ProxyAgent,
|
|
33
|
-
} from "undici";
|
|
23
|
+
import { buildConnector, ProxyAgent, Socks5ProxyAgent } from "undici";
|
|
34
24
|
import type { Dispatcher as IDispatcher } from "undici";
|
|
35
25
|
|
|
36
|
-
import {
|
|
37
|
-
import { normalizeHostname } from "./match.js";
|
|
26
|
+
import type { NetworkProxyConfig, NetworkProxyServer } from "./config.js";
|
|
38
27
|
|
|
39
28
|
/** 到代理服务器连接的 autoSelectFamilyAttemptTimeout(对齐 pi 原生调优同值)。 */
|
|
40
29
|
export const PROXY_CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
|
|
@@ -50,26 +39,6 @@ const PROXY_TLS_TUNING: buildConnector.BuildOptions = {
|
|
|
50
39
|
autoSelectFamilyAttemptTimeout: PROXY_CONNECT_ATTEMPT_TIMEOUT_MS,
|
|
51
40
|
};
|
|
52
41
|
|
|
53
|
-
/** 代理出站计数回调:host = 归一化目标主机名;proxied = true 经代理 / false 直连。 */
|
|
54
|
-
export type DispatchCounter = (host: string, proxied: boolean) => void;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 跨模块环境身份标记:jiti 全量 reload 产生的新模块环境与旧环境的类对象互异(instanceof
|
|
58
|
-
* 失效;根因 = reload 后 extensions/loader.js:472-476 新建 jiti 实例(moduleCache: false)
|
|
59
|
-
* 重新 import 本扩展,两环境各持一份类构造函数——pi reload 链总锚点见 installer.ts
|
|
60
|
-
* detachResidualDispatcher 注释),而 Symbol.for 全局注册表是进程级注册表、跨模块环境
|
|
61
|
-
* 共享——以它识别全局槽上本扩展旧环境残留的
|
|
62
|
-
* 分流器实例(残留不摘除则禁用失效 / 重装嵌套双计,见 installer.ts detachResidualDispatcher)。
|
|
63
|
-
*/
|
|
64
|
-
const RULE_DISPATCH_MARK = Symbol.for("@zhushanwen/pi-network-proxy:rule-dispatch");
|
|
65
|
-
|
|
66
|
-
/** 类型守卫:目标分发器是否本扩展的 RuleDispatchDispatcher(跨模块环境可辨,见 RULE_DISPATCH_MARK)。 */
|
|
67
|
-
export function isRuleDispatchDispatcher(
|
|
68
|
-
dispatcher: IDispatcher,
|
|
69
|
-
): dispatcher is RuleDispatchDispatcher {
|
|
70
|
-
return RULE_DISPATCH_MARK in dispatcher;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
42
|
/**
|
|
74
43
|
* 防崩溃 error listener(对齐 pi withUndiciErrorListener 形态):undici 内部 Client 在
|
|
75
44
|
* 中途终止 fetch body 时可能 emit "error"——EventEmitter 的 unhandled "error" 特例会炸掉
|
|
@@ -108,62 +77,3 @@ export function buildProxyDispatcher(config: NetworkProxyConfig): IDispatcher {
|
|
|
108
77
|
}),
|
|
109
78
|
);
|
|
110
79
|
}
|
|
111
|
-
|
|
112
|
-
/** 从 dispatch opts.origin 取归一化主机名;origin 缺失/非法(无已发生证据的异常路径)返回空串。 */
|
|
113
|
-
function hostnameOf(origin: string | URL | undefined): string {
|
|
114
|
-
if (origin === undefined) return "";
|
|
115
|
-
try {
|
|
116
|
-
const url = origin instanceof URL ? origin : new URL(origin);
|
|
117
|
-
return normalizeHostname(url.hostname);
|
|
118
|
-
} catch {
|
|
119
|
-
return "";
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface RuleDispatchDeps { // oe-exempt:20261019:framework:分流器依赖注入接缝(单测以 fake 分发器驱动,不 mock undici 全局槽)
|
|
124
|
-
|
|
125
|
-
/** 安装前捕获的原分发器:noProxy 命中与规则未命中流量委托——保留 pi 原生 EnvHttpProxyAgent 的 env 代理路由/连接调优/防崩溃语义(构造性等价:未命中 = 未安装本扩展时 pi 的确切行为)。 */
|
|
126
|
-
fallback: IDispatcher;
|
|
127
|
-
/** 规则命中流量的代理分发器(buildProxyDispatcher 产物)。 */
|
|
128
|
-
proxy: IDispatcher;
|
|
129
|
-
noProxy: string[];
|
|
130
|
-
rules: string[];
|
|
131
|
-
/** 分流计数回调(发起时点,本类不落盘)。 */
|
|
132
|
-
onCount: DispatchCounter;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* 规则分流分发器:dispatch 内 noProxy(排除,优先)→ rules(命中走代理)→ 委托 fallback。
|
|
137
|
-
* 分流覆盖一切经全局分发器的请求(含 WebSocket 升级请求——Node 全局 WebSocket 建连默认取
|
|
138
|
-
* 全局分发器,设计 §3.1 行为序列 3)。
|
|
139
|
-
*/
|
|
140
|
-
export class RuleDispatchDispatcher extends Dispatcher {
|
|
141
|
-
readonly [RULE_DISPATCH_MARK] = true;
|
|
142
|
-
readonly fallback: IDispatcher;
|
|
143
|
-
readonly proxy: IDispatcher;
|
|
144
|
-
readonly noProxy: string[];
|
|
145
|
-
readonly rules: string[];
|
|
146
|
-
readonly onCount: DispatchCounter;
|
|
147
|
-
|
|
148
|
-
constructor(deps: RuleDispatchDeps) {
|
|
149
|
-
super();
|
|
150
|
-
this.fallback = deps.fallback;
|
|
151
|
-
this.proxy = deps.proxy;
|
|
152
|
-
this.noProxy = deps.noProxy;
|
|
153
|
-
this.rules = deps.rules;
|
|
154
|
-
this.onCount = deps.onCount;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
dispatch(opts: IDispatcher.DispatchOptions, handler: IDispatcher.DispatchHandler): boolean {
|
|
158
|
-
const host = hostnameOf(opts.origin);
|
|
159
|
-
const useProxy = !hitsNoProxy(host, this.noProxy) && hitsRules(host, this.rules);
|
|
160
|
-
this.onCount(host, useProxy);
|
|
161
|
-
return useProxy ? this.proxy.dispatch(opts, handler) : this.fallback.dispatch(opts, handler);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// 不 override close/destroy:①全链路无全局分发器 close/destroy 调用方(✅ 本会话读码
|
|
165
|
-
// 核实——undici fetch 链路、pi http-dispatcher.js(只构造与 setGlobalDispatcher)、本包
|
|
166
|
-
// 代码均不调用);②undici 父类 close/destroy 是 void 与 Promise<void> 返回互斥的重载集,
|
|
167
|
-
// 单一实现签名无法通过 TS override 兼容检查(实测 TS2322);父类默认抛 not implemented
|
|
168
|
-
// = 若未来出现调用方则 fail-fast 暴露,不为无已发生证据的调用方保留透传实现。
|
|
169
|
-
}
|
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
* 签名自包含(不引用文件内私有别名 FetchInit)——导出签名的被引用类型必须可命名。
|
|
53
|
+
*/
|
|
54
|
+
export type ProxyFetchInit = Omit<NonNullable<Parameters<FetchLike>[1]>, "dispatcher"> & { dispatcher: Dispatcher };
|
|
55
|
+
|
|
56
|
+
/** 代理 fetch 注入接缝:生产环境 = undici.fetch;单测用 fake 避免真实网络。 */
|
|
57
|
+
export type ProxyFetch = (input: Parameters<FetchLike>[0], init: ProxyFetchInit) => Promise<Response>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 跨模块环境身份标记(Symbol.for 进程级注册表,jiti reload 后新旧环境共享):
|
|
61
|
+
* 记录包装前捕获的 original fetch,供新环境摘除旧包装。
|
|
62
|
+
*/
|
|
63
|
+
const FETCH_WRAPPER_MARK = Symbol.for("@zhushanwen/pi-network-proxy:fetch-wrapper");
|
|
64
|
+
|
|
65
|
+
interface FetchWrapperMark { // oe-exempt:20261020:framework:包装函数标记形状(跨模块环境身份契约,单实现为常态)
|
|
66
|
+
|
|
67
|
+
originalFetch: FetchLike;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 当前包装状态;null = 未包装(globalThis.fetch 是 pi 原生实现)。 */
|
|
71
|
+
export interface FetchWrapperState { // oe-exempt:20261020:framework:包装状态机载体形状(状态机唯一状态类型,单实现为常态)
|
|
72
|
+
|
|
73
|
+
/** 包装前捕获的原 fetch(首次捕获后跨重建保留——恢复与直连委托都用它)。 */
|
|
74
|
+
originalFetch: FetchLike;
|
|
75
|
+
/** 已应用(或已锁定失败)配置的指纹。 */
|
|
76
|
+
fingerprint: string;
|
|
77
|
+
/** 当前包装函数;null = 构造失败锁定态(未包装)。 */
|
|
78
|
+
wrapper: FetchLike | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FetchWrapperDeps { // oe-exempt:20261020:framework:状态机依赖注入接缝(单测注入 fake fetch / buildProxy / proxyFetch)
|
|
82
|
+
|
|
83
|
+
getFetch: () => FetchLike;
|
|
84
|
+
setFetch: (fetch: FetchLike) => void;
|
|
85
|
+
/** 代理分发器构造(可注入抛错——「构造失败 → 不包装 → 全直连」单测入口)。 */
|
|
86
|
+
buildProxy: (config: NetworkProxyConfig) => Dispatcher;
|
|
87
|
+
proxyFetch: ProxyFetch;
|
|
88
|
+
/** extension error 呈现(logger.error;文案含恢复动作)。 */
|
|
89
|
+
onError: (message: string) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 从 URL 字符串取归一化主机名;非法 URL 返回空串(不计数、原样放行)。 */
|
|
93
|
+
function hostnameOfUrl(raw: string): string {
|
|
94
|
+
try {
|
|
95
|
+
return normalizeHostname(new URL(raw).hostname);
|
|
96
|
+
} catch {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 从 fetch 入参取主机名:string / URL / Request(含 url 属性的对象)。
|
|
103
|
+
* 空串 = 无法判定目标(相对 URL / 非法入参),调用方原样放行且不计数。
|
|
104
|
+
*/
|
|
105
|
+
function hostnameOfInput(input: FetchInput): string {
|
|
106
|
+
if (typeof input === "string") return hostnameOfUrl(input);
|
|
107
|
+
if (input instanceof URL) return normalizeHostname(input.hostname);
|
|
108
|
+
if (typeof input === "object" && input !== null && "url" in input) {
|
|
109
|
+
const url: unknown = input.url;
|
|
110
|
+
if (typeof url === "string") return hostnameOfUrl(url);
|
|
111
|
+
}
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 给包装函数打标记(不可枚举,避免暴露给业务代码的序列化/枚举)。 */
|
|
116
|
+
function markWrapper(wrapper: FetchLike, originalFetch: FetchLike): void {
|
|
117
|
+
Object.defineProperty(wrapper, FETCH_WRAPPER_MARK, {
|
|
118
|
+
value: { originalFetch } satisfies FetchWrapperMark,
|
|
119
|
+
enumerable: false,
|
|
120
|
+
configurable: false,
|
|
121
|
+
writable: false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 函数形态守卫(标记值的 originalFetch 校验,避免断言到 FetchLike)。 */
|
|
126
|
+
function isFetchLike(value: unknown): value is FetchLike {
|
|
127
|
+
return typeof value === "function";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 读取包装标记;非本扩展包装返回 null。 */
|
|
131
|
+
function wrapperMarkOf(fetch: FetchLike): FetchWrapperMark | null {
|
|
132
|
+
const descriptor = Object.getOwnPropertyDescriptor(fetch, FETCH_WRAPPER_MARK);
|
|
133
|
+
if (descriptor === undefined || !("value" in descriptor)) return null;
|
|
134
|
+
const value: unknown = descriptor.value;
|
|
135
|
+
if (value === null || typeof value !== "object") return null;
|
|
136
|
+
const original: unknown = Reflect.get(value, "originalFetch");
|
|
137
|
+
return isFetchLike(original) ? { originalFetch: original } : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 构造包装函数:每次调用现算 host → 计数 → 命中规则走代理 fetch,未命中委托原 fetch。
|
|
142
|
+
* 计数时点 = 请求发起时(与旧分流器一致;不等待完成,成败由 pi 原生错误链路呈现)。
|
|
143
|
+
*/
|
|
144
|
+
function createWrappedFetch(
|
|
145
|
+
originalFetch: FetchLike,
|
|
146
|
+
config: NetworkProxyConfig,
|
|
147
|
+
proxy: Dispatcher,
|
|
148
|
+
proxyFetch: ProxyFetch,
|
|
149
|
+
): FetchLike {
|
|
150
|
+
const wrapped: FetchLike = async (input, init) => {
|
|
151
|
+
const host = hostnameOfInput(input);
|
|
152
|
+
if (host === "") return originalFetch(input, init);
|
|
153
|
+
const proxied = !hitsNoProxy(host, config.noProxy) && hitsRules(host, config.rules);
|
|
154
|
+
recordDispatch(host, proxied);
|
|
155
|
+
if (!proxied) return originalFetch(input, init);
|
|
156
|
+
const rest: Omit<NonNullable<FetchInit>, "dispatcher"> = { ...(init ?? {}) };
|
|
157
|
+
return proxyFetch(input, { ...rest, dispatcher: proxy });
|
|
158
|
+
};
|
|
159
|
+
markWrapper(wrapped, originalFetch);
|
|
160
|
+
return wrapped;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 禁用态收口:仅当槽位仍归本扩展时恢复原 fetch(槽位被外部覆盖时不改写别人的实现)。 */
|
|
164
|
+
function restoreOriginalIfOwned(
|
|
165
|
+
state: FetchWrapperState | null,
|
|
166
|
+
deps: Pick<FetchWrapperDeps, "getFetch" | "setFetch">,
|
|
167
|
+
): void {
|
|
168
|
+
if (state?.wrapper !== null && state?.wrapper !== undefined && deps.getFetch() === state.wrapper) {
|
|
169
|
+
deps.setFetch(state.originalFetch);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* jiti reload 残留摘除:新模块环境 installed=null 但旧环境的包装仍挂在 globalThis.fetch 上。
|
|
175
|
+
* 不摘除则 enabled=false 时判「未包装」零动作(旧包装继续生效),或 enabled=true 时把旧包装
|
|
176
|
+
* 当作 original 再包一层(同一请求两次计数、两级代理判定)。
|
|
177
|
+
*/
|
|
178
|
+
export function detachResidualFetchWrapper(
|
|
179
|
+
state: FetchWrapperState | null,
|
|
180
|
+
deps: Pick<FetchWrapperDeps, "getFetch" | "setFetch">,
|
|
181
|
+
): void {
|
|
182
|
+
if (state !== null) return;
|
|
183
|
+
const current = deps.getFetch();
|
|
184
|
+
const mark = wrapperMarkOf(current);
|
|
185
|
+
if (mark !== null) deps.setFetch(mark.originalFetch);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 构造失败收口:error 上报 + 恢复原 fetch(全部直连),返回记指纹的锁定态。 */
|
|
189
|
+
function lockOnBuildFailure(
|
|
190
|
+
config: NetworkProxyConfig,
|
|
191
|
+
state: FetchWrapperState | null,
|
|
192
|
+
originalFetch: FetchLike,
|
|
193
|
+
fingerprint: string,
|
|
194
|
+
deps: FetchWrapperDeps,
|
|
195
|
+
error: unknown,
|
|
196
|
+
): FetchWrapperState {
|
|
197
|
+
deps.onError(
|
|
198
|
+
`网络代理转发器构造失败(协议 ${config.proxy.protocol}),已保持全部直连,不影响 pi 运行;` +
|
|
199
|
+
`可核对代理配置后保存重试,或关闭网络代理开关。构造错误: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
+
);
|
|
201
|
+
if (state?.wrapper !== null && state?.wrapper !== undefined && deps.getFetch() === state.wrapper) {
|
|
202
|
+
deps.setFetch(state.originalFetch);
|
|
203
|
+
}
|
|
204
|
+
return { originalFetch, fingerprint, wrapper: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 按新配置推进包装状态机(转移表见模块头)。config 契约 = loadNetworkProxyConfig 产物
|
|
209
|
+
* (normalize 已校验合法——非法配置在读取层回落 defaults+error,构造性到达本函数恒合法)。
|
|
210
|
+
*/
|
|
211
|
+
export function applyNetworkProxyConfig(
|
|
212
|
+
config: NetworkProxyConfig,
|
|
213
|
+
state: FetchWrapperState | null,
|
|
214
|
+
deps: FetchWrapperDeps,
|
|
215
|
+
): FetchWrapperState | null {
|
|
216
|
+
if (!config.enabled) {
|
|
217
|
+
restoreOriginalIfOwned(state, deps);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const fingerprint = networkProxyFingerprint(config);
|
|
222
|
+
if (state && state.fingerprint === fingerprint && state.wrapper === null) {
|
|
223
|
+
return state; // 锁定态:同指纹不重试构造(首败即停)
|
|
224
|
+
}
|
|
225
|
+
const currentFetch = deps.getFetch();
|
|
226
|
+
const owned =
|
|
227
|
+
state?.wrapper !== null &&
|
|
228
|
+
state?.wrapper !== undefined &&
|
|
229
|
+
currentFetch === state.wrapper;
|
|
230
|
+
if (state && state.fingerprint === fingerprint && owned) {
|
|
231
|
+
return state; // 同指纹且包装仍在槽位:零动作
|
|
232
|
+
}
|
|
233
|
+
const originalFetch = owned && state ? state.originalFetch : currentFetch;
|
|
234
|
+
|
|
235
|
+
let proxy: Dispatcher;
|
|
236
|
+
try {
|
|
237
|
+
proxy = deps.buildProxy(config);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return lockOnBuildFailure(config, state, originalFetch, fingerprint, deps, error);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const wrapper = createWrappedFetch(originalFetch, config, proxy, deps.proxyFetch);
|
|
243
|
+
deps.setFetch(wrapper);
|
|
244
|
+
return { originalFetch, fingerprint, wrapper };
|
|
245
|
+
}
|