onebrowser 0.2.0 → 0.4.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
@@ -15,15 +15,16 @@ const page = await session.newPage(); // Playwright; or any CDP client on sessio
15
15
  await page.goto('https://example.com');
16
16
  await session.close();
17
17
 
18
- await browsers.quota('steel'); // { remaining, unit: 'USD', renewsAt: null }
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
19
20
  await browsers.syncUsage(); // fetches the providers' records of ended sessions into the ledger
20
21
  browsers.usage({ since: Date.parse('2026-11-01') }); // [{ day, provider, client, sessions, minutes, … }]
21
22
  await browsers.close();
22
23
  ```
23
24
 
24
- **What it does not do**: choose a provider, cap the sessions held at once, spread sessions over providers, or stop using a provider whose quota runs low. Those are policy, and belong to the program using it. onebrowser opens the session it is asked for, and tells the program what it needs to decide: the quota, and `QuotaError` when a provider refuses a session for the account's quota.
25
+ **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.
25
26
 
26
- Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbrowser](https://hyperbrowser.ai), [Lightpanda](https://lightpanda.io), [Kernel](https://www.kernel.sh) and [Anchor Browser](https://anchorbrowser.io); a program can add its own (see [Adding a provider](#adding-a-provider)). Node 22.13 or later.
27
+ 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.
27
28
 
28
29
  ## Interface
29
30
 
@@ -33,7 +34,7 @@ Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbro
33
34
  |---|---|
34
35
  | `client` | The program using it, recorded with each session; required |
35
36
  | `ledgerPath` | SQLite file of the ledger (default below; `':memory:'` for tests). Opened when first needed |
36
- | `providers` | Settings per provider name, on top of its adapter's `defaults`: `apiKey`, and the adapter's own settings (API base URLs, plan facts such as `monthlyHours`) |
37
+ | `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`) |
37
38
  | `adapters` | Further adapters by provider name, or replacements of built-in ones |
38
39
 
39
40
  | Method | |
@@ -41,12 +42,13 @@ Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbro
41
42
  | `providers` | Names of the providers it can open sessions with |
42
43
  | `checkReady(provider)` | Throws when the provider is unknown or has no API key |
43
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 |
44
- | `quota(provider)` | `{ remaining, unit, renewsAt }` in the provider's own unit; `renewsAt` (ms) when it renews by itself, `null` when only a top-up renews it. Throws when it cannot be read |
45
+ | `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 |
45
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 |
46
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` |
47
49
  | `close()` | Closes the sessions still open and the ledger |
48
50
 
49
- A session (`openSession`) has `id`, `provider`, `egress`, `cdpUrl` (it may carry the API key: never log it), `openedAt`, `closedAt`, and:
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
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.
52
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.
@@ -74,17 +76,25 @@ How each provider meets them:
74
76
  | `lightpanda` | its datacenter pool in that country | at once: a session is its CDP connection | Lightpanda's own limit is 15 min |
75
77
  | `kernel` | not supported (throws) | `timeout_seconds` after the CDP client left; until then a disconnected session stays in standby, counting toward the plan's concurrent browsers | this process only |
76
78
  | `anchor` | residential proxy | `idle_timeout` after the last CDP connection left, in whole minutes rounded down (at least 1) | `max_duration`, in whole minutes |
79
+ | `firecrawl` | its `location` in that country (default: the US) | `activityTtl` (no CDP commands), in whole seconds rounded down, at most 1 h | `ttl`, in whole seconds, at least 30 s and at most 1 h |
77
80
 
78
81
  ### Quota
79
82
 
80
- | Provider | Quota, unit | Read from |
81
- |---|---|---|
82
- | `steel` | balance, USD | `GET /v1/credits/balance`, the endpoint of Steel's dashboard (not in its API docs) |
83
- | `tinyfish` | wallet, USD | [`GET /v1/wallet`](https://docs.tinyfish.ai/wallet); a session refused with 402 throws `QuotaError` |
84
- | `hyperbrowser` | credits | `GET /api/team/credit-info`, the endpoint of Hyperbrowser's SDK (not in its API docs) |
85
- | `lightpanda` | browser hours of the calendar month (`monthlyHours`, default: the free plan's) | the ledger's record of its sessions: its console shows the month's usage, but its token cannot read it. So the reading is whole while the account is used only through ledgers of this machine |
86
- | `kernel` | browser hours of the calendar month (`monthlyHours`, default: what the free plan's credits pay for) | the uptime of the account's sessions this month, from `GET /browsers`; a session refused with 403 (spending cap) throws `QuotaError` |
87
- | `anchor` | credits | `GET /v1/billing`; a session refused with 402 throws `QuotaError` |
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.
84
+
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.
86
+
87
+ | Provider | Quota, unit | Read from | `price` | `billingDelayMs` |
88
+ |---|---|---|---|---|
89
+ | `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 |
90
+ | `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 |
91
+ | `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 |
92
+ | `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 |
93
+ | `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 |
94
+ | `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 |
95
+ | `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 |
96
+
97
+ 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.
88
98
 
89
99
  ### API keys
90
100
 
@@ -92,30 +102,30 @@ How each provider meets them:
92
102
 
93
103
  ### The ledger
94
104
 
