effect-browserbase 0.1.0-beta.104 → 0.2.0-beta.1
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 +106 -307
- package/dist/Account.d.mts +17 -19
- package/dist/Account.mjs +4 -9
- package/dist/Account.mjs.map +1 -1
- package/dist/{Acquisition-WBafHwXH.mjs → Acquisition-CAsTTXp4.mjs} +25 -70
- package/dist/Acquisition-CAsTTXp4.mjs.map +1 -0
- package/dist/Agents-5HpTp9YE.d.mts +155 -0
- package/dist/Agents.d.mts +3 -156
- package/dist/Agents.mjs +4 -20
- package/dist/Agents.mjs.map +1 -1
- package/dist/Allocation.d.mts +9 -11
- package/dist/Allocation.mjs +2 -4
- package/dist/Allocation.mjs.map +1 -1
- package/dist/Browser-DpSajNai.mjs +270 -0
- package/dist/Browser-DpSajNai.mjs.map +1 -0
- package/dist/Browser.d.mts +26 -143
- package/dist/Browser.mjs +3 -223
- package/dist/BrowserBinding.d.mts +7 -34
- package/dist/BrowserBinding.mjs +30 -1
- package/dist/BrowserBinding.mjs.map +1 -0
- package/dist/Certificates-D4NQmyeA.d.mts +34 -0
- package/dist/Certificates.d.mts +3 -35
- package/dist/Certificates.mjs +2 -7
- package/dist/Certificates.mjs.map +1 -1
- package/dist/{Cleanup-G2YpTCuU.d.mts → Cleanup-DMTDBir8.d.mts} +5 -6
- package/dist/Cleanup.d.mts +3 -2
- package/dist/Cleanup.mjs +1 -6
- package/dist/Cleanup.mjs.map +1 -1
- package/dist/{Client-D1_xYM9o.mjs → Client-4RMKIpL2.mjs} +12 -12
- package/dist/Client-4RMKIpL2.mjs.map +1 -0
- package/dist/{Client-Ca0zSg1n.d.mts → Client-B_f6bBLT.d.mts} +4 -5
- package/dist/Client.d.mts +3 -2
- package/dist/Client.mjs +1 -1
- package/dist/Connection-sxyxtwlk.mjs +56 -0
- package/dist/Connection-sxyxtwlk.mjs.map +1 -0
- package/dist/ContextCoordination.d.mts +5 -6
- package/dist/ContextCoordination.mjs +2 -9
- package/dist/ContextCoordination.mjs.map +1 -1
- package/dist/{ContextWriter-BrQmoXRg.mjs → ContextWriter-oKWFLHuP.mjs} +8 -12
- package/dist/ContextWriter-oKWFLHuP.mjs.map +1 -0
- package/dist/Contexts-DqdL7cyX.d.mts +31 -0
- package/dist/Contexts.d.mts +3 -32
- package/dist/Contexts.mjs +3 -9
- package/dist/Contexts.mjs.map +1 -1
- package/dist/Downloads-CdhiCO5X.d.mts +43 -0
- package/dist/Downloads.d.mts +3 -44
- package/dist/Downloads.mjs +3 -8
- package/dist/Downloads.mjs.map +1 -1
- package/dist/Errors.d.mts +3 -26
- package/dist/Errors.mjs +1 -137
- package/dist/Errors.mjs.map +1 -1
- package/dist/Extensions-DBXuVM1f.d.mts +43 -0
- package/dist/{Extensions-ClZbYb1H.mjs → Extensions-Dg3jHOf0.mjs} +3 -9
- package/dist/Extensions-Dg3jHOf0.mjs.map +1 -0
- package/dist/Extensions.d.mts +3 -44
- package/dist/Extensions.mjs +1 -1
- package/dist/Functions-CsKMMUuZ.d.mts +226 -0
- package/dist/Functions.d.mts +3 -227
- package/dist/Functions.mjs +4 -18
- package/dist/Functions.mjs.map +1 -1
- package/dist/{Launch-BHcAEq05.mjs → Launch-cAdE2rrZ.mjs} +16 -12
- package/dist/Launch-cAdE2rrZ.mjs.map +1 -0
- package/dist/Launch.d.mts +127 -2
- package/dist/Launch.mjs +5 -13
- package/dist/Launch.mjs.map +1 -1
- package/dist/PageFetch-CAMqb5m8.d.mts +39 -0
- package/dist/PageFetch.d.mts +3 -40
- package/dist/PageFetch.mjs +2 -8
- package/dist/PageFetch.mjs.map +1 -1
- package/dist/Projects-tm5bTDH0.d.mts +38 -0
- package/dist/Projects.d.mts +3 -39
- package/dist/Projects.mjs +2 -8
- package/dist/Projects.mjs.map +1 -1
- package/dist/Recordings-bVI33sTq.d.mts +27 -0
- package/dist/Recordings.d.mts +3 -28
- package/dist/Recordings.mjs +3 -5
- package/dist/Recordings.mjs.map +1 -1
- package/dist/{References-URDAtapt.d.mts → References-DOH3Wu44.d.mts} +5 -6
- package/dist/References.d.mts +3 -2
- package/dist/References.mjs +10 -18
- package/dist/References.mjs.map +1 -1
- package/dist/Replays-Bo-9xeLz.d.mts +27 -0
- package/dist/Replays.d.mts +3 -28
- package/dist/Replays.mjs +3 -5
- package/dist/Replays.mjs.map +1 -1
- package/dist/Search-1g6TUem7.d.mts +37 -0
- package/dist/Search.d.mts +3 -38
- package/dist/Search.mjs +2 -9
- package/dist/Search.mjs.map +1 -1
- package/dist/{SessionData-DRKcKCqw.d.mts → SessionData-5xYZDshJ.d.mts} +5 -6
- package/dist/SessionData.d.mts +3 -2
- package/dist/SessionData.mjs +1 -8
- package/dist/SessionData.mjs.map +1 -1
- package/dist/{Sessions-B8E4kMxo.d.mts → Sessions-BpZgjteE.d.mts} +8 -8
- package/dist/{Sessions-CtbseWEh.mjs → Sessions-suOLSym9.mjs} +34 -18
- package/dist/Sessions-suOLSym9.mjs.map +1 -0
- package/dist/Sessions.d.mts +4 -3
- package/dist/Sessions.mjs +1 -1
- package/dist/Testing.d.mts +119 -0
- package/dist/Testing.mjs +206 -0
- package/dist/Testing.mjs.map +1 -0
- package/dist/{Transfers-Czv5IZzJ.d.mts → Transfers-GVqzrDWl.d.mts} +22 -9
- package/dist/Transfers.d.mts +3 -2
- package/dist/Transfers.mjs +12 -28
- package/dist/Transfers.mjs.map +1 -1
- package/dist/Uploads-C2neBEpj.d.mts +28 -0
- package/dist/Uploads.d.mts +3 -29
- package/dist/Uploads.mjs +3 -5
- package/dist/Uploads.mjs.map +1 -1
- package/dist/Webhooks-BoeRAxzG.d.mts +59 -0
- package/dist/Webhooks.d.mts +3 -60
- package/dist/Webhooks.mjs +2 -11
- package/dist/Webhooks.mjs.map +1 -1
- package/dist/{WriterFacts-osvYAsIx.d.mts → WriterFacts-BBJRDZYY.d.mts} +8 -15
- package/dist/index.d.mts +27 -31
- package/dist/index.mjs +26 -31
- package/package.json +7 -28
- package/dist/Acquisition-WBafHwXH.mjs.map +0 -1
- package/dist/Association-CEps4hYP.mjs +0 -10
- package/dist/Association-CEps4hYP.mjs.map +0 -1
- package/dist/Bootstrap-BXuzZojd.mjs +0 -194
- package/dist/Bootstrap-BXuzZojd.mjs.map +0 -1
- package/dist/Bootstrap-m7lHgj0D.d.mts +0 -177
- package/dist/Bootstrap.d.mts +0 -2
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserBinding-DlJTGYZL.mjs +0 -2508
- package/dist/BrowserBinding-DlJTGYZL.mjs.map +0 -1
- package/dist/BrowserData-LpcAuAub.d.mts +0 -369
- package/dist/BrowserData.d.mts +0 -2
- package/dist/BrowserData.mjs +0 -442
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/Capture-Bc0z9IAi.mjs +0 -571
- package/dist/Capture-Bc0z9IAi.mjs.map +0 -1
- package/dist/Capture-BvWI-l1A.d.mts +0 -168
- package/dist/Capture.d.mts +0 -2
- package/dist/Capture.mjs +0 -2
- package/dist/Client-D1_xYM9o.mjs.map +0 -1
- package/dist/ContextWriter-BrQmoXRg.mjs.map +0 -1
- package/dist/Extensions-ClZbYb1H.mjs.map +0 -1
- package/dist/Images-DgscEfSf.mjs +0 -127
- package/dist/Images-DgscEfSf.mjs.map +0 -1
- package/dist/Launch-BHcAEq05.mjs.map +0 -1
- package/dist/Launch-C8CfaYxb.d.mts +0 -132
- package/dist/LocalBrowser-BLancJ3R.d.mts +0 -79
- package/dist/LocalBrowser-DsQLWNL1.mjs +0 -439
- package/dist/LocalBrowser-DsQLWNL1.mjs.map +0 -1
- package/dist/LocalBrowser.d.mts +0 -2
- package/dist/LocalBrowser.mjs +0 -2
- package/dist/PageControl.d.mts +0 -16
- package/dist/PageControl.mjs +0 -36
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageControlAssociation-DabEg5AR.mjs +0 -10
- package/dist/PageControlAssociation-DabEg5AR.mjs.map +0 -1
- package/dist/Session-D4nT9hOO.mjs +0 -1379
- package/dist/Session-D4nT9hOO.mjs.map +0 -1
- package/dist/Sessions-CtbseWEh.mjs.map +0 -1
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Browserbase for Effect
|
|
2
2
|
|
|
3
|
-
`effect-browserbase`
|
|
3
|
+
`effect-browserbase` supplies Browserbase resources and hosted lifetimes to the shared browser runtime: account identity and transport, session, context and extension resources, one owned browser over Playwright/CDP — allocated or borrowed — with trusted registrations, modeled file selection, bounded live capture, explicit page holds, the provider's recordings, replays, uploads and website downloads, and the platform APIs outside a browser session (projects, certificates, Search, Fetch, Agents, Functions and webhooks).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Its host supplies the required `effect-browser` peer, at the same coordinated `0.2.0-beta.1` version; it has no Effect Agent dependency. Install both packages explicitly so the provider and application share one runtime instance. Playwright is an optional peer of the shared runtime and is loaded only when a browser connects; artifact-only consumers do not need it. The existing Effect peer range remains `^4.0.0-rc.115`, with only rc.115 qualified. The package targets trusted Node and Bun hosts. It is published as a beta (`0.2.0-beta.0` on the `beta` dist-tag): hosted provider behavior requires separate validation, and ordinary tests never create a paid Browserbase session or invoke a paid model.
|
|
6
6
|
|
|
7
7
|
## One account, then resources
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ const account = Account.layerConfig({
|
|
|
19
19
|
});
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
`Account.layer({ projectId, apiKey: Redacted.make(key), ... })` takes the same authority explicitly. Either one bundles every resource service on a single Client, so credentials are composed once instead of per service; each service still exports its own `layer` when you want a narrower set. `BrowserbaseBrowser.layer` stays separate, because a browser also fixes budgets and a connection lifetime that an account does not. The Client uses Effect's `FetchHttpClient.Fetch` reference, which already defaults to `globalThis.fetch`; provide a different `fetch` only when you need one.
|
|
22
|
+
`Account.layer({ projectId, apiKey: Redacted.make(key), ... })` takes the same authority explicitly. Either one bundles every resource service on a single Client, so credentials are composed once instead of per service; each service still exports its own `layer` when you want a narrower set. `BrowserbaseBrowser.layer` stays separate, because a browser also fixes budgets and a connection lifetime that an account does not. It also requires Effect's `Crypto` from the host platform (`NodeServices.layer`, `BunServices.layer` or `NodeCrypto.layer`), which supplies allocation attempt ids, connection ids and handoff tokens; the account services need no `Crypto`. The Client uses Effect's `FetchHttpClient.Fetch` reference, which already defaults to `globalThis.fetch`; provide a different `fetch` only when you need one.
|
|
23
23
|
|
|
24
24
|
`artifactOrigins` is the exact set of HTTPS origins this client will fetch provider media from, and it is deliberately empty by default: recording downloads and replay media are refused with `unsafe-url` until you approve a host. Browserbase documents only a "signed CDN URL" and does not publish that origin, so discover your own rather than copying anyone's — request a recording for a completed session, read `downloadUrl` from `GET /v1/sessions/{id}/recording/downloads`, and approve exactly its origin. A hosted run on 21 September 2026 confirmed that a completed recording then downloads through this check rather than around it. The origin is an observation and not a contract: it may differ by project or region, the provider can re-point it without notice, and a BYOS project returns no signed URL at all. Treat a later `unsafe-url` as the delivery host having moved, not as a defect.
|
|
25
25
|
|
|
@@ -34,8 +34,10 @@ const account = Account.layerConfig({
|
|
|
34
34
|
For the simplest post-session video path, opt in to Browserbase recording in the launch recipe:
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
|
+
import { NodeServices } from "@effect/platform-node";
|
|
37
38
|
import { BrowserbaseBrowser } from "effect-browserbase/browser";
|
|
38
|
-
import
|
|
39
|
+
import * as Browser from "effect-browser/browser";
|
|
40
|
+
import { BrowserPolicy, NavigateRequest } from "effect-browser/browser-data";
|
|
39
41
|
import { recipe } from "effect-browserbase/launch";
|
|
40
42
|
import { BrowserbaseRecordings } from "effect-browserbase/recordings";
|
|
41
43
|
import { RecordingPageReference } from "effect-browserbase/transfers";
|
|
@@ -49,19 +51,17 @@ const policy = BrowserPolicy.unrestricted();
|
|
|
49
51
|
|
|
50
52
|
const layers = Layer.merge(BrowserbaseBrowser.layer({ launch }), BrowserbaseRecordings.layer).pipe(
|
|
51
53
|
Layer.provide(account),
|
|
54
|
+
Layer.provide(NodeServices.layer),
|
|
52
55
|
);
|
|
53
56
|
|
|
54
57
|
const program = Effect.gen(function* () {
|
|
55
|
-
const browser = yield* BrowserbaseBrowser;
|
|
56
58
|
const recordings = yield* BrowserbaseRecordings;
|
|
57
59
|
|
|
58
|
-
const reference = yield*
|
|
60
|
+
const reference = yield* Browser.scoped(BrowserbaseBrowser.open(policy), (session) =>
|
|
59
61
|
Effect.gen(function* () {
|
|
60
|
-
|
|
62
|
+
yield* session.navigate(NavigateRequest.make({ url: "https://example.com" }));
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// An AgentRuntime can borrow this same session through the adapter package.
|
|
64
|
+
// Browser.scoped checks the owner's release before returning this durable identity.
|
|
65
65
|
return session.reference;
|
|
66
66
|
}),
|
|
67
67
|
);
|
|
@@ -91,7 +91,7 @@ The launch recipe deliberately differs from three Browserbase defaults. Set a fi
|
|
|
91
91
|
| `logSession` | on | off | CDP logs carry page content and typed input |
|
|
92
92
|
| `solveCaptchas` | on | off | solving is an action the host should choose |
|
|
93
93
|
|
|
94
|
-
For model-driven control, use `effect-agent-
|
|
94
|
+
For model-driven control, use `effect-agent-browser`, whose tools borrow an already-owned session rather than allocating a browser per tool call. Provider credentials, CDP URLs, context choices, Live View controls and recording configuration are host decisions and are never Tool parameters.
|
|
95
95
|
|
|
96
96
|
## Choose a video path
|
|
97
97
|
|
|
@@ -118,206 +118,32 @@ Prefer provider recording when post-session MP4/HLS is enough. Use `capture` whe
|
|
|
118
118
|
|
|
119
119
|
That split is why an agent can use one browser for many turns while provider artifacts remain accessible after the interactive scope ends. Importing a Layer does not allocate a browser.
|
|
120
120
|
|
|
121
|
-
`Allocation.scoped(recipe)` allocates a session without connecting a browser, for a caller that drives the remote session with its own automation client. It is the same allocation path the browser owner uses, so the creation request is never retried, an unknown outcome is still reported exactly once, a persisting Context still needs a live writer permit, and no native peer is loaded. The scope owns the release: closing it releases the session whether or not anything ever connected. A reply naming another project is refused, and because a foreign reference is never acted on, the caller is handed that reference rather than a mutation attempted against it.
|
|
122
|
-
|
|
123
|
-
The browser owner does not allocate or release anything itself. It supplies the local half of cleanup — fence, capture stop, initialization teardown, disconnect — and the canonical control plane owns the release request and the terminal status observation. A local disconnect, an accepted provider release request and provider-confirmed termination therefore stay distinct facts in one `CleanupResult`, together with the exact steps that failed.
|
|
124
|
-
|
|
125
|
-
Mutations are serialized. An observation identifies retained native nodes only until the next invalidating event; it is not a DOM snapshot version. Replaced or detached nodes fail instead of silently resolving to replacements. An action interrupted or timed out after native dispatch has an **unknown** outcome: the owner is fenced and the package never automatically replays it. `undispatched` is used only when the package established that native mutation dispatch did not occur.
|
|
126
|
-
|
|
127
|
-
### A navigation you can watch while it loads
|
|
128
|
-
|
|
129
|
-
`navigate` holds nothing open that you can see into: it returns when the document reaches DOMContentLoaded. `startNavigation` is the same single dispatch, left in flight, so a recorder can look at a page while it is still arriving, hold it, and let it finish:
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
const operation =
|
|
133
|
-
yield * handle.startNavigation(StartNavigationRequest.make({ url, timeoutMillis: 30_000 }));
|
|
134
|
-
|
|
135
|
-
const early = yield * session.checkpoint({ picture: true }); // what has loaded so far
|
|
136
|
-
const receipt = yield * PageControl.suspend(session, page); // timers, CSS and parsing stop
|
|
137
|
-
yield * PageControl.resume(session, receipt);
|
|
138
|
-
const { url: loaded } = yield * operation.completed;
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
The owner's permit is released as soon as the navigation is dispatched. While it loads, reads, checkpoints, holds and every other page proceed, and anything that would change _this_ page fails `busy` and `undispatched`. `navigate` runs on the same machinery, so there is one navigator.
|
|
142
|
-
|
|
143
|
-
- `completed` belongs to that one navigation: a successor reaching the same URL fails it instead of completing it, and its URL is the page that navigated, not whichever page is selected by then. **Interrupting a waiter stops nothing.** The browser keeps loading and nothing is dispatched again.
|
|
144
|
-
- `stop` asks the browser to stop loading. Its acknowledgement is a known outcome: `completed` then fails `interrupted`, the page holds whatever had loaded, and the session stays usable. It does not undo anything the page already did.
|
|
145
|
-
- Leaving the operation's scope unsettled, or a navigation that fails after dispatch, fences the session as uncertain, exactly as an interrupted mutation always has. Nothing knows what the browser did, so nothing more is sent, and it is never replayed.
|
|
146
|
-
|
|
147
|
-
A read is ordered against the document being replaced by failing: if the document it was reading was replaced underneath it, or a navigation is still in flight on its page, the error is `target-changed` and `undispatched`, which means read again. A read is never a mutation, so that is always safe. A held page is not read at all; take the checkpoint before the hold and keep it.
|
|
148
|
-
|
|
149
|
-
Locally, a hold during an incremental response stops parsing as well as timers: a chunk the server sends meanwhile is not parsed until resume. That is an observation of the pinned Chromium, not a guarantee about hosted sessions.
|
|
150
|
-
|
|
151
|
-
### What is on screen, and what a host may know about it
|
|
152
|
-
|
|
153
|
-
`session.observe()` reads the whole document. `session.observe({ scope: "viewport" })` keeps only text and controls that are on screen and reachable, and says what it left out:
|
|
154
|
-
|
|
155
|
-
```ts
|
|
156
|
-
const seen = yield * session.observe({ scope: "viewport" });
|
|
157
|
-
seen.viewport; // { clippedText, coveredText, uncertainText, unreachableControls, exhausted, ... }
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
Visibility here is geometry and hit-testing, never a pixel comparison, and the counts say which was which. Text is kept when its line boxes intersect the viewport and the browser finds its own element at a sampled point. A text node that crosses the viewport edge contributes only its lines on screen (`clippedText`). Text behind another element is left out (`coveredText`). Text under something that takes no pointer events cannot be hit-tested at all, so it is left out as `uncertainText` rather than called visible. `exhausted` means the traversal budget ran out first and the reading is known to be incomplete. Canvas pixels and compositing effects are not interpreted.
|
|
161
|
-
|
|
162
|
-
An `Observation` is safe to show a model, and the adapter's `browser_inspect` Tool returns it as is. It therefore carries no destination, form target or field value, in either scope. What a host needs to decide whether a control may be acted on is a separate, host-only read from the exact node:
|
|
163
|
-
|
|
164
|
-
```ts
|
|
165
|
-
const facts = yield * session.controlFacts(reference);
|
|
166
|
-
// kind, label, disabled, editable, inputType, autocomplete, formMethod, box, placement, hitTest
|
|
167
|
-
// destination: the resolved link target, or where this control submits its form
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
`destination` is resolved by the browser against the document's base URL, and honours a `formaction` override. It can carry a token, which is why it is not in an `Observation`. An over-long destination is left out, never cut. No value and no markup is ever included.
|
|
171
|
-
|
|
172
|
-
A reference is to the control that was inspected, not merely to a node. If the same attached node now has a different destination, input type, autocomplete category, form method, label or disabled state, acting on it fails `stale` and `undispatched`, exactly as it does for a replaced or detached node. Nothing is ever re-found by selector or label.
|
|
173
|
-
|
|
174
|
-
When a decision has to be current at dispatch, pass a policy. It is evaluated on facts read from that exact node immediately before the input:
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
yield *
|
|
178
|
-
session.fillElement(reference, value, {
|
|
179
|
-
admit: (facts) => facts.inputType !== "password" && facts.autocomplete !== "cc-number",
|
|
180
|
-
});
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
Anything but `true`, or a policy that throws, sends nothing and fails `denied`. The policy is a plain synchronous function on purpose: it runs while the owner's permit is held, where waiting on a model or a network call would stall every other operation. It is not an atomic check-and-input transaction, because page script can still run before the native input lands.
|
|
184
|
-
|
|
185
|
-
### Passive checkpoints for a recorder
|
|
186
|
-
|
|
187
|
-
`observe()` replaces the one observation whose nodes later actions may name, so a recorder calling it would retire the references an agent is about to use. `session.checkpoint()` is the passive path: viewport text, control facts and, when asked, a PNG of the viewport.
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
const checkpoint = yield * session.checkpoint({ picture: true });
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
It issues no references, is not a mutation, and leaves the action observation and the selection exactly as they were, so inspect, checkpoint, then act on the inspected node all compose. It is host-only, because it carries control facts. Text and picture are read one after the other, never atomically: the interval is on the host monotonic clock, and `documentChanged` says the document was replaced in between. A checkpoint is charged as one action, like any other bounded read. A held page is refused `busy` rather than woken to be read: take the checkpoint before the hold and keep it.
|
|
194
|
-
|
|
195
|
-
### Real pointer and wheel input
|
|
196
|
-
|
|
197
|
-
`pointerMove`, `hover` and `wheel` send the input a person's hardware would, so pages see trusted events, `:hover` applies, and the browser itself decides what is under the pointer. `scroll` stays what it was: script in the page, instantaneous, raising no wheel event. That difference is how a recording tells one from the other.
|
|
198
|
-
|
|
199
|
-
```ts
|
|
200
|
-
const handle = session.bind();
|
|
201
|
-
|
|
202
|
-
yield * handle.pointerMove(PointerMoveRequest.make({ to: { x: 140, y: 100 } }));
|
|
203
|
-
yield * handle.hover(HoverRequest.make({ selector: "#menu" }));
|
|
204
|
-
// A nested scroll container under the pointer scrolls, not the page.
|
|
205
|
-
const receipt =
|
|
206
|
-
yield * handle.wheel(WheelRequest.make({ deltaX: 0, deltaY: 240, at: { x: 420, y: 120 } }));
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
Coordinates are CSS pixels in the main frame's viewport. Each call is one native command, charged as one action and fenced like any other mutation, so a handle bound to a page that is no longer selected sends nothing to either page. Easing, pacing and cursor artwork are yours: send the points you want, and draw the cursor from the positions the receipts report.
|
|
210
|
-
|
|
211
|
-
`hover` places the pointer on one exact element where it is, by selector or by the node an observation named (`session.hoverElement`). It never scrolls to reach it, because that would hide a scripted scroll inside a native-input operation. If the pointer cannot be placed on the element (it is outside the viewport, has no area, or something covers it) the call fails `not-visible` and `undispatched`.
|
|
212
|
-
|
|
213
|
-
For a child-frame element, hover checks the commanded point through each ancestor
|
|
214
|
-
frame and then the exact node, including cross-origin documents. The receipt
|
|
215
|
-
still uses main-viewport CSS coordinates. An overlay or clipping ancestor refuses
|
|
216
|
-
input. Frame traversal is bounded at 32 levels, with bounded shadow/DOM ancestry
|
|
217
|
-
checks. Axis-aligned translation and positive scaling are supported; rotation,
|
|
218
|
-
perspective and other unsupported frame mappings are refused rather than guessed.
|
|
219
|
-
These reads and native input are separate operations in the browser: page script
|
|
220
|
-
can still change geometry between validation and dispatch.
|
|
221
|
-
|
|
222
|
-
An `InputReceipt` carries the target it was sent to, the position this owner commanded (null until it has placed the pointer on that page), and an interval on the same host monotonic clock that stamps `CapturedFrame.receivedMonotonicNanos`. Input and pixels share one timeline, so a compositor can place the pointer on the frame that shows it. A wheel event is dispatched, not awaited: the receipt does not claim the page finished scrolling or that any frame shows it.
|
|
223
|
-
|
|
224
|
-
### Real key input
|
|
225
|
-
|
|
226
|
-
`fill` sets a field's value in one step: the page gets an `input` event and no `keydown`, `keypress` or `keyup`, so anything that reacts to keys behaves differently under a recorder than it does for a person. `press` and `type` send the strokes a keyboard would. Handlers see trusted key events, and the browser does what it does for a person: Tab moves focus and selects the field it lands in, Enter submits a form that has a submit button, and Backspace edits.
|
|
227
|
-
|
|
228
|
-
```ts
|
|
229
|
-
const handle = session.bind();
|
|
230
|
-
|
|
231
|
-
// Focus is the page's business. A real click gives it, and keys then follow it.
|
|
232
|
-
yield * handle.click(ClickRequest.make({ selector: "#from" }));
|
|
233
|
-
yield * handle.type(TypeRequest.make({ text: "Vienna" }));
|
|
234
|
-
yield * handle.press(PressRequest.make({ key: "Backspace" }));
|
|
235
|
-
yield * handle.press(PressRequest.make({ key: "k", modifiers: ["Control"] }));
|
|
236
|
-
// Sent only if `#from` still has focus; otherwise nothing is sent at all.
|
|
237
|
-
yield * handle.press(PressRequest.make({ key: "Enter", into: "#from" }));
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
Keys go to whatever has focus in the selected page, in whichever frame that is, because that is where the browser sends them. `into` narrows that to one exact element, which must already have focus or hold the element that does, through any open shadow root. If it does not, the call fails `not-focused` and `undispatched`. It never focuses the element for you, for the reason `hover` never scrolls: that would hide a scripted focus inside a native-input operation. `session.pressElement` and `session.typeElement` apply the same rule to the node an observation named and take the same host admission as `fillElement`, so switching from `fill` to real typing gives up neither exactness nor the check on fresh control facts.
|
|
241
|
-
|
|
242
|
-
A key is spelled as the `KeyboardEvent.key` the page will see, and the vocabulary is closed: `Enter`, `Tab`, `Backspace`, `Delete`, `Escape`, the four arrows, `Home`, `End`, `PageUp`, `PageDown`, or one printable ASCII character (a space is `" "`), with `Shift`, `Control`, `Alt` and `Meta` as modifiers. The native engine parses a key string, chords included, and begins holding the modifiers before it has validated the key, so nothing reaches it that was not reviewed here. A modifier other than Shift makes a chord rather than a character, and nothing is typed.
|
|
243
|
-
|
|
244
|
-
`type` sends up to 256 characters as one charged action, two native commands for each, one after another under a single action timeout. On a slow link a long passage can outlast that timeout, which leaves an unknown outcome and fences the session like any interrupted mutation, so send it as several shorter runs. The owner is checked between characters, which means a fence stops the rest instead of typing into a session that is closing. Two limits come from the pinned engine. A character the US layout cannot produce is committed as text, the way an input method commits it: the field changes and no key event says so. And a shifted character arrives as its own key with `shiftKey` false. When a page reads the modifier, send that stroke through `press` with `Shift` held, spelling the key as the page will see it: `{ key: "A", modifiers: ["Shift"] }`. Spelled `"a"`, the engine sends `a` with Shift down, which is what Shift produces with Caps Lock on. Control characters are refused in text because the engine presses Enter for a line break; a named key is always its own `press`. Pacing is yours, as easing is for the pointer: for a typist's cadence, send one character per call and sleep between them, at one action each.
|
|
245
|
-
|
|
246
|
-
A press is dispatched, not awaited. If Enter submits a form, wait for what the next document shows with `waitFor`. A receipt carries the same target, pointer position and interval as any other input, and never says which key was pressed or what was typed. Typing a secret is still more observable than one `fill`, because the page sees every stroke; prefer `fill` for one unless the page requires keys. Neither operation is part of the model-facing toolkit in `effect-agent-browserbase`.
|
|
247
|
-
|
|
248
|
-
The connection endpoint is read through the exact allocated session, so a provider reply that names a different session is refused before any CDP attachment.
|
|
249
|
-
|
|
250
|
-
Persistent Browserbase contexts require a live writer permit from `ContextCoordination.withWriter` when writes are persisted. Detach/reconnect is opt-in with `keepAlive`; reconnect creates a new handle generation, verifies the selected target, obtains fresh state, and never replays pending input or treats serialized agent state as a live browser.
|
|
251
|
-
|
|
252
|
-
Human handoff pauses automation before returning host-only Live View material. Resume requires an explicit operator-release signal and obtains a fresh observation while holding the same mutation permit. A failed handoff does not silently resume automation. Live View URLs are temporary bearer material; iframe styling is not an authorization boundary. Live View is also where browser-window presentation already exists for watching a session as it runs, and it is the provider's: beside each full-screen URL Browserbase issues a bordered one (`debuggerUrl`, "mimic a real browser with borders"), and a navbar that `navbar=false` hides. This package decodes and returns only the full-screen URL. The bordered one carries the same control authority and would be issued under the same rules, so surfacing it is a small host-only addition whenever something needs it; nothing here does yet, so it is not exported.
|
|
253
|
-
|
|
254
|
-
## Registrations, capabilities and document readiness
|
|
121
|
+
`Allocation.scoped(recipe)` allocates a session without connecting a browser, for a caller that drives the remote session with its own automation client. It is the same allocation path the browser owner uses, so the creation request is never retried, an unknown outcome is still reported exactly once, a persisting Context still needs a live writer permit, and no native peer is loaded. The scope owns the release: closing it releases the session whether or not anything ever connected. A reply naming another project is refused, and because a foreign reference is never acted on, the caller is handed that reference rather than a mutation attempted against it. The attempt id comes from Effect's `Crypto`, so `Allocation.scoped` requires that service beside the account.
|
|
255
122
|
|
|
256
|
-
|
|
123
|
+
The shared browser runtime delegates allocation and release to this provider lifetime. It supplies the local half of cleanup — fence, capture stop, initialization teardown, disconnect — and the canonical control plane owns the release request and the terminal status observation. A local disconnect, an accepted provider release request and provider-confirmed termination therefore stay distinct facts in one `CleanupResult`, together with the exact steps that failed.
|
|
257
124
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
) {}
|
|
267
|
-
|
|
268
|
-
const bootstrap = Bootstrap.combine(
|
|
269
|
-
Bootstrap.permissions({ origin: "https://portal.example.com", permissions: ["clipboard-read"] }),
|
|
270
|
-
Bootstrap.binding({
|
|
271
|
-
name: "getShowSettings",
|
|
272
|
-
origins: ["https://portal.example.com"],
|
|
273
|
-
input: Schema.Struct({ version: Schema.Literal(3) }),
|
|
274
|
-
output: Schema.Struct({ title: Schema.String }),
|
|
275
|
-
maxConcurrent: 2,
|
|
276
|
-
maxInputBytes: 256,
|
|
277
|
-
maxOutputBytes: 4096,
|
|
278
|
-
timeoutMillis: 2000,
|
|
279
|
-
failureMode: "fail-session",
|
|
280
|
-
handle: () => Effect.map(ShowSettings, (settings) => ({ title: settings.title })),
|
|
281
|
-
}),
|
|
282
|
-
Bootstrap.init({
|
|
283
|
-
id: "show-settings-v3",
|
|
284
|
-
origins: ["https://portal.example.com"],
|
|
285
|
-
content: "globalThis.__ready = globalThis.getShowSettings({ version: 3 }).then(() => true);",
|
|
286
|
-
readiness: {
|
|
287
|
-
expression: "globalThis.__ready",
|
|
288
|
-
timeoutMillis: 5_000,
|
|
289
|
-
existingDocuments: "RequireFreshNavigation",
|
|
290
|
-
},
|
|
291
|
-
}),
|
|
292
|
-
);
|
|
125
|
+
`onCleanup` runs once after the canonical receipt and any Context-writer settlement are recorded.
|
|
126
|
+
Its construction throws, defects, self-interruption and two-second cooperative timeout are
|
|
127
|
+
contained separately from the built-in diagnostics reporter. A failed reporter cannot suppress
|
|
128
|
+
the sink, and a failed sink cannot change cleanup facts or repeat release. The receipt remains
|
|
129
|
+
readable through `cleanupResult`; `closeChecked` still fails when its actual confirmation predicate
|
|
130
|
+
fails. An outer race can discard that checked error, so keep any required host receipt sink outside
|
|
131
|
+
the raced workflow. The shared guide's [Layer and receipt examples](../browser/README.md#a-long-lived-session-in-a-layer)
|
|
132
|
+
describe sharing one bounded session and the supervision that a Layer alone does not provide.
|
|
293
133
|
|
|
294
|
-
|
|
295
|
-
const browser = yield* BrowserbaseBrowser;
|
|
296
|
-
return yield* browser.withBrowser(BrowserPolicy.unrestricted(), { bootstrap }, (session) =>
|
|
297
|
-
Effect.gen(function* () {
|
|
298
|
-
yield* session.bind().navigate({ url: "https://portal.example.com" });
|
|
299
|
-
yield* session.ready;
|
|
300
|
-
return yield* session.observe();
|
|
301
|
-
}),
|
|
302
|
-
);
|
|
303
|
-
});
|
|
304
|
-
// run requires BrowserbaseBrowser and ShowSettings. withBrowser discharges both the
|
|
305
|
-
// callback's and the use function's Scope, but neither one's other services or errors.
|
|
306
|
-
```
|
|
134
|
+
Mutations are serialized. An observation identifies retained native nodes only until the next invalidating event; it is not a DOM snapshot version. Replaced or detached nodes fail instead of silently resolving to replacements. An action interrupted or timed out after native dispatch has an **unknown** outcome and is never automatically replayed. Unresolved control fences the owner; the shared runtime's main-frame navigation recovery can preserve usability after one acknowledged stop while still reporting `Timeout/unknown`. Recovery is bounded by the loading deadline plus at most three seconds and the existing lifetime. Child-frame timeouts retain the conservative fence. `undispatched` is used only when the package established that native mutation dispatch did not occur.
|
|
307
135
|
|
|
308
|
-
|
|
136
|
+
## Shared browser operations
|
|
309
137
|
|
|
310
|
-
|
|
138
|
+
Navigation, observation, native input, typed bootstrap bindings, live capture and page holds are implemented by [`effect-browser`](../browser/README.md). Import their contracts from that package and use them with the exact `BrowserbaseSession` returned here. The [`effect-agent-browser`](../agent-browser/README.md) adapter and maintained tools accept both this session and self-managed Chromium without a second connection.
|
|
311
139
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
The reviewed permission subset is granted against a real browser in native acceptance rather than copied from a list. Extension identity and storage across Context reuse, and registration retention across provider reconnects, remain hosted questions and are not claimed here.
|
|
140
|
+
The shared session also provides passive `status` and bounded `diagnostics`, including after
|
|
141
|
+
closure. Known expiry, callback failure and policy pressure are not automatically labeled
|
|
142
|
+
uncertain; unresolved native dispatch remains a separate fact. Owned provider termination can
|
|
143
|
+
retire native control, whereas borrowed disconnection alone cannot. These snapshots do not replace
|
|
144
|
+
the provider's canonical cleanup receipt or make business outcomes known. `maxHostReads` is a
|
|
145
|
+
browser Layer option (default 10,000, maximum 1,000,000) for checkpoint/control-facts sampling;
|
|
146
|
+
it is independent of model action accounting and never a tool parameter.
|
|
321
147
|
|
|
322
148
|
## Files in and out
|
|
323
149
|
|
|
@@ -329,13 +155,41 @@ Larger files use `BrowserbaseUploads.create`, which places bytes for the exact r
|
|
|
329
155
|
|
|
330
156
|
`BrowserbaseBrowser.attach(reference, { policy, target })` takes control of a session this process did not allocate. It reads status first, so project authority is checked before anything is borrowed; a terminal session is refused because that needs a fresh allocation rather than a reattachment, and a starting session is admitted only within a bounded `pendingWaitMillis`. The requested target is resolved explicitly — there is no positional first-tab fallback — and connection credentials are fetched fresh, because expiry races the caller's own status read.
|
|
331
157
|
|
|
332
|
-
A borrowed scope disconnects locally and reports `ownership: "borrowed"` with `remote: "not-owned"`. It never requests release and never claims Context-writer authority: whoever allocated the session keeps both. It also does not detach and reattach inside itself; attaching again is the cross-process path, and it revalidates the session instead of assuming it is still there. A prior uncertain business mutation is still yours to reconcile before the next one; nothing is replayed automatically.
|
|
158
|
+
`session.closeChecked` accepts confirmed owned cleanup or complete borrowed disconnection according to the actual ownership, then returns the same frozen `CleanupResult` cached by `close`. The concrete success value replaces the former `undefined`; failed confirmation remains a typed `BrowserError`. A borrowed scope disconnects locally and reports `ownership: "borrowed"` with `remote: "not-owned"`. It never requests release and never claims Context-writer authority: whoever allocated the session keeps both. It also does not detach and reattach inside itself; attaching again is the cross-process path, and it revalidates the session instead of assuming it is still there. A prior uncertain business mutation is still yours to reconcile before the next one; nothing is replayed automatically.
|
|
159
|
+
|
|
160
|
+
## Shared API migration
|
|
161
|
+
|
|
162
|
+
| Before | Now |
|
|
163
|
+
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
164
|
+
| `session.bind()` or `yield* session.currentTarget` | Ordinary calls use `session` directly; `yield* session.retain` explicitly obtains checked stale-on-selection semantics |
|
|
165
|
+
| Bare page IDs in selection/closure, selection returns a handle | Pass `PageInfo` to `selectPage`/`closePage`; selection returns `void`, and `createPage` returns the exact created record |
|
|
166
|
+
| Reuse old page/frame metadata after reconnect | In the same known browser lifetime, read fresh pages and match exactly one saved `targetId`; then read fresh frames. No fallback to order, old ID, URL or title |
|
|
167
|
+
| `Adapter.fromSession(session)` or `currentHandle` | Await the Effect with explicit `{ selection: "current" }` or `{ selection: "retained" }`; its browser is the original concrete session |
|
|
168
|
+
| `BrowserError.reason` string and optional outcome | Tagged reason and required outcome. Use `Effect.catchReason`; factual limit/status/retry fields belong to their reason |
|
|
169
|
+
| All host reason names reach model output | A compact eleven-reason tool failure; original structured fields and bounded call IDs remain in `ToolHost.toolFailures` |
|
|
170
|
+
| `closeChecked` success is `undefined` | Concrete receipt success after the existing ownership check; `close` and `cleanupResult` remain available |
|
|
171
|
+
| Capture `dropped` | `discarded = overflow + late + duplicates + rejected`; no change to default capacity or unknown upstream loss |
|
|
172
|
+
| Non-supervising helper takes default `BrowserSession` | Use `AnySession`; keep supervising helpers generic in callback error or concrete session |
|
|
173
|
+
|
|
174
|
+
Binding omission defaults are one concurrent call, 64 KiB each direction, 10 seconds and
|
|
175
|
+
`reject-call`; explicit values remain validated. Combined readiness uses the most conservative
|
|
176
|
+
`existingDocuments` policy. Common navigation now accepts optional `timeoutMillis` with the same
|
|
177
|
+
bounds as `startNavigation`; direct, retained and pinned operations share it. Observations add
|
|
178
|
+
bounded checked/selected/inputType/required state without field values or destinations. These
|
|
179
|
+
contracts live in the [common browser guide](../browser/README.md).
|
|
180
|
+
|
|
181
|
+
Explicit generic applications of curried `Browser.scoped` use four outer parameters and three
|
|
182
|
+
returned parameters; ordinary inferred syntax is unchanged. Provider resource errors such as
|
|
183
|
+
`SessionError` keep their independent reason schemas. When a control-plane read becomes a browser
|
|
184
|
+
operation failure, the bridge preserves available facts; a resource limit with no measured bound
|
|
185
|
+
becomes `Provider` rather than fabricating `Limit` values. A native connection whose setup fails
|
|
186
|
+
after connection work began carries `unknown` outcome.
|
|
333
187
|
|
|
334
188
|
`test/native/handoff-process.test.ts` exercises that path with a real second process: it starts a fresh runtime that receives only the reference, a target id and fixture addresses, attaches, changes the page and closes as a borrower, and the allocating process then drives the same page and releases it. That is local CDP evidence, not a hosted handoff.
|
|
335
189
|
|
|
336
190
|
## The native engine
|
|
337
191
|
|
|
338
|
-
`browser-binding` names the engine an owned or borrowed browser connects through. The default is Playwright over CDP with the provider's own address, so nothing has to be provided; `BrowserBinding.layer(binding)` supplies another to every browser Layer built beneath it. A binding is opaque and
|
|
192
|
+
`browser-binding` names the engine an owned or borrowed browser connects through. The default is Playwright over CDP with the provider's own address, so nothing has to be provided; `BrowserBinding.layer(binding)` supplies another to every browser Layer built beneath it. A binding is opaque and issued by the common runtime, so a value that merely has its shape is refused before anything is allocated.
|
|
339
193
|
|
|
340
194
|
`BrowserBinding.playwright({ resolveEndpoint, onConnected })` is trusted host configuration for running the unmodified Playwright engine somewhere else, such as a local Chromium. `resolveEndpoint` runs only after the provider-issued address passed the default checks, and chooses the CDP endpoint actually connected to; `onConnected` sees the engine's own browser object before the owner drives it. Neither is model-facing, and a local engine reached this way says nothing about how a hosted connection behaves. This package's own native fixtures use exactly this, rather than replacing `chromium.connectOverCDP` globally.
|
|
341
195
|
|
|
@@ -371,6 +225,46 @@ A host that does this still selects `Unrestricted` here, and the containment cla
|
|
|
371
225
|
|
|
372
226
|
What this package does check is input, not requests. `controlFacts` and an `admit` policy ([above](#what-is-on-screen-and-what-a-host-may-know-about-it)) let a host that drives the session refuse a link or a form by its resolved destination before anything is sent. That limits what the host's own automation acts on. It does not limit what a page loads, or where it redirects.
|
|
373
227
|
|
|
228
|
+
## Testing against a scripted control plane
|
|
229
|
+
|
|
230
|
+
`effect-browserbase/testing` supplies the provider side of a test the way `effect-browser/testing` supplies the browser side. `Testing.provider(script)` returns a `fetch` for `FetchHttpClient.Fetch` and a `ProviderControl` handle; the real `Client`, `Sessions`, `Uploads`, allocation and cleanup code parse its replies, apply their bounds and reconcile release against it. The script says how creation answers (`Accept`, `Reject` with a status the transport classifies and an optional `retryAfterMillis`, `Malformed`, or `Lost` after the request was sent, so the allocation outcome is unknown), whether a release request is `confirmed`, stays `pending` or fails, whether a Live View is issued, and whose identity a session's retrieval and release replies carry (`own`, `foreign-session` or `foreign-project`). Its secrets are marker strings, so evidence can be grepped for a leak, and nothing in it is a credential.
|
|
231
|
+
|
|
232
|
+
`Testing.layer({ browser, provider?, launch?, options? })` composes that control plane under the real `Account.layer` and `BrowserbaseBrowser.layer`, with the scripted engine from `effect-browser/testing` as the `BrowserBinding` and its `sequentialCrypto` as the browser's `Crypto`, and adds a `ScriptedBrowserbase` service whose `provider` is the control handle and whose `browsers` are the engine control handles, one per provider session: a keep-alive reconnection or a borrowed attachment reaches the same scripted browser again, with the pages it kept. An application written against `BrowserbaseBrowser.open` runs unchanged:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import { expect, it } from "@effect/vitest";
|
|
236
|
+
import { Effect } from "effect";
|
|
237
|
+
import * as Browser from "effect-browser/browser";
|
|
238
|
+
import { BrowserPolicy } from "effect-browser/browser-data";
|
|
239
|
+
import { BrowserbaseBrowser } from "effect-browserbase/browser";
|
|
240
|
+
import * as Testing from "effect-browserbase/testing";
|
|
241
|
+
|
|
242
|
+
it.effect("release is requested once and confirmed", () =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const scripted = yield* Testing.ScriptedBrowserbase;
|
|
245
|
+
|
|
246
|
+
const receipt = yield* Browser.scoped(
|
|
247
|
+
BrowserbaseBrowser.open(BrowserPolicy.unrestricted()),
|
|
248
|
+
(session) =>
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
yield* session.navigate({ url: "https://shop.test/" });
|
|
251
|
+
|
|
252
|
+
return yield* session.closeChecked;
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(receipt).toMatchObject({ releaseRequested: true, remote: "confirmed", local: "closed" });
|
|
257
|
+
expect(yield* scripted.provider.sessions).toMatchObject([
|
|
258
|
+
{ id: "session-1", status: "COMPLETED", releaseRequests: 1 },
|
|
259
|
+
]);
|
|
260
|
+
}).pipe(Effect.provide(Testing.layer({ browser: shop }))),
|
|
261
|
+
);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Sessions are numbered `session-1`, `session-2`, … in creation order. A `pending` release leaves the session `RUNNING`, so checked close fails `Provider` with outcome `unknown` after the real status bound elapses, and the receipt carries `remote: "pending"`; under `it.effect` a `TestClock.adjust` reaches that bound with nothing real elapsing. A `Lost` creation makes `onAllocationUncertain` fire once, as it does when a real request is cut off. A `foreign-session` or `foreign-project` identity makes connection fail `Malformed` before any page is reached, and no reply about the other session can confirm this one's release, so its receipt carries `remote: "unknown"`. Borrowed attachment through `attach` makes a second connection to the owner's scripted browser, which its control lists under `connections`, and no release request. `Allocation.scoped`, `BrowserbaseSessions` and the other resource services run over the same `fetch` with `Testing.provider()` alone, with no browser at all; `Allocation.scoped` also takes a `Crypto`, and `sequentialCrypto` from `effect-browser/testing` keeps its attempt ids predictable.
|
|
265
|
+
|
|
266
|
+
What a scripted control plane answers is what this repository has read of the provider API, replayed. It establishes that this package handles those answers correctly; it establishes nothing about what Browserbase answers today, which only the hosted checks in [Status](../../docs/STATUS.md) record.
|
|
267
|
+
|
|
374
268
|
## Beyond the browser session
|
|
375
269
|
|
|
376
270
|
These services share the Client and its rules: strict input decoding, identity-checked replies, 1 MiB reply bounds, mutations that are never retried, and typed failures whose `outcome` says whether a request was sent. They are host APIs; none of them is exposed to a model by the adapter package.
|
|
@@ -391,21 +285,19 @@ An agent run or Function invocation that persists a Context writes it from Brows
|
|
|
391
285
|
- `sessions`, `contexts`, `context-coordination` — passive inspection, explicit release, context resources and writer settlement.
|
|
392
286
|
- `extensions` — provisioning a Chrome extension archive once as a durable project resource, and selecting it by reference at launch.
|
|
393
287
|
- `uploads` — placing a file where the running session can already reach it, and the receipt that authorizes attaching it.
|
|
394
|
-
- `
|
|
395
|
-
- `launch`, `references`, `browser-data`, `session-data`, `cleanup`, `transfers`, `errors` — credential-free schemas and typed expected errors.
|
|
288
|
+
- `launch`, `references`, `session-data`, `cleanup`, `transfers`, `errors` — credential-free schemas and typed expected errors.
|
|
396
289
|
- `browser-binding` — the trusted, opaque native engine a browser connects through: Playwright by default, or Playwright routed to a host-resolved endpoint.
|
|
397
290
|
- `browser` — scoped allocation, borrowed attachment to a running session, deterministic page control, real pointer, wheel and key input, host-only tabs/frames/viewport, modeled file selection, Live View handoff, keep-alive detach and explicit reconnect.
|
|
398
|
-
- `capture` — optional target-pinned live-page JPEG frame streams using Playwright 1.63's maintained screencast API. The caller owns encoding, storage and presentation.
|
|
399
|
-
- `page-control` — opt-in host-owned stage holds and explicit receipt-based resume, independent of scout selection.
|
|
400
291
|
- `recordings` — post-session Browserbase MP4 assembly, status and bounded retrieval. Stable identity is session + recording page; signed URLs are refreshed and are not durable identity.
|
|
401
292
|
- `replays` — host-authorized replay metadata and validated HLS media proxy material. It is playback access to a recording, not another recording.
|
|
402
293
|
- `downloads` — ordinary website download metadata, provider-side filters, bounded byte streams and deletion, separate from provider recordings.
|
|
403
294
|
- `projects`, `certificates` — project inspection and usage; proxy CA certificate administration.
|
|
404
295
|
- `search`, `page-fetch`, `agents`, `functions`, `webhooks` — the Browserbase platform APIs outside a browser session.
|
|
296
|
+
- `testing` — a scripted control plane as `fetch`, and the real account and browser Layers composed over it and over the scripted engine from `effect-browser/testing`.
|
|
405
297
|
|
|
406
298
|
Every expected failure says three things. `operation` is what you asked for, from a closed vocabulary per error class: `BrowserError` names the browser operations, `SessionError` only session calls, and so on, so you can match on them exhaustively and a misspelling is a type error, not a string that happens to compile. `reason` is why it failed. `outcome`, when present, is whether the work was sent: `undispatched` is safe to retry, `rejected` was refused, and `unknown` means a mutation may have happened and is never replayed for you. Why an extension archive was refused is a `reason` (`limit`, `unsafe-filename`, `configuration`) of the one `extension-archive` operation. A native step's own name never appears: the driver raises a private failure, and the owner stamps the operation it admitted.
|
|
407
299
|
|
|
408
|
-
Everything under `src/internal/` is private,
|
|
300
|
+
Everything under `src/internal/` is private; the common runtime offers a modeled integration constructor, while no consumer CDP seam is exported; `browser-binding` chooses the engine and where it connects, never what runs over the connection. The driver does hold a CDP session; exposing it, or the ownership internals, would place actions outside the mutation permit that serializes them and outside the fencing that makes an uncertain outcome detectable. Opening a second debugger connection beside this one has the same effect and is equally unsupported. An unmodeled need is a request for a modeled entry point, not a reason to reach around the boundary.
|
|
409
301
|
|
|
410
302
|
## Artifact and capture guarantees
|
|
411
303
|
|
|
@@ -415,101 +307,8 @@ Website downloads retain their provider download ID, safe filename, MIME type an
|
|
|
415
307
|
|
|
416
308
|
Replay playlists reject arbitrary URI-bearing tags and proxy only indexed media from the validated playlist. API credentials are never returned to a browser client.
|
|
417
309
|
|
|
418
|
-
Live
|
|
419
|
-
|
|
420
|
-
`sourceTimeMillis` is the browser's wall clock when it took the frame for the screencast, stamped before the frame is encoded. Chromium encodes up to three frames at once and emits each when its encode completes, so two frames stamped close together can arrive in either order. A frame that arrives behind a newer one can no longer be presented in order: it is discarded and counted in `late`, which is part of `dropped`. It is never sorted back in or given another time, so delivered source times strictly increase and a gap in `sequence` marks the omission. Concurrent encoding can put at most two late frames in a row. A longer run means source time itself went backwards, and the interval ends with reason `timestamp`.
|
|
421
|
-
|
|
422
|
-
Closing, navigating, detaching a relevant frame, or resizing the captured page ends its interval explicitly without ending a sibling page's capture. `Capture.start(session, { lifetime: "page" })` instead follows a page's main frame across documents: start it before a navigation and it covers the loading in between. The native screencast is never restarted for a navigation, so a boundary is not a gap this package introduced. Each frame carries the `document` it was received during (0, then one more per navigation), and the summary's bounded `documentBoundaries` give the last sequence before each one and the address it committed, with `initialUrl` for document 0. That is attribution by receipt order, not proof of whose pixels a frame shows: one received just after a navigation can still show the document before it. Selecting another page or frame does not invalidate an unrelated interval. Handoff pause, connection loss, an uncertain owner and session closure still invalidate all child intervals. A confirmed native stop releases only its own reservation; a failed stop on a live page keeps that target quarantined. A definitively closed page releases its capture reservation. Stopping a child capture does not close its browser. The frame seam has **no website-audio source**, so this package does not synthesize silent samples or infer audio support from a video container. Caller encoding is demonstrated in `examples/record-video.ts`; the example decodes every generated frame with the caller's FFmpeg and checks presentation timestamps and pixel checksums. Native acceptance requires changing pixels and source-time agreement rather than accepting container headers as video evidence. Filming across a navigation with one page-lifetime interval, resampled onto a constant-rate reel with the address of each document reported, is demonstrated in `examples/realistic-footage`.
|
|
423
|
-
|
|
424
|
-
### Read metadata while capture is running
|
|
425
|
-
|
|
426
|
-
```ts
|
|
427
|
-
const interval = yield * Capture.start(session, { lifetime: "page" });
|
|
428
|
-
const current = yield * interval.snapshot;
|
|
429
|
-
// current.phase is "capturing"; initialUrl and recorded documentBoundaries are available now.
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
`snapshot` copies the bounded metadata already recorded in host memory. It does
|
|
433
|
-
no browser work, consumes no frames, charges no action, and works while the page
|
|
434
|
-
is held. Repeated reads neither stop nor restart capture. `observedMonotonicNanos`
|
|
435
|
-
stamps the read; each boundary retains its own commit observation time. The
|
|
436
|
-
prefix holds at most 64 boundaries; `documentBoundariesTruncated` reports overflow
|
|
437
|
-
and `currentDocument` continues counting. Addresses longer than the existing
|
|
438
|
-
bound remain `null`. The model should not receive these host-only addresses by
|
|
439
|
-
accident merely because it can inspect the page.
|
|
440
|
-
|
|
441
|
-
While capturing, `reason` and `nativeStop` are `null`. `phase: "stopping"` means
|
|
442
|
-
capture has stopped accepting frames but native cleanup is pending. `"stopped"`
|
|
443
|
-
means that cleanup attempt settled; only `nativeStop: "confirmed"` confirms the
|
|
444
|
-
native stop. All snapshots are observations of accounting so far: buffered frames
|
|
445
|
-
can still be drained after stop. For final accounting, stop or await capture
|
|
446
|
-
termination, finish draining `frames`, then read `completed` to obtain the terminal
|
|
447
|
-
summary with final delivery counts. Preserve the terminal error and loss counters
|
|
448
|
-
when reconciling earlier snapshots. `late` remains included in `dropped`, and
|
|
449
|
-
`upstreamDrops` remains `"unknown"`; live metadata adds no stronger pixel or loss
|
|
450
|
-
guarantee.
|
|
451
|
-
|
|
452
|
-
### What a compositor is given, and what it owns
|
|
453
|
-
|
|
454
|
-
Footage from `capture` is the page surface. It has no pointer, no tab strip and no address bar, so on its own it reads as the inside of a tab rather than as a browser. Drawing those is the application's, exactly as cursor artwork, easing and encoding are: this package has no window-compositing API and will not grow one. What it owes a compositor is the evidence only it can see, on one timeline:
|
|
455
|
-
|
|
456
|
-
- each frame's `receivedMonotonicNanos`, and the `document` it was received during;
|
|
457
|
-
- an `InputReceipt` for every native pointer and key operation, with the position commanded and an interval on that same clock;
|
|
458
|
-
- an address for every document a frame can name: `initialUrl` for document 0, read in the same turn the watch is installed, and a `url` on each boundary, read inside the navigation event that committed it. An application that samples `observe()` between actions can learn an address, but never when it became the address. One longer than 8192 characters is `null`, never cut into an address the page did not show.
|
|
459
|
-
|
|
460
|
-
A boundary is the commit, and it is the only moment of a navigation an application cannot see for itself. When the navigation started and when its document finished loading are yours to stamp around the operation that caused it, because you made the call: `startNavigation` returns at dispatch and its `completed` resolves at DOMContentLoaded. The clock is Effect's `Clock`, read where the session was opened and the capture was started, so `Clock.clockWith((clock) => clock.monotonicTimeNanos)` in the same runtime is on the same timeline as every frame and receipt. A title is page state that changes whenever the page likes, not part of a transition: read it with `session.pages` when you need one, and stamp that read yourself.
|
|
461
|
-
|
|
462
|
-
The two clocks are never related for you. `sourceTimeMillis` is the browser's wall clock; everything above is the host's monotonic clock; on a hosted session they differ by an offset this package cannot observe. Every frame it receives is already late by the very capture latency it would be trying to measure, and nothing else it is handed carries the browser's time. Relating them takes a round trip into the page, which costs either a charged action or a registered binding, on a schedule only the application can choose, and it fails on a held page. So place input on frames by receipt time, which is always available and late by the capture latency, or measure the offset yourself with a four-timestamp exchange over a typed binding, as NTP does: the page stamps when it called and when the reply arrived, the host stamps when it received and when it replied, and half the best round trip is the error bound to report beside the offset.
|
|
463
|
-
|
|
464
|
-
## Explicit stage-page holds (opt-in)
|
|
465
|
-
|
|
466
|
-
Set `pageControl: true` on `BrowserbaseBrowser.layer` to use the host-only `page-control` module. The default remains off. `PageControl.suspend(session, page)` returns a live `PageSuspension`; `PageControl.resume(session, receipt)` consumes that exact receipt. Use a `PageInfo` from `session.pages`. Selection can move to the scout without invalidating the receipt, but connection loss, external target invalidation, completed resume, or another session does invalidate it. Holding or resuming a page may run its `freeze` and `resume` handlers, so nothing observed on _that_ page may be acted on unchecked afterwards: a reference fails `stale` until `session.revalidateElement(reference)` confirms it is still attached and still the control that was inspected. That check sends nothing, never searches for a substitute, and refuses a replaced, detached or changed node. An observation of another page is untouched, so an agent keeps driving the scout while the stage is held. `PageControl.state` reports the last acknowledged local state, not proof about a lost remote connection.
|
|
467
|
-
|
|
468
|
-
This opt-in uses maintained CDP attachment with `noDefaults: true` and owner-controlled per-page focus emulation. It intentionally does not support `keepAlive`, reattachment, human handoff, or popup/dialog `pause` policies. These combinations fail before provider allocation; use the existing `retain`/`close` popup policies and `dismiss` dialog policy. Resume explicitly activates the native page without changing SDK selection. Do not enable it where another native client owns focus. Modeled input, DOM reads, waits and viewport changes on held/unknown pages fail before dispatch; the scout remains operable. Page close and session close remain available. Capture does not thaw a held page; frame consumption and acknowledgements never suspend/resume it implicitly.
|
|
469
|
-
|
|
470
|
-
The native tests cover page timers, RAF and CSS animation, scout progress with both pages captured, an already-paused animation, and restoration of a non-default rate. Resume waits for one bounded real RAF in an isolated world at rate zero before restoring that rate, avoiding Blink's stale pre-hold animation clock. In-flight callbacks are not undone. Date/wall time, network, media/audio, workers/service workers, and external/provider actions are not promised frozen. This is presentation control, not a security boundary or browser virtual time.
|
|
471
|
-
|
|
472
|
-
Partial native failures fence the session as uncertain; there is no success receipt, automatic rollback or retry. Scope cleanup never sends a hidden resume: it closes the owned session/connection. Chromium may reset animation state on CDP detachment, so a previous hold acknowledgement does not guarantee remote clocks remain held after connection loss. Hosted-provider equivalence has not been tested.
|
|
310
|
+
Live frames and page holds follow the common [capture guarantees](../browser/README.md#live-capture-and-presentation). The provider's recording/replay services retain their independent lifetime after the browser scope ends. A downstream encoder consumes public frame streams; it does not own another browser.
|
|
473
311
|
|
|
474
312
|
## Development and evidence
|
|
475
313
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
`tools/packed-consumer.sh` installs the emitted npm tarballs into separate consumers with exact public dependency versions, checks NodeNext declarations, and runs the unchanged native suites without workspace aliases. It also runs real programs directly on Node and Bun: `test/consumer/resources.ts` with no Playwright and no framework installed, and `test/consumer/native.ts` against a real local Chromium.
|
|
479
|
-
|
|
480
|
-
## Independent local Chromium
|
|
481
|
-
|
|
482
|
-
`effect-browserbase/local-browser` provides `LocalBrowser`. It uses the same modeled browser owner, native driver, exact-node observations, bootstrap bindings, capture and page control as a hosted session. It requires no Browserbase client, project, key, allocation response or provider endpoint. `BrowserSession<E>` is their shared modeled capability; `BrowserbaseSession<E>` retains the separate provider reference, artifacts, Live View, handoff and release contracts.
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
import { Effect } from "effect";
|
|
486
|
-
import { BrowserPolicy, NavigateRequest } from "effect-browserbase/browser-data";
|
|
487
|
-
import { LocalBrowser } from "effect-browserbase/local-browser";
|
|
488
|
-
|
|
489
|
-
const program = Effect.scoped(
|
|
490
|
-
Effect.gen(function* () {
|
|
491
|
-
const browser = yield* LocalBrowser;
|
|
492
|
-
const session = yield* browser.open(BrowserPolicy.unrestricted());
|
|
493
|
-
yield* session.bind().navigate(NavigateRequest.make({ url: "https://example.com" }));
|
|
494
|
-
return yield* session.observe({ scope: "viewport" });
|
|
495
|
-
}),
|
|
496
|
-
).pipe(
|
|
497
|
-
Effect.provide(
|
|
498
|
-
LocalBrowser.layer({
|
|
499
|
-
viewport: { width: 1280, height: 720 },
|
|
500
|
-
pageControl: true,
|
|
501
|
-
launch: { headless: true, chromiumSandbox: true },
|
|
502
|
-
}),
|
|
503
|
-
),
|
|
504
|
-
);
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
Layer construction validates configuration and starts nothing. The optional `playwright-core` peer is loaded only when needed. `acquire(policy, { bootstrap })` starts one owned Chromium process and registers cleanup before waiting for a connection; its cached `connect` yields one `LocalSession<E>`. `open` combines those steps. Bootstrap consumer errors and services remain in the acquisition signatures, just as for hosted sessions. `withBrowser(policy, request, use)` supervises callback failure and checks local cleanup before returning a normal result.
|
|
508
|
-
|
|
509
|
-
Owned launch currently supports POSIX hosts (Linux and macOS). It uses a fresh temporary profile, an ephemeral loopback debugger port and one maintained CDP connection. The launcher owns those arguments; callers cannot replace them through `args`. `executablePath` selects an installed Chromium explicitly; otherwise the pinned Playwright executable is used. Headless mode and Chromium sandboxing default to enabled. `chromiumSandbox: false` is an explicit host exception, never inferred from `CI`, root execution or a connection failure. Setting the option alone does not prove the operating system's sandbox configuration. Startup waiting is bounded by `startupTimeoutMillis` (15 seconds by default, at most 60 seconds) and the owner's remaining lifetime. Acquisition does not retry a failed launch.
|
|
510
|
-
|
|
511
|
-
For an externally owned Chromium, use `browser.attach(endpoint, { policy, target?, bootstrap? })`. `endpoint` is a `Redacted<string>` containing the exact `ws://127.0.0.1:PORT/devtools/browser/ID` or IPv6-loopback equivalent advertised by that browser. HTTP discovery URLs, non-loopback hosts, credentials, queries and fragments are refused. This validates the control endpoint's shape; it does not authenticate the host running it. A supplied `target.targetId` must identify an existing page. Without one, several candidate pages are an explicit ambiguity error. Attachment preserves the existing viewport and does not replay the layer's launch arguments or create a replacement browser. Coordinate any independent controllers yourself, especially when enabling page control.
|
|
512
|
-
|
|
513
|
-
Local identity is `{ provider: "local", id }`, identifying this ownership lifetime. It is not a Browserbase session reference, a PID or an attachment credential. `close` and `cleanupResult` retain `connection` and `process` facts separately. Owned cleanup fences operations, stops capture, disposes initialization, disconnects and terminates its process group; only observed termination permits removing the temporary profile. Failure or timeout remains in `issues` and leaves `process: "unknown"` when exit was not established. Borrowed cleanup disconnects its own client and reports `process: "not-owned"`; it never terminates the external process. Repeated close calls share one result. No local result has a provider `remote: "confirmed"` field. `onCleanup` receives these bounded, host-only facts even when connection setup fails after launch.
|
|
514
|
-
|
|
515
|
-
`launch.proxy: { server, bypass? }` forwards an existing host-operated proxy to Chromium. With a proxy, the default bypass value is `<-loopback>` so Chromium does not silently exclude loopback destinations; a different bypass is an explicit host choice. Additional reviewed native flags, such as disabling QUIC and non-proxied WebRTC UDP, can be supplied through `launch.args`. This module does not implement a proxy or qualify its transport/DNS coverage. Browser policy remains `Unrestricted`; a local endpoint, URL admission or successful local test never establishes whole-browser egress containment. Preserve and test the selected enforcing proxy independently. Hosted endpoint validation and network policy are unchanged.
|
|
314
|
+
Read [Contributing](../../CONTRIBUTING.md) for the frozen compatibility workspace and required checks. Provider unit tests cover HTTP/resources and release reconciliation; `test/testing.test.ts` runs the public scripted control plane and Layer through the real allocation and cleanup code. Provider regressions that need a browser, such as release reconciliation around a held teardown or a keep-alive reconnection, run over the same public entries in this package's `test/`. The `generic` and `agent-hosted` installed consumers run the maintained native suites against real Chromium with scripted provider HTTP; the `resources` consumer installs no Playwright or Effect Agent, and it also opens scripted browsers through both testing entry points from the packed tarballs. Hosted evidence remains separately recorded in [Status](../../docs/STATUS.md) and never inferred from a local pass.
|