Skip to content
DocspackagesDocumentation

@lunora/browser

Cloudflare Browser Run for Lunora — ctx.browser (action-only) for headless screenshots, PDFs, HTML scraping, and arbitrary page evaluation.

PackagesBrowser

@lunora/browser provides a ctx.browser helper for Cloudflare Browser Run (formerly Browser Rendering). It is action-only: browser navigation is non-deterministic network I/O, so codegen wires it onto ActionCtx exclusively, the same class as ctx.ai / ctx.fetch. A ctx.browser call in a query or mutation is a type error.

@cloudflare/playwright is an optional peer dependency (the chromium-protocol shim). Install both:

pnpm add @lunora/browser @cloudflare/playwright

Add the binding to your wrangler.jsonc (the Vite plugin / CLI infers and reconciles it automatically when it sees a @lunora/browser import):

{
    "browser": { "binding": "BROWSER" },
}

Usage

import { action, v } from "@/lunora/_generated/server";

export const screenshotPage = action.input({ url: v.string() }).action(async ({ args: { url }, ctx }) => {
    // Action context only. `ctx.browser` is TYPED automatically, but it is not
    // CONSTRUCTED for you: codegen emits a throwing stub unless the app passes a
    // `browser` thunk to `createShardDO()` (see "Wiring the thunk" below).
    const png = await ctx.browser.screenshot(url, { fullPage: true });

    const { key } = await ctx.storage.store(`shots/${crypto.randomUUID()}.png`, png.buffer, { contentType: "image/png" });

    return ctx.storage.getUrl(key);
});

Wiring the thunk

Codegen weaves ctx.browser onto every ActionCtx and reconciles the BROWSER binding, but it does not construct the helper: createBrowser needs the optional @cloudflare/playwright launch peer, and the generated worker stays free of it deliberately. So codegen emits config.browser ? config.browser(env) : browserStub, and browserStub throws a directed error on every method. Pass the thunk once, where you build the shard:

import { launch } from "@cloudflare/playwright";
import { createBrowser } from "@lunora/browser";

export const ShardDO = createShardDO({
    browser: (env) => createBrowser({ binding: env.BROWSER, launch }),
});

Without that line every ctx.browser call throws — including the one the browserTool agent sandbox makes.

Outside a Lunora action (worker entry, DO, queue handler), build the helper directly:

import { launch } from "@cloudflare/playwright";
import { createBrowser } from "@lunora/browser";

const browser = createBrowser({ binding: env.BROWSER, launch });

const png = await browser.screenshot("https://example.com", { fullPage: true });
const pdf = await browser.pdf("https://example.com", { format: "A4", printBackground: true });
const html = await browser.content("https://example.com");
const title = await browser.scrape("https://example.com", () => document.title);

API reference

createBrowser(options)

OptionTypeNotes
bindingBrowserBindingLikeenv.BROWSER, the Browser Rendering binding. Required.
launchBrowserLaunchLikeimport { launch } from "@cloudflare/playwright". Required — YOU pass it; codegen never injects the optional peer.
timeoutMsnumberFactory-level navigation timeout (ms). Clamped to 120 000. Default 30 000.
allowPrivateTargetsbooleanOpt in to navigating private/internal hosts (loopback, RFC1918, link-local). Default false.
allowedHostsstring[]Strict host allowlist. When set, a navigation URL is refused unless its hostname exactly matches an entry; [] allows nothing. Default unset.
resolveDnsbooleanBest-effort DNS-rebinding re-check over Cloudflare DoH. Defaults to true, or false when allowedHosts is set.
connect, sessionsPlaywright exportsimport { connect, sessions } from "@cloudflare/playwright". Required only for session reuse.
restApi{ accountId, apiToken }Browser Run REST API credentials. Required only for crawl / crawlResult / cancelCrawl (/crawl has no binding method).

Browser

MethodSignatureNotes
screenshot(url, options?) → Promise<Uint8Array>PNG or JPEG. Options: fullPage, type, viewport, timeoutMs, waitUntil.
pdf(url, options?) → Promise<Uint8Array>Options: format, printBackground, viewport, timeoutMs, waitUntil.
content(url, options?) → Promise<string>Returns the page's serialized HTML.
scrape(url, fn, options?) → Promise<T>Evaluates fn in page context; result must be serializable.
launch(fn: (browser) => Promise<T>) → Promise<T>Low-level escape hatch: runs fn with the raw Playwright browser.
connect(sessionId, fn, { close? }) → Promise<T>Re-attach to a kept-alive session. Several workers may share one session.
sessions() → Promise<BrowserSession[]>List live sessions.
quickAction(action, url, options?) → Promise<Response>One-request Browser Run Quick Action through the binding.
crawl(url, options?) → Promise<string>Start an async crawl; returns the job id. Needs restApi.
crawlResult(jobId, { cursor?, limit?, status? }) → Promise<CrawlJob>Job status plus one page of records. Needs restApi.
cancelCrawl(jobId) → Promise<void>Cancel a running crawl. Needs restApi.

