agents
Version:
A home for your AI agents
938 lines (937 loc) • 41 kB
JavaScript
import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "../classPrivateFieldGet2-DZBYAB34.js";
import { t as _classPrivateMethodInitSpec } from "../classPrivateMethodInitSpec-qMjJ6sHQ.js";
import { t as LifecycleCapability } from "../capability-DBAKc6UZ.js";
import { t as browserCapabilityId } from "../capability-id-B3J8Nok_.js";
import { a as conversationScope, c as CdpConnection, d as connectUrl, i as DEFAULT_BROWSER_SCOPE, n as BrowserSession, o as browserFunctionDocs, t as ACTIVE_PAGE_SESSION, u as CdpSession } from "../session-CRY0bGiI.js";
import { C as getBrowserRecording, S as deleteBrowserSession, T as listBrowserTargets, _ as acquireBrowserSession, a as loadCdpSpec, b as connectBrowserSession, c as browserExtract, d as browserPdf, f as browserScrape, g as BrowserRenderingError, h as runQuickAction, i as DurableBrowserSessionStore, l as browserLinks, m as browserSnapshot, n as DEFAULT_EXEC_SWEEP_IDLE_MS, o as createLiveView, p as browserScreenshot, r as DEFAULT_SWEEP_IDLE_MS, s as browserContent, t as BrowserConnector, u as browserMarkdown, v as closeBrowserSession, w as isMissingBrowserSession, x as createBrowserSession, y as connectBrowser } from "../connector-Dl098To-.js";
import { WorkerEntrypoint } from "cloudflare:workers";
import { CodemodeRuntime } from "@cloudflare/codemode";
import { Validator } from "@cfworker/json-schema";
//#region src/browser/internal-hosts.ts
/**
* Internal hostnames: sites only this agent's browser can open.
*
* A host maps a hostname such as `preview.internal` to code in your agent
* (`fetch`) or to a deployed Worker (`worker`). Browser Run sends the
* browser's requests for that hostname to it instead of the internet, so a
* page can be served from a sandbox port or a workspace folder, without exposing anything publicly.
*
* How a request travels: Browser Run accepts only entrypoints of your own
* Worker as routes, and keeps them for the whole browser session (observed
* across redeploys and Durable Object eviction; Browser Run doesn't document
* this yet). So every `fetch` host routes to {@link InternalHostRouter}, which
* you export from your main module. Its props say which agent and browser
* own the host, and nothing else. It calls the agent's `fetchInternalHost()`
* over RPC, and the agent's `Browser` runs the host's `fetch`. `Agent` has
* that method built in; a plain Durable Object adds it (see
* {@link InternalHostMessage}). A `worker` host goes straight to the Worker
* you gave it.
*/
/** The most hostnames one browser can have. A Browser Run limit. */
const MAX_INTERNAL_HOSTS = 8;
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
/**
* Suffixes reserved so they never resolve on the internet. A real domain
* would be served by the host over http but reach the real site over https
* (and Chrome tries https first), so internal hostnames must use one.
* Single labels like `preview` would work, but Browser Run doesn't route
* labels that are real TLDs (`docs`, `app`) and gives no error for them, so
* they're rejected rather than checked against a TLD list.
*/
const RESERVED_SUFFIXES = [
".internal",
".test",
".example",
".invalid"
];
/** Why `host` can't be an internal hostname, or undefined when it can. */
function hostnameProblem(host) {
if (host.includes("://")) return "drop the scheme";
if (host.includes("/")) return "drop the path";
if (host.includes(":")) return "drop the port (only port 80 is routed)";
if (host !== host.toLowerCase()) return "use lowercase";
if (/^\d+(\.\d+){3}$/.test(host)) return "use a name, not an IP address";
if (host === "localhost" || host.endsWith(".localhost")) return "Chrome sends localhost to the browser's own machine";
if (!HOSTNAME.test(host)) return "not a valid hostname";
if (!RESERVED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return `use a name that can't be a real site: end it in ${RESERVED_SUFFIXES.join(", ")}`;
}
/**
* Check `internalHosts` when the provider is created, so a mistake fails at
* startup instead of at the first browser launch.
*/
function validateInternalHosts(options) {
const hosts = Object.entries(options.hosts);
if (hosts.length === 0) throw new Error("internalHosts.hosts needs at least one hostname");
if (hosts.length > 8) throw new Error(`internalHosts has ${hosts.length} hostnames; Browser Run allows at most 8 per browser`);
for (const [host, config] of hosts) {
const problem = hostnameProblem(host);
if (problem) throw new Error(`Internal hostname "${host}": ${problem}`);
const hasFetch = typeof config.fetch === "function";
const hasWorker = config.worker !== void 0;
if (hasFetch === hasWorker) throw new Error(`Internal hostname "${host}" needs exactly one of fetch or worker`);
if (hasWorker && config.target !== void 0) throw new Error(`Internal hostname "${host}": target needs fetch; a worker host can't be assigned`);
if (hasFetch && typeof options.router !== "function") throw new Error(`Internal hostname "${host}" uses fetch, which needs internalHosts.router: this.ctx.exports.InternalHostRouter (and \`export { InternalHostRouter } from "agents/browser"\` in your main module)`);
if (config.target !== void 0) targetJsonSchema(host, config.target);
}
}
/**
* Identifies the set of hostnames and how each is routed. A browser started
* with a different set is replaced: Browser Run fixes routes at launch.
*/
function internalHostsSignature(options) {
if (!options) return void 0;
return Object.entries(options.hosts).map(([host, config]) => `${host}=${config.worker ? "worker" : "fetch"}`).sort().join(",");
}
/**
* The route Browser Run gets for a `worker` host. A `ctx.exports`
* entrypoint is a loopback stub that Browser Run can't take as is, so it's
* called (with empty props) to get a Fetcher it can.
*/
function workerRoute(worker) {
if (typeof worker !== "function") return worker;
return worker({ props: {} });
}
function isStandardSchema(schema) {
return typeof schema === "object" && schema !== null && "~standard" in schema;
}
/** A host's target schema as JSON Schema, for the model and for plain validation. */
function targetJsonSchema(host, schema) {
if (!isStandardSchema(schema)) {
if (typeof schema !== "object" || schema === null) throw new Error(`Internal hostname "${host}": target must be a schema`);
return schema;
}
const { jsonSchema } = schema["~standard"];
if (!jsonSchema?.input) throw new Error(`Internal hostname "${host}": target needs a schema that can describe itself as JSON Schema (Standard JSON Schema, such as zod 4.2+), or a plain JSON Schema object`);
try {
return jsonSchema.input({ target: "draft-07" });
} catch {
return jsonSchema.input({ target: "draft-2020-12" });
}
}
const plainValidators = /* @__PURE__ */ new WeakMap();
function formatIssues(issues) {
return issues.map((issue) => issue.path ? `${issue.path}: ${issue.message}` : issue.message).join("; ");
}
function pathString(path) {
if (!path?.length) return void 0;
return path.map((part) => String(typeof part === "object" ? part.key : part)).join(".");
}
function plainIssues(errors) {
const specific = errors.filter((error) => error.keyword !== "properties");
return (specific.length > 0 ? specific : errors).map((error) => ({
message: error.error,
path: error.instanceLocation.replace(/^#\/?/, "").replaceAll("/", ".") || void 0
}));
}
/**
* Validate a target against a host's schema. Returns the validated value
* (a Standard Schema's output, which may differ from its input), or a
* message saying what's wrong.
*/
async function validateTarget(schema, value) {
if (isStandardSchema(schema)) {
const result = await schema["~standard"].validate(value);
if (result.issues) return {
ok: false,
error: formatIssues(result.issues.map((issue) => ({
message: issue.message,
path: pathString(issue.path)
})))
};
return {
ok: true,
value: result.value
};
}
let validator = plainValidators.get(schema);
if (!validator) {
validator = new Validator(schema, "7", false);
plainValidators.set(schema, validator);
}
const result = validator.validate(value);
return result.valid ? {
ok: true,
value
} : {
ok: false,
error: formatIssues(plainIssues(result.errors))
};
}
function isInternalHostMessage(value) {
if (typeof value !== "object" || value === null) return false;
const message = value;
return typeof message.browser === "string" && typeof message.host === "string" && message.request instanceof Request;
}
/** A plain-text response the browser shows for a failed internal request. */
function internalHostError(status, message) {
return new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8" }
});
}
/**
* Whether an RPC call failed because the agent has no `fetchInternalHost()`.
* workerd says: `The RPC receiver does not implement the method
* "fetchInternalHost".` If that wording changes, the router falls back to its
* generic 502, which names the method too.
*/
function isMissingMethod(error) {
return error instanceof Error && error.message.includes("fetchInternalHost") && /does not implement|is not a function/.test(error.message);
}
/**
* Carries a browser's requests for an internal hostname to the agent that
* owns it. Export it from your Worker's main module, so
* `this.ctx.exports.InternalHostRouter` exists:
*
* ```ts
* export { InternalHostRouter } from "agents/browser";
* ```
*
* It trusts its props: Browser Run sets them from your routes, but another
* Worker in your account with a service binding to this entrypoint could
* set its own and reach any agent's hosts. That Worker could already call
* your agents directly, so this grants nothing new.
*
* It holds no logic of its own: it calls the agent's `fetchInternalHost()`
* with an {@link InternalHostMessage}, and the agent's `Browser` runs the
* host's `fetch`. `Agent` has `fetchInternalHost()` built in; a plain
* Durable Object adds it (see {@link InternalHostMessage}).
*
* @experimental The API may change before it stabilizes.
*/
var InternalHostRouter = class extends WorkerEntrypoint {
async fetch(request) {
const props = this.ctx.props;
if (!isRouterProps(props)) return internalHostError(502, "InternalHostRouter was called without route props. Only Browser Run calls it, with the routes browserRun({ internalHosts }) gives it.");
const namespace = this.ctx.exports?.[props.className];
if (!namespace?.getByName) return internalHostError(502, `${props.host}: the agent class "${props.className}" isn't exported from this Worker`);
try {
const response = await namespace.getByName(props.name).fetchInternalHost({
browser: props.browser,
host: props.host,
request
});
if (response instanceof Response) return response;
return internalHostError(502, `${props.host}: the agent didn't answer with a Response`);
} catch (error) {
if (isMissingMethod(error)) return internalHostError(502, `${props.host}: ${props.className} has no fetchInternalHost() method. Agent has it built in; on a plain Durable Object add: fetchInternalHost(message) { return this.browser.fetchInternalHost(message); }`);
console.error(`[agents/browser] ${props.className} failed to serve ${props.host}`, error);
return internalHostError(502, `${props.host}: the agent failed to serve this request`);
}
}
};
function isRouterProps(props) {
return props?.v === 1 && typeof props.className === "string" && typeof props.name === "string" && typeof props.browser === "string" && typeof props.host === "string";
}
//#endregion
//#region src/browser/providers/provider-adapter.ts
/**
* Thrown by {@link BrowserProviderAdapter.connect} when the session no longer
* exists upstream (closed, expired, or reclaimed). `Browser` treats it as
* "this browser is lost" and replaces it; any other error is passed on.
*/
var BrowserSessionGoneError = class extends Error {
constructor(sessionId, options) {
super(`Browser session gone: ${sessionId}`, options);
this.sessionId = sessionId;
this.name = "BrowserSessionGoneError";
}
};
/**
* Run the browser on Cloudflare Browser Run.
*
* @example
* ```ts
* new Browser({ provider: browserRun(env.BROWSER, { recording: true }) });
* ```
*/
function browserRun(binding, options = {}) {
const erased = options;
if (erased.internalHosts) validateInternalHosts(erased.internalHosts);
return {
kind: "browser-run",
binding,
options: erased
};
}
function isDataCloneError(error) {
return error instanceof Error && error.name === "DataCloneError";
}
/** One `outboundByHost` route per internal hostname. */
function internalHostRoutes(internalHosts, owner) {
const routes = {};
const hosts = Object.entries(internalHosts.hosts);
for (const [host, config] of hosts) {
if (config.worker) {
routes[host] = workerRoute(config.worker);
continue;
}
routes[host] = internalHosts.router({ props: {
v: 1,
...owner,
host
} });
}
return routes;
}
/** The code behind {@link browserRun}. */
function browserRunAdapter(provider) {
const { binding, options } = provider;
const keepAliveMs = () => options.keepAliveMs ?? 6e5;
return {
get keepAliveMs() {
return keepAliveMs();
},
internalHosts: options.internalHosts,
internalHostsSignature: internalHostsSignature(options.internalHosts),
async create(context) {
const { internalHosts } = options;
if (internalHosts && !context?.owner) throw new Error("Internal hostnames need the Browser installed with Lifecycle.use() on a top-level agent");
try {
return await acquireBrowserSession(binding, {
keepAliveMs: keepAliveMs(),
recording: options.recording,
guardrails: options.guardrails,
...internalHosts && context?.owner ? { outboundByHost: internalHostRoutes(internalHosts, context.owner) } : {}
});
} catch (error) {
if (internalHosts && isDataCloneError(error)) throw new Error("Browser Run couldn't take the internal hostname routes: a worker host must be a service binding or an entrypoint of this Worker (this.ctx.exports.Name), not a Dynamic Worker or another object it can't hold", { cause: error });
throw error;
}
},
async isAlive(sessionId) {
try {
await listBrowserTargets(binding, sessionId);
return true;
} catch (error) {
if (isMissingBrowserSession(error)) return false;
throw error;
}
},
async connect(sessionId, connectOptions) {
try {
return await connectBrowserSession(binding, sessionId, connectOptions);
} catch (error) {
if (isMissingBrowserSession(error)) throw new BrowserSessionGoneError(sessionId, { cause: error });
throw error;
}
},
async close(sessionId) {
try {
await closeBrowserSession(binding, sessionId);
} catch (error) {
if (!isMissingBrowserSession(error)) throw error;
}
},
spec(sessionId) {
return loadCdpSpec({
browser: binding,
sessionId
});
},
async liveView(sessionId, mode) {
try {
return createLiveView(sessionId, await listBrowserTargets(binding, sessionId), mode);
} catch (error) {
if (isMissingBrowserSession(error)) return void 0;
throw error;
}
}
};
}
/**
* Scope records kept per browser. Writing a new one past the cap deletes the
* least recently used; a scope that comes back afterwards starts fresh.
*/
const MAX_BROWSER_SCOPES = 100;
const NAMED_SESSION_KEY_PREFIX = "browser:session:";
/** The store key holding the named browser's current record. */
function namedBrowserSessionKey(name) {
return `${NAMED_SESSION_KEY_PREFIX}${name}`;
}
/**
* Where a closed or lost browser's record moves: permanent evidence that the
* name once owned a browser, so a later resolve reports `restarted: true`.
* Kept outside {@link NAMED_SESSION_KEY_PREFIX} so listing that prefix
* yields only names that currently own a browser. Grows with the number of
* distinct names the host has ever used.
*/
const RETIRED_SESSION_KEY_PREFIX = "browser:retired:";
function retiredBrowserSessionKey(name) {
return `${RETIRED_SESSION_KEY_PREFIX}${name}`;
}
/**
* Where a browser's scope records live. The name's length is part of the
* prefix, so one browser's prefix never matches another name's (`a` with
* scope `b:c` vs. `a:b` with scope `c`).
*/
function browserScopeKeyPrefix(name) {
return `browser:scope:${name.length}:${name}:`;
}
/**
* Where a browser's internal-host targets live: one key per host, under a
* prefix per browser (length-prefixed like scope records, so one browser's
* prefix never matches another's). Kept apart from the session record, so
* targets survive the browser being replaced.
*/
function internalHostTargetsPrefix(name) {
return `browser:hosts:${name.length}:${name}:`;
}
/** The adapter behind a provider: one explicit case per provider kind. */
function providerAdapterFor(provider) {
switch (provider.kind) {
case "browser-run": return browserRunAdapter(provider);
}
}
var _options = /* @__PURE__ */ new WeakMap();
var _key = /* @__PURE__ */ new WeakMap();
var _adapter = /* @__PURE__ */ new WeakMap();
var _store = /* @__PURE__ */ new WeakMap();
var _Browser_brand = /* @__PURE__ */ new WeakSet();
/**
* One named, persistent browser.
*
* Install it with `Lifecycle.use()` — on an Agent subclass or any Durable
* Object that composes `Lifecycle` — and it stores its record in the
* object's own storage. It schedules nothing: the provider reclaims idle
* browsers itself (Browser Run once `keep_alive` elapses), and the next use
* replaces the lost browser with `restarted: true`.
*
* Everything here is host-side. Models never see browser names, provider
* session ids, or Live View URLs through this surface.
*
* Store discipline: locks wrap storage operations only — liveness probes and
* provider create/close calls always happen outside any lock, with a commit
* re-check to detect concurrent swaps.
*
* @experimental The API surface may change before stabilizing.
*/
var Browser = class extends LifecycleCapability {
constructor(options) {
const name = options.name ?? "default";
super(browserCapabilityId(name));
_classPrivateMethodInitSpec(this, _Browser_brand);
_classPrivateFieldInitSpec(this, _options, void 0);
_classPrivateFieldInitSpec(this, _key, void 0);
_classPrivateFieldInitSpec(this, _adapter, void 0);
_classPrivateFieldInitSpec(this, _store, void 0);
if (name.trim() === "") throw new Error("Browser names must be non-empty");
this.name = name;
_classPrivateFieldSet2(_options, this, options);
_classPrivateFieldSet2(_key, this, namedBrowserSessionKey(name));
_classPrivateFieldSet2(_adapter, this, providerAdapterFor(options.provider));
}
/**
* Reattach to the browser when it is still alive, otherwise create a fresh
* one. See {@link ResolvedBrowser.restarted} for the mortality signal.
*/
async resolve() {
const { name } = this;
const key = _classPrivateFieldGet2(_key, this);
for (let attempt = 0; attempt < 4; attempt++) {
const existing = await _assertClassBrand(_Browser_brand, this, _readStored).call(this);
if (existing === void 0) {
const { session, restarted } = await _assertClassBrand(_Browser_brand, this, _createAndCommit).call(this);
return {
name,
restarted,
...resolvedFields(session)
};
}
const routesMatch = existing.internalHosts === _classPrivateFieldGet2(_adapter, this).internalHostsSignature;
const alive = routesMatch && await _classPrivateFieldGet2(_adapter, this).isAlive(existing.sessionId);
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(key);
try {
const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(key);
if (current?.sessionId !== existing.sessionId) continue;
if (alive) {
const refreshed = {
...current,
updatedAt: Date.now()
};
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, refreshed);
return {
name,
restarted: false,
...resolvedFields(refreshed)
};
}
await _assertClassBrand(_Browser_brand, this, _retire).call(this, current);
} finally {
await lock.release();
}
if (!routesMatch) await _assertClassBrand(_Browser_brand, this, _closeQuietly).call(this, existing.sessionId);
}
throw new Error(`Browser "${name}" kept changing concurrently — retry`);
}
/**
* Resolve the browser and open a CDP connection to it for one scope (see
* {@link BrowserConnectOptions.scope}). Commands sent over the connection
* refresh the record's `updatedAt` (throttled to
* {@link SESSION_TOUCH_INTERVAL_MS}), so hosts can tell an actively used
* browser from one the platform has likely reclaimed.
*
* If the browser expires between the liveness probe and the WebSocket
* upgrade, the record is retired and the browser resolved once more, so
* the caller gets a fresh browser instead of an error.
*/
async connect(options = {}) {
const scope = options.scope ?? "shared";
if (scope.trim() === "") throw new Error("Browser scopes must be non-empty");
let resolved = await this.resolve();
let cdp;
try {
cdp = await _assertClassBrand(_Browser_brand, this, _open).call(this, resolved);
} catch (error) {
if (!(error instanceof BrowserSessionGoneError)) throw error;
await _assertClassBrand(_Browser_brand, this, _retireIfCurrent).call(this, resolved.sessionId);
resolved = await this.resolve();
cdp = await _assertClassBrand(_Browser_brand, this, _open).call(this, resolved);
}
try {
const entered = await _assertClassBrand(_Browser_brand, this, _enterScope).call(this, scope, resolved.sessionId);
return _assertClassBrand(_Browser_brand, this, _connection).call(this, resolved, scope, cdp, entered);
} catch (error) {
cdp.disconnect();
throw error;
}
}
/**
* Start a session on this browser for one run: connects on its first
* call, resolves `sessionId: "active"` to the scope's tab, and reports
* what happened to the browser when you close it. `browserTool` opens
* one per run.
*/
session(options) {
return new BrowserSession(this, options);
}
/**
* What the model needs to know to use this browser's `browser.*`
* functions ({@link BrowserSession.functions}): the functions in Markdown,
* including the internal sites and hosts functions this browser has, and
* the rules for using them. For a tool or module description; add rules
* about how your code runs (timeouts, screenshots) yourself.
*/
functionDocs() {
return browserFunctionDocs(this.internalHosts);
}
/**
* Close the browser: retire the record (so the next resolve reports
* `restarted: true`) and close its provider session. Returns `false` when
* there was nothing to close.
*
* The provider close is best-effort: the retired record is the durable
* outcome, and a browser whose close failed is unreachable through this
* store, so the provider reclaims it once idle (Browser Run: after
* `keepAliveMs`, at most 20 minutes).
*/
async close() {
let stored;
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this));
try {
const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
if (!current) return false;
stored = current;
await _assertClassBrand(_Browser_brand, this, _retire).call(this, current);
} finally {
await lock.release();
}
try {
await _classPrivateFieldGet2(_adapter, this).close(stored.sessionId);
} catch (error) {
console.warn(`[agents/browser] Failed to close browser session ${stored.sessionId}`, error);
}
return true;
}
/**
* Mint Live View URLs for the browser's open tabs, fresh from a target
* listing. `undefined` when the browser was never created, is closed, or
* is gone. The URLs are bearer credentials with a short connect window
* (~5 min) — hand them to a trusted human, never to the model, and never
* store them: re-mint instead.
*/
async liveView(options) {
const entry = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
if (!entry) return void 0;
const view = await _classPrivateFieldGet2(_adapter, this).liveView(entry.sessionId, options?.mode);
if (!view) return void 0;
try {
if (!await _assertClassBrand(_Browser_brand, this, _touch).call(this, entry.sessionId)) return void 0;
} catch (error) {
console.warn(`[agents/browser] Failed to refresh activity for browser "${this.name}"`, error);
}
return view;
}
/**
* The browser's internal hostnames, or `undefined` when its provider has
* none. See {@link BrowserInternalHosts}.
*/
get internalHosts() {
const config = _classPrivateFieldGet2(_adapter, this).internalHosts;
if (!config) return void 0;
const hosts = config.hosts;
const describe = () => Object.entries(hosts).map(([host, entry]) => ({
host,
url: `http://${host}/`,
...entry.description ? { description: entry.description } : {},
assignable: entry.target !== void 0,
...entry.target !== void 0 ? { targetSchema: targetJsonSchema(host, entry.target) } : {}
}));
const assignable = (host) => {
const entry = hosts[host];
if (!entry || !Object.hasOwn(hosts, host)) throw new Error(`"${host}" isn't an internal hostname. The hostnames are: ${Object.keys(hosts).join(", ")}`);
if (entry.target === void 0) throw new Error(`"${host}" always serves the same thing; it has no target to assign`);
return entry.target;
};
return {
describe,
list: async () => {
const prefix = internalHostTargetsPrefix(this.name);
const stored = await this.lifecycle.storage.list({ prefix });
return describe().map((info) => {
const key = prefix + info.host;
return stored.has(key) ? {
...info,
target: stored.get(key)
} : info;
});
},
assign: async (host, target) => {
const schema = assignable(host);
if (target === void 0) throw new Error(`browser.assignHost needs a target for ${host}; use browser.clearHost to unassign it`);
const json = JSON.parse(JSON.stringify(target));
const checked = await validateTarget(schema, json);
if (!checked.ok) throw new Error(`Invalid target for ${host}: ${checked.error}`);
await this.lifecycle.storage.put(_assertClassBrand(_Browser_brand, this, _targetKey).call(this, host), json);
return { url: `http://${host}/` };
},
clear: async (host) => {
assignable(host);
return this.lifecycle.storage.delete(_assertClassBrand(_Browser_brand, this, _targetKey).call(this, host));
}
};
}
/**
* Serve one browser request to an internal hostname. `InternalHostRouter`
* calls the agent's `fetchInternalHost()` with the message; `Agent` passes
* it here on its own. A plain Durable Object passes it on itself:
*
* ```ts
* fetchInternalHost(message: InternalHostMessage) {
* return this.browser.fetchInternalHost(message);
* }
* ```
*
* Answers 502 with the reason when this browser doesn't serve the host.
*/
async fetchInternalHost(message) {
if (message.browser !== this.name) return internalHostError(502, `${message.host} belongs to browser "${message.browser}", but was passed to browser "${this.name}"`);
return _assertClassBrand(_Browser_brand, this, _serveInternalHost).call(this, message.host, message.request);
}
/**
* `Agent.fetchInternalHost()` routes here through Lifecycle, so `Agent`
* needn't import the browser.
*
* @internal Called by Lifecycle; not part of the host API.
*/
async onRoute(context) {
const { payload } = context;
if (!isInternalHostMessage(payload)) throw new Error(`Browser "${this.name}" got an unknown routed message`);
return this.fetchInternalHost(payload);
}
};
function _connection(resolved, scope, cdp, entered) {
const { sessionId } = resolved;
return {
name: resolved.name,
sessionId,
scope,
restarted: entered.restarted,
activeTargetId: entered.activeTargetId,
cdp,
setActiveTarget: (targetId) => _assertClassBrand(_Browser_brand, this, _updateScope).call(this, scope, sessionId, targetId),
targetsInOtherScopes: async () => activeTargets(await _assertClassBrand(_Browser_brand, this, _listScopes).call(this), scope, sessionId),
spec: () => _classPrivateFieldGet2(_adapter, this).spec(sessionId)
};
}
async function _open(resolved) {
let lastTouchAt = Date.now();
let touchInFlight = false;
return _classPrivateFieldGet2(_adapter, this).connect(resolved.sessionId, {
timeoutMs: _classPrivateFieldGet2(_options, this).timeoutMs,
onActivity: () => {
const now = Date.now();
if (touchInFlight || now - lastTouchAt < _get_touchIntervalMs.call(_assertClassBrand(_Browser_brand, this))) return;
touchInFlight = true;
lastTouchAt = now;
_assertClassBrand(_Browser_brand, this, _touch).call(this, resolved.sessionId).catch((error) => {
console.warn(`[agents/browser] Failed to refresh activity for browser "${this.name}"`, error);
}).finally(() => {
touchInFlight = false;
});
}
});
}
async function _serveInternalHost(host, request) {
const hosts = _classPrivateFieldGet2(_adapter, this).internalHosts?.hosts;
const entry = hosts && Object.hasOwn(hosts, host) ? hosts[host] : void 0;
if (!entry?.fetch) return internalHostError(502, `${host} is no longer an internal hostname of browser "${this.name}"`);
let target;
if (entry.target !== void 0) {
const assigned = await this.lifecycle.storage.get(_assertClassBrand(_Browser_brand, this, _targetKey).call(this, host));
if (assigned === void 0) return internalHostError(502, `${host} isn't pointed anywhere yet. Call browser.assignHost({ host: "${host}", target }) first.`);
const checked = await validateTarget(entry.target, assigned);
if (!checked.ok) return internalHostError(502, `${host}'s target no longer matches its schema (${checked.error}). Call browser.assignHost again.`);
target = checked.value;
}
const fetch = entry.fetch;
try {
const response = await this.lifecycle.runInHostContext(() => fetch(request, target), { request });
if (response instanceof Response) return response;
return internalHostError(502, `${host}: its fetch didn't return a Response`);
} catch (error) {
console.error(`[agents/browser] ${host}: its fetch threw`, error);
return internalHostError(502, `${host}: its fetch failed`);
}
}
/**
* Which agent and browser the session's routes lead back to. `undefined`
* without internal hostnames.
*/
function _internalHostOwner() {
if (!_classPrivateFieldGet2(_adapter, this).internalHosts) return void 0;
const services = this.lifecycleServices;
if (!services) throw new Error(`Browser "${this.name}" has internal hostnames, which need it installed with Lifecycle.use()`);
if (services.routes.source !== void 0) throw new Error(`Browser "${this.name}" has internal hostnames, which sub-agents don't support yet`);
return {
className: services.className,
name: services.name,
browser: this.name
};
}
/** The storage key of one host's target. */
function _targetKey(host) {
return internalHostTargetsPrefix(this.name) + host;
}
async function _closeQuietly(sessionId) {
try {
await _classPrivateFieldGet2(_adapter, this).close(sessionId);
} catch (error) {
console.warn(`[agents/browser] Failed to close browser session ${sessionId}`, error);
}
}
/**
* Capped at half the provider's keep-alive window: a browser with
* continuous CDP traffic must never look older than the window the
* provider uses to reclaim it.
*/
function _get_touchIntervalMs() {
const interval = _classPrivateFieldGet2(_options, this).touchIntervalMs ?? 6e4;
const { keepAliveMs } = _classPrivateFieldGet2(_adapter, this);
return keepAliveMs === void 0 ? interval : Math.min(interval, Math.floor(keepAliveMs / 2));
}
/** Lazy: `lifecycle.storage` exists only once `Lifecycle.use()` ran. */
function _get_sessionStore() {
_classPrivateFieldGet2(_store, this) ?? _classPrivateFieldSet2(_store, this, _classPrivateFieldGet2(_options, this).store ?? new DurableBrowserSessionStore(this.lifecycle.storage));
return _classPrivateFieldGet2(_store, this);
}
/**
* Refresh `updatedAt` while the record still holds `sessionId`. Returns
* false when a concurrent close or replacement already retired it.
*/
function _touch(sessionId) {
return _assertClassBrand(_Browser_brand, this, _update).call(this, sessionId, (current) => ({
...current,
updatedAt: Date.now()
}));
}
/**
* Rewrite the record only while it still holds `sessionId`: a replaced or
* retired record is never resurrected.
*/
async function _update(sessionId, change) {
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this));
try {
const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
if (current?.sessionId !== sessionId) return false;
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(_classPrivateFieldGet2(_key, this), change(current));
return true;
} finally {
await lock.release();
}
}
function _scopeKey(scope) {
return `${browserScopeKeyPrefix(this.name)}${scope}`;
}
/** The lock every scope record of this browser is written under. */
function _get_scopesLockKey() {
return browserScopeKeyPrefix(this.name);
}
/** Every scope record of this browser, by scope. */
async function _listScopes() {
const prefix = browserScopeKeyPrefix(this.name);
const entries = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).list(prefix);
const scopes = /* @__PURE__ */ new Map();
for (const [key, entry] of entries) scopes.set(key.slice(prefix.length), entry);
return scopes;
}
/**
* Start a connection's scope: a scope whose record names an earlier
* browser was restarted, and its record moves to this one. A new record
* past {@link MAX_BROWSER_SCOPES} evicts the least recently used.
*/
async function _enterScope(scope, sessionId) {
const key = _assertClassBrand(_Browser_brand, this, _scopeKey).call(this, scope);
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_get_scopesLockKey.call(_assertClassBrand(_Browser_brand, this)));
try {
const entry = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(key);
const now = Date.now();
if (entry?.sessionId === sessionId) {
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, {
...entry,
updatedAt: now
});
return {
restarted: false,
activeTargetId: entry.activeTargetId
};
}
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, {
sessionId,
createdAt: now,
updatedAt: now
});
if (!entry) await _assertClassBrand(_Browser_brand, this, _evictScopes).call(this);
return { restarted: entry !== void 0 };
} finally {
await lock.release();
}
}
/** Delete the least recently used scope records past the cap. */
async function _evictScopes() {
const scopes = await _assertClassBrand(_Browser_brand, this, _listScopes).call(this);
if (scopes.size <= 100) return;
const oldest = [...scopes].sort(([, a], [, b]) => a.updatedAt - b.updatedAt).slice(0, scopes.size - 100);
for (const [scope] of oldest) await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).delete(_assertClassBrand(_Browser_brand, this, _scopeKey).call(this, scope));
}
/**
* Set a scope's tab (or clear it with `undefined`). Never resurrects:
* does nothing (and returns `false`) once the browser this connection
* resolved was closed or replaced.
*/
async function _updateScope(scope, sessionId, targetId) {
const key = _assertClassBrand(_Browser_brand, this, _scopeKey).call(this, scope);
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_get_scopesLockKey.call(_assertClassBrand(_Browser_brand, this)));
try {
const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
const entry = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(key);
if (current?.sessionId !== sessionId || entry?.sessionId !== sessionId) return false;
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(key, {
...entry,
activeTargetId: targetId,
updatedAt: Date.now()
});
return true;
} finally {
await lock.release();
}
}
/** Retire the record if it still holds `sessionId`. */
async function _retireIfCurrent(sessionId) {
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this));
try {
const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
if (current?.sessionId === sessionId) await _assertClassBrand(_Browser_brand, this, _retire).call(this, current);
} finally {
await lock.release();
}
}
/**
* Move the record to its retired marker. Callers hold the key lock, so a
* resolver always sees either the record or the marker.
*/
async function _retire(current) {
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(retiredBrowserSessionKey(this.name), {
...current,
closedAt: Date.now()
});
await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).delete(_classPrivateFieldGet2(_key, this));
}
/**
* Create a provider session (outside any lock) and commit it. The adapter
* applies the provider's creation options. If a concurrent caller
* committed an entry first, theirs wins and the redundant session is
* closed best-effort.
*
* `restarted` is read under the commit lock: a retired marker — left by
* close() or a dead-browser recovery — is evidence a prior browser
* existed, and only first-ever use of the name is not a restart. Markers
* are permanent, so reading at commit time also catches a browser that
* was created and closed while this create was in flight.
*/
async function _createAndCommit() {
const info = await _classPrivateFieldGet2(_adapter, this).create({ owner: _assertClassBrand(_Browser_brand, this, _internalHostOwner).call(this) });
const now = Date.now();
const signature = _classPrivateFieldGet2(_adapter, this).internalHostsSignature;
const stored = {
sessionId: info.sessionId,
createdAt: now,
updatedAt: now,
...signature !== void 0 ? { internalHosts: signature } : {}
};
let winner;
let restarted;
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this));
try {
restarted = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(retiredBrowserSessionKey(this.name)) !== void 0;
const current = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
if (current === void 0) await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).set(_classPrivateFieldGet2(_key, this), stored);
else winner = current;
} finally {
await lock.release();
}
if (winner) {
try {
await _classPrivateFieldGet2(_adapter, this).close(stored.sessionId);
} catch (error) {
console.warn(`[agents/browser] Failed to close redundant browser session ${stored.sessionId}`, error);
}
return {
session: winner,
restarted
};
}
return {
session: stored,
restarted
};
}
async function _readStored() {
const lock = await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).acquireLock(_classPrivateFieldGet2(_key, this));
try {
return await _get_sessionStore.call(_assertClassBrand(_Browser_brand, this)).get(_classPrivateFieldGet2(_key, this));
} finally {
await lock.release();
}
}
/** A store record without fields that belong to scope records. */
function resolvedFields(session) {
const { sessionId, createdAt, updatedAt } = session;
return {
sessionId,
createdAt,
updatedAt
};
}
/** The tabs scopes other than `scope` are working in, in `sessionId`. */
function activeTargets(scopes, scope, sessionId) {
const targets = /* @__PURE__ */ new Set();
for (const [other, entry] of scopes) {
if (other === scope || entry.sessionId !== sessionId) continue;
if (entry.activeTargetId) targets.add(entry.activeTargetId);
}
return targets;
}
//#endregion
export { ACTIVE_PAGE_SESSION, Browser, BrowserConnector, BrowserRenderingError, BrowserSession, CdpConnection, CdpSession, CodemodeRuntime, DEFAULT_BROWSER_SCOPE, DEFAULT_EXEC_SWEEP_IDLE_MS, DEFAULT_SWEEP_IDLE_MS, DurableBrowserSessionStore, InternalHostRouter, MAX_BROWSER_SCOPES, MAX_INTERNAL_HOSTS, browserContent, browserExtract, browserLinks, browserMarkdown, browserPdf, browserRun, browserScrape, browserScreenshot, browserSnapshot, connectBrowser, connectBrowserSession, connectUrl, conversationScope, createBrowserSession, deleteBrowserSession, getBrowserRecording, listBrowserTargets, loadCdpSpec, runQuickAction };
//# sourceMappingURL=index.js.map