onebrowser 0.4.0 → 0.6.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/README.md +43 -33
- package/package.json +2 -2
- package/src/account.js +139 -0
- package/src/cli.js +29 -24
- package/src/cost.js +8 -6
- package/src/credentials.js +17 -12
- package/src/index.js +54 -186
- package/src/ledger.js +74 -63
- package/src/options.js +30 -0
- package/src/providers/anchor.js +1 -0
- package/src/providers/index.js +22 -25
- package/src/session.js +27 -38
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# onebrowser
|
|
2
2
|
|
|
3
|
-
One interface to remote browser providers. A
|
|
3
|
+
One interface to remote browser providers. A provider is an adapter; an account is one API key at a provider, and sessions are opened with an account. Every session is recorded in a ledger, which each account's usage and quota are read from.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install onebrowser
|
|
@@ -10,19 +10,21 @@ npm install onebrowser
|
|
|
10
10
|
import { OneBrowser, QuotaError } from 'onebrowser';
|
|
11
11
|
|
|
12
12
|
const browsers = new OneBrowser({ client: 'my-crawler' });
|
|
13
|
-
const
|
|
13
|
+
const [steel] = browsers.accounts('steel'); // one account per key in STEEL_API_KEY
|
|
14
|
+
const session = await steel.openSession({ proxyCountry: 'DE' });
|
|
14
15
|
const page = await session.newPage(); // Playwright; or any CDP client on session.cdpUrl
|
|
15
16
|
await page.goto('https://example.com');
|
|
16
17
|
await session.close();
|
|
17
18
|
|
|
18
|
-
await
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
browsers.
|
|
19
|
+
await steel.readQuota(); // { remaining, unbilled, unit: 'USD', renewsAt: null }
|
|
20
|
+
steel.quota(); // the same now, from that reading: sessions opened since count at once
|
|
21
|
+
steel.maxSessionCost(); // what a session with the default options costs at most, in USD
|
|
22
|
+
await browsers.syncUsage(); // stores the providers' records of ended sessions in the ledger
|
|
23
|
+
browsers.usage({ since: Date.parse('2026-11-01') }); // [{ day, provider, account, client, sessions, minutes, … }]
|
|
22
24
|
await browsers.close();
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
Choosing an account, capping sessions and stopping before a quota runs out are the program's policy. onebrowser opens the sessions it is asked for and gives the program what it needs to decide: the accounts it has keys for, what each quota has left, what a session costs at most, and `QuotaError` when a provider refuses a session for the account's quota.
|
|
26
28
|
|
|
27
29
|
Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbrowser](https://hyperbrowser.ai), [Lightpanda](https://lightpanda.io), [Kernel](https://www.kernel.sh), [Anchor Browser](https://anchorbrowser.io) and [Firecrawl](https://www.firecrawl.dev); a program can add its own (see [Adding a provider](#adding-a-provider)). Node 22.13 or later.
|
|
28
30
|
|
|
@@ -34,34 +36,40 @@ Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbro
|
|
|
34
36
|
|---|---|
|
|
35
37
|
| `client` | The program using it, recorded with each session; required |
|
|
36
38
|
| `ledgerPath` | SQLite file of the ledger (default below; `':memory:'` for tests). Opened when first needed |
|
|
37
|
-
| `providers` | Settings per provider name, on top of its adapter's `defaults`: `
|
|
39
|
+
| `providers` | Settings per provider name, on top of its adapter's `defaults`: `apiKeys` (see [Accounts](#accounts)), `price`, `billingDelayMs`, `minIdleTimeoutSec` (see [Quota](#quota)) and the adapter's own (API base URLs, plan facts such as `monthlyHours`) |
|
|
38
40
|
| `adapters` | Further adapters by provider name, or replacements of built-in ones |
|
|
39
41
|
|
|
40
42
|
| Method | |
|
|
41
43
|
|---|---|
|
|
42
|
-
| `providers` | Names of the providers it
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `maxSessionCost(provider, options)` | What a session with these session options costs at most, in the unit of the provider's quota: as if it lasted `maxDurationMs` and then `idleTimeoutSec`, at the provider's price |
|
|
47
|
-
| `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 |
|
|
48
|
-
| `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` |
|
|
44
|
+
| `providers` | Names of the providers it has adapters for |
|
|
45
|
+
| `accounts(provider?)` | The accounts with an API key, in the providers' order and then the keys': all of them, or the provider's (throws for an unknown provider) |
|
|
46
|
+
| `syncUsage()` | Stores the providers' records (duration, proxy traffic) of ended sessions: `{ reported, failed: [{ account, error }] }` |
|
|
47
|
+
| `usage({ since })` | Sessions opened since `since` (ms) per UTC day, account and client: `sessions`, `minutes` (the provider's record, else the open-to-close timing), `untimed`, `unsynced`, `proxyBytes` |
|
|
49
48
|
| `close()` | Closes the sessions still open and the ledger |
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
An account has `id` (`steel#3f9a1c`: the provider's name and the key's fingerprint), `provider`, `fingerprint`, and:
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
| Method | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `openSession({ proxyCountry, idleTimeoutSec, maxDurationMs })` | A new session, in the ledger from when the provider has created it. Throws `QuotaError` when the provider refuses it for the account's quota |
|
|
55
|
+
| `readQuota({ maxAgeMs })` | Reads the account's quota from the provider, unless a reading in progress or started at most `maxAgeMs` ago (default 0) can be used, a failed one too; then answers as `quota()` |
|
|
56
|
+
| `quota()` | What is left now, from the last reading: `{ remaining, unbilled, unit, renewsAt }` (see [Quota](#quota)). Throws until the account has been read, and while its last reading failed |
|
|
57
|
+
| `maxSessionCost(options)` | What a session with these options costs at most, in the unit of the quota |
|
|
58
|
+
|
|
59
|
+
A session has `id`, `account`, `egress`, `cdpUrl` (it may carry the API key: never log it), `openedAt` (when it was asked for), `closedAt` (when its provider released it), and:
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
- `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 in use.
|
|
62
|
+
- `newPage()`: a new page in the default context: the page a remote browser starts with cannot always be navigated.
|
|
63
|
+
- `close()`: disconnects and releases it; every call answers as the first. Throws when the provider could not release it: the provider ends it by itself, and the ledger counts it as lasting as long as it can.
|
|
64
|
+
|
|
65
|
+
A session and a `OneBrowser` are async-disposable: `await using session = await steel.openSession()` closes the session at the end of its block (Node 24 or later; on Node 22 call `close()`).
|
|
58
66
|
|
|
59
67
|
### Session options: one meaning on every provider
|
|
60
68
|
|
|
61
69
|
| Option | Default | Meaning |
|
|
62
70
|
|---|---|---|
|
|
63
71
|
| `proxyCountry` | `null` | A country code: the provider's proxy in that country. `null`: its default egress |
|
|
64
|
-
| `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) |
|
|
72
|
+
| `idleTimeoutSec` | 60 | A session whose client is gone (its process was killed) ends this long after at the latest, or after the provider's `minIdleTimeoutSec` if longer. At least 30 (twice the keep-alive interval) |
|
|
65
73
|
| `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 |
|
|
66
74
|
|
|
67
75
|
`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.
|
|
@@ -80,9 +88,9 @@ How each provider meets them:
|
|
|
80
88
|
|
|
81
89
|
### Quota
|
|
82
90
|
|
|
83
|
-
`quota()`
|
|
91
|
+
`quota()` is the last reading less `unbilled`: what the ledger's sessions of the account that the reading does not show cost at the provider's `price`. A reading shows the sessions that ended `billingDelayMs` before it; the others are open ones, counted as lasting as long as they can (`maxDurationMs`, then `idleTimeoutSec`, at least the provider's `minIdleTimeoutSec`), and those ended since, counted as long as they lasted. So each session counts once from when its provider has created it, whichever program on the machine opened it, and a program can check before each session that the account pays for it (`maxSessionCost`). A provider that bills a session while it runs has part of it counted twice, on the safe side. Not counted: sessions opened through another machine's ledger, until the provider bills them, and proxy traffic billed by the GB.
|
|
84
92
|
|
|
85
|
-
`price` is in the quota's unit: `perHour`, `perSession` (default 0),
|
|
93
|
+
`price` is in the quota's unit: `perHour`, `perSession` (default 0), the session time billed in whole `stepSec`, rounded up (default 1), and at least `minSec` (default 0). Where a provider does not document its rounding, the adapter takes the minute. `renewsAt` (ms) is when the quota renews by itself; `null` when only a top-up renews it. `price` and `billingDelayMs` are settings: a program on another plan sets its own.
|
|
86
94
|
|
|
87
95
|
| Provider | Quota, unit | Read from | `price` | `billingDelayMs` |
|
|
88
96
|
|---|---|---|---|---|
|
|
@@ -96,36 +104,38 @@ How each provider meets them:
|
|
|
96
104
|
|
|
97
105
|
Every provider read from its API showed a session's cost within about a minute of its end (2026-11); `billingDelayMs` leaves room for more.
|
|
98
106
|
|
|
99
|
-
###
|
|
107
|
+
### Accounts
|
|
108
|
+
|
|
109
|
+
An account is one API key at a provider: the quota, the plan's limits and the bill are the key's. The keys of a provider are `apiKeys` in its settings, else those in the environment variable `<PROVIDER>_API_KEY` (`STEEL_API_KEY`, …; `apiKeyEnv(provider)` names it), else those in the file named by `<PROVIDER>_API_KEY_FILE`, separated by commas, spaces or line breaks; each key makes one account, in that order, and a key given twice makes one. A provider without a key has no account.
|
|
100
110
|
|
|
101
|
-
`
|
|
111
|
+
An account is known by its `id`, the provider's name and the first 6 hex digits of the key's SHA-256 (`steel#3f9a1c`): the same for the same key in every program and run, without telling the key. The ledger records sessions under it, so an account's usage is whole however many programs open sessions with its key.
|
|
102
112
|
|
|
103
113
|
### The ledger
|
|
104
114
|
|
|
105
|
-
One SQLite file per machine by default: `$ONEBROWSER_DB`, else `onebrowser/sessions.db` in `$XDG_DATA_HOME` (default `~/.local/share`)
|
|
115
|
+
One SQLite file per machine by default: `$ONEBROWSER_DB`, else `onebrowser/sessions.db` in `$XDG_DATA_HOME` (default `~/.local/share`), shared by every program on the machine under its own `client`. A session is recorded when its provider has created it (it is billed from then on), with its account and when it ends at the latest; closed when its provider has released it; and completed with the provider's own record by `syncUsage()`. A ledger made by an earlier onebrowser is brought up to date when opened; one made by a later onebrowser is refused. Sessions recorded by onebrowser 0.5 or earlier have no account: they become the provider's only account's when it has one here, and stay without one while it has several.
|
|
106
116
|
|
|
107
117
|
## Command line
|
|
108
118
|
|
|
109
|
-
The
|
|
119
|
+
The 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.
|
|
110
120
|
|
|
111
121
|
```sh
|
|
112
|
-
npx onebrowser quota # what each
|
|
113
|
-
npx onebrowser quota steel kernel
|
|
114
|
-
npx onebrowser usage --days 7 # the ledger's sessions per UTC day,
|
|
122
|
+
npx onebrowser quota # what each account has left, once its sessions not billed yet are paid for
|
|
123
|
+
npx onebrowser quota steel kernel#a81f2c # only these: a provider's accounts, or one account by id
|
|
124
|
+
npx onebrowser usage --days 7 # the ledger's sessions per UTC day, account and client, today included
|
|
115
125
|
npx onebrowser usage --json --env-file .env
|
|
116
126
|
```
|
|
117
127
|
|
|
118
|
-
`usage` first fetches the providers' records of the sessions that have ended, as `syncUsage()` does. With `--json`, `quota` prints an array of what `
|
|
128
|
+
`usage` first fetches the providers' records of the sessions that have ended, as `syncUsage()` does. With `--json`, `quota` prints an array of what `readQuota()` returns, with the account: `{ account, provider, remaining, unbilled, unit, renewsAt }`, or `{ account, 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.
|
|
119
129
|
|
|
120
130
|
## Adding a provider
|
|
121
131
|
|
|
122
132
|
Write `src/providers/<name>.js` and register it in `ADAPTERS` (`src/providers/index.js`, where the contract is):
|
|
123
133
|
|
|
124
|
-
- `defaults`:
|
|
134
|
+
- `defaults`: `price`, `billingDelayMs`, `minIdleTimeoutSec` if the provider takes no idle timeout as short as asked (see [Quota](#quota)), and its own settings (API base URLs, plan facts).
|
|
125
135
|
- `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.
|
|
126
136
|
- `releaseSession(settings, apiKey, session)`.
|
|
127
137
|
- `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.
|
|
128
|
-
- `quota(settings, apiKey, history) → { remaining, unit, renewsAt? }`: `history.endedSessionMsSince(ms)` is the session time of the
|
|
138
|
+
- `quota(settings, apiKey, history) → { remaining, unit, renewsAt? }`: for a provider that cannot report its usage, `history.endedSessionMsSince(ms)` is the session time of the ledger's sessions that the reading shows.
|
|
129
139
|
|
|
130
140
|
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.
|
|
131
141
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onebrowser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One interface to remote browser providers: sessions by provider
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "One interface to remote browser providers: sessions by account (an API key at a provider), a usage ledger, account quotas",
|
|
5
5
|
"keywords": ["browser", "remote-browser", "cdp", "playwright", "steel", "hyperbrowser", "lightpanda", "kernel", "tinyfish", "anchorbrowser", "firecrawl"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Richard Wang",
|
package/src/account.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { sessionCost } from './cost.js';
|
|
2
|
+
import { fingerprint } from './credentials.js';
|
|
3
|
+
import { QuotaError } from './errors.js';
|
|
4
|
+
import { sessionMsAtMost, sessionOptions } from './options.js';
|
|
5
|
+
import { Session } from './session.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One API key at one provider: the account its sessions are billed to, with its own quota and its own record in
|
|
9
|
+
* the ledger. id, the provider's name and the key's fingerprint (`steel#3f9a1c`), names it in the ledger, in
|
|
10
|
+
* logs and on the command line; the key itself stays inside.
|
|
11
|
+
*
|
|
12
|
+
* ledger(): the shared Ledger (ledger.js), opened when first needed; client: the program, recorded with each
|
|
13
|
+
* session.
|
|
14
|
+
*/
|
|
15
|
+
export class Account {
|
|
16
|
+
#adapter;
|
|
17
|
+
#settings;
|
|
18
|
+
#apiKey;
|
|
19
|
+
#client;
|
|
20
|
+
#ledger;
|
|
21
|
+
#open = new Set();
|
|
22
|
+
#latest = null; // the latest quota reading, in progress or done
|
|
23
|
+
#done = null; // the latest reading done
|
|
24
|
+
|
|
25
|
+
constructor({ provider, adapter, settings, apiKey, client, ledger }) {
|
|
26
|
+
this.provider = provider;
|
|
27
|
+
this.fingerprint = fingerprint(apiKey);
|
|
28
|
+
this.id = `${provider}#${this.fingerprint}`;
|
|
29
|
+
this.#adapter = adapter;
|
|
30
|
+
this.#settings = settings;
|
|
31
|
+
this.#apiKey = apiKey;
|
|
32
|
+
this.#client = client;
|
|
33
|
+
this.#ledger = ledger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A new session (options: sessionOptions()), in the ledger from when the provider has created it. Throws when
|
|
38
|
+
* the provider creates none; QuotaError when it refuses for the account's quota.
|
|
39
|
+
*/
|
|
40
|
+
async openSession(options = {}) {
|
|
41
|
+
const o = sessionOptions(options);
|
|
42
|
+
const openedAt = Date.now();
|
|
43
|
+
let remote;
|
|
44
|
+
try {
|
|
45
|
+
remote = await this.#adapter.createSession(this.#settings, this.#apiKey, o);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (e instanceof QuotaError) throw e;
|
|
48
|
+
throw new Error(`failed to create a ${this.provider} session: ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
const session = new Session({ account: this, remote, openedAt, maxDurationMs: o.maxDurationMs, release: (s) => this.#release(s) });
|
|
51
|
+
try {
|
|
52
|
+
this.#ledger().opened({ provider: this.provider, account: this.id, id: session.id, client: this.#client, openedAt, endsBy: openedAt + sessionMsAtMost(o, this.#settings) });
|
|
53
|
+
} catch (e) {
|
|
54
|
+
await session.close().catch(() => {}); // a session the ledger does not know is not handed out
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
this.#open.add(session);
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Releases `session` at the provider and records it closed: when it was released, so its whole billed time counts */
|
|
62
|
+
async #release(session) {
|
|
63
|
+
try {
|
|
64
|
+
await this.#adapter.releaseSession(this.#settings, this.#apiKey, session);
|
|
65
|
+
const closedAt = Date.now();
|
|
66
|
+
this.#ledger().closed({ account: this.id, id: session.id, closedAt });
|
|
67
|
+
return closedAt;
|
|
68
|
+
} finally {
|
|
69
|
+
this.#open.delete(session);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads the quota from the provider, unless a reading in progress or started at most maxAgeMs ago can be used
|
|
75
|
+
* (a failed one throws again); then answers as quota().
|
|
76
|
+
*/
|
|
77
|
+
async readQuota({ maxAgeMs = 0 } = {}) {
|
|
78
|
+
const reading = this.#reading(maxAgeMs);
|
|
79
|
+
await reading.settled;
|
|
80
|
+
if (reading.error) throw reading.error;
|
|
81
|
+
return this.quota();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What is left of the quota now, in the provider's unit: { remaining, unbilled, unit, renewsAt }. The last
|
|
86
|
+
* reading shows the sessions that ended billingDelayMs before it; `unbilled` is what the ledger's others cost
|
|
87
|
+
* at the provider's price, open ones as if they lasted as long as they can, and remaining the reading less
|
|
88
|
+
* that. renewsAt (ms): when the quota renews by itself; null: only a top-up renews it. Throws until the
|
|
89
|
+
* provider has been read, and while the last reading failed.
|
|
90
|
+
*/
|
|
91
|
+
quota() {
|
|
92
|
+
const reading = this.#done;
|
|
93
|
+
if (!reading) throw new Error(`the quota of ${this.id} has not been read yet (readQuota)`);
|
|
94
|
+
if (reading.error) throw reading.error;
|
|
95
|
+
const unbilled = this.#ledger().openSince(this.id, reading.billedBefore).reduce((cost, s) => cost + sessionCost(this.#settings.price, s.durationMs), 0);
|
|
96
|
+
const { remaining, unit, renewsAt = null } = reading.value;
|
|
97
|
+
return { remaining: remaining - unbilled, unbilled, unit, renewsAt };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The latest reading if it is in progress or at most maxAgeMs old, else a new one */
|
|
101
|
+
#reading(maxAgeMs) {
|
|
102
|
+
const latest = this.#latest;
|
|
103
|
+
if (latest && (latest !== this.#done || Date.now() - latest.startedAt <= maxAgeMs)) return latest;
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
const reading = { startedAt, billedBefore: startedAt - this.#settings.billingDelayMs, value: null, error: null };
|
|
106
|
+
this.#latest = reading;
|
|
107
|
+
reading.settled = (async () => {
|
|
108
|
+
try {
|
|
109
|
+
const q = await this.#adapter.quota(this.#settings, this.#apiKey, this.#ledger().history(this.id, reading.billedBefore));
|
|
110
|
+
if (!(Number.isFinite(q?.remaining) && typeof q.unit === 'string')) throw new Error(`${this.provider} answered no quota: ${JSON.stringify(q)}`);
|
|
111
|
+
reading.value = q;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
reading.error = e;
|
|
114
|
+
}
|
|
115
|
+
this.#done = reading;
|
|
116
|
+
})();
|
|
117
|
+
return reading;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What a session with these options costs at most, in the unit of the quota */
|
|
121
|
+
maxSessionCost(options) {
|
|
122
|
+
return sessionCost(this.#settings.price, sessionMsAtMost(sessionOptions(options), this.#settings));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stores the provider's records (duration, proxy traffic) of this account's ended sessions that have none yet; how many */
|
|
126
|
+
async syncUsage() {
|
|
127
|
+
const ledger = this.#ledger();
|
|
128
|
+
const rows = ledger.awaitingUsage(this.id);
|
|
129
|
+
if (!rows.length) return 0;
|
|
130
|
+
const usage = await this.#adapter.sessionUsage(this.#settings, this.#apiKey, rows);
|
|
131
|
+
ledger.tx(() => usage.forEach((u) => ledger.recordUsage(this.id, u)));
|
|
132
|
+
return usage.length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Closes its sessions still open; their release errors are ignored: the provider ends those */
|
|
136
|
+
async close() {
|
|
137
|
+
await Promise.all([...this.#open].map((s) => s.close().catch(() => {})));
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -18,6 +18,18 @@ const GLOBAL_OPTIONS = {
|
|
|
18
18
|
help: { type: 'boolean', short: 'h' },
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/** The accounts `names` ask for: each name a provider's (it must have an API key) or one account's id */
|
|
22
|
+
function askedAccounts(browsers, names) {
|
|
23
|
+
return names.flatMap((name) => {
|
|
24
|
+
const own = browsers.accounts().filter((a) => a.id === name);
|
|
25
|
+
if (own.length) return own;
|
|
26
|
+
if (!browsers.providers.includes(name)) throw new CommandLineError(`unknown provider or account ${name} (expected ${browsers.providers.join(', ')}, or an account id)`);
|
|
27
|
+
const accounts = browsers.accounts(name);
|
|
28
|
+
if (!accounts.length) throw new Error(`${name} has no API key: set ${apiKeyEnv(name)} or ${apiKeyEnv(name)}_FILE`);
|
|
29
|
+
return accounts;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
/**
|
|
22
34
|
* The commands: their own options, whether they take arguments, and run(browsers, values, args, { out, err })
|
|
23
35
|
* → exit status
|
|
@@ -26,16 +38,16 @@ const COMMANDS = {
|
|
|
26
38
|
quota: {
|
|
27
39
|
positionals: true,
|
|
28
40
|
async run(browsers, { json }, names, { out }) {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
const accounts = names.length ? askedAccounts(browsers, names) : browsers.accounts();
|
|
42
|
+
if (!accounts.length) throw new Error(`no provider has an API key: set ${browsers.providers.map(apiKeyEnv).join(', ')}`);
|
|
43
|
+
const quotas = await Promise.all(
|
|
44
|
+
accounts.map((a) => a.readQuota().then((q) => ({ account: a.id, provider: a.provider, ...q }), (e) => ({ account: a.id, provider: a.provider, error: e.message }))),
|
|
45
|
+
);
|
|
34
46
|
if (json) out.write(`${JSON.stringify(quotas, null, 2)}\n`);
|
|
35
47
|
else {
|
|
36
48
|
for (const q of quotas) {
|
|
37
49
|
const text = q.error ? `unknown (${q.error})` : `${round(q.remaining)} ${q.unit} left${q.unbilled ? ` (after ${round(q.unbilled)} not billed yet)` : ''}${q.renewsAt ? `, renews ${day(q.renewsAt)}` : ''}`;
|
|
38
|
-
out.write(`${q.
|
|
50
|
+
out.write(`${q.account.padEnd(21)} ${text}\n`);
|
|
39
51
|
}
|
|
40
52
|
}
|
|
41
53
|
return quotas.some((q) => q.error) ? 1 : 0;
|
|
@@ -49,15 +61,15 @@ const COMMANDS = {
|
|
|
49
61
|
if (!(Number.isInteger(n) && n >= 1)) throw new CommandLineError(`--days must be a whole number ≥ 1, not ${days}`);
|
|
50
62
|
const since = new Date().setUTCHours(0, 0, 0, 0) - (n - 1) * DAY_MS;
|
|
51
63
|
const { failed } = await browsers.syncUsage();
|
|
52
|
-
for (const f of failed) err.write(`${f.
|
|
64
|
+
for (const f of failed) err.write(`${f.account}: the provider's records were not fetched (${f.error})\n`);
|
|
53
65
|
const sessions = browsers.usage({ since });
|
|
54
66
|
if (json) out.write(`${JSON.stringify({ ledger: browsers.ledgerPath, since: day(since), sessions, failed }, null, 2)}\n`);
|
|
55
67
|
else {
|
|
56
68
|
out.write(`sessions since ${day(since)} in ${browsers.ledgerPath}:\n`);
|
|
57
69
|
if (!sessions.length) out.write(' none\n');
|
|
58
|
-
for (const r of sessions) out.write(usageLine(`${r.day} ${r.
|
|
59
|
-
for (const [
|
|
60
|
-
if (rows.length > 1) out.write(usageLine(`total ${
|
|
70
|
+
for (const r of sessions) out.write(usageLine(`${r.day} ${r.account} ${r.client}`, r));
|
|
71
|
+
for (const [account, rows] of Map.groupBy(sessions, (r) => r.account)) {
|
|
72
|
+
if (rows.length > 1) out.write(usageLine(`total ${account}`, sumUsage(rows)));
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
return failed.length ? 1 : 0;
|
|
@@ -68,9 +80,10 @@ const COMMANDS = {
|
|
|
68
80
|
const help = (providers) => `onebrowser: the quotas of remote browser provider accounts, and the sessions in this machine's ledger
|
|
69
81
|
|
|
70
82
|
Usage:
|
|
71
|
-
onebrowser quota [
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
onebrowser quota [NAME...] what each account has left, less what the sessions in the ledger that it has
|
|
84
|
+
not billed yet cost; NAME: a provider (every account with its API key) or an
|
|
85
|
+
account id such as steel#3f9a1c; default: every account
|
|
86
|
+
onebrowser usage [--days N] sessions per UTC day, account and client in the last N UTC days, today
|
|
74
87
|
included (default 30), after fetching the providers' records of ended sessions
|
|
75
88
|
|
|
76
89
|
Options:
|
|
@@ -79,27 +92,19 @@ Options:
|
|
|
79
92
|
-h, --help
|
|
80
93
|
|
|
81
94
|
Environment:
|
|
82
|
-
${providers.map((p) => ` ${apiKeyEnv(p).padEnd(22)} API
|
|
95
|
+
${providers.map((p) => ` ${apiKeyEnv(p).padEnd(22)} API keys of ${p}, one per account, or ${apiKeyEnv(p)}_FILE naming a file that holds them`).join('\n')}
|
|
96
|
+
(keys are separated by commas, spaces or line breaks)
|
|
83
97
|
ONEBROWSER_DB the ledger file (default: onebrowser/sessions.db in $XDG_DATA_HOME, else ~/.local/share)
|
|
84
98
|
|
|
85
99
|
Exit status: 0 done, 1 a reading failed, 2 the command line is wrong.
|
|
86
100
|
`;
|
|
87
101
|
|
|
88
|
-
function isReady(browsers, provider) {
|
|
89
|
-
try {
|
|
90
|
-
browsers.checkReady(provider);
|
|
91
|
-
return true;
|
|
92
|
-
} catch {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
102
|
const USAGE_FIELDS = ['sessions', 'minutes', 'untimed', 'unsynced', 'proxyBytes'];
|
|
98
103
|
|
|
99
104
|
const sumUsage = (rows) => Object.fromEntries(USAGE_FIELDS.map((k) => [k, rows.reduce((t, r) => t + r[k], 0)]));
|
|
100
105
|
|
|
101
106
|
const usageLine = (label, r) =>
|
|
102
|
-
` ${label.padEnd(
|
|
107
|
+
` ${label.padEnd(40)} ${String(r.sessions).padStart(5)} sessions ${r.minutes.toFixed(1).padStart(8)} min` +
|
|
103
108
|
` ${(r.proxyBytes / 1e6).toFixed(1).padStart(7)} MB proxy` +
|
|
104
109
|
(r.untimed ? ` (${r.untimed} untimed)` : '') +
|
|
105
110
|
(r.unsynced ? ` (${r.unsynced} not reported by the provider yet)` : '') +
|
package/src/cost.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A provider's price (its
|
|
2
|
+
* A provider's price (its `price` setting), in the unit its quota is read in:
|
|
3
3
|
* perHour for the session time
|
|
4
4
|
* perSession for each session, however short (default 0)
|
|
5
5
|
* stepSec the session time is billed in whole steps of this, rounded up (default 1)
|
|
6
6
|
* minSec and at least this much of it (default 0)
|
|
7
|
-
*
|
|
8
|
-
* price is used to bound what sessions cost, so it errs on the high side.
|
|
7
|
+
* It bounds what sessions cost, so where a provider does not document its rounding the adapter takes the minute.
|
|
9
8
|
*/
|
|
10
|
-
|
|
9
|
+
|
|
10
|
+
/** The price with its defaults filled in; throws when it is not one */
|
|
11
|
+
export function fullPrice(price) {
|
|
11
12
|
const { perHour, perSession = 0, stepSec = 1, minSec = 0, ...unknown } = price ?? {};
|
|
12
13
|
const fine = [perHour, perSession, minSec].every((x) => Number.isFinite(x) && x >= 0) && Number.isFinite(stepSec) && stepSec > 0;
|
|
13
14
|
if (!fine || Object.keys(unknown).length) throw new Error(`price must be { perHour, perSession, stepSec, minSec } of numbers ≥ 0 (stepSec > 0), not ${JSON.stringify(price)}`);
|
|
15
|
+
return { perHour, perSession, stepSec, minSec };
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
/** What a session that lasts durationMs costs at `price` */
|
|
17
|
-
export function sessionCost({ perHour, perSession
|
|
18
|
+
/** What a session that lasts durationMs costs at `price` (fullPrice) */
|
|
19
|
+
export function sessionCost({ perHour, perSession, stepSec, minSec }, durationMs) {
|
|
18
20
|
const billedSec = Math.max(minSec, Math.ceil(durationMs / 1000 / stepSec) * stepSec);
|
|
19
21
|
return perSession + (perHour * billedSec) / 3600;
|
|
20
22
|
}
|
package/src/credentials.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
|
|
3
|
-
/** The environment variable holding a provider's API
|
|
4
|
+
/** The environment variable holding a provider's API keys: STEEL_API_KEY for `steel` */
|
|
4
5
|
export const apiKeyEnv = (provider) => `${provider.toUpperCase()}_API_KEY`;
|
|
5
6
|
|
|
7
|
+
/** What names the account an API key belongs to without telling the key: the first 6 hex digits of its SHA-256 */
|
|
8
|
+
export const fingerprint = (apiKey) => crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 6);
|
|
9
|
+
|
|
10
|
+
/** Keys written one after another, separated by commas, spaces or line breaks */
|
|
11
|
+
const split = (text) => text.split(/[\s,]+/).filter(Boolean);
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
|
-
* API
|
|
8
|
-
* else the file named by that variable with _FILE appended
|
|
9
|
-
*
|
|
14
|
+
* A provider's API keys, one per account: `apiKeys` as given in its settings, else those in the environment
|
|
15
|
+
* variable apiKeyEnv(provider), else those in the file named by that variable with _FILE appended, in their
|
|
16
|
+
* order and each once. Separators: commas, spaces or line breaks. None is fine; the file must exist.
|
|
10
17
|
*/
|
|
11
|
-
export function
|
|
12
|
-
if (apiKey?.trim()) return apiKey.trim();
|
|
18
|
+
export function readApiKeys(provider, apiKeys) {
|
|
13
19
|
const env = apiKeyEnv(provider);
|
|
14
20
|
const file = process.env[`${env}_FILE`];
|
|
15
|
-
let
|
|
16
|
-
if (!
|
|
17
|
-
if (!fs.existsSync(file)) throw new Error(`${provider}
|
|
18
|
-
|
|
21
|
+
let keys = apiKeys ?? split(process.env[env] ?? '');
|
|
22
|
+
if (!keys.length && file) {
|
|
23
|
+
if (!fs.existsSync(file)) throw new Error(`${provider} keys: ${env}_FILE names no file (${file})`);
|
|
24
|
+
keys = split(fs.readFileSync(file, 'utf8'));
|
|
19
25
|
}
|
|
20
|
-
|
|
21
|
-
return key.trim();
|
|
26
|
+
return [...new Set(keys.map((k) => k.trim()).filter(Boolean))];
|
|
22
27
|
}
|