ch31-images
在 GitHub 上檢視·5 個檔案·6.7 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch31-images
cd ch31-images
npm install可用指令
npm run dev # wrangler dev
npm run types # wrangler types --env-interface CloudflareBindings說明
ch31 — Images
Section titled “ch31 — Images”Probe project for chapter 31. Unlike the Pipelines (ch23) and Browser Run
(ch30) bindings, this one actually runs locally — so most of these routes
produce real output in wrangler dev.
Verified with wrangler 4.118.0.
npm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9040Routes
Section titled “Routes”| Route | What it shows |
|---|---|
/shape | ImagesBindingImpl with real prototype methods — not an RPC stub. |
/info | .info() on a valid PNG and on garbage. Note the error code. |
/transform | input → transform → output, with the resulting byte count. |
/draw | .draw() taking another transformer as the overlay. |
/hosted?id= | The 2026-06 CRUD interface: list / upload / details / missing. |
/cf-image | The other surface: fetch() with cf.image. |
/img?key=&w= | Serve from R2 with a whitelisted width. |
It runs locally — but at low fidelity
Section titled “It runs locally — but at low fidelity”curl -s localhost:9040/transform | jq# { "ok": { "contentType": "image/webp", "bytes": 88 }, "ms": 17 }Cloudflare documents two modes for this binding:
wrangler dev— offline, low-fidelity, “supports onlywidth,height,rotate, andformat”wrangler dev --remote— “the same version that Cloudflare runs globally in production”
So fit: "cover" and quality: 80 don’t error locally, but per that sentence
they very likely aren’t doing anything. Never validate output quality or byte
size locally.
The local implementation is Sharp inside miniflare — visible in the error
stack from /info:
code: 9523Unexpected error response 500: Error: Input buffer contains unsupported image format at Sharp.metadata (file:///.../node_modules/sharp/dist/input.mjs:642:17) at runInfo (/.../node_modules/miniflare/dist/src/index.js:...)Two things there: the generated type’s doc comment claims code 9412 for non-image input, and the runtime gives 9523 — don’t branch on 9412. And the message leaks host filesystem paths, so don’t forward raw errors to users.
.info() is now free
Section titled “.info() is now free”As of the 2026-07-01 changelog, the Images binding is billed per unique
transformation (same image + same parameters = once per calendar month), and
“Calls to .info() are no longer billed.”
That makes check-then-decide a zero-cost pattern:
const meta = await env.IMAGES.info(stream); // freeif (meta.format === "image/svg+xml") return passThrough(); // SVG is never resizedif (meta.width <= 256) return passThrough(); // already small enough⚠️ /images/pricing/ still carries the pre-change text (“every call to the
binding counts as a transformation, regardless of whether the image or
parameters are unique”), contradicting the changelog and the binding docs. Cite
the changelog.
Whitelist your sizes
Section titled “Whitelist your sizes”Billing is per unique transformation, so the attack surface is generating
many distinct parameter sets. ?w=1, ?w=2, ?w=3 … is a thousand billed
transformations from a thousand requests.
const ALLOWED = new Set([32, 64, 128, 256]);const w = ALLOWED.has(Number(q.get("w"))) ? Number(q.get("w")) : 64;Put every output-affecting parameter in the URL, not in a header you then
Vary on.
The infinite-loop trap
Section titled “The infinite-loop trap”A transforming Worker mounted where the source images live will fetch from
itself. Cloudflare’s documented mitigation is the Via header:
if (/image-resizing/.test(request.headers.get("via") ?? "")) { return fetch(request); // must be the first thing in the handler}Note the string is image-resizing — the retired product name is still the
only correct check.
Better: don’t let the paths overlap at all. /img reads bytes from R2
(env.MEDIA.get(key)), so it makes no HTTP request and cannot loop.
hosted needs a paid plan (and local dev won’t tell you)
Section titled “hosted needs a paid plan (and local dev won’t tell you)”curl -s localhost:9040/hosted | jqLocally it all works, including __cf_local/imagedelivery/... variant URLs.
In production, “Hosted image operations require a paid Images plan with
storage” — stated on the storage/binding docs page, not in the changelog.
Also note details() returns null for a missing id rather than throwing, and
uploading a PNG comes back with filename: "uploaded.jpg" — a default that
has nothing to do with the real format.
Limits worth memorising
Section titled “Limits worth memorising”.input()maxes out at 20 MB — five times smaller than the 100 MB for remote sources. Phone-camera originals routinely exceed it.- AVIF output maxes at 1,200 px, vs 12,000 px for everything else.
- SVG is never resized — parameters are ignored (it is sanitised through
svg-hushthough). Detect and pass through;.info()is free for exactly this. - Animations over 50 MP are delivered untransformed — a silent downgrade.
Which of the three surfaces?
Section titled “Which of the three surfaces?”All three are current; none is deprecated. Cloudflare publishes no comparison guide. The rule this example follows:
- source is a URL →
fetch()withcf.image - source is bytes (an R2 object, a request body) → the binding
.input() takes a ReadableStream, not a URL. The type signature is the
division of labour.
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch31-images",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
"images": { "binding": "IMAGES" },
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "ch31-media" }]
}package.json
{
"name": "ch31-images",
"private": true,
"type": "module",
"scripts": { "dev": "wrangler dev", "types": "wrangler types --env-interface CloudflareBindings" },
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}src/index.ts
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) {
const err = e as Error & { code?: number };
return {
threwName: err.name,
code: err.code,
threw: String(err.message ?? e).slice(0, 260),
ms: Date.now() - started,
};
}
};
/** A tiny PNG, so the probes need no external fetch. */
const TINY_PNG = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEUlEQVR4nGP40KCAFTEMLQkARxFkAQqvFGYAAAAASUVORK5CYII=",
),
(c) => c.charCodeAt(0),
);
const streamOf = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
new Blob([bytes as unknown as ArrayBufferView]).stream() as ReadableStream<Uint8Array>;
export default {
async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
const url = new URL(request.url);
const q = url.searchParams;
switch (url.pathname) {
// What is on the binding? Note `hosted` -- added 2026-06.
case "/shape": {
const b = env.IMAGES as unknown as object;
const hosted = (env.IMAGES as { hosted?: object }).hosted;
return Response.json({
ctorName: Object.getPrototypeOf(b)?.constructor?.name ?? null,
protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(b)),
ownKeys: Object.getOwnPropertyNames(b),
hasInfo: typeof (b as { info?: unknown }).info,
hasInput: typeof (b as { input?: unknown }).input,
hasHosted: typeof hosted,
hostedKeys: hosted
? [
...Object.getOwnPropertyNames(hosted),
...Object.getOwnPropertyNames(Object.getPrototypeOf(hosted)),
]
: null,
});
}
// `.info()` -- as of 2026-07-01 this is no longer billed.
case "/info": {
return Response.json({
png: await t(() => env.IMAGES.info(streamOf(TINY_PNG))),
// Documented to throw ImagesError code 9412 for non-images.
notAnImage: await t(() =>
env.IMAGES.info(streamOf(new TextEncoder().encode("definitely not a png"))),
),
});
}
// The transformation pipeline: input -> transform -> output.
case "/transform": {
return Response.json(
await t(async () => {
const res = await env.IMAGES.input(streamOf(TINY_PNG))
.transform({ width: 64, height: 64, fit: "cover" })
.output({ format: "image/webp", quality: 80 });
return {
contentType: res.contentType(),
bytes: (await new Response(res.image()).arrayBuffer()).byteLength,
};
}),
);
}
// Chained transforms + draw (watermark / composite).
case "/draw": {
return Response.json(
await t(async () => {
const watermark = env.IMAGES.input(streamOf(TINY_PNG)).transform({
width: 16,
height: 16,
});
const res = await env.IMAGES.input(streamOf(TINY_PNG))
.transform({ width: 200, height: 200 })
.draw(watermark, { bottom: 8, right: 8, opacity: 0.6 })
.output({ format: "image/png" });
return { contentType: res.contentType() };
}),
);
}
// The 2026-06 hosted CRUD interface. No API token needed in a Worker,
// but it does require a paid Images plan.
case "/hosted": {
const h = env.IMAGES.hosted;
const id = q.get("id") ?? "ch31-probe";
return Response.json({
list: await t(() => h.list({ limit: 5 })),
upload: await t(() =>
h.upload(TINY_PNG.buffer as ArrayBuffer, { id, metadata: { chapter: 31 } }),
),
details: await t(() => h.image(id).details()),
missing: await t(() => h.image("definitely-does-not-exist").details()),
});
}
// The OTHER transformation surface: fetch()'s cf.image. Not deprecated;
// it takes a URL rather than bytes.
case "/cf-image": {
return Response.json(
await t(async () => {
const res = await fetch("https://example.com/photo.jpg", {
cf: { image: { width: 400, format: "webp", fit: "scale-down" } },
} as RequestInit);
return {
status: res.status,
// Cloudflare reports the outcome here.
cfResized: res.headers.get("cf-resized"),
contentType: res.headers.get("content-type"),
};
}),
);
}
// Serve from R2 at a size derived from the path -- and never let the
// transform request re-enter this handler (see README: infinite loop).
case "/img": {
const key = q.get("key") ?? "avatars/demo.png";
const w = Math.min(Number(q.get("w") ?? 256), 1024);
const obj = await env.MEDIA.get(key);
if (!obj) return new Response("not found", { status: 404 });
const res = await env.IMAGES.input(obj.body as ReadableStream<Uint8Array>)
.transform({ width: w, height: w, fit: "cover" })
.output({ format: "image/webp", quality: 82 });
return new Response(res.image(), {
headers: {
"content-type": res.contentType(),
// The width is in the URL, so the cache key is already distinct.
// Putting transform options in a header and Vary-ing on it is the
// classic way to poison a cache.
"cache-control": "public, max-age=31536000, immutable",
},
});
}
default:
return new Response(
"/shape /info /transform /draw /hosted?id= /cf-image /img?key=&w=\n",
{ status: 404 },
);
}
},
} satisfies ExportedHandler<CloudflareBindings>;.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"]
}