95
- One SQLite file per machine by default: `$ONEBROWSER_DB`, else `onebrowser/sessions.db` in `$XDG_DATA_HOME` (default `~/.local/share`). Every program that uses onebrowser on the machine shares it, each under its own `client`, so the record of a provider account's sessions is whole whichever program opened them; several processes may write to it at once. Each session is recorded when its provider has created it (it is billed from then on, also when connecting fails afterwards), closed when released, and completed with the provider's own record by `syncUsage()` once it has ended.
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.
96
106
 
97
107
  ## Command line
98
108
 
99
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.
100
110
 
101
111
  ```sh
102
- npx onebrowser quota # what each provider account with an API key has left
112
+ npx onebrowser quota # what each provider account with an API key has left, once its sessions not billed yet are paid for
103
113
  npx onebrowser quota steel kernel # only these; one without an API key is a failed reading
104
114
  npx onebrowser usage --days 7 # the ledger's sessions per UTC day, provider and client, today included
105
115
  npx onebrowser usage --json --env-file .env
106
116
  ```
107
117
 
108
- `usage` first fetches the providers' records of the sessions that have ended, as `syncUsage()` does. With `--json`, `quota` prints an array of what `quota()` returns, with the provider: `{ provider, remaining, unit, renewsAt }`, or `{ provider, error }` for a failed reading; `usage` prints `{ ledger, since, sessions, failed }`, where `sessions` is what `usage()` returns and `failed` what `syncUsage()` could not fetch. API keys are read as by the library, from the environment (`--env-file PATH` loads a file into it first). Exit status: 0 done, 1 a reading failed, 2 the command line is wrong.
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.
109
119
 
110
120
  ## Adding a provider
111
121
 
112
122
  Write `src/providers/<name>.js` and register it in `ADAPTERS` (`src/providers/index.js`, where the contract is):
113
123
 
114
- - `defaults`: its settings (API base URLs, plan facts).
124
+ - `defaults`: its settings: `price` and `billingDelayMs` (see [Quota](#quota)), and its own (API base URLs, plan facts).
115
125
  - `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.
116
126
  - `releaseSession(settings, apiKey, session)`.
117
127
  - `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.
118
- - `quota(settings, apiKey, history) → { remaining, unit, renewsAt? }`: `history.sessionMsSince(ms)` is the ledger's record of the provider's sessions, for a provider that cannot report its own usage.
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.
119
129
 
120
130
  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.
121
131
 
@@ -125,7 +135,7 @@ A program can also pass an adapter of its own in `adapters`, without changing on
125
135
 
126
136
  `npm test` runs offline: fake adapters and fetch, no network, no browser.
127
137
 
128
- A release is a tag: bump `version` in `package.json` and add its entry to [CHANGELOG.md](CHANGELOG.md), then push the tag `vX.Y.Z`. The release workflow checks the tag against the version, runs the tests and publishes to npm with trusted publishing (no token stored).
138
+ A release is a tag: bump `version` in `package.json`, carry it into `package-lock.json` with `npm install --package-lock-only`, add its entry to [CHANGELOG.md](CHANGELOG.md), then push the tag `vX.Y.Z`. The release workflow checks the tag against the version, runs the tests and publishes to npm with trusted publishing (no token stored).
129
139
 
130
140
  ## License
131
141
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "onebrowser",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "One interface to remote browser providers: sessions by provider name, a usage ledger, account quotas",
5
- "keywords": ["browser", "remote-browser", "cdp", "playwright", "steel", "hyperbrowser", "lightpanda", "kernel", "tinyfish", "anchorbrowser"],
5
+ "keywords": ["browser", "remote-browser", "cdp", "playwright", "steel", "hyperbrowser", "lightpanda", "kernel", "tinyfish", "anchorbrowser", "firecrawl"],
6
6
  "license": "MIT",
7
7
  "author": "Richard Wang",
8
8
  "repository": { "type": "git", "url": "git+https://github.com/i-richardwang/onebrowser.git" },
package/src/cli.js CHANGED
@@ -6,6 +6,8 @@ const DAY_MS = 864e5;
6
6
 
7
7
  const day = (ms) => new Date(ms).toISOString().slice(0, 10);
8
8
 
9
+ const round = (x) => Number(x.toFixed(2));
10
+
9
11
  /** The command line is wrong: exit status 2 */
10
12
  class CommandLineError extends Error {}
11
13
 
@@ -32,7 +34,7 @@ const COMMANDS = {
32
34
  if (json) out.write(`${JSON.stringify(quotas, null, 2)}\n`);
33
35
  else {
34
36
  for (const q of quotas) {
35
- const text = q.error ? `unknown (${q.error})` : `${Number(q.remaining.toFixed(2))} ${q.unit} left${q.renewsAt ? `, renews ${day(q.renewsAt)}` : ''}`;
37
+ 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)}` : ''}`;
36
38
  out.write(`${q.provider.padEnd(14)} ${text}\n`);
37
39
  }
38
40
  }
@@ -66,7 +68,8 @@ const COMMANDS = {
66
68
  const help = (providers) => `onebrowser: the quotas of remote browser provider accounts, and the sessions in this machine's ledger
67
69
 
68
70
  Usage:
69
- onebrowser quota [PROVIDER...] what each provider account has left; default: every provider with an API key
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
70
73
  onebrowser usage [--days N] sessions per UTC day, provider and client in the last N UTC days, today
71
74
  included (default 30), after fetching the providers' records of ended sessions
72
75
 
package/src/cost.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A provider's price (its adapter's `price` setting, providers/index.js), in the unit its quota is read in:
3
+ * perHour for the session time
4
+ * perSession for each session, however short (default 0)
5
+ * stepSec the session time is billed in whole steps of this, rounded up (default 1)
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.
9
+ */
10
+ export function checkPrice(price) {
11
+ const { perHour, perSession = 0, stepSec = 1, minSec = 0, ...unknown } = price ?? {};
12
+ const fine = [perHour, perSession, minSec].every((x) => Number.isFinite(x) && x >= 0) && Number.isFinite(stepSec) && stepSec > 0;
13
+ 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)}`);
14
+ }
15
+
16
+ /** What a session that lasts durationMs costs at `price` */
17
+ export function sessionCost({ perHour, perSession = 0, stepSec = 1, minSec = 0 }, durationMs) {
18
+ const billedSec = Math.max(minSec, Math.ceil(durationMs / 1000 / stepSec) * stepSec);
19
+ return perSession + (perHour * billedSec) / 3600;
20
+ }
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { sessionCost } from './cost.js';
1
2
  import { readApiKey } from './credentials.js';
