Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ Android-APP/gradlew.bat
# "bun run build:android-bundle" + copy; see Android-APP/AGENTS.md BUILD section.
Android-APP/app/src/main/assets/server_bundle/server.cjs
/Android-APP/design/previews

# Fork: captcha worker bundle is a build artifact -- regenerated by
# scripts/build-fork-worker.ts (prebuild hook), never committed.
src/proxy/captcha-worker-entry.bundle.js
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ bun run dev # 开发模式启动面板

架构与实现细节见 [`src/`](src/) 下各源码文件内的注释。

## Privacy

This fork adds **no telemetry, no analytics, and no outbound reporting** of
any kind. Nothing about your usage, device, or configuration leaves your
machine. (Inherited from upstream: the proxy is fully local; debug/dump logs
auto-redact API keys, JWTs, and proxy keys.)

## License

MIT
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev": "bun run src/index.ts",
"start": "bun run src/index.ts",
"test": "bun test",
"prebuild": "bun run scripts/build-fork-worker.ts",
"build": "bun build --compile --define \"require.resolve=undefined\" src/index.ts --outfile zcode-proxy.exe",
"build:linux-x64": "bun build --compile --target bun-linux-x64 --define \"require.resolve=undefined\" src/index.ts --outfile zcode-proxy-linux-x64",
"build:linux-arm64": "bun build --compile --target bun-linux-arm64 --define \"require.resolve=undefined\" src/index.ts --outfile zcode-proxy-linux-arm64",
Expand Down
24 changes: 24 additions & 0 deletions scripts/build-fork-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Build script -- fork patch: bundle the captcha worker entry into a
* self-contained ESM file so it can be embedded as a `with { type: "file" }`
* asset in the compiled single-file binary. Raw .ts assets are NOT parsed by
* Bun at extraction (they'd be evaluated as plain JS and fail on type
* annotations), so the worker must be pre-bundled to plain JS.
*
* Run before `bun build --compile`: bun run scripts/build-fork-worker.ts
* Output: src/proxy/captcha-worker-entry.bundle.mjs (gitignored build input)
*/
import { build } from "bun";

const result = await build({
entrypoints: ["./src/proxy/captcha-worker-entry.ts"],
outdir: "./src/proxy",
target: "bun",
format: "esm",
naming: { entry: "[dir]/[name].bundle.[ext]" },
minify: false,
external: [],
});

for (const log of result.logs) console.log(String(log));
console.log(`captcha worker bundled: ${result.outputs.map((o) => o.path).join(", ")}`);
163 changes: 86 additions & 77 deletions src/proxy/captcha-happy.ts

Large diffs are not rendered by default.

103 changes: 92 additions & 11 deletions src/proxy/captcha-solver.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,109 @@
/**
* Solver backend dispatch — fully in-process, self-contained.
* Solver backend dispatch -- fork patch: out-of-thread solving.
*
* Backend (ZCODE_CAPTCHA_BACKEND): "happy" (default) — the happy-dom solver
* in src/proxy/captcha-happy.ts. Runs inside the Bun process; bundled into
* the single-file release binary by `bun build --compile`. No external
* Node.js, no browser. (The historical jsdom and Node-daemon/playwright
* backends have been removed.)
* Backend (ZCODE_CAPTCHA_BACKEND): "happy" (default) -- the happy-dom solver
* in src/proxy/captcha-happy.ts.
*
* Fork change: the solver runs in a worker_threads Worker instead of on the
* main thread. In-process solving froze the proxy's event loop via
* Atomics.wait sync XHRs (each up to 30s), so a burst of parallel solves
* stalled EVERY connection -- the "thinking for minutes after idle" failure.
* Worker solving also isolates happy-dom's global browser-frame/cookie state
* per solve (one Worker per solve, terminated after), removing cross-solve
* races the in-process path suffered under parallel waves.
*
* Bundling: `bun build --compile` cannot resolve `new Worker(new URL(...))`
* at runtime (module paths don't exist in the single-file binary), so the
* worker entry is imported as a build-time FILE ASSET
* (`import entryPath from "./captcha-worker-entry.ts" with { type: "file" }`)
* and extracted to a temp path by the compiled runtime. Verified working in
* compiled exes on Bun 1.4; plain `bun run` resolves the same asset import.
*/
import { Worker } from "node:worker_threads";
// Asset import: embeds the PRE-BUNDLED worker (plain JS, self-contained ESM --
// built by scripts/build-fork-worker.ts before compilation; raw .ts assets
// are not parsed by the compiled runtime). If the bundle is missing, run
// scripts/build-fork-worker.ts to regenerate it (package.json wires it as
// the prebuild hook). @ts-expect-error -- Bun's `with { type: "file" }`
// asset import has no DOM-lib type declaration; the default export is the
// extracted file path at runtime.
// @ts-expect-error asset import
import captchaWorkerEntryPath from "./captcha-worker-entry.bundle.js" with { type: "file" };

