effect-browser 0.0.0-reserved.0 → 0.2.0-beta.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/LICENSE +21 -0
- package/README.md +664 -3
- package/dist/Association-zlgInoaj.mjs +134 -0
- package/dist/Association-zlgInoaj.mjs.map +1 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +183 -0
- package/dist/Bootstrap-Be-2Ddmh.mjs +187 -0
- package/dist/Bootstrap-Be-2Ddmh.mjs.map +1 -0
- package/dist/Bootstrap.d.mts +3 -0
- package/dist/Bootstrap.mjs +2 -0
- package/dist/Browser.d.mts +177 -0
- package/dist/Browser.mjs +14 -0
- package/dist/Browser.mjs.map +1 -0
- package/dist/BrowserData-DJAnUHoZ.d.mts +412 -0
- package/dist/BrowserData.d.mts +3 -0
- package/dist/BrowserData.mjs +494 -0
- package/dist/BrowserData.mjs.map +1 -0
- package/dist/BrowserRuntime-fZz1b0VI.mjs +5169 -0
- package/dist/BrowserRuntime-fZz1b0VI.mjs.map +1 -0
- package/dist/BrowserRuntime.d.mts +139 -0
- package/dist/BrowserRuntime.mjs +2 -0
- package/dist/Capture.d.mts +40 -0
- package/dist/Capture.mjs +476 -0
- package/dist/Capture.mjs.map +1 -0
- package/dist/CaptureData-AmqVP_Ba.mjs +162 -0
- package/dist/CaptureData-AmqVP_Ba.mjs.map +1 -0
- package/dist/CaptureData.d.mts +148 -0
- package/dist/CaptureData.mjs +2 -0
- package/dist/Chromium.d.mts +84 -0
- package/dist/Chromium.mjs +381 -0
- package/dist/Chromium.mjs.map +1 -0
- package/dist/Errors.d.mts +106 -0
- package/dist/Errors.mjs +155 -0
- package/dist/Errors.mjs.map +1 -0
- package/dist/PageControl.d.mts +15 -0
- package/dist/PageControl.mjs +28 -0
- package/dist/PageControl.mjs.map +1 -0
- package/dist/PageControlAssociation-DabEg5AR.mjs +10 -0
- package/dist/PageControlAssociation-DabEg5AR.mjs.map +1 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +9 -0
- package/package.json +67 -5
package/README.md
CHANGED
|
@@ -1,5 +1,666 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Browser automation for Effect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`effect-browser` owns one scoped browser runtime: bounded actions, exact observations, typed page-to-host bindings, live JPEG capture and explicit page holds. `effect-browser/chromium` launches or attaches to self-managed Chromium. [Browserbase](../browserbase/README.md) supplies the same runtime through its own hosted acquisition and cleanup.
|
|
4
4
|
|
|
5
|
-
This package
|
|
5
|
+
This package has no Browserbase or Effect Agent dependency. Its common entry points do not load Chromium process-management code. Playwright is an optional peer loaded when a browser connects; install the pinned `playwright-core@1.63.0` to use that capability. The implementation targets trusted Node and Bun hosts. Data contracts being provider-independent do not imply browser-client or edge-runtime support.
|
|
6
|
+
|
|
7
|
+
## One owner and two ways to supply it
|
|
8
|
+
|
|
9
|
+
A `BrowserSession<E>` is the live, host-only capability returned by the supplying implementation. It preserves callback failures and diagnostics of type `E`, one action budget, one native connection and one set of capture/page-control reservations. `implementation` identifies the control implementation. `closeChecked` performs the owner's cleanup and fails if its required cleanup was not confirmed. On concrete Chromium and Browserbase sessions it returns that same frozen cleanup receipt on success; the generic session permits discarding that value. Helpers that only use operations take `AnySession`, an alias for `BrowserSession<unknown>`. Helpers that supervise callback failures must stay generic in `E` or the concrete session so they retain those failures.
|
|
10
|
+
|
|
11
|
+
All callers use this exact session. `Capture.start`, `Capture.stream` and `PageControl` authenticate its identity privately; spreading or decoding an object cannot copy authority. A session or binding absent from the receiving runtime's private registry fails with reason `UnregisteredSession` and outcome `undispatched`. A copy, fabricated value or separately loaded runtime can cause that refusal; it does not establish which occurred. A registered session with page control disabled still fails `Unsupported`. `Tools.run` from `effect-agent-browser/tools` uses the original session directly; `yield* Adapter.fromSession(session, { selection: "current" | "retained" })` adapts it to the framework's handle with an explicit target policy. Neither opens another browser.
|
|
12
|
+
|
|
13
|
+
Mutations are serialized. An observed node remains usable only until an invalidating event; a replaced node is never searched for again. A timed-out or interrupted mutation after dispatch has an unknown outcome and is never automatically replayed. Unresolved control fences the owner; a main-frame loading timeout can instead retire control through one acknowledged stop, as described below. `undispatched`, `rejected` and `unknown` remain distinct expected outcomes.
|
|
14
|
+
|
|
15
|
+
## Self-managed Chromium
|
|
16
|
+
|
|
17
|
+
`effect-browser/chromium` provides `Chromium`. 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.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Effect } from "effect";
|
|
21
|
+
import * as Browser from "effect-browser/browser";
|
|
22
|
+
import { BrowserPolicy } from "effect-browser/browser-data";
|
|
23
|
+
import { Chromium } from "effect-browser/chromium";
|
|
24
|
+
|
|
25
|
+
const program = Browser.scoped(Chromium.launch(BrowserPolicy.unrestricted()), (browser) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
yield* browser.navigate({ url: "https://example.com" });
|
|
28
|
+
return yield* browser.observe({ scope: "viewport" });
|
|
29
|
+
}),
|
|
30
|
+
).pipe(
|
|
31
|
+
Effect.provide(
|
|
32
|
+
Chromium.layer({
|
|
33
|
+
viewport: { width: 1280, height: 720 },
|
|
34
|
+
pageControl: true,
|
|
35
|
+
launch: { headless: true, chromiumSandbox: true },
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Layer construction validates configuration and starts nothing. The optional `playwright-core` peer is loaded only when needed. `Chromium.acquire(policy, { bootstrap })` starts one owned Chromium process and registers cleanup before waiting for a connection; its cached `connect` yields one `ChromiumSession<E>`. `Chromium.launch` combines those steps. These static operations access the configured service, as do `Chromium.attach` and the corresponding `BrowserbaseBrowser` operations. Bootstrap consumer errors and services remain in the acquisition signatures.
|
|
42
|
+
|
|
43
|
+
`Browser.scoped(open, use)` is the common workflow supervisor for both sources, including borrowed attachment. It runs the acquisition once in its own scope, retains the concrete browser type in `use`, and races workflow completion against typed fail-session callbacks. The workflow has its own child scope: fibers and finalizers finish before `closeChecked` runs, so callback cleanup may still use a healthy browser. Checked closure runs on success, failure, thrown defects and cancellation. A body failure and a cleanup failure both remain in this workflow's own final Effect cause; neither is overwritten. An outer race or timeout can select another result and discard that losing cause even though cleanup ran. Configure the provider's `onCleanup` to record its receipt in a host-owned sink outside the raced workflow when cleanup evidence must survive that composition. A body that completes concurrently with a fail-session callback can still win the race; supervision does not establish failure priority. The ownership finalizer still runs if acquisition itself fails. No failed acquisition or action is replayed.
|
|
44
|
+
|
|
45
|
+
The same combinator supports `open.pipe(Browser.scoped(use))`, including a reusable `const use = Browser.scoped((browser) => browser.observe())` stored before choosing the provider. That unannotated callback sees common browser operations; use an annotated callback or the data-first form for provider-specific members or typed binding diagnostics. The returned function infers the supplying session's callback error independently of its acquisition error. Explicit generic applications must follow the curried overload's revised parameter lists: four outer parameters (`S, A, E2, R2`) and three returned parameters (`E, AE, AR`). It removes the scopes it owns while preserving other required services and error types. Returning a browser, stream or other live capability from `use` does not extend that resource's lifetime. The old provider-specific `withBrowser` methods and `BrowserRuntime.withBrowser` are replaced by this one public operation.
|
|
46
|
+
|
|
47
|
+
### A long-lived session in a Layer
|
|
48
|
+
|
|
49
|
+
An application can share one acquired session through a Layer for its finite application scope:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { Context, Effect, Layer } from "effect";
|
|
53
|
+
import { Chromium, type ChromiumSession } from "effect-browser/chromium";
|
|
54
|
+
import { BrowserPolicy } from "effect-browser/browser-data";
|
|
55
|
+
|
|
56
|
+
class SharedBrowser extends Context.Service<SharedBrowser, ChromiumSession>()(
|
|
57
|
+
"app/SharedBrowser",
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
const shared = Layer.effect(
|
|
61
|
+
SharedBrowser,
|
|
62
|
+
Chromium.launch(BrowserPolicy.unrestricted({ maxElapsedMillis: 300_000 })),
|
|
63
|
+
).pipe(Layer.provide(Chromium.layer({ onCleanup: recordReceipt })));
|
|
64
|
+
|
|
65
|
+
const program = Effect.gen(function* () {
|
|
66
|
+
const browser = yield* SharedBrowser;
|
|
67
|
+
return yield* browser.observe({ scope: "viewport" });
|
|
68
|
+
}).pipe(Effect.provide(shared));
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Here `recordReceipt` is the host's bounded receipt sink. Consumers of the same Layer build share
|
|
72
|
+
selection, budgets, native admission and lifetime; the Layer does not reset an expired session or
|
|
73
|
+
make concurrent operations independent. Its acquisition finalizer performs unchecked release and
|
|
74
|
+
stores the receipt. It does not supervise `browser.failure`, and does not turn incomplete cleanup
|
|
75
|
+
into a checked-close error for every consumer. The application must choose that supervision and
|
|
76
|
+
explicit checked closure, or use `Browser.scoped` for a bounded workflow that owns those decisions.
|
|
77
|
+
Calling `Browser.scoped` on the shared live session would close it for every consumer, so it is not
|
|
78
|
+
a per-request wrapper for this pattern.
|
|
79
|
+
|
|
80
|
+
### Receipts outside a race
|
|
81
|
+
|
|
82
|
+
An outer timeout may return `None` while discarding the losing workflow's checked-close error.
|
|
83
|
+
Place the receipt sink outside that race when those facts must remain observable:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
let receipt: ChromiumCleanupResult | undefined;
|
|
87
|
+
const result =
|
|
88
|
+
yield *
|
|
89
|
+
Browser.scoped(Chromium.launch(policy), (browser) => browser.navigate({ url })).pipe(
|
|
90
|
+
Effect.timeoutOption("30 seconds"),
|
|
91
|
+
Effect.provide(
|
|
92
|
+
Chromium.layer({
|
|
93
|
+
onCleanup: (value) =>
|
|
94
|
+
Effect.sync(() => {
|
|
95
|
+
receipt = value;
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
// result can be None; receipt still records the completed cleanup attempt's actual facts.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The sink above retains one host-only receipt; it is not durable storage. A direct path can return
|
|
104
|
+
`yield* browser.closeChecked` from the callback to obtain the concrete receipt, with typed failure
|
|
105
|
+
when the owner's required cleanup was unconfirmed. Repeated checked closure and scope finalization
|
|
106
|
+
reuse the same cleanup attempt. Body failure and direct or explicitly awaited interruption retain
|
|
107
|
+
checked-close errors in that workflow's own cause; a racing parent chooses which result survives.
|
|
108
|
+
The [complete compiled example](examples/shared-session.ts) shows all three compositions.
|
|
109
|
+
|
|
110
|
+
Cleanup notification is attempted once after canonical evidence has been stored. Callback
|
|
111
|
+
construction throws, defects, self-interruption and a two-second cooperative timeout are contained.
|
|
112
|
+
Browserbase's built-in diagnostic reporter and the optional `onCleanup` sink are contained
|
|
113
|
+
independently, so a broken reporter cannot suppress the sink. A failed sink does not confirm
|
|
114
|
+
cleanup, alter the stored receipt, or retry teardown. `cleanupResult` remains the canonical local
|
|
115
|
+
record. Notification timeout relies on cooperative interruption and cannot preempt synchronous
|
|
116
|
+
host code that never yields. Receipt storage, Context-writer settlement and `closeChecked` validation
|
|
117
|
+
are outside this optional-notification boundary.
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
Chromium identity is `{ provider: "chromium", 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.
|
|
124
|
+
|
|
125
|
+
`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. The Browserbase integration validates its own provider-issued endpoints separately.
|
|
126
|
+
|
|
127
|
+
## Passive status and native diagnostics
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const status = yield * session.status;
|
|
131
|
+
const diagnostics = yield * session.diagnostics;
|
|
132
|
+
// status: { phase, reason, generation, busy, unresolvedDispatch }
|
|
133
|
+
// diagnostics: { records, total, dropped, truncated }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Both reads copy host memory, acquire no browser permit, charge no action and remain available
|
|
137
|
+
while busy, faulted or closed. A snapshot is not an admission token: the next operation still
|
|
138
|
+
checks its ticket. `phase` describes admission/lifecycle, not remote cleanup confirmation. Its
|
|
139
|
+
closed vocabulary includes `faulted` for a known terminal trigger, alongside `acquiring`, `open`,
|
|
140
|
+
`paused`, `detached`, `uncertain`, `closing` and `closed`; exhaustive host matches must include the
|
|
141
|
+
new case. `reason` retains the original terminal trigger through later cleanup. `busy` reports
|
|
142
|
+
active admission or pending policy cleanup. Action-count or host-read exhaustion leaves the owner
|
|
143
|
+
open and is not a terminal failure.
|
|
144
|
+
|
|
145
|
+
`unresolvedDispatch` is separate from the trigger. Idle expiry records `expired` without inventing
|
|
146
|
+
an unknown dispatch. Expiry or a fail-session callback overlapping native work retains both the
|
|
147
|
+
known trigger and unresolved control. A fence or borrowed disconnection is not acknowledgement
|
|
148
|
+
that the browser stopped. Positive owned termination evidence can retire that control indication;
|
|
149
|
+
it does not reconcile earlier business effects or change their `unknown` outcomes. The concrete
|
|
150
|
+
cleanup receipt remains the authority for process termination or provider release confirmation.
|
|
151
|
+
|
|
152
|
+
Diagnostics retain the latest 32 records, oldest first, with a closed reason, disposition,
|
|
153
|
+
connection generation and host monotonic timestamp. Counters saturate at `Number.MAX_SAFE_INTEGER`;
|
|
154
|
+
`truncated` and `dropped` disclose eviction. Records contain no URL, title, message, native object
|
|
155
|
+
or consumer cause. Typed callback errors remain in `failure` and `bindingDiagnostics`.
|
|
156
|
+
|
|
157
|
+
Under popup-close policy, a popup beyond `maxPages` is quarantined and closed once. Its originating
|
|
158
|
+
click can finish, while new browser work is refused until cleanup is confirmed. Confirmed closure
|
|
159
|
+
preserves the original page's usability. The native cleanup pool is bounded at 32 operations;
|
|
160
|
+
the two-second acknowledgement deadline does not free an unresolved native promise's slot.
|
|
161
|
+
Lost acknowledgement fences control without replay; a late acknowledgement cannot reopen it.
|
|
162
|
+
Capacity refusal before dispatch is a known block rather than fabricated uncertainty. Dialog-cap
|
|
163
|
+
overflow follows the same bounded dismissal rules. An acknowledged before-unload dismissal can
|
|
164
|
+
retire only the exact navigation captured when its dialog arrived and subsequently rejected.
|
|
165
|
+
Existing popup/dialog pause policies still require their explicit host recovery path.
|
|
166
|
+
|
|
167
|
+
## Browser operations
|
|
168
|
+
|
|
169
|
+
### Selected, retained and pinned targets
|
|
170
|
+
|
|
171
|
+
The session itself is the convenient selected-target API. Its `navigate`, `readText`, `click`,
|
|
172
|
+
`fill`, pointer/key input and screenshot Effects resolve the selected page/frame when the Effect
|
|
173
|
+
actually executes. Constructing an Effect does not freeze the current selection:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const navigateScout = session.navigate({ url: scoutUrl });
|
|
177
|
+
yield * session.selectPage(scoutInfo);
|
|
178
|
+
yield * navigateScout; // navigates the scout selected above
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`yield* session.retain` is the deliberate retained-selection form. Acquisition resolves and
|
|
182
|
+
validates selection under the owner's permit, then remembers its generation and selection revision.
|
|
183
|
+
Selecting another page or frame, including moving away and back, makes that handle fail
|
|
184
|
+
`Stale/undispatched`. The shared operation interface is `TargetOperations`; the session, retained
|
|
185
|
+
view and pinned view choose their target at different times. The old `bind()` and `currentTarget`
|
|
186
|
+
members have been removed; `session.target` remains a checked metadata read.
|
|
187
|
+
|
|
188
|
+
| Previous API | Current API |
|
|
189
|
+
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
190
|
+
| `bind()` / `currentTarget`, `BoundTarget` | `retain`, `RetainedTarget` and shared `TargetOperations`; direct operations remain on the session |
|
|
191
|
+
| `selectPage(id)` / `closePage(id)`, string returned by `createPage` | Checked `PageInfo` in and out; `selectPage` and `selectFrame` return `void` |
|
|
192
|
+
| String `BrowserError.reason`, optional outcome | Tagged reason, `catchReason`/`catchReasons`, and required dispatch outcome |
|
|
193
|
+
| Concrete `closeChecked` returns `undefined` | The canonical frozen receipt after the existing ownership check passes |
|
|
194
|
+
| Capture `dropped` | `discarded` and disjoint `overflow`, `late`, `duplicates`, `rejected` |
|
|
195
|
+
| Default-never `BrowserSession` on a non-supervising helper | `AnySession`; supervisors preserve their actual `E` or concrete session |
|
|
196
|
+
|
|
197
|
+
The [root migration table](../../README.md#api-migration) also covers the adapter's required
|
|
198
|
+
selection option, compact tool reasons/host diagnostics, binding defaults, common navigation
|
|
199
|
+
deadline, optional control state and the explicit C1 generic-arity edge.
|
|
200
|
+
|
|
201
|
+
`createPage` returns `PageInfo` for the exact created entry and does not select it. Both
|
|
202
|
+
`selectPage(pageInfo)` and `closePage(pageInfo)` verify its local and native identities under
|
|
203
|
+
owner admission. Page/frame selection returns `void`; request `retain` separately when a retained
|
|
204
|
+
sequence is intended. Failed metadata acquisition after creation keeps the creation's dispatch
|
|
205
|
+
evidence and never creates a second page.
|
|
206
|
+
|
|
207
|
+
For work that must stay on a page while selection moves elsewhere, pin it explicitly:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const stageInfo = (yield * session.pages).find((page) => page.title === "Stage")!;
|
|
211
|
+
const stage = yield * session.pinPage(stageInfo);
|
|
212
|
+
const childInfo = (yield * session.framesOf(stageInfo)).find(
|
|
213
|
+
(frame) => frame.parentFrameId !== null,
|
|
214
|
+
)!;
|
|
215
|
+
const child = yield * session.pinFrame(stageInfo, childInfo);
|
|
216
|
+
|
|
217
|
+
yield * session.selectPage(scoutInfo);
|
|
218
|
+
yield * stage.click({ selector: "#advance" });
|
|
219
|
+
const childText = yield * child.readText({ selector: "#status" });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Pinning never changes global selection and opens no second browser connection. A pinned handle
|
|
223
|
+
contains only modeled operations plus its immutable `Target`; every operation re-resolves the
|
|
224
|
+
connection-local page/frame identity through the same owner. Reconnect makes the old generation
|
|
225
|
+
stale, closing its page or detaching its frame makes it unusable, and an in-flight navigation
|
|
226
|
+
reservation or page hold is checked on the pinned page rather than whichever page happens to be
|
|
227
|
+
selected.
|
|
228
|
+
|
|
229
|
+
Page and frame IDs are opaque and namespaced per connection. Old `PageInfo`, IDs and live handles
|
|
230
|
+
are invalid after reconnect. In the same known browser lifetime, read fresh `pages`, match exactly
|
|
231
|
+
one saved `targetId`, and use that fresh page record; zero matches means gone and multiple matches
|
|
232
|
+
mean ambiguous. Never fall back to order, local serial, URL or title. Then reacquire frames with
|
|
233
|
+
`framesOf(freshPage)`. A surviving native target can identify the same page, but cannot make old
|
|
234
|
+
local metadata or handles current again.
|
|
235
|
+
|
|
236
|
+
Pinned operations are selector-based. They intentionally do not create another retained
|
|
237
|
+
`Observation` or an exact-node namespace. There is still one observation: a new `observe` replaces
|
|
238
|
+
it. A pinned read or mutation on another page leaves that observation intact, as does selecting
|
|
239
|
+
away and back without observing another document. Exact-node work refuses `Stale/undispatched`
|
|
240
|
+
while another page or frame is selected; it never redirects the reference to that selection.
|
|
241
|
+
Returning to the original page/frame can use the original reference only if its document and
|
|
242
|
+
exact control are still valid. This does not revive a retained-selection handle: `retain` still
|
|
243
|
+
becomes stale after every selection change, including away and back.
|
|
244
|
+
|
|
245
|
+
Dispatched input on the observation's own page still retires its references, including `hover`,
|
|
246
|
+
`pointerMove`, `wheel` and `scroll`. Hover then click therefore deliberately needs a new inspection.
|
|
247
|
+
Page scripts can replace nodes or change relevant state in response to any input; no verb is
|
|
248
|
+
assumed harmless. Navigation or closure on that page and reconnect also retire its observation,
|
|
249
|
+
even while another page is selected. IDs are opaque and connection-specific; consume the actual
|
|
250
|
+
inspection result rather than predicting serials. Attachment, identity and fresh-state checks at
|
|
251
|
+
dispatch remain necessary even when the last input targeted a different page.
|
|
252
|
+
|
|
253
|
+
The complete [multi-page example](examples/multi-page.ts) keeps a presentation page pinned while
|
|
254
|
+
the selected scout supplies observations. It reads and captures the presentation page without
|
|
255
|
+
switching selection and returns only data after the shared browser scope closes.
|
|
256
|
+
|
|
257
|
+
### Typed host failures
|
|
258
|
+
|
|
259
|
+
`BrowserError` keeps its operation and a required `outcome` (`undispatched`, `rejected`, or
|
|
260
|
+
`unknown`), while `reason` is a tagged union. Recover by reason without parsing strings:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const read = session
|
|
264
|
+
.readText({})
|
|
265
|
+
.pipe(Effect.catchReason("BrowserError", "Busy", () => Effect.succeed({ text: "" })));
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`Limit` includes measured `dimension`, `maximum` and `observed` fields. For example, an action
|
|
269
|
+
allowance exhausted at two reports `maximum: 2, observed: 2`; it does not count a third call that
|
|
270
|
+
was never admitted. `Configuration` and `Malformed` may name a declared schema-field prefix,
|
|
271
|
+
never a supplied value or unknown property name. `Provider`/`Transport` may carry `status` and
|
|
272
|
+
`RateLimited` may carry `retryAfterMillis`; those fields no longer sit on the outer error.
|
|
273
|
+
`InitializationError` and the provider's separate resource-error families retain their own
|
|
274
|
+
contracts. The Agent tools project host errors to their compact eleven-reason vocabulary and
|
|
275
|
+
retain the originals in the bounded, host-only `ToolHost.toolFailures` snapshot.
|
|
276
|
+
|
|
277
|
+
### A navigation you can watch while it loads
|
|
278
|
+
|
|
279
|
+
`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:
|
|
280
|
+
|
|
281
|
+
Both accept optional `timeoutMillis` from 1 to 600000, capped by the remaining session lifetime.
|
|
282
|
+
Omitting it uses the configured action timeout. Direct, retained and pinned calls share the
|
|
283
|
+
same navigator; the maintained model `browser_navigate` tool still accepts only its upstream URL
|
|
284
|
+
request. This field is the loading deadline, not a promise of rollback on timeout. On a main-frame
|
|
285
|
+
timeout from the pinned engine, up to 3000 ms of recovery may follow that deadline. One absolute
|
|
286
|
+
recovery deadline includes owner-permit waiting, native setup and acknowledgement, and is capped
|
|
287
|
+
by the remaining session lifetime. It is never renewed and cannot extend that lifetime.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const operation =
|
|
291
|
+
yield * handle.startNavigation(StartNavigationRequest.make({ url, timeoutMillis: 30_000 }));
|
|
292
|
+
|
|
293
|
+
const early = yield * session.checkpoint({ picture: true }); // what has loaded so far
|
|
294
|
+
const receipt = yield * PageControl.suspend(session, page); // timers, CSS and parsing stop
|
|
295
|
+
yield * PageControl.resume(session, receipt);
|
|
296
|
+
const { url: loaded } = yield * operation.completed;
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
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.
|
|
300
|
+
|
|
301
|
+
- `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.
|
|
302
|
+
- `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. Concurrent callers share their active stop attempt. A busy refusal or cancellation before dispatch allows a later request only after native setup and its port retire; pending or unconfirmed retirement keeps setup capacity occupied. Once a stop is dispatched, every later caller receives that attempt's recorded result, including failure or interruption, without sending it again. A completed navigation cannot stop its successor.
|
|
303
|
+
- A main-frame loading timeout asks the same stop coordinator to retire that exact navigation. Acknowledged stop completes it with `Timeout/unknown` and leaves the owner open: partial content remains inspectable and deliberate subsequent input is allowed. An explicit stop racing recovery shares its attempt; a completed predecessor cannot stop a successor. The `unknown` outcome still says nothing about page effects before cancellation.
|
|
304
|
+
- A failed or unacknowledged recovery, replacement navigation, detached frame or other native rejection keeps the conservative fence. Pinned child-frame timeout also fences: `Page.stopLoading` acts on the whole page, so automatic frame-local cancellation is not claimed. Explicit stop retains its page-wide meaning.
|
|
305
|
+
- Leaving the operation's scope unsettled fences it as unknown. Neither navigation nor a dispatched stop is replayed, and no timeout or acknowledgement promises rollback, an unchanged DOM, or termination of every page timer or worker.
|
|
306
|
+
|
|
307
|
+
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.
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
### What is on screen, and what a host may know about it
|
|
312
|
+
|
|
313
|
+
`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:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
const seen = yield * session.observe({ scope: "viewport" });
|
|
317
|
+
seen.viewport; // { clippedText, coveredText, uncertainText, unreachableControls, exhausted, ... }
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
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.
|
|
321
|
+
|
|
322
|
+
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:
|
|
323
|
+
|
|
324
|
+
Controls also carry optional `checked`, `selected`, `inputType` and `required`. Native checkbox
|
|
325
|
+
and radio state comes from the element; applicable ARIA state accepts only explicit true/false.
|
|
326
|
+
Mixed, unknown or invalid values are omitted, not converted to false. Selection describes an
|
|
327
|
+
option or selectable control, never an invented state for an entire `<select>`. Required state
|
|
328
|
+
is emitted only for applicable native inputs or reviewed ARIA roles. State that can change an
|
|
329
|
+
input decision participates in the exact-node fresh-facts check; field values and destinations
|
|
330
|
+
remain excluded from the model projection.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
const facts = yield * session.controlFacts(reference);
|
|
334
|
+
// kind, label, disabled, editable, inputType, autocomplete, formMethod, box, placement, hitTest
|
|
335
|
+
// destination: the resolved link target, or where this control submits its form
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`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.
|
|
339
|
+
|
|
340
|
+
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.
|
|
341
|
+
|
|
342
|
+
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:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
yield *
|
|
346
|
+
session.fillElement(reference, value, {
|
|
347
|
+
admit: (facts) => facts.inputType !== "password" && facts.autocomplete !== "cc-number",
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
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.
|
|
352
|
+
|
|
353
|
+
### Exact native option selection
|
|
354
|
+
|
|
355
|
+
`selectOption(reference, options, admission?)` selects once on the native `<select>` named by
|
|
356
|
+
an `ObservedElement`. The options are a nonempty array of at most 64 unique option `elementId`s
|
|
357
|
+
from the same observation. No label, value or selector is a substitute for an issued ID.
|
|
358
|
+
|
|
359
|
+
An observed native select has `multiple` and `optionsTruncated` metadata. Its retained option
|
|
360
|
+
controls carry `selectElementId`, `selected`, `disabled` and a bounded label. Choices of a visible
|
|
361
|
+
select are available in viewport observations even when its menu is collapsed; they consume the
|
|
362
|
+
same `maxControls` allowance as other controls. Truncation is explicit, and an option outside the
|
|
363
|
+
retained set cannot be selected by guessing its position or value.
|
|
364
|
+
|
|
365
|
+
The private value comparison is bounded to 65,536 UTF-16 units per option. A longer value is not
|
|
366
|
+
issued for selection: its legacy control state may remain visible, but `selectElementId` is
|
|
367
|
+
absent and the parent reports `optionsTruncated`. After a page hold, revalidate the select and
|
|
368
|
+
each requested option through `revalidateElement`; revalidating only the select does not approve
|
|
369
|
+
its option nodes.
|
|
370
|
+
|
|
371
|
+
The owner validates the same select and option nodes, document, current membership, multiple and
|
|
372
|
+
enabled state before dispatch. It also compares the submitted value privately without returning
|
|
373
|
+
that value in the observation or action result. Duplicate labels remain distinguishable through
|
|
374
|
+
their IDs. Changed or replaced controls are refused before input; unknown outcomes after dispatch
|
|
375
|
+
are never replayed. Selection emits ordinary native select input/change events through the
|
|
376
|
+
maintained engine, returns `ActionResult` and retires the observation on that page. It does not
|
|
377
|
+
claim the website finished work triggered by those events. The same synchronous host `admission`
|
|
378
|
+
used for exact-node input applies to the selected control.
|
|
379
|
+
|
|
380
|
+
### Bounded waits that leave room for recording
|
|
381
|
+
|
|
382
|
+
`waitFor({ selector, state })` still supports the host's bounded selector conditions: `visible`,
|
|
383
|
+
`hidden`, `attached` and `detached`. It captures the selected page, frame and document when admitted.
|
|
384
|
+
`waitForElement({ reference, state, timeoutMillis? })` instead waits on an exact node issued by the
|
|
385
|
+
current observation, with `visible`, `hidden`, `enabled` or `disabled`. It never searches for a node
|
|
386
|
+
that replaced the reference. Hidden includes detachment of the original node; for the other
|
|
387
|
+
conditions detachment fails `Stale/undispatched`. Replacing the document or frame fails even a
|
|
388
|
+
hidden wait. A node's changed enabled/visible state is what the wait observes, not fresh input
|
|
389
|
+
authorization: inspect again before acting on changed control state.
|
|
390
|
+
|
|
391
|
+
Wait admission briefly holds the existing permit, checks readiness and page holds, and charges
|
|
392
|
+
one model-reachable action. The pending wait then owns its own cancellation signal, connection
|
|
393
|
+
generation and absolute deadline. An exact-node request may shorten the deadline to 1–60,000 ms;
|
|
394
|
+
the configured action timeout and remaining lifetime still cap it, including setup. Selection
|
|
395
|
+
can move elsewhere without retargeting the wait. Its condition is a sample, not a reservation
|
|
396
|
+
that the page will remain unchanged.
|
|
397
|
+
|
|
398
|
+
While a wait is pending, `checkpoint`, `pages` and ordinary bounded reads can use the normal
|
|
399
|
+
permit. Mutations, another navigation and page holds on the waited page are refused before
|
|
400
|
+
dispatch. Input on another page may proceed; replacing the single observation is refused until
|
|
401
|
+
the logical wait ends. Closing a page or session remains available and cancels affected waits.
|
|
402
|
+
No general read-under-write bypass has been added.
|
|
403
|
+
|
|
404
|
+
One native wait may be outstanding per browser session. Cancellation and timeout release the
|
|
405
|
+
logical barrier without inventing an uncertain mutation, but retain native capacity until the
|
|
406
|
+
actual native promise and any required handle disposal settle, or that exact connection retires.
|
|
407
|
+
Consequently another wait can still receive `Busy/undispatched` after its predecessor's caller
|
|
408
|
+
has stopped waiting. Status `busy` includes that retained native capacity; it does not mean every
|
|
409
|
+
ordinary operation is blocked. A cancelled exact-node wait keeps only its leased node alive when
|
|
410
|
+
the observation is replaced, and its late cleanup cannot dispose a successor's references.
|
|
411
|
+
|
|
412
|
+
### Passive checkpoints for a recorder
|
|
413
|
+
|
|
414
|
+
`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.
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
const checkpoint = yield * session.checkpoint({ picture: true });
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
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 held page is refused `busy` rather than woken to be read: take the checkpoint before the hold and keep it.
|
|
421
|
+
|
|
422
|
+
`checkpoint` and `controlFacts` each consume one separate host-read allowance, configured by
|
|
423
|
+
`Chromium.layer({ maxHostReads })` or the corresponding provider Layer. The default is 10,000,
|
|
424
|
+
with an explicit integer bound of 1–1,000,000; invalid values are refused rather than clamped.
|
|
425
|
+
Exhaustion reports `Limit { dimension: "host-reads", maximum, observed } / undispatched` while the
|
|
426
|
+
owner stays open. These operations still obey the same lifetime, bytes, deadline and fail-fast
|
|
427
|
+
concurrency bounds: a checkpoint can execute page script and is not unlimited free work.
|
|
428
|
+
`observe`, `readText`, `screenshot` and `waitFor` continue consuming the model-reachable action
|
|
429
|
+
allowance. Neither budget is a tool parameter, and `maxActions` has not become mutations-only.
|
|
430
|
+
|
|
431
|
+
### Real pointer and wheel input
|
|
432
|
+
|
|
433
|
+
`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.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const handle = session; // ordinary selected-page operations
|
|
437
|
+
|
|
438
|
+
yield * handle.pointerMove(PointerMoveRequest.make({ to: { x: 140, y: 100 } }));
|
|
439
|
+
yield * handle.hover(HoverRequest.make({ selector: "#menu" }));
|
|
440
|
+
// A nested scroll container under the pointer scrolls, not the page.
|
|
441
|
+
const receipt =
|
|
442
|
+
yield * handle.wheel(WheelRequest.make({ deltaX: 0, deltaY: 240, at: { x: 420, y: 120 } }));
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
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.
|
|
446
|
+
|
|
447
|
+
`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`.
|
|
448
|
+
|
|
449
|
+
For a child-frame element, hover checks the commanded point through each ancestor
|
|
450
|
+
frame and then the exact node, including cross-origin documents. The receipt
|
|
451
|
+
still uses main-viewport CSS coordinates. An overlay or clipping ancestor refuses
|
|
452
|
+
input. Frame traversal is bounded at 32 levels, with bounded shadow/DOM ancestry
|
|
453
|
+
checks. Axis-aligned translation and positive scaling are supported; rotation,
|
|
454
|
+
perspective and other unsupported frame mappings are refused rather than guessed.
|
|
455
|
+
These reads and native input are separate operations in the browser: page script
|
|
456
|
+
can still change geometry between validation and dispatch.
|
|
457
|
+
|
|
458
|
+
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.
|
|
459
|
+
|
|
460
|
+
### Real key input
|
|
461
|
+
|
|
462
|
+
`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.
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
const handle = session; // ordinary selected-page operations
|
|
466
|
+
|
|
467
|
+
// Focus is the page's business. A real click gives it, and keys then follow it.
|
|
468
|
+
yield * handle.click(ClickRequest.make({ selector: "#from" }));
|
|
469
|
+
yield * handle.type(TypeRequest.make({ text: "Vienna" }));
|
|
470
|
+
yield * handle.press(PressRequest.make({ key: "Backspace" }));
|
|
471
|
+
yield * handle.press(PressRequest.make({ key: "k", modifiers: ["Control"] }));
|
|
472
|
+
// Sent only if `#from` still has focus; otherwise nothing is sent at all.
|
|
473
|
+
yield * handle.press(PressRequest.make({ key: "Enter", into: "#from" }));
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
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.
|
|
477
|
+
|
|
478
|
+
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.
|
|
479
|
+
|
|
480
|
+
`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.
|
|
481
|
+
|
|
482
|
+
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-browser`.
|
|
483
|
+
|
|
484
|
+
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.
|
|
485
|
+
|
|
486
|
+
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.
|
|
487
|
+
|
|
488
|
+
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.
|
|
489
|
+
|
|
490
|
+
## Registrations, capabilities and document readiness
|
|
491
|
+
|
|
492
|
+
`browser.launch(policy, { bootstrap })`, `browser.acquire(policy, { bootstrap })` and `browser.attach(endpoint, { policy, bootstrap })` take a trusted registration plan at acquisition. The browser Layer fixes launch configuration; consumer callback dependencies are captured at acquisition. A plan is built from `Bootstrap.binding`, `Bootstrap.init`, `Bootstrap.permissions` and `Bootstrap.combine`. Combination preserves the error and service unions of different handlers; the callable bridge precedes all dependent init steps in one native script registration. Script content, handler implementations and origin grants are host configuration, never model output or page input.
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import * as Bootstrap from "effect-browser/bootstrap";
|
|
496
|
+
import * as Browser from "effect-browser/browser";
|
|
497
|
+
import { Chromium } from "effect-browser/chromium";
|
|
498
|
+
import { BrowserPolicy } from "effect-browser/browser-data";
|
|
499
|
+
import { Context, Effect, Schema } from "effect";
|
|
500
|
+
|
|
501
|
+
class ShowSettings extends Context.Service<ShowSettings, { readonly title: string }>()(
|
|
502
|
+
"ShowSettings",
|
|
503
|
+
) {}
|
|
504
|
+
|
|
505
|
+
const bootstrap = Bootstrap.combine(
|
|
506
|
+
Bootstrap.permissions({ origin: "https://portal.example.com", permissions: ["clipboard-read"] }),
|
|
507
|
+
Bootstrap.binding({
|
|
508
|
+
name: "getShowSettings",
|
|
509
|
+
origins: ["https://portal.example.com"],
|
|
510
|
+
input: Schema.Struct({ version: Schema.Literal(3) }),
|
|
511
|
+
output: Schema.Struct({ title: Schema.String }),
|
|
512
|
+
maxConcurrent: 2,
|
|
513
|
+
maxInputBytes: 256,
|
|
514
|
+
maxOutputBytes: 4096,
|
|
515
|
+
timeoutMillis: 2000,
|
|
516
|
+
failureMode: "fail-session",
|
|
517
|
+
handle: () => Effect.map(ShowSettings, (settings) => ({ title: settings.title })),
|
|
518
|
+
}),
|
|
519
|
+
Bootstrap.init({
|
|
520
|
+
id: "show-settings-v3",
|
|
521
|
+
origins: ["https://portal.example.com"],
|
|
522
|
+
content: "globalThis.__ready = globalThis.getShowSettings({ version: 3 }).then(() => true);",
|
|
523
|
+
readiness: {
|
|
524
|
+
expression: "globalThis.__ready",
|
|
525
|
+
timeoutMillis: 5_000,
|
|
526
|
+
existingDocuments: "RequireFreshNavigation",
|
|
527
|
+
},
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const run = Browser.scoped(
|
|
532
|
+
Chromium.launch(BrowserPolicy.unrestricted(), { bootstrap }),
|
|
533
|
+
(session) =>
|
|
534
|
+
Effect.gen(function* () {
|
|
535
|
+
yield* session.navigate({ url: "https://portal.example.com" });
|
|
536
|
+
yield* session.ready;
|
|
537
|
+
return yield* session.observe();
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
// run requires Chromium and ShowSettings. Browser.scoped discharges both the
|
|
541
|
+
// callback's and the use function's Scope, but neither one's other services or errors.
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
Registration is installed before this connection creates any document, and permissions precede the bundle. It is still not readiness: an asynchronous step cannot pause a website's own scripts, so a document is ready only when its expression resolves to exactly `true`. Readiness is keyed by frame and document epoch and evaluated once per document, so a completed wait can never ready the document that replaced the one it observed.
|
|
545
|
+
|
|
546
|
+
Each binding accepts exactly one JSON-compatible argument and returns the output codec's **encoded** JSON value. A transforming codec such as `Schema.FiniteFromString` therefore exposes a string to the page while its host handler works with a number. The native callback validates the actual caller's allowed origin and document before input decoding, immediately before invoking the handler, and before replying. This uses Chromium's execution-context identity and `uniqueContextId` on child CDP sessions belonging to the existing connection: a frame URL, a page-supplied origin, and a reused numeric context id are not authorization. The pinned Playwright `exposeBinding` callback supplies a frame but not the calling document's identity; it is deliberately not used as a weaker substitute. No raw protocol or second browser owner is exposed.
|
|
547
|
+
|
|
548
|
+
Plans admit at most 16 uniquely named bindings. Omitted binding options default to `maxConcurrent: 1`, `maxInputBytes: 65536`, `maxOutputBytes: 65536`, `timeoutMillis: 10000` and `failureMode: "reject-call"`. Name, exact origins, codecs and handler remain required. Explicit bounds/mode pass the same validated registration path; zero, null or excessive values are rejected, never clamped. `combine` selects the most conservative `existingDocuments` policy across its scripts, even when their origin sets differ. Admission reserves capacity before native validation, codec work or a callback fiber starts; a timed-out native operation retains that reservation until it actually settles, including across reconnect. The page wrapper additionally rejects cyclic, sparse, accessor-bearing, non-plain, non-finite and non-JSON input rather than silently changing it through `JSON.stringify`. Its traversal admits at most 64 levels and 65,536 nodes; the configured byte limit still applies. Native target and default-document registries are finite, and closed native targets retire their authority immediately.
|
|
549
|
+
|
|
550
|
+
`reject-call` rejects only the affected invocation and permits subsequent healthy calls. `fail-session` completes `session.failure` with the original typed consumer cause and fences the owner. Pages receive only `BrowserBindingError: Browser binding call rejected`, with no host stack, consumer error payload, credentials or SDK cause. `session.bindingDiagnostics` is a bounded **host-only** snapshot containing per-binding accounting and the latest 32 typed causes; do not serialize it into a Tool response. Callback service reads run independently of a browser mutation, but reentrant browser work never waits behind that mutation's permit: it fails `busy` with `undispatched` instead.
|
|
551
|
+
|
|
552
|
+
`Browser.scoped` supervises fail-session errors and requires the owner's checked cleanup. Explicit `acquire`/`launch` retain the typed failure signal and detailed cleanup receipt for callers that need to manage that decision themselves. Teardown synchronously closes callback admission, interrupts managed callback fibers, removes this connection's registrations, disconnects locally, and still invokes the supplying lifetime’s release when a prior cleanup step fails. Reconnect installs fresh callable registrations, never replays an old invocation or a consumer init script into an already-running document, and cannot reuse quarantined callback capacity.
|
|
553
|
+
|
|
554
|
+
Operations that depend on an initialized document wait for the current one. Navigation, selection and page management do not, so initialization cannot deadlock the navigation that produces the document it is waiting for. A document that was already running when the bundle was registered — the page you attach to, or the one a reconnect finds — never ran it: `RequireFreshNavigation` reports `RequiresNavigation` and refuses dependent work, while `AcceptAlreadyRunning` verifies the requirement against that document instead of assuming it. Neither reloads a page whose work may be uncertain; that stays your decision. `session.ready` reports the current document without charging an action, and an origin outside the plan is reported as `NotApplicable` rather than waited on.
|
|
555
|
+
|
|
556
|
+
The reviewed permission subset is exercised against real Chromium. Provider extensions, persistent contexts and provider reconnect evidence belong to the supplying integration; see the [Browserbase guide](../browserbase/README.md).
|
|
557
|
+
|
|
558
|
+
## Live capture and presentation
|
|
559
|
+
|
|
560
|
+
For a frame-processing pipeline, use `Capture.stream(session, options)`. It starts only when consumed and owns one interval per subscription. `Stream.take`, consumer failure and interruption all finish that interval before the stream completes, while the enclosing browser stays open. A new subscription creates a new interval; concurrent subscriptions on the same page still fail `busy` under the existing reservation. Capture bounds and native-stop quarantine rules are unchanged.
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
import { Stream } from "effect";
|
|
564
|
+
import * as Capture from "effect-browser/capture";
|
|
565
|
+
|
|
566
|
+
const firstFrame = Capture.stream(session, { lifetime: "page" }).pipe(
|
|
567
|
+
Stream.take(1),
|
|
568
|
+
Stream.runCollect,
|
|
569
|
+
);
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Use `Capture.start` instead when the host needs passive snapshots, an explicit stop acknowledgement or the final loss summary. Stream completion runs cleanup but does not itself assert that Chromium acknowledged the stop; unconfirmed cleanup keeps the page quarantined. Both APIs use the same bounded frame stream and owner.
|
|
573
|
+
|
|
574
|
+
Live capture frames carry owned JPEG bytes, captured target identity, sequence number, source presentation time, host monotonic receipt time, geometry and explicit drop accounting. Buffers are bounded by frame count and bytes; slow consumers drop old frames instead of creating an unbounded fiber/callback backlog. Buffer dropping is not page-clock backpressure and does not reduce what the browser produced upstream. Holding a capture callback is not a promise that page timers or animations stop.
|
|
575
|
+
|
|
576
|
+
`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`. 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 `Timestamp` error evidence.
|
|
577
|
+
|
|
578
|
+
The migration equation is **`discarded = overflow + late + duplicates + rejected`**. The four
|
|
579
|
+
components are mutually exclusive: `overflow` is buffer eviction, `late` is out-of-order input,
|
|
580
|
+
`duplicates` is repeated source time, and `rejected` contains other refused or undelivered frames.
|
|
581
|
+
The old `dropped` field is removed, not redefined as overflow. A late frame was omitted from
|
|
582
|
+
delivery; that fact does not prove network loss. The same counters appear in live snapshots,
|
|
583
|
+
and `upstreamDrops` remains `"unknown"`. The default frame capacity remains four.
|
|
584
|
+
|
|
585
|
+
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 [the caller encoder example](../browserbase/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 [the footage example](../browserbase/examples/realistic-footage/README.md).
|
|
586
|
+
|
|
587
|
+
### Read metadata while capture is running
|
|
588
|
+
|
|
589
|
+
```ts
|
|
590
|
+
const interval = yield * Capture.start(session, { lifetime: "page" });
|
|
591
|
+
const current = yield * interval.snapshot;
|
|
592
|
+
// current.phase is "capturing"; initialUrl and recorded documentBoundaries are available now.
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
`snapshot` copies the bounded metadata already recorded in host memory. It does
|
|
596
|
+
no browser work, consumes no frames, charges no action, and works while the page
|
|
597
|
+
is held. Repeated reads neither stop nor restart capture. `observedMonotonicNanos`
|
|
598
|
+
stamps the read; each boundary retains its own commit observation time. The
|
|
599
|
+
prefix holds at most 64 boundaries; `documentBoundariesTruncated` reports overflow
|
|
600
|
+
and `currentDocument` continues counting. Addresses longer than the existing
|
|
601
|
+
bound remain `null`. The model should not receive these host-only addresses by
|
|
602
|
+
accident merely because it can inspect the page.
|
|
603
|
+
|
|
604
|
+
While capturing, `reason` and `nativeStop` are `null`. `phase: "stopping"` means
|
|
605
|
+
capture has stopped accepting frames but native cleanup is pending. `"stopped"`
|
|
606
|
+
means that cleanup attempt settled; only `nativeStop: "confirmed"` confirms the
|
|
607
|
+
native stop. All snapshots are observations of accounting so far: buffered frames
|
|
608
|
+
can still be drained after stop. For final accounting, stop or await capture
|
|
609
|
+
termination, finish draining `frames`, then read `completed` to obtain the terminal
|
|
610
|
+
summary with final delivery counts. Preserve the terminal error and loss counters
|
|
611
|
+
when reconciling earlier snapshots. `late` is one disjoint component of `discarded`, and
|
|
612
|
+
`upstreamDrops` remains `"unknown"`; live metadata adds no stronger pixel or loss
|
|
613
|
+
guarantee.
|
|
614
|
+
|
|
615
|
+
### What a compositor is given, and what it owns
|
|
616
|
+
|
|
617
|
+
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:
|
|
618
|
+
|
|
619
|
+
- each frame's `receivedMonotonicNanos`, and the `document` it was received during;
|
|
620
|
+
- an `InputReceipt` for every native pointer and key operation, with the position commanded and an interval on that same clock;
|
|
621
|
+
- 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.
|
|
622
|
+
|
|
623
|
+
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.
|
|
624
|
+
|
|
625
|
+
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.
|
|
626
|
+
|
|
627
|
+
## Explicit stage-page holds (opt-in)
|
|
628
|
+
|
|
629
|
+
Set `pageControl: true` on `Chromium.layer` or `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.
|
|
630
|
+
|
|
631
|
+
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 acquisition; 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.
|
|
632
|
+
|
|
633
|
+
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.
|
|
634
|
+
|
|
635
|
+
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.
|
|
636
|
+
|
|
637
|
+
## Network policy
|
|
638
|
+
|
|
639
|
+
Only explicit trusted-host `Unrestricted` network policy is implemented. `ExactHosts` and `PublicWeb` fail before acquisition. A local debugger endpoint, an input-admission callback or a filtered initial navigation URL does not establish containment for redirects, subresources, popups, workers or DNS. Applications requiring egress restrictions must operate and independently qualify that enforcing boundary. Chromium can select a host-operated proxy at launch; provider routing is configured by its own integration. These choices never silently substitute a browser provider.
|
|
640
|
+
|
|
641
|
+
## Integration construction
|
|
642
|
+
|
|
643
|
+
`effect-browser/browser-runtime` is the supported host integration boundary. `make` validates an immutable runtime configuration and returns `acquire(policy, source, request)`. A source acquires one concrete lifetime, registers its release in the supplied Scope before returning, resolves its authorized endpoint, and supplies detailed release evidence plus `closeChecked`. Browserbase owns remote allocation/status and writer settlement; Chromium owns process termination/profile removal. The runtime supplies connection cleanup, captures bootstrap services, and creates the one registered session.
|
|
644
|
+
|
|
645
|
+
The connection exposes that exact session and a bounded set of modeled integration operations. An integration can add its own resource identity and authorized file-selection or handoff methods to the same object. It must authorize stored file paths before using integration file selection; ordinary browser sessions do not expose a server-path interface. Hosted handoff pauses under the existing owner before a provider Live View is issued, and resume/reconnect remain owned state transitions.
|
|
646
|
+
|
|
647
|
+
The native driver, action permits, mutable capture leases and registry lookup are private. No constructor returns a raw CDP driver or permits a consumer to recover native authority from a session. `playwright` issues opaque engine configuration; any endpoint resolver is trusted host configuration and must retain the supplying integration's endpoint validation. `Chromium` and `BrowserbaseBrowser` both use this same construction path.
|
|
648
|
+
|
|
649
|
+
## Public entry points
|
|
650
|
+
|
|
651
|
+
| Entry point | Responsibility |
|
|
652
|
+
| ------------------------ | ------------------------------------------------------------------------------------- |
|
|
653
|
+
| `browser` | Common session, bound target, navigation operation and host admission types |
|
|
654
|
+
| `browser-data`, `errors` | Credential-free schemas and expected browser/initialization errors |
|
|
655
|
+
| `bootstrap` | Typed bindings, init/permission plans, readiness and host diagnostics |
|
|
656
|
+
| `capture` | Bounded live frame intervals, snapshots and final accounting; reexports frame schemas |
|
|
657
|
+
| `capture-data` | Capture options, binary frame and result schemas without session operations |
|
|
658
|
+
| `page-control` | Explicit host-owned page holds and receipt-based resume |
|
|
659
|
+
| `chromium` | Self-managed Chromium launch, borrowed loopback attachment and process cleanup |
|
|
660
|
+
| `browser-runtime` | Supported construction for integrations supplying browser lifetimes |
|
|
661
|
+
|
|
662
|
+
The root intentionally excludes the Chromium namespace. Import its entry point explicitly. Source-only cross-package regression tests live in the repository's `test/integration`; they are not exported or shipped.
|
|
663
|
+
|
|
664
|
+
## Validation
|
|
665
|
+
|
|
666
|
+
Use the pinned workspace and Vite+ commands described in [Contributing](../../CONTRIBUTING.md). Unit tests exercise ownership, callback errors, native-call classification and bounded capture. Native Chromium tests use real loopback pages, capture and page holds, and verify that borrowed closure leaves the external browser running. The `browser` installed consumer has no Browserbase or Effect Agent package; the `agent` consumer adds only the common adapter and framework. Provider-backed native integration remains separately exercised by Browserbase consumers. None of these local checks establishes hosted-provider equivalence.
|