Quick Actions

quickAction calls the binding's quickAction() (compatibility date 2026-03-24 or later) — no Playwright session, no launch needed. Actions: accessibilityTree, content, json, links, markdown, pdf, scrape, screenshot, snapshot. Options are forwarded as-is; the result is Browser Run's Response (binary for screenshot/pdf, JSON otherwise, non-2xx with an error body on failure).

const response = await ctx.browser.quickAction("snapshot", url, { formats: ["markdown", "accessibilityTree"] });
const { result } = await response.json();

The url goes through the same guards as a navigation. Inline html is refused, since no URL guard could inspect what it loads. Quick Actions have no request interception or guardrails, so redirects and sub-resources are not re-checked.

Crawling

/crawl is REST-only, so pass account credentials (an API token with Browser Rendering edit rights, kept in .dev.vars / wrangler secret):

const browser = createBrowser({ binding: env.BROWSER, launch, restApi: { accountId: env.CF_ACCOUNT_ID, apiToken: env.BROWSER_RUN_TOKEN } });

const jobId = await browser.crawl("https://docs.example.com", { contentUse: "reference", formats: ["markdown"], limit: 50 });
const job = await browser.crawlResult(jobId, { status: "completed" });

The crawler respects robots.txt and Content Signals; contentUse declares the level you need and a stricter site rejects the crawl with a 400 (surfaced as BROWSER_RUN_ERROR). The starting URL passes the navigation guards; with allowedHosts set, options.includeExternalLinks / options.includeSubdomains are refused because the crawler would leave the allowlist.

Instead of polling, subscribe a Queue to crawl events (wrangler queues subscription create <queue> --source browserRun --events crawl.started,crawl.updated,crawl.finished) and type the consumer's messages as BrowserRunCrawlEvent, narrowing on type (cf.browserRun.crawl.finished carries the final counts). Events carry status only — read pages with crawlResult.

Sharing a session

Browser Run sessions accept several connections at once. Hold a session open with launch(fn, { keepAlive }), then connect(sessionId, fn) from as many actions as you like. Open a context per caller (browser.newContext()) so pages and cookies stay apart, and close it when done. connect(..., { close: true }) closes the browser for every connected client, so only the flow that owns the session should pass it.

Production posture: set allowedHosts

allowedHosts is the supported production configuration. Browser Run enforces it on the session itself (guardrails.allowedDomains): every request the browser makes, redirect hops, sub-resources, service workers and raw CDP included, is checked by Cloudflare, not by code in your Worker.

createBrowser({ binding: env.BROWSER, launch, allowedHosts: ["example.com", "cdn.example.com"] });

Without allowedHosts, the guard below is a best-effort check in the Worker, and createBrowser warns once per isolate. Its known limits:

  • Not everything is routed. Speculation-rules prerenders and WebRTC (STUN/TURN, ICE candidates) do not pass through Playwright's request interception, so the guard never sees them.
  • Cookie scope. Requests are fetched by Playwright's request client in the Worker, which stores Set-Cookie through context.addCookies. How a cookie without a Domain attribute ends up scoped (host-only or widened to subdomains) is not verified.
  • The lock is API-deep. The browser launch() / connect() hand out is locked against Playwright's public API only; code that reaches Playwright's private objects can still remove the guard.
  • Subrequest cost. Every page asset becomes a Worker subrequest (plus a DoH lookup per host every 30 seconds), which counts against the Workers subrequest limit and adds a round trip per asset.

The browser_user_url_without_allowlist advisor lint flags a ctx.browser call whose URL comes from args while the app's createBrowser has no allowedHosts (resolveDns: true no longer counts as containment).

URL safety (SSRF guard)

