UNPKG

@aluvia-connect/agent-connect

Version:

Automatic retry and proxy fallback for Playwright powered by Aluvia

257 lines (256 loc) 11.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.AluviaError = void 0; exports.agentConnect = agentConnect; exports.startDynamicProxy = startDynamicProxy; const proxy_chain_1 = require("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 = {})); class AluviaError extends Error { constructor(message, code) { super(message); this.name = "AluviaError"; this.code = code; } } exports.AluviaError = AluviaError; 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 Promise.resolve().then(() => __importStar(require("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(); 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 } }. */ async function startDynamicProxy(port) { let upstream = null; const server = new proxy_chain_1.Server({ 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; }, }; }