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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// lib/proxy/stream.js — ByteStream:socket 单泵字节流 + 行/精确字节的 pull 解析
|
|
2
|
+
//
|
|
3
|
+
// 关键不变量:socket 上只挂一套 data/end/error 监听,数据进入内部队列,
|
|
4
|
+
// 解析方用 `readLine()` / `readExactly(n)` 按需拉取;不足时挂起等待。
|
|
5
|
+
// 超时语义:空闲超时(每次收到数据重置),而非一次性绝对生命周期——
|
|
6
|
+
// 大下载只要持续有数据就不会被误杀。
|
|
7
|
+
// 背压:内部缓冲超过高水位时 pause socket,消耗到低水位以下再 resume。
|
|
8
|
+
import { proxyError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
/** 单行头上限 64KB;超过视为畸形响应。 */
|
|
11
|
+
export const MAX_LINE = 64 * 1024;
|
|
12
|
+
/** 内部缓冲高水位(超过则 pause socket)。 */
|
|
13
|
+
export const HIGH_WATER = 1024 * 1024;
|
|
14
|
+
/** 低水位(缓冲降到该值以下 resume)。 */
|
|
15
|
+
export const LOW_WATER = HIGH_WATER / 2;
|
|
16
|
+
|
|
17
|
+
export class ByteStream {
|
|
18
|
+
/**
|
|
19
|
+
* @param {import("node:net").Socket} sock
|
|
20
|
+
* @param {{idleMs?:number, maxBuffered?:number}|number} [opts] number 兼容旧签名(视为 idleMs)
|
|
21
|
+
*/
|
|
22
|
+
constructor(sock, opts = {}) {
|
|
23
|
+
const options = typeof opts === "number" ? { idleMs: opts } : opts;
|
|
24
|
+
this.sock = sock;
|
|
25
|
+
this.buf = Buffer.alloc(0);
|
|
26
|
+
this.waiters = []; // [{ type:'line'|'bytes'|'data', n?, resolve, reject }]
|
|
27
|
+
this.ended = false;
|
|
28
|
+
this.error = null;
|
|
29
|
+
this.detached = false;
|
|
30
|
+
this.idleMs = options.idleMs ?? 0;
|
|
31
|
+
this.maxBuffered = options.maxBuffered ?? HIGH_WATER;
|
|
32
|
+
this.paused = false;
|
|
33
|
+
|
|
34
|
+
sock.on("data", (d) => this._push(d));
|
|
35
|
+
sock.on("end", () => this._finish());
|
|
36
|
+
sock.on("error", (e) => this._fail(e));
|
|
37
|
+
this._armTimer();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_armTimer() {
|
|
41
|
+
this._clearTimer();
|
|
42
|
+
if (this.idleMs > 0 && !this.detached && !this.ended) {
|
|
43
|
+
this.timer = setTimeout(() => {
|
|
44
|
+
this.timer = null;
|
|
45
|
+
this._fail(proxyError("ETIMEOUT", "proxy read idle timeout"));
|
|
46
|
+
}, this.idleMs);
|
|
47
|
+
this.timer.unref?.();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_push(d) {
|
|
52
|
+
if (this.detached) return;
|
|
53
|
+
this.buf = this.buf.length === 0 ? d : Buffer.concat([this.buf, d]);
|
|
54
|
+
this._armTimer();
|
|
55
|
+
// 背压:超过高水位暂停 socket 输入
|
|
56
|
+
if (!this.paused && this.buf.length > this.maxBuffered) {
|
|
57
|
+
this.paused = true;
|
|
58
|
+
try { this.sock.pause(); } catch {}
|
|
59
|
+
}
|
|
60
|
+
this._drain();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_finish() {
|
|
64
|
+
if (this.detached) return;
|
|
65
|
+
this.ended = true;
|
|
66
|
+
this._clearTimer();
|
|
67
|
+
this._drain();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_fail(e) {
|
|
71
|
+
if (this.detached) return;
|
|
72
|
+
this.error = e;
|
|
73
|
+
this.ended = true;
|
|
74
|
+
this._clearTimer();
|
|
75
|
+
this._drain();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_clearTimer() {
|
|
79
|
+
if (this.timer) {
|
|
80
|
+
clearTimeout(this.timer);
|
|
81
|
+
this.timer = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_maybeResume() {
|
|
86
|
+
if (this.paused && this.buf.length <= LOW_WATER) {
|
|
87
|
+
this.paused = false;
|
|
88
|
+
try { this.sock.resume(); } catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_drain() {
|
|
93
|
+
while (this.waiters.length > 0) {
|
|
94
|
+
const w = this.waiters[0];
|
|
95
|
+
if (w.type === "data") {
|
|
96
|
+
if (this.buf.length > 0) {
|
|
97
|
+
this.waiters.shift();
|
|
98
|
+
const out = this.buf;
|
|
99
|
+
this.buf = Buffer.alloc(0);
|
|
100
|
+
this._maybeResume();
|
|
101
|
+
w.resolve(out);
|
|
102
|
+
} else if (this.ended) {
|
|
103
|
+
this.waiters.shift();
|
|
104
|
+
if (this.error) w.reject(this.error);
|
|
105
|
+
else w.resolve(null);
|
|
106
|
+
} else return;
|
|
107
|
+
} else if (w.type === "line") {
|
|
108
|
+
const idx = this.buf.indexOf(0x0a); // '\n'
|
|
109
|
+
if (idx === -1) {
|
|
110
|
+
if (this.buf.length > MAX_LINE) {
|
|
111
|
+
this.waiters.shift();
|
|
112
|
+
w.reject(proxyError("ELINE", "response header line too long"));
|
|
113
|
+
} else if (this.ended) {
|
|
114
|
+
this.waiters.shift();
|
|
115
|
+
w.reject(this.error ?? proxyError("EEOF", "connection closed before line terminator"));
|
|
116
|
+
} else return;
|
|
117
|
+
} else {
|
|
118
|
+
this.waiters.shift();
|
|
119
|
+
let line = this.buf.subarray(0, idx);
|
|
120
|
+
this.buf = this.buf.subarray(idx + 1);
|
|
121
|
+
if (line.length > 0 && line[line.length - 1] === 0x0d) line = line.subarray(0, line.length - 1); // 剥 \r
|
|
122
|
+
this._maybeResume();
|
|
123
|
+
w.resolve(line.toString("latin1"));
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
if (this.buf.length >= w.n) {
|
|
127
|
+
this.waiters.shift();
|
|
128
|
+
const out = this.buf.subarray(0, w.n);
|
|
129
|
+
this.buf = this.buf.subarray(w.n);
|
|
130
|
+
this._maybeResume();
|
|
131
|
+
w.resolve(out);
|
|
132
|
+
} else if (this.ended) {
|
|
133
|
+
this.waiters.shift();
|
|
134
|
+
w.reject(this.error ?? proxyError("EEOF", `connection closed before ${w.n} bytes were available`));
|
|
135
|
+
} else return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 读取一行(不含换行符;\r\n 与 \n 均可)。 */
|
|
141
|
+
readLine() {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
this.waiters.push({ type: "line", resolve, reject });
|
|
144
|
+
this._drain();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 精确读取 n 字节。 */
|
|
149
|
+
readExactly(n) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
this.waiters.push({ type: "bytes", n, resolve, reject });
|
|
152
|
+
this._drain();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 取下一段可用数据(可能小于请求量);流结束返回 null。 */
|
|
157
|
+
nextData() {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
this.waiters.push({ type: "data", resolve, reject });
|
|
160
|
+
this._drain();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 取走内部剩余缓冲(供调用方回灌 socket,如 CONNECT 早字节)。 */
|
|
165
|
+
takeBuf() {
|
|
166
|
+
const out = this.buf;
|
|
167
|
+
this.buf = Buffer.alloc(0);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 停止接收后续数据(socket 仍归调用方管理)。 */
|
|
172
|
+
detach() {
|
|
173
|
+
this.detached = true;
|
|
174
|
+
this._clearTimer();
|
|
175
|
+
for (const w of this.waiters.splice(0)) {
|
|
176
|
+
w.reject(proxyError("EDETACHED", "stream detached"));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/lib/proxy-env.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// lib/proxy-env.js — standard proxy environment ownership for future child processes
|
|
2
|
+
|
|
3
|
+
const PROXY_KEYS = [
|
|
4
|
+
"HTTP_PROXY",
|
|
5
|
+
"HTTPS_PROXY",
|
|
6
|
+
"ALL_PROXY",
|
|
7
|
+
"NO_PROXY",
|
|
8
|
+
"http_proxy",
|
|
9
|
+
"https_proxy",
|
|
10
|
+
"all_proxy",
|
|
11
|
+
"no_proxy",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const URL_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
15
|
+
const NO_PROXY_KEYS = ["NO_PROXY", "no_proxy"];
|
|
16
|
+
const LOCAL_BYPASS = ["127.0.0.1", "localhost", "::1"];
|
|
17
|
+
|
|
18
|
+
export const PROXY_ENV_KEYS = Object.freeze([...PROXY_KEYS]);
|
|
19
|
+
export const SUBPROCESS_INHERITANCE_NOTE = "仅后续新启动的普通 Bash/下载进程继承;已运行后台任务和 persistent bash 保留旧环境,需重启或重置后生效";
|
|
20
|
+
|
|
21
|
+
function hasOwn(env, key) {
|
|
22
|
+
return Object.prototype.hasOwnProperty.call(env, key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function capture(env) {
|
|
26
|
+
const values = {};
|
|
27
|
+
for (const key of PROXY_KEYS) {
|
|
28
|
+
values[key] = hasOwn(env, key)
|
|
29
|
+
? { present: true, value: env[key] }
|
|
30
|
+
: { present: false, value: undefined };
|
|
31
|
+
}
|
|
32
|
+
return values;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cloneSnapshot(snapshot) {
|
|
36
|
+
if (!snapshot) return null;
|
|
37
|
+
return Object.fromEntries(PROXY_KEYS.map((key) => [key, { ...snapshot[key] }]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function restoreValues(env, values) {
|
|
41
|
+
for (const key of PROXY_KEYS) {
|
|
42
|
+
const entry = values[key];
|
|
43
|
+
if (entry.present) env[key] = entry.value;
|
|
44
|
+
else delete env[key];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function applyValues(env, values) {
|
|
49
|
+
const before = capture(env);
|
|
50
|
+
try {
|
|
51
|
+
restoreValues(env, values);
|
|
52
|
+
} catch {
|
|
53
|
+
try {
|
|
54
|
+
restoreValues(env, before);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error("Failed to update subprocess proxy environment; rollback was incomplete");
|
|
57
|
+
}
|
|
58
|
+
throw new Error("Failed to update subprocess proxy environment; previous state was restored");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bracketHost(host) {
|
|
63
|
+
const value = String(host || "127.0.0.1");
|
|
64
|
+
return value.includes(":") && !value.startsWith("[") ? `[${value}]` : value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function proxyUrl(cfg, { includeAuth = true } = {}) {
|
|
68
|
+
const scheme = cfg.protocol === "socks5" ? "socks5" : "http";
|
|
69
|
+
const url = new URL(`${scheme}://${bracketHost(cfg.host)}:${cfg.port || 7890}`);
|
|
70
|
+
if (includeAuth) {
|
|
71
|
+
if (cfg.username) url.username = cfg.username;
|
|
72
|
+
if (cfg.password) url.password = cfg.password;
|
|
73
|
+
}
|
|
74
|
+
return url.href;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function mergeNoProxy(original, configured = []) {
|
|
78
|
+
const merged = [];
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const add = (raw) => {
|
|
81
|
+
for (const part of String(raw ?? "").split(",")) {
|
|
82
|
+
const value = part.trim();
|
|
83
|
+
const key = value.toLowerCase();
|
|
84
|
+
if (!value || seen.has(key)) continue;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
merged.push(value);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const key of NO_PROXY_KEYS) {
|
|
91
|
+
const entry = original?.[key];
|
|
92
|
+
if (entry?.present) add(entry.value);
|
|
93
|
+
}
|
|
94
|
+
for (const value of configured) add(value);
|
|
95
|
+
for (const value of LOCAL_BYPASS) add(value);
|
|
96
|
+
return merged;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** mergeNoProxy 的原始 env 形态便捷入口(key → string,非 snapshot 条目)。 */
|
|
100
|
+
export function mergeNoProxyEnv(env = {}, configured = []) {
|
|
101
|
+
const entries = {};
|
|
102
|
+
for (const key of NO_PROXY_KEYS) {
|
|
103
|
+
entries[key] = { present: hasOwn(env, key), value: env[key] };
|
|
104
|
+
}
|
|
105
|
+
return mergeNoProxy(entries, configured);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function desiredValues(cfg, original) {
|
|
109
|
+
const url = proxyUrl(cfg);
|
|
110
|
+
const noProxy = mergeNoProxy(original, Array.isArray(cfg.noProxy) ? cfg.noProxy : []);
|
|
111
|
+
const values = {};
|
|
112
|
+
for (const key of URL_KEYS) values[key] = { present: true, value: url };
|
|
113
|
+
for (const key of NO_PROXY_KEYS) values[key] = { present: true, value: noProxy.join(",") };
|
|
114
|
+
return { values, noProxy };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class ProxyEnvironmentController {
|
|
118
|
+
constructor(env = process.env) {
|
|
119
|
+
this.env = env;
|
|
120
|
+
this.original = null;
|
|
121
|
+
this.active = false;
|
|
122
|
+
this.currentProxyUrl = "";
|
|
123
|
+
this.currentNoProxy = [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
checkpoint() {
|
|
127
|
+
return {
|
|
128
|
+
values: capture(this.env),
|
|
129
|
+
original: cloneSnapshot(this.original),
|
|
130
|
+
active: this.active,
|
|
131
|
+
currentProxyUrl: this.currentProxyUrl,
|
|
132
|
+
currentNoProxy: [...this.currentNoProxy],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
restoreCheckpoint(checkpoint) {
|
|
137
|
+
applyValues(this.env, checkpoint.values);
|
|
138
|
+
this.original = cloneSnapshot(checkpoint.original);
|
|
139
|
+
this.active = checkpoint.active;
|
|
140
|
+
this.currentProxyUrl = checkpoint.currentProxyUrl;
|
|
141
|
+
this.currentNoProxy = [...checkpoint.currentNoProxy];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
enable(cfg) {
|
|
145
|
+
const original = this.active ? this.original : capture(this.env);
|
|
146
|
+
const desired = desiredValues(cfg, original);
|
|
147
|
+
applyValues(this.env, desired.values);
|
|
148
|
+
this.original = original;
|
|
149
|
+
this.active = true;
|
|
150
|
+
this.currentProxyUrl = proxyUrl(cfg, { includeAuth: false });
|
|
151
|
+
this.currentNoProxy = desired.noProxy;
|
|
152
|
+
return this.status();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
disable() {
|
|
156
|
+
if (!this.active) return this.status();
|
|
157
|
+
applyValues(this.env, this.original);
|
|
158
|
+
this.original = null;
|
|
159
|
+
this.active = false;
|
|
160
|
+
this.currentProxyUrl = "";
|
|
161
|
+
this.currentNoProxy = [];
|
|
162
|
+
return this.status();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
status() {
|
|
166
|
+
return {
|
|
167
|
+
enabled: this.active,
|
|
168
|
+
proxyUrl: this.currentProxyUrl,
|
|
169
|
+
noProxy: [...this.currentNoProxy],
|
|
170
|
+
inheritance: SUBPROCESS_INHERITANCE_NOTE,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// lib/routes.js — 路由解析:把 canonical bindings 解析为深冻结的
|
|
2
|
+
// `direct` / `profile` 路由快照。provider 覆盖缺失时继承 Agent 路由;
|
|
3
|
+
// 显式 direct 永不随 Agent 路由变化而继承。快照不可变,供 fetch/shell 路由使用。
|
|
4
|
+
import { DEFAULT_PROFILE_ID, profileConfigured, UNCONFIGURED_GUIDANCE } from "./config.js";
|
|
5
|
+
|
|
6
|
+
export function deepFreeze(value) {
|
|
7
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
8
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
9
|
+
Object.freeze(value);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function routeError(code, message) {
|
|
15
|
+
const error = new Error(message);
|
|
16
|
+
error.code = code;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 把一条 binding route 物化为不可变快照;profile 快照内嵌脱敏前完整 profile(含凭据,仅内部使用)。 */
|
|
21
|
+
export function routeSnapshot(canonical, route) {
|
|
22
|
+
if (!route || route.kind === "direct") return Object.freeze({ kind: "direct" });
|
|
23
|
+
if (route.kind !== "profile") throw routeError("INVALID_ROUTE", `unknown route kind: ${JSON.stringify(route.kind)}`);
|
|
24
|
+
const profile = canonical.profiles.find((p) => p.id === route.profileId);
|
|
25
|
+
if (!profile) throw routeError("MISSING_PROFILE", `profile route references missing profile "${route.profileId}"`);
|
|
26
|
+
if (!profileConfigured(profile)) throw routeError("UNCONFIGURED_PROFILE", UNCONFIGURED_GUIDANCE);
|
|
27
|
+
return deepFreeze({
|
|
28
|
+
kind: "profile",
|
|
29
|
+
profileId: profile.id,
|
|
30
|
+
profile: {
|
|
31
|
+
protocol: profile.protocol,
|
|
32
|
+
host: profile.host,
|
|
33
|
+
port: profile.port,
|
|
34
|
+
username: profile.username || undefined,
|
|
35
|
+
password: profile.password || undefined,
|
|
36
|
+
noProxy: [...(profile.noProxy || [])],
|
|
37
|
+
timeout: profile.timeout,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Agent 路由快照。 */
|
|
43
|
+
export function resolveAgentRoute(canonical) {
|
|
44
|
+
return routeSnapshot(canonical, canonical.bindings.agent);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Provider 路由快照:有显式覆盖用覆盖(多条取最后一条),否则继承 Agent。 */
|
|
48
|
+
export function resolveProviderRoute(canonical, providerId) {
|
|
49
|
+
if (typeof providerId !== "string") return resolveAgentRoute(canonical);
|
|
50
|
+
let override = null;
|
|
51
|
+
for (const entry of canonical.bindings.providers) {
|
|
52
|
+
if (entry.provider === providerId) override = entry.route;
|
|
53
|
+
}
|
|
54
|
+
if (override) return routeSnapshot(canonical, override);
|
|
55
|
+
return resolveAgentRoute(canonical);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Gateway 路由快照:null 绑定继承 Agent。 */
|
|
59
|
+
export function resolveGatewayRoute(canonical) {
|
|
60
|
+
if (canonical.bindings.gateway == null) return resolveAgentRoute(canonical);
|
|
61
|
+
return routeSnapshot(canonical, canonical.bindings.gateway);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Purpose 路由快照:缺失 purpose 绑定继承 gateway(gateway 又继承 Agent)。 */
|
|
65
|
+
export function resolvePurposeRoute(canonical, purposeId) {
|
|
66
|
+
if (typeof purposeId === "string") {
|
|
67
|
+
for (const entry of canonical.bindings.gatewayPurposes) {
|
|
68
|
+
if (entry.purpose === purposeId) return routeSnapshot(canonical, entry.route);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return resolveGatewayRoute(canonical);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 便捷:默认 profile id(契约锚点)。 */
|
|
75
|
+
export { DEFAULT_PROFILE_ID };
|
package/lib/rpc.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// lib/rpc.js — loopback Connection RPC for the browser Settings diagnostics.
|
|
2
|
+
import { discoverProxies } from "./discovery.js";
|
|
3
|
+
|
|
4
|
+
const CHANNEL = "/proxy-agent";
|
|
5
|
+
const DEFAULT_TIMEOUT = 1500;
|
|
6
|
+
const MAX_HOST_LENGTH = 255;
|
|
7
|
+
|
|
8
|
+
function failure(code, message, details = {}) {
|
|
9
|
+
return { ok: false, error: { code, message, details } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function endpointOf(value) {
|
|
17
|
+
if (!isObject(value)) throw new Error("proxy endpoint must be an object");
|
|
18
|
+
if (value.protocol !== "http" && value.protocol !== "socks5") throw new Error("protocol must be http or socks5");
|
|
19
|
+
if (typeof value.host !== "string" || value.host.trim() === "" || value.host.length > MAX_HOST_LENGTH) throw new Error("host must be a non-empty string");
|
|
20
|
+
if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535) throw new Error("port must be an integer between 1 and 65535");
|
|
21
|
+
return { protocol: value.protocol, host: value.host.trim(), port: value.port };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function publicProbe(result) {
|
|
25
|
+
return {
|
|
26
|
+
ok: result.ok,
|
|
27
|
+
connectMs: result.connectMs,
|
|
28
|
+
totalMs: result.totalMs,
|
|
29
|
+
httpStatus: result.httpStatus,
|
|
30
|
+
...(result.error ? { error: String(result.error).replace(/https?:\/\/[^\s]+/gi, "target") } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mutationOf(value) {
|
|
35
|
+
if (!isObject(value)) throw new Error("proxy mutation must be an object")
|
|
36
|
+
const hasEndpoint = Object.prototype.hasOwnProperty.call(value, "endpoint")
|
|
37
|
+
const hasAgent = Object.prototype.hasOwnProperty.call(value, "agent")
|
|
38
|
+
if (!hasEndpoint && !hasAgent) throw new Error("proxy mutation must include endpoint or agent")
|
|
39
|
+
const result = {}
|
|
40
|
+
if (hasEndpoint) result.endpoint = endpointOf(value.endpoint)
|
|
41
|
+
if (hasAgent) {
|
|
42
|
+
if (!isObject(value.agent)) throw new Error("agent route must be an object")
|
|
43
|
+
if (value.agent.kind === "direct") result.agent = { kind: "direct" }
|
|
44
|
+
else if (value.agent.kind === "profile" && value.agent.profileId === "default") result.agent = { kind: "profile", profileId: "default" }
|
|
45
|
+
else throw new Error("agent route must be direct or the default profile")
|
|
46
|
+
}
|
|
47
|
+
if (value.expectedRevision !== undefined
|
|
48
|
+
&& (!Number.isInteger(value.expectedRevision) || value.expectedRevision < 0)) {
|
|
49
|
+
throw new Error("expectedRevision must be a non-negative integer")
|
|
50
|
+
}
|
|
51
|
+
result.expectedRevision = value.expectedRevision
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function applyMutation(canonical, mutation) {
|
|
56
|
+
const next = structuredClone(canonical)
|
|
57
|
+
if (mutation.endpoint !== undefined) {
|
|
58
|
+
const index = next.profiles.findIndex((profile) => profile.id === "default")
|
|
59
|
+
if (index < 0) throw new Error("default proxy profile is unavailable")
|
|
60
|
+
next.profiles[index] = { ...next.profiles[index], ...mutation.endpoint }
|
|
61
|
+
}
|
|
62
|
+
if (mutation.agent !== undefined) next.bindings = { ...next.bindings, agent: mutation.agent }
|
|
63
|
+
return next
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function dispatch(control, endpoint, payload, signal) {
|
|
67
|
+
if (endpoint === "discover") {
|
|
68
|
+
if (payload !== undefined && (!isObject(payload) || Object.keys(payload).length !== 0)) throw new Error("discover accepts an empty payload");
|
|
69
|
+
const found = await discoverProxies({ timeout: DEFAULT_TIMEOUT, signal });
|
|
70
|
+
return {
|
|
71
|
+
scanned: found.scanned,
|
|
72
|
+
candidates: found.candidates,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (endpoint === "probe") {
|
|
76
|
+
const proxy = endpointOf(payload);
|
|
77
|
+
return publicProbe(await control.probe(proxy, { signal }));
|
|
78
|
+
}
|
|
79
|
+
if (endpoint === "mutate") {
|
|
80
|
+
const mutation = mutationOf(payload)
|
|
81
|
+
const result = await control.mutate({
|
|
82
|
+
expectedSettingsRevision: mutation.expectedRevision,
|
|
83
|
+
apply: (canonical) => applyMutation(canonical, mutation),
|
|
84
|
+
})
|
|
85
|
+
const settingsRevision = control.getSettingsRevision?.()
|
|
86
|
+
return {
|
|
87
|
+
runtimeRevision: result.revision,
|
|
88
|
+
...(Number.isInteger(settingsRevision) ? { settingsRevision } : {}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw Object.assign(new Error(`unknown proxy endpoint: ${endpoint}`), { code: "UNKNOWN_ENDPOINT" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Register the browser diagnostics channel only when Web Connection exists. */
|
|
95
|
+
export function installProxyRpc(ctx, control) {
|
|
96
|
+
if (typeof ctx.inject !== "function") return;
|
|
97
|
+
ctx.inject(["connection"], (connectionCtx) => {
|
|
98
|
+
connectionCtx.connection.rpc.handle(CHANNEL, async (endpoint, payload, signal) => {
|
|
99
|
+
try {
|
|
100
|
+
return { ok: true, value: await dispatch(control, endpoint, payload, signal) };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.name === "AbortError" || error?.code === "ABORT_ERR") return failure("cancelled", "proxy diagnostic was cancelled");
|
|
103
|
+
if (error?.code === "UNKNOWN_ENDPOINT") return failure("not-found", "proxy diagnostic endpoint was not found");
|
|
104
|
+
return failure("bad-request", error instanceof Error ? error.message : String(error));
|
|
105
|
+
}
|
|
106
|
+
}, { authority: "loopback" });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const PROXY_RPC_CHANNEL = CHANNEL;
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// lib/settings.js — official DSH settings seam for the proxy-routing namespace.
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
import { canonicalDefaults } from "./config.js";
|
|
5
|
+
|
|
6
|
+
export const PROXY_ROUTING_NAMESPACE = settingsNamespace("proxy-routing");
|
|
7
|
+
|
|
8
|
+
const RouteSchema = z.object({
|
|
9
|
+
kind: z.union(["direct", "profile"]).default("direct"),
|
|
10
|
+
profileId: z.string(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const ProfileSchema = z.object({
|
|
14
|
+
id: z.string().default("default"),
|
|
15
|
+
protocol: z.union(["http", "socks5"]),
|
|
16
|
+
host: z.string(),
|
|
17
|
+
port: z.natural().min(1).max(65535),
|
|
18
|
+
username: z.string().role("secret"),
|
|
19
|
+
password: z.string().role("secret"),
|
|
20
|
+
noProxy: z.array(z.string()).default(["127.0.0.1", "localhost", "::1", "api.deepseek.com"]),
|
|
21
|
+
timeout: z.natural().min(1000).max(600000).default(60000),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ProviderRouteSchema = z.object({
|
|
25
|
+
provider: z.string(),
|
|
26
|
+
route: RouteSchema.default({ kind: "direct" }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const GatewaySchema = z.object({
|
|
30
|
+
enabled: z.boolean().default(false),
|
|
31
|
+
port: z.natural().min(1).max(65535).default(17890),
|
|
32
|
+
dedicatedPurposePorts: z.boolean().default(false),
|
|
33
|
+
purposes: z.array(z.string()).default([]),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Canonical v2 schema registered under the DSH settings namespace. */
|
|
37
|
+
export const ProxyRoutingSettingsSchema = z.object({
|
|
38
|
+
version: z.natural().default(2),
|
|
39
|
+
profiles: z.array(ProfileSchema).default(canonicalDefaults().profiles),
|
|
40
|
+
bindings: z.object({
|
|
41
|
+
agent: RouteSchema.default({ kind: "direct" }),
|
|
42
|
+
providers: z.array(ProviderRouteSchema).default([]),
|
|
43
|
+
gateway: z.union([RouteSchema, z.const(null)]).default(null),
|
|
44
|
+
gatewayPurposes: z.array(z.object({
|
|
45
|
+
purpose: z.string(),
|
|
46
|
+
route: RouteSchema.default({ kind: "direct" }),
|
|
47
|
+
})).default([]),
|
|
48
|
+
}).default(canonicalDefaults().bindings),
|
|
49
|
+
gateway: GatewaySchema.default(canonicalDefaults().gateway),
|
|
50
|
+
}).default(canonicalDefaults());
|
|
51
|
+
|
|
52
|
+
/** Stable error for compositions that omit the official settings provider. */
|
|
53
|
+
export function settingsUnavailableError() {
|
|
54
|
+
const error = new Error(
|
|
55
|
+
"dsh-proxy-routing requires the DSH settings service; "
|
|
56
|
+
+ "enable @deepseek-ai/dsh-settings-file in the profile first",
|
|
57
|
+
);
|
|
58
|
+
error.code = "SETTINGS_UNAVAILABLE";
|
|
59
|
+
return error;
|
|
60
|
+
}
|