@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 +21 -0
- package/README.md +204 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/src/index.js +320 -0
- package/dist/esm/src/index.js +281 -0
- package/dist/types/src/index.d.ts +159 -0
- package/package.json +70 -0
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
|
+
[](https://www.npmjs.com/package/agent-connect)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](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
|
+
}
|