@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 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
- * One probe attempt: HEAD first, then a GET retry when the endpoint does not
173
- * implement HEAD (405/501) — many APIs only answer GET, and a HEAD-only probe
174
- * would libel a perfectly usable host. `request` is the caller's fetch: the
175
- * global one for the policy channel, this undici copy's `fetch` for forced
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 a verdict from classifyProbeStatus / classifyTargetFailure.
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 was rejected but GET works — the host is usable.'
205
- : 'HEAD was rejected; the GET retry hit the same class of problem.'
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: 'forced channel unavailable (undici could not be loaded)',
219
- why: 'The app-side undici package could not be imported by the host process.',
220
- fix: 'Uncheck 使用代理 to use the policy channel, or restart DSH; report the plugin version if it persists.' }
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: `invalid proxy address: ${String(error?.message ?? error)}`,
229
- why: 'The effective proxy address is not a valid http(s) proxy URL.',
230
- fix: 'Fix the address in 代理配置 (mode = 自定义地址) or clear the custom URL.' }
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
- * Probe one target the way web_fetch will see it: route verdict first, then a
247
- * real request through the installed policy (global fetch → policy dispatcher).
248
- * `channel` selects the probe path: 'policy' (default), 'proxy' (forced via
249
- * proxy), or 'direct' (forced bypass).
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: 'invalid URL — expected http(s)://host/path',
258
- why: 'The text is not a parseable absolute URL.', fix: 'Use the full form, e.g. https://github.com/foo/bar.' } }
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 policyProbe = proxyRouteFor(parsed)
263
- routeName = policyProbe.proxied ? `PROXIED ${policyProbe.proxy}` : 'DIRECT'
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: 'no active proxy url in the current snapshot',
276
- why: 'The proxy switch is off (or the Windows system proxy is off), so there is nothing to force traffic through.',
277
- fix: 'Turn the proxy on, or fix the address/mode in 代理配置.' }
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 probeHttp((u, i) => fetch(u, i), parsed, undefined)
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
- ? `usable${p.status === undefined ? '' : ` (HTTP ${p.status})`}`
509
- : `${p.verdict === 'degraded' ? 'degraded' : 'unusable'} — ${p.short ?? 'unknown'}`
523
+ ? `可用${p.status === undefined ? '' : `(HTTP ${p.status})`}`
524
+ : `${p.verdict === 'degraded' ? '降级' : '不可用'} — ${p.short ?? '未知'}`
510
525
  const lines = [
511
- `Route: ${value.route}`,
512
- `NO_PROXY bypassed: ${value.bypassed}`,
513
- `Probe: ${head}`,
526
+ `路由: ${value.route}`,
527
+ `NO_PROXY 命中: ${value.bypassed}`,
528
+ `连通探测: ${head}`,
514
529
  ]
515
- if (p.why) lines.push(`Why: ${p.why}`)
516
- if (p.fix) lines.push(`Fix: ${p.fix}`)
517
- if (p.note) lines.push(`Note: ${p.note}`)
518
- if (value.hint) lines.push(`Hint: ${value.hint}`)
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 hint: SWG proxies (netentsec etc.) ask undici for NTLM auth
258
- * which undici cannot negotiate; a curl/browser NAT'd connection keeps a
259
- * window open. Only relevant when a 407 or auth-required response is seen.
260
- * The example URL is caller-supplied (the effective proxy), never hardcoded —
261
- * a fixed sample leaked this developer's own corporate proxy into every hint.
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 `This proxy asks for NTLM authentication, which undici cannot negotiate by itself. A recent curl/browser connection to the same proxy keeps an authenticated window open — run a \`curl\` through the proxy (e.g. \`curl -x ${example} -I https://github.com\`) once, then retry.`
267
+ return `该代理要求 NTLM 认证,undici 无法自行协商。最近一次 curl/浏览器到同一代理的连接会保持一段已认证窗口——先执行一次 \`curl -x ${example} -I https://github.com\`,然后重试。`
268
268
  }
269
269
 
