Skip to content
DocspackagesDocumentation

@lunora/browser

Cloudflare Browser Rendering 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 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.

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.

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; it falls back to the string guard if the lookup fails). 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). 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.