@aluvia-connect/agent-connect
Version:
Automatic retry and proxy fallback for Playwright powered by Aluvia
218 lines (217 loc) • 9.55 kB
JavaScript
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; },
};
}