const BACKEND = process.env.ZCODE_CAPTCHA_BACKEND?.trim().toLowerCase() || "happy";

let happyMod: typeof import("./captcha-happy.js") | null = null;
/** Per-solve timeout: overall deadline the worker gets before termination. */
const SOLVE_WORKER_TIMEOUT_MS = Number(process.env.CAPTCHA_SOLVE_TIMEOUT_MS || 20_000);

interface SolveRequest {
id: number;
scene: string;
region: string;
prefix: string;
}
type SolveResponse = { id: number; ok: true; param: string } | { id: number; ok: false; error: string };

let nextSolveId = 0;

export async function runCaptchaSolve(scene: string, region: string, prefix: string): Promise<string> {
if (BACKEND !== "happy") {
throw new Error(`captcha backend "${BACKEND}" is not available; use ZCODE_CAPTCHA_BACKEND=happy`);
}
if (!happyMod) happyMod = await import("./captcha-happy.js");
return happyMod.solveTraceless({ scene, region, prefix });
return solveInWorker({ scene, region, prefix });
}

/**
* One solve = one Worker. Startup cost is a few ms (happy-dom loads lazily
* inside the entry on first message); termination guarantees no state leaks
* between solves. A hung solve cannot wedge anything: the pool's takeToken
* race deadline (25s) fires first, and the worker is force-terminated here.
*/
function solveInWorker(req: { scene: string; region: string; prefix: string }): Promise<string> {
const id = ++nextSolveId;
return new Promise<string>((resolve, reject) => {
let settled = false;
let worker: Worker | null = null;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { worker?.terminate(); } catch {}
fn();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`captcha worker timeout (${SOLVE_WORKER_TIMEOUT_MS}ms)`)));
}, SOLVE_WORKER_TIMEOUT_MS);

try {
worker = new Worker(captchaWorkerEntryPath);
} catch (err) {
settle(() => reject(new Error(`captcha worker spawn failed: ${(err as Error).message}`)));
return;
}
const msg: SolveRequest = { id, ...req };
worker.on("message", (m: SolveResponse) => {
if (!m || m.id !== id) return;
if (m.ok) settle(() => resolve(m.param));
else settle(() => reject(new Error(m.error)));
});
worker.on("error", (err: Error) => {
settle(() => reject(new Error(`captcha worker error: ${err.message}`)));
});
worker.on("exit", (code) => {
if (code !== 0 && !settled) {
settle(() => reject(new Error(`captcha worker exited (code ${code}) before solving`)));
} else if (!settled) {
settle(() => reject(new Error("captcha worker exited before responding")));
}
});
worker.postMessage(msg);
});
}

/** In-process solving needs no worker pool management — kept for the pool API. */
/** Worker-per-solve needs no concurrency plumbing -- kept for the pool API. */
export function setCaptchaSolverConcurrency(_n: number): void {}

/** In-process solving needs no worker pool management — kept for the pool API. */
/** Nothing long-lived to shut down: workers are terminated per solve. */
export function shutdownCaptchaSolver(): void {}

export function captchaSolverConcurrency(): number {
Expand Down
32 changes: 32 additions & 0 deletions src/proxy/captcha-worker-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Captcha solver worker entry -- fork patch.
*
* Runs the happy-dom solver (captcha-happy.ts) inside a worker thread so the
* proxy's main event loop NEVER blocks on Atomics.wait sync XHRs. One solve
* per worker at a time: the module's global browser-frame/cookie state is
* per-worker, which also removes the cross-solve global races of in-process
* parallel solving.
*
* Protocol: {id, scene, region, prefix} in -> {id, ok, param|error} out.
* Spawned by captcha-solver.ts via a build-time FILE ASSET import
* (`with { type: "file" }`) -- the only worker mechanism that survives
* `bun build --compile` single-file binaries (verified on Bun 1.4).
*/
import { parentPort } from "node:worker_threads";
import { solveTraceless } from "./captcha-happy.js";

type SolveMsg = { id: number; scene: string; region: string; prefix: string };

const port = parentPort;
if (!port) throw new Error("captcha worker entry requires a worker_threads parent");

port.on("message", (m: SolveMsg) => {
void (async () => {
try {
const param = await solveTraceless({ scene: m.scene, region: m.region, prefix: m.prefix });
port.postMessage({ id: m.id, ok: true, param });
} catch (err) {
port.postMessage({ id: m.id, ok: false, error: String((err as Error)?.message ?? err) });
}
})();
});
36 changes: 28 additions & 8 deletions src/proxy/captcha.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* Aliyun Captcha V3 front-end — config fetch + pre-solved token pool.
* Aliyun Captcha V3 front-end -- config fetch + pre-solved token pool.
*
* Solving itself lives in captcha-happy.ts (in-process happy-dom solver,
* production-proven, self-contained: bundled into the single-file release
* binary — no external Node.js, no browser, no jsdom). Tokens are minted
* binary -- no external Node.js, no browser, no jsdom). Tokens are minted
* into a pool (captcha-pool.ts); requests take an already-solved token
* (sub-ms) while background refills keep the pool warm — the hot path
* (sub-ms) while background refills keep the pool warm -- the hot path
* never waits on a solve.
*
* Fingerprint stability: the happy-dom solver's polyfill/guest-patch values
* are deterministic and STABLE (never randomized) — Aliyun's risk engine
* are deterministic and STABLE (never randomized) -- Aliyun's risk engine
* correlates fingerprint stability across requests; randomizing per-solve
* flags it as `verifyCode: F001`. See captcha-happy.ts.
*/
Expand All @@ -31,6 +31,9 @@ const CONFIGS_API = "https://zcode.z.ai/api/v1/client/configs";

