File size: 7,394 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | /**
* Shared captcha (3007) challenge detection + single-retry flow.
*
* The challenge arrives in two variants (observed 2026-08-29, PR #38):
* 1. response-header variant β `x-aliyun-captcha-verify-param` set on a
* non-2xx response (`captcha.detectCaptchaChallenge`);
* 2. in-body variant β HTTP 400 with `{"code":3007,...}` in the JSON body
* and no captcha header.
*
* Both the chat hot path (handler.ts) and /v1/responses (responses-handler.ts)
* used to hand-roll this detection and the re-solve/retry, and the copies had
* drifted (error mapping, peek handling). This module is the unified seam.
*
* Runtime-import discipline: only TYPE imports from ./captcha.js β the real
* module (which drags in the happy-dom solver) is lazily loaded by the
* callers and passed in as a value, so non-start-plan processes never pay
* its startup cost.
*/
import type * as CaptchaExports from "./captcha.js";
/** Shape the callers pass in β satisfied by the real captcha module or test fakes. */
export type CaptchaModuleLike = Pick<typeof CaptchaExports, "detectCaptchaChallenge" | "getCaptchaToken" | "RETRY_HEADERS">;
/** Magic strings of the in-body challenge, for both JSON spacing styles. */
export const IN_BODY_CHALLENGE_MARKERS = ['"code":3007', '"code": 3007'] as const;
/** Bound on how many raw (possibly compressed) body bytes a challenge peek reads. */
export const MAX_CHALLENGE_PEEK_BYTES = 64 * 1024;
/** Bound on decompressed sniff output β error bodies are small; more is waste. */
const MAX_SNIFF_INFLATED_BYTES = 256 * 1024;
/**
* Peek (bounded) the body of a non-ok, non-SSE response and detect the
* in-body 3007 challenge. The response stays consumable for the error path:
* the peek reads a CLONE, so the original body is untouched.
*
* Covers both passthrough transports where the body may arrive still
* gzip-compressed (Bun fetch `decompress:false`, ordered raw-TCP): when the
* response advertises `content-encoding: gzip|x-gzip`, the peek inflates
* before sniffing. On a runtime that already auto-inflated the body while
* keeping the header (Node undici), inflation fails and the raw bytes are
* sniffed as-is.
*/
export async function detectInBodyChallenge(resp: Response): Promise<boolean> {
if (resp.ok) return false;
const ctype = resp.headers.get("content-type") ?? "";
if (ctype.includes("text/event-stream")) return false;
let bytes: Uint8Array;
try {
bytes = await peekBodyBytes(resp, MAX_CHALLENGE_PEEK_BYTES);
} catch {
return false;
}
if (bytes.byteLength === 0) return false;
const text = await decodeMaybeGzip(bytes, resp.headers.get("content-encoding"));
return IN_BODY_CHALLENGE_MARKERS.some((marker) => text.includes(marker));
}
/** Unified challenge check: response-header variant first, then in-body peek. */
export async function isCaptchaChallenged(
resp: Response,
captcha: Pick<typeof CaptchaExports, "detectCaptchaChallenge">,
): Promise<boolean> {
if (captcha.detectCaptchaChallenge(resp) !== null) return true;
return detectInBodyChallenge(resp);
}
export interface CaptchaRetryArgs {
captcha: CaptchaModuleLike;
appVersion: string;
/** The challenged response; its body is cancelled before the retry. */
challengedResp: Response;
/** Dispatch the retry. Receives the fresh captcha headers; must return the upstream Response. */
solveAndRetry: (retryHeaders: Record<string, string>) => Promise<Response>;
/**
* Error mapping (unified contract, mirrors handler.ts's correct shape):
* "solver" β the re-solve (getCaptchaToken) failed
* "dispatch" β the retry dispatch failed at the network level
*/
mapError: (err: Error, phase: "solver" | "dispatch") => Response;
/** Optional debug line sink. */
debug?: (message: string) => void;
}
/**
* Shared single-retry flow for a detected captcha challenge: cancel the old
* body, re-solve a fresh token, rebuild + re-dispatch ONCE (the challenged
* token was already consumed by this request). Never loops.
*
* The caller MUST return `ok:false` responses directly to the client β they
* are already fully-formed error responses (solver β 503 captcha_solver_failed,
* dispatch β 502 upstream_unreachable); feeding them back through the
* upstream `!ok` branch would wrap the error body a second time.
*/
export async function retryOnCaptchaChallenge(args: CaptchaRetryArgs): Promise<{ ok: true; resp: Response } | { ok: false; resp: Response }> {
const { captcha, appVersion, challengedResp, solveAndRetry, mapError, debug } = args;
debug?.("captcha challenge β re-solving and retrying once");
try {
await challengedResp.body?.cancel();
} catch {
// already drained/cancelled
}
let fresh: { verifyParam: string; region: string };
try {
fresh = await captcha.getCaptchaToken(appVersion);
} catch (err) {
return { ok: false, resp: mapError(err as Error, "solver") };
}
const retryHeaders: Record<string, string> = {
[captcha.RETRY_HEADERS.PARAM]: fresh.verifyParam,
[captcha.RETRY_HEADERS.REGION]: fresh.region,
};
try {
return { ok: true, resp: await solveAndRetry(retryHeaders) };
} catch (err) {
return { ok: false, resp: mapError(err as Error, "dispatch") };
}
}
/** Read at most `limit` bytes from a clone of the response body. */
async function peekBodyBytes(resp: Response, limit: number): Promise<Uint8Array> {
const body = resp.clone().body;
if (!body) return new Uint8Array(0);
const reader = body.getReader();
const parts: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value);
total += value.byteLength;
if (total >= limit) break;
}
} finally {
reader.cancel().catch(() => {});
reader.releaseLock?.();
}
const all = Buffer.concat(parts);
return all.byteLength > limit ? all.subarray(0, limit) : all;
}
/**
* Decode sniff bytes, inflating first when the response is labeled gzip.
* TextDecoder is non-fatal, so invalid UTF-8 degrades to replacement chars β
* the ASCII magic strings still match.
*/
async function decodeMaybeGzip(bytes: Uint8Array, encodingHeader: string | null): Promise<string> {
const enc = encodingHeader?.toLowerCase().trim() ?? "";
if (enc === "gzip" || enc === "x-gzip") {
try {
return new TextDecoder().decode(await inflateBounded(bytes));
} catch {
// Runtime already inflated the body while keeping the header (Node
// undici) β sniff the bytes as-is.
}
}
return new TextDecoder().decode(bytes);
}
async function inflateBounded(bytes: Uint8Array): Promise<Uint8Array> {
const gunzip = new DecompressionStream("gzip") as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
const source = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
const reader = source.pipeThrough(gunzip).getReader();
const parts: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value);
total += value.byteLength;
if (total >= MAX_SNIFF_INFLATED_BYTES) break;
}
} finally {
reader.cancel().catch(() => {});
reader.releaseLock?.();
}
return Buffer.concat(parts);
}
|