跳到內容

ch30-browser

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch30-browser
cd ch30-browser
npm install

可用指令

npm run dev	# wrangler dev
npm run deploy	# wrangler deploy
npm run types	# wrangler types --env-interface CloudflareBindings

說明

Probe project for chapter 30. Browser Run cannot run locally — remote: true is mandatory — so the routes here are written to be run against a real account, and the local probes exist to show exactly how it fails without it.

Verified with wrangler 4.118.0, @cloudflare/puppeteer 1.2.0, @cloudflare/playwright 1.3.3.

Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9037 # needs "remote": true and an account
RouteWhat it shows
/shapeThe binding’s real nature, and what happens when you call it locally.
/screenshot?url=Quick action with rejectResourceTypes, reporting X-Browser-Ms-Used.
/screenshot-unfiltered?url=The same page without resource blocking. Compare the two numbers.
/markdown /links /jsonOther quick actions, each reporting browser ms.
/missing-actionsaccessibilityTree and crawl have no binding overload.
/puppeteerFull browser control via puppeteer.launch(env.BROWSER).
/sessionsSession reuse: sessions()connect()disconnect().
/session-listCurrent sessions.
/og?slug=LinkForge OG image: render from html (no navigation), cache in R2.

Renamed, but almost nothing you type changed

Section titled “Renamed, but almost nothing you type changed”

Browser Rendering became Browser Run on 2026-04-15. Unchanged:

  • REST path — still .../accounts/<id>/browser-rendering/...
  • wrangler key — still "browser"
  • packages — still @cloudflare/puppeteer / @cloudflare/playwright

Changed: the generated type is now BrowserRun (was BrowserWorker).

Terminal window
curl -s localhost:9039/shape | jq

Without it, the startup table says env.BROWSER Browser Run local, the binding exists — and:

{
"ctorName": "Fetcher",
"quickActionSource": "[object JsRpcProperty]",
"hasQuickAction": "function",
"typeofNonsense": "function",
"localCallResult": {
"threwName": "TypeError",
"threw": "The RPC receiver does not implement the method \"quickAction\"."
}
}

typeof env.BROWSER.notARealMethod is also "function". This is the same RPC-proxy shape as the Pipelines binding (ch23). On Workers, typeof x === "function" proves nothing.

The binding covers 8 of the 10 quick actions

Section titled “The binding covers 8 of the 10 quick actions”

From the quickAction overloads in worker-configuration.d.ts:

screenshot | pdf | content | scrape | links | snapshot | json | markdown

accessibilityTree and crawl are REST-only. Binding quick actions need compatibility_date ≥ 2026-03-24.

Every quick action response carries X-Browser-Ms-Used (when status < 500). It is the only per-request cost signal — the dashboard lags. Pipe it into Analytics Engine (ch22) keyed by tenant and action.

Three levers, in order of impact:

  1. Prefer Quick Actions over a full browser. Quick Actions bill browser hours only; Puppeteer/Playwright/CDP sessions bill hours and concurrent browsers.
  2. Reuse sessions, and use disconnect() — not close(). Note Playwright differs: close() disconnects a connect()-ed browser but closes a launch()-ed one, so it is easy to write fake reuse. New tabs are free and “do not count against either limit”.
  3. Don’t fetch bytes you’ll discardrejectResourceTypes: ["image", "media", "font"]. The docs describe these as request blocking and never tie them to cost; /screenshot vs /screenshot-unfiltered exists so you can measure that claim yourself.

Two numbers that look contradictory but aren’t: the limits page says 120 concurrent browsers on Paid, the pricing page says 10 included. 120 is the ceiling, 10 is the allowance — beyond that it’s $2.00 per browser per month.

gotoOptions.waitUntil defaults to "domcontentloaded" (timeout 30000, max 60000). Don’t reflexively write networkidle0.

html beats url when you control the content

Section titled “html beats url when you control the content”
type BrowserRunCommonOptions =
| (BrowserRunBaseOptions & { url: string })
| (BrowserRunBaseOptions & { html: string });

Exactly one of the two. Passing html means no navigation and no external requests — the cheapest possible screenshot. That is how /og works.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch30-browser",
  "main": "src/index.ts",
  // Playwright requires >= 2025-09-15.
  "compatibility_date": "2026-07-24",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  // The key is still `browser`, even though the product was renamed to
  // Browser Run in 2026-04.
  "browser": { "binding": "BROWSER", "remote": true },
  "r2_buckets": [{ "binding": "OG", "bucket_name": "ch30-og" }],
  "kv_namespaces": [{ "binding": "CACHE", "id": "ch30-local" }]
}