interface FetchedCaptchaConfig { enabled: boolean; prefix: string; sceneId: string; region: string; }
let cachedConfig: { value: FetchedCaptchaConfig | null; expiresAt: number } = { value: null, expiresAt: 0 };
// Fork patch: short negative cache so a network outage doesn't make every
// request pay the config-fetch timeout before falling back.
let cfgNegUntil = 0;

export function detectCaptchaChallenge(resp: Response): string | null {
const v = resp.headers.get(CAPTCHA_HEADER);
Expand All @@ -40,13 +43,30 @@ export function detectCaptchaChallenge(resp: Response): string | null {

async function fetchCaptchaConfig(appVersion: string): Promise<FetchedCaptchaConfig | null> {
if (cachedConfig.value && cachedConfig.expiresAt > Date.now()) return cachedConfig.value;
if (Date.now() < cfgNegUntil) return null;
try {
const resp = await fetch(`${CONFIGS_API}?app_version=${encodeURIComponent(appVersion)}&platform=win32-x64`);
// Fork patch: bounded fetch -- the hot path awaits this (60s cache) and an
// unbounded fetch against zcode.z.ai stalls every request while the
// network is down. 5s cap + fail-open (returns null on error).
// Override: CAPTCHA_CONFIG_TIMEOUT_MS.
const cfgTimeoutMs = Number(process.env.CAPTCHA_CONFIG_TIMEOUT_MS || 5_000);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), cfgTimeoutMs);
let resp: Response;
try {
resp = await fetch(`${CONFIGS_API}?app_version=${encodeURIComponent(appVersion)}&platform=win32-x64`, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
const json = (await resp.json()) as { data?: { configs?: { captcha?: FetchedCaptchaConfig } } };
const cfg = json?.data?.configs?.captcha ?? null;
cachedConfig = { value: cfg, expiresAt: Date.now() + 60000 };
if (!cfg) cfgNegUntil = Date.now() + 15_000;
return cfg;
} catch { return null; }
} catch {
cfgNegUntil = Date.now() + 15_000;
return null;
}
}

/**
Expand All @@ -58,7 +78,7 @@ export async function getCaptchaToken(appVersion: string): Promise<{ verifyParam
const cfg = await fetchCaptchaConfig(appVersion);
if (!cfg || !cfg.enabled || !cfg.prefix || !cfg.sceneId) throw new Error("Captcha config unavailable");
// Pre-solved token pool: requests take an already-minted token (sub-ms)
// while background solves refill — the hot path never waits on a solve.
// while background solves refill -- the hot path never waits on a solve.
const verifyParam = await takeCaptchaToken(cfg);
return { verifyParam, region: cfg.region };
}
Expand All @@ -79,7 +99,7 @@ export async function startCaptchaPool(appVersion: string): Promise<void> {
// first configure() so a cold boot doesn't mint a storm of soon-expired
// tokens. Defaults are sized for start-plan's 5-concurrent-request ceiling:
// worst case ~10 instantaneous takes (5 requests + challenge retries), with
// ~8-24 tokens circulating per 95s TTL — 15 covers that plus F008/expiry
// ~8-24 tokens circulating per 95s TTL -- 15 covers that plus F008/expiry
// discards and bridges a pe-storm mint outage (~30-45s). Mint capacity
// (~4-6/s at concurrency 3) stays an order of magnitude above demand.
// CAPTCHA_POOL_MIN/CAPTCHA_POOL_MAX env vars override the defaults.
Expand Down