onebrowser 0.3.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 +27 -19
- package/package.json +1 -1
- package/src/cli.js +5 -2
- package/src/cost.js +20 -0
- package/src/index.js +74 -13
- package/src/ledger.js +70 -14
- package/src/providers/anchor.js +4 -0
- package/src/providers/firecrawl.js +2 -0
- package/src/providers/hyperbrowser.js +2 -0
- package/src/providers/index.js +16 -3
- package/src/providers/kernel.js +4 -2
- package/src/providers/lightpanda.js +6 -4
- package/src/providers/steel.js +2 -0
- package/src/providers/tinyfish.js +2 -0
- package/src/session.js +5 -4
package/README.md
CHANGED
|
@@ -15,13 +15,14 @@ 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
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
|
|
|
@@ -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
|
|
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
|
|
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.
|
|
@@ -78,15 +80,21 @@ How each provider meets them:
|
|
|
78
80
|
|
|
79
81
|
### Quota
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
|
86
|
-
|
|
87
|
-
| `
|
|
88
|
-
| `
|
|
89
|
-
| `
|
|
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.
|
|
90
98
|
|
|
91
99
|
### API keys
|
|
92
100
|
|
|
@@ -94,30 +102,30 @@ How each provider meets them:
|
|
|
94
102
|
|
|
95
103
|
### The ledger
|
|
96
104
|
|
|
97
|
-
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.
|
|
98
106
|
|
|
99
107
|
## Command line
|
|
100
108
|
|
|
101
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.
|
|
102
110
|
|
|
103
111
|
```sh
|
|
104
|
-
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
|
|
105
113
|
npx onebrowser quota steel kernel # only these; one without an API key is a failed reading
|
|
106
114
|
npx onebrowser usage --days 7 # the ledger's sessions per UTC day, provider and client, today included
|
|
107
115
|
npx onebrowser usage --json --env-file .env
|
|
108
116
|
```
|
|
109
117
|
|
|
110
|
-
`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.
|
|
111
119
|
|
|
112
120
|
## Adding a provider
|
|
113
121
|
|
|
114
122
|
Write `src/providers/<name>.js` and register it in `ADAPTERS` (`src/providers/index.js`, where the contract is):
|
|
115
123
|
|
|
116
|
-
- `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).
|
|
117
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.
|
|
118
126
|
- `releaseSession(settings, apiKey, session)`.
|
|
119
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.
|
|
120
|
-
- `quota(settings, apiKey, history) → { remaining, unit, renewsAt? }`: `history.
|
|
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.
|
|
121
129
|
|
|
122
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.
|
|
123
131
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onebrowser",
|
|
3
|
-
"version": "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
5
|
"keywords": ["browser", "remote-browser", "cdp", "playwright", "steel", "hyperbrowser", "lightpanda", "kernel", "tinyfish", "anchorbrowser", "firecrawl"],
|
|
6
6
|
"license": "MIT",
|
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})` : `${
|
|
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
|
|
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, ...
|
|
64
|
-
|
|
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
|
|
80
|
-
#
|
|
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:
|
|
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
|
|
126
|
-
*
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
19
|
-
* even if connecting fails), closed when released, and completed with the
|
|
20
|
-
* (duration, proxy traffic) once the session has ended and syncUsage has
|
|
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
|
|
23
|
-
CREATE TABLE
|
|
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
|
|
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
|
|
36
|
+
CREATE INDEX idx_sessions_opened ON sessions(provider, opened_at);
|
|
35
37
|
`;
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
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.#
|
|
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 (
|
|
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
|
|
72
|
-
|
|
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(
|
package/src/providers/anchor.js
CHANGED
|
@@ -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 });
|
|
@@ -13,6 +13,8 @@ import { json } from './http.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export const defaults = {
|
|
15
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
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const TTL_SEC = { min: 30, max: 3600 }; // what Firecrawl takes for ttl; activityTtl has the same maximum
|
|
@@ -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 });
|
package/src/providers/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkPrice } from '../cost.js';
|
|
1
2
|
import * as anchor from './anchor.js';
|
|
2
3
|
import * as firecrawl from './firecrawl.js';
|
|
3
4
|
import * as hyperbrowser from './hyperbrowser.js';
|
|
@@ -8,9 +9,11 @@ import * as tinyfish from './tinyfish.js';
|
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Provider adapters, by provider name: the only provider-specific code. Each exports:
|
|
11
|
-
* defaults its settings
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
14
17
|
* createSession(settings, apiKey, options) → { id, cdpUrl, egress }
|
|
15
18
|
* options: { proxyCountry, idleTimeoutSec, maxDurationMs } (OneBrowser.openSession), mapped onto the
|
|
16
19
|
* provider's own parameters. Throws on failure; QuotaError (errors.js) when the provider refuses the
|
|
@@ -37,3 +40,13 @@ export function checkAdapter(name, adapter) {
|
|
|
37
40
|
const missing = CONTRACT.filter((f) => typeof adapter?.[f] !== 'function');
|
|
38
41
|
if (missing.length) throw new Error(`provider ${name}: its adapter lacks ${missing.join(', ')}`);
|
|
39
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
|
+
}
|
package/src/providers/kernel.js
CHANGED
|
@@ -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.
|
|
46
|
-
*
|
|
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
|
|
34
|
-
* the account is used through ledgers of this machine only. The hours are assumed to renew with
|
|
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.
|
|
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
|
|
package/src/providers/steel.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|