270
270
  /**
271
- * Three-state verdict for one HTTP answer, shared by the panel, `proxy_test`
272
- * and the HTTP API:
271
+ * 单个 HTTP 应答的三态判定,供面板、`proxy_test` 与 HTTP API 共用:
273
272
  *
274
- * - `usable` — the route works for real calls (2xx/3xx, plus 401: a healthy
275
- * API endpoint answering the credential-less probe).
276
- * - `degraded` — an HTTP answer came back, so the route works, but the call
277
- * itself would likely fail (403/404/405/429 and other 4xx).
278
- * - `unusable` — the route or the far end failed (407 auth wall, 5xx from the
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
- * Every verdict carries `why` (the likely cause) and `fix` (what to try), so a
282
- * diagnosis is actionable instead of a bare status code (2026-09-17: 504 and
283
- * 200 both rendered as "reachable", which answered nothing).
279
+ * 每个结论都带 `why`(可能原因)与 `fix`(挽救措施),让诊断直接可行动,
280
+ * 而不是丢一个状态码(2026-09-17:504 曾与 200 一样显示成「可达」)。
284
281
  *
285
- * @param status - the HTTP status code a probe received.
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 — the proxy asks for authentication (NTLM)',
293
- why: 'The request never reached the target: the proxy demands NTLM, which undici cannot negotiate.',
294
- fix: 'Run one curl/browser request through the same proxy (e.g. curl -x <proxy> -I https://github.com) to open an authenticated window, then retry.' }
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} — the target answered`, why: '', fix: '' }
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 — the target is alive (credentials required)',
301
- why: 'The probe sends no credentials, so 401 is the healthy answer from an API endpoint.',
302
- fix: 'Real calls need a valid key/header; the route itself is proven to work.' }
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 — the target refused the request',
306
- why: 'The server was reached but denies this client: key scope, IP allow-list, WAF, or a blocked probe User-Agent.',
307
- fix: 'Compare the other channel (direct vs proxy) and check the key/UA rules for this host.' }
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 — path not found',
311
- why: 'The route reached the server; that path does not exist there (probes often hit the bare origin).',
312
- fix: 'Re-test with the concrete path you really call; if that works, ignore this result.' }
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} — the endpoint does not accept HEAD`,
316
- why: 'The diagnostic probes with HEAD; some APIs and gateways only implement GET.',
317
- fix: 'The panel retries with GET automatically; a GET success means the host is usable.' }
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 — rate limited',
321
- why: 'The server is alive but throttling this client.',
322
- fix: 'Retry later or lower the request rate; the route itself is fine.' }
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: 'An intermediary (the proxy or the target gateway) got an invalid answer from upstream — often the proxy cannot reach this host.',
327
- fix: 'Compare the other channel (direct vs proxy); if the proxy is the failing leg, keep this host on the working side of NO_PROXY.' }
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 — service unavailable',
331
- why: 'The target (or its gateway) is overloaded or in maintenance.',
332
- fix: 'Retry later; this is server-side and unrelated to the route.' }
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 — gateway/upstream timeout',
336
- why: 'The request was forwarded but nothing answered upstream in time: the target is slow or dead, or the proxy leg to it is broken.',
337
- fix: 'Compare the other channel (direct vs proxy) and retry; if only one channel times out, that channel is the problem.' }
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} — the target failed`,
341
- why: 'The server answered with an error of its own; the route is fine.',
342
- fix: 'Retry later; nothing to change in the proxy configuration.' }
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} — request rejected`,
345
- why: 'The route works (an HTTP answer came back) but the request itself was not successful.',
346
- fix: 'Check the method, path and headers your real calls use.' }
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
- * Turn a fetch rejection into an actionable verdict. A bare `fetch failed`
351
- * hides the real cause: a dispatcher built by one undici copy handed to another
352
- * rejects with UND_ERR_INVALID_ARG ("invalid onRequestStart method"), which is
353
- * how the forced-channel diagnostic failed for both channels (LESSONS §22).
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 - the rejection from `fetch`.
356
- * @returns `{ ok, verdict, kind, short, why, fix }` with a cause-aware message.
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 lookup failed (${code})`,
364
- why: 'The hostname did not resolve — a wrong name/DNS, or a proxy that cannot resolve it.',
365
- fix: 'Check the hostname; if this host must go through the proxy, remove it from NO_PROXY (or add it if it must go direct).' }
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: 'connection refused (ECONNREFUSED)',
369
- why: 'Something answered but refused the connection: nothing listens on that port, or the proxy is not accepting.',
370
- fix: 'Check the port and that the proxy is running; compare the other channel.' }
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: `cannot reach directly (${code}) — needs a proxy route`,
374
- why: 'The network path itself is unavailable from this machine.',
375
- fix: 'Route this host through the proxy (remove the NO_PROXY entry) and retry.' }
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: `no answer before the timeout (${code || error?.name || 'timeout'})`,
379
- why: 'Nothing answered in time — a slow/dead target, or a proxy that silently drops this destination.',
380
- fix: 'Compare the other channel (direct vs proxy) and retry; if only one channel times out, that channel is the problem.' }
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: `connection reset (${code})`,
384
- why: 'The connection was cut mid-flight by an intermediary or a TLS problem (corporate interception, MTU, or a reset by the far end).',
385
- fix: 'Retry; compare the other channel; if TLS interception is in play, confirm the corporate CA is trusted (NODE_EXTRA_CA_CERTS).' }
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 rejected by this undici instance (${code || 'UND_ERR_INVALID_ARG'}) — mixed undici copies`,
389
- why: 'The request and its dispatcher came from two different undici copies; the diagnostic had this bug (fixed in 1.0.2).',
390
- fix: 'Update the plugin; if it persists, report the plugin version with this message.' }
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: 'the proxy asks for NTLM authentication',
394
- why: 'The proxy demands NTLM, which undici cannot negotiate by itself.',
395
- fix: 'Run one curl/browser request through the same proxy to open an authenticated window, then retry.' }
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} (${detail})` : message).slice(0, 180),
401
- why: 'The request failed for a reason the probe could not classify.',
402
- fix: 'Compare the other channel (direct vs proxy) and retry; check proxy_status for the effective route.' }
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",
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",