Skip to content
← Public packages

@kody/browser-run

Call Cloudflare Browser Run Quick Actions and reuse shared sessions with createBrowserContext.

src/worker-recipe.ts

128 lines · 4.5 KB · TypeScript
import { object, optional, parse, string } from 'remix/data-schema'
import { DOCS_REUSE_SESSIONS } from './client.ts'

const recipeInput = object(
	{
		bindingName: optional(string()),
		compatibilityDate: optional(string()),
	},
	{ unknownKeys: 'error' },
)

/**
 * Documentation-as-data for a Cloudflare Worker that uses a browser binding
 * plus `@cloudflare/puppeteer` ≥ 1.1.0: list sessions → connect →
 * createBrowserContext → disconnect (never browser.close on shared sessions).
 * Not runnable Puppeteer inside Kody — copy into a Worker you deploy.
 *
 * @param raw.bindingName - Wrangler browser binding name (default MYBROWSER)
 * @param raw.compatibilityDate - Must be ≥ 2025-09-15 for concurrent clients
 * @returns Structured recipe: wrangler snippet, Worker TypeScript, footguns
 *
 * @example
 * import workerRecipe from 'kody:@kody/browser-run/worker-recipe'
 * const recipe = await workerRecipe({ bindingName: 'MYBROWSER' })
 */
export default async function workerRecipe(raw: unknown = {}) {
	const input = parse(recipeInput, raw ?? {})
	const binding = (input.bindingName || 'MYBROWSER').trim() || 'MYBROWSER'
	const compatibilityDate = (input.compatibilityDate || '2026-09-29').trim()

	return {
		purpose:
			'Deploy a Worker that reuses Browser Run sessions with per-request createBrowserContext isolation.',
		docs: DOCS_REUSE_SESSIONS,
		requirements: {
			puppeteer: '@cloudflare/puppeteer >= 1.1.0',
			playwright: '@cloudflare/playwright >= 1.3.0 (if using Playwright)',
			compatibilityFlags: ['nodejs_compat'],
			compatibilityDateMin: '2025-09-15',
			compatibilityDate,
			browserBinding: binding,
		},
		wranglerJsonc: {
			name: 'browser-worker',
			main: 'src/index.ts',
			compatibility_date: compatibilityDate,
			compatibility_flags: ['nodejs_compat'],
			browser: { binding },
		},
		install: 'npm i -D @cloudflare/puppeteer',
		workerTypeScript: `
import puppeteer from "@cloudflare/puppeteer";

const MAX_CONCURRENT_CONTEXTS = 4;

interface Env {
  ${binding}: Fetcher;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    let reqUrl = url.searchParams.get("url") || "https://example.com";
    reqUrl = new URL(reqUrl).toString();

    const sessions = await puppeteer.sessions(env.${binding});
    const start = sessions.length ? Math.floor(Math.random() * sessions.length) : 0;
    const ordered = sessions.length
      ? [...sessions.slice(start), ...sessions.slice(0, start)]
      : [];

    let browser;
    let launched = false;
    for (const session of ordered) {
      try {
        const candidate = await puppeteer.connect(env.${binding}, session.sessionId);
        try {
          const client = await candidate.target().createCDPSession();
          try {
            const { browserContextIds } = await client.send("Target.getBrowserContexts");
            if (browserContextIds.length < MAX_CONCURRENT_CONTEXTS) {
              browser = candidate;
              break;
            }
          } finally {
            await client.detach();
          }
        } finally {
          if (candidate !== browser) await candidate.disconnect();
        }
      } catch {
        // Session may have closed after it was listed
      }
    }
    if (!browser) {
      browser = await puppeteer.launch(env.${binding});
      launched = true;
    }

    const sessionId = browser.sessionId();
    const context = await browser.createBrowserContext();
    try {
      const page = await context.newPage();
      const response = await page.goto(reqUrl);
      const html = await response!.text();
      return new Response(
        (launched ? "Launched " : "Connected to ") + sessionId + "\\n-----\\n" + html,
        { headers: { "content-type": "text/plain" } },
      );
    } finally {
      await context.close();
      await browser.disconnect(); // NEVER browser.close() while sharing
    }
  },
};
`.trim(),
		footguns: [
			'Never call browser.close() when other clients may share the session.',
			'Always createBrowserContext() per request, then context.close().',
			'Use browser.disconnect() to drop your CDP connection only.',
			'Older @cloudflare/puppeteer (<1.1.0) is single-connection — upgrade.',
			'compatibility_date must be 2025-09-15 or later with nodejs_compat.',
			'For local wrangler dev with a real browser, set browser.remote: true.',
		],
		kodyNote:
			'This package does not run Puppeteer inside Kody isolates. Use ./open-isolated + ./sessions/* for REST/CDP metadata, or deploy this Worker recipe on Cloudflare.',
	}
}