package.json

{
  "name": "ch30-browser",
  "private": true,
  "type": "module",
  "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "types": "wrangler types --env-interface CloudflareBindings" },
  "dependencies": { "@cloudflare/puppeteer": "^1.2.0", "@cloudflare/playwright": "^1.3.3" },
  "devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}

src/index.ts

import puppeteer from "@cloudflare/puppeteer";

const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
  const started = Date.now();
  try {
    const v = await fn();
    return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
  } catch (e) {
    return {
      threwName: (e as Error).name,
      threw: String((e as Error).message ?? e).slice(0, 300),
      ms: Date.now() - started,
    };
  }
};

/** Every quick action sets this header. It is the only per-request cost signal. */
function cost(res: Response): number | null {
  const v = res.headers.get("x-browser-ms-used");
  return v === null ? null : Number(v);
}

export default {
  async fetch(request: Request, env: CloudflareBindings, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const q = url.searchParams;
    const target = q.get("url") ?? "https://example.com";

    switch (url.pathname) {
      case "/shape": {
        const b = env.BROWSER as unknown as object;
        return Response.json({
          typeofBinding: typeof b,
          ctorName: Object.getPrototypeOf(b)?.constructor?.name ?? null,
          protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(b)),
          hasQuickAction: typeof (b as { quickAction?: unknown }).quickAction,
          quickActionSource: String((b as { quickAction?: unknown }).quickAction).slice(0, 40),
          // Any name at all is typeof "function" on an RPC proxy.
          typeofNonsense: typeof (b as unknown as Record<string, unknown>).notARealMethod,
          hasFetch: typeof (b as { fetch?: unknown }).fetch,
          localCallResult: await t(async () => {
            const res = await env.BROWSER.quickAction("screenshot", { url: "https://example.com" });
            return { status: res.status, body: (await res.text()).slice(0, 200) };
          }),
        });
      }

      // The binding-based quick action. No API token, no puppeteer.
      case "/screenshot": {
        return Response.json(
          await t(async () => {
            const res = await env.BROWSER.quickAction("screenshot", {
              url: target,
              viewport: { width: 1200, height: 630 },
              screenshotOptions: { type: "png" },
              // THE cost lever: never download bytes you will throw away.
              rejectResourceTypes: ["image", "media", "font", "stylesheet"],
              gotoOptions: { waitUntil: "domcontentloaded", timeout: 15_000 },
            });
            return {
              status: res.status,
              contentType: res.headers.get("content-type"),
              browserMsUsed: cost(res),
              bytes: res.ok ? (await res.arrayBuffer()).byteLength : await res.text(),
            };
          }),
        );
      }

      // Same page, WITHOUT resource blocking. Compare browserMsUsed.
      case "/screenshot-unfiltered": {
        return Response.json(
          await t(async () => {
            const res = await env.BROWSER.quickAction("screenshot", {
              url: target,
              viewport: { width: 1200, height: 630 },
              screenshotOptions: { type: "png" },
            });
            return {
              status: res.status,
              browserMsUsed: cost(res),
              bytes: res.ok ? (await res.arrayBuffer()).byteLength : await res.text(),
            };
          }),
        );
      }

      case "/markdown": {
        return Response.json(
          await t(async () => {
            const res = await env.BROWSER.quickAction("markdown", { url: target });
            return { status: res.status, browserMsUsed: cost(res), body: await res.json() };
          }),
        );
      }

      case "/links": {
        return Response.json(
          await t(async () => {
            const res = await env.BROWSER.quickAction("links", { url: target });
            return { status: res.status, browserMsUsed: cost(res), body: await res.json() };
          }),
        );
      }

      // `json` runs an AI extraction over the page.
      case "/json": {
        return Response.json(
          await t(async () => {
            const res = await env.BROWSER.quickAction("json", {
              url: target,
              prompt: "Extract the page title and a one-sentence summary.",
            });
            return { status: res.status, browserMsUsed: cost(res), body: await res.json() };
          }),
        );
      }

      // Actions the REST API has but the BINDING does not expose.
      case "/missing-actions": {
        const anyBinding = env.BROWSER as unknown as {
          quickAction(a: string, o: unknown): Promise<Response>;
        };
        const out: Record<string, unknown> = {};
        for (const action of ["accessibilityTree", "crawl", "notAnAction"]) {
          out[action] = await t(async () => {
            const res = await anyBinding.quickAction(action, { url: target });
            return { status: res.status, body: (await res.text()).slice(0, 200) };
          });
        }
        out.note =
          "The binding's overloads cover 8 actions: screenshot, pdf, content, scrape, links, snapshot, json, markdown. accessibilityTree and crawl exist on the REST API but have no binding overload.";
        return Response.json(out);
      }

      // The full-control path: puppeteer over the same binding.
      case "/puppeteer": {
        return Response.json(
          await t(async () => {
            const browser = await puppeteer.launch(env.BROWSER);
            try {
              const page = await browser.newPage();
              await page.setViewport({ width: 1200, height: 630 });
              await page.goto(target, { waitUntil: "domcontentloaded" });
              const title = await page.title();
              const shot = await page.screenshot({ type: "png" });
              return { title, bytes: (shot as Uint8Array).byteLength };
            } finally {
              // close() ENDS the session. See /sessions for when not to.
              await browser.close();
            }
          }),
        );
      }

      // Session reuse: the single biggest cost lever for repeated work.
      case "/sessions": {
        return Response.json(
          await t(async () => {
            const sessions = await puppeteer.sessions(env.BROWSER);
            const free = sessions.find((s) => !s.connectionId);

            const browser = free
              ? await puppeteer.connect(env.BROWSER, free.sessionId)
              : await puppeteer.launch(env.BROWSER, { keep_alive: 600_000 });

            const page = await browser.newPage();
            await page.goto(target, { waitUntil: "domcontentloaded" });
            const title = await page.title();
            await page.close();

            // disconnect(), NOT close(). close() kills the session and the next
            // request pays a full browser startup again.
            browser.disconnect();

            return {
              reusedExisting: Boolean(free),
              sessionsBefore: sessions.length,
              sessionIds: sessions.map((s) => s.sessionId.slice(0, 8)),
              title,
            };
          }),
        );
      }

      case "/session-list": {
        return Response.json(await t(() => puppeteer.sessions(env.BROWSER)));
      }

      // LinkForge: generate an OG image once, cache it in R2.
      case "/og": {
        const slug = q.get("slug") ?? "demo";
        const key = `og/${slug}.png`;
        const cached = await env.OG.get(key);
        if (cached) {
          return new Response(cached.body, {
            headers: { "content-type": "image/png", "x-cache": "HIT" },
          });
        }

        const res = await env.BROWSER.quickAction("screenshot", {
          // `html` instead of `url`: no navigation, no external fetches, and
          // therefore the cheapest possible screenshot.
          html: ogHtml(slug),
          viewport: { width: 1200, height: 630 },
          screenshotOptions: { type: "png" },
        });
        if (!res.ok) return new Response(await res.text(), { status: res.status });

        const png = await res.arrayBuffer();
        ctx.waitUntil(env.OG.put(key, png, { httpMetadata: { contentType: "image/png" } }));
        return new Response(png, {
          headers: {
            "content-type": "image/png",
            "x-cache": "MISS",
            "x-browser-ms-used": String(cost(res) ?? ""),
          },
        });
      }

      default:
        return new Response(
          "/shape /screenshot?url= /screenshot-unfiltered?url= /markdown /links /json\n" +
            "/missing-actions /puppeteer /sessions /session-list /og?slug=\n",
          { status: 404 },
        );
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

const esc = (s: string) =>
  s.replace(
    /[&<>"']/g,
    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
  );

function ogHtml(slug: string): string {
  return `<!doctype html><meta charset="utf-8"><style>
  html,body{margin:0;height:630px;width:1200px;display:grid;place-items:center;
    font-family:ui-sans-serif,system-ui,sans-serif;
    background:linear-gradient(135deg,#f38020,#faad3f);color:#fff}
  h1{font-size:84px;margin:0} p{font-size:32px;opacity:.9}
  </style><div style="text-align:center"><h1>${esc(slug)}</h1><p>linkforge.dev</p></div>`;
}

.gitignore

node_modules/
.wrangler/
worker-configuration.d.ts

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext", "lib": ["esnext"], "module": "esnext",
    "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
    "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
  },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"]
}