mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: sub2api T05/T08/T09/T13/T14 + bump to 3.0.0-rc.7
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -6,9 +6,9 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.6] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_
|
||||
## [3.0.0-rc.7] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_
|
||||
|
||||
> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 10 sub2api gap improvements.
|
||||
> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 15 sub2api gap improvements (T01–T15 complete).
|
||||
|
||||
### 🆕 New Providers
|
||||
|
||||
@@ -111,6 +111,22 @@ OmniRoute now automatically refreshes model lists for connected providers every
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.7] - 2026-03-23
|
||||
|
||||
### 🔧 Improvements (sub2api Gap Analysis — T05, T08, T09, T13, T14)
|
||||
|
||||
- **T05** — Rate-limit DB persistence: `setConnectionRateLimitUntil()`, `isConnectionRateLimited()`, `getRateLimitedConnections()` in `providers.ts`. The existing `rate_limited_until` column is now exposed as a dedicated API — OAuth token refresh must NOT touch this field to prevent rate-limit loops.
|
||||
- **T08** — Per-API-key session limit: `max_sessions INTEGER DEFAULT 0` added to `api_keys` via auto-migration. `sessionManager.ts` gains `registerKeySession()`, `unregisterKeySession()`, `checkSessionLimit()`, and `getActiveSessionCountForKey()`. Callers in `chatCore.js` can enforce the limit and decrement on `req.close`.
|
||||
- **T09** — Codex vs Spark rate-limit scopes: `getCodexModelScope()` and `getCodexRateLimitKey()` in `codex.ts`. Standard models (`gpt-5.x-codex`, `codex-mini`) get scope `"codex"`; spark models (`codex-spark*`) get scope `"spark"`. Rate-limit keys should be `${accountId}:${scope}` so exhausting one pool doesn't block the other.
|
||||
- **T13** — Stale quota display fix: `getEffectiveQuotaUsage(used, resetAt)` returns `0` when the reset window has passed; `formatResetCountdown(resetAt)` returns a human-readable countdown string (e.g. `"2h 35m"`). Both exported from `providers.ts` + `localDb.ts` for dashboard consumption.
|
||||
- **T14** — Proxy fast-fail: new `src/lib/proxyHealth.ts` with `isProxyReachable(proxyUrl, timeoutMs=2000)` (TCP check, ≤2s instead of 30s timeout), `getCachedProxyHealth()`, `invalidateProxyHealth()`, and `getAllProxyHealthStatuses()`. Results cached 30s by default; configurable via `PROXY_FAST_FAIL_TIMEOUT_MS` / `PROXY_HEALTH_CACHE_TTL_MS`.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **832 tests, 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.6] - 2026-03-23
|
||||
|
||||
### 🔧 Bug Fixes & Improvements (sub2api Gap Analysis — T01–T15)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.0.0-rc.6
|
||||
version: 3.0.0-rc.7
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -3,6 +3,46 @@ import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
||||
|
||||
// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ────────────────────────
|
||||
// Codex has two independent quota pools: "codex" (standard) and "spark" (premium).
|
||||
// Exhausting one should NOT block requests to the other.
|
||||
// Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex)
|
||||
|
||||
/**
|
||||
* Maps model name substrings to their rate-limit scope.
|
||||
* Checked in order — first match wins.
|
||||
*/
|
||||
const CODEX_SCOPE_PATTERNS: Array<{ pattern: string; scope: "codex" | "spark" }> = [
|
||||
{ pattern: "codex-spark", scope: "spark" },
|
||||
{ pattern: "spark", scope: "spark" },
|
||||
{ pattern: "codex", scope: "codex" },
|
||||
{ pattern: "gpt-5", scope: "codex" }, // gpt-5.2-codex, gpt-5.3-codex, etc.
|
||||
];
|
||||
|
||||
/**
|
||||
* T09: Determine the rate-limit scope for a Codex model.
|
||||
* Use this key as the suffix for per-scope rate limit state:
|
||||
* `${accountId}:${getModelScope(model)}`
|
||||
*
|
||||
* @param model - The Codex model ID (e.g. "gpt-5.3-codex", "codex-spark-mini")
|
||||
* @returns "codex" | "spark"
|
||||
*/
|
||||
export function getCodexModelScope(model: string): "codex" | "spark" {
|
||||
const lower = model.toLowerCase();
|
||||
for (const { pattern, scope } of CODEX_SCOPE_PATTERNS) {
|
||||
if (lower.includes(pattern)) return scope;
|
||||
}
|
||||
return "codex"; // default scope
|
||||
}
|
||||
|
||||
/**
|
||||
* T09: Get the scope-keyed rate limit identifier for an account+model combination.
|
||||
* Use this as the key for rateLimitState maps to ensure scope isolation.
|
||||
*/
|
||||
export function getCodexRateLimitKey(accountId: string, model: string): string {
|
||||
return `${accountId}:${getCodexModelScope(model)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* T03: Parsed quota snapshot from Codex response headers.
|
||||
* Codex includes per-account usage windows that allow precise reset scheduling.
|
||||
|
||||
@@ -173,6 +173,69 @@ export function getActiveSessions(): Array<SessionEntry & { sessionId: string; a
|
||||
*/
|
||||
export function clearSessions(): void {
|
||||
sessions.clear();
|
||||
activeSessionsByKey.clear();
|
||||
}
|
||||
|
||||
// ─── T08: Per-API-Key Session Limit ─────────────────────────────────────────
|
||||
// Tracks concurrent sticky sessions per API key and enforces max_sessions limits.
|
||||
// Ref: sub2api PR #634 (fix: stabilize session hash + add user-level session limit)
|
||||
|
||||
// Map: apiKeyId → Set<sessionId>
|
||||
const activeSessionsByKey = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* T08: Get the number of currently active sessions for an API key.
|
||||
* @param apiKeyId - The API key's UUID from the database
|
||||
*/
|
||||
export function getActiveSessionCountForKey(apiKeyId: string): number {
|
||||
return activeSessionsByKey.get(apiKeyId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Register a session as belonging to an API key.
|
||||
* Call this after session creation is allowed (i.e., limit check passed).
|
||||
*/
|
||||
export function registerKeySession(apiKeyId: string, sessionId: string): void {
|
||||
if (!activeSessionsByKey.has(apiKeyId)) {
|
||||
activeSessionsByKey.set(apiKeyId, new Set());
|
||||
}
|
||||
activeSessionsByKey.get(apiKeyId)!.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Unregister a session from an API key's active set.
|
||||
* Call this when the request closes or the session TTL expires.
|
||||
*/
|
||||
export function unregisterKeySession(apiKeyId: string, sessionId: string): void {
|
||||
activeSessionsByKey.get(apiKeyId)?.delete(sessionId);
|
||||
// Clean up empty sets to avoid memory leaks
|
||||
if (activeSessionsByKey.get(apiKeyId)?.size === 0) {
|
||||
activeSessionsByKey.delete(apiKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Check whether adding a new session would exceed the key's max_sessions limit.
|
||||
* Returns null if allowed, or an error object to return as a 429 response.
|
||||
*
|
||||
* @param apiKeyId - The API key's UUID
|
||||
* @param maxSessions - The limit from the DB (0 = unlimited)
|
||||
*/
|
||||
export function checkSessionLimit(
|
||||
apiKeyId: string,
|
||||
maxSessions: number
|
||||
): { code: "SESSION_LIMIT_EXCEEDED"; message: string; limit: number; current: number } | null {
|
||||
if (!maxSessions || maxSessions <= 0) return null; // unlimited
|
||||
const current = getActiveSessionCountForKey(apiKeyId);
|
||||
if (current < maxSessions) return null;
|
||||
return {
|
||||
code: "SESSION_LIMIT_EXCEEDED",
|
||||
message:
|
||||
`You have reached the maximum number of active sessions (${maxSessions}). ` +
|
||||
`Please close unused sessions or wait for them to expire.`,
|
||||
limit: maxSessions,
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.6",
|
||||
"version": "3.0.0-rc.7",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -40,6 +40,8 @@ interface ApiKeyMetadata {
|
||||
accessSchedule: AccessSchedule | null;
|
||||
maxRequestsPerDay: number | null;
|
||||
maxRequestsPerMinute: number | null;
|
||||
// T08: Per-key max concurrent sticky sessions (0 = unlimited)
|
||||
maxSessions: number;
|
||||
}
|
||||
|
||||
interface ApiKeyRow extends JsonRecord {
|
||||
@@ -197,6 +199,11 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_minute INTEGER");
|
||||
console.log("[DB] Added api_keys.max_requests_per_minute column");
|
||||
}
|
||||
// T08: max concurrent sticky sessions per key (0 = unlimited)
|
||||
if (!columnNames.has("max_sessions")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN max_sessions INTEGER NOT NULL DEFAULT 0");
|
||||
console.log("[DB] Added api_keys.max_sessions column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -222,7 +229,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
|
||||
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
|
||||
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
|
||||
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute FROM api_keys WHERE key = ?"
|
||||
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions FROM api_keys WHERE key = ?"
|
||||
);
|
||||
_stmtInsertKey = db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
@@ -418,6 +425,8 @@ export async function updateApiKeyPermissions(
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
// T08: max concurrent sessions for this key (0 = unlimited)
|
||||
maxSessions?: number | null;
|
||||
}
|
||||
) {
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
@@ -436,6 +445,7 @@ export async function updateApiKeyPermissions(
|
||||
accessSchedule: update.accessSchedule,
|
||||
maxRequestsPerDay: update.maxRequestsPerDay,
|
||||
maxRequestsPerMinute: update.maxRequestsPerMinute,
|
||||
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -447,7 +457,8 @@ export async function updateApiKeyPermissions(
|
||||
normalized.isActive === undefined &&
|
||||
normalized.accessSchedule === undefined &&
|
||||
normalized.maxRequestsPerDay === undefined &&
|
||||
normalized.maxRequestsPerMinute === undefined
|
||||
normalized.maxRequestsPerMinute === undefined &&
|
||||
(normalized as Record<string, unknown>).maxSessions === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -464,6 +475,7 @@ export async function updateApiKeyPermissions(
|
||||
accessSchedule?: string | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
maxSessions?: number;
|
||||
} = { id };
|
||||
|
||||
if (normalized.name !== undefined) {
|
||||
@@ -514,6 +526,12 @@ export async function updateApiKeyPermissions(
|
||||
params.maxRequestsPerMinute = normalized.maxRequestsPerMinute;
|
||||
}
|
||||
|
||||
const maxSessionsUpdate = (normalized as Record<string, unknown>).maxSessions;
|
||||
if (maxSessionsUpdate !== undefined) {
|
||||
updates.push("max_sessions = @maxSessions");
|
||||
params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0;
|
||||
}
|
||||
|
||||
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
@@ -605,6 +623,8 @@ export async function getApiKeyMetadata(
|
||||
const rawMaxRPD = record.max_requests_per_day ?? record.maxRequestsPerDay;
|
||||
const rawMaxRPM = record.max_requests_per_minute ?? record.maxRequestsPerMinute;
|
||||
|
||||
const rawMaxSessions = record.max_sessions ?? record.maxSessions;
|
||||
|
||||
const metadata: ApiKeyMetadata = {
|
||||
id: metadataId,
|
||||
name: metadataName,
|
||||
@@ -619,6 +639,8 @@ export async function getApiKeyMetadata(
|
||||
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
|
||||
maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null,
|
||||
maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null,
|
||||
// T08: max concurrent sessions; 0 = unlimited (default & backward-compatible)
|
||||
maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0,
|
||||
};
|
||||
|
||||
if (!metadata.id) {
|
||||
|
||||
@@ -513,3 +513,98 @@ export async function deleteProviderNode(id: string) {
|
||||
backupDbFile("pre-write");
|
||||
return rowToCamel(existing);
|
||||
}
|
||||
|
||||
// ──────────────── T05: Rate-Limit DB Persistence ──────────────────────────
|
||||
// Allows rate-limit state to survive token refresh without being accidentally
|
||||
// cleared. DB column rate_limited_until already exists in schema.
|
||||
// Ref: sub2api PR #1218 (fix(openai): prevent rescheduling rate-limited accounts)
|
||||
|
||||
/**
|
||||
* T05: Persist when a connection is rate-limited, directly in DB.
|
||||
* This survives token refresh — OAuth flows must NOT override this field.
|
||||
*
|
||||
* @param connectionId - The provider_connections.id
|
||||
* @param until - Epoch ms when the rate limit expires (null to clear)
|
||||
*/
|
||||
export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
db.prepare(
|
||||
"UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?"
|
||||
).run(until, new Date().toISOString(), connectionId);
|
||||
invalidateDbCache("connections");
|
||||
}
|
||||
|
||||
/**
|
||||
* T05: Check if a connection is currently rate-limited (DB-backed).
|
||||
* Use this before account selection to skip transiently rate-limited accounts.
|
||||
*
|
||||
* @returns true if rate_limited_until is set and in the future
|
||||
*/
|
||||
export function isConnectionRateLimited(connectionId: string): boolean {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db
|
||||
.prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?")
|
||||
.get(connectionId) as { rate_limited_until?: number | null } | undefined;
|
||||
if (!row?.rate_limited_until) return false;
|
||||
return Date.now() < row.rate_limited_until;
|
||||
}
|
||||
|
||||
/**
|
||||
* T05: Get all connections for a provider that are currently rate-limited.
|
||||
* Returns an array of { id, rateLimitedUntil } for dashboard display.
|
||||
*/
|
||||
export function getRateLimitedConnections(
|
||||
provider: string
|
||||
): Array<{ id: string; rateLimitedUntil: number }> {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const now = Date.now();
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, rate_limited_until FROM provider_connections WHERE provider = ? AND rate_limited_until > ?"
|
||||
)
|
||||
.all(provider, now) as Array<{ id: string; rate_limited_until: number }>;
|
||||
return rows.map((r) => ({ id: r.id, rateLimitedUntil: r.rate_limited_until }));
|
||||
}
|
||||
|
||||
// ──────────────── T13: Stale Quota Display Fix ─────────────────────────────
|
||||
// Codex/Claude quotas display stale cumulative usage after the window resets.
|
||||
// By comparing resetAt timestamp to now(), we can show 0 when window has passed.
|
||||
// Ref: sub2api PR #1171 (fix: quota display shows stale cumulative usage after reset)
|
||||
|
||||
/**
|
||||
* T13: Get effective quota usage, zeroing it out if the window has already reset.
|
||||
*
|
||||
* @param used - Stored usage value (tokens used in the window)
|
||||
* @param resetAt - ISO-8601 string or epoch ms when the window resets, or null
|
||||
* @returns Effective usage: 0 if window expired, original value otherwise
|
||||
*/
|
||||
export function getEffectiveQuotaUsage(
|
||||
used: number,
|
||||
resetAt: string | number | null | undefined
|
||||
): number {
|
||||
if (!resetAt) return used;
|
||||
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
||||
if (isNaN(resetTime)) return used;
|
||||
// Window has passed — display should show 0 (pending next snapshot)
|
||||
if (Date.now() >= resetTime) return 0;
|
||||
return used;
|
||||
}
|
||||
|
||||
/**
|
||||
* T13: Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s".
|
||||
* Returns null if resetAt is in the past or not set.
|
||||
*/
|
||||
export function formatResetCountdown(resetAt: string | number | null | undefined): string | null {
|
||||
if (!resetAt) return null;
|
||||
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
||||
if (isNaN(resetTime)) return null;
|
||||
const diffMs = resetTime - Date.now();
|
||||
if (diffMs <= 0) return null;
|
||||
const totalSeconds = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ export {
|
||||
createProviderNode,
|
||||
updateProviderNode,
|
||||
deleteProviderNode,
|
||||
|
||||
// T05: Rate-limit DB persistence (survives token refresh)
|
||||
setConnectionRateLimitUntil,
|
||||
isConnectionRateLimited,
|
||||
getRateLimitedConnections,
|
||||
|
||||
// T13: Stale quota display fix (zero out usage after window resets)
|
||||
getEffectiveQuotaUsage,
|
||||
formatResetCountdown,
|
||||
} from "./db/providers";
|
||||
|
||||
export {
|
||||
|
||||
140
src/lib/proxyHealth.ts
Normal file
140
src/lib/proxyHealth.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* T14: Proxy Fast-Fail — TCP health check with in-memory cache.
|
||||
*
|
||||
* When a configured HTTP/SOCKS5 proxy is unreachable, every request
|
||||
* through OmniRoute used to wait for the full PROXY_TIMEOUT_MS (30s)
|
||||
* before failing. This module detects dead proxies in <2s via a quick
|
||||
* TCP connection check, caching the result to avoid overhead per request.
|
||||
*
|
||||
* Ref: sub2api PR #1167 (fix: proxy-fast-fail)
|
||||
*/
|
||||
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
// Configurable via env vars
|
||||
const FAST_FAIL_TIMEOUT_MS = parseInt(process.env.PROXY_FAST_FAIL_TIMEOUT_MS ?? "2000", 10);
|
||||
const HEALTH_CACHE_TTL_MS = parseInt(process.env.PROXY_HEALTH_CACHE_TTL_MS ?? "30000", 10);
|
||||
|
||||
interface ProxyHealthEntry {
|
||||
healthy: boolean;
|
||||
checkedAt: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
// In-memory cache: proxyUrl → health entry
|
||||
const proxyHealthCache = new Map<string, ProxyHealthEntry>();
|
||||
|
||||
/**
|
||||
* T14: Perform a fast TCP check to see if a proxy host:port is reachable.
|
||||
* Results are cached for `cacheTtlMs` (default 30s) to avoid checking every request.
|
||||
*
|
||||
* @param proxyUrl - Full proxy URL, e.g. http://user:pass@1.2.3.4:8080
|
||||
* @param timeoutMs - TCP connection timeout (default 2000ms)
|
||||
* @param cacheTtlMs - How long to cache the health result (default 30000ms)
|
||||
* @returns true if proxy TCP port is open, false otherwise
|
||||
*/
|
||||
export async function isProxyReachable(
|
||||
proxyUrl: string,
|
||||
timeoutMs = FAST_FAIL_TIMEOUT_MS,
|
||||
cacheTtlMs = HEALTH_CACHE_TTL_MS
|
||||
): Promise<boolean> {
|
||||
const cached = proxyHealthCache.get(proxyUrl);
|
||||
if (cached && Date.now() - cached.checkedAt < cached.ttlMs) {
|
||||
return cached.healthy;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(proxyUrl);
|
||||
} catch {
|
||||
// Malformed URL — treat as unreachable
|
||||
proxyHealthCache.set(proxyUrl, {
|
||||
healthy: false,
|
||||
checkedAt: Date.now(),
|
||||
ttlMs: cacheTtlMs,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = url.hostname;
|
||||
const port = parseInt(url.port || defaultPortForScheme(url.protocol), 10);
|
||||
|
||||
if (!host || isNaN(port)) {
|
||||
proxyHealthCache.set(proxyUrl, {
|
||||
healthy: false,
|
||||
checkedAt: Date.now(),
|
||||
ttlMs: cacheTtlMs,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const healthy = await tcpCheck(host, port, timeoutMs);
|
||||
proxyHealthCache.set(proxyUrl, { healthy, checkedAt: Date.now(), ttlMs: cacheTtlMs });
|
||||
return healthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached health status of a proxy without re-checking.
|
||||
* Returns null if there is no cached entry.
|
||||
*/
|
||||
export function getCachedProxyHealth(proxyUrl: string): boolean | null {
|
||||
const cached = proxyHealthCache.get(proxyUrl);
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.checkedAt >= cached.ttlMs) return null; // stale
|
||||
return cached.healthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached health for a proxy URL (force re-check on next call).
|
||||
*/
|
||||
export function invalidateProxyHealth(proxyUrl: string): void {
|
||||
proxyHealthCache.delete(proxyUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently cached proxy health entries (for dashboard display).
|
||||
*/
|
||||
export function getAllProxyHealthStatuses(): Array<{
|
||||
proxyUrl: string;
|
||||
healthy: boolean;
|
||||
checkedAt: number;
|
||||
stale: boolean;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
return [...proxyHealthCache.entries()].map(([proxyUrl, entry]) => ({
|
||||
proxyUrl,
|
||||
healthy: entry.healthy,
|
||||
checkedAt: entry.checkedAt,
|
||||
stale: now - entry.checkedAt >= entry.ttlMs,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Internals ────────────────────────────────────────────────────────────────
|
||||
|
||||
function defaultPortForScheme(protocol: string): string {
|
||||
switch (protocol.replace(":", "").toLowerCase()) {
|
||||
case "https":
|
||||
return "443";
|
||||
case "socks5":
|
||||
case "socks5h":
|
||||
return "1080";
|
||||
case "http":
|
||||
default:
|
||||
return "8080";
|
||||
}
|
||||
}
|
||||
|
||||
function tcpCheck(host: string, port: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = createConnection({ host, port }, () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.on("error", () => resolve(false));
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user