@yanglaofish/dsh-proxy-pro 1.0.3 → 1.0.4
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/docs/LESSONS.md +25 -1
- package/lib/index.js +50 -35
- package/lib/proxy-core.js +81 -83
- package/package.json +1 -1
package/docs/LESSONS.md
CHANGED
|
@@ -699,4 +699,28 @@ module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"
|
|
|
699
699
|
|
|
700
700
|
**教训**:诊断工具的返回值必须回答"能不能用"和"下一步怎么办"——只回一个状态码,等于把问题
|
|
701
701
|
又丢回给用户。另外 `additionalProperties: false` 的 schema 必须同步列出新字段
|
|
702
|
-
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
702
|
+
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## 25. 策略通道也要同一 undici 副本(2026-09-17)
|
|
707
|
+
|
|
708
|
+
**症状**:1.0.3 面板「连通探测」(不勾选「使用代理」)显示
|
|
709
|
+
`不可用:dispatcher rejected by this undici instance (UND_ERR_INVALID_ARG) — mixed undici copies`。
|
|
710
|
+
|
|
711
|
+
**根因**:1.0.2 只修了强制通道(`und.fetch` + 同副本 dispatcher,§22)。策略通道一直用 Node
|
|
712
|
+
内置 `fetch` 且不传 dispatcher —— 但全局 dispatcher 是 `installProxyFromEnvironment` 用 **app
|
|
713
|
+
undici** 装的,内置 fetch 拿它发请求 = A 组合(内置 fetch + 外部 Agent)→ 必然
|
|
714
|
+
UND_ERR_INVALID_ARG。之前没暴露,是因为面板默认勾选「使用代理」(强制通道)掩盖了它。
|
|
715
|
+
|
|
716
|
+
**修法**(1.0.4):策略通道同样走 app undici——
|
|
717
|
+
`policyProbe()` = `und.fetch(url, { dispatcher: und.getGlobalDispatcher() })`。
|
|
718
|
+
`getGlobalDispatcher()` 返回的正是 web_fetch 通道在用的全局 dispatcher,语义 = 模拟 web_fetch,
|
|
719
|
+
且永远同副本。三条探测路径现在统一:policy = 全局 dispatcher / forced-direct = 新建 Agent /
|
|
720
|
+
forced-proxy = 新建 ProxyAgent,全部由 `und.fetch` 发起。
|
|
721
|
+
|
|
722
|
+
**连带**:所有展示文案(short/why/fix/hint)中文化——面板是中文 UI,英文 why/fix 让人无法
|
|
723
|
+
行动;`proxy_test` 工具输出同步中文(模型照读)。教训:给用户看的文案别用英文"图省事",
|
|
724
|
+
判定结论必须能直接用。
|
|
725
|
+
|
|
726
|
+
**测试**:断言随中文文案同步翻新(/混用/、/超时/、/上游|超时/、/ECONNREFUSED/)。
|
package/lib/index.js
CHANGED
|
@@ -169,13 +169,12 @@ const PROBE_UA = 'dsh-proxy-pro/0.1'
|
|
|
169
169
|
const PROBE_TIMEOUT_MS = 6000
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* channels (LESSONS §22 — the two must never be mixed).
|
|
172
|
+
* 一个探测尝试:先 HEAD,端点不支持 HEAD(405/501)时再用 GET 复测——
|
|
173
|
+
* 很多 API 只实现 GET,只用 HEAD 会冤枉一个可用主机。`request` 是调用方
|
|
174
|
+
* 的 fetch:两条通道现在都用 app undici 的 `und.fetch` 配同副本 dispatcher
|
|
175
|
+
* (LESSONS §22/§25 —— 绝不要把两份副本混在一起)。
|
|
177
176
|
*
|
|
178
|
-
* @returns
|
|
177
|
+
* @returns classifyProbeStatus / classifyTargetFailure 的判定。
|
|
179
178
|
*/
|
|
180
179
|
async function probeHttp(request, target, dispatcher) {
|
|
181
180
|
const attempt = async (method) => {
|
|
@@ -201,8 +200,8 @@ async function probeHttp(request, target, dispatcher) {
|
|
|
201
200
|
if (get.error !== undefined) return classifyTargetFailure(get.error)
|
|
202
201
|
const retried = classifyProbeStatus(get.res.status)
|
|
203
202
|
retried.note = retried.ok === true
|
|
204
|
-
? 'HEAD
|
|
205
|
-
: 'HEAD
|
|
203
|
+
? 'HEAD 被拒绝但 GET 可用 — 主机可用。'
|
|
204
|
+
: 'HEAD 被拒绝;GET 复测也遇到同类问题。'
|
|
206
205
|
return retried
|
|
207
206
|
}
|
|
208
207
|
|
|
@@ -215,9 +214,9 @@ async function probeHttp(request, target, dispatcher) {
|
|
|
215
214
|
async function forceChannelProbe(url, channel, proxyUrl) {
|
|
216
215
|
const und = await getUndici()
|
|
217
216
|
if (und === null) {
|
|
218
|
-
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '
|
|
219
|
-
why: '
|
|
220
|
-
fix: '
|
|
217
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '强制通道不可用(undici 未能加载)',
|
|
218
|
+
why: '宿主进程无法加载 app 侧 undici 包。',
|
|
219
|
+
fix: '取消勾选「使用代理」改用策略通道,或重启 DSH;仍出现请反馈插件版本。' }
|
|
221
220
|
}
|
|
222
221
|
let dispatcher
|
|
223
222
|
try {
|
|
@@ -225,9 +224,9 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
225
224
|
? new und.Agent({ connections: 1 })
|
|
226
225
|
: new und.ProxyAgent(proxyUrl)
|
|
227
226
|
} catch (error) {
|
|
228
|
-
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short:
|
|
229
|
-
why: '
|
|
230
|
-
fix: '
|
|
227
|
+
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `代理地址无效:${String(error?.message ?? error)}`,
|
|
228
|
+
why: '当前生效的代理地址不是有效的 http(s) 代理 URL。',
|
|
229
|
+
fix: '在「代理配置」修复地址(mode = 自定义地址)或清空自定义地址。' }
|
|
231
230
|
}
|
|
232
231
|
try {
|
|
233
232
|
// MUST issue the request with THIS undici copy. The host runs two copies —
|
|
@@ -243,10 +242,26 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
243
242
|
}
|
|
244
243
|
|
|
245
244
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
245
|
+
* 按已安装策略探测,与 web_fetch 走同一条路:用同一份 undici 的 fetch 加
|
|
246
|
+
* 全局 dispatcher。策略通道不能用 Node 内置 `fetch`——全局 dispatcher 由
|
|
247
|
+
* app 的 undici(installProxyFromEnvironment)安装,内置 fetch 拿它必然
|
|
248
|
+
* UND_ERR_INVALID_ARG(LESSONS §25,A 组合;面板当时显示"dispatcher
|
|
249
|
+
* rejected… mixed undici copies")。
|
|
250
|
+
*/
|
|
251
|
+
async function policyProbe(target) {
|
|
252
|
+
const und = await getUndici()
|
|
253
|
+
if (und === null) {
|
|
254
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '策略通道不可用(undici 未能加载)',
|
|
255
|
+
why: '宿主进程无法加载 app 侧 undici 包。', fix: '重启 DSH;仍出现请反馈插件版本。' }
|
|
256
|
+
}
|
|
257
|
+
const dispatcher = typeof und.getGlobalDispatcher === 'function' ? und.getGlobalDispatcher() : undefined
|
|
258
|
+
return probeHttp((u, i) => und.fetch(u, i), target, dispatcher)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 按 web_fetch 的方式探测一个目标:先给路由判定,再用策略通道发真实请求
|
|
263
|
+
* (app undici 的 fetch + 全局 dispatcher,见 policyProbe)。`channel` 选择
|
|
264
|
+
* 探测路径:'policy'(默认)、'proxy'(强制走代理)、'direct'(强制绕过)。
|
|
250
265
|
*/
|
|
251
266
|
async function probeTarget(url, snapshot, channel = 'policy') {
|
|
252
267
|
let parsed
|
|
@@ -254,13 +269,13 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
254
269
|
parsed = new URL(url)
|
|
255
270
|
} catch {
|
|
256
271
|
return { url, route: 'invalid', bypassed: false,
|
|
257
|
-
probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: '
|
|
258
|
-
why: '
|
|
272
|
+
probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
|
|
273
|
+
why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' } }
|
|
259
274
|
}
|
|
260
275
|
let routeName = 'DIRECT'
|
|
261
276
|
try {
|
|
262
|
-
const
|
|
263
|
-
routeName =
|
|
277
|
+
const routeProbe = proxyRouteFor(parsed)
|
|
278
|
+
routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
|
|
264
279
|
} catch {
|
|
265
280
|
// proxyRouteFor unavailable in this composition → rely on state snapshot
|
|
266
281
|
}
|
|
@@ -272,13 +287,13 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
272
287
|
if (channel === 'proxy' || channel === 'direct') {
|
|
273
288
|
const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
|
|
274
289
|
const probe = proxyUrl === '' && channel === 'proxy'
|
|
275
|
-
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '
|
|
276
|
-
why: '
|
|
277
|
-
fix: '
|
|
290
|
+
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
|
|
291
|
+
why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
|
|
292
|
+
fix: '打开代理,或在「代理配置」里修复地址/模式。' }
|
|
278
293
|
: await forceChannelProbe(parsed, channel, proxyUrl)
|
|
279
294
|
return { url, route: routeName, bypassed, channel, probe }
|
|
280
295
|
}
|
|
281
|
-
const probe = await
|
|
296
|
+
const probe = await policyProbe(parsed)
|
|
282
297
|
return { url, route: routeName, bypassed, probe }
|
|
283
298
|
}
|
|
284
299
|
|
|
@@ -505,17 +520,17 @@ function apply(ctx, config) {
|
|
|
505
520
|
render: (_args, value) => {
|
|
506
521
|
const p = value.probe ?? {}
|
|
507
522
|
const head = p.verdict === 'usable'
|
|
508
|
-
?
|
|
509
|
-
: `${p.verdict === 'degraded' ? '
|
|
523
|
+
? `可用${p.status === undefined ? '' : `(HTTP ${p.status})`}`
|
|
524
|
+
: `${p.verdict === 'degraded' ? '降级' : '不可用'} — ${p.short ?? '未知'}`
|
|
510
525
|
const lines = [
|
|
511
|
-
|
|
512
|
-
`NO_PROXY
|
|
513
|
-
|
|
526
|
+
`路由: ${value.route}`,
|
|
527
|
+
`NO_PROXY 命中: ${value.bypassed}`,
|
|
528
|
+
`连通探测: ${head}`,
|
|
514
529
|
]
|
|
515
|
-
if (p.why) lines.push(
|
|
516
|
-
if (p.fix) lines.push(
|
|
517
|
-
if (p.note) lines.push(
|
|
518
|
-
if (value.hint) lines.push(
|
|
530
|
+
if (p.why) lines.push(`可能原因: ${p.why}`)
|
|
531
|
+
if (p.fix) lines.push(`挽救措施: ${p.fix}`)
|
|
532
|
+
if (p.note) lines.push(`备注: ${p.note}`)
|
|
533
|
+
if (value.hint) lines.push(`提示: ${value.hint}`)
|
|
519
534
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
520
535
|
},
|
|
521
536
|
},
|
package/lib/proxy-core.js
CHANGED
|
@@ -254,150 +254,148 @@ export function summarize(snapshot) {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* NTLM keepalive
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
257
|
+
* NTLM keepalive 提示:SWG 类代理(netentsec 等)向 undici 索要 NTLM 认证,
|
|
258
|
+
* undici 无法协商;curl/浏览器最近一次经过同一代理的连接会保持一段已认证
|
|
259
|
+
* 窗口。仅在看到 407 或认证相关应答时才有意义。示例 URL 由调用方传入
|
|
260
|
+
* (生效代理地址),绝不硬编码——固定样例曾把开发者自己的公司代理泄漏进
|
|
261
|
+
* 每一条提示里。
|
|
262
262
|
*/
|
|
263
263
|
export function keepaliveHint(message = '', proxyUrl = '') {
|
|
264
264
|
const text = typeof message === 'string' ? message : ''
|
|
265
265
|
if (!/407|NTLM|proxy authentication|authentication required/i.test(text)) return ''
|
|
266
266
|
const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? proxyUrl : 'http://localhost:7890'
|
|
267
|
-
return
|
|
267
|
+
return `该代理要求 NTLM 认证,undici 无法自行协商。最近一次 curl/浏览器到同一代理的连接会保持一段已认证窗口——先执行一次 \`curl -x ${example} -I https://github.com\`,然后重试。`
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
/**
|
|
271
|
-
*
|
|
272
|
-
* and the HTTP API:
|
|
271
|
+
* 单个 HTTP 应答的三态判定,供面板、`proxy_test` 与 HTTP API 共用:
|
|
273
272
|
*
|
|
274
|
-
* - `usable` —
|
|
275
|
-
*
|
|
276
|
-
* - `degraded` —
|
|
277
|
-
*
|
|
278
|
-
* - `unusable` —
|
|
279
|
-
* target, gateway/upstream timeouts).
|
|
273
|
+
* - `usable` — 真实调用可用(2xx/3xx,以及 401:健康的 API 端点对
|
|
274
|
+
* 不带凭证的探测就该回 401)。
|
|
275
|
+
* - `degraded` — 拿到了 HTTP 应答(路由通),但这次调用多半会失败
|
|
276
|
+
* (403/404/405/429 等 4xx)。
|
|
277
|
+
* - `unusable` — 路由或对端失败(407 认证墙、目标 5xx、网关/上游超时)。
|
|
280
278
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* 200 both rendered as "reachable", which answered nothing).
|
|
279
|
+
* 每个结论都带 `why`(可能原因)与 `fix`(挽救措施),让诊断直接可行动,
|
|
280
|
+
* 而不是丢一个状态码(2026-09-17:504 曾与 200 一样显示成「可达」)。
|
|
284
281
|
*
|
|
285
|
-
* @param status -
|
|
286
|
-
* @returns `{ ok, verdict, kind, status, short, why, fix }
|
|
282
|
+
* @param status - 探测收到的 HTTP 状态码。
|
|
283
|
+
* @returns `{ ok, verdict, kind, status, short, why, fix }`(中文文案)。
|
|
287
284
|
*/
|
|
288
285
|
export function classifyProbeStatus(status) {
|
|
289
286
|
const code = Number(status)
|
|
290
287
|
const base = { status: code }
|
|
291
288
|
if (code === 407) {
|
|
292
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'auth', short: 'HTTP 407 —
|
|
293
|
-
why: '
|
|
294
|
-
fix: '
|
|
289
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'auth', short: 'HTTP 407 — 代理要求 NTLM 认证',
|
|
290
|
+
why: '请求根本没到目标:代理拦下并索要 NTLM 认证,undici 无法协商。',
|
|
291
|
+
fix: '先用 curl/浏览器走同一代理过一次(如 curl -x <代理> -I https://github.com)打开认证窗口,再重试。' }
|
|
295
292
|
}
|
|
296
293
|
if (code >= 200 && code < 400) {
|
|
297
|
-
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: `HTTP ${code} —
|
|
294
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: `HTTP ${code} — 目标正常响应`, why: '', fix: '' }
|
|
298
295
|
}
|
|
299
296
|
if (code === 401) {
|
|
300
|
-
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: 'HTTP 401 —
|
|
301
|
-
why: '
|
|
302
|
-
fix: '
|
|
297
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: 'HTTP 401 — 目标存活(需要凭证)',
|
|
298
|
+
why: '探测不带凭证,所以 401 是健康 API 端点的正常应答。',
|
|
299
|
+
fix: '真实调用需要有效 key/请求头;这条结果已经证明路由可用。' }
|
|
303
300
|
}
|
|
304
301
|
if (code === 403) {
|
|
305
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 403 —
|
|
306
|
-
why: '
|
|
307
|
-
fix: '
|
|
302
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 403 — 目标拒绝了请求',
|
|
303
|
+
why: '已到达服务器但被拒绝:key 权限、IP 白名单、WAF,或探测 UA 被挡。',
|
|
304
|
+
fix: '对比直连/代理两侧,并检查该主机的 key/UA 规则。' }
|
|
308
305
|
}
|
|
309
306
|
if (code === 404) {
|
|
310
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 —
|
|
311
|
-
why: '
|
|
312
|
-
fix: '
|
|
307
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 — 路径不存在',
|
|
308
|
+
why: '路由已到服务器,但该路径不存在(探测常打根路径)。',
|
|
309
|
+
fix: '用真实调用的完整路径复测;能通就忽略此结果。' }
|
|
313
310
|
}
|
|
314
311
|
if (code === 405 || code === 501) {
|
|
315
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} —
|
|
316
|
-
why: '
|
|
317
|
-
fix: '
|
|
312
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 端点不支持 HEAD`,
|
|
313
|
+
why: '诊断用 HEAD 探测,有些 API/网关只实现 GET。',
|
|
314
|
+
fix: '面板已自动换 GET 复测;GET 成功即主机可用。' }
|
|
318
315
|
}
|
|
319
316
|
if (code === 429) {
|
|
320
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 429 —
|
|
321
|
-
why: '
|
|
322
|
-
fix: '
|
|
317
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 429 — 被限流',
|
|
318
|
+
why: '服务器活着但在限流。',
|
|
319
|
+
fix: '稍后重试或降低请求频率;路由本身没问题。' }
|
|
323
320
|
}
|
|
324
321
|
if (code === 502) {
|
|
325
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 502 — bad gateway',
|
|
326
|
-
why: '
|
|
327
|
-
fix: '
|
|
322
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 502 — 网关错误(bad gateway)',
|
|
323
|
+
why: '中间环节(代理或目标网关)拿到无效上游响应——常见是代理连不上该主机。',
|
|
324
|
+
fix: '对比直连/代理两侧;若失败在代理侧,把该主机保留在 NO_PROXY 的另一边。' }
|
|
328
325
|
}
|
|
329
326
|
if (code === 503) {
|
|
330
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 503 —
|
|
331
|
-
why: '
|
|
332
|
-
fix: '
|
|
327
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 503 — 服务不可用',
|
|
328
|
+
why: '目标(或其网关)过载或维护中。',
|
|
329
|
+
fix: '稍后重试;这是服务端问题,与路由无关。' }
|
|
333
330
|
}
|
|
334
331
|
if (code === 504) {
|
|
335
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 504 —
|
|
336
|
-
why: '
|
|
337
|
-
fix: '
|
|
332
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 504 — 网关/上游超时',
|
|
333
|
+
why: '请求已转发但上游迟迟无响应:目标慢或挂了,或代理到目标这一段坏了。',
|
|
334
|
+
fix: '对比直连/代理两侧并重试;只有某一侧超时,问题就在那一侧。' }
|
|
338
335
|
}
|
|
339
336
|
if (code >= 500) {
|
|
340
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'server-error', short: `HTTP ${code} —
|
|
341
|
-
why: '
|
|
342
|
-
fix: '
|
|
337
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'server-error', short: `HTTP ${code} — 目标服务端错误`,
|
|
338
|
+
why: '服务器自身报错;路由没问题。',
|
|
339
|
+
fix: '稍后重试;无需改动代理配置。' }
|
|
343
340
|
}
|
|
344
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} —
|
|
345
|
-
why: '
|
|
346
|
-
fix: '
|
|
341
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 请求被拒绝`,
|
|
342
|
+
why: '路由通(拿到了 HTTP 应答),但这次请求本身不成功。',
|
|
343
|
+
fix: '检查真实调用用的方法、路径和请求头。' }
|
|
347
344
|
}
|
|
348
345
|
|
|
349
346
|
/**
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
347
|
+
* 把 fetch 的拒绝转成可行动的结论。裸的 `fetch failed` 掩盖了真正原因:
|
|
348
|
+
* 一份 undici 造的 dispatcher 交给另一份使用时,会抛 UND_ERR_INVALID_ARG
|
|
349
|
+
* ("invalid onRequestStart method")——1.0.2 之前强制通道诊断两个方向都
|
|
350
|
+
* 死在这里(LESSONS §22);1.0.4 又发现策略通道也在混两份副本
|
|
351
|
+
* (LESSONS §25)。把 cause code 亮出来,下一次报告就能指名道姓。
|
|
354
352
|
*
|
|
355
|
-
* @param error -
|
|
356
|
-
* @returns `{ ok, verdict, kind, short, why, fix }
|
|
353
|
+
* @param error - `fetch` 抛出的拒绝。
|
|
354
|
+
* @returns 带 cause 信息的 `{ ok, verdict, kind, short, why, fix }`(中文文案)。
|
|
357
355
|
*/
|
|
358
356
|
export function classifyTargetFailure(error) {
|
|
359
357
|
const cause = error?.cause
|
|
360
358
|
const code = cause?.code ?? error?.code ?? ''
|
|
361
359
|
const message = String(error?.message ?? error)
|
|
362
360
|
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
363
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `DNS
|
|
364
|
-
why: '
|
|
365
|
-
fix: '
|
|
361
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `DNS 解析失败(${code})`,
|
|
362
|
+
why: '主机名没解析出来——名字/DNS 错,或代理无法解析它。',
|
|
363
|
+
fix: '检查主机名;该主机若必须走代理,把它移出 NO_PROXY(反之加进去)。' }
|
|
366
364
|
}
|
|
367
365
|
if (code === 'ECONNREFUSED') {
|
|
368
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: '
|
|
369
|
-
why: '
|
|
370
|
-
fix: '
|
|
366
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: '连接被拒绝(ECONNREFUSED)',
|
|
367
|
+
why: '有东西应答但拒绝连接:端口无人监听,或代理不在接受连接。',
|
|
368
|
+
fix: '检查端口与代理是否在运行;对比另一侧通道。' }
|
|
371
369
|
}
|
|
372
370
|
if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
|
373
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short:
|
|
374
|
-
why: '
|
|
375
|
-
fix: '
|
|
371
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `直连不可达(${code})—需要走代理`,
|
|
372
|
+
why: '本机到目标的网络路径本身不可用。',
|
|
373
|
+
fix: '让该主机走代理(从 NO_PROXY 移除)再重试。' }
|
|
376
374
|
}
|
|
377
375
|
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ABORT_ERR' || error?.name === 'AbortError') {
|
|
378
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short:
|
|
379
|
-
why: '
|
|
380
|
-
fix: '
|
|
376
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `超时前无应答(${code || error?.name || 'timeout'})`,
|
|
377
|
+
why: '到时间没应答——目标慢/挂,或代理静默丢弃该目标。',
|
|
378
|
+
fix: '对比直连/代理两侧并重试;只有某一侧超时,问题就在那一侧。' }
|
|
381
379
|
}
|
|
382
380
|
if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET' || code === 'EPIPE') {
|
|
383
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short:
|
|
384
|
-
why: '
|
|
385
|
-
fix: '
|
|
381
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `连接被重置(${code})`,
|
|
382
|
+
why: '连接中途被切断——中间设备或 TLS 问题(公司拦截、MTU、对端重置)。',
|
|
383
|
+
fix: '重试并对比另一侧;若有 TLS 拦截,确认公司 CA 已被信任(NODE_EXTRA_CA_CERTS)。' }
|
|
386
384
|
}
|
|
387
385
|
if (code === 'UND_ERR_INVALID_ARG' || /invalid onRequestStart/i.test(String(cause?.message ?? ''))) {
|
|
388
|
-
return { ok: false, verdict: 'unusable', kind: 'dispatcher', short: `dispatcher
|
|
389
|
-
why: '
|
|
390
|
-
fix: '
|
|
386
|
+
return { ok: false, verdict: 'unusable', kind: 'dispatcher', short: `dispatcher 被当前 undici 副本拒绝(${code || 'UND_ERR_INVALID_ARG'})—两份副本混用`,
|
|
387
|
+
why: '请求和它的 dispatcher 来自两份不同的 undici 副本;1.0.2 修过强制通道,1.0.4 修策略通道。',
|
|
388
|
+
fix: '更新插件;若仍出现,把插件版本连同这条信息一起反馈。' }
|
|
391
389
|
}
|
|
392
390
|
if (/407|NTLM|authentication required/i.test(message)) {
|
|
393
|
-
return { ok: false, verdict: 'unusable', kind: 'auth', short: '
|
|
394
|
-
why: '
|
|
395
|
-
fix: '
|
|
391
|
+
return { ok: false, verdict: 'unusable', kind: 'auth', short: '代理要求 NTLM 认证',
|
|
392
|
+
why: '代理索要 NTLM,undici 无法单独完成协商。',
|
|
393
|
+
fix: '先用 curl/浏览器走同一代理过一次(打开认证窗口),再重试。' }
|
|
396
394
|
}
|
|
397
395
|
const detail = cause?.code
|
|
398
396
|
? `${cause.code}${cause?.message ? `: ${String(cause.message).slice(0, 90)}` : ''}`
|
|
399
397
|
: (cause?.message ?? '')
|
|
400
|
-
return { ok: false, verdict: 'unusable', kind: 'other', short: (detail ? `${message}
|
|
401
|
-
why: '
|
|
402
|
-
fix: '
|
|
398
|
+
return { ok: false, verdict: 'unusable', kind: 'other', short: (detail ? `${message}(${detail})` : message).slice(0, 180),
|
|
399
|
+
why: '请求失败,探测器无法归类原因。',
|
|
400
|
+
fix: '对比直连/代理两侧并重试;用 proxy_status 查看生效路由。' }
|
|
403
401
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanglaofish/dsh-proxy-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "DSH proxy manager: route model requests, web fetches and spawned tools through the Windows system proxy or a custom address; per-URL route diagnosis, one-click toggle from the conversation header, a dedicated Settings page, and keepalive-aware 407 hints.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|