onebrowser 0.1.1
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 +130 -0
- package/bin/onebrowser.js +4 -0
- package/package.json +22 -0
- package/src/cli.js +138 -0
- package/src/credentials.js +22 -0
- package/src/errors.js +2 -0
- package/src/index.js +180 -0
- package/src/ledger.js +112 -0
- package/src/providers/http.js +31 -0
- package/src/providers/hyperbrowser.js +42 -0
- package/src/providers/index.js +37 -0
- package/src/providers/kernel.js +68 -0
- package/src/providers/lightpanda.js +55 -0
- package/src/providers/month.js +5 -0
- package/src/providers/steel.js +44 -0
- package/src/providers/tinyfish.js +55 -0
- package/src/session.js +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Richard Wang
|
|
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,130 @@
|
|
|
1
|
+
# onebrowser
|
|
2
|
+
|
|
3
|
+
One interface to remote browser providers. A session is asked for by provider name, the way an LLM request names its model. Every session is recorded in a ledger, which each provider account's usage and quota are read from.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install onebrowser
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { OneBrowser, QuotaError } from 'onebrowser';
|
|
11
|
+
|
|
12
|
+
const browsers = new OneBrowser({ client: 'my-crawler' });
|
|
13
|
+
const session = await browsers.openSession({ provider: 'steel', proxyCountry: 'DE' });
|
|
14
|
+
const page = await session.newPage(); // Playwright; or any CDP client on session.cdpUrl
|
|
15
|
+
await page.goto('https://example.com');
|
|
16
|
+
await session.close();
|
|
17
|
+
|
|
18
|
+
await browsers.quota('steel'); // { remaining, unit: 'USD', renewsAt: null }
|
|
19
|
+
await browsers.syncUsage(); // fetches the providers' records of ended sessions into the ledger
|
|
20
|
+
browsers.usage({ since: Date.parse('2026-11-01') }); // [{ day, provider, client, sessions, minutes, … }]
|
|
21
|
+
await browsers.close();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**What it does not do**: choose a provider, cap the sessions held at once, spread sessions over providers, or stop using a provider whose quota runs low. Those are policy, and belong to the program using it. onebrowser opens the session it is asked for, and tells the program what it needs to decide: the quota, and `QuotaError` when a provider refuses a session for the account's quota.
|
|
25
|
+
|
|
26
|
+
Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbrowser](https://hyperbrowser.ai), [Lightpanda](https://lightpanda.io) and [Kernel](https://www.kernel.sh); a program can add its own (see [Adding a provider](#adding-a-provider)). Node 22.13 or later.
|
|
27
|
+
|
|
28
|
+
## Interface
|
|
29
|
+
|
|
30
|
+
`new OneBrowser({ client, ledgerPath, providers, adapters })`
|
|
31
|
+
|
|
32
|
+
| Option | |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `client` | The program using it, recorded with each session; required |
|
|
35
|
+
| `ledgerPath` | SQLite file of the ledger (default below; `':memory:'` for tests). Opened when first needed |
|
|
36
|
+
| `providers` | Settings per provider name, on top of its adapter's `defaults`: `apiKey`, and the adapter's own settings (API base URLs, plan facts such as `monthlyHours`) |
|
|
37
|
+
| `adapters` | Further adapters by provider name, or replacements of built-in ones |
|
|
38
|
+
|
|
39
|
+
| Method | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `providers` | Names of the providers it can open sessions with |
|
|
42
|
+
| `checkReady(provider)` | Throws when the provider is unknown or has no API key |
|
|
43
|
+
| `openSession({ provider, proxyCountry, idleTimeoutSec, maxDurationMs })` | A new session, recorded in the ledger once the provider has created it. Throws `QuotaError` when the provider refuses it for the account's quota |
|
|
44
|
+
| `quota(provider)` | `{ remaining, unit, renewsAt }` in the provider's own unit; `renewsAt` (ms) when it renews by itself, `null` when only a top-up renews it. Throws when it cannot be read |
|
|
45
|
+
| `syncUsage()` | Stores the providers' records (duration, proxy traffic) of the ended sessions that have none yet: `{ reported, failed: [{ provider, error }] }`. Sessions of a provider it has no adapter for (another program's own) are left to the programs that have one |
|
|
46
|
+
| `usage({ since })` | Sessions opened since `since` (ms) per UTC day, provider and client: `sessions`, `minutes` (the provider's record, else the open-to-close timing), `untimed`, `unsynced`, `proxyBytes` |
|
|
47
|
+
| `close()` | Closes the sessions still open and the ledger |
|
|
48
|
+
|
|
49
|
+
A session (`openSession`) has `id`, `provider`, `egress`, `cdpUrl` (it may carry the API key: never log it), `openedAt`, `closedAt`, and:
|
|
50
|
+
|
|
51
|
+
- `connect()`: Playwright's `Browser` over its CDP connection, made once. A keep-alive command every 15 s keeps the provider's idle timeout from ending a session that is in use.
|
|
52
|
+
- `newPage()`: always a new page in the default context: the page a remote browser starts with is not always one that can be navigated.
|
|
53
|
+
- `close()`: disconnects and releases it; only the first call does. The ledger records it closed in any case. Throws when the provider could not release it: the provider then ends it by itself.
|
|
54
|
+
|
|
55
|
+
A session and a `OneBrowser` are async-disposable: `await using session = await browsers.openSession({ provider: 'steel' })` closes the session at the end of its block (Node 24 or later; on Node 22 call `close()`).
|
|
56
|
+
|
|
57
|
+
### Session options: one meaning on every provider
|
|
58
|
+
|
|
59
|
+
| Option | Default | Meaning |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `proxyCountry` | `null` | A country code: the provider's proxy in that country. `null`: its default egress |
|
|
62
|
+
| `idleTimeoutSec` | 60 | A session whose client is gone (its process was killed) ends this long after at the latest. At least 30 (twice the keep-alive interval) |
|
|
63
|
+
| `maxDurationMs` | 15 min | The session ends this long after it was opened at the latest: this process closes it then, and the provider's own limit is set to it where its API takes one |
|
|
64
|
+
|
|
65
|
+
`sessionOptions(options)` returns them with the defaults filled in and throws when one is unknown or out of range, so that a program can check its configuration before it opens any session.
|
|
66
|
+
|
|
67
|
+
How each provider meets them:
|
|
68
|
+
|
|
69
|
+
| Provider | `proxyCountry` | `idleTimeoutSec` | `maxDurationMs` |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| `steel` | residential proxy | `inactivityTimeout` (no CDP commands) | `timeout` |
|
|
72
|
+
| `tinyfish` | residential proxy | `timeout_seconds` (no CDP commands) | this process only |
|
|
73
|
+
| `hyperbrowser` | residential proxy, paid plans | at once: a session ends with its CDP connection | `timeoutMinutes`, in whole minutes |
|
|
74
|
+
| `lightpanda` | its datacenter pool in that country | at once: a session is its CDP connection | Lightpanda's own limit is 15 min |
|
|
75
|
+
| `kernel` | not supported (throws) | `timeout_seconds` after the CDP client left; until then a disconnected session stays in standby, counting toward the plan's concurrent browsers | this process only |
|
|
76
|
+
|
|
77
|
+
### Quota
|
|
78
|
+
|
|
79
|
+
| Provider | Quota, unit | Read from |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `steel` | balance, USD | `GET /v1/credits/balance`, the endpoint of Steel's dashboard (not in its API docs) |
|
|
82
|
+
| `tinyfish` | wallet, USD | [`GET /v1/wallet`](https://docs.tinyfish.ai/wallet); a session refused with 402 throws `QuotaError` |
|
|
83
|
+
| `hyperbrowser` | credits | `GET /api/team/credit-info`, the endpoint of Hyperbrowser's SDK (not in its API docs) |
|
|
84
|
+
| `lightpanda` | browser hours of the calendar month (`monthlyHours`, default: the free plan's) | the ledger's record of its sessions: its console shows the month's usage, but its token cannot read it. So the reading is whole while the account is used only through ledgers of this machine |
|
|
85
|
+
| `kernel` | browser hours of the calendar month (`monthlyHours`, default: what the free plan's credits pay for) | the uptime of the account's sessions this month, from `GET /browsers`; a session refused with 403 (spending cap) throws `QuotaError` |
|
|
86
|
+
|
|
87
|
+
### API keys
|
|
88
|
+
|
|
89
|
+
`apiKey` in the provider's settings, else the environment variable `<PROVIDER>_API_KEY` (`STEEL_API_KEY`, …; `apiKeyEnv(provider)` names it), else the file named by `<PROVIDER>_API_KEY_FILE`. An empty value counts as unset.
|
|
90
|
+
|
|
91
|
+
### The ledger
|
|
92
|
+
|
|
93
|
+
One SQLite file per machine by default: `$ONEBROWSER_DB`, else `onebrowser/sessions.db` in `$XDG_DATA_HOME` (default `~/.local/share`). Every program that uses onebrowser on the machine shares it, each under its own `client`, so the record of a provider account's sessions is whole whichever program opened them; several processes may write to it at once. Each session is recorded when its provider has created it (it is billed from then on, also when connecting fails afterwards), closed when released, and completed with the provider's own record by `syncUsage()` once it has ended.
|
|
94
|
+
|
|
95
|
+
## Command line
|
|
96
|
+
|
|
97
|
+
The provider accounts and the ledger belong to the machine rather than to one program, so the `onebrowser` command reads them without a program of your own. It opens no sessions.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npx onebrowser quota # what each provider account with an API key has left
|
|
101
|
+
npx onebrowser quota steel kernel # only these; one without an API key is a failed reading
|
|
102
|
+
npx onebrowser usage --days 7 # the ledger's sessions per UTC day, provider and client, today included
|
|
103
|
+
npx onebrowser usage --json --env-file .env
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`usage` first fetches the providers' records of the sessions that have ended, as `syncUsage()` does. With `--json`, `quota` prints an array of what `quota()` returns, with the provider: `{ provider, remaining, unit, renewsAt }`, or `{ provider, error }` for a failed reading; `usage` prints `{ ledger, since, sessions, failed }`, where `sessions` is what `usage()` returns and `failed` what `syncUsage()` could not fetch. API keys are read as by the library, from the environment (`--env-file PATH` loads a file into it first). Exit status: 0 done, 1 a reading failed, 2 the command line is wrong.
|
|
107
|
+
|
|
108
|
+
## Adding a provider
|
|
109
|
+
|
|
110
|
+
Write `src/providers/<name>.js` and register it in `ADAPTERS` (`src/providers/index.js`, where the contract is):
|
|
111
|
+
|
|
112
|
+
- `defaults`: its settings (API base URLs, plan facts).
|
|
113
|
+
- `createSession(settings, apiKey, { proxyCountry, idleTimeoutSec, maxDurationMs }) → { id, cdpUrl, egress }`: maps the session options onto the provider's parameters; throws `QuotaError` when the provider refuses the session for the account's quota.
|
|
114
|
+
- `releaseSession(settings, apiKey, session)`.
|
|
115
|
+
- `sessionUsage(settings, apiKey, [{ id, openedAt, closedAt }]) → [{ id, durationMs, proxyBytes }]`: the record of those sessions that have ended: the provider's own, or the ledger's timing for a provider whose records its API key cannot read.
|
|
116
|
+
- `quota(settings, apiKey, history) → { remaining, unit, renewsAt? }`: `history.sessionMsSince(ms)` is the ledger's record of the provider's sessions, for a provider that cannot report its own usage.
|
|
117
|
+
|
|
118
|
+
Requests go through `src/providers/http.js`: every status other than 2xx is an `HttpError` (with `status`), so an adapter handles only the statuses that mean something of their own, such as a quota refusal.
|
|
119
|
+
|
|
120
|
+
A program can also pass an adapter of its own in `adapters`, without changing onebrowser.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
`npm test` runs offline: fake adapters and fetch, no network, no browser.
|
|
125
|
+
|
|
126
|
+
A release is a tag: bump `version` in `package.json` and add its entry to [CHANGELOG.md](CHANGELOG.md), then push the tag `vX.Y.Z`. The release workflow checks the tag against the version, runs the tests and publishes to npm with trusted publishing (no token stored).
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "onebrowser",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "One interface to remote browser providers: sessions by provider name, a usage ledger, account quotas",
|
|
5
|
+
"keywords": ["browser", "remote-browser", "cdp", "playwright", "steel", "hyperbrowser", "lightpanda", "kernel", "tinyfish"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Richard Wang",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/i-richardwang/onebrowser.git" },
|
|
9
|
+
"homepage": "https://github.com/i-richardwang/onebrowser#readme",
|
|
10
|
+
"bugs": "https://github.com/i-richardwang/onebrowser/issues",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"exports": "./src/index.js",
|
|
13
|
+
"bin": { "onebrowser": "bin/onebrowser.js" },
|
|
14
|
+
"files": ["src", "bin"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test test/*.test.js"
|
|
17
|
+
},
|
|
18
|
+
"engines": { "node": ">=22.13" },
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"playwright-core": "^1.48.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { apiKeyEnv } from './credentials.js';
|
|
3
|
+
import { OneBrowser } from './index.js';
|
|
4
|
+
|
|
5
|
+
const DAY_MS = 864e5;
|
|
6
|
+
|
|
7
|
+
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
8
|
+
|
|
9
|
+
/** The command line is wrong: exit status 2 */
|
|
10
|
+
class CommandLineError extends Error {}
|
|
11
|
+
|
|
12
|
+
/** Options of every command */
|
|
13
|
+
const GLOBAL_OPTIONS = {
|
|
14
|
+
json: { type: 'boolean' },
|
|
15
|
+
'env-file': { type: 'string' },
|
|
16
|
+
help: { type: 'boolean', short: 'h' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The commands: their own options, whether they take arguments, and run(browsers, values, args, { out, err })
|
|
21
|
+
* → exit status
|
|
22
|
+
*/
|
|
23
|
+
const COMMANDS = {
|
|
24
|
+
quota: {
|
|
25
|
+
positionals: true,
|
|
26
|
+
async run(browsers, { json }, names, { out }) {
|
|
27
|
+
const unknown = names.filter((n) => !browsers.providers.includes(n));
|
|
28
|
+
if (unknown.length) throw new CommandLineError(`unknown provider ${unknown.join(', ')} (expected ${browsers.providers.join(', ')})`);
|
|
29
|
+
const asked = names.length ? names : browsers.providers.filter((p) => isReady(browsers, p));
|
|
30
|
+
if (!asked.length) throw new Error(`no provider has an API key: set ${browsers.providers.map(apiKeyEnv).join(', ')}`);
|
|
31
|
+
const quotas = await Promise.all(asked.map((provider) => browsers.quota(provider).then((q) => ({ provider, ...q }), (e) => ({ provider, error: e.message }))));
|
|
32
|
+
if (json) out.write(`${JSON.stringify(quotas, null, 2)}\n`);
|
|
33
|
+
else {
|
|
34
|
+
for (const q of quotas) {
|
|
35
|
+
const text = q.error ? `unknown (${q.error})` : `${Number(q.remaining.toFixed(2))} ${q.unit} left${q.renewsAt ? `, renews ${day(q.renewsAt)}` : ''}`;
|
|
36
|
+
out.write(`${q.provider.padEnd(14)} ${text}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return quotas.some((q) => q.error) ? 1 : 0;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
usage: {
|
|
44
|
+
options: { days: { type: 'string', default: '30' } },
|
|
45
|
+
async run(browsers, { days, json }, args, { out, err }) {
|
|
46
|
+
const n = Number(days);
|
|
47
|
+
if (!(Number.isInteger(n) && n >= 1)) throw new CommandLineError(`--days must be a whole number ≥ 1, not ${days}`);
|
|
48
|
+
const since = new Date().setUTCHours(0, 0, 0, 0) - (n - 1) * DAY_MS;
|
|
49
|
+
const { failed } = await browsers.syncUsage();
|
|
50
|
+
for (const f of failed) err.write(`${f.provider}: the provider's records were not fetched (${f.error})\n`);
|
|
51
|
+
const sessions = browsers.usage({ since });
|
|
52
|
+
if (json) out.write(`${JSON.stringify({ ledger: browsers.ledgerPath, since: day(since), sessions, failed }, null, 2)}\n`);
|
|
53
|
+
else {
|
|
54
|
+
out.write(`sessions since ${day(since)} in ${browsers.ledgerPath}:\n`);
|
|
55
|
+
if (!sessions.length) out.write(' none\n');
|
|
56
|
+
for (const r of sessions) out.write(usageLine(`${r.day} ${r.provider} ${r.client}`, r));
|
|
57
|
+
for (const [provider, rows] of Map.groupBy(sessions, (r) => r.provider)) {
|
|
58
|
+
if (rows.length > 1) out.write(usageLine(`total ${provider}`, sumUsage(rows)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return failed.length ? 1 : 0;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const help = (providers) => `onebrowser: the quotas of remote browser provider accounts, and the sessions in this machine's ledger
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
onebrowser quota [PROVIDER...] what each provider account has left; default: every provider with an API key
|
|
70
|
+
onebrowser usage [--days N] sessions per UTC day, provider and client in the last N UTC days, today
|
|
71
|
+
included (default 30), after fetching the providers' records of ended sessions
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
--json JSON on stdout instead of text
|
|
75
|
+
--env-file PATH read environment variables (API keys) from PATH first
|
|
76
|
+
-h, --help
|
|
77
|
+
|
|
78
|
+
Environment:
|
|
79
|
+
${providers.map((p) => ` ${apiKeyEnv(p).padEnd(22)} API key of ${p}, or ${apiKeyEnv(p)}_FILE naming a file that holds it`).join('\n')}
|
|
80
|
+
ONEBROWSER_DB the ledger file (default: onebrowser/sessions.db in $XDG_DATA_HOME, else ~/.local/share)
|
|
81
|
+
|
|
82
|
+
Exit status: 0 done, 1 a reading failed, 2 the command line is wrong.
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
function isReady(browsers, provider) {
|
|
86
|
+
try {
|
|
87
|
+
browsers.checkReady(provider);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const USAGE_FIELDS = ['sessions', 'minutes', 'untimed', 'unsynced', 'proxyBytes'];
|
|
95
|
+
|
|
96
|
+
const sumUsage = (rows) => Object.fromEntries(USAGE_FIELDS.map((k) => [k, rows.reduce((t, r) => t + r[k], 0)]));
|
|
97
|
+
|
|
98
|
+
const usageLine = (label, r) =>
|
|
99
|
+
` ${label.padEnd(36)} ${String(r.sessions).padStart(5)} sessions ${r.minutes.toFixed(1).padStart(8)} min` +
|
|
100
|
+
` ${(r.proxyBytes / 1e6).toFixed(1).padStart(7)} MB proxy` +
|
|
101
|
+
(r.untimed ? ` (${r.untimed} untimed)` : '') +
|
|
102
|
+
(r.unsynced ? ` (${r.unsynced} not reported by the provider yet)` : '') +
|
|
103
|
+
'\n';
|
|
104
|
+
|
|
105
|
+
/** The command and its parsed options: parsed once to find the command, then again with only its own options */
|
|
106
|
+
function parseCommandLine(argv) {
|
|
107
|
+
const every = Object.assign({}, GLOBAL_OPTIONS, ...Object.values(COMMANDS).map((c) => c.options));
|
|
108
|
+
const [name] = parseArgs({ args: argv, options: every, allowPositionals: true }).positionals;
|
|
109
|
+
if (name !== undefined && !Object.hasOwn(COMMANDS, name)) throw new CommandLineError(`unknown command ${name} (expected ${Object.keys(COMMANDS).join(', ')})`);
|
|
110
|
+
const command = COMMANDS[name]; // none without a name: the help
|
|
111
|
+
const { values, positionals } = parseArgs({ args: argv, options: { ...GLOBAL_OPTIONS, ...command?.options }, allowPositionals: true });
|
|
112
|
+
const args = positionals.slice(1);
|
|
113
|
+
if (args.length && !command?.positionals) throw new CommandLineError(`${name} takes no arguments, not ${args.join(' ')}`);
|
|
114
|
+
return { command, values, args };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The onebrowser command (bin/onebrowser.js): runs `argv` and returns the exit status. browsers: the OneBrowser
|
|
119
|
+
* to read (default: one with the built-in providers and the default ledger); it is closed when done.
|
|
120
|
+
*/
|
|
121
|
+
export async function run(argv, { out = process.stdout, err = process.stderr, browsers } = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const { command, values, args } = parseCommandLine(argv);
|
|
124
|
+
if (values['env-file']) process.loadEnvFile(values['env-file']);
|
|
125
|
+
browsers ??= new OneBrowser({ client: 'onebrowser' });
|
|
126
|
+
if (values.help || !command) {
|
|
127
|
+
(values.help ? out : err).write(help(browsers.providers));
|
|
128
|
+
return values.help ? 0 : 2;
|
|
129
|
+
}
|
|
130
|
+
return await command.run(browsers, values, args, { out, err });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
const misuse = e instanceof CommandLineError || e.code?.startsWith('ERR_PARSE_ARGS');
|
|
133
|
+
err.write(`onebrowser: ${e.message}\n${misuse ? 'see onebrowser --help\n' : ''}`);
|
|
134
|
+
return misuse ? 2 : 1;
|
|
135
|
+
} finally {
|
|
136
|
+
await browsers?.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/** The environment variable holding a provider's API key: STEEL_API_KEY for `steel` */
|
|
4
|
+
export const apiKeyEnv = (provider) => `${provider.toUpperCase()}_API_KEY`;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* API key of a provider: `apiKey` as given in its settings, else the environment variable apiKeyEnv(provider),
|
|
8
|
+
* else the file named by that variable with _FILE appended. An empty value counts as unset. Throws when there is
|
|
9
|
+
* none.
|
|
10
|
+
*/
|
|
11
|
+
export function readApiKey(provider, apiKey) {
|
|
12
|
+
if (apiKey?.trim()) return apiKey.trim();
|
|
13
|
+
const env = apiKeyEnv(provider);
|
|
14
|
+
const file = process.env[`${env}_FILE`];
|
|
15
|
+
let key = process.env[env];
|
|
16
|
+
if (!key && file) {
|
|
17
|
+
if (!fs.existsSync(file)) throw new Error(`${provider} key: ${env}_FILE names no file (${file})`);
|
|
18
|
+
key = fs.readFileSync(file, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
if (!key?.trim()) throw new Error(`missing ${provider} key: set ${env} or ${env}_FILE`);
|
|
21
|
+
return key.trim();
|
|
22
|
+
}
|
package/src/errors.js
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readApiKey } from './credentials.js';
|
|
2
|
+
import { QuotaError } from './errors.js';
|
|
3
|
+
import { defaultLedgerPath, Ledger } from './ledger.js';
|
|
4
|
+
import { ADAPTERS, checkAdapter } from './providers/index.js';
|
|
5
|
+
import { KEEP_ALIVE_MS, Session } from './session.js';
|
|
6
|
+
|
|
7
|
+
export { apiKeyEnv } from './credentials.js';
|
|
8
|
+
export { QuotaError };
|
|
9
|
+
|
|
10
|
+
/** Session options when openSession() is not given them */
|
|
11
|
+
const SESSION_DEFAULTS = {
|
|
12
|
+
proxyCountry: null, // null: the provider's default egress
|
|
13
|
+
idleTimeoutSec: 60, // a session whose client is gone (its process was killed) ends this long after at the latest
|
|
14
|
+
maxDurationMs: 15 * 60_000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The session options of openSession() with the defaults filled in (SESSION_DEFAULTS); throws when one is unknown
|
|
19
|
+
* or out of range, so that a program can check its configuration before it opens any session:
|
|
20
|
+
* proxyCountry country code: the provider's proxy there; null: its default egress
|
|
21
|
+
* idleTimeoutSec a session whose client is gone ends this long after at the latest; at least twice the
|
|
22
|
+
* keep-alive interval (session.js)
|
|
23
|
+
* maxDurationMs the session ends this long after it was opened at the latest
|
|
24
|
+
*/
|
|
25
|
+
export function sessionOptions(options = {}) {
|
|
26
|
+
const unknown = Object.keys(options).filter((k) => !Object.hasOwn(SESSION_DEFAULTS, k));
|
|
27
|
+
if (unknown.length) throw new Error(`unknown session option ${unknown.join(', ')} (expected ${Object.keys(SESSION_DEFAULTS).join(', ')})`);
|
|
28
|
+
const o = { ...SESSION_DEFAULTS, ...options };
|
|
29
|
+
if (!(Number.isFinite(o.idleTimeoutSec) && o.idleTimeoutSec * 1000 >= 2 * KEEP_ALIVE_MS)) {
|
|
30
|
+
throw new Error(`idleTimeoutSec must be at least ${(2 * KEEP_ALIVE_MS) / 1000} (twice the keep-alive interval), not ${o.idleTimeoutSec}`);
|
|
31
|
+
}
|
|
32
|
+
if (!(Number.isFinite(o.maxDurationMs) && o.maxDurationMs > 0)) throw new Error(`maxDurationMs must be a number > 0, not ${o.maxDurationMs}`);
|
|
33
|
+
return o;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remote browsers from several providers behind one interface: a session is asked for by provider name, the way
|
|
38
|
+
* an LLM request names its model. Every session is recorded in a ledger (ledger.js), which the usage of each
|
|
39
|
+
* provider account and, for providers that cannot report it themselves, its quota are read from.
|
|
40
|
+
*
|
|
41
|
+
* Which provider to use, how many sessions to hold and when to stop using one are the caller's decisions:
|
|
42
|
+
* OneBrowser opens the sessions it is asked for.
|
|
43
|
+
*
|
|
44
|
+
* new OneBrowser({ client, ledgerPath, providers, adapters })
|
|
45
|
+
* client the program using it, recorded with each session (several programs may share one ledger)
|
|
46
|
+
* ledgerPath SQLite file of the ledger (default: defaultLedgerPath() in ledger.js; ':memory:' for tests),
|
|
47
|
+
* opened when first needed
|
|
48
|
+
* providers settings per provider name: apiKey (else from the environment, see credentials.js), and the
|
|
49
|
+
* adapter's own settings on top of its defaults (providers/<name>.js)
|
|
50
|
+
* adapters further adapters by provider name, or replacements of built-in ones (providers/index.js)
|
|
51
|
+
*/
|
|
52
|
+
export class OneBrowser {
|
|
53
|
+
#providers = new Map(); // name → { adapter, settings: its defaults with the given ones on top, apiKey: the one given }
|
|
54
|
+
#ledger = null;
|
|
55
|
+
#open = new Set();
|
|
56
|
+
|
|
57
|
+
constructor({ client, ledgerPath = defaultLedgerPath(), providers = {}, adapters = {} }) {
|
|
58
|
+
if (!client?.trim()) throw new Error('onebrowser: client must name the program using it');
|
|
59
|
+
this.client = client;
|
|
60
|
+
this.ledgerPath = ledgerPath;
|
|
61
|
+
for (const [name, adapter] of Object.entries({ ...ADAPTERS, ...adapters })) {
|
|
62
|
+
checkAdapter(name, adapter);
|
|
63
|
+
const { apiKey, ...settings } = providers[name] ?? {};
|
|
64
|
+
this.#providers.set(name, { adapter, settings: { ...adapter.defaults, ...settings }, apiKey });
|
|
65
|
+
}
|
|
66
|
+
const unknown = Object.keys(providers).filter((name) => !this.#providers.has(name));
|
|
67
|
+
if (unknown.length) throw new Error(`onebrowser: no adapter for provider ${unknown.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Names of the providers it can open sessions with */
|
|
71
|
+
get providers() {
|
|
72
|
+
return [...this.#providers.keys()];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get #store() {
|
|
76
|
+
return (this.#ledger ??= new Ledger(this.ledgerPath));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The adapter, settings and API key of `provider`; throws when it is unknown or has no API key */
|
|
80
|
+
#ready(provider) {
|
|
81
|
+
const p = this.#providers.get(provider);
|
|
82
|
+
if (!p) throw new Error(`unknown provider ${provider} (expected ${this.providers.join(', ')})`);
|
|
83
|
+
return { ...p, apiKey: readApiKey(provider, p.apiKey) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Throws when sessions cannot be opened with `provider`: unknown, or no API key */
|
|
87
|
+
checkReady(provider) {
|
|
88
|
+
this.#ready(provider);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A new session with `provider`, recorded in the ledger once the provider has created it; options: see
|
|
93
|
+
* sessionOptions(). Throws when the provider does not create one; QuotaError when it refuses for the account's
|
|
94
|
+
* quota.
|
|
95
|
+
*/
|
|
96
|
+
async openSession({ provider, ...options }) {
|
|
97
|
+
const { adapter, settings, apiKey } = this.#ready(provider);
|
|
98
|
+
const o = sessionOptions(options);
|
|
99
|
+
let remote;
|
|
100
|
+
try {
|
|
101
|
+
remote = await adapter.createSession(settings, apiKey, o);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e instanceof QuotaError) throw e;
|
|
104
|
+
throw new Error(`failed to create a ${provider} session: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
const session = new Session({
|
|
107
|
+
provider, remote, maxDurationMs: o.maxDurationMs,
|
|
108
|
+
release: async (s) => {
|
|
109
|
+
this.#open.delete(s);
|
|
110
|
+
this.#store.closed(s);
|
|
111
|
+
await adapter.releaseSession(settings, apiKey, s);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
this.#store.opened({ provider, id: session.id, client: this.client, openedAt: session.openedAt });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
await session.close().catch(() => {}); // a session the ledger has no record of is not handed out
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
this.#open.add(session);
|
|
121
|
+
return session;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What is left of the account's quota with `provider`: { remaining, unit, renewsAt } in the provider's own
|
|
126
|
+
* unit; renewsAt (ms) when it renews by itself, null when only a top-up renews it. Throws when it cannot be read.
|
|
127
|
+
*/
|
|
128
|
+
async quota(provider) {
|
|
129
|
+
const { adapter, settings, apiKey } = this.#ready(provider);
|
|
130
|
+
const q = await adapter.quota(settings, apiKey, this.#store.history(provider));
|
|
131
|
+
if (!(Number.isFinite(q?.remaining) && typeof q.unit === 'string')) throw new Error(`${provider} answered no quota: ${JSON.stringify(q)}`);
|
|
132
|
+
return { remaining: q.remaining, unit: q.unit, renewsAt: q.renewsAt ?? null };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Stores each provider's record (duration, proxy traffic) of the sessions in the ledger that have ended and have
|
|
137
|
+
* none yet; sessions still running wait for a later call. A provider that cannot be reached, or has no API key,
|
|
138
|
+
* is left for a later call too. Sessions of a provider it has no adapter for (another program's own) are left
|
|
139
|
+
* to the programs that have one. Returns { reported: sessions completed, failed: [{ provider, error }] }.
|
|
140
|
+
*/
|
|
141
|
+
async syncUsage() {
|
|
142
|
+
const store = this.#store;
|
|
143
|
+
let reported = 0;
|
|
144
|
+
const failed = [];
|
|
145
|
+
for (const [provider, rows] of Map.groupBy(store.awaitingUsage(this.providers), (s) => s.provider)) {
|
|
146
|
+
try {
|
|
147
|
+
const { adapter, settings, apiKey } = this.#ready(provider);
|
|
148
|
+
const usage = await adapter.sessionUsage(settings, apiKey, rows);
|
|
149
|
+
store.tx(() => usage.forEach((u) => store.recordUsage(provider, u)));
|
|
150
|
+
reported += usage.length;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
failed.push({ provider, error: e.message });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { reported, failed };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Sessions opened since `since` (ms), per UTC day, provider and client:
|
|
160
|
+
* [{ day, provider, client, sessions, minutes, untimed, unsynced, proxyBytes }]. Minutes are the provider's
|
|
161
|
+
* record where syncUsage has stored it, otherwise the open-to-close timing of the process that opened the
|
|
162
|
+
* session; untimed: sessions with neither (their process ended before closing them and the provider has not
|
|
163
|
+
* reported them yet); unsynced: sessions without the provider's record.
|
|
164
|
+
*/
|
|
165
|
+
usage({ since }) {
|
|
166
|
+
return this.#store.usage(since);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Closes every session still open (their release errors are ignored: the providers end those) and the ledger */
|
|
170
|
+
async close() {
|
|
171
|
+
await Promise.all([...this.#open].map((s) => s.close().catch(() => {})));
|
|
172
|
+
this.#ledger?.close();
|
|
173
|
+
this.#ledger = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** close(), for `await using browsers = new OneBrowser(…)` */
|
|
177
|
+
[Symbol.asyncDispose]() {
|
|
178
|
+
return this.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the ledger lives unless a path is given: $ONEBROWSER_DB, else onebrowser/sessions.db in the user's data
|
|
8
|
+
* directory ($XDG_DATA_HOME, default ~/.local/share). One ledger per machine is shared by every project on it, so
|
|
9
|
+
* that the record of a provider account's sessions is whole, whichever project opened them.
|
|
10
|
+
*/
|
|
11
|
+
export function defaultLedgerPath() {
|
|
12
|
+
if (process.env.ONEBROWSER_DB) return process.env.ONEBROWSER_DB;
|
|
13
|
+
const data = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
14
|
+
return path.join(data, 'onebrowser', 'sessions.db');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* sessions every remote browser session: recorded when its provider has created it (it is billed from then on,
|
|
19
|
+
* even if connecting fails), closed when released, and completed with the provider's own record
|
|
20
|
+
* (duration, proxy traffic) once the session has ended and syncUsage has fetched it
|
|
21
|
+
*/
|
|
22
|
+
const SCHEMA = `
|
|
23
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
24
|
+
provider TEXT NOT NULL,
|
|
25
|
+
id TEXT NOT NULL, -- the session id its provider gave it
|
|
26
|
+
client TEXT NOT NULL, -- the program that opened it (OneBrowser's client)
|
|
27
|
+
opened_at TEXT NOT NULL, -- when the provider created it
|
|
28
|
+
closed_at TEXT, -- when it was released; empty if its process ended first
|
|
29
|
+
duration_ms INTEGER, -- the provider's record, once the session has ended
|
|
30
|
+
proxy_bytes INTEGER, -- proxy traffic in the provider's record
|
|
31
|
+
synced_at TEXT, -- when the provider's record was stored; empty = not yet
|
|
32
|
+
PRIMARY KEY (provider, id)
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_opened ON sessions(provider, opened_at);
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
/** Duration of a session: the provider's record, else the open-to-close timing of the process that opened it */
|
|
38
|
+
const SESSION_MS = 'COALESCE(duration_ms, CAST((julianday(closed_at) - julianday(opened_at)) * 86400000 AS INTEGER))';
|
|
39
|
+
|
|
40
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
41
|
+
const int = (x) => (x === null || x === undefined || !Number.isFinite(Number(x)) ? null : Math.trunc(Number(x)));
|
|
42
|
+
|
|
43
|
+
export class Ledger {
|
|
44
|
+
#db;
|
|
45
|
+
#stmts = new Map();
|
|
46
|
+
|
|
47
|
+
constructor(file) {
|
|
48
|
+
if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
this.#db = new DatabaseSync(file);
|
|
50
|
+
this.#db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;'); // several processes share it
|
|
51
|
+
this.#db.exec(SCHEMA);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#q(sql) {
|
|
55
|
+
let s = this.#stmts.get(sql);
|
|
56
|
+
if (!s) this.#stmts.set(sql, (s = this.#db.prepare(sql)));
|
|
57
|
+
return s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
opened({ provider, id, client, openedAt }) {
|
|
61
|
+
this.#q('INSERT INTO sessions (provider, id, client, opened_at) VALUES (?,?,?,?)').run(provider, id, client, iso(openedAt));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
closed({ provider, id, closedAt }) {
|
|
65
|
+
this.#q('UPDATE sessions SET closed_at=? WHERE provider=? AND id=? AND closed_at IS NULL').run(iso(closedAt), provider, id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The record of one provider's sessions, as a provider adapter reads it for its quota (providers/index.js) */
|
|
69
|
+
history(provider) {
|
|
70
|
+
return {
|
|
71
|
+
/** Session time (ms) of the sessions opened since `since` (ms) that have a duration */
|
|
72
|
+
sessionMsSince: (since) => this.#q(`SELECT COALESCE(SUM(${SESSION_MS}), 0) ms FROM sessions WHERE provider=? AND opened_at >= ?`).get(provider, iso(since)).ms,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Sessions with one of `providers` whose provider's record has not been stored yet: [{ provider, id, openedAt, closedAt }] (ISO times) */
|
|
77
|
+
awaitingUsage(providers) {
|
|
78
|
+
return this.#q(
|
|
79
|
+
'SELECT provider, id, opened_at openedAt, closed_at closedAt FROM sessions WHERE synced_at IS NULL AND provider IN (SELECT value FROM json_each(?)) ORDER BY opened_at',
|
|
80
|
+
).all(JSON.stringify(providers));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
recordUsage(provider, { id, durationMs, proxyBytes }) {
|
|
84
|
+
this.#q('UPDATE sessions SET duration_ms=?, proxy_bytes=?, synced_at=? WHERE provider=? AND id=?').run(int(durationMs), int(proxyBytes), iso(Date.now()), provider, id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
tx(fn) {
|
|
88
|
+
this.#db.exec('BEGIN');
|
|
89
|
+
try {
|
|
90
|
+
const r = fn();
|
|
91
|
+
this.#db.exec('COMMIT');
|
|
92
|
+
return r;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
this.#db.exec('ROLLBACK');
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** See OneBrowser.usage() */
|
|
100
|
+
usage(since) {
|
|
101
|
+
return this.#q(
|
|
102
|
+
`SELECT substr(opened_at, 1, 10) day, provider, client, COUNT(*) sessions,
|
|
103
|
+
COALESCE(SUM(${SESSION_MS}), 0) / 60000.0 minutes, SUM(${SESSION_MS} IS NULL) untimed,
|
|
104
|
+
SUM(synced_at IS NULL) unsynced, COALESCE(SUM(proxy_bytes), 0) proxyBytes
|
|
105
|
+
FROM sessions WHERE opened_at >= ? GROUP BY day, provider, client ORDER BY day, provider, client`,
|
|
106
|
+
).all(iso(since));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close() {
|
|
110
|
+
this.#db.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A provider API answered with a status other than 2xx; text: the start of its answer */
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
constructor(status, answer) {
|
|
4
|
+
const text = answer.slice(0, 300);
|
|
5
|
+
super(`HTTP ${status}: ${text}`);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.text = text;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One request to a provider API: { body (its JSON; null when empty), headers }. `body` is sent as JSON. Throws
|
|
13
|
+
* HttpError for a status other than 2xx, so that an adapter handles only the statuses that mean something of
|
|
14
|
+
* their own (a quota refusal, a session already gone).
|
|
15
|
+
*/
|
|
16
|
+
export async function request(url, { method = 'GET', headers = {}, body, timeoutMs = 30_000 } = {}) {
|
|
17
|
+
const r = await fetch(url, {
|
|
18
|
+
method,
|
|
19
|
+
headers: body === undefined ? headers : { ...headers, 'Content-Type': 'application/json' },
|
|
20
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
21
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
22
|
+
});
|
|
23
|
+
const text = await r.text();
|
|
24
|
+
if (!r.ok) throw new HttpError(r.status, text);
|
|
25
|
+
return { body: text ? JSON.parse(text) : null, headers: r.headers };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** request() → the response's JSON */
|
|
29
|
+
export async function json(url, options) {
|
|
30
|
+
return (await request(url, options)).body;
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { json } from './http.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hyperbrowser (https://hyperbrowser.ai/docs). Without a proxy a session uses one of Hyperbrowser's US
|
|
5
|
+
* datacenter IPs, drawn from a pool: consecutive sessions usually differ, but an IP can come back.
|
|
6
|
+
* proxyCountry selects its residential proxy (paid plans only). It has no idle timeout: a session ends when its
|
|
7
|
+
* CDP connection closes, which meets any idleTimeoutSec, and after maxDurationMs in any case, which Hyperbrowser
|
|
8
|
+
* takes in whole minutes.
|
|
9
|
+
*/
|
|
10
|
+
export const defaults = {
|
|
11
|
+
apiBase: 'https://api.hyperbrowser.ai/api',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const headers = (apiKey) => ({ 'x-api-key': apiKey });
|
|
15
|
+
|
|
16
|
+
export async function createSession(settings, apiKey, { proxyCountry, maxDurationMs }) {
|
|
17
|
+
const body = { timeoutMinutes: Math.ceil(maxDurationMs / 60_000) };
|
|
18
|
+
if (proxyCountry) Object.assign(body, { useProxy: true, proxyCountry });
|
|
19
|
+
const s = await json(`${settings.apiBase}/session`, { method: 'POST', headers: headers(apiKey), body, timeoutMs: 90_000 });
|
|
20
|
+
return { id: s.id, cdpUrl: s.wsEndpoint, egress: proxyCountry ? `proxy ${proxyCountry}` : 'datacenter IP' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Succeeds also for a session that has already ended (its CDP connection closed first) */
|
|
24
|
+
export async function releaseSession(settings, apiKey, session) {
|
|
25
|
+
await json(`${settings.apiBase}/session/${session.id}/stop`, { method: 'PUT', headers: headers(apiKey) });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The team's credits left (the endpoint of Hyperbrowser's SDK; it is not in Hyperbrowser's API docs) */
|
|
29
|
+
export async function quota(settings, apiKey) {
|
|
30
|
+
const { remaining } = await json(`${settings.apiBase}/team/credit-info`, { headers: headers(apiKey) });
|
|
31
|
+
return { remaining, unit: 'credits' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Usage of the sessions among `sessions` that have ended (have an endTime), fetched by id. Times are epoch ms, as numbers or strings */
|
|
35
|
+
export async function sessionUsage(settings, apiKey, sessions) {
|
|
36
|
+
const usage = [];
|
|
37
|
+
for (const { id } of sessions) {
|
|
38
|
+
const s = await json(`${settings.apiBase}/session/${id}`, { headers: headers(apiKey) });
|
|
39
|
+
if (s.endTime) usage.push({ id, durationMs: Number(s.endTime) - Number(s.startTime), proxyBytes: Number(s.proxyDataConsumed) });
|
|
40
|
+
}
|
|
41
|
+
return usage;
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as hyperbrowser from './hyperbrowser.js';
|
|
2
|
+
import * as kernel from './kernel.js';
|
|
3
|
+
import * as lightpanda from './lightpanda.js';
|
|
4
|
+
import * as steel from './steel.js';
|
|
5
|
+
import * as tinyfish from './tinyfish.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Provider adapters, by provider name: the only provider-specific code. Each exports:
|
|
9
|
+
* defaults its settings (API base URLs, plan facts such as monthlyHours); OneBrowser's
|
|
10
|
+
* `providers` option overrides them per provider. The API key is not among them:
|
|
11
|
+
* each function gets it as its own argument
|
|
12
|
+
* createSession(settings, apiKey, options) → { id, cdpUrl, egress }
|
|
13
|
+
* options: { proxyCountry, idleTimeoutSec, maxDurationMs } (OneBrowser.openSession), mapped onto the
|
|
14
|
+
* provider's own parameters. Throws on failure; QuotaError (errors.js) when the provider refuses the
|
|
15
|
+
* session because the account's quota is used up
|
|
16
|
+
* releaseSession(settings, apiKey, session) ends the session; throws on failure
|
|
17
|
+
* sessionUsage(settings, apiKey, [{ id, openedAt, closedAt }]) → [{ id, durationMs, proxyBytes }]
|
|
18
|
+
* the record of those sessions that have ended: the provider's own, or, for a provider whose records the
|
|
19
|
+
* API key cannot read, the ledger's timing (closedAt is empty for a session no process closed); throws on
|
|
20
|
+
* failure
|
|
21
|
+
* quota(settings, apiKey, history) → { remaining, unit, renewsAt? }
|
|
22
|
+
* what is left of the account's quota, in the provider's own unit (USD, credits, hours, …), and when it
|
|
23
|
+
* renews if it does by itself (ms); history: the ledger's record of the provider's sessions
|
|
24
|
+
* (Ledger.history). Throws when it cannot be read
|
|
25
|
+
*
|
|
26
|
+
* Their requests go through http.js, which turns every status other than 2xx into an HttpError: an adapter
|
|
27
|
+
* handles only the statuses that mean something of their own.
|
|
28
|
+
*/
|
|
29
|
+
export const ADAPTERS = { tinyfish, steel, hyperbrowser, lightpanda, kernel };
|
|
30
|
+
|
|
31
|
+
const CONTRACT = ['createSession', 'releaseSession', 'sessionUsage', 'quota'];
|
|
32
|
+
|
|
33
|
+
/** Throws unless `adapter` has the whole contract */
|
|
34
|
+
export function checkAdapter(name, adapter) {
|
|
35
|
+
const missing = CONTRACT.filter((f) => typeof adapter?.[f] !== 'function');
|
|
36
|
+
if (missing.length) throw new Error(`provider ${name}: its adapter lacks ${missing.join(', ')}`);
|
|
37
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { QuotaError } from '../errors.js';
|
|
2
|
+
import { json, request } from './http.js';
|
|
3
|
+
import { utcMonth } from './month.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Kernel (https://www.kernel.sh/docs). Sessions are headless: Kernel's default is a headful browser, billed at
|
|
7
|
+
* a higher rate. A session uses the IP of the Kernel machine it runs on, a different one per session. Kernel's
|
|
8
|
+
* proxies are resources of their own, on paid plans, so proxyCountry is not supported. Closing the CDP
|
|
9
|
+
* connection does not end a session: Kernel keeps it in standby, unbilled but counting toward the plan's
|
|
10
|
+
* concurrent browsers, and deletes it idleTimeoutSec after its CDP client left (timeout_seconds), unless it is
|
|
11
|
+
* released before. It takes no limit on a session's length. An account at its spending cap is refused new
|
|
12
|
+
* sessions with 403.
|
|
13
|
+
*/
|
|
14
|
+
export const defaults = {
|
|
15
|
+
apiBase: 'https://api.onkernel.com',
|
|
16
|
+
monthlyHours: 80, // headless browser hours a month that the free plan's credits pay for, rounded down
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const headers = (apiKey) => ({ Authorization: `Bearer ${apiKey}` });
|
|
20
|
+
|
|
21
|
+
export async function createSession(settings, apiKey, { proxyCountry, idleTimeoutSec }) {
|
|
22
|
+
if (proxyCountry) throw new Error('proxyCountry is not supported by the kernel provider');
|
|
23
|
+
let s;
|
|
24
|
+
try {
|
|
25
|
+
s = await json(`${settings.apiBase}/browsers`, { method: 'POST', headers: headers(apiKey), body: { headless: true, timeout_seconds: idleTimeoutSec }, timeoutMs: 90_000 });
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e.status === 403) throw new QuotaError(`spending cap reached: ${e.text}`);
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
return { id: s.session_id, cdpUrl: s.cdp_ws_url, egress: 'datacenter IP' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 404: Kernel has deleted the session already */
|
|
34
|
+
export async function releaseSession(settings, apiKey, session) {
|
|
35
|
+
try {
|
|
36
|
+
await json(`${settings.apiBase}/browsers/${session.id}`, { method: 'DELETE', headers: headers(apiKey) });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (e.status !== 404) throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Browser hours left of the month's monthlyHours, after the uptime of every session of the account this month:
|
|
44
|
+
* Kernel's own records, which include sessions opened by any program. The sessions are listed newest first, so
|
|
45
|
+
* the listing stops at a page that holds none of this month. The hours are assumed to renew with each calendar
|
|
46
|
+
* month (UTC).
|
|
47
|
+
*/
|
|
48
|
+
export async function quota(settings, apiKey) {
|
|
49
|
+
const month = utcMonth();
|
|
50
|
+
let usedMs = 0;
|
|
51
|
+
for (let offset = 0; ; offset += 100) {
|
|
52
|
+
const r = await request(`${settings.apiBase}/browsers?status=all&limit=100&offset=${offset}`, { headers: headers(apiKey) });
|
|
53
|
+
const ofMonth = r.body.filter((s) => Date.parse(s.created_at) >= month.start);
|
|
54
|
+
usedMs += ofMonth.reduce((ms, s) => ms + (s.usage?.uptime_ms ?? 0), 0);
|
|
55
|
+
if (!ofMonth.length || r.headers.get('x-has-more') !== 'true') break;
|
|
56
|
+
}
|
|
57
|
+
return { remaining: settings.monthlyHours - usedMs / 3_600_000, unit: 'hours', renewsAt: month.next };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Usage of the sessions among `sessions` that Kernel has deleted and finished billing, fetched by id */
|
|
61
|
+
export async function sessionUsage(settings, apiKey, sessions) {
|
|
62
|
+
const usage = [];
|
|
63
|
+
for (const { id } of sessions) {
|
|
64
|
+
const s = await json(`${settings.apiBase}/browsers/${id}?include_deleted=true`, { headers: headers(apiKey) });
|
|
65
|
+
if (s.usage_status === 'ready') usage.push({ id, durationMs: s.usage.uptime_ms, proxyBytes: null });
|
|
66
|
+
}
|
|
67
|
+
return usage;
|
|
68
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { utcMonth } from './month.js';
|
|
3
|
+
|
|
4
|
+
/** Lightpanda closes a CDP connection after this long, whatever maxDurationMs asks for */
|
|
5
|
+
const MAX_CONNECTION_MS = 15 * 60_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lightpanda Cloud (https://lightpanda.io/docs). It has no session API: a session is one CDP connection to
|
|
9
|
+
* connectBase, and it ends when that connection closes, which meets any idleTimeoutSec, and at the latest after
|
|
10
|
+
* MAX_CONNECTION_MS. Its `datacenter` proxy gives each session one IP from a shared pool, a different one per
|
|
11
|
+
* session; proxyCountry asks the pool for IPs in that country. Its engine is Lightpanda's own, not Chromium.
|
|
12
|
+
*/
|
|
13
|
+
export const defaults = {
|
|
14
|
+
connectBase: 'wss://euwest.cloud.lightpanda.io/ws',
|
|
15
|
+
monthlyHours: 10, // browser hours a month on the free plan
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function createSession(settings, apiKey, { proxyCountry }) {
|
|
19
|
+
const query = new URLSearchParams({ token: apiKey, proxy: 'datacenter' });
|
|
20
|
+
if (proxyCountry) query.set('country', proxyCountry.toLowerCase());
|
|
21
|
+
return {
|
|
22
|
+
id: randomUUID(),
|
|
23
|
+
cdpUrl: `${settings.connectBase}?${query}`,
|
|
24
|
+
egress: proxyCountry ? `datacenter IP, ${proxyCountry}` : 'datacenter IP',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Closing the CDP connection has ended the session */
|
|
29
|
+
export async function releaseSession() {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Browser hours left of the month's monthlyHours. Lightpanda's console shows the month's usage, but the token
|
|
33
|
+
* cannot read it: the month's usage is the ledger's record of the sessions (history), which is all of it while
|
|
34
|
+
* the account is used through ledgers of this machine only. The hours are assumed to renew with each calendar
|
|
35
|
+
* month (UTC).
|
|
36
|
+
*/
|
|
37
|
+
export async function quota(settings, apiKey, history) {
|
|
38
|
+
const month = utcMonth();
|
|
39
|
+
const usedHours = history.sessionMsSince(month.start) / 3_600_000;
|
|
40
|
+
return { remaining: settings.monthlyHours - usedHours, unit: 'hours', renewsAt: month.next };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lightpanda shows its records of sessions only in its console, which the token cannot read, so a session's
|
|
45
|
+
* record is the ledger's timing, which is exact: the session lasted as long as its CDP connection. A session no
|
|
46
|
+
* process closed (its process was killed) has ended once MAX_CONNECTION_MS have passed, untimed.
|
|
47
|
+
*/
|
|
48
|
+
export async function sessionUsage(settings, apiKey, sessions) {
|
|
49
|
+
const usage = [];
|
|
50
|
+
for (const { id, openedAt, closedAt } of sessions) {
|
|
51
|
+
if (closedAt) usage.push({ id, durationMs: Date.parse(closedAt) - Date.parse(openedAt), proxyBytes: null });
|
|
52
|
+
else if (Date.now() - Date.parse(openedAt) > MAX_CONNECTION_MS) usage.push({ id, durationMs: null, proxyBytes: null });
|
|
53
|
+
}
|
|
54
|
+
return usage;
|
|
55
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** The current calendar month (UTC): its first ms, and the first ms of the next month */
|
|
2
|
+
export function utcMonth() {
|
|
3
|
+
const d = new Date();
|
|
4
|
+
return { start: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1), next: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) };
|
|
5
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { json } from './http.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Steel.dev (https://docs.steel.dev). Without a proxy a session uses one of Steel's US datacenter IPs,
|
|
5
|
+
* a different one per session; proxyCountry selects a Steel residential proxy. Steel ends a session after
|
|
6
|
+
* idleTimeoutSec without CDP commands (inactivityTimeout), and after maxDurationMs in any case (timeout).
|
|
7
|
+
*/
|
|
8
|
+
export const defaults = {
|
|
9
|
+
apiBase: 'https://api.steel.dev/v1',
|
|
10
|
+
connectBase: 'wss://connect.steel.dev',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const headers = (apiKey) => ({ 'steel-api-key': apiKey });
|
|
14
|
+
|
|
15
|
+
export async function createSession(settings, apiKey, { proxyCountry, idleTimeoutSec, maxDurationMs }) {
|
|
16
|
+
const body = { timeout: maxDurationMs, inactivityTimeout: idleTimeoutSec * 1000, optimizeBandwidth: true };
|
|
17
|
+
if (proxyCountry) body.useProxy = { geolocation: { country: proxyCountry } };
|
|
18
|
+
const s = await json(`${settings.apiBase}/sessions`, { method: 'POST', headers: headers(apiKey), body, timeoutMs: 90_000 });
|
|
19
|
+
return {
|
|
20
|
+
id: s.id,
|
|
21
|
+
cdpUrl: `${settings.connectBase}?apiKey=${apiKey}&sessionId=${s.id}`,
|
|
22
|
+
egress: proxyCountry ? `proxy ${proxyCountry}` : 'datacenter IP',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function releaseSession(settings, apiKey, session) {
|
|
27
|
+
await json(`${settings.apiBase}/sessions/${session.id}/release`, { method: 'POST', headers: headers(apiKey), body: {} });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The account's balance in USD, as Steel's dashboard reads it (the endpoint is not in Steel's API docs) */
|
|
31
|
+
export async function quota(settings, apiKey) {
|
|
32
|
+
const { balanceCents } = await json(`${settings.apiBase}/credits/balance`, { headers: headers(apiKey) });
|
|
33
|
+
return { remaining: balanceCents / 100, unit: 'USD' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Usage of the sessions among `sessions` that have ended, fetched by id: the session list cannot be filtered by time */
|
|
37
|
+
export async function sessionUsage(settings, apiKey, sessions) {
|
|
38
|
+
const usage = [];
|
|
39
|
+
for (const { id } of sessions) {
|
|
40
|
+
const s = await json(`${settings.apiBase}/sessions/${id}`, { headers: headers(apiKey) });
|
|
41
|
+
if (s.status !== 'live') usage.push({ id, durationMs: s.duration, proxyBytes: s.proxyBytesUsed });
|
|
42
|
+
}
|
|
43
|
+
return usage;
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { QuotaError } from '../errors.js';
|
|
2
|
+
import { json } from './http.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TinyFish Browser API (https://docs.tinyfish.ai/browser-api): residential proxy per session, country chosen by
|
|
6
|
+
* proxy_config.country_code. TinyFish ends a session after idleTimeoutSec without CDP commands (timeout_seconds);
|
|
7
|
+
* it takes no limit on a session's length. Sessions are paid from the account's wallet; with too little in it,
|
|
8
|
+
* creating one answers 402.
|
|
9
|
+
*/
|
|
10
|
+
export const defaults = {
|
|
11
|
+
apiBase: 'https://api.browser.tinyfish.ai',
|
|
12
|
+
walletUrl: 'https://agent.tinyfish.ai/v1/wallet',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const headers = (apiKey) => ({ 'X-API-Key': apiKey });
|
|
16
|
+
|
|
17
|
+
export async function createSession(settings, apiKey, { proxyCountry, idleTimeoutSec }) {
|
|
18
|
+
const body = { timeout_seconds: idleTimeoutSec };
|
|
19
|
+
if (proxyCountry) body.proxy_config = { country_code: proxyCountry };
|
|
20
|
+
let s;
|
|
21
|
+
try {
|
|
22
|
+
s = await json(settings.apiBase, { method: 'POST', headers: headers(apiKey), body, timeoutMs: 90_000 });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (e.status === 402) throw new QuotaError(`wallet too low: ${e.text}`);
|
|
25
|
+
throw e;
|
|
26
|
+
}
|
|
27
|
+
return { id: s.session_id, cdpUrl: s.cdp_url, egress: proxyCountry ? `proxy ${proxyCountry}` : 'default egress' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function releaseSession(settings, apiKey, session) {
|
|
31
|
+
await json(`${settings.apiBase}/${session.id}`, { method: 'DELETE', headers: headers(apiKey) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The wallet's balance (https://docs.tinyfish.ai/wallet) */
|
|
35
|
+
export async function quota(settings, apiKey) {
|
|
36
|
+
const w = await json(settings.walletUrl, { headers: headers(apiKey) });
|
|
37
|
+
return { remaining: Number(w.available_balance), unit: w.currency };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Usage of the sessions among `sessions` that have ended, from the paged usage list of the account. The list
|
|
42
|
+
* starts a minute before the earliest was opened: the provider's start time precedes the ledger's.
|
|
43
|
+
*/
|
|
44
|
+
export async function sessionUsage(settings, apiKey, sessions) {
|
|
45
|
+
const wanted = new Set(sessions.map((s) => s.id));
|
|
46
|
+
const since = new Date(Math.min(...sessions.map((s) => Date.parse(s.openedAt))) - 60_000).toISOString();
|
|
47
|
+
const usage = [];
|
|
48
|
+
for (let page = 1; ; page++) {
|
|
49
|
+
const body = await json(`${settings.apiBase}/usage?start_after=${since}&status=ended&limit=1000&page=${page}`, { headers: headers(apiKey) });
|
|
50
|
+
for (const s of body.items) {
|
|
51
|
+
if (wanted.has(s.session_id)) usage.push({ id: s.session_id, durationMs: s.duration_ms, proxyBytes: (s.proxy_trg_rx_bytes ?? 0) + (s.proxy_trg_tx_bytes ?? 0) });
|
|
52
|
+
}
|
|
53
|
+
if (!body.has_more) return usage;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CDP command this often while connected keeps a provider's idle timeout from ending a session that is in use
|
|
3
|
+
* (also while a page waits long for an answer). OneBrowser requires idleTimeoutSec to be at least twice as long.
|
|
4
|
+
*/
|
|
5
|
+
export const KEEP_ALIVE_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
/** Playwright errors carry a multi-line call log; the first line says what failed */
|
|
8
|
+
const firstLine = (e) => String(e.message).split('\n')[0];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One remote browser session, as OneBrowser.openSession() opened it: created by its provider and recorded in the
|
|
12
|
+
* ledger. Use it over CDP: through cdpUrl with any CDP client, or through connect() / newPage() with Playwright.
|
|
13
|
+
* It ends at the latest maxDurationMs after it was opened: this process closes it then (a client using it sees
|
|
14
|
+
* its connection drop). close() releases it; one never closed (its process was killed) is ended by its provider
|
|
15
|
+
* idleTimeoutSec after its client left.
|
|
16
|
+
*
|
|
17
|
+
* id, provider, egress the provider's session id, its name, and the egress the session uses
|
|
18
|
+
* cdpUrl CDP WebSocket URL (it may carry the API key: never log it)
|
|
19
|
+
* openedAt, closedAt ms; closedAt is null while open
|
|
20
|
+
*/
|
|
21
|
+
export class Session {
|
|
22
|
+
#release;
|
|
23
|
+
#browser = null; // the connection in progress or made: a promise of Playwright's Browser
|
|
24
|
+
#keepAlive = null;
|
|
25
|
+
#deadline;
|
|
26
|
+
|
|
27
|
+
/** release(session): records the session closed and releases it at its provider (OneBrowser) */
|
|
28
|
+
constructor({ provider, remote, maxDurationMs, release }) {
|
|
29
|
+
this.provider = provider;
|
|
30
|
+
this.id = remote.id;
|
|
31
|
+
this.egress = remote.egress;
|
|
32
|
+
this.cdpUrl = remote.cdpUrl;
|
|
33
|
+
this.openedAt = Date.now();
|
|
34
|
+
this.closedAt = null;
|
|
35
|
+
this.#release = release;
|
|
36
|
+
this.#deadline = setTimeout(() => this.close().catch(() => {}), maxDurationMs); // a release that fails here leaves the session to the provider's idle timeout
|
|
37
|
+
this.#deadline.unref();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Playwright's Browser over this session's CDP connection, made once; the keep-alive runs while it is connected */
|
|
41
|
+
connect() {
|
|
42
|
+
if (this.closedAt) return Promise.reject(new Error(`${this.provider} session ${this.id} is closed`));
|
|
43
|
+
this.#browser ??= this.#connect();
|
|
44
|
+
return this.#browser;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async #connect() {
|
|
48
|
+
const { chromium } = await import('playwright-core');
|
|
49
|
+
let browser;
|
|
50
|
+
try {
|
|
51
|
+
browser = await chromium.connectOverCDP(this.cdpUrl, { timeout: 60_000 });
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new Error(`failed to connect to the ${this.provider} browser: ${firstLine(e)}`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const cdp = await browser.newBrowserCDPSession();
|
|
57
|
+
if (this.closedAt) throw new Error(`${this.provider} session ${this.id} is closed`); // closed while connecting
|
|
58
|
+
this.#keepAlive = setInterval(() => cdp.send('Browser.getVersion').catch(() => {}), KEEP_ALIVE_MS);
|
|
59
|
+
this.#keepAlive.unref();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
await browser.close().catch(() => {});
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
return browser;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A new page in the browser's default context. Always a new one: the page a remote browser starts with is not
|
|
69
|
+
* always one that can be navigated.
|
|
70
|
+
*/
|
|
71
|
+
async newPage() {
|
|
72
|
+
const browser = await this.connect();
|
|
73
|
+
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
74
|
+
return context.newPage();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Disconnects and releases the session at its provider; only the first call does. The ledger records it closed
|
|
79
|
+
* in any case. Throws when the provider could not release it: the provider then ends it by itself,
|
|
80
|
+
* idleTimeoutSec after the disconnect.
|
|
81
|
+
*/
|
|
82
|
+
async close() {
|
|
83
|
+
if (this.closedAt) return;
|
|
84
|
+
this.closedAt = Date.now();
|
|
85
|
+
clearTimeout(this.#deadline);
|
|
86
|
+
clearInterval(this.#keepAlive);
|
|
87
|
+
const browser = this.#browser;
|
|
88
|
+
this.#browser = null;
|
|
89
|
+
await browser?.then((b) => b.close()).catch(() => {});
|
|
90
|
+
await this.#release(this);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** close(): `await using session = await browsers.openSession(…)` releases it at the end of the block */
|
|
94
|
+
[Symbol.asyncDispose]() {
|
|
95
|
+
return this.close();
|
|
96
|
+
}
|
|
97
|
+
}
|