onebrowser 0.7.0 → 0.8.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
@@ -16,7 +16,7 @@ const page = await session.newPage(); // Playwright; or any CDP client on sessio
16
16
  await page.goto('https://example.com');
17
17
  await session.close();
18
18
 
19
- await steel.readQuota(); // { remaining, unbilled, unit: 'USD', renewsAt: null }
19
+ await steel.readQuota(); // { remaining, unbilled, unit: 'USD', renewsAt: null, total: 30 }
20
20
  steel.quota(); // the same now, from that reading: sessions opened since count at once
21
21
  steel.maxSessionCost(); // what a session with the default options costs at most, in USD
22
22
  await browsers.syncUsage(); // stores the providers' records of ended sessions in the ledger
@@ -30,13 +30,15 @@ Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbro
30
30
 
31
31
  ## Interface
32
32
 
33
- `new OneBrowser({ client, ledgerPath, providers, adapters })`
33
+ `new OneBrowser({ client, env, envFile, ledgerPath, providers, adapters })`
34
34
 
35
35
  | Option | |
36
36
  |---|---|
37
37
  | `client` | The program using it, recorded with each session; required |
38
+ | `env` | The variables it reads the API keys (see [Accounts](#accounts)) and the ledger's place (see [The ledger](#the-ledger)) from; default: `process.env` |
39
+ | `envFile` | An env file whose variables it reads under those set in `env`, parsed as Node parses env files; a variable set but empty is unset. They stay out of the process's environment, so a program's child processes do not see the keys |
38
40
  | `ledgerPath` | SQLite file of the ledger (default below; `':memory:'` for tests). Opened when first needed |
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`) |
41
+ | `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` and `includedQuota`) |
40
42
  | `adapters` | Further adapters by provider name, or replacements of built-in ones |
41
43
 
42
44
  | Method | |
@@ -55,7 +57,7 @@ An account has `id` (`steel#3f9a1c`: the provider's name and the key's fingerpri
55
57
  |---|---|
56
58
  | `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 |
57
59
  | `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()` |
58
- | `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 |
60
+ | `quota()` | What is left now, from the last reading: `{ remaining, unbilled, unit, renewsAt, total }` (see [Quota](#quota)). Throws until the account has been read, and while its last reading failed |
59
61
  | `maxSessionCost(options)` | What a session with these options costs at most, in the unit of the quota |
60
62
 
61
63
  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:
@@ -92,29 +94,31 @@ How each provider meets them:
92
94
 
93
95
  `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.
94
96
 
95
- `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.
97
+ `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. `total` is what the quota holds when whole, for showing how much of it is used: the plan's as the provider reports it, else the adapter's plan facts (`monthlyHours`, or `includedQuota`: the free plan's, or the grant a new account starts with); `null` when neither says. A top-up can leave `remaining` above it. `price`, `billingDelayMs` and the plan facts are settings: a program on another plan sets its own.
96
98
 
97
- | Provider | Quota, unit | Read from | `price` | `billingDelayMs` |
98
- |---|---|---|---|---|
99
- | `steel` | balance, USD | `GET /v1/credits/balance`, the endpoint of Steel's dashboard (not in its API docs) | $0.10 an hour (Launch plan), by the minute | 5 min |
100
- | `tinyfish` | wallet, USD | [`GET /v1/wallet`](https://docs.tinyfish.ai/wallet); a session refused with 402 throws `QuotaError` | $0.002 a minute, by the minute | 5 min |
101
- | `hyperbrowser` | credits | `GET /api/team/credit-info`, the endpoint of Hyperbrowser's SDK (not in its API docs) | 100 an hour, by the minute | 5 min |
102
- | `lightpanda` | browser hours of the calendar month (`monthlyHours`, default: the free plan's) | the ledger's record of its sessions that have ended: 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 | 1 per browser hour, by the second | 0 |
103
- | `kernel` | browser hours of the calendar month (`monthlyHours`, default: what the free plan's credits pay for), counted from the first of the month, 00:00 UTC | the uptime of the account's sessions this month, from `GET /browsers` (Kernel has no API for what its credits leave); a session refused with 403 (spending cap) throws `QuotaError` | 1 per browser hour, by the second | 5 min |
104
- | `anchor` | credits | `GET /v1/billing`; a session refused with 402 throws `QuotaError` | 0.01 a session and 0.15 an hour, by the minute: above what its sessions' records bill (its docs: 0.05 an hour) | 5 min |
105
- | `firecrawl` | credits; the plan's credits renew at the end of each billing period | [`GET /v2/team/credit-usage`](https://docs.firecrawl.dev/api-reference/endpoint/credit-usage); a session refused with 402 throws `QuotaError` | 2 a minute, in 30 s steps, at least a minute | 5 min |
99
+ | Provider | Quota, unit | `total` | Read from | `price` | `billingDelayMs` |
100
+ |---|---|---|---|---|---|
101
+ | `steel` | balance, USD | `includedQuota`: 30, the Launch plan's one-time credits (valid 90 days) | `GET /v1/credits/balance`, the endpoint of Steel's dashboard (not in its API docs) | $0.10 an hour (Launch plan), by the minute | 5 min |
102
+ | `tinyfish` | wallet, USD | `includedQuota`: 8, what a new wallet starts with | [`GET /v1/wallet`](https://docs.tinyfish.ai/wallet); a session refused with 402 throws `QuotaError` | $0.002 a minute, by the minute | 5 min |
103
+ | `hyperbrowser` | credits | `includedQuota`: 5,000, a free account's ($5) | `GET /api/team/credit-info`, the endpoint of Hyperbrowser's SDK (not in its API docs) | 100 an hour, by the minute | 5 min |
104
+ | `lightpanda` | browser hours of the calendar month (`monthlyHours`, default: the free plan's) | `monthlyHours`: 10 | the ledger's record of its sessions that have ended: 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 | 1 per browser hour, by the second | 0 |
105
+ | `kernel` | browser hours of the calendar month (`monthlyHours`, default: what the free plan's credits pay for), counted from the first of the month, 00:00 UTC | `monthlyHours`: 80, what the free plan's $5 a month pays for | the uptime of the account's sessions this month, from `GET /browsers` (Kernel has no API for what its credits leave); a session refused with 403 (spending cap) throws `QuotaError` | 1 per browser hour, by the second | 5 min |
106
+ | `anchor` | credits | `includedQuota`: 5, the free plan's each month | `GET /v1/billing`; a session refused with 402 throws `QuotaError` | 0.01 a session and 0.15 an hour, by the minute: above what its sessions' records bill (its docs: 0.05 an hour) | 5 min |
107
+ | `firecrawl` | credits; the plan's credits renew at the end of each billing period | the plan's credits (`planCredits`), as read | [`GET /v2/team/credit-usage`](https://docs.firecrawl.dev/api-reference/endpoint/credit-usage); a session refused with 402 throws `QuotaError` | 2 a minute, in 30 s steps, at least a minute | 5 min |
106
108
 
107
109
  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.
108
110
 
109
111
  ### Accounts
110
112
 
111
- 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.
113
+ 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 its 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.
112
114
 
113
115
  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.
114
116
 
115
117
  ### The ledger
116
118
 
117
- 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()`. Nothing removes a session from the ledger, so its record is whole from the ledger's first session on. Each ledger has an id of its own (`ledgerId`), given when it is made (or first opened by onebrowser 0.7 or later) and kept for its life. 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.
119
+ One SQLite file per machine by default: the variable `ONEBROWSER_DB`, else `onebrowser/sessions.db` in `XDG_DATA_HOME` (ignored when relative, as the XDG spec has it; default `~/.local/share`), `~` standing for the home directory in either, 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()`. Nothing removes a session from the ledger, so its record is whole from the ledger's first session on. Each ledger has an id of its own (`ledgerId`), given when it is made (or first opened by onebrowser 0.7 or later) and kept for its life. 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.
120
+
121
+ `defaultLedgerPath({ env, envFile })` says where a `OneBrowser` with these options keeps its ledger unless given a path, and `LEDGER_PATH_VARIABLES` which variables place it. A program that only looks for the ledger imports them from `onebrowser/ledger-path`, which loads no SQLite.
118
122
 
119
123
  ## Command line
120
124
 
@@ -127,7 +131,7 @@ npx onebrowser usage --days 7 # the ledger's sessions per UTC day, accoun
127
131
  npx onebrowser usage --json --env-file .env
128
132
  ```
129
133
 
130
- `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, ledgerId, 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.
134
+ `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, total }`, or `{ account, provider, error }` for a failed reading; `usage` prints `{ ledger, ledgerId, since, sessions, failed }`, where `sessions` is what `usage()` returns and `failed` what `syncUsage()` could not fetch. API keys and the ledger are read as by the library, from the environment and, with `--env-file PATH`, from that file (its `envFile`). Exit status: 0 done, 1 a reading failed, 2 the command line is wrong.
131
135
 
132
136
  ## Adding a provider
133
137
 
@@ -137,7 +141,7 @@ Write `src/providers/<name>.js` and register it in `ADAPTERS` (`src/providers/in
137
141
  - `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.
138
142
  - `releaseSession(settings, apiKey, session)`.
139
143
  - `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.
140
- - `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.
144
+ - `quota(settings, apiKey, history) → { remaining, unit, renewsAt?, total? }`: for a provider that cannot report its usage, `history.endedSessionMsSince(ms)` is the session time of the ledger's sessions that the reading shows.
141
145
 
142
146
  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.
143
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onebrowser",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
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",
@@ -9,7 +9,10 @@
9
9
  "homepage": "https://github.com/i-richardwang/onebrowser#readme",
10
10
  "bugs": "https://github.com/i-richardwang/onebrowser/issues",
11
11
  "type": "module",
12
- "exports": "./src/index.js",
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./ledger-path": "./src/ledger-path.js"
15
+ },
13
16
  "bin": { "onebrowser": "bin/onebrowser.js" },
14
17
  "files": ["src", "bin"],
15
18
  "scripts": {
package/src/account.js CHANGED
@@ -82,19 +82,21 @@ export class Account {
82
82
  }
83
83
 
84
84
  /**
85
- * What is left of the quota now, in the provider's unit: { remaining, unbilled, unit, renewsAt }. The last
85
+ * What is left of the quota now, in the provider's unit: { remaining, unbilled, unit, renewsAt, total }. The last
86
86
  * reading shows the sessions that ended billingDelayMs before it; `unbilled` is what the ledger's others cost
87
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.
88
+ * that. renewsAt (ms): when the quota renews by itself; null: only a top-up renews it. total: what the quota
89
+ * holds when whole, the plan's or a free grant's, as the provider reports it or the adapter's plan facts give
90
+ * it, for showing how much of it is used; null when neither says. Throws until the provider has been read, and
91
+ * while the last reading failed.
90
92
  */
91
93
  quota() {
92
94
  const reading = this.#done;
93
95
  if (!reading) throw new Error(`the quota of ${this.id} has not been read yet (readQuota)`);
94
96
  if (reading.error) throw reading.error;
95
97
  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
+ const { remaining, unit, renewsAt, total } = reading.value;
99
+ return { remaining: remaining - unbilled, unbilled, unit, renewsAt, total };
98
100
  }
99
101
 
100
102
  /** The latest reading if it is in progress or at most maxAgeMs old, else a new one */
@@ -108,7 +110,7 @@ export class Account {
108
110
  try {
109
111
  const q = await this.#adapter.quota(this.#settings, this.#apiKey, this.#ledger().history(this.id, reading.billedBefore));
110
112
  if (!(Number.isFinite(q?.remaining) && typeof q.unit === 'string')) throw new Error(`${this.provider} answered no quota: ${JSON.stringify(q)}`);
111
- reading.value = q;
113
+ reading.value = { remaining: q.remaining, unit: q.unit, renewsAt: q.renewsAt ?? null, total: Number.isFinite(q.total) && q.total > 0 ? q.total : null };
112
114
  } catch (e) {
113
115
  reading.error = e;
114
116
  }
package/src/cli.js CHANGED
@@ -46,7 +46,7 @@ const COMMANDS = {
46
46
  if (json) out.write(`${JSON.stringify(quotas, null, 2)}\n`);
47
47
  else {
48
48
  for (const q of quotas) {
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)}` : ''}`;
49
+ const text = q.error ? `unknown (${q.error})` : `${round(q.remaining)}${q.total ? ` of ${round(q.total)}` : ''} ${q.unit} left${q.unbilled ? ` (after ${round(q.unbilled)} not billed yet)` : ''}${q.renewsAt ? `, renews ${day(q.renewsAt)}` : ''}`;
50
50
  out.write(`${q.account.padEnd(21)} ${text}\n`);
51
51
  }
52
52
  }
@@ -88,7 +88,7 @@ Usage:
88
88
 
89
89
  Options:
90
90
  --json JSON on stdout instead of text
91
- --env-file PATH read environment variables (API keys) from PATH first
91
+ --env-file PATH read the variables below from PATH too; those set in the environment win
92
92
  -h, --help
93
93
 
94
94
  Environment:
@@ -129,8 +129,7 @@ function parseCommandLine(argv) {
129
129
  export async function run(argv, { out = process.stdout, err = process.stderr, browsers } = {}) {
130
130
  try {
131
131
  const { command, values, args } = parseCommandLine(argv);
132
- if (values['env-file']) process.loadEnvFile(values['env-file']);
133
- browsers ??= new OneBrowser({ client: 'onebrowser' });
132
+ browsers ??= new OneBrowser({ client: 'onebrowser', envFile: values['env-file'] });
134
133
  if (values.help || !command) {
135
134
  (values.help ? out : err).write(help(browsers.providers));
136
135
  return values.help ? 0 : 2;
@@ -11,16 +11,16 @@ export const fingerprint = (apiKey) => crypto.createHash('sha256').update(apiKey
11
11
  const split = (text) => text.split(/[\s,]+/).filter(Boolean);
12
12
 
13
13
  /**
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
14
+ * A provider's API keys, one per account: `apiKeys` as given in its settings, else those in the variable
15
+ * apiKeyEnv(provider) of `variables`, else those in the file named by that variable with _FILE appended, in their
16
16
  * order and each once. Separators: commas, spaces or line breaks. None is fine; the file must exist.
17
17
  */
18
- export function readApiKeys(provider, apiKeys) {
19
- const env = apiKeyEnv(provider);
20
- const file = process.env[`${env}_FILE`];
21
- let keys = apiKeys ?? split(process.env[env] ?? '');
18
+ export function readApiKeys(provider, apiKeys, variables) {
19
+ const name = apiKeyEnv(provider);
20
+ const file = variables[`${name}_FILE`];
21
+ let keys = apiKeys ?? split(variables[name] ?? '');
22
22
  if (!keys.length && file) {
23
- if (!fs.existsSync(file)) throw new Error(`${provider} keys: ${env}_FILE names no file (${file})`);
23
+ if (!fs.existsSync(file)) throw new Error(`${provider} keys: ${name}_FILE names no file (${file})`);
24
24
  keys = split(fs.readFileSync(file, 'utf8'));
25
25
  }
26
26
  return [...new Set(keys.map((k) => k.trim()).filter(Boolean))];
package/src/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { Account } from './account.js';
2
2
  import { readApiKeys } from './credentials.js';
3
3
  import { QuotaError } from './errors.js';
4
- import { defaultLedgerPath, Ledger } from './ledger.js';
4
+ import { Ledger } from './ledger.js';
5
+ import { ledgerPathIn } from './ledger-path.js';
5
6
  import { ADAPTERS, checkAdapter, fullSettings } from './providers/index.js';
7
+ import { readVariables } from './variables.js';
6
8
 
7
9
  export { apiKeyEnv } from './credentials.js';
10
+ export { defaultLedgerPath, LEDGER_PATH_VARIABLES } from './ledger-path.js';
8
11
  export { sessionOptions } from './options.js';
9
12
  export { QuotaError };
10
13
 
@@ -15,19 +18,22 @@ export { QuotaError };
15
18
  * what its quota has left at any moment, are known. Which account to use, and when to stop using one, are the
16
19
  * caller's decisions.
17
20
  *
18
- * client: the program using it, recorded with each session; ledgerPath: the ledger's SQLite file, opened when
19
- * first needed; providers: settings per provider name on top of its adapter's defaults, apiKeys among them
20
- * (else credentials.js); adapters: further or replacement adapters by provider name.
21
+ * client: the program using it, recorded with each session; env, envFile: the variables it reads the API keys
22
+ * and the ledger's place from (variables.js), the process's environment by default, over those of an env file
23
+ * if one is named; ledgerPath: the ledger's SQLite file, opened when first needed; providers: settings per
24
+ * provider name on top of its adapter's defaults, apiKeys among them; adapters: further or replacement adapters
25
+ * by provider name.
21
26
  */
22
27
  export class OneBrowser {
23
28
  #providers = []; // names, in the adapters' order
24
29
  #accounts = [];
25
30
  #ledger = null;
26
31
 
27
- constructor({ client, ledgerPath = defaultLedgerPath(), providers = {}, adapters = {} }) {
32
+ constructor({ client, env = process.env, envFile, ledgerPath, providers = {}, adapters = {} }) {
28
33
  if (!client?.trim()) throw new Error('onebrowser: client must name the program using it');
34
+ const variables = readVariables(env, envFile);
29
35
  this.client = client;
30
- this.ledgerPath = ledgerPath;
36
+ this.ledgerPath = ledgerPath ?? ledgerPathIn(variables);
31
37
  const all = { ...ADAPTERS, ...adapters };
32
38
  const unknown = Object.keys(providers).filter((name) => !all[name]);
33
39
  if (unknown.length) throw new Error(`onebrowser: no adapter for provider ${unknown.join(', ')}`);
@@ -36,7 +42,7 @@ export class OneBrowser {
36
42
  const { apiKeys, ...given } = providers[provider] ?? {};
37
43
  const settings = fullSettings(provider, { ...adapter.defaults, ...given });
38
44
  this.#providers.push(provider);
39
- for (const apiKey of readApiKeys(provider, apiKeys)) {
45
+ for (const apiKey of readApiKeys(provider, apiKeys, variables)) {
40
46
  this.#accounts.push(new Account({ provider, adapter, settings, apiKey, client, ledger: () => this.#store }));
41
47
  }
42
48
  }
@@ -0,0 +1,27 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readVariables } from './variables.js';
4
+
5
+ // Where the ledger lives: the one part of onebrowser a program needs to find the ledger without opening it. The
6
+ // package exports it on its own (onebrowser/ledger-path), which loads no SQLite.
7
+
8
+ /** The variables that place the ledger, in the order they are read */
9
+ export const LEDGER_PATH_VARIABLES = ['ONEBROWSER_DB', 'XDG_DATA_HOME'];
10
+
11
+ /** A path as a variable holds it, `~` standing for the home directory: an env file's are not expanded by a shell */
12
+ const expandHome = (p) => (p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p);
13
+
14
+ /**
15
+ * Where the ledger lives unless a path is given, by onebrowser's variables (variables.js): ONEBROWSER_DB, else
16
+ * onebrowser/sessions.db in the user's data directory: XDG_DATA_HOME, which the XDG spec ignores when relative,
17
+ * else ~/.local/share. One ledger per machine is shared by every project on it, so that the record of an
18
+ * account's sessions is whole, whichever project opened them.
19
+ */
20
+ export function ledgerPathIn(variables) {
21
+ if (variables.ONEBROWSER_DB) return expandHome(variables.ONEBROWSER_DB);
22
+ const data = variables.XDG_DATA_HOME && expandHome(variables.XDG_DATA_HOME);
23
+ return path.join(data && path.isAbsolute(data) ? data : path.join(os.homedir(), '.local', 'share'), 'onebrowser', 'sessions.db');
24
+ }
25
+
26
+ /** Where a OneBrowser given `env` and `envFile` (as its options) keeps its ledger unless it is given a path */
27
+ export const defaultLedgerPath = ({ env = process.env, envFile } = {}) => ledgerPathIn(readVariables(env, envFile));
package/src/ledger.js CHANGED
@@ -1,20 +1,8 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
  import { DatabaseSync } from 'node:sqlite';
6
5
 
7
- /**
8
- * Where the ledger lives unless a path is given: $ONEBROWSER_DB, else onebrowser/sessions.db in the user's data
9
- * directory ($XDG_DATA_HOME, default ~/.local/share). One ledger per machine is shared by every project on it, so
10
- * that the record of an account's sessions is whole, whichever project opened them.
11
- */
12
- export function defaultLedgerPath() {
13
- if (process.env.ONEBROWSER_DB) return process.env.ONEBROWSER_DB;
14
- const data = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
15
- return path.join(data, 'onebrowser', 'sessions.db');
16
- }
17
-
18
6
  /** Every remote browser session, from when its provider has created it: it is billed from then on, even if connecting fails */
19
7
  const SCHEMA = `
20
8
  CREATE TABLE sessions (
@@ -12,6 +12,7 @@ import { json } from './http.js';
12
12
  */
13
13
  export const defaults = {
14
14
  apiBase: 'https://api.anchorbrowser.io/v1',
15
+ includedQuota: 5, // credits the free plan includes each month (docs.anchorbrowser.io/pricing, 2026-11)
15
16
  // credits ($1 each): $0.01 a browser and, by its docs, $0.05 an hour by the minute; the sessions' own records
16
17
  // (credits_used) bill more, about $0.06 an hour and a step of $0.003 in the second minute, which this covers
17
18
  price: { perSession: 0.01, perHour: 0.15, stepSec: 60 },
@@ -45,7 +46,7 @@ export async function releaseSession(settings, apiKey, session) {
45
46
  /** The account's credits left, gifted ones included. Unlike the other endpoints, /billing answers without a `data` wrapper */
46
47
  export async function quota(settings, apiKey) {
47
48
  const { credits } = await json(`${settings.apiBase}/billing`, { headers: headers(apiKey) });
48
- return { remaining: credits, unit: 'credits' };
49
+ return { remaining: credits, unit: 'credits', total: settings.includedQuota };
49
50
  }
50
51
 
51
52
  /** Usage of the sessions among `sessions` that have ended, fetched by id: duration in whole seconds */
@@ -44,10 +44,13 @@ export async function releaseSession(settings, apiKey, session) {
44
44
  await json(`${settings.apiBase}/interact/${session.id}`, { method: 'DELETE', headers: headers(apiKey) });
45
45
  }
46
46
 
47
- /** The team's credits left, coupon credits included; the plan's credits renew at the end of its billing period */
47
+ /**
48
+ * The team's credits left, coupon credits included; the plan's credits (planCredits, without coupon or
49
+ * pay-as-you-go ones) renew at the end of its billing period
50
+ */
48
51
  export async function quota(settings, apiKey) {
49
52
  const { data } = await json(`${settings.apiBase}/team/credit-usage`, { headers: headers(apiKey) });
50
- return { remaining: data.remainingCredits, unit: 'credits', renewsAt: data.billingPeriodEnd ? Date.parse(data.billingPeriodEnd) : null };
53
+ return { remaining: data.remainingCredits, unit: 'credits', renewsAt: data.billingPeriodEnd ? Date.parse(data.billingPeriodEnd) : null, total: data.planCredits };
51
54
  }
52
55
 
53
56
  /** Usage of the sessions among `sessions` that have ended, fetched by id: the session list has no durations (GET /interact/{id} is not in Firecrawl's API docs) */
@@ -9,6 +9,7 @@ import { json } from './http.js';
9
9
  */
10
10
  export const defaults = {
11
11
  apiBase: 'https://api.hyperbrowser.ai/api',
12
+ includedQuota: 5000, // credits a free account comes with, worth $5 (hyperbrowser.ai/pricing, 2026-11)
12
13
  price: { perHour: 100, stepSec: 60 }, // credits; its rounding is not documented
13
14
  billingDelayMs: 5 * 60_000, // billed while the session runs, seen within 10 s of its end
14
15
  };
@@ -30,7 +31,7 @@ export async function releaseSession(settings, apiKey, session) {
30
31
  /** The team's credits left (the endpoint of Hyperbrowser's SDK; it is not in Hyperbrowser's API docs) */
31
32
  export async function quota(settings, apiKey) {
32
33
  const { remaining } = await json(`${settings.apiBase}/team/credit-info`, { headers: headers(apiKey) });
33
- return { remaining, unit: 'credits' };
34
+ return { remaining, unit: 'credits', total: settings.includedQuota };
34
35
  }
35
36
 
36
37
  /** Usage of the sessions among `sessions` that have ended (have an endTime), fetched by id. Times are epoch ms, as numbers or strings */
@@ -13,14 +13,16 @@ import * as tinyfish from './tinyfish.js';
13
13
  * price what its sessions cost, in the unit of its quota (cost.js)
14
14
  * billingDelayMs how long after a session has ended its quota reading shows it, at the latest
15
15
  * minIdleTimeoutSec the shortest idle timeout it takes (default 0)
16
- * and its own API base URLs, plan facts such as monthlyHours
16
+ * and its own API base URLs, plan facts such as monthlyHours or includedQuota (what the plan, or
17
+ * the free grant, puts in the quota when the provider does not say)
17
18
  * createSession(settings, apiKey, { proxyCountry, idleTimeoutSec, maxDurationMs }) → { id, cdpUrl, egress }
18
19
  * throws QuotaError (errors.js) when the provider refuses the session for the account's quota
19
20
  * releaseSession(settings, apiKey, session)
20
21
  * sessionUsage(settings, apiKey, [{ id, openedAt, closedAt }]) → [{ id, durationMs, proxyBytes }]
21
22
  * the provider's record of those that have ended, or the ledger's timing where its API key cannot read one
22
- * quota(settings, apiKey, history) → { remaining, unit, renewsAt? }
23
- * what the account has left in the provider's unit, and when it renews by itself (ms); history: the
23
+ * quota(settings, apiKey, history) → { remaining, unit, renewsAt?, total? }
24
+ * what the account has left in the provider's unit, when it renews by itself (ms), and what it holds when
25
+ * whole, as the provider reports it or the plan facts give it; history: the
24
26
  * ledger's sessions that the reading shows (Ledger.history), for a provider that cannot count its usage
25
27
  * Every function throws when it fails. Requests go through http.js, which throws HttpError for a status other
26
28
  * than 2xx.
@@ -13,7 +13,7 @@ import { utcMonth } from './month.js';
13
13
  */
14
14
  export const defaults = {
15
15
  apiBase: 'https://api.onkernel.com',
16
- monthlyHours: 80, // headless browser hours a month that the free plan's credits pay for, rounded down
16
+ monthlyHours: 80, // headless browser hours a month that the free plan's $5 of credits pay for, rounded down
17
17
  price: { perHour: 1 }, // the quota is in headless browser hours, billed by the second
18
18
  billingDelayMs: 5 * 60_000, // seen within 10 s
19
19
  };
@@ -56,7 +56,7 @@ export async function quota(settings, apiKey) {
56
56
  usedMs += ofMonth.reduce((ms, s) => ms + (s.usage?.uptime_ms ?? 0), 0);
57
57
  if (!ofMonth.length || r.headers.get('x-has-more') !== 'true') break;
58
58
  }
59
- return { remaining: settings.monthlyHours - usedMs / 3_600_000, unit: 'hours', renewsAt: month.next };
59
+ return { remaining: settings.monthlyHours - usedMs / 3_600_000, unit: 'hours', renewsAt: month.next, total: settings.monthlyHours };
60
60
  }
61
61
 
62
62
  /** Usage of the sessions among `sessions` that Kernel has deleted and finished billing, fetched by id */
@@ -39,7 +39,7 @@ export async function releaseSession() {}
39
39
  export async function quota(settings, apiKey, history) {
40
40
  const month = utcMonth();
41
41
  const usedHours = history.endedSessionMsSince(month.start) / 3_600_000;
42
- return { remaining: settings.monthlyHours - usedHours, unit: 'hours', renewsAt: month.next };
42
+ return { remaining: settings.monthlyHours - usedHours, unit: 'hours', renewsAt: month.next, total: settings.monthlyHours };
43
43
  }
44
44
 
45
45
  /**
@@ -8,6 +8,7 @@ import { json } from './http.js';
8
8
  export const defaults = {
9
9
  apiBase: 'https://api.steel.dev/v1',
10
10
  connectBase: 'wss://connect.steel.dev',
11
+ includedQuota: 30, // USD: the Launch plan's one-time credits, valid for 90 days (steel.dev/pricing, 2026-11)
11
12
  price: { perHour: 0.1, stepSec: 60 }, // USD on the Launch plan, by the minute rounded up
12
13
  billingDelayMs: 5 * 60_000, // seen within 11 s
13
14
  };
@@ -32,7 +33,7 @@ export async function releaseSession(settings, apiKey, session) {
32
33
  /** The account's balance in USD, as Steel's dashboard reads it (the endpoint is not in Steel's API docs) */
33
34
  export async function quota(settings, apiKey) {
34
35
  const { balanceCents } = await json(`${settings.apiBase}/credits/balance`, { headers: headers(apiKey) });
35
- return { remaining: balanceCents / 100, unit: 'USD' };
36
+ return { remaining: balanceCents / 100, unit: 'USD', total: settings.includedQuota };
36
37
  }
37
38
 
38
39
  /** Usage of the sessions among `sessions` that have ended, fetched by id: the session list cannot be filtered by time */
@@ -10,6 +10,7 @@ import { json } from './http.js';
10
10
  export const defaults = {
11
11
  apiBase: 'https://api.browser.tinyfish.ai',
12
12
  walletUrl: 'https://agent.tinyfish.ai/v1/wallet',
13
+ includedQuota: 8, // USD: what a new account's wallet starts with (tinyfish.ai/pricing, 2026-11)
13
14
  price: { perHour: 0.12, stepSec: 60 }, // USD: $0.002 a minute, proxy included; its rounding is not documented
14
15
  billingDelayMs: 5 * 60_000, // seen within 64 s
15
16
  };
@@ -36,7 +37,7 @@ export async function releaseSession(settings, apiKey, session) {
36
37
  /** The wallet's balance (https://docs.tinyfish.ai/wallet) */
37
38
  export async function quota(settings, apiKey) {
38
39
  const w = await json(settings.walletUrl, { headers: headers(apiKey) });
39
- return { remaining: Number(w.available_balance), unit: w.currency };
40
+ return { remaining: Number(w.available_balance), unit: w.currency, total: settings.includedQuota };
40
41
  }
41
42
 
42
43
  /**
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import { parseEnv } from 'node:util';
3
+
4
+ /**
5
+ * The variables onebrowser reads its configuration from, the API keys (credentials.js) and the ledger's place
6
+ * (ledger-path.js): those set in `env`, over those of the env file at `envFile` if one is named, parsed as Node
7
+ * parses env files. A variable set but empty is unset, as onebrowser reads every variable, so it hides none of the
8
+ * file's.
9
+ */
10
+ export function readVariables(env, envFile) {
11
+ if (envFile === undefined) return env;
12
+ let text;
13
+ try {
14
+ text = fs.readFileSync(envFile, 'utf8');
15
+ } catch (e) {
16
+ throw new Error(`cannot read the env file ${envFile}: ${e.message}`);
17
+ }
18
+ return { ...parseEnv(text), ...Object.fromEntries(Object.entries(env).filter(([, value]) => value)) };
19
+ }