跳到內容

ch12-r2

取得並執行

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

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch12-r2
cd ch12-r2
npm install

可用指令

npm run dev	# wrangler dev
npm run typecheck	# tsc --noEmit
npm run cf-typegen	# wrangler types --env-interface CloudflareBindings

說明

Companion example for docs/12-r2.md.

Terminal window
npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed"
curl -s "$B/shapes"
curl -s "$B/conditional"
curl -s "$B/range"
curl -s "$B/list"
curl -s "$B/checksum"
curl -s "$B/multipart"
curl -s "$B/storageclass"
curl -s "$B/delete"

2026-07-28 · wrangler@4.114.0 · workerd@1.20260722.1

{"getKeys":["body","bodyUsed","arrayBuffer","bytes","text","json","blob","constructor"],
"hasBody":true,"headHasBody":false,
"object":{"key":"reports/2026-01.csv","size":15,
"etag":"8bf9c7df3df8a11296ccc7d90a22240f",
"httpEtag":"\"8bf9c7df3df8a11296ccc7d90a22240f\"",
"storageClass":"","checksums":["sha512","sha384","sha256","sha1","md5"],
"httpMetadata":{"contentType":"text/csv"},"customMetadata":{"tenant":"t1"}}}

get() returns a body; head() does not. Note bytes() on the prototype, which is absent from the documented method list. httpEtag is the quoted form, ready for a header.

A failed precondition is indistinguishable between 304 and 412

Section titled “A failed precondition is indistinguishable between 304 and 412”
{"ifNoneMatch_matching": {"isNull":false,"hasBody":false},
"ifNoneMatch_notMatching":{"isNull":false,"hasBody":true},
"ifMatch_failing": {"isNull":false,"hasBody":false},
"put_failingPrecondition":"returned null"}

If-None-Match hitting (semantically 304) and If-Match failing (semantically 412) produce identical results from the binding. Decide the status from which header the client sent.

put() with a failed precondition returns null and does not store — the right primitive for optimistic concurrency.

{"full":"id,clicks\n1,10\n","first5":"id,cl","suffix":"1,10\n",
"rangeMeta":{"offset":0,"length":5}}
{"foldered":{"keys":["logo.png"],"delimitedPrefixes":["exports/","reports/"]}}

Far cheaper than listing everything and grouping client-side — ListObjects is a Class A operation.

{"correct":{"ok":{"checksums":{"md5":"5d41402a...","sha256":"2cf24dba..."}}},
"wrong":{"threw":"put: The SHA-256 checksum you specified did not match what we received."},
"twoHashes":{"threw":"TypeError: You cannot specify multiple hashing algorithms."}}

Multipart size rules are enforced at complete(), not uploadPart()

Section titled “Multipart size rules are enforced at complete(), not uploadPart()”
{"evenComplete":{"ok":{"key":"big/even","size":13631488,
"etag":"5f853ab4ba46f5da5f04e29de1cbc1be-3"}},
"allPartsUploadedOk":[1,2,3],
"unevenComplete":{"threw":"completeMultipartUpload: Your proposed upload is smaller than the minimum allowed object size. (10011)"},
"singleSmallPart":{"ok":{"key":"big/tiny","size":1024}}}
  • 6 MiB / 6 MiB / 1 MiB completes; the last part may be smaller.
  • A 1 MiB part in the middle uploads fine — all three parts reported success — and only complete() fails. You can upload 9,999 parts before finding out.
  • A single 1 KiB part completes: the 5 MiB floor does not apply to the last (or only) part.
  • Multipart etags carry a -<partCount> suffix.
{"standard":"","infrequent":""}

Empty string for both. Local-vs-production divergence.

localproduction
put/get/head/delete/listyesyes
conditional requestsyesyes
rangeyesyes
checksum verificationyesyes
multipart size rulesyesyes
storageClass""real value
event notifications → Queuesnot firedfired
1 write/sec per keynot enforcedenforced

