ch12-r2
在 GitHub 上檢視·4 個檔案·8.5 KB
取得並執行
這個範例可以獨立 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說明
ch12 — R2 behaviour probes
Section titled “ch12 — R2 behaviour probes”Companion example for docs/12-r2.md.
npm install && npm run devB=localhost:8787curl -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"Verified findings
Section titled “Verified findings”2026-07-28 · wrangler@4.114.0 · workerd@1.20260722.1
R2Object vs R2ObjectBody
Section titled “R2Object vs R2ObjectBody”{"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}}delimiter folds prefixes
Section titled “delimiter folds prefixes”{"foldered":{"keys":["logo.png"],"delimitedPrefixes":["exports/","reports/"]}}Far cheaper than listing everything and grouping client-side — ListObjects
is a Class A operation.
Checksums
Section titled “Checksums”{"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.
storageClass is not simulated locally
Section titled “storageClass is not simulated locally”{"standard":"","infrequent":""}Empty string for both. Local-vs-production divergence.
| local | production | |
|---|---|---|
| put/get/head/delete/list | yes | yes |
| conditional requests | yes | yes |
| range | yes | yes |
| checksum verification | yes | yes |
| multipart size rules | yes | yes |
| storageClass | "" | real value |
| event notifications → Queues | not fired | fired |
| 1 write/sec per key | not enforced | enforced |
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"] }