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 }) => {
    // ctx.browser is wired automatically — action context only.
    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);
});

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 (injected by codegen).
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. 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.