2
3
  import { QuotaError } from './errors.js';
3
4
  import { defaultLedgerPath, Ledger } from './ledger.js';
4
- import { ADAPTERS, checkAdapter } from './providers/index.js';
5
+ import { ADAPTERS, checkAdapter, checkSettings } from './providers/index.js';
5
6
  import { KEEP_ALIVE_MS, Session } from './session.js';
6
7
 
7
8
  export { apiKeyEnv } from './credentials.js';
@@ -33,11 +34,22 @@ export function sessionOptions(options = {}) {
33
34
  return o;
34
35
  }
35
36
 
37
+ /**
38
+ * How long a session with these options (sessionOptions) lasts at most, whatever becomes of its process: this
39
+ * process ends it at maxDurationMs, and its provider idleTimeoutSec after its client left, should the process die
40
+ * or its release fail before
41
+ */
42
+ const sessionMsAtMost = (o) => o.maxDurationMs + o.idleTimeoutSec * 1000;
43
+
36
44
  /**
37
45
  * Remote browsers from several providers behind one interface: a session is asked for by provider name, the way
38
46
  * an LLM request names its model. Every session is recorded in a ledger (ledger.js), which the usage of each
39
47
  * provider account and, for providers that cannot report it themselves, its quota are read from.
40
48
  *
49
+ * What is left of an account's quota is what its provider reads, less what the sessions it has not billed yet
50
+ * cost (quota()): the ledger knows those, and each provider's price (cost.js) what they cost. So a program can
51
+ * tell, before it opens a session, whether the account can pay for it.
52
+ *
41
53
  * Which provider to use, how many sessions to hold and when to stop using one are the caller's decisions:
42
54
  * OneBrowser opens the sessions it is asked for.
43
55
  *
@@ -53,6 +65,7 @@ export class OneBrowser {
53
65
  #providers = new Map(); // name → { adapter, settings: its defaults with the given ones on top, apiKey: the one given }
54
66
  #ledger = null;
55
67
  #open = new Set();
68
+ #readings = new Map(); // provider → its last quota reading (#reading)
56
69
 
57
70
  constructor({ client, ledgerPath = defaultLedgerPath(), providers = {}, adapters = {} }) {
58
71
  if (!client?.trim()) throw new Error('onebrowser: client must name the program using it');
@@ -60,8 +73,10 @@ export class OneBrowser {
60
73
  this.ledgerPath = ledgerPath;
61
74
  for (const [name, adapter] of Object.entries({ ...ADAPTERS, ...adapters })) {
62
75
  checkAdapter(name, adapter);
63
- const { apiKey, ...settings } = providers[name] ?? {};
64
- this.#providers.set(name, { adapter, settings: { ...adapter.defaults, ...settings }, apiKey });
76
+ const { apiKey, ...given } = providers[name] ?? {};
77
+ const settings = { ...adapter.defaults, ...given };
78
+ checkSettings(name, settings);
79
+ this.#providers.set(name, { adapter, settings, apiKey });
65
80
  }
66
81
  const unknown = Object.keys(providers).filter((name) => !this.#providers.has(name));
67
82
  if (unknown.length) throw new Error(`onebrowser: no adapter for provider ${unknown.join(', ')}`);
@@ -76,10 +91,16 @@ export class OneBrowser {
76
91
  return (this.#ledger ??= new Ledger(this.ledgerPath));
77
92
  }
78
93
 
79
- /** The adapter, settings and API key of `provider`; throws when it is unknown or has no API key */
80
- #ready(provider) {
94
+ /** The adapter, settings and the API key given of `provider`; throws when it is unknown */
95
+ #known(provider) {
81
96
  const p = this.#providers.get(provider);
82
97
  if (!p) throw new Error(`unknown provider ${provider} (expected ${this.providers.join(', ')})`);
98
+ return p;
99
+ }
100
+
101
+ /** The adapter, settings and API key of `provider`; throws when it is unknown or has no API key */
102
+ #ready(provider) {
103
+ const p = this.#known(provider);
83
104
  return { ...p, apiKey: readApiKey(provider, p.apiKey) };
84
105
  }
85
106
 
