@aluvia-connect/agent-connect 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Aluvia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # Agent Connect
2
+
3
+ [![npm version](https://badge.fury.io/js/agent-connect.svg)](https://www.npmjs.com/package/agent-connect)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org)
6
+
7
+ Retry failed [Playwright](https://playwright.dev) navigations automatically with proxy fallback.
8
+
9
+ [Read the full documentation](https://docs.aluvia.io/docs/using-aluvia/agent-connect-sdk)
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @aluvia-connect/agent-connect
15
+ ```
16
+
17
+ ```bash
18
+ yarn add @aluvia-connect/agent-connect
19
+ ```
20
+
21
+ ```bash
22
+ pnpm add @aluvia-connect/agent-connect
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import { chromium } from "playwright";
29
+ import { retryWithProxy } from "@aluvia-connect/agent-connect";
30
+
31
+ const browser = await chromium.launch();
32
+ const context = await browser.newContext();
33
+ const page = await context.newPage();
34
+
35
+ const { response, page: retriedPage } = await retryWithProxy(page).goto(
36
+ "https://blocked-website.com"
37
+ );
38
+
39
+ console.log("Page title:", await retriedPage.title());
40
+ await browser.close();
41
+ ```
42
+
43
+ ## API Key Setup
44
+
45
+ This SDK uses an Aluvia API key to fetch proxies when retries occur.
46
+ Get your key from your Aluvia account's [Dev Tools page](https://dashboard.aluvia.io/tools)
47
+ and set it in .env:
48
+
49
+ ```bash
50
+ ALUVIA_API_KEY=your_aluvia_api_key
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ You can control how `retryWithProxy` behaves using environment variables or options passed in code.
56
+ The environment variables set defaults globally, while the TypeScript options let you override them per call.
57
+
58
+ ### Environment Variables
59
+
60
+ | Variable | Description | Default |
61
+ | -------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------- |
62
+ | `ALUVIA_API_KEY` | Required unless you provide a custom `proxyProvider`. Used to fetch proxies from Aluvia. | _none_ |
63
+ | `ALUVIA_MAX_RETRIES` | Number of retry attempts after the first failed navigation. | `1` |
64
+ | `ALUVIA_BACKOFF_MS` | Base delay (ms) between retries, grows exponentially with jitter. | `300` |
65
+ | `ALUVIA_RETRY_ON` | Comma-separated list of retryable error substrings. | `ECONNRESET,ETIMEDOUT,net::ERR,Timeout` |
66
+
67
+ #### Example `.env`
68
+
69
+ ```env
70
+ ALUVIA_API_KEY=your_aluvia_api_key
71
+ ALUVIA_MAX_RETRIES=2
72
+ ALUVIA_BACKOFF_MS=500
73
+ ALUVIA_RETRY_ON=ECONNRESET,ETIMEDOUT,net::ERR,Timeout
74
+ ```
75
+
76
+ ### TypeScript Options
77
+
78
+ You can also configure behavior programmatically by passing options to `retryWithProxy()`.
79
+
80
+ ```typescript
81
+ import { retryWithProxy } from "@aluvia-connect/agent-connect";
82
+
83
+ const { response, page } = await retryWithProxy(page, {
84
+ maxRetries: 3,
85
+ backoffMs: 500,
86
+ retryOn: ["ECONNRESET", /403/],
87
+ closeOldBrowser: false,
88
+ onRetry: (attempt, maxRetries, lastError) => {
89
+ console.log(
90
+ `Retry attempt ${attempt} of ${maxRetries} due to error:`,
91
+ lastError
92
+ );
93
+ },
94
+ onProxyLoaded: (proxy) => {
95
+ console.log(`Proxy loaded: ${proxy.server}`);
96
+ },
97
+ });
98
+ ```
99
+
100
+ #### Available Options
101
+
102
+ | Option | Type | Default | Description |
103
+ | ----------------- | ------------------------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
104
+ | `maxRetries` | `number` | `process.env.ALUVIA_MAX_RETRIES` or `1` | Number of retry attempts after the first failure. |
105
+ | `backoffMs` | `number` | `process.env.ALUVIA_BACKOFF_MS` or `300` | Base delay (in ms) between retries, grows exponentially with jitter. |
106
+ | `retryOn` | `(string \| RegExp)[]` | `process.env.ALUVIA_RETRY_ON` | Error patterns considered retryable. |
107
+ | `closeOldBrowser` | `boolean` | `true` | Whether to close the old browser when relaunching. |
108
+ | `proxyProvider` | `ProxyProvider` | Uses Aluvia SDK | Custom proxy provider that returns proxy credentials. |
109
+ | `onRetry` | `(attempt: number, maxRetries: number, lastError: unknown) => void \| Promise<void>` | `undefined` | Callback invoked before each retry attempt. |
110
+ | `onProxyLoaded` | `(proxy: ProxySettings) => void \| Promise<void>` | `undefined` | Callback fired after a proxy has been successfully fetched (either from the Aluvia API or a custom provider). |
111
+
112
+ #### Custom Proxy Provider Example
113
+
114
+ ```typescript
115
+ const myProxyProvider = {
116
+ async get() {
117
+ return {
118
+ server: "http://myproxy.example.com:8000",
119
+ username: "user123",
120
+ password: "secret",
121
+ };
122
+ },
123
+ };
124
+
125
+ const { response, page } = await retryWithProxy(page, {
126
+ proxyProvider: myProxyProvider,
127
+ maxRetries: 3,
128
+ });
129
+ ```
130
+
131
+ ## Dynamic Proxy (No Browser Relaunch)
132
+
133
+ To avoid relaunching the browser for each retry you can run a local proxy that dynamically swaps its upstream. This keeps all pages, contexts and session state intact.
134
+
135
+ 1. Start the dynamic proxy.
136
+ 2. Launch Playwright pointing at its local address.
137
+ 3. Pass the dynamic proxy into `retryWithProxy`.
138
+ 4. On a retryable failure the upstream proxy is fetched and swapped; navigation is re-attempted on the same page instance.
139
+
140
+ ```ts
141
+ import { chromium } from "playwright";
142
+ import { retryWithProxy, startDynamicProxy } from "@aluvia-connect/agent-connect";
143
+
144
+ // Start local proxy-chain server (random free port)
145
+ const dyn = await startDynamicProxy();
146
+
147
+ // Launch browser using ONLY the local proxy initially (direct connection upstream)
148
+ const browser = await chromium.launch({ proxy: { server: dyn.url } });
149
+ const context = await browser.newContext();
150
+ const page = await context.newPage();
151
+
152
+ const { page: samePage } = await retryWithProxy(page, {
153
+ dynamicProxy: dyn,
154
+ maxRetries: 2,
155
+ retryOn: ["Timeout", /net::ERR/],
156
+ onProxyLoaded: (p) => console.log("Upstream proxy loaded", p.server),
157
+ onRetry: (a, m) => console.log(`Dynamic retry ${a}/${m}`),
158
+ }).goto("https://blocked-website.example");
159
+
160
+ console.log(await samePage.title());
161
+ await dyn.close();
162
+ await browser.close();
163
+ ```
164
+
165
+ Notes:
166
+
167
+ - The first attempt is direct (no upstream proxy). On failure we fetch a proxy and call `dynamicProxy.setUpstream()` internally.
168
+ - Subsequent retries reuse the same browser & page; cookies and session data persist.
169
+ - Provide your own `proxyProvider` if you do not want to use the Aluvia API.
170
+
171
+ You can integrate this with any proxy API or local pool, as long as it returns a `server`, `username`, and `password`.
172
+
173
+ ## Requirements
174
+
175
+ - Node.js >= 18
176
+ - Playwright
177
+ - Aluvia API key (_if not using a custom proxy provider_)
178
+
179
+ ## About Aluvia
180
+
181
+ [Aluvia](https://www.aluvia.io/) provides real mobile proxy networks for developers and data teams, built for web automation, testing, and scraping with real device IPs.
182
+
183
+ ## Contributing
184
+
185
+ Contributions are welcome! Please see [CONTRIBUTING.MD](CONTRIBUTING.MD) for guidelines.
186
+
187
+ - Fork the repo and create your branch.
188
+ - Write clear commit messages.
189
+ - Add tests for new features.
190
+ - Open a pull request.
191
+
192
+ ## Support
193
+
194
+ For bugs, feature requests, or questions, please open an issue on [GitHub](https://github.com/aluvia-connect/agent-connect/issues).
195
+
196
+ For commercial support or proxy questions, visit [Aluvia](https://www.aluvia.io/).
197
+
198
+ ## License
199
+
200
+ MIT License - see the [LICENSE](LICENSE) file for details.
201
+
202
+ ## Author
203
+
204
+ Aluvia - [https://www.aluvia.io/](https://www.aluvia.io/)
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,320 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AluviaError = void 0;
37
+ exports.retryWithProxy = retryWithProxy;
38
+ exports.startDynamicProxy = startDynamicProxy;
39
+ const proxy_chain_1 = require("proxy-chain");
40
+ const DEFAULT_GOTO_TIMEOUT_MS = 15000;
41
+ const ENV_MAX_RETRIES = Math.max(0, parseInt(process.env.ALUVIA_MAX_RETRIES || "1", 10)); // prettier-ignore
42
+ const ENV_BACKOFF_MS = Math.max(0, parseInt(process.env.ALUVIA_BACKOFF_MS || "300", 10)); // prettier-ignore
43
+ const ENV_RETRY_ON = (process.env.ALUVIA_RETRY_ON ?? "ECONNRESET,ETIMEDOUT,net::ERR,Timeout")
44
+ .split(",")
45
+ .map((value) => value.trim())
46
+ .filter(Boolean);
47
+ /* Pre-compile retry patterns for performance & correctness */
48
+ const DEFAULT_RETRY_PATTERNS = ENV_RETRY_ON.map((value) => value.startsWith("/") && value.endsWith("/")
49
+ ? new RegExp(value.slice(1, -1))
50
+ : value);
51
+ var AluviaErrorCode;
52
+ (function (AluviaErrorCode) {
53
+ AluviaErrorCode["NoApiKey"] = "ALUVIA_NO_API_KEY";
54
+ AluviaErrorCode["NoProxy"] = "ALUVIA_NO_PROXIES";
55
+ AluviaErrorCode["ProxyFetchFailed"] = "ALUVIA_PROXY_FETCH_FAILED";
56
+ AluviaErrorCode["InsufficientBalance"] = "ALUVIA_INSUFFICIENT_BALANCE";
57
+ AluviaErrorCode["BalanceFetchFailed"] = "ALUVIA_BALANCE_FETCH_FAILED";
58
+ })(AluviaErrorCode || (AluviaErrorCode = {}));
59
+ class AluviaError extends Error {
60
+ constructor(message, code) {
61
+ super(message);
62
+ this.name = "AluviaError";
63
+ this.code = code;
64
+ }
65
+ }
66
+ exports.AluviaError = AluviaError;
67
+ let aluviaClient; // lazy-loaded Aluvia client instance
68
+ async function getAluviaProxy() {
69
+ const apiKey = process.env.ALUVIA_API_KEY || "";
70
+ if (!apiKey) {
71
+ throw new AluviaError("Missing ALUVIA_API_KEY environment variable.", AluviaErrorCode.NoApiKey);
72
+ }
73
+ if (!aluviaClient) {
74
+ // Dynamic import to play nicely with test mocks (avoids top-level evaluation before vi.mock)
75
+ const mod = await Promise.resolve().then(() => __importStar(require("aluvia-ts-sdk")));
76
+ const AluviaCtor = mod?.default || mod;
77
+ aluviaClient = new AluviaCtor(apiKey);
78
+ }
79
+ const proxy = await aluviaClient.first();
80
+ if (!proxy) {
81
+ throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.NoProxy);
82
+ }
83
+ return {
84
+ server: `http://${proxy.host}:${proxy.httpPort}`,
85
+ username: proxy.username,
86
+ password: proxy.password,
87
+ };
88
+ }
89
+ async function getAluviaBalance() {
90
+ const apiKey = process.env.ALUVIA_API_KEY || "";
91
+ if (!apiKey) {
92
+ throw new AluviaError("Missing ALUVIA_API_KEY environment variable.", AluviaErrorCode.NoApiKey);
93
+ }
94
+ const response = await fetch("https://api.aluvia.io/account/status", {
95
+ headers: {
96
+ Authorization: `Bearer ${apiKey}`,
97
+ },
98
+ });
99
+ if (!response.ok) {
100
+ throw new AluviaError(`Failed to fetch Aluvia account status: ${response.status} ${response.statusText}`, AluviaErrorCode.BalanceFetchFailed);
101
+ }
102
+ const data = await response.json();
103
+ return data.data.balance_gb;
104
+ }
105
+ function backoffDelay(base, attempt) {
106
+ // exponential + jitter
107
+ const jitter = Math.random() * 100;
108
+ return base * Math.pow(2, attempt) + jitter;
109
+ }
110
+ function compileRetryable(patterns = DEFAULT_RETRY_PATTERNS) {
111
+ return (err) => {
112
+ if (!err)
113
+ return false;
114
+ const msg = String(err?.message ?? err ?? "");
115
+ const code = String(err?.code ?? "");
116
+ const name = String(err?.name ?? "");
117
+ return patterns.some((p) => p instanceof RegExp
118
+ ? p.test(msg) || p.test(code) || p.test(name)
119
+ : msg.includes(p) || code.includes(p) || name.includes(p));
120
+ };
121
+ }
122
+ function inferBrowserTypeFromPage(page) {
123
+ const browser = page.context().browser();
124
+ const browserType = browser?.browserType?.();
125
+ if (!browserType) {
126
+ throw new Error("Cannot infer BrowserType from page");
127
+ }
128
+ return browserType;
129
+ }
130
+ async function inferContextDefaults(page) {
131
+ const context = page.context();
132
+ const options = context._options;
133
+ return options ?? {};
134
+ }
135
+ function inferLaunchDefaults(page) {
136
+ const browser = page.context().browser();
137
+ const options = browser._options;
138
+ return options ?? {};
139
+ }
140
+ async function relaunchWithProxy(proxy, oldPage, closeOldBrowser = true) {
141
+ const browserType = inferBrowserTypeFromPage(oldPage);
142
+ const launchDefaults = inferLaunchDefaults(oldPage);
143
+ const contextDefaults = await inferContextDefaults(oldPage);
144
+ if (closeOldBrowser) {
145
+ const oldBrowser = oldPage.context().browser();
146
+ try {
147
+ await oldBrowser?.close();
148
+ }
149
+ catch { }
150
+ }
151
+ const retryLaunch = {
152
+ ...launchDefaults,
153
+ proxy,
154
+ };
155
+ const browser = await browserType.launch(retryLaunch);
156
+ const context = await browser.newContext(contextDefaults);
157
+ const page = await context.newPage();
158
+ return { page };
159
+ }
160
+ const GOTO_ORIGINAL = Symbol.for("aluvia.gotoOriginal");
161
+ function retryWithProxy(page, options) {
162
+ const { maxRetries = ENV_MAX_RETRIES, backoffMs = ENV_BACKOFF_MS, retryOn = DEFAULT_RETRY_PATTERNS, closeOldBrowser = true, proxyProvider, onRetry, onProxyLoaded, dynamicProxy, } = options ?? {};
163
+ const isRetryable = compileRetryable(retryOn);
164
+ /** Prefer unpatched goto to avoid recursion */
165
+ const getRawGoto = (p) => (p[GOTO_ORIGINAL]?.bind(p) ?? p.goto.bind(p));
166
+ return {
167
+ async goto(url, gotoOptions) {
168
+ const run = async () => {
169
+ let basePage = page;
170
+ let lastErr;
171
+ // First attempt without proxy
172
+ try {
173
+ const response = await getRawGoto(basePage)(url, {
174
+ ...(gotoOptions ?? {}),
175
+ timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS,
176
+ waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded",
177
+ });
178
+ return { response: response ?? null, page: basePage };
179
+ }
180
+ catch (err) {
181
+ lastErr = err;
182
+ if (!isRetryable(err)) {
183
+ throw err;
184
+ }
185
+ }
186
+ if (!proxyProvider) {
187
+ const balance = await getAluviaBalance().catch(() => null);
188
+ if (balance !== null && balance <= 0) {
189
+ throw new AluviaError("Your Aluvia account has no remaining balance. Please top up at https://dashboard.aluvia.io/ to continue using proxies.", AluviaErrorCode.InsufficientBalance);
190
+ }
191
+ }
192
+ const proxy = await (proxyProvider?.get() ?? getAluviaProxy()).catch((err) => {
193
+ lastErr = err;
194
+ return undefined;
195
+ });
196
+ if (!proxy) {
197
+ throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.ProxyFetchFailed);
198
+ }
199
+ else {
200
+ await onProxyLoaded?.(proxy);
201
+ }
202
+ // If dynamic proxy supplied, switch upstream & retry on same page without relaunch.
203
+ if (dynamicProxy) {
204
+ await dynamicProxy.setUpstream(proxy);
205
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
206
+ if (backoffMs > 0) {
207
+ const delay = backoffDelay(backoffMs, attempt - 1);
208
+ await new Promise((r) => setTimeout(r, delay));
209
+ }
210
+ await onRetry?.(attempt, maxRetries, lastErr);
211
+ try {
212
+ const response = await getRawGoto(basePage)(url, {
213
+ ...(gotoOptions ?? {}),
214
+ timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS,
215
+ waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded",
216
+ });
217
+ return { response: response ?? null, page: basePage };
218
+ }
219
+ catch (err) {
220
+ lastErr = err;
221
+ if (!isRetryable(err))
222
+ break; // stop early on non-retryable error
223
+ continue; // next attempt
224
+ }
225
+ }
226
+ if (lastErr instanceof Error)
227
+ throw lastErr;
228
+ throw new Error(lastErr ? String(lastErr) : "Navigation failed");
229
+ }
230
+ // Original relaunch path if no dynamic proxy provided
231
+ // Retries with proxy
232
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
233
+ if (backoffMs > 0) {
234
+ const delay = backoffDelay(backoffMs, attempt - 1);
235
+ await new Promise((resolve) => setTimeout(resolve, delay));
236
+ }
237
+ await onRetry?.(attempt, maxRetries, lastErr);
238
+ try {
239
+ const { page: newPage } = await relaunchWithProxy(proxy, basePage, closeOldBrowser);
240
+ try {
241
+ const response = await getRawGoto(newPage)(url, {
242
+ ...(gotoOptions ?? {}),
243
+ timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS,
244
+ waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded",
245
+ });
246
+ // non-fatal readiness gate
247
+ try {
248
+ await newPage.waitForFunction(() => typeof document !== "undefined" && !!document.title?.trim(), { timeout: DEFAULT_GOTO_TIMEOUT_MS });
249
+ }
250
+ catch { }
251
+ return {
252
+ response: response ?? null,
253
+ page: newPage,
254
+ };
255
+ }
256
+ catch (err) {
257
+ // navigation on the new page failed — carry this page forward
258
+ basePage = newPage;
259
+ lastErr = err;
260
+ // next loop iteration will close this browser (since we pass basePage)
261
+ continue;
262
+ }
263
+ }
264
+ catch (err) {
265
+ // relaunch itself failed (no new page created)
266
+ lastErr = err;
267
+ continue;
268
+ }
269
+ }
270
+ if (lastErr instanceof Error) {
271
+ throw lastErr;
272
+ }
273
+ throw new Error(lastErr ? String(lastErr) : "Navigation failed");
274
+ };
275
+ return run();
276
+ },
277
+ };
278
+ }
279
+ /**
280
+ * Starts a local proxy-chain server which can have its upstream changed at runtime
281
+ * without relaunching the browser. Launch Playwright with { proxy: { server: dynamic.url } }.
282
+ */
283
+ async function startDynamicProxy(port) {
284
+ let upstream = null;
285
+ const server = new proxy_chain_1.Server({
286
+ port: port || 0,
287
+ prepareRequestFunction: async () => {
288
+ if (!upstream)
289
+ return {};
290
+ let url = upstream.server.startsWith("http") ? upstream.server : `http://${upstream.server}`;
291
+ if (upstream.username && upstream.password) {
292
+ try {
293
+ const u = new URL(url);
294
+ u.username = upstream.username;
295
+ u.password = upstream.password;
296
+ url = u.toString();
297
+ }
298
+ catch { }
299
+ }
300
+ return { upstreamProxyUrl: url };
301
+ },
302
+ });
303
+ await server.listen();
304
+ const address = server.server.address();
305
+ const resolvedPort = typeof address === "object" && address ? address.port : port || 8000;
306
+ const url = `http://127.0.0.1:${resolvedPort}`;
307
+ return {
308
+ url,
309
+ async setUpstream(p) {
310
+ upstream = p;
311
+ },
312
+ async close() {
313
+ try {
314
+ await server.close(false);
315
+ }
316
+ catch { }
317
+ },
318
+ currentUpstream() { return upstream; },
319
+ };
320
+ }
@@ -0,0 +1,281 @@
1
+ import { Server as ProxyChainServer } from "proxy-chain";
2
+ const DEFAULT_GOTO_TIMEOUT_MS = 15000;
3
+ const ENV_MAX_RETRIES = Math.max(0, parseInt(process.env.ALUVIA_MAX_RETRIES || "1", 10)); // prettier-ignore
4
+ const ENV_BACKOFF_MS = Math.max(0, parseInt(process.env.ALUVIA_BACKOFF_MS || "300", 10)); // prettier-ignore
5
+ const ENV_RETRY_ON = (process.env.ALUVIA_RETRY_ON ?? "ECONNRESET,ETIMEDOUT,net::ERR,Timeout")
6
+ .split(",")
7
+ .map((value) => value.trim())
8
+ .filter(Boolean);
9
+ /* Pre-compile retry patterns for performance & correctness */
10
+ const DEFAULT_RETRY_PATTERNS = ENV_RETRY_ON.map((value) => value.startsWith("/") && value.endsWith("/")
11
+ ? new RegExp(value.slice(1, -1))
12
+ : value);
13
+ var AluviaErrorCode;
14
+ (function (AluviaErrorCode) {
15
+ AluviaErrorCode["NoApiKey"] = "ALUVIA_NO_API_KEY";
16
+ AluviaErrorCode["NoProxy"] = "ALUVIA_NO_PROXIES";
17
+ AluviaErrorCode["ProxyFetchFailed"] = "ALUVIA_PROXY_FETCH_FAILED";
18
+ AluviaErrorCode["InsufficientBalance"] = "ALUVIA_INSUFFICIENT_BALANCE";
19
+ AluviaErrorCode["BalanceFetchFailed"] = "ALUVIA_BALANCE_FETCH_FAILED";
20
+ })(AluviaErrorCode || (AluviaErrorCode = {}));
21
+ export class AluviaError extends Error {
22
+ constructor(message, code) {
23
+ super(message);
24
+ this.name = "AluviaError";
25
+ this.code = code;
26
+ }
27
+ }
28
+ let aluviaClient; // lazy-loaded Aluvia client instance
29
+ async function getAluviaProxy() {
30
+ const apiKey = process.env.ALUVIA_API_KEY || "";
31
+ if (!apiKey) {
32
+ throw new AluviaError("Missing ALUVIA_API_KEY environment variable.", AluviaErrorCode.NoApiKey);
33
+ }
34
+ if (!aluviaClient) {
35
+ // Dynamic import to play nicely with test mocks (avoids top-level evaluation before vi.mock)
36
+ const mod = await import("aluvia-ts-sdk");
37
+ const AluviaCtor = mod?.default || mod;
38
+ aluviaClient = new AluviaCtor(apiKey);
39
+ }
40
+ const proxy = await aluviaClient.first();
41
+ if (!proxy) {
42
+ throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.NoProxy);
43
+ }
44
+ return {
45
+ server: `http://${proxy.host}:${proxy.httpPort}`,
46
+ username: proxy.username,
47
+ password: proxy.password,
48
+ };
49
+ }
50
+ async function getAluviaBalance() {
51
+ const apiKey = process.env.ALUVIA_API_KEY || "";
52
+ if (!apiKey) {
53
+ throw new AluviaError("Missing ALUVIA_API_KEY environment variable.", AluviaErrorCode.NoApiKey);
54
+ }
55
+ const response = await fetch("https://api.aluvia.io/account/status", {
56
+ headers: {
57
+ Authorization: `Bearer ${apiKey}`,
58
+ },
59
+ });
60
+ if (!response.ok) {
61
+ throw new AluviaError(`Failed to fetch Aluvia account status: ${response.status} ${response.statusText}`, AluviaErrorCode.BalanceFetchFailed);
62
+ }
63
+ const data = await response.json();
64
+ return data.data.balance_gb;
65
+ }
66
+ function backoffDelay(base, attempt) {
67
+ // exponential + jitter
68
+ const jitter = Math.random() * 100;
69
+ return base * Math.pow(2, attempt) + jitter;
70
+ }
71
+ function compileRetryable(patterns = DEFAULT_RETRY_PATTERNS) {
72
+ return (err) => {
73
+ if (!err)
74
+ return false;
75
+ const msg = String(err?.message ?? err ?? "");
76
+ const code = String(err?.code ?? "");
77
+ const name = String(err?.name ?? "");
78
+ return patterns.some((p) => p instanceof RegExp
79
+ ? p.test(msg) || p.test(code) || p.test(name)
80
+ : msg.includes(p) || code.includes(p) || name.includes(p));
81
+ };
82
+ }
83
+ function inferBrowserTypeFromPage(page) {
84
+ const browser = page.context().browser();
85
+ const browserType = browser?.browserType?.();
86
+ if (!browserType) {
87
+ throw new Error("Cannot infer BrowserType from page");
88
+ }
89
+ return browserType;
90
+ }
91
+ async function inferContextDefaults(page) {
92
+ const context = page.context();
93
+ const options = context._options;
94
+ return options ?? {};
95
+ }
96
+ function inferLaunchDefaults(page) {
97
+ const browser = page.context().browser();
98
+ const options = browser._options;
99
+ return options ?? {};
100
+ }
101
+ async function relaunchWithProxy(proxy, oldPage, closeOldBrowser = true) {
102
+ const browserType = inferBrowserTypeFromPage(oldPage);
103
+ const launchDefaults = inferLaunchDefaults(oldPage);
104
+ const contextDefaults = await inferContextDefaults(oldPage);
105
+ if (closeOldBrowser) {
106
+ const oldBrowser = oldPage.context().browser();
107
+ try {
108
+ await oldBrowser?.close();
109
+ }
110
+ catch { }
111
+ }
112
+ const retryLaunch = {
113
+ ...launchDefaults,
114
+ proxy,
115
+ };
116
+ const browser = await browserType.launch(retryLaunch);
117
+ const context = await browser.newContext(contextDefaults);
118
+ const page = await context.newPage();
119
+ return { page };
120
+ }
121
+ const GOTO_ORIGINAL = Symbol.for("aluvia.gotoOriginal");
122
+ export function retryWithProxy(page, options) {
123
+ const { maxRetries = ENV_MAX_RETRIES, backoffMs = ENV_BACKOFF_MS, retryOn = DEFAULT_RETRY_PATTERNS, closeOldBrowser = true, proxyProvider, onRetry, onProxyLoaded, dynamicProxy, } = options ?? {};
124
+ const isRetryable = compileRetryable(retryOn);
125
+ /** Prefer unpatched goto to avoid recursion */
126
+ const getRawGoto = (p) => (p[GOTO_ORIGINAL]?.bind(p) ?? p.goto.bind(p));
127
+ return {
128
+ async goto(url, gotoOptions) {
129
+ const run = async () => {
130
+ let basePage = page;
131
+ let lastErr;
132
+ // First attempt without proxy
133
+ try {
134
+ const response = await getRawGoto(basePage)(url, {
135
+ ...(gotoOptions ?? {}),
136
+ timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS,
137
+ waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded",
138
+ });
139
+ return { response: response ?? null, page: basePage };
140
+ }
141
+ catch (err) {
142
+ lastErr = err;
143
+ if (!isRetryable(err)) {
144
+ throw err;
145
+ }
146
+ }
147
+ if (!proxyProvider) {
148
+ const balance = await getAluviaBalance().catch(() => null);
149
+ if (balance !== null && balance <= 0) {
150
+ throw new AluviaError("Your Aluvia account has no remaining balance. Please top up at https://dashboard.aluvia.io/ to continue using proxies.", AluviaErrorCode.InsufficientBalance);
151
+ }
152
+ }
153
+ const proxy = await (proxyProvider?.get() ?? getAluviaProxy()).catch((err) => {
154
+ lastErr = err;
155
+ return undefined;
156
+ });
157
+ if (!proxy) {
158
+ throw new AluviaError("Failed to obtain a proxy for retry attempts. Check your balance and proxy pool at https://dashboard.aluvia.io/.", AluviaErrorCode.ProxyFetchFailed);
159
+ }
160
+ else {
161
+ await onProxyLoaded?.(proxy);
162
+ }
163
+ // If dynamic proxy supplied, switch upstream & retry on same page without relaunch.
164
+ if (dynamicProxy) {
165
+ await dynamicProxy.setUpstream(proxy);
166
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
167
+ if (backoffMs > 0) {
168
+ const delay = backoffDelay(backoffMs, attempt - 1);
169
+ await new Promise((r) => setTimeout(r, delay));
170
+ }
171
+ await onRetry?.(attempt, maxRetries, lastErr);
172
+ try {
173
+ const response = await getRawGoto(basePage)(url, {
174
+ ...(gotoOptions ?? {}),
175
+ timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS,
176
+ waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded",
177
+ });
178
+ return { response: response ?? null, page: basePage };
179
+ }
180
+ catch (err) {
181
+ lastErr = err;
182
+ if (!isRetryable(err))
183
+ break; // stop early on non-retryable error
184
+ continue; // next attempt
185
+ }
186
+ }
187
+ if (lastErr instanceof Error)
188
+ throw lastErr;
189
+ throw new Error(lastErr ? String(lastErr) : "Navigation failed");
190
+ }
191
+ // Original relaunch path if no dynamic proxy provided
192
+ // Retries with proxy
193
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
194
+ if (backoffMs > 0) {
195
+ const delay = backoffDelay(backoffMs, attempt - 1);
196
+ await new Promise((resolve) => setTimeout(resolve, delay));
197
+ }
198
+ await onRetry?.(attempt, maxRetries, lastErr);
199
+ try {
200
+ const { page: newPage } = await relaunchWithProxy(proxy, basePage, closeOldBrowser);
201
+ try {
202
+ const response = await getRawGoto(newPage)(url, {
203
+ ...(gotoOptions ?? {}),
204
+ timeout: gotoOptions?.timeout ?? DEFAULT_GOTO_TIMEOUT_MS,
205
+ waitUntil: gotoOptions?.waitUntil ?? "domcontentloaded",
206
+ });
207
+ // non-fatal readiness gate
208
+ try {
209
+ await newPage.waitForFunction(() => typeof document !== "undefined" && !!document.title?.trim(), { timeout: DEFAULT_GOTO_TIMEOUT_MS });
210
+ }
211
+ catch { }
212
+ return {
213
+ response: response ?? null,
214
+ page: newPage,
215
+ };
216
+ }
217
+ catch (err) {
218
+ // navigation on the new page failed — carry this page forward
219
+ basePage = newPage;
220
+ lastErr = err;
221
+ // next loop iteration will close this browser (since we pass basePage)
222
+ continue;
223
+ }
224
+ }
225
+ catch (err) {
226
+ // relaunch itself failed (no new page created)
227
+ lastErr = err;
228
+ continue;
229
+ }
230
+ }
231
+ if (lastErr instanceof Error) {
232
+ throw lastErr;
233
+ }
234
+ throw new Error(lastErr ? String(lastErr) : "Navigation failed");
235
+ };
236
+ return run();
237
+ },
238
+ };
239
+ }
240
+ /**
241
+ * Starts a local proxy-chain server which can have its upstream changed at runtime
242
+ * without relaunching the browser. Launch Playwright with { proxy: { server: dynamic.url } }.
243
+ */
244
+ export async function startDynamicProxy(port) {
245
+ let upstream = null;
246
+ const server = new ProxyChainServer({
247
+ port: port || 0,
248
+ prepareRequestFunction: async () => {
249
+ if (!upstream)
250
+ return {};
251
+ let url = upstream.server.startsWith("http") ? upstream.server : `http://${upstream.server}`;
252
+ if (upstream.username && upstream.password) {
253
+ try {
254
+ const u = new URL(url);
255
+ u.username = upstream.username;
256
+ u.password = upstream.password;
257
+ url = u.toString();
258
+ }
259
+ catch { }
260
+ }
261
+ return { upstreamProxyUrl: url };
262
+ },
263
+ });
264
+ await server.listen();
265
+ const address = server.server.address();
266
+ const resolvedPort = typeof address === "object" && address ? address.port : port || 8000;
267
+ const url = `http://127.0.0.1:${resolvedPort}`;
268
+ return {
269
+ url,
270
+ async setUpstream(p) {
271
+ upstream = p;
272
+ },
273
+ async close() {
274
+ try {
275
+ await server.close(false);
276
+ }
277
+ catch { }
278
+ },
279
+ currentUpstream() { return upstream; },
280
+ };
281
+ }
@@ -0,0 +1,159 @@
1
+ import type { Page, Response } from "playwright";
2
+ export type RetryPattern = string | RegExp;
3
+ type GoToOptions = NonNullable<Parameters<Page["goto"]>[1]>;
4
+ export interface RetryWithProxyRunner {
5
+ goto(url: string, options?: GoToOptions): Promise<{
6
+ response: Response | null;
7
+ page: Page;
8
+ }>;
9
+ }
10
+ export type ProxySettings = {
11
+ server: string;
12
+ username?: string;
13
+ password?: string;
14
+ };
15
+ export interface ProxyProvider {
16
+ get(): Promise<ProxySettings>;
17
+ }
18
+ declare enum AluviaErrorCode {
19
+ NoApiKey = "ALUVIA_NO_API_KEY",
20
+ NoProxy = "ALUVIA_NO_PROXIES",
21
+ ProxyFetchFailed = "ALUVIA_PROXY_FETCH_FAILED",
22
+ InsufficientBalance = "ALUVIA_INSUFFICIENT_BALANCE",
23
+ BalanceFetchFailed = "ALUVIA_BALANCE_FETCH_FAILED"
24
+ }
25
+ export declare class AluviaError extends Error {
26
+ code?: AluviaErrorCode;
27
+ constructor(message: string, code?: AluviaErrorCode);
28
+ }
29
+ export interface RetryWithProxyOptions {
30
+ /**
31
+ * Number of retry attempts after the first failed navigation.
32
+ *
33
+ * The first `page.goto()` is always attempted without a proxy.
34
+ * If it fails with a retryable error (as defined by `retryOn`),
35
+ * the helper will fetch a new proxy and relaunch the browser.
36
+ *
37
+ * @default process.env.ALUVIA_MAX_RETRIES || 1
38
+ * @example
39
+ * // Try up to 3 proxy relaunches after the first failure
40
+ * { maxRetries: 3 }
41
+ */
42
+ maxRetries?: number;
43
+ /**
44
+ * Base delay (in milliseconds) for exponential backoff between retries.
45
+ *
46
+ * Each retry waits `backoffMs * 2^attempt + random(0–100)` before continuing.
47
+ * Useful to avoid hammering proxy endpoints or triggering rate limits.
48
+ *
49
+ * @default process.env.ALUVIA_BACKOFF_MS || 300
50
+ * @example
51
+ * // Start with 500ms and double each time (with jitter)
52
+ * { backoffMs: 500 }
53
+ */
54
+ backoffMs?: number;
55
+ /**
56
+ * List of error patterns that are considered retryable.
57
+ *
58
+ * A pattern can be a string or a regular expression. When a navigation error’s
59
+ * message, name, or code matches any of these, the helper will trigger a retry.
60
+ *
61
+ * @default process.env.ALUVIA_RETRY_ON
62
+ * or ["ECONNRESET", "ETIMEDOUT", "net::ERR", "Timeout"]
63
+ * @example
64
+ * // Retry on connection resets and 403 responses
65
+ * { retryOn: ["ECONNRESET", /403/] }
66
+ */
67
+ retryOn?: RetryPattern[];
68
+ /**
69
+ * Whether to close the old browser instance when relaunching with a new proxy.
70
+ *
71
+ * Set to `true` (default) to prevent multiple browsers from staying open,
72
+ * which is safer for most workflows. Set to `false` if you manage browser
73
+ * lifecycles manually or reuse a shared browser across tasks.
74
+ *
75
+ * @default true
76
+ * @example
77
+ * // Keep old browser open (you must close it yourself)
78
+ * { closeOldBrowser: false }
79
+ */
80
+ closeOldBrowser?: boolean;
81
+ /**
82
+ * Optional custom proxy provider used to fetch proxy credentials.
83
+ *
84
+ * By default, `retryWithProxy` automatically uses the Aluvia API
85
+ * via the `aluvia-ts-sdk` and reads the API key from
86
+ * `process.env.ALUVIA_API_KEY`.
87
+ *
88
+ * Supplying your own `proxyProvider` allows you to integrate with
89
+ * any proxy rotation service, database, or in-house pool instead.
90
+ *
91
+ * A proxy provider must expose a `get()` method that returns a
92
+ * `Promise<ProxySettings>` object with `server`, and optionally
93
+ * `username` and `password` fields.
94
+ *
95
+ * @default Uses the built-in Aluvia client with `process.env.ALUVIA_API_KEY`
96
+ * @example
97
+ * ```ts
98
+ * import { retryWithProxy } from "page-retry";
99
+ *
100
+ * // Custom proxy provider example
101
+ * const myProxyProvider = {
102
+ * async get() {
103
+ * // Pull from your own proxy pool or API
104
+ * return {
105
+ * server: "http://myproxy.example.com:8000",
106
+ * username: "user123",
107
+ * password: "secret",
108
+ * };
109
+ * },
110
+ * };
111
+ *
112
+ * const { response, page } = await retryWithProxy(page, {
113
+ * proxyProvider: myProxyProvider,
114
+ * maxRetries: 3,
115
+ * });
116
+ * ```
117
+ */
118
+ proxyProvider?: ProxyProvider;
119
+ /**
120
+ * Optional callback fired before each retry attempt (after backoff).
121
+ *
122
+ * @param attempt Current retry attempt number (1-based)
123
+ * @param maxRetries Maximum number of retries
124
+ * @param lastError The error that triggered the retry
125
+ */
126
+ onRetry?: (attempt: number, maxRetries: number, lastError: unknown) => void | Promise<void>;
127
+ /**
128
+ * Optional callback fired when a proxy has been successfully fetched.
129
+ *
130
+ * @param proxy The proxy settings that were fetched or provided
131
+ */
132
+ onProxyLoaded?: (proxy: ProxySettings) => void | Promise<void>;
133
+ /**
134
+ * Optional dynamic proxy. If provided, retries will switch upstream proxy
135
+ * via this local proxy instead of relaunching the browser.
136
+ *
137
+ * To use: const dyn = await startDynamicProxy();
138
+ * chromium.launch({ proxy: { server: dyn.url } })
139
+ * Then pass { dynamicProxy: dyn } to retryWithProxy().
140
+ */
141
+ dynamicProxy?: DynamicProxy;
142
+ }
143
+ export declare function retryWithProxy(page: Page, options?: RetryWithProxyOptions): RetryWithProxyRunner;
144
+ /**
145
+ * Starts a local proxy-chain server which can have its upstream changed at runtime
146
+ * without relaunching the browser. Launch Playwright with { proxy: { server: dynamic.url } }.
147
+ */
148
+ export declare function startDynamicProxy(port?: number): Promise<DynamicProxy>;
149
+ export interface DynamicProxy {
150
+ /** Local proxy URL (host:port) to be used in Playwright launch options */
151
+ url: string;
152
+ /** Update upstream proxy; null disables upstream (direct connection) */
153
+ setUpstream(proxy: ProxySettings | null): Promise<void>;
154
+ /** Dispose the local proxy server */
155
+ close(): Promise<void>;
156
+ /** Returns the currently configured upstream settings (if any) */
157
+ currentUpstream(): ProxySettings | null;
158
+ }
159
+ export {};
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@aluvia-connect/agent-connect",
3
+ "version": "1.0.0",
4
+ "description": "Automatic retry and proxy fallback for Playwright powered by Aluvia",
5
+ "homepage": "https://github.com/aluvia-connect/agent-connect#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/aluvia-connect/agent-connect/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/aluvia-connect/agent-connect.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": "Aluvia",
15
+ "type": "module",
16
+ "main": "./dist/cjs/index.js",
17
+ "module": "./dist/esm/index.js",
18
+ "types": "./dist/types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/types/index.d.ts",
22
+ "import": "./dist/esm/index.js",
23
+ "require": "./dist/cjs/index.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "scripts": {
28
+ "clean": "rimraf dist",
29
+ "build": "npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').mkdirSync('dist/cjs',{recursive:true});require('fs').writeFileSync('dist/cjs/package.json','{\\\"type\\\":\\\"commonjs\\\"}')\"",
30
+ "release:check": "npm run build && npm pack --dry-run",
31
+ "release:tag": "git tag v$(node -p \"require('./package.json').version\")",
32
+ "release:push": "git push origin main && git push origin --tags",
33
+ "release:version": "node -p \"require('./package.json').version\"",
34
+ "audit:check": "npm audit --audit-level moderate",
35
+ "test": "vitest run --reporter=dot",
36
+ "test:watch": "vitest"
37
+ },
38
+ "keywords": [
39
+ "playwright",
40
+ "proxy",
41
+ "aluvia",
42
+ "retry",
43
+ "automation",
44
+ "typescript"
45
+ ],
46
+ "files": [
47
+ "dist/**/*",
48
+ "README.md",
49
+ "LICENSE"
50
+ ],
51
+ "sideEffects": false,
52
+ "dependencies": {
53
+ "aluvia-ts-sdk": "^2.1.2",
54
+ "proxy-chain": "^2.3.0"
55
+ },
56
+ "peerDependencies": {
57
+ "playwright": ">=1.40.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^24.9.1",
61
+ "@vitest/coverage-v8": "^4.0.3",
62
+ "playwright": "^1.56.1",
63
+ "rimraf": "^6.0.1",
64
+ "typescript": "^5.9.3",
65
+ "vitest": "^4.0.3"
66
+ },
67
+ "engines": {
68
+ "node": ">=18"
69
+ }
70
+ }