Every navigation URL is validated before the browser is launched: non-http(s) schemes, embedded credentials, and private/internal targets (loopback, RFC1918, link-local, CGNAT, localhost/*.internal/*.local) are rejected by default. IPv4-mapped IPv6 and octal/hex encodings are normalized first. Set allowPrivateTargets: true only when every URL is trusted.

A public hostname that resolves to a private/metadata IP passes the string guard above (classic DNS rebinding), so a second layer runs on top:

  • resolveDns is on by default when allowedHosts is unset: a DoH re-check that resolves the hostname over Cloudflare DNS and refuses if any A/AAAA record is private. Best-effort: it adds a round-trip and is TOCTOU-imperfect. A lookup that answers with no address (empty, SERVFAIL, NXDOMAIN) is refused, and so is one that cannot be completed (DoH unreachable or past its timeout): the name's nameserver can cause either on purpose. Verdicts are cached per hostname for 30 seconds within one browser context, so a page loading a hundred assets from one CDN costs one lookup, not a hundred. Setting allowedHosts turns it off by default, because the allowlist is the stronger guard and may legitimately name an internal host that a resolved-address check would refuse; pass resolveDns: true to run both. Set resolveDns: false for trusted, non-user-controlled URLs where the round-trip matters.
  • allowedHosts: [...] is a strict allowlist that refuses any hostname not exactly on the list (case-insensitive, trailing-dot- and IPv6-bracket-normalized, Unicode names compared in punycode). Entries are hosts, not origins: ports are not restricted. This is the only guard that fully closes rebinding, so prefer it whenever you can enumerate the destinations. allowedHosts: [] allows nothing — an empty list is a configured allowlist with no members, not an absent one, so every navigation is refused. Omit the option to run without an allowlist.

Redirects. Playwright hands a request interceptor only the first request of a redirect chain, so a public URL, or an <img> on a public page, could otherwise 302 the browser to a private address unchecked. How the guard closes that depends on allowedHosts:

  • Without allowedHosts (the default), nothing behind the guard sees a hop, so it fetches every request itself, through Playwright's request client in the Worker, without following redirects. A redirect's Location passes the same checks, DNS included, before anything requests it. A refused navigation fails the operation with FORBIDDEN; a refused sub-resource is blocked. An allowed navigation becomes a fresh, checked navigation (screenshot, pdf, content and scrape follow it for you, up to 20 hops; in the raw launch() / connect() browser it lands on a short page that navigates on, so wait for the final URL). An allowed sub-resource redirect is followed hop by hop, up to 20, and refused if it changes protocol (route.fetch cannot follow that).
  • With allowedHosts, Browser Run enforces the list on every request the session makes, hops included, so requests are checked and then left in the browser. That is also what keeps an internal host reached over a Tunnel loadable: the Worker never has to reach it.

The cost of the default mode: every page asset is a Worker subrequest, so a page with many assets counts against the Workers subrequest limit (and adds a Worker round trip per asset). DoH verdicts are cached per hostname for 30 seconds within a browser context, so a hundred assets from one CDN cost one lookup. WebSockets are checked before they connect, in both modes.

What a Worker-fetched request looks like, so it matches a native load:

  • Redirect hops follow browser rules. A 303 of anything but GET/HEAD, and a 301/302 of a POST, continue as a GET without the body; a hop to another origin drops Authorization and Proxy-Authorization, takes its cookies from the context's jar for the new URL, sends an opaque Origin, and cuts Referer back to its origin. A navigation's redirect is a fresh browser navigation, so it is always a GET (a 307/308 of a form POST is not re-posted).
  • Cookies are written once. Playwright's request client stores every hop's Set-Cookie in the context's jar as it fetches; the guard does not hand those headers to the browser again, which would store a second copy under Chromium's own parsing. Not verified here: how a cookie without a Domain attribute is scoped when Playwright stores it (context.addCookies with the response host).
  • Service workers are blocked. A service worker fetches outside every route handler, so every context the factory opens, and every one opened from a guarded launch() / connect() browser, is created with serviceWorkers: "block". A context another connection opened in a shared session keeps whatever it was created with.

Every browser the factory launches also receives allowedHosts as Browser Run session guardrails (guardrails.allowedDomains), so Cloudflare itself blocks every off-list request the session makes, sub-resource redirects included. Guardrails accept at most 50 entries; a longer list is refused before launch.

Session ids are bearer secrets. Browser Run cannot tag a session with an owner, so whoever holds an id can connect to it, read its pages and cookies, and close it. Keep ids server-side, keyed by the identity of the caller that opened the session; never send one to a client or accept one from it. The browser connect hands you carries the same SSRF guard as launch.

The handed-out browser is locked. On the browser launch() and connect() give your code, the guard cannot be removed through Playwright's API: unrouteAll(), unroute() without a handler, routeFromHAR(), routeWebSocket(), newCDPSession() and newBrowserCDPSession() throw FORBIDDEN, on every context and page however it was reached (page.context(), browser.contexts(), a popup). Your own route() handlers still work, but their continue() falls through to the guard, and route.fetch() inside them is refused. A context another connection opens in the same session shows up in browser.contexts() only once its guard is in place. This holds against the public API, not against code that reaches Playwright's private objects; when the code driving the browser is untrusted (an LLM tool, say), set allowedHosts, which Browser Run enforces on the session itself.

Platform support

ctx.browser is the browser key of the capability matrix (PlatformCapabilities in @lunora/platform):

TargetRatingNotes
cloudflarenativeBrowser Run: Playwright sessions (with allowedHosts as session guardrails), Quick Actions through the binding, /crawl over the account REST API.
nodeunsupportedNo headless-browser binding.
celldunsupportedBrowser Rendering is not a celld binding type.

On an unsupported target codegen omits ctx.browser and reports platform_unsupported_feature at build time.

What is tested where. The workerd suite runs createBrowser with the real @cloudflare/playwright exports against a fake Browser Run behind a real service binding. It checks what reaches the binding: keep_alive in milliseconds, allowedHosts as normalized guardrails, the session id on connect, the session list, and quickAction arguments across the RPC boundary. It also checks the operation deadline, the DoH re-check (a private answer, SERVFAIL, and its timeout) and /crawl errors on workerd's timers and its own fetch, which leaves workerd for a fake internet rather than a stub. The fake cannot answer the DevTools WebSocket because no Chrome runs locally. Navigation, screenshots, PDFs, scrape and the redirect guard against a live page are covered only by the Node suite's doubles, which model Playwright's rule that a redirect hop never reaches the interceptor. Whether route.fetch itself works inside a Browser Run session is unverified here, and Cloudflare's own enforcement of guardrails and keep_alive is not exercised by this repo.

Errors

Refusals are LunoraErrors with a stable code, thrown before a browser is launched wherever the input alone decides the answer. INTERNAL errors are redacted to a generic 500 on the wire and logged in full.

CodeWhenWhat to do
BAD_REQUESTThe URL is empty, not absolute, not http(s), or embeds user:pass@.Pass an absolute http(s) URL without credentials.
BAD_REQUESTkeepAlive is positive but outside 10–600 seconds.Use a value in that window, or 0 / omit it for the always-close path.
BAD_REQUESTallowedHosts has more than 50 entries or an entry containing * (thrown by createBrowser).List at most 50 exact hosts. Guardrails would read * as a wildcard the Lunora-side guard does not honour.
BAD_REQUESTquickAction got an html option, or its options nest more than 50 distinct URLs.Pass a URL instead of inline HTML; trim the nested addScriptTag / addStyleTag URLs.
FORBIDDENThe target, or a redirect's Location, is private or internal, off the allowedHosts list (or the list is empty), or its name resolves to a private address or to none (the DNS-rebinding re-check).Use a public URL. For a trusted internal host, set allowPrivateTargets: true with an allowedHosts list naming it.
FORBIDDENcrawl asked for includeExternalLinks / includeSubdomains while allowedHosts is set.Drop the option, or crawl without an allowlist.
FORBIDDENThe DNS-rebinding re-check could not complete (DoH unreachable or past its timeout): DNS for url host … could not be verified.Retry; if Cloudflare DoH is unreachable from your Worker, set allowedHosts (which turns the re-check off) or resolveDns: false for trusted URLs.
FORBIDDENCode driving a browser from launch() / connect() called a member that would remove or bypass the guard (unrouteAll, unroute without a handler, routeFromHAR, routeWebSocket, a CDP session, route.fetch() in its own handler).Use route() / unroute(pattern, handler) for your own routes, or configure allowedHosts / allowPrivateTargets if you need the raw member.
BROWSER_TOO_MANY_REDIRECTSA navigation redirected more than 20 times (502).Fix the redirect loop at the target, or navigate to its final URL.
BROWSER_TIMEOUTNavigation plus the operation ran past the timeout budget (504). The session is closed.Raise timeoutMs (capped at 120 s), use a cheaper waitUntil, or check the page for something that never settles.
BROWSER_RUN_ERRORThe /crawl REST API answered non-2xx (the status is kept, 502 otherwise; the body is capped in the message and kept whole on cause), or returned no job id / job.Read the upstream message: a 400 is usually Content Signals refusing the crawl, a 401/403 the API token's permissions, a 429 the account's limits.
INTERNALA peer export is missing (launch, connect, sessions), the binding has no quickAction, or crawl was called without restApi.Pass the export from @cloudflare/playwright, set the binding's compatibility date to 2026-03-24 or later, or pass restApi.

createBrowser({}) without a binding throws a plain TypeError: it is a wiring mistake, not a request error.

Failures inside a Playwright session are not wrapped. If Browser Run refuses a session (for example the account's concurrent-session limit), @cloudflare/playwright's own error (Unable to create new browser: code: 429 …) reaches the caller unchanged; a failure after launch still closes the session. Nothing is retried for you, because every retry is another billed session; retry in the action if the error is transient.