Local data lives in .wrangler/state/v3/r2.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch12-r2",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "linkforge-exports" }]
}

package.json

{ "name": "ch12-r2", "private": true, "type": "module",
  "scripts": { "dev": "wrangler dev", "typecheck": "tsc --noEmit",
    "cf-typegen": "wrangler types --env-interface CloudflareBindings" },
  "devDependencies": { "typescript": "^5.9.0", "wrangler": "^4.114.0" } }

src/index.ts

// ---------------------------------------------------------------------------
// Chapter 12 — R2 behaviour probes.
// ---------------------------------------------------------------------------
const t = async (fn: () => Promise<unknown>): Promise<unknown> => {
  try { return { ok: await fn() }; }
  catch (e) { return { threw: String(e).slice(0, 200) }; }
};

export default {
  async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
    const url = new URL(request.url);
    const b = env.BUCKET;

    switch (url.pathname) {
      case "/seed": {
        await b.put("reports/2026-01.csv", "id,clicks\n1,10\n", {
          httpMetadata: { contentType: "text/csv" },
          customMetadata: { tenant: "t1" },
        });
        await b.put("reports/2026-02.csv", "id,clicks\n1,20\n");
        await b.put("exports/dump.json", JSON.stringify({ a: 1 }));
        await b.put("logo.png", new Uint8Array([137, 80, 78, 71]));
        return Response.json({ seeded: 4 });
      }

      // What comes back from get() vs head().
      case "/shapes": {
        const got = await b.get("reports/2026-01.csv");
        const head = await b.head("reports/2026-01.csv");
        return Response.json({
          getKeys: got ? Object.getOwnPropertyNames(Object.getPrototypeOf(got)) : null,
          hasBody: got ? "body" in got : null,
          headHasBody: head ? "body" in head : null,
          object: head && {
            key: head.key, size: head.size, etag: head.etag, httpEtag: head.httpEtag,
            storageClass: head.storageClass, checksums: Object.keys(head.checksums ?? {}),
            httpMetadata: head.httpMetadata, customMetadata: head.customMetadata,
          },
        });
      }

      // Conditional GET. A failed precondition returns an R2Object with NO body.
      case "/conditional": {
        const head = await b.head("reports/2026-01.csv");
        const etag = head!.httpEtag;
        const results: Record<string, unknown> = {};

        const match = await b.get("reports/2026-01.csv", {
          onlyIf: new Headers({ "if-none-match": etag }),
        });
        results.ifNoneMatch_matching = {
          isNull: match === null,
          hasBody: match !== null && "body" in match,
        };

        const nomatch = await b.get("reports/2026-01.csv", {
          onlyIf: new Headers({ "if-none-match": '"does-not-match"' }),
        });
        results.ifNoneMatch_notMatching = {
          isNull: nomatch === null,
          hasBody: nomatch !== null && "body" in nomatch,
        };

        // If-Match failure — the docs do not say how to tell 412 from 304.
        const ifMatchFail = await b.get("reports/2026-01.csv", {
          onlyIf: new Headers({ "if-match": '"nope"' }),
        });
        results.ifMatch_failing = {
          isNull: ifMatchFail === null,
          hasBody: ifMatchFail !== null && "body" in ifMatchFail,
        };

        // put() with a failing precondition returns null.
        results.put_failingPrecondition = await b.put("reports/2026-01.csv", "x", {
          onlyIf: { etagDoesNotMatch: head!.etag },
        }) === null ? "returned null" : "returned an object";

        return Response.json({ etag, ...results });
      }

      case "/range": {
        const full = await b.get("reports/2026-01.csv");
        const text = await full!.text();
        const first5 = await b.get("reports/2026-01.csv", { range: { offset: 0, length: 5 } });
        const suffix = await b.get("reports/2026-01.csv", { range: { suffix: 5 } });
        return Response.json({
          full: text,
          first5: await (first5 as R2ObjectBody).text(),
          suffix: await (suffix as R2ObjectBody).text(),
          rangeMeta: (first5 as R2ObjectBody).range,
        });
      }

      // delimiter + delimitedPrefixes is how you emulate folders.
      case "/list": {
        const flat = await b.list();
        const foldered = await b.list({ delimiter: "/" });
        const prefixed = await b.list({ prefix: "reports/", include: ["customMetadata"] });
        return Response.json({
          flat: { keys: flat.objects.map((o) => o.key), truncated: flat.truncated },
          foldered: {
            keys: foldered.objects.map((o) => o.key),
            delimitedPrefixes: foldered.delimitedPrefixes,
          },
          prefixed: prefixed.objects.map((o) => ({ key: o.key, custom: o.customMetadata })),
        });
      }

      // Checksums are verified on receipt.
      case "/checksum": {
        const body = "hello";
        const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
        const hex = [...new Uint8Array(digest)].map((x) => x.toString(16).padStart(2, "0")).join("");
        return Response.json({
          correct: await t(() => b.put("ck/ok", body, { sha256: hex })),
          wrong: await t(() => b.put("ck/bad", body, { sha256: "00".repeat(32) })),
          twoHashes: await t(() =>
            b.put("ck/two", body, { sha256: hex, md5: "00".repeat(16) }),
          ),
        });
      }

      // Multipart: parts upload fine; the size rule is enforced at complete().
      case "/multipart": {
        const out: Record<string, unknown> = {};

        // A. Even parts (6 MiB, 6 MiB, 1 MiB tail) — the legal shape.
        const even = await b.createMultipartUpload("big/even");
        const evenParts = [
          await even.uploadPart(1, new Uint8Array(6 * 1024 * 1024)),
          await even.uploadPart(2, new Uint8Array(6 * 1024 * 1024)),
          await even.uploadPart(3, new Uint8Array(1 * 1024 * 1024)), // last may be smaller
        ];
        out.evenComplete = await t(async () => {
          const o = await even.complete(evenParts);
          return { key: o.key, size: o.size, etag: o.etag };
        });

        // B. A small part in the MIDDLE — uploads fine, fails at complete().
        const uneven = await b.createMultipartUpload("big/uneven");
        const unevenParts = [
          await uneven.uploadPart(1, new Uint8Array(6 * 1024 * 1024)),
          await uneven.uploadPart(2, new Uint8Array(1 * 1024 * 1024)), // too small, not last
          await uneven.uploadPart(3, new Uint8Array(6 * 1024 * 1024)),
        ];
        out.allPartsUploadedOk = unevenParts.map((p) => p.partNumber);
        out.unevenComplete = await t(async () => {
          const o = await uneven.complete(unevenParts);
          return { key: o.key, size: o.size };
        });
        await t(() => uneven.abort());

        // C. A single part below the 5 MiB minimum is fine if it is the only one.
        const tiny = await b.createMultipartUpload("big/tiny");
        const tinyPart = await tiny.uploadPart(1, new Uint8Array(1024));
        out.singleSmallPart = await t(async () => {
          const o = await tiny.complete([tinyPart]);
          return { key: o.key, size: o.size };
        });

        return Response.json(out);
      }

      case "/storageclass": {
        const std = await b.put("sc/std", "x", { storageClass: "Standard" });
        const ia = await b.put("sc/ia", "x", { storageClass: "InfrequentAccess" });
        return Response.json({
          standard: std?.storageClass,
          infrequent: ia?.storageClass,
        });
      }

      // delete accepts up to 1000 keys and is a FREE operation.
      case "/delete": {
        await b.put("del/a", "1");
        await b.put("del/b", "2");
        await b.delete(["del/a", "del/b", "del/never-existed"]);
        return Response.json({ a: await b.head("del/a"), b: await b.head("del/b") });
      }

      default:
        return new Response(
          "try /seed /shapes /conditional /range /list /checksum /multipart /storageclass /delete\n",
          { status: 404 },
        );
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

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"] }