UNPKG

@aluvia-connect/agent-connect

Version:

Automatic retry and proxy fallback for Playwright powered by Aluvia

218 lines (217 loc) 9.55 kB
import { Server as ProxyChainServer } from "proxy-chain"; const DEFAULT_GOTO_TIMEOUT_MS = 15000; const ENV_MAX_RETRIES = Math.max(0, parseInt(process.env.ALUVIA_MAX_RETRIES || "2", 10)); // prettier-ignore const ENV_BACKOFF_MS = Math.max(0, parseInt(process.env.ALUVIA_BACKOFF_MS || "300", 10)); // prettier-ignore const ENV_RETRY_ON = (process.env.ALUVIA_RETRY_ON ?? "ECONNRESET,ETIMEDOUT,net::ERR,Timeout") .split(",") .map((value) => value.trim()) .filter(Boolean); /* Pre-compile retry patterns for performance & correctness */ const DEFAULT_RETRY_PATTERNS = ENV_RETRY_ON.map((value) => value.startsWith("/") && value.endsWith("/") ? new RegExp(value.slice(1, -1)) : value); var AluviaErrorCode; (function (AluviaErrorCode) { AluviaErrorCode["NoApiKey"] = "ALUVIA_NO_API_KEY"; AluviaErrorCode["NoProxy"] = "ALUVIA_NO_PROXIES"; AluviaErrorCode["ProxyFetchFailed"] = "ALUVIA_PROXY_FETCH_FAILED"; AluviaErrorCode["InsufficientBalance"] = "ALUVIA_INSUFFICIENT_BALANCE"; AluviaErrorCode["BalanceFetchFailed"] = "ALUVIA_BALANCE_FETCH_FAILED"; AluviaErrorCode["NoDynamicProxy"] = "ALUVIA_NO_DYNAMIC_PROXY"; })(AluviaErrorCode || (AluviaErrorCode = {})); export class AluviaError extends Error { constructor(message, code) { super(message); this.name = "AluviaError"; this.code = code; } } let aluviaClient; // lazy-loaded Aluvia client instance async function getAluviaProxy() { const apiKey = process.env.ALUVIA_TOKEN || ""; if (!apiKey) { throw new AluviaError("Missing ALUVIA_TOKEN environment variable.", AluviaErrorCode.NoApiKey); } if (!aluviaClient) { // Dynamic import to play nicely with test mocks (avoids top-level evaluation before vi.mock) const mod = await import("aluvia-ts-sdk"); const AluviaCtor = mod?.default || mod; aluviaClient = new AluviaCtor(apiKey); } const proxy = await aluviaClient.first(); if (!proxy) { throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.NoProxy); } const sessionId = generateSessionId(); return { server: `http://${proxy.host}:${proxy.httpPort}`, username: `${proxy.username}-session-${sessionId}`, password: proxy.password, }; } async function getAluviaBalance() { const apiKey = process.env.ALUVIA_TOKEN || ""; if (!apiKey) { throw new AluviaError("Missing ALUVIA_TOKEN environment variable.", AluviaErrorCode.NoApiKey); } const response = await fetch("https://api.aluvia.io/account/status", { headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { throw new AluviaError(`Failed to fetch Aluvia account status: ${response.status} ${response.statusText}`, AluviaErrorCode.BalanceFetchFailed); } const data = await response.json(); return data.data.balance_gb; } function backoffDelay(base, attempt) { // exponential + jitter const jitter = Math.random() * 100; return base * Math.pow(2, attempt) + jitter; } function compileRetryable(patterns = DEFAULT_RETRY_PATTERNS) { return (err) => { if (!err) return false; const msg = String(err?.message ?? err ?? ""); const code = String(err?.code ?? ""); const name = String(err?.name ?? ""); return patterns.some((p) => p instanceof RegExp ? p.test(msg) || p.test(code) || p.test(name) : msg.includes(p) || code.includes(p) || name.includes(p)); }; } function generateSessionId() { return Math.random().toString(36).substring(2, 10); } const GOTO_ORIGINAL = Symbol.for("aluvia.gotoOriginal"); const CONTEXT_LISTENER_ATTACHED = new WeakSet(); export function agentConnect(page, options) { const { dynamicProxy, maxRetries = ENV_MAX_RETRIES, backoffMs = ENV_BACKOFF_MS, retryOn = DEFAULT_RETRY_PATTERNS, proxyProvider, onRetry, onProxyLoaded, } = options ?? {}; if (!dynamicProxy) { throw new AluviaError("No dynamic proxy supplied to agentConnect", AluviaErrorCode.NoDynamicProxy); } const isRetryable = compileRetryable(retryOn); /** Prefer unpatched goto to avoid recursion */ const getRawGoto = (p) => (p[GOTO_ORIGINAL]?.bind(p) ?? p.goto.bind(p)); return { async goto(url, gotoOptions) { const run = async () => { let basePage = page; let lastErr; // One-time attach context close listener to shut down dynamic proxy if (dynamicProxy) { const ctx = basePage.context(); if (!CONTEXT_LISTENER_ATTACHED.has(ctx)) { ctx.on('close', async () => { try { await dynamicProxy.close(); } catch { } }); CONTEXT_LISTENER_ATTACHED.add(ctx); } } // First attempt without proxy try { const response = await getRawGoto(basePage)(url, { ...(gotoOptions ?? {}), timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS, waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded", }); return { response: response ?? null, page: basePage }; } catch (err) { lastErr = err; if (!isRetryable(err)) { throw err; } } if (!proxyProvider) { const balance = await getAluviaBalance().catch(() => null); if (balance !== null && balance <= 0) { throw new AluviaError("Your Aluvia account has no remaining balance. Please top up at https://dashboard.aluvia.io/ to continue using proxies.", AluviaErrorCode.InsufficientBalance); } } for (let attempt = 1; attempt <= maxRetries; attempt++) { const proxy = await (proxyProvider?.get() ?? getAluviaProxy()).catch((err) => { lastErr = err; return undefined; }); if (!proxy) { throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.ProxyFetchFailed); } else { await onProxyLoaded?.(proxy); } // switch upstream & retry on same page without relaunch. await dynamicProxy.setUpstream(proxy); if (backoffMs > 0) { const delay = backoffDelay(backoffMs, attempt - 1); await new Promise((r) => setTimeout(r, delay)); } await onRetry?.(attempt, maxRetries, lastErr); try { const response = await getRawGoto(basePage)(url, { ...(gotoOptions ?? {}), timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS, waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded", }); return { response: response ?? null, page: basePage }; } catch (err) { lastErr = err; if (!isRetryable(err)) break; // stop early on non-retryable error continue; // next attempt } } if (lastErr instanceof Error) throw lastErr; throw new Error(lastErr ? String(lastErr) : "Navigation failed"); }; return run(); }, }; } /** * Starts a local proxy-chain server which can have its upstream changed at runtime * without relaunching the browser. Launch Playwright with { proxy: { server: dynamic.url } }. */ export async function startDynamicProxy(port) { let upstream = null; const server = new ProxyChainServer({ port: port || 0, prepareRequestFunction: async () => { if (!upstream) return {}; let url = upstream.server.startsWith("http") ? upstream.server : `http://${upstream.server}`; if (upstream.username && upstream.password) { try { const u = new URL(url); u.username = upstream.username; u.password = upstream.password; url = u.toString(); } catch { } } return { upstreamProxyUrl: url }; }, }); await server.listen(); const address = server.server.address(); const resolvedPort = typeof address === "object" && address ? address.port : port || 8000; const url = `http://127.0.0.1:${resolvedPort}`; return { url, async setUpstream(p) { upstream = p; }, async close() { try { await server.close(true); } catch { } }, currentUpstream() { return upstream; }, }; }