dsh-proxy-routing 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.i18n.yaml +4 -0
- package/README.md +193 -0
- package/README.zh.md +193 -0
- package/cordis.patch.yml +6 -0
- package/lib/actions.js +61 -0
- package/lib/als.js +8 -0
- package/lib/client.js +480 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +194 -0
- package/lib/control.js +199 -0
- package/lib/discovery.js +89 -0
- package/lib/fetch-router.js +77 -0
- package/lib/index.js +296 -0
- package/lib/llm-router.js +63 -0
- package/lib/probe.js +68 -0
- package/lib/proxy/connect.js +166 -0
- package/lib/proxy/decode.js +60 -0
- package/lib/proxy/errors.js +26 -0
- package/lib/proxy/http11.js +133 -0
- package/lib/proxy/http2.js +96 -0
- package/lib/proxy/noproxy.js +92 -0
- package/lib/proxy/parse.js +22 -0
- package/lib/proxy/request.js +206 -0
- package/lib/proxy/stream.js +179 -0
- package/lib/proxy-env.js +173 -0
- package/lib/routes.js +75 -0
- package/lib/rpc.js +110 -0
- package/lib/settings.js +60 -0
- package/lib/shell-router.js +201 -0
- package/lib/status.js +61 -0
- package/lib/tools.js +453 -0
- package/package.json +114 -0
- package/tools/gen-cert.sh +13 -0
- package/tools/proxy-probe.mjs +49 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// lib/config.js — v2 canonical proxy-routing state and pure validation helpers.
|
|
2
|
+
// Persistence is owned by the official DSH settings seam (`proxy-routing` in
|
|
3
|
+
// `$DSH_HOME/settings.yaml`); this module has no private file lifecycle.
|
|
4
|
+
|
|
5
|
+
export const CONFIG_VERSION = 2;
|
|
6
|
+
export const DEFAULT_PROFILE_ID = "default";
|
|
7
|
+
|
|
8
|
+
/** 未配置代理端点时的固定指引文案(工具/校验/状态共用,避免散落)。 */
|
|
9
|
+
export const UNCONFIGURED_GUIDANCE =
|
|
10
|
+
"代理端点未配置:请运行 net_proxy_discover(仅 Full Access)寻找已运行代理,或向用户询问 protocol/host/port 后运行 net_proxy_probe,再请求 net_proxy_enable。";
|
|
11
|
+
|
|
12
|
+
/** 全新 canonical 默认:default profile 未配置端点,agent 直连,gateway 关闭。 */
|
|
13
|
+
export function canonicalDefaults() {
|
|
14
|
+
return {
|
|
15
|
+
version: CONFIG_VERSION,
|
|
16
|
+
profiles: [
|
|
17
|
+
{
|
|
18
|
+
id: DEFAULT_PROFILE_ID,
|
|
19
|
+
noProxy: ["127.0.0.1", "localhost", "::1", "api.deepseek.com"],
|
|
20
|
+
timeout: 60000,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
bindings: {
|
|
24
|
+
agent: { kind: "direct" },
|
|
25
|
+
providers: [],
|
|
26
|
+
gateway: null,
|
|
27
|
+
gatewayPurposes: [],
|
|
28
|
+
},
|
|
29
|
+
gateway: {
|
|
30
|
+
enabled: false,
|
|
31
|
+
port: 17890,
|
|
32
|
+
dedicatedPurposePorts: false,
|
|
33
|
+
purposes: [],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 把 profile 投影为通道层使用的代理参数对象。 */
|
|
39
|
+
export function toProxy(value) {
|
|
40
|
+
const profile = value && Array.isArray(value.profiles)
|
|
41
|
+
? value.profiles.find((entry) => entry?.id === DEFAULT_PROFILE_ID) ?? value.profiles[0]
|
|
42
|
+
: value;
|
|
43
|
+
const c = profile && typeof profile === "object" ? profile : {};
|
|
44
|
+
return {
|
|
45
|
+
protocol: c.protocol,
|
|
46
|
+
host: c.host,
|
|
47
|
+
port: c.port,
|
|
48
|
+
username: c.username || undefined,
|
|
49
|
+
password: c.password || undefined,
|
|
50
|
+
noProxy: Array.isArray(c.noProxy) ? c.noProxy : [],
|
|
51
|
+
timeout: c.timeout ?? 60000,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/** profile 是否已配置端点(protocol/host/port 齐全,且 host 非空)。 */
|
|
57
|
+
export function profileConfigured(profile) {
|
|
58
|
+
return Boolean(
|
|
59
|
+
profile &&
|
|
60
|
+
(profile.protocol === "http" || profile.protocol === "socks5") &&
|
|
61
|
+
typeof profile.host === "string" &&
|
|
62
|
+
profile.host.trim() !== "" &&
|
|
63
|
+
Number.isInteger(profile.port) &&
|
|
64
|
+
profile.port >= 1 &&
|
|
65
|
+
profile.port <= 65535,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function configError(code, message) {
|
|
70
|
+
const error = new Error(message);
|
|
71
|
+
error.code = code;
|
|
72
|
+
return error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validProviderId(id) {
|
|
76
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 64 || /[\s\u0000-\u001f]/.test(id)) {
|
|
77
|
+
throw configError("INVALID_PROVIDER_ID", `invalid provider id: ${JSON.stringify(id)}`);
|
|
78
|
+
}
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 校验 + 规范化 canonical。无效 profile 引用、被 profile 路由引用但未配置端点的 profile 抛固定指引错误。 */
|
|
83
|
+
export function validateCanonical(input) {
|
|
84
|
+
const c = input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
85
|
+
if (c.version !== undefined && c.version !== CONFIG_VERSION) {
|
|
86
|
+
throw configError("UNSUPPORTED_CONFIG_VERSION", `unsupported proxy-routing config version: ${JSON.stringify(c.version)}`);
|
|
87
|
+
}
|
|
88
|
+
const canonical = canonicalDefaults();
|
|
89
|
+
|
|
90
|
+
const defaultProfile = canonical.profiles[0];
|
|
91
|
+
const rawProfiles = Array.isArray(c.profiles) && c.profiles.length > 0 ? c.profiles : canonical.profiles;
|
|
92
|
+
const ids = new Set();
|
|
93
|
+
canonical.profiles = rawProfiles.map((p, index) => {
|
|
94
|
+
const profile = {
|
|
95
|
+
id: p && typeof p.id === "string" && p.id !== "" ? p.id : index === 0 ? DEFAULT_PROFILE_ID : `profile-${index}`,
|
|
96
|
+
noProxy: [...defaultProfile.noProxy],
|
|
97
|
+
timeout: defaultProfile.timeout,
|
|
98
|
+
};
|
|
99
|
+
if (profile.id.length > 64 || /\s/.test(profile.id)) {
|
|
100
|
+
throw configError("INVALID_PROFILE_ID", `invalid profile id: ${JSON.stringify(profile.id)}`);
|
|
101
|
+
}
|
|
102
|
+
if (ids.has(profile.id)) throw configError("DUPLICATE_PROFILE_ID", `duplicate profile id: ${profile.id}`);
|
|
103
|
+
ids.add(profile.id);
|
|
104
|
+
if (!p || typeof p !== "object") return profile;
|
|
105
|
+
if (p.protocol === "http" || p.protocol === "socks5") profile.protocol = p.protocol;
|
|
106
|
+
if (typeof p.host === "string") profile.host = p.host;
|
|
107
|
+
if (p.port !== undefined) {
|
|
108
|
+
if (!Number.isInteger(p.port) || p.port < 1 || p.port > 65535) {
|
|
109
|
+
throw configError("INVALID_PORT", `profile ${profile.id}: invalid port`);
|
|
110
|
+
}
|
|
111
|
+
profile.port = p.port;
|
|
112
|
+
}
|
|
113
|
+
if (typeof p.username === "string") profile.username = p.username;
|
|
114
|
+
if (typeof p.password === "string") profile.password = p.password;
|
|
115
|
+
if (Array.isArray(p.noProxy)) profile.noProxy = p.noProxy.filter((x) => typeof x === "string");
|
|
116
|
+
if (Number.isInteger(p.timeout) && p.timeout > 0) profile.timeout = p.timeout;
|
|
117
|
+
return profile;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const normRoute = (route) => {
|
|
121
|
+
if (!route || typeof route !== "object") return { kind: "direct" };
|
|
122
|
+
if (route.kind === "direct") return { kind: "direct" };
|
|
123
|
+
if (route.kind === "profile") {
|
|
124
|
+
if (typeof route.profileId !== "string" || route.profileId === "") {
|
|
125
|
+
throw configError("INVALID_ROUTE", "profile route requires a profileId");
|
|
126
|
+
}
|
|
127
|
+
if (!ids.has(route.profileId)) {
|
|
128
|
+
throw configError("MISSING_PROFILE", `profile route references missing profile "${route.profileId}"`);
|
|
129
|
+
}
|
|
130
|
+
const profile = canonical.profiles.find((x) => x.id === route.profileId);
|
|
131
|
+
if (!profileConfigured(profile)) {
|
|
132
|
+
throw configError("UNCONFIGURED_PROFILE", UNCONFIGURED_GUIDANCE);
|
|
133
|
+
}
|
|
134
|
+
return { kind: "profile", profileId: route.profileId };
|
|
135
|
+
}
|
|
136
|
+
throw configError("INVALID_ROUTE", `unknown route kind: ${JSON.stringify(route.kind)}`);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const bindings = c.bindings && typeof c.bindings === "object" ? c.bindings : {};
|
|
140
|
+
canonical.bindings = {
|
|
141
|
+
agent: normRoute(bindings.agent),
|
|
142
|
+
providers: Array.isArray(bindings.providers)
|
|
143
|
+
? bindings.providers
|
|
144
|
+
.filter((entry) => entry && typeof entry === "object" && typeof entry.provider === "string")
|
|
145
|
+
.map((entry) => ({ provider: validProviderId(entry.provider), route: normRoute(entry.route) }))
|
|
146
|
+
: [],
|
|
147
|
+
gateway: bindings.gateway == null ? null : normRoute(bindings.gateway),
|
|
148
|
+
gatewayPurposes: Array.isArray(bindings.gatewayPurposes)
|
|
149
|
+
? bindings.gatewayPurposes
|
|
150
|
+
.filter((entry) => entry && typeof entry === "object" && typeof entry.purpose === "string")
|
|
151
|
+
.map((entry) => ({ purpose: entry.purpose, route: normRoute(entry.route) }))
|
|
152
|
+
: [],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const gw = c.gateway && typeof c.gateway === "object" ? c.gateway : {};
|
|
156
|
+
canonical.gateway = {
|
|
157
|
+
enabled: gw.enabled === true,
|
|
158
|
+
port: Number.isInteger(gw.port) && gw.port >= 1 && gw.port <= 65535 ? gw.port : 17890,
|
|
159
|
+
dedicatedPurposePorts: gw.dedicatedPurposePorts === true,
|
|
160
|
+
purposes: Array.isArray(gw.purposes) ? gw.purposes.filter((x) => typeof x === "string") : [],
|
|
161
|
+
};
|
|
162
|
+
return canonical;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** canonical 序列化(JSON 美化)。 */
|
|
166
|
+
export function serializeCanonical(canonical) {
|
|
167
|
+
return JSON.stringify(canonical, null, 2);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 公开快照:仅 profile ids 与脱敏端点;凭据值永不出现(hasUsername/hasPassword)。 */
|
|
171
|
+
export function publicSnapshot(canonical) {
|
|
172
|
+
const c = canonical;
|
|
173
|
+
return {
|
|
174
|
+
version: c.version,
|
|
175
|
+
profiles: c.profiles.map((p) => ({
|
|
176
|
+
id: p.id,
|
|
177
|
+
configured: profileConfigured(p),
|
|
178
|
+
...(p.protocol ? { protocol: p.protocol } : {}),
|
|
179
|
+
...(p.host ? { host: p.host } : {}),
|
|
180
|
+
...(p.port !== undefined ? { port: p.port } : {}),
|
|
181
|
+
hasUsername: Boolean(p.username),
|
|
182
|
+
hasPassword: Boolean(p.password),
|
|
183
|
+
noProxy: [...(p.noProxy || [])],
|
|
184
|
+
timeout: p.timeout,
|
|
185
|
+
})),
|
|
186
|
+
bindings: {
|
|
187
|
+
agent: { ...c.bindings.agent },
|
|
188
|
+
providers: c.bindings.providers.map((entry) => ({ provider: entry.provider, route: { ...entry.route } })),
|
|
189
|
+
gateway: c.bindings.gateway ? { ...c.bindings.gateway } : null,
|
|
190
|
+
gatewayPurposes: c.bindings.gatewayPurposes.map((entry) => ({ purpose: entry.purpose, route: { ...entry.route } })),
|
|
191
|
+
},
|
|
192
|
+
gateway: { ...c.gateway },
|
|
193
|
+
};
|
|
194
|
+
}
|
package/lib/control.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// lib/control.js — serialized runtime transactions backed by official settings.
|
|
2
|
+
// Participant lifecycle remains prepare -> persist/compensate -> publish -> commit.
|
|
3
|
+
import { validateCanonical, serializeCanonical } from "./config.js";
|
|
4
|
+
|
|
5
|
+
function safe(fn, ...args) {
|
|
6
|
+
try {
|
|
7
|
+
const result = fn?.(...args);
|
|
8
|
+
return result && typeof result.then === "function" ? result.catch(() => {}) : result;
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ControlPlane {
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {object} options.canonical 已校验的初始 canonical
|
|
18
|
+
* @param {(next:object, previous:object, capture:(snapshot:object, revision?:number)=>void, expectedSettingsRevision?:number)=>Promise<object|void>} [options.persist] settings-backed write
|
|
19
|
+
* @param {(previousSection:object, previous:object, failed:object, expectedRevision?:number)=>Promise<void>} [options.compensate] settings-backed rollback
|
|
20
|
+
* @param {Array<object>} [options.participants] 运行时参与者
|
|
21
|
+
* @param {{info?:Function, warn?:Function}} [options.logger]
|
|
22
|
+
*/
|
|
23
|
+
constructor({ canonical, persist, compensate, participants = [], logger = console }) {
|
|
24
|
+
this.canonical = canonical;
|
|
25
|
+
this.persist = persist;
|
|
26
|
+
this.compensate = compensate;
|
|
27
|
+
this.revision = 0;
|
|
28
|
+
this.active = true;
|
|
29
|
+
this.participants = [...participants];
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
this.tail = Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
addParticipant(participant) {
|
|
35
|
+
this.participants.push(participant);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 串行化:任何任务都排在上一任务之后(失败不影响后续入队)。 */
|
|
39
|
+
enqueue(task) {
|
|
40
|
+
const run = this.tail.then(task, task);
|
|
41
|
+
this.tail = run.then(() => undefined, () => undefined);
|
|
42
|
+
return run;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run one validated runtime transaction. Settings writes happen after all
|
|
47
|
+
* participants prepare; a later failure is compensated through the same
|
|
48
|
+
* official settings scope before the error is returned.
|
|
49
|
+
* @param {object} options
|
|
50
|
+
* @param {object} options.candidate
|
|
51
|
+
* @param {boolean} [options.persist=true] write candidate through settings
|
|
52
|
+
* @param {boolean} [options.compensateOnFailure=false] restore persisted external edit
|
|
53
|
+
* @param {object} [options.compensation] raw settings user section to restore
|
|
54
|
+
* @param {number} [options.compensationRevision] raw settings revision expected for compensation
|
|
55
|
+
* @param {number} [options.expectedSettingsRevision] raw settings revision expected for this transition
|
|
56
|
+
* @param {(next:object, previous:object, capture:(snapshot:object, revision?:number)=>void, expectedSettingsRevision?:number)=>Promise<object|void>} options.persist
|
|
57
|
+
*/
|
|
58
|
+
async transition({ candidate, persist = true, compensateOnFailure = false, compensation, compensationRevision, expectedSettingsRevision }) {
|
|
59
|
+
if (!this.active) throw new Error("proxy control plane has been disposed");
|
|
60
|
+
const prev = this.canonical;
|
|
61
|
+
const prevRevision = this.revision;
|
|
62
|
+
let validated;
|
|
63
|
+
let persistenceSnapshot = compensation;
|
|
64
|
+
let persistenceRevision = compensationRevision;
|
|
65
|
+
const capturePersistenceSnapshot = (snapshot, revision) => {
|
|
66
|
+
if (snapshot !== undefined) persistenceSnapshot = structuredClone(snapshot);
|
|
67
|
+
if (Number.isInteger(revision)) persistenceRevision = revision;
|
|
68
|
+
};
|
|
69
|
+
const staged = [];
|
|
70
|
+
let persisted = false;
|
|
71
|
+
try {
|
|
72
|
+
validated = validateCanonical(candidate);
|
|
73
|
+
if (serializeCanonical(validated) === serializeCanonical(prev)) {
|
|
74
|
+
return { canonical: prev, revision: this.revision, unchanged: true };
|
|
75
|
+
}
|
|
76
|
+
for (const p of this.participants) {
|
|
77
|
+
const s = await p.prepare({ next: validated, prev, kind: "transition" });
|
|
78
|
+
staged.push({ p, s });
|
|
79
|
+
}
|
|
80
|
+
if (persist) {
|
|
81
|
+
if (typeof this.persist !== "function") {
|
|
82
|
+
const error = new Error("proxy-routing settings persistence is unavailable");
|
|
83
|
+
error.code = "SETTINGS_UNAVAILABLE";
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
persisted = true;
|
|
87
|
+
let returnedSnapshot;
|
|
88
|
+
try {
|
|
89
|
+
returnedSnapshot = await this.persist(validated, prev, capturePersistenceSnapshot, expectedSettingsRevision);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.settingsPersisted === false) persisted = false;
|
|
92
|
+
if (Number.isInteger(error?.settingsRevision)) persistenceRevision = error.settingsRevision;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
if (returnedSnapshot && typeof returnedSnapshot === "object"
|
|
96
|
+
&& Object.prototype.hasOwnProperty.call(returnedSnapshot, "compensation")) {
|
|
97
|
+
persistenceSnapshot = structuredClone(returnedSnapshot.compensation);
|
|
98
|
+
if (Number.isInteger(returnedSnapshot.revision)) persistenceRevision = returnedSnapshot.revision;
|
|
99
|
+
} else if (returnedSnapshot !== undefined) {
|
|
100
|
+
persistenceSnapshot = returnedSnapshot;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.canonical = validated;
|
|
104
|
+
this.revision += 1;
|
|
105
|
+
for (const { p, s } of staged) await p.commit(s); // 设计上不得抛错
|
|
106
|
+
for (const { p, s } of staged) await safe(p.finalize, s, { superseded: false });
|
|
107
|
+
return { canonical: validated, revision: this.revision };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
for (const { p, s } of staged) await safe(p.rollback, s);
|
|
110
|
+
let compensationFailure;
|
|
111
|
+
if (persisted || compensateOnFailure) {
|
|
112
|
+
if (typeof this.compensate !== "function") {
|
|
113
|
+
this.logger.warn?.("[proxy-routing] settings compensation is unavailable");
|
|
114
|
+
compensationFailure = new Error("Failed to switch proxy configuration; persisted rollback was incomplete");
|
|
115
|
+
compensationFailure.code = "PROXY_SETTINGS_COMPENSATION_FAILED";
|
|
116
|
+
} else {
|
|
117
|
+
try {
|
|
118
|
+
await this.compensate(persistenceSnapshot ?? prev, prev, validated, persistenceRevision);
|
|
119
|
+
} catch (compensationError) {
|
|
120
|
+
this.logger.warn?.("[proxy-routing] 配置回滚不完整:未能恢复 settings namespace");
|
|
121
|
+
compensationFailure = new Error("Failed to switch proxy configuration; persisted rollback was incomplete");
|
|
122
|
+
compensationFailure.code = "PROXY_SETTINGS_COMPENSATION_FAILED";
|
|
123
|
+
compensationFailure.cause = compensationError;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
this.canonical = prev;
|
|
128
|
+
this.revision = prevRevision;
|
|
129
|
+
if (compensationFailure) throw compensationFailure;
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 工具/Web 写路径:CAS + 可持久化。
|
|
136
|
+
* @param {object} options
|
|
137
|
+
* @param {number} [options.expectedRevision] runtime revision for the current control plane
|
|
138
|
+
* @param {number} [options.expectedSettingsRevision] raw settings revision for the persistence CAS
|
|
139
|
+
* @param {(canonical:object)=>object} options.apply returns a candidate canonical (or throws validation error)
|
|
140
|
+
* @param {boolean} [options.persist=true] 是否写文件
|
|
141
|
+
*/
|
|
142
|
+
mutate({ expectedRevision, expectedSettingsRevision, apply, persist = true }) {
|
|
143
|
+
if (this.disposing || !this.active) {
|
|
144
|
+
return Promise.reject(new Error("proxy control plane has been disposed"));
|
|
145
|
+
}
|
|
146
|
+
return this.enqueue(async () => {
|
|
147
|
+
if (!this.active) throw new Error("proxy control plane has been disposed");
|
|
148
|
+
if (expectedRevision !== undefined && expectedRevision !== this.revision) {
|
|
149
|
+
throw new Error(`stale revision: expected ${expectedRevision}, current ${this.revision}`);
|
|
150
|
+
}
|
|
151
|
+
const candidate = validateCanonical(apply(this.canonical));
|
|
152
|
+
return this.transition({ candidate, persist, expectedSettingsRevision });
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** External settings update: the provider already persisted it; apply once. */
|
|
157
|
+
reloadSettings({ next, previous, previousRaw, previousRawRevision }) {
|
|
158
|
+
if (this.disposing || !this.active) {
|
|
159
|
+
return Promise.reject(new Error("proxy control plane has been disposed"));
|
|
160
|
+
}
|
|
161
|
+
return this.enqueue(async () => {
|
|
162
|
+
if (!this.active) throw new Error("proxy control plane has been disposed");
|
|
163
|
+
try {
|
|
164
|
+
return await this.transition({
|
|
165
|
+
candidate: next,
|
|
166
|
+
persist: false,
|
|
167
|
+
compensateOnFailure: true,
|
|
168
|
+
compensation: previousRaw,
|
|
169
|
+
compensationRevision: previousRawRevision,
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// The raw previous section is used for compensation; the resolved
|
|
173
|
+
// previous value remains a diagnostic fallback for lightweight callers.
|
|
174
|
+
if (previous && serializeCanonical(previous) !== serializeCanonical(this.canonical)) {
|
|
175
|
+
this.logger.warn?.("[proxy-routing] settings watcher previous value differs from runtime snapshot");
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 异步销毁:拒绝新变更 → 排空队列 → 参与者 dispose(幂等)。 */
|
|
183
|
+
async dispose() {
|
|
184
|
+
if (this.disposing || !this.active) return;
|
|
185
|
+
this.disposing = true;
|
|
186
|
+
await this.tail.catch(() => {});
|
|
187
|
+
this.active = false;
|
|
188
|
+
for (const p of this.participants) await safe(p.dispose);
|
|
189
|
+
this.participants = [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
status() {
|
|
193
|
+
return {
|
|
194
|
+
active: this.active,
|
|
195
|
+
revision: this.revision,
|
|
196
|
+
participants: this.participants.map((p) => p.name).filter(Boolean),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Local proxy discovery for first-use onboarding.
|
|
2
|
+
// Discovery is deliberately bounded: environment proxy URLs plus a small set
|
|
3
|
+
// of loopback ports. It reports candidates only; it never persists or enables.
|
|
4
|
+
import { URL } from "node:url";
|
|
5
|
+
import { probeProxy } from "./probe.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_DISCOVERY_PORTS = [7890, 7897, 1080, 10808, 10809];
|
|
8
|
+
const PROXY_ENV_KEYS = [
|
|
9
|
+
"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function endpointKey(endpoint) {
|
|
13
|
+
return `${endpoint.protocol}://${endpoint.host}:${endpoint.port}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseProxyUrl(raw) {
|
|
17
|
+
if (typeof raw !== "string" || raw.trim() === "") return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(raw.trim());
|
|
20
|
+
const protocol = url.protocol.replace(":", "").toLowerCase();
|
|
21
|
+
if (protocol !== "http" && protocol !== "socks5") return undefined;
|
|
22
|
+
const port = Number(url.port || (protocol === "http" ? 80 : 1080));
|
|
23
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || url.hostname === "") return undefined;
|
|
24
|
+
return {
|
|
25
|
+
protocol,
|
|
26
|
+
host: url.hostname,
|
|
27
|
+
port,
|
|
28
|
+
...(url.username ? { username: decodeURIComponent(url.username) } : {}),
|
|
29
|
+
...(url.password ? { password: decodeURIComponent(url.password) } : {}),
|
|
30
|
+
};
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function envCandidates(env) {
|
|
37
|
+
const candidates = [];
|
|
38
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
39
|
+
const endpoint = parseProxyUrl(env?.[key]);
|
|
40
|
+
if (endpoint) candidates.push({ endpoint, source: `env:${key}` });
|
|
41
|
+
}
|
|
42
|
+
return candidates;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Probe bounded candidates in parallel. Credentials stay inside the probe call
|
|
47
|
+
* and are never returned to callers.
|
|
48
|
+
* @param {{target?: string, timeout?: number, ports?: number[], env?: object, signal?: AbortSignal}} options
|
|
49
|
+
*/
|
|
50
|
+
export async function discoverProxies({
|
|
51
|
+
target,
|
|
52
|
+
timeout = 1500,
|
|
53
|
+
ports = DEFAULT_DISCOVERY_PORTS,
|
|
54
|
+
env = process.env,
|
|
55
|
+
signal,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const candidates = envCandidates(env);
|
|
58
|
+
const seen = new Set(candidates.map(({ endpoint }) => endpointKey(endpoint)));
|
|
59
|
+
for (const rawPort of ports) {
|
|
60
|
+
if (!Number.isInteger(rawPort) || rawPort < 1 || rawPort > 65535) continue;
|
|
61
|
+
for (const protocol of ["http", "socks5"]) {
|
|
62
|
+
const endpoint = { protocol, host: "127.0.0.1", port: rawPort };
|
|
63
|
+
const key = endpointKey(endpoint);
|
|
64
|
+
if (seen.has(key)) continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
candidates.push({ endpoint, source: "loopback-common-port" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const results = await Promise.all(candidates.map(async ({ endpoint, source }) => {
|
|
71
|
+
const result = await probeProxy(endpoint, { target, timeout, signal });
|
|
72
|
+
return {
|
|
73
|
+
source,
|
|
74
|
+
protocol: endpoint.protocol,
|
|
75
|
+
host: endpoint.host,
|
|
76
|
+
port: endpoint.port,
|
|
77
|
+
ok: result.ok,
|
|
78
|
+
connectMs: result.connectMs,
|
|
79
|
+
totalMs: result.totalMs,
|
|
80
|
+
...(result.httpStatus >= 0 ? { httpStatus: result.httpStatus } : {}),
|
|
81
|
+
...(result.error ? { error: result.error } : {}),
|
|
82
|
+
};
|
|
83
|
+
}));
|
|
84
|
+
return {
|
|
85
|
+
scanned: results.length,
|
|
86
|
+
candidates: results.filter((result) => result.ok),
|
|
87
|
+
results,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// lib/fetch-router.js — 唯一稳定的全局 fetch 路由器。
|
|
2
|
+
// 进程级 symbol registry 保证跨重载不递归包装;owner 注册路由解析器,
|
|
3
|
+
// 卸载只移除本 owner;最后一个 owner 仅在 globalThis.fetch 仍是本 wrapper
|
|
4
|
+
// 时恢复先前 fetch(不覆盖后来者安装的外部 wrapper)。
|
|
5
|
+
import { proxiedFetch } from "./proxy/request.js";
|
|
6
|
+
import { providerStore } from "./als.js";
|
|
7
|
+
|
|
8
|
+
const FETCH_REGISTRY = Symbol.for("dsh-proxy-routing.fetch-registry");
|
|
9
|
+
|
|
10
|
+
function registry() {
|
|
11
|
+
let reg = globalThis[FETCH_REGISTRY];
|
|
12
|
+
if (!reg) {
|
|
13
|
+
reg = { owners: new Map(), wrapped: false, previousFetch: undefined, wrapper: undefined };
|
|
14
|
+
Object.defineProperty(globalThis, FETCH_REGISTRY, { value: reg, configurable: true, writable: true });
|
|
15
|
+
}
|
|
16
|
+
return reg;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dispatch(reg, input, init) {
|
|
20
|
+
const ctx = providerStore.getStore();
|
|
21
|
+
let route;
|
|
22
|
+
if (ctx && ctx.route) {
|
|
23
|
+
route = ctx.route; // provider 上下文:不可变快照
|
|
24
|
+
} else {
|
|
25
|
+
const last = [...reg.owners.values()].at(-1);
|
|
26
|
+
route = last ? last.getAgentRoute() : Object.freeze({ kind: "direct" });
|
|
27
|
+
}
|
|
28
|
+
if (route.kind === "direct") return reg.previousFetch(input, init);
|
|
29
|
+
return proxiedFetch(input, init, route.profile, reg.previousFetch);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class FetchRouter {
|
|
33
|
+
/** @param {{getAgentRoute:()=>object}} deps 返回当前 Agent 路由快照(读取时原子) */
|
|
34
|
+
constructor({ getAgentRoute }) {
|
|
35
|
+
this.name = "fetch-router";
|
|
36
|
+
this.token = Symbol("fetch-router-owner");
|
|
37
|
+
this.getAgentRoute = getAgentRoute;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
install() {
|
|
41
|
+
const reg = registry();
|
|
42
|
+
if (reg.owners.has(this.token)) return;
|
|
43
|
+
reg.owners.set(this.token, { getAgentRoute: this.getAgentRoute });
|
|
44
|
+
if (!reg.wrapped) {
|
|
45
|
+
reg.wrapped = true;
|
|
46
|
+
reg.previousFetch = globalThis.fetch;
|
|
47
|
+
reg.wrapper = (input, init) => dispatch(reg, input, init);
|
|
48
|
+
globalThis.fetch = reg.wrapper;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
uninstall() {
|
|
53
|
+
const reg = registry();
|
|
54
|
+
reg.owners.delete(this.token);
|
|
55
|
+
if (reg.owners.size === 0 && reg.wrapped && globalThis.fetch === reg.wrapper) {
|
|
56
|
+
globalThis.fetch = reg.previousFetch;
|
|
57
|
+
reg.wrapped = false;
|
|
58
|
+
reg.previousFetch = undefined;
|
|
59
|
+
reg.wrapper = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ownerCount() {
|
|
64
|
+
return registry().owners.size;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
owned() {
|
|
68
|
+
return registry().wrapped;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ControlPlane 参与者协议:路由在派发时读取,无需预取资源。
|
|
72
|
+
prepare() { return {}; }
|
|
73
|
+
commit() {}
|
|
74
|
+
finalize() {}
|
|
75
|
+
rollback() {}
|
|
76
|
+
dispose() { this.uninstall(); }
|
|
77
|
+
}
|