@@ -96,6 +117,7 @@ export class OneBrowser {
96
117
  async openSession({ provider, ...options }) {
97
118
  const { adapter, settings, apiKey } = this.#ready(provider);
98
119
  const o = sessionOptions(options);
120
+ const openedAt = Date.now(); // the provider bills the session from some time after this
99
121
  let remote;
100
122
  try {
101
123
  remote = await adapter.createSession(settings, apiKey, o);
@@ -104,7 +126,7 @@ export class OneBrowser {
104
126
  throw new Error(`failed to create a ${provider} session: ${e.message}`);
105
127
  }
106
128
  const session = new Session({
107
- provider, remote, maxDurationMs: o.maxDurationMs,
129
+ provider, remote, openedAt, maxDurationMs: o.maxDurationMs,
108
130
  release: async (s) => {
109
131
  this.#open.delete(s);
110
132
  this.#store.closed(s);
@@ -112,7 +134,7 @@ export class OneBrowser {
112
134
  },
113
135
  });
114
136
  try {
115
- this.#store.opened({ provider, id: session.id, client: this.client, openedAt: session.openedAt });
137
+ this.#store.opened({ provider, id: session.id, client: this.client, openedAt, endsBy: openedAt + sessionMsAtMost(o) });
116
138
  } catch (e) {
117
139
  await session.close().catch(() => {}); // a session the ledger has no record of is not handed out
118
140
  throw e;
@@ -122,14 +144,53 @@ export class OneBrowser {
122
144
  }
123
145
 
124
146
  /**
125
- * What is left of the account's quota with `provider`: { remaining, unit, renewsAt } in the provider's own
126
- * unit; renewsAt (ms) when it renews by itself, null when only a top-up renews it. Throws when it cannot be read.
147
+ * What is left of the account's quota with `provider`, in the provider's own unit: { remaining, unbilled, unit,
148
+ * renewsAt }. remaining: what the provider reads, less `unbilled`: what the sessions in the ledger that the
149
+ * reading may not show yet cost, each at the provider's price (cost.js). Those are the sessions that had not
150
+ * ended billingDelayMs before the reading: open ones as if they lasted as long as they can, ended ones as long
151
+ * as they lasted. renewsAt (ms) when the quota renews by itself, null when only a top-up renews it.
152
+ *
153
+ * The provider is read anew unless a reading started at most maxAgeMs ago, or still in progress, can be used
154
+ * (a failed one too: it throws again); what is unbilled is worked out from the ledger every time, so it includes
155
+ * the sessions opened since, by any program sharing the ledger. Throws when the provider cannot be read.
156
+ */
157
+ async quota(provider, { maxAgeMs = 0 } = {}) {
158
+ const { settings } = this.#known(provider);
159
+ const reading = this.#reading(provider, maxAgeMs);
160
+ const answer = await reading.answer;
161
+ const sessions = this.#store.openAfter(provider, reading.startedAt - settings.billingDelayMs);
162
+ const unbilled = sessions.reduce((cost, s) => cost + sessionCost(settings.price, s.durationMs), 0);
163
+ return { remaining: answer.remaining - unbilled, unbilled, unit: answer.unit, renewsAt: answer.renewsAt ?? null };
164
+ }
165
+
166
+ /**
167
+ * The provider's quota reading, { startedAt, answer: a promise of the adapter's answer }: the last one if it
168
+ * started at most maxAgeMs ago or is still in progress, else a new one. A failed reading counts as one too: its
169
+ * error is the answer until maxAgeMs have passed.
127
170
  */
128
- async quota(provider) {
171
+ #reading(provider, maxAgeMs) {
172
+ const last = this.#readings.get(provider);
173
+ if (last && (!last.done || Date.now() - last.startedAt <= maxAgeMs)) return last;
129
174
  const { adapter, settings, apiKey } = this.#ready(provider);
130
- const q = await adapter.quota(settings, apiKey, this.#store.history(provider));
131
- if (!(Number.isFinite(q?.remaining) && typeof q.unit === 'string')) throw new Error(`${provider} answered no quota: ${JSON.stringify(q)}`);
132
- return { remaining: q.remaining, unit: q.unit, renewsAt: q.renewsAt ?? null };
175
+ const reading = { startedAt: Date.now(), done: false };
176
+ reading.answer = adapter
177
+ .quota(settings, apiKey, this.#store.history(provider))
178
+ .then((q) => {
179
+ if (!(Number.isFinite(q?.remaining) && typeof q.unit === 'string')) throw new Error(`${provider} answered no quota: ${JSON.stringify(q)}`);
180
+ return q;
181
+ })
182
+ .finally(() => (reading.done = true));
183
+ reading.answer.catch(() => {}); // awaited by the callers; not an unhandled rejection while cached
184
+ this.#readings.set(provider, reading);
185
+ return reading;
186
+ }
187
+
188
+ /**
189
+ * What a session with `provider` and these session options (sessionOptions) costs at most, in the unit of its
190
+ * quota: as if it lasted as long as it can, at the provider's price
191
+ */
192
+ maxSessionCost(provider, options) {
193
+ return sessionCost(this.#known(provider).settings.price, sessionMsAtMost(sessionOptions(options)));
133
194
  }
134
195
 
135
196
  /**
package/src/ledger.js CHANGED
@@ -15,27 +15,59 @@ export function defaultLedgerPath() {
15
15
  }
16
16
 
17
17
  /**
18
- * sessions every remote browser session: recorded when its provider has created it (it is billed from then on,
19
- * even if connecting fails), closed when released, and completed with the provider's own record
20
- * (duration, proxy traffic) once the session has ended and syncUsage has fetched it
18
+ * sessions every remote browser session: recorded when it is asked for, once its provider has created it (it
19
+ * is billed from then on, even if connecting fails), closed when released, and completed with the
20
+ * provider's own record (duration, proxy traffic) once the session has ended and syncUsage has
21
+ * fetched it
21
22
  */
22
- const SCHEMA = `
23
- CREATE TABLE IF NOT EXISTS sessions (
23
+ const SESSIONS = `
24
+ CREATE TABLE sessions (
24
25
  provider TEXT NOT NULL,
25
26
  id TEXT NOT NULL, -- the session id its provider gave it
26
27
  client TEXT NOT NULL, -- the program that opened it (OneBrowser's client)
27
- opened_at TEXT NOT NULL, -- when the provider created it
28
+ opened_at TEXT NOT NULL, -- when it was asked for; its provider created it right after
29
+ ends_by TEXT NOT NULL, -- when it has ended at the latest, whatever becomes of its process
28
30
  closed_at TEXT, -- when it was released; empty if its process ended first
29
31
  duration_ms INTEGER, -- the provider's record, once the session has ended
30
32
  proxy_bytes INTEGER, -- proxy traffic in the provider's record
31
33
  synced_at TEXT, -- when the provider's record was stored; empty = not yet
32
34
  PRIMARY KEY (provider, id)
33
35
  );
34
- CREATE INDEX IF NOT EXISTS idx_sessions_opened ON sessions(provider, opened_at);
36
+ CREATE INDEX idx_sessions_opened ON sessions(provider, opened_at);
35
37
  `;
36
38
 
37
- /** Duration of a session: the provider's record, else the open-to-close timing of the process that opened it */
38
- const SESSION_MS = 'COALESCE(duration_ms, CAST((julianday(closed_at) - julianday(opened_at)) * 86400000 AS INTEGER))';
39
+ /**
40
+ * The schema's versions, each the step from the one before (PRAGMA user_version counts the steps taken). A ledger
41
+ * is shared by every program on the machine and outlives the onebrowser version that created it, so an older
42
+ * ledger is brought up to date when it is opened.
43
+ */
44
+ const MIGRATIONS = [
45
+ // 1: the sessions, as onebrowser 0.1–0.3 kept them
46
+ `CREATE TABLE IF NOT EXISTS sessions (
47
+ provider TEXT NOT NULL, id TEXT NOT NULL, client TEXT NOT NULL, opened_at TEXT NOT NULL, closed_at TEXT,
48
+ duration_ms INTEGER, proxy_bytes INTEGER, synced_at TEXT, PRIMARY KEY (provider, id)
49
+ );`,
50
+ // 2: ends_by; the sessions of 1 were all opened with the session options' defaults: 15 min, then 60 s idle
51
+ `ALTER TABLE sessions RENAME TO sessions_1;
52
+ DROP INDEX IF EXISTS idx_sessions_opened;
53
+ ${SESSIONS}
54
+ INSERT INTO sessions (provider, id, client, opened_at, ends_by, closed_at, duration_ms, proxy_bytes, synced_at)
55
+ SELECT provider, id, client, opened_at, strftime('%Y-%m-%dT%H:%M:%fZ', opened_at, '+16 minutes'), closed_at, duration_ms, proxy_bytes, synced_at
56
+ FROM sessions_1;
57
+ DROP TABLE sessions_1;`,
58
+ ];
59
+
60
+ /** ms from one ISO time to another */
61
+ const msBetween = (from, to) => `CAST(ROUND((julianday(${to}) - julianday(${from})) * 86400000) AS INTEGER)`;
62
+
63
+ /** How long a session lasted: the provider's record, else the open-to-close timing of the process that opened it */
64
+ const SESSION_MS = `COALESCE(duration_ms, ${msBetween('opened_at', 'closed_at')})`;
65
+
66
+ /** How long a session lasts at most: as SESSION_MS, or until it ends at the latest while it has no timing */
67
+ const SESSION_MS_AT_MOST = `COALESCE(${SESSION_MS}, ${msBetween('opened_at', 'ends_by')})`;
68
+
69
+ /** When a session has ended or ends at the latest */
70
+ const ENDED_AT = 'COALESCE(closed_at, ends_by)';
39
71
 
40
72
  const iso = (ms) => new Date(ms).toISOString();
41
73
  const int = (x) => (x === null || x === undefined || !Number.isFinite(Number(x)) ? null : Math.trunc(Number(x)));
@@ -48,7 +80,22 @@ export class Ledger {
48
80
  if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });
49
81
  this.#db = new DatabaseSync(file);
50
82
  this.#db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;'); // several processes share it
51
- this.#db.exec(SCHEMA);
83
+ this.#migrate();
84
+ }
85
+
86
+ /** Takes the steps of MIGRATIONS the ledger has not taken, in one transaction: another process may be at it too */
87
+ #migrate() {
88
+ const version = () => this.#db.prepare('PRAGMA user_version').get().user_version;
89
+ if (version() === MIGRATIONS.length) return;
90
+ this.#db.exec('BEGIN IMMEDIATE');
91
+ try {
92
+ for (let v = version(); v < MIGRATIONS.length; v++) this.#db.exec(MIGRATIONS[v]);
93
+ this.#db.exec(`PRAGMA user_version = ${MIGRATIONS.length}`);
94
+ this.#db.exec('COMMIT');
95
+ } catch (e) {
96
+ this.#db.exec('ROLLBACK');
97
+ throw e;
98
+ }
52
99
  }
53
100
 
54
101
  #q(sql) {
@@ -57,8 +104,8 @@ export class Ledger {
57
104
  return s;
58
105
  }
59
106
 
60
- opened({ provider, id, client, openedAt }) {
61
- this.#q('INSERT INTO sessions (provider, id, client, opened_at) VALUES (?,?,?,?)').run(provider, id, client, iso(openedAt));
107
+ opened({ provider, id, client, openedAt, endsBy }) {
108
+ this.#q('INSERT INTO sessions (provider, id, client, opened_at, ends_by) VALUES (?,?,?,?,?)').run(provider, id, client, iso(openedAt), iso(endsBy));
62
109
  }
63
110
 
64
111
  closed({ provider, id, closedAt }) {
@@ -68,11 +115,20 @@ export class Ledger {
68
115
  /** The record of one provider's sessions, as a provider adapter reads it for its quota (providers/index.js) */
69
116
  history(provider) {
70
117
  return {
71
- /** Session time (ms) of the sessions opened since `since` (ms) that have a duration */
72
- sessionMsSince: (since) => this.#q(`SELECT COALESCE(SUM(${SESSION_MS}), 0) ms FROM sessions WHERE provider=? AND opened_at >= ?`).get(provider, iso(since)).ms,
118
+ /** Session time (ms) of the sessions opened since `since` (ms) that have ended, each at most as long as it can have lasted */
119
+ endedSessionMsSince: (since) =>
120
+ this.#q(`SELECT COALESCE(SUM(${SESSION_MS_AT_MOST}), 0) ms FROM sessions WHERE provider=? AND opened_at >= ? AND ${ENDED_AT} < ?`).get(provider, iso(since), iso(Date.now())).ms,
73
121
  };
74
122
  }
75
123
 
124
+ /**
125
+ * The sessions of `provider` still open at `at` (ms): those open now, and those that have ended since.
126
+ * [{ durationMs }]: each as long as it lasted, or can last at most while it has no timing
127
+ */
128
+ openAfter(provider, at) {
129
+ return this.#q(`SELECT ${SESSION_MS_AT_MOST} durationMs FROM sessions WHERE provider=? AND ${ENDED_AT} >= ?`).all(provider, iso(at));
130
+ }
131
+
76
132
  /** Sessions with one of `providers` whose provider's record has not been stored yet: [{ provider, id, openedAt, closedAt }] (ISO times) */
77
133
  awaitingUsage(providers) {
78
134
  return this.#q(
@@ -12,6 +12,10 @@ import { json } from './http.js';
12
12
  */
13
13
  export const defaults = {
14
14
  apiBase: 'https://api.anchorbrowser.io/v1',
15
+ // credits ($1 each): $0.01 a browser and, by its docs, $0.05 an hour by the minute; the sessions' own records
16
+ // (credits_used) bill more, about $0.06 an hour and a step of $0.003 in the second minute, which this covers
17
+ price: { perSession: 0.01, perHour: 0.15, stepSec: 60 },
18
+ billingDelayMs: 5 * 60_000, // seen within 10 s
15
19
  };
16
20
 
17
21
  const headers = (apiKey) => ({ 'anchor-api-key': apiKey });
@@ -0,0 +1,61 @@
1
+ import { QuotaError } from '../errors.js';
2
+ import { json } from './http.js';
3
+
4
+ /**
5
+ * Firecrawl's Browser Sandbox (https://docs.firecrawl.dev/features/browser). A session browses from a US ISP IP
6
+ * by default, a different one per session; proxyCountry selects Firecrawl's location in that country (location).
7
+ * Closing the CDP connection does not end a session: Firecrawl ends it idleTimeoutSec after its last CDP command
8
+ * (activityTtl), and maxDurationMs after it was created in any case (ttl), both in whole seconds and at most 1 h.
9
+ * activityTtl is rounded down and ttl cut to 1 h, so that neither ends a session later than asked; ttl is rounded
10
+ * up, to at least 30 s, as this process closes the session at maxDurationMs. Its live view stream and its ad
11
+ * blocking, both on by default, are turned off. Sessions are paid from the account's credits; with too few,
12
+ * creating one answers 402.
13
+ */
14
+ export const defaults = {
15
+ apiBase: 'https://api.firecrawl.dev/v2',
16
+ price: { perHour: 120, stepSec: 30, minSec: 60 }, // credits: 2 a minute, in whole credits rounded up, at least 1 minute
17
+ billingDelayMs: 5 * 60_000, // seen within 10 s
18
+ };
19
+
20
+ const TTL_SEC = { min: 30, max: 3600 }; // what Firecrawl takes for ttl; activityTtl has the same maximum
21
+
22
+ const headers = (apiKey) => ({ Authorization: `Bearer ${apiKey}` });
23
+
24
+ export async function createSession(settings, apiKey, { proxyCountry, idleTimeoutSec, maxDurationMs }) {
25
+ const body = {
26
+ ttl: Math.min(TTL_SEC.max, Math.max(TTL_SEC.min, Math.ceil(maxDurationMs / 1000))),
27
+ activityTtl: Math.min(TTL_SEC.max, Math.floor(idleTimeoutSec)),
28
+ streamWebView: false,
29
+ blockAds: false,
30
+ };
31
+ if (proxyCountry) body.location = { country: proxyCountry };
32
+ let s;
33
+ try {
34
+ s = await json(`${settings.apiBase}/interact`, { method: 'POST', headers: headers(apiKey), body, timeoutMs: 90_000 });
35
+ } catch (e) {
36
+ if (e.status === 402) throw new QuotaError(`credits used up: ${e.text}`);
37
+ throw e;
38
+ }
39
+ return { id: s.id, cdpUrl: s.cdpUrl, egress: `proxy ${proxyCountry ?? 'US'}` };
40
+ }
41
+
42
+ /** Succeeds also for a session that has already ended */
43
+ export async function releaseSession(settings, apiKey, session) {
44
+ await json(`${settings.apiBase}/interact/${session.id}`, { method: 'DELETE', headers: headers(apiKey) });
45
+ }
46
+
47
+ /** The team's credits left, coupon credits included; the plan's credits renew at the end of its billing period */
48
+ export async function quota(settings, apiKey) {
49
+ 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 };
51
+ }
52
+
53
+ /** 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) */
54
+ export async function sessionUsage(settings, apiKey, sessions) {
55
+ const usage = [];
56
+ for (const { id } of sessions) {
57
+ const s = await json(`${settings.apiBase}/interact/${id}`, { headers: headers(apiKey) });
58
+ if (s.status !== 'running') usage.push({ id, durationMs: s.sessionDurationMs, proxyBytes: null });
59
+ }
60
+ return usage;
61
+ }
@@ -9,6 +9,8 @@ import { json } from './http.js';
9
9
  */
10
10
  export const defaults = {
11
11
  apiBase: 'https://api.hyperbrowser.ai/api',
12
+ price: { perHour: 100, stepSec: 60 }, // credits; its rounding is not documented
13
+ billingDelayMs: 5 * 60_000, // billed while the session runs, seen within 10 s of its end
12
14
  };
13
15
 
14
16
  const headers = (apiKey) => ({ 'x-api-key': apiKey });
@@ -1,4 +1,6 @@
1
+ import { checkPrice } from '../cost.js';
1
2
  import * as anchor from './anchor.js';
3
+ import * as firecrawl from './firecrawl.js';
2
4
  import * as hyperbrowser from './hyperbrowser.js';
3
5
  import * as kernel from './kernel.js';
4
6
  import * as lightpanda from './lightpanda.js';
@@ -7,9 +9,11 @@ import * as tinyfish from './tinyfish.js';
7
9
 
8
10
  /**
9
11
  * Provider adapters, by provider name: the only provider-specific code. Each exports:
10
- * defaults its settings (API base URLs, plan facts such as monthlyHours); OneBrowser's
11
- * `providers` option overrides them per provider. The API key is not among them:
12
- * each function gets it as its own argument
12
+ * defaults its settings; OneBrowser's `providers` option overrides them per provider (the API
13
+ * key is not among them: each function gets it as its own argument):
14
+ * price what its sessions cost, in the unit of its quota (cost.js)
15
+ * billingDelayMs how long after a session has ended its quota reading shows its cost, at the latest
16
+ * and its own API base URLs, plan facts such as monthlyHours
13
17
  * createSession(settings, apiKey, options) → { id, cdpUrl, egress }
14
18
  * options: { proxyCountry, idleTimeoutSec, maxDurationMs } (OneBrowser.openSession), mapped onto the
15
19
  * provider's own parameters. Throws on failure; QuotaError (errors.js) when the provider refuses the
@@ -27,7 +31,7 @@ import * as tinyfish from './tinyfish.js';
27
31
  * Their requests go through http.js, which turns every status other than 2xx into an HttpError: an adapter
28
32
  * handles only the statuses that mean something of their own.
29
33
  */
30
- export const ADAPTERS = { tinyfish, steel, hyperbrowser, lightpanda, kernel, anchor };
34
+ export const ADAPTERS = { tinyfish, steel, hyperbrowser, lightpanda, kernel, anchor, firecrawl };
31
35
 
32
36
  const CONTRACT = ['createSession', 'releaseSession', 'sessionUsage', 'quota'];
33
37
 
@@ -36,3 +40,13 @@ export function checkAdapter(name, adapter) {
36
40
  const missing = CONTRACT.filter((f) => typeof adapter?.[f] !== 'function');
37
41
  if (missing.length) throw new Error(`provider ${name}: its adapter lacks ${missing.join(', ')}`);
38
42
  }
43
+
44
+ /** Throws unless `settings` (an adapter's defaults with a program's settings on top) have a price and a billing delay */
45
+ export function checkSettings(name, settings) {
46
+ try {
47
+ checkPrice(settings.price);
48
+ } catch (e) {
49
+ throw new Error(`provider ${name}: ${e.message}`);
50
+ }
51
+ if (!(Number.isFinite(settings.billingDelayMs) && settings.billingDelayMs >= 0)) throw new Error(`provider ${name}: billingDelayMs must be a number ≥ 0, not ${settings.billingDelayMs}`);
52
+ }
@@ -14,6 +14,8 @@ import { utcMonth } from './month.js';
14
14
  export const defaults = {
15
15
  apiBase: 'https://api.onkernel.com',
16
16
  monthlyHours: 80, // headless browser hours a month that the free plan's credits pay for, rounded down
17
+ price: { perHour: 1 }, // the quota is in headless browser hours, billed by the second
18
+ billingDelayMs: 5 * 60_000, // seen within 10 s
17
19
  };
18
20
 
19
21
  const headers = (apiKey) => ({ Authorization: `Bearer ${apiKey}` });
@@ -42,8 +44,8 @@ export async function releaseSession(settings, apiKey, session) {
42
44
  /**
43
45
  * Browser hours left of the month's monthlyHours, after the uptime of every session of the account this month:
44
46
  * Kernel's own records, which include sessions opened by any program. The sessions are listed newest first, so
45
- * the listing stops at a page that holds none of this month. The hours are assumed to renew with each calendar
46
- * month (UTC).
47
+ * the listing stops at a page that holds none of this month. Kernel counts usage from the first of each month,
48
+ * 00:00 UTC (https://www.kernel.sh/docs/info/spending-caps), and has no API that reads what its credits leave.
47
49
  */
48
50
  export async function quota(settings, apiKey) {
49
51
  const month = utcMonth();
@@ -13,6 +13,8 @@ const MAX_CONNECTION_MS = 15 * 60_000;
13
13
  export const defaults = {
14
14
  connectBase: 'wss://euwest.cloud.lightpanda.io/ws',
15
15
  monthlyHours: 10, // browser hours a month on the free plan
16
+ price: { perHour: 1 }, // the quota is in browser hours, billed by the second
17
+ billingDelayMs: 0, // the quota is the ledger's record
16
18
  };
17
19
 
18
20
  export async function createSession(settings, apiKey, { proxyCountry }) {
@@ -30,13 +32,13 @@ export async function releaseSession() {}
30
32
 
31
33
  /**
32
34
  * Browser hours left of the month's monthlyHours. Lightpanda's console shows the month's usage, but the token
33
- * cannot read it: the month's usage is the ledger's record of the sessions (history), which is all of it while
34
- * the account is used through ledgers of this machine only. The hours are assumed to renew with each calendar
35
- * month (UTC).
35
+ * cannot read it: the month's usage is the ledger's record of the sessions that have ended (history), which is
36
+ * all of it while the account is used through ledgers of this machine only. The hours are assumed to renew with
37
+ * each calendar month (UTC).
36
38
  */
37
39
  export async function quota(settings, apiKey, history) {
38
40
  const month = utcMonth();
39
- const usedHours = history.sessionMsSince(month.start) / 3_600_000;
41
+ const usedHours = history.endedSessionMsSince(month.start) / 3_600_000;
40
42
  return { remaining: settings.monthlyHours - usedHours, unit: 'hours', renewsAt: month.next };
41
43
  }
42
44
 
@@ -8,6 +8,8 @@ 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
+ price: { perHour: 0.1, stepSec: 60 }, // USD on the Launch plan, by the minute rounded up
12
+ billingDelayMs: 5 * 60_000, // seen within 11 s
11
13
  };
12
14
 
13
15
  const headers = (apiKey) => ({ 'steel-api-key': apiKey });
@@ -10,6 +10,8 @@ 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
+ price: { perHour: 0.12, stepSec: 60 }, // USD: $0.002 a minute, proxy included; its rounding is not documented
14
+ billingDelayMs: 5 * 60_000, // seen within 64 s
13
15
  };
14
16
 
15
17
  const headers = (apiKey) => ({ 'X-API-Key': apiKey });
package/src/session.js CHANGED
@@ -16,7 +16,8 @@ const firstLine = (e) => String(e.message).split('\n')[0];
16
16
  *
17
17
  * id, provider, egress the provider's session id, its name, and the egress the session uses
18
18
  * cdpUrl CDP WebSocket URL (it may carry the API key: never log it)
19
- * openedAt, closedAt ms; closedAt is null while open
19
+ * openedAt, closedAt ms: when it was asked for (its provider created it right after), when it was
20
+ * closed; closedAt is null while open
20
21
  */
21
22
  export class Session {
22
23
  #release;
@@ -25,15 +26,15 @@ export class Session {
25
26
  #deadline;
26
27
 
27
28
  /** release(session): records the session closed and releases it at its provider (OneBrowser) */
28
- constructor({ provider, remote, maxDurationMs, release }) {
29
+ constructor({ provider, remote, openedAt, maxDurationMs, release }) {
29
30
  this.provider = provider;
30
31
  this.id = remote.id;
31
32
  this.egress = remote.egress;
32
33
  this.cdpUrl = remote.cdpUrl;
33
- this.openedAt = Date.now();
34
+ this.openedAt = openedAt;
34
35
  this.closedAt = null;
35
36
  this.#release = release;
36
- this.#deadline = setTimeout(() => this.close().catch(() => {}), maxDurationMs); // a release that fails here leaves the session to the provider's idle timeout
37
+ this.#deadline = setTimeout(() => this.close().catch(() => {}), openedAt + maxDurationMs - Date.now()); // a release that fails here leaves the session to the provider's idle timeout
37
38
  this.#deadline.unref();
38
39
  }
39
40