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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # onebrowser
2
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.
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 session = await browsers.openSession({ provider: 'steel', proxyCountry: 'DE' });
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 browsers.quota('steel'); // { remaining, unbilled, unit: 'USD', renewsAt: null }
19
- browsers.maxSessionCost('steel', {}); // what a session with these options costs at most, in USD
20
- await browsers.syncUsage(); // fetches the providers' records of ended sessions into the ledger
21
- browsers.usage({ since: Date.parse('2026-11-01') }); // [{ day, provider, client, sessions, minutes, … }]
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
- **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: what is left of the quota once the sessions not billed yet are paid for, what a session costs at most, and `QuotaError` when a provider refuses a session for the account's quota.
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`: `apiKey`, `price` and `billingDelayMs` (see [Quota](#quota)), and the adapter's own settings (API base URLs, plan facts such as `monthlyHours`) |
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 can open sessions with |
43
- | `checkReady(provider)` | Throws when the provider is unknown or has no API key |
44
- | `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 |
45
- | `quota(provider, { maxAgeMs })` | `{ remaining, unbilled, unit, renewsAt }` in the provider's own unit: what the provider reads less `unbilled`, what the sessions it may not show yet cost (see [Quota](#quota)); `renewsAt` (ms) when it renews by itself, `null` when only a top-up renews it. The provider is read anew unless a reading started at most `maxAgeMs` ago (default 0) or one in progress can be used, a failed one too; `unbilled` is worked out anew every time. Throws when the provider cannot be read |
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
- A session (`openSession`) has `id`, `provider`, `egress`, `cdpUrl` (it may carry the API key: never log it), `openedAt` (when it was asked for), `closedAt`, and:
50
+ An account has `id` (`steel#3f9a1c`: the provider's name and the key's fingerprint), `provider`, `fingerprint`, and:
52
51
 
53
- - `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.
54
- - `newPage()`: always a new page in the default context: the page a remote browser starts with is not always one that can be navigated.
55
- - `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.
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
- 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()`).
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()` answers what is left of the account's quota once every session not billed yet is paid for. The provider's reading shows the sessions that ended up to `billingDelayMs` before it; the ledger has the others, still open or ended since, and each costs the provider's `price`: an open session as if it lasted as long as it can (`maxDurationMs`, then `idleTimeoutSec`, should its process die), an ended one as long as it lasted. So a program can check, before each session, that the account can pay for one more (`maxSessionCost`), also between readings and with other programs on the machine opening sessions. Sessions opened elsewhere, through another machine's ledger, are not known until the provider bills them, and proxy traffic billed by the GB is not in the price.
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), and the session time billed in whole `stepSec`, rounded up (default 1), at least `minSec` (default 0). It bounds what sessions cost, so where a provider does not document its rounding the adapter takes the minute, rounded up. Both are settings: a program on another plan sets its own.
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
- ### API keys
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
- `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.
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`). 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), with when it ends at the latest, closed when released, and completed with the provider's own record by `syncUsage()` once it has ended. A ledger made by an older onebrowser is brought up to date when it is opened.
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 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.
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 provider account with an API key has left, once its sessions not billed yet are paid for
113
- npx onebrowser quota steel kernel # only these; one without an API key is a failed reading
114
- npx onebrowser usage --days 7 # the ledger's sessions per UTC day, provider and client, today included
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 `quota()` returns, with the provider: `{ provider, remaining, unbilled, 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.
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`: its settings: `price` and `billingDelayMs` (see [Quota](#quota)), and its own (API base URLs, plan facts).
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 provider's sessions in the ledger that have ended, for a provider that cannot report its own usage.
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.0",
4
- "description": "One interface to remote browser providers: sessions by provider name, a usage ledger, account quotas",
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 unknown = names.filter((n) => !browsers.providers.includes(n));
30
- if (unknown.length) throw new CommandLineError(`unknown provider ${unknown.join(', ')} (expected ${browsers.providers.join(', ')})`);
31
- const asked = names.length ? names : browsers.providers.filter((p) => isReady(browsers, p));
32
- if (!asked.length) throw new Error(`no provider has an API key: set ${browsers.providers.map(apiKeyEnv).join(', ')}`);
33
- const quotas = await Promise.all(asked.map((provider) => browsers.quota(provider).then((q) => ({ provider, ...q }), (e) => ({ provider, error: e.message }))));
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.provider.padEnd(14)} ${text}\n`);
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.provider}: the provider's records were not fetched (${f.error})\n`);
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.provider} ${r.client}`, r));
59
- for (const [provider, rows] of Map.groupBy(sessions, (r) => r.provider)) {
60
- if (rows.length > 1) out.write(usageLine(`total ${provider}`, sumUsage(rows)));
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 [PROVIDER...] what each provider account has left, less what the sessions in the ledger
72
- that it has not billed yet cost; default: every provider with an API key
73
- onebrowser usage [--days N] sessions per UTC day, provider and client in the last N UTC days, today
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 key of ${p}, or ${apiKeyEnv(p)}_FILE naming a file that holds it`).join('\n')}
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(36)} ${String(r.sessions).padStart(5)} sessions ${r.minutes.toFixed(1).padStart(8)} min` +
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 adapter's `price` setting, providers/index.js), in the unit its quota is read in:
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
- * Where a provider's documentation does not say how it rounds, its adapter takes the minute, rounded up: the
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
- export function checkPrice(price) {
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 = 0, stepSec = 1, minSec = 0 }, durationMs) {
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
  }
@@ -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 key: STEEL_API_KEY for `steel` */
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 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.
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 readApiKey(provider, apiKey) {
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 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');
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
- if (!key?.trim()) throw new Error(`missing ${provider} key: set ${env} or ${env}_FILE`);
21
- return key.trim();
26
+ return [...new Set(keys.map((k) => k.trim()).filter(Boolean))];
22
27
  }