Skip to content

Latest commit

 

History

History
157 lines (101 loc) · 8.41 KB

File metadata and controls

157 lines (101 loc) · 8.41 KB
browser

Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions

Experimental — this package is outside the Lunora 1.0 stability promise: its API may change in any release, without a major version bump.


typescript-image FSL-1.1-Apache-2.0 licence npm version npm downloads PRs Welcome


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.

Action-only — and why

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.

Install

@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/playwright
pnpm add @lunora/browser @cloudflare/playwright

Add the binding to your wrangler.jsonc (the Lunora Vite plugin / CLI infers and reconciles it for you 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 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);

URL safety (SSRF guard)

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.

Related

Supported Node.js Versions

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.

Contributing

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.

Credits

Made with ❤️ at Anolilab

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!

License

The Lunora browser package is open-sourced software licensed under the FSL-1.1-Apache-2.0.