mslxdff 0.1.184 → 0.1.185

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.184",
3
+ "version": "0.1.185",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
  import { statSync } from "node:fs";
5
5
  import { resolveWarpConfig } from "./config.js";
6
6
  import { createRotatingDispatcher } from "./rotate.js";
7
+ import { classifyWarpSignal, SIGNAL_STATUSES } from "./verdict.js";
7
8
  import { createHealthMonitor } from "./health.js";
8
9
  import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
9
10
  import { registerWarpDevice } from "./register.js";
@@ -51,10 +52,35 @@ export function createWarpPool({
51
52
  const cfg = resolveWarpConfig(env);
52
53
  if (!cfg.active) return null;
53
54
 
54
- /** 身份轮换器:429 时换一台 WARP 设备换出口 IP(串行换身份,见 identity.js 头注)。 */
55
+ /** 身份轮换器:上游按出口 IP 计的限额(403 FreeTierError / 429 FreeUsage·GoUsage / 裸 429)时换一台 WARP 设备换出口。 */
55
56
  let rotator = null;
56
57
 
57
- const dispatcher = createRotatingDispatcher({ nodes: [], strategy: cfg.strategy, cooldownMs: cfg.cooldownMs, log, onStatus: (status, port) => { if (status === 429 && cfg.rotate && rotator) void rotator.rotate(port); } });
58
+ const dispatcher = createRotatingDispatcher({
59
+ nodes: [],
60
+ strategy: cfg.strategy,
61
+ cooldownMs: cfg.cooldownMs,
62
+ log,
63
+ // 判决照抄参照实现 zen-router 的决策表(见 verdict.js 头注):403 FreeTierError 与
64
+ // 429 FreeUsage/GoUsage 是同一条件、必须**强制**换出口(跳过冷却);BlackUsageLimitError
65
+ // 是账号日配额、绝不换;其余状态码不参与判决、也不许刷日志。
66
+ onStatus: (signal, port) => {
67
+ const status = Number(signal?.status);
68
+ if (!SIGNAL_STATUSES.includes(status)) return;
69
+ const v = classifyWarpSignal(signal);
70
+ if (!cfg.rotate) {
71
+ log(`[warp-pool] :${port} 上游 ${status}(${v.kind})—— 轮换开关关着(MSLXDFF_WARP_ROTATE=0),不换出口`);
72
+ return;
73
+ }
74
+ if (!v.rotate || !rotator) {
75
+ log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 不换出口:${v.why}`);
76
+ return;
77
+ }
78
+ log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 换身份换出口${v.forced ? "(强制,跳过冷却)" : ""}:${v.why}`);
79
+ void rotator.rotate(port, { force: v.forced })
80
+ .then((r) => { if (r && r.skipped) log(`[warp-pool] :${port} 换出口被冷却掐掉(距上次不足最小间隔)—— 这一发仍走原出口`); })
81
+ .catch(() => {});
82
+ },
83
+ });
58
84
  /** @type {Map<number, {stop: () => void}>} */
59
85
  const tunnels = new Map();
60
86
 
@@ -7,6 +7,7 @@
7
7
  // bootstrap 完成后 setNodes() 换上真实出口。这样 daemon 启动不会因注册/下载而被阻塞。
8
8
  import { getUndici } from "../compat.js";
9
9
  import { createSelector } from "./strategy.js";
10
+ import { looksLikeQuotaSignal, SIGNAL_MAX_BYTES, SIGNAL_STATUSES } from "./verdict.js";
10
11
 
11
12
  const SESSION_HEADER = "x-opencode-session";
12
13
 
@@ -20,14 +21,64 @@ export function headerValue(headers, name) {
20
21
  return "";
21
22
  }
22
23
 
23
- /** 让 dispatcher 看见上游状态码:复制一层 handler,只截 onHeaders,其余方法原样转发(全部调用都落在副本上,this 一致)。 */
24
+ /**
25
+ * 让 dispatcher 看见上游的失败信号:复制一层 handler,其余方法原样转发(全部调用都落在副本上,this 一致)。
26
+ *
27
+ * 403/429 要**连正文一起**交出去才能判决(`403 FreeTierError` 与 `429 FreeUsageLimitError` 是同一条件,
28
+ * 而 `429 BlackUsageLimitError` 是账号配额、绝不能换出口)——参照实现 zen-router 就是按正文里的具名判决
29
+ * 分类的(internal/zen/errors.go)。故这两类状态码延迟到「正文到手」再回调:凑够具名判决即提前回调,
30
+ * 否则等 onComplete/onError/触顶。其余状态码照旧在 onHeaders 立即回调(正文为空)。
31
+ * 正文只**旁听**不消费:onData 原样转发给下游 handler,捕获 ≤ SIGNAL_MAX_BYTES 字节。
32
+ */
24
33
  export function tapHandler(handler, tap) {
25
34
  if (!tap || !handler || typeof handler !== "object") return handler;
26
35
  const out = Object.assign(Object.create(Object.getPrototypeOf(handler)), handler);
27
- const orig = handler.onHeaders;
36
+ const origHeaders = handler.onHeaders;
37
+ const origData = handler.onData;
38
+ const origComplete = handler.onComplete;
39
+ const origError = handler.onError;
40
+ /** @type {null | { status: number, buf: Buffer[], size: number, done: boolean }} */
41
+ let capture = null;
42
+ const fire = () => {
43
+ if (!capture || capture.done) return;
44
+ capture.done = true;
45
+ try { tap({ status: capture.status, text: Buffer.concat(capture.buf).toString("utf8") }); } catch {}
46
+ };
47
+
28
48
  out.onHeaders = function (statusCode, headers, resume, statusText) {
29
- try { tap(statusCode); } catch {}
30
- return typeof orig === "function" ? orig.call(this, statusCode, headers, resume, statusText) : undefined;
49
+ const code = Number(statusCode);
50
+ // 只有 403/429 值得等正文;且必须下游真能收数据,否则退回「只按状态码判决」。
51
+ if (SIGNAL_STATUSES.includes(code) && typeof origData === "function") {
52
+ capture = { status: code, buf: [], size: 0, done: false };
53
+ } else {
54
+ try { tap({ status: code, text: "" }); } catch {}
55
+ }
56
+ return typeof origHeaders === "function" ? origHeaders.call(this, statusCode, headers, resume, statusText) : undefined;
57
+ };
58
+ out.onData = function (chunk) {
59
+ if (capture && !capture.done) {
60
+ try {
61
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
62
+ const room = SIGNAL_MAX_BYTES - capture.size;
63
+ if (room > 0) {
64
+ const slice = b.length > room ? b.subarray(0, room) : b;
65
+ capture.buf.push(slice);
66
+ capture.size += slice.length;
67
+ }
68
+ } catch {}
69
+ // 具名判决一到手就判决,不必等整条 body;触顶也判(大 body 不许把判决拖成慢扫描)
70
+ const text = capture.buf.length ? Buffer.concat(capture.buf).toString("utf8") : "";
71
+ if (capture.size >= SIGNAL_MAX_BYTES || looksLikeQuotaSignal(text)) fire();
72
+ }
73
+ return typeof origData === "function" ? origData.call(this, chunk) : undefined;
74
+ };
75
+ out.onComplete = function (trailers) {
76
+ fire();
77
+ return typeof origComplete === "function" ? origComplete.call(this, trailers) : undefined;
78
+ };
79
+ out.onError = function (err) {
80
+ fire();
81
+ return typeof origError === "function" ? origError.call(this, err) : undefined;
31
82
  };
32
83
  return out;
33
84
  }
@@ -0,0 +1,69 @@
1
+ // warp-pool · 上游失败信号的「换不换出口」判决(纯函数,零 IO)。
2
+ //
3
+ // 判据逐字对齐参照实现 kyomufs/zen-router:
4
+ // - internal/zen/errors.go 的 Kind 分类表(哪些错误算「按出口 IP 计的限额」);
5
+ // - internal/router/stage.go 的 NextAttempt 决策表(每种 Kind 该换什么、要不要绕过冷却)。
6
+ //
7
+ // 立此模块的原因(2026-10-17 十次修订):我原先只在 rotate.js 的 onStatus 里认 `status === 429`,
8
+ // 漏掉了参照实现表里**最重要的一格**——`403 FreeTierError` 与 `429 FreeUsageLimitError` 是
9
+ // **同一个条件的两种状态码**(上游按自己的窗口状态二选一,文案完全相同)。参照实现实测:
10
+ // 同一身份连发 3 次 429,**身份一换同一个请求立刻 200**(errors.go:32-42);
11
+ // 而它最初把 403 当通用 403 不重试,代价是整轮请求直接死掉(errors.go:50-61 记录得很清楚)。
12
+ // 我这边更糟:`terminal-verdict.js` 把 FreeTierError 判成**终局**(跳过接力),
13
+ // warp-pool 又只认 429 ⇒ **403 FreeTierError 从来没有任何换出口动作**。
14
+ //
15
+ // 反向护栏同样照抄:`BlackUsageLimitError` 是真正的**账号日配额**,换出口抬不动,
16
+ // 参照实现明确「绝不旋转身份」(stage.go:125-128、errors.go:28-31);换 key 才是它的正解。
17
+ // 裸 403(正文没有 FreeTier 判决)也不旋转:参照实现的 kindByStatus 把 403 归为
18
+ // KindClient(不可重试),换出口对形状/区域/凭据类 403 毫无用处。
19
+ //
20
+ // 读不到正文时的退路也照抄 kindByStatus:429 → 当出口 IP 限流(换);403 → 不换。
21
+
22
+ /** 上游失败正文里判定免费层资格的具名判决(与 terminal-verdict.js 的 FREE_TIER_PATTERNS 同源口径)。 */
23
+ const FREE_TIER_RE = /FreeTierError|free tier can only be used/i;
24
+ /** 匿名免费层 429(参照实现实测:按出口 IP 计,换身份即换桶)。 */
25
+ const FREE_QUOTA_RE = /FreeUsageLimitError|GoUsageLimitError/i;
26
+ /** 黑名单层:真账号日配额,换出口抬不动。 */
27
+ const BLACK_QUOTA_RE = /BlackUsageLimitError/i;
28
+
29
+ /** 只这两类状态码参与判决;其余(含 200)一律不参与、也不许刷日志。 */
30
+ export const SIGNAL_STATUSES = [403, 429];
31
+
32
+ /** 正文捕获上限(判据只需要文案里的类名,2KB 足够,且防大 body 拖成慢扫描)。 */
33
+ export const SIGNAL_MAX_BYTES = 2048;
34
+
35
+ /** 正文里出现了具名判决(供 tapHandler 一凑够就提前判决,不必等整条 body 读完)。 */
36
+ export function looksLikeQuotaSignal(text) {
37
+ const t = typeof text === "string" ? text : "";
38
+ return FREE_TIER_RE.test(t) || FREE_QUOTA_RE.test(t) || BLACK_QUOTA_RE.test(t);
39
+ }
40
+
41
+ /**
42
+ * 一次上游失败该不该换出口。
43
+ * @param {{ status?: number, text?: string }} signal 上游状态码 + 正文(≤ SIGNAL_MAX_BYTES;读不到给空串)
44
+ * @returns {{ kind: "free-tier"|"free-quota"|"black-quota"|"ip-limit"|"none", rotate: boolean,
45
+ * forced: boolean, why: string }}
46
+ * forced=true 表示必须**跳过**冷却(参照实现的 RotateForced:冷却曾把第一次真实反应吞掉,
47
+ * 于是重试复用了同一个已限额的出口 —— stage.go:315-324)。
48
+ */
49
+ export function classifyWarpSignal({ status, text = "" } = {}) {
50
+ const code = Number(status);
51
+ const body = typeof text === "string" ? text : "";
52
+ if (!SIGNAL_STATUSES.includes(code)) {
53
+ return { kind: "none", rotate: false, forced: false, why: `上游 ${code || "?"} 不是 403/429,与出口限额无关` };
54
+ }
55
+ // 顺序即优先级,照抄 errors.go 的 classify():先认正文里的具名判决,再退回状态码。
56
+ if (BLACK_QUOTA_RE.test(body)) {
57
+ return { kind: "black-quota", rotate: false, forced: false, why: "BlackUsageLimitError 是真正的账号日配额,换出口抬不动(参照实现同样绝不旋转)——该换 key,不是换 IP" };
58
+ }
59
+ if (FREE_TIER_RE.test(body)) {
60
+ return { kind: "free-tier", rotate: true, forced: true, why: "403 FreeTierError 与 429 FreeUsageLimitError 是同一条件的两种状态码;参照实现实测换身份后同一个请求立刻 200 ⇒ 强制换出口,跳过冷却" };
61
+ }
62
+ if (FREE_QUOTA_RE.test(body)) {
63
+ return { kind: "free-quota", rotate: true, forced: true, why: "匿名免费层 429 实测是**按出口 IP** 计的限额(同一身份连发 3 次全 429,换身份立刻 200)⇒ 强制换出口,跳过冷却" };
64
+ }
65
+ if (code === 429) {
66
+ return { kind: "ip-limit", rotate: true, forced: false, why: "裸 429(正文无配额判决)= 出口 IP 限流信号 ⇒ 换出口;保留冷却,防 429 风暴把 CF 注册刷爆" };
67
+ }
68
+ return { kind: "none", rotate: false, forced: false, why: "403 但正文没有 FreeTier 判决 ⇒ 不是配额信号(形状/区域/凭据类),换出口无用" };
69
+ }