Experimental — this package is outside the Lunora 1.0 stability promise: its API may change in any release, without a major version bump.
Daniel Bannert's open source work is supported by the community on GitHub Sponsors
Cloudflare Browser Rendering for Lunora. Wraps the env.BROWSER binding — driven through @cloudflare/playwright (launch(env.BROWSER)) — with a small typed ctx.browser API: screenshot, pdf, scrape/content, plus a low-level launch() escape hatch. Every helper opens a context + page, navigates, performs the op, and always closes the session in a finally (a leaked Browser Rendering session is billed and rate-limited).
Part of the Lunora framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.
ctx.browser is wired onto the action context only — never QueryCtx/MutationCtx. Driving a real headless browser to a URL is non-deterministic network I/O (the same class as fetch), and Lunora queries/mutations must be deterministic so they can be re-run, cached, and replayed over the live channel. So codegen weaves ctx.browser into ActionCtx exclusively — exactly like ctx.ai / ctx.fetch.
This isn't just convention: because the browser type is not on QueryCtx/MutationCtx, a ctx.browser.* call in a query or mutation is a type error and won't compile. It's the same mistake class the nondeterministic_query_mutation advisor flags for fetch/Date.now/Math.random — here it's structurally impossible.
@cloudflare/playwright is an optional peer dependency (it bundles a chromium-protocol shim — apps that never screenshot shouldn't pay for it). Install both:
npm install @lunora/browser @cloudflare/playwrightpnpm add @lunora/browser @cloudflare/playwrightAdd the binding to your wrangler.jsonc (the Lunora Vite plugin / CLI infers and reconciles it for you when it sees a @lunora/browser import):
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 an action — in the worker entry, a Durable Object, or a queue/scheduled handler — build the helper directly. It is the exact launch(env.BROWSER) equivalence, just with the always-close / URL-validation / viewport-cap guards applied. The config thunk codegen uses is browser: (env) => createBrowser({ binding: env.BROWSER, launch }):
import { launch } from "@cloudflare/playwright";
import { createBrowser } from "@lunora/browser";
const browser = createBrowser({ binding: env.BROWSER, launch });
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);Every navigation URL is validated before the browser is launched. Beyond rejecting non-http(s) schemes (file:, javascript:, data:, …) and embedded user:pass@ credentials, the helper default-denies private / internal targets — loopback (127.0.0.0/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), link-local incl. the cloud-metadata address (169.254.169.254), CGNAT (100.64/10), IPv6 ULA/link-local, and localhost / *.internal / *.local literals (octal/hex/integer IPv4 and IPv4-mapped IPv6 encodings are normalized first, so they can't slip past). This matters because action url args are often caller-controlled.
If you deliberately drive the browser at an internal service reachable through a private-network binding / Cloudflare Tunnel, opt out per-factory:
const browser = createBrowser({ binding: env.BROWSER, launch, allowPrivateTargets: true });Only set allowPrivateTargets when every URL is trusted — it re-opens the SSRF surface.
DNS rebinding is covered too: whenever allowedHosts is unset, the host is resolved over DoH and refused if it maps to a private address, before the browser launches and again on every redirect hop. Setting allowedHosts turns that re-check off, because an exact-host allowlist already closes rebinding and may deliberately name an internal host reachable over a Tunnel; pass resolveDns: true to force both.
This README covers the basics. For the full API, options, and guides, see the documentation.
@lunora/server— callctx.browserfrom actions.@lunora/storage— persist the screenshots/PDFs you render.@lunora/advisor— the determinism lint that keeps non-deterministic I/O out of queries/mutations.
Libraries in this ecosystem make the best effort to track Node.js' release schedule. Here's a post on why we think this is important.
If you would like to help take a look at the list of issues and check our Contributing guidelines.
Note: please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
This is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. Anolilab is a Development and AI Studio. Contact us at hello@anolilab.com if you need any help with these technologies or just want to say hi!
The Lunora browser package is open-sourced software licensed under the FSL-1.1-Apache-2.0.
{ "browser": { "binding": "BROWSER" }, }