跳到內容

ch31-images

取得並執行

這個範例可以獨立 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

說明

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.

Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9040
RouteWhat it shows
/shapeImagesBindingImpl with real prototype methods — not an RPC stub.
/info.info() on a valid PNG and on garbage. Note the error code.
/transforminput → 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-imageThe other surface: fetch() with cf.image.
/img?key=&w=Serve from R2 with a whitelisted width.
Terminal window
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 only width, height, rotate, and format
  • 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: 9523
Unexpected 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.

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); // free
if (meta.format === "image/svg+xml") return passThrough(); // SVG is never resized
if (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.

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.

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)”
Terminal window
curl -s localhost:9040/hosted | jq

Locally 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.

  • .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-hush though). Detect and pass through; .info() is free for exactly this.
  • Animations over 50 MP are delivered untransformed — a silent downgrade.

All three are current; none is deprecated. Cloudflare publishes no comparison guide. The rule this example follows:

  • source is a URLfetch() with cf.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.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"]
}