ch30-browser
對應 30. Browser Run:託管的 headless 瀏覽器
在 GitHub 上檢視·5 個檔案·10.4 KB
取得並執行
這個範例可以獨立 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說明
ch30 — Browser Run
Section titled “ch30 — Browser Run”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.
npm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9037 # needs "remote": true and an accountRoutes
Section titled “Routes”| Route | What it shows |
|---|---|
/shape | The 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 /json | Other quick actions, each reporting browser ms. |
/missing-actions | accessibilityTree and crawl have no binding overload. |
/puppeteer | Full browser control via puppeteer.launch(env.BROWSER). |
/sessions | Session reuse: sessions() → connect() → disconnect(). |
/session-list | Current 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).
remote: true is not optional
Section titled “remote: true is not optional”curl -s localhost:9039/shape | jqWithout 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 | markdownaccessibilityTree 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:
- Prefer Quick Actions over a full browser. Quick Actions bill browser hours only; Puppeteer/Playwright/CDP sessions bill hours and concurrent browsers.
- Reuse sessions, and use
disconnect()— notclose(). Note Playwright differs:close()disconnects aconnect()-ed browser but closes alaunch()-ed one, so it is easy to write fake reuse. New tabs are free and “do not count against either limit”. - Don’t fetch bytes you’ll discard —
rejectResourceTypes: ["image", "media", "font"]. The docs describe these as request blocking and never tie them to cost;/screenshotvs/screenshot-unfilteredexists 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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.tstsconfig.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"]
}