Skip to content
Open
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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ node_modules/

# Browser binary
camoufox/
camoufox-macos/

# Temporary files
tmp/
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ tmp/
temp/
ui/dist/
*.css
test/
debug_*.png
debug_*.html
proxylist.txt
Expand Down
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ build/

# Camoufox directory
camoufox/
camoufox-macos/

# Logs
*.log
Expand All @@ -32,3 +33,8 @@ Thumbs.db
# Temporary files
*.tmp
*.temp

# Local tooling and rejected configs
.npm-cache/
.serena/
*.REJECTED.yaml
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ services:
| `ENABLE_AUTH_UPDATE` | 是否启用自动保存凭证更新。默认为启用状态,将在每次登录/切换账号成功时以及每 24 小时自动更新 auth 文件。设为 `false` 禁用。 | `true` |
| `MAX_RETRIES` | 请求失败后的最大重试次数(仅对假流式和非流式生效)。 | `3` |
| `RETRY_DELAY` | 两次重试之间的间隔(毫秒)。 | `2000` |
| `STREAM_TIMEOUT_MS` | 真流式响应相邻数据块之间的超时时间(毫秒),最大 `300000`。 | `60000` |
| `STREAM_TIMEOUT_MS` | 真流式响应相邻数据块之间的超时时间(毫秒),默认值为 `0`(禁用超时),设为正值启用,最大 `300000`。 | `0` |
| `FAKE_STREAM_TIMEOUT_MS` | 假流式/非流式缓冲响应的超时时间(毫秒),最大 `300000`。 | `300000` |
| `SWITCH_ON_USES` | 自动切换帐户前允许的请求次数(设为 `0` 禁用)。 | `40` |
| `FAILURE_THRESHOLD` | 切换帐户前允许的连续失败次数(设为 `0` 禁用)。 | `3` |
Expand Down
2 changes: 1 addition & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ Usage:
| `ENABLE_AUTH_UPDATE` | Whether to enable automatic auth credential updates. Defaults to enabled. The auth file will be automatically updated upon successful login/account switch and every 24 hours. Set to `false` to disable. | `true` |
| `MAX_RETRIES` | Maximum number of retries for failed requests (only effective for fake streaming and non-streaming). | `3` |
| `RETRY_DELAY` | Delay between retries in milliseconds. | `2000` |
| `STREAM_TIMEOUT_MS` | Timeout between real streaming chunks, in milliseconds. Maximum: `300000`. | `60000` |
| `STREAM_TIMEOUT_MS` | Timeout between real streaming chunks, in milliseconds. Default is `0` (disabled); set a positive value to enable. Maximum: `300000`. | `0` |
| `FAKE_STREAM_TIMEOUT_MS` | Timeout for fake streaming / non-streaming buffered responses, in milliseconds. Maximum: `300000`. | `300000` |
| `SWITCH_ON_USES` | Number of requests before automatically switching accounts (`0` to disable). | `40` |
| `FAILURE_THRESHOLD` | Number of consecutive failures before switching accounts (`0` to disable). | `3` |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"lint:js:fix": "eslint . --fix",
"lint:css:fix": "stylelint \"ui/**/*.{css,less}\" --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"test": "node --test"
},
"dependencies": {
"archiver": "^7.0.1",
Expand Down
62 changes: 32 additions & 30 deletions scripts/client/index.html
Original file line number Diff line number Diff line change
@@ -1,35 +1,37 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your App</title>
<script>
(function () {
// Create promise for build.js to await
var resolveAuthIndex;
window.__authIndexReady = new Promise(function(resolve) { resolveAuthIndex = resolve; });
<head>
<meta charset="UTF-8" />
<title>Your App</title>
<script>
(function () {
// Create promise for build.js to await
var resolveAuthIndex;
window.__authIndexReady = new Promise(function (resolve) {
resolveAuthIndex = resolve;
});

window.addEventListener("message", function (event) {
if (event.data && event.data.type === "authIndexResponse") {
var authIndex = parseInt(event.data.authIndex, 10);
if (!isNaN(authIndex) && authIndex >= 0) {
if (!window.chrome) window.chrome = {};
window.chrome._contextId = authIndex;
console.log("[Init] ✅ Got authIndex:", authIndex);
resolveAuthIndex(authIndex);
window.addEventListener("message", function (event) {
if (event.data && event.data.type === "authIndexResponse") {
var authIndex = parseInt(event.data.authIndex, 10);
if (!isNaN(authIndex) && authIndex >= 0) {
if (!window.chrome) window.chrome = {};
window.chrome._contextId = authIndex;
console.log("[Init] ✅ Got authIndex:", authIndex);
resolveAuthIndex(authIndex);
}
}
}
});
});

// Immediately request authIndex from parent
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: "requestAuthIndex" }, "*");
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/index.ts"></script>
</body>
// Immediately request authIndex from parent
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: "requestAuthIndex" }, "*");
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/index.ts"></script>
</body>
</html>
79 changes: 67 additions & 12 deletions src/auth/AuthSwitcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
* Handles account switching logic including single/multi-account modes and fallback mechanisms
*/
class AuthSwitcher {
// Dispose a context only after this many consecutive empty-upstream judgments on the SAME context.
// Prevents a hot dispose/recreate loop when every account is judged empty (e.g. detector false-positive).
static EMPTY_DISPOSE_THRESHOLD = 3;

constructor(logger, config, authSource, browserManager) {
this.logger = logger;
this.config = config;
Expand All @@ -18,6 +22,8 @@ class AuthSwitcher {
this.failureCount = 0;
this.usageCount = 0;
this.isSystemBusy = false;
// authIndex -> consecutive empty_upstream_response judgment count.
this._emptyJudgmentCounts = new Map();
}

get currentAuthIndex() {
Expand All @@ -27,6 +33,17 @@ class AuthSwitcher {
set currentAuthIndex(value) {
this.browserManager.currentAuthIndex = value;
}
/**
* Reset the consecutive empty-upstream judgment counter for a successful auth index.
* A success on an account means its next empty judgment starts counting from 1 again;
* only consecutive empties without an intervening success may reach the dispose threshold.
* @param {number|null} authIndex - The account index that served a successful request.
*/
resetEmptyJudgmentCountForAuth(authIndex) {
if (Number.isInteger(authIndex) && authIndex >= 0) {
this._emptyJudgmentCounts.delete(authIndex);
}
}

// getNextAuthIndex() {
// const available = this.authSource.getRotationIndices();
Expand All @@ -49,7 +66,7 @@ class AuthSwitcher {
// return available[nextIndexInArray];
// }

async switchToNextAuth() {
async switchToNextAuth(failedAuthIndex = this.currentAuthIndex, allowOriginalFallback = true) {
const available = this.authSource.getRotationIndices();

if (available.length === 0) {
Expand All @@ -64,6 +81,28 @@ class AuthSwitcher {
this.isSystemBusy = true;

try {
const getCurrentCanonicalIndex = () =>
failedAuthIndex >= 0 ? this.authSource.getCanonicalIndex(failedAuthIndex) : -1;

if (failedAuthIndex >= 0) {
const emptyCount = this._emptyJudgmentCounts.get(failedAuthIndex) || 0;
// Churn guard: dispose a context only after K consecutive empty-upstream judgments
// on it. Non-empty failures (429/403/5xx) never dispose — the context stays warm
// and the account recovers after cooldown, keeping switching instant.
if (emptyCount >= AuthSwitcher.EMPTY_DISPOSE_THRESHOLD) {
this.logger.info(
`🗑️ [Auth] Disposing tainted context #${failedAuthIndex} on account switch/retry...`
);
await this.browserManager.closeContext(failedAuthIndex).catch(err => {
this.logger.warn(`[Auth] Failed to close context #${failedAuthIndex}: ${err.message}`);
});
if (emptyCount > 0) this._emptyJudgmentCounts.delete(failedAuthIndex);
} else {
this.logger.info(
`🛡️ [Auth] Skipping context #${failedAuthIndex} disposal (${emptyCount}/${AuthSwitcher.EMPTY_DISPOSE_THRESHOLD} consecutive empty judgments) to avoid churn.`
);
}
}
// Single account mode
if (available.length === 1) {
const singleIndex = available[0];
Expand All @@ -76,6 +115,7 @@ class AuthSwitcher {

try {
await this.browserManager.launchOrSwitchContext(singleIndex);
this._emptyJudgmentCounts.delete(singleIndex);
this.resetCounters();
this.browserManager.rebalanceContextPool().catch(err => {
this.logger.error(`[Auth] Background rebalance failed: ${err.message}`);
Expand All @@ -92,18 +132,14 @@ class AuthSwitcher {
}

// Multi-account mode
const currentCanonicalIndex =
this.currentAuthIndex >= 0
? this.authSource.getCanonicalIndex(this.currentAuthIndex)
: this.currentAuthIndex;
const currentIndexInArray = available.indexOf(currentCanonicalIndex);
const currentIndexInArray = available.indexOf(getCurrentCanonicalIndex());
const hasCurrentAccount = currentIndexInArray !== -1;
const startIndex = hasCurrentAccount ? currentIndexInArray : 0;
const originalStartAccount = hasCurrentAccount ? available[startIndex] : null;

this.logger.info("==================================================");
this.logger.info(`🔄 [Auth] Multi-account mode: Starting intelligent account switching`);
this.logger.info(` • Current account: #${this.currentAuthIndex}`);
this.logger.info(` • Failed account: #${failedAuthIndex}`);
this.logger.info(
` • Available accounts (dedup by email, keeping latest index): [${available.join(", ")}]`
);
Expand All @@ -129,6 +165,7 @@ class AuthSwitcher {
`🔄 [Auth] Attempting to switch to account #${accountIndex} (${attemptNumber}/${tryCount} accounts)...`
);

const prevIdx = this.currentAuthIndex;
try {
// Pre-cleanup: remove excess contexts BEFORE creating new one to avoid exceeding maxContexts
await this.browserManager.preCleanupForSwitch(accountIndex);
Expand All @@ -151,13 +188,15 @@ class AuthSwitcher {
return { failedAccounts, newIndex: accountIndex, success: true };
} catch (error) {
this.logger.error(`❌ [Auth] Account #${accountIndex} failed: ${error.message}`);
if (this.browserManager.currentAuthIndex === accountIndex) {
this.browserManager.currentAuthIndex = prevIdx;
}
failedAccounts.push(accountIndex);
}
}

// If we had a current account, try it as a final fallback
// If we had no current account, we already tried all accounts, so skip fallback
if (hasCurrentAccount && originalStartAccount !== null) {
// Manual rotation may fall back to the original account; failure recovery must not retry it.
if (allowOriginalFallback && hasCurrentAccount && originalStartAccount !== null) {
this.logger.warn("==================================================");
this.logger.warn(
`⚠️ [Auth] All other accounts failed. Making final attempt with original starting account #${originalStartAccount}...`
Expand Down Expand Up @@ -255,7 +294,23 @@ class AuthSwitcher {
);
}

const isImmediateSwitch = this.config.immediateSwitchStatusCodes.includes(errorDetails.status);
const isImmediateSwitch =
this.config.immediateSwitchStatusCodes.includes(errorDetails.status) ||
errorDetails.status === 502 ||
errorDetails.reason === "empty_upstream_response";

// Track consecutive empty-upstream judgments per context so we don't dispose/recreate
// contexts in a hot loop when every account is judged empty. Reset on any non-empty failure.
const idx = Number.isInteger(errorDetails.authIndex) ? errorDetails.authIndex : this.currentAuthIndex;
if (errorDetails.reason === "empty_upstream_response") {
if (idx >= 0) {
this._emptyJudgmentCounts.set(idx, (this._emptyJudgmentCounts.get(idx) || 0) + 1);
}
} else {
if (idx >= 0) {
this._emptyJudgmentCounts.delete(idx);
}
}
const isThresholdReached =
this.config.failureThreshold > 0 && this.failureCount >= this.config.failureThreshold;

Expand All @@ -271,7 +326,7 @@ class AuthSwitcher {
}

try {
const result = await this.switchToNextAuth();
const result = await this.switchToNextAuth(idx, false);
if (!result.success) {
this.logger.warn(`⚠️ [Auth] Account switch skipped: ${result.reason}`);
if (sendErrorCallback) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/BrowserManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -2619,6 +2619,10 @@ class BrowserManager {

const contextData = this.contexts.get(authIndex);

// NOTE: no in-flight-deferral here. `activeRequests` is not maintained anywhere, so a
// deferred-disposal path would be dead code that leaks the context (never actually closed).
// Dispose immediately; any in-flight request on this context is already failing over to
// the next account via the auth-switcher before closeContext is called.
// Stop health monitor for this context
if (contextData.healthMonitorInterval) {
clearInterval(contextData.healthMonitorInterval);
Expand Down
8 changes: 4 additions & 4 deletions src/core/ConnectionRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ class ConnectionRegistry extends EventEmitter {
);
return;
}
this._routeMessage(parsedMessage, entry.queue);
this._routeMessage(parsedMessage, entry.queue, entry.authIndex);
} else {
this.logger.warn(`[Server] Received message for unknown or outdated request ID: ${requestId}`);
}
Expand All @@ -279,16 +279,16 @@ class ConnectionRegistry extends EventEmitter {
}
}

_routeMessage(message, queue) {
_routeMessage(message, queue, authIndex = null) {
const { event_type } = message;
switch (event_type) {
case "response_headers":
case "chunk":
case "error":
queue.enqueue(message);
queue.enqueue(Number.isInteger(authIndex) ? { ...message, authIndex } : message);
break;
case "stream_close":
queue.enqueue({ type: "STREAM_END" });
queue.enqueue(Number.isInteger(authIndex) ? { authIndex, type: "STREAM_END" } : { type: "STREAM_END" });
break;
default:
this.logger.warn(`[Server] Unknown internal event type: ${event_type}`);
Expand Down
Loading