update skills

This commit is contained in:
2026-03-17 16:53:22 -07:00
parent 0b0783ef8e
commit f9a530667e
389 changed files with 54512 additions and 1 deletions

View File

@@ -0,0 +1,95 @@
# Cloudflare R2 Object Storage
S3-compatible object storage with zero egress fees, optimized for large file storage and delivery.
## Overview
R2 provides:
- S3-compatible API (Workers API + S3 REST)
- Zero egress fees globally
- Strong consistency for writes/deletes
- Storage classes (Standard/Infrequent Access)
- SSE-C encryption support
**Use cases:** Media storage, backups, static assets, user uploads, data lakes
## Quick Start
```bash
wrangler r2 bucket create my-bucket --location=enam
wrangler r2 object put my-bucket/file.txt --file=./local.txt
```
```typescript
// Upload
await env.MY_BUCKET.put(key, data, {
httpMetadata: { contentType: 'image/jpeg' }
});
// Download
const object = await env.MY_BUCKET.get(key);
if (object) return new Response(object.body);
```
## Core Operations
| Method | Purpose | Returns |
|--------|---------|---------|
| `put(key, value, options?)` | Upload object | `R2Object \| null` |
| `get(key, options?)` | Download object | `R2ObjectBody \| R2Object \| null` |
| `head(key)` | Get metadata only | `R2Object \| null` |
| `delete(keys)` | Delete object(s) | `Promise<void>` |
| `list(options?)` | List objects | `R2Objects` |
## Storage Classes
- **Standard**: Frequent access, low latency reads
- **InfrequentAccess**: 30-day minimum storage, retrieval fees, lower storage cost
## Event Notifications
R2 integrates with Cloudflare Queues for reactive workflows:
```typescript
// wrangler.jsonc
{
"event_notifications": [{
"queue": "r2-notifications",
"actions": ["PutObject", "DeleteObject"]
}]
}
// Consumer
async queue(batch: MessageBatch, env: Env) {
for (const message of batch.messages) {
const event = message.body; // { action, bucket, object, timestamps }
if (event.action === 'PutObject') {
// Process upload: thumbnail generation, virus scan, etc.
}
}
}
```
## Reading Order
**First-time users:** README → configuration.md → api.md → patterns.md
**Specific tasks:**
- Setup: configuration.md
- Client uploads: patterns.md (presigned URLs)
- Public static site: patterns.md (public access + custom domain)
- Processing uploads: README (event notifications) + queues reference
- Debugging: gotchas.md
## In This Reference
- [configuration.md](./configuration.md) - Bindings, S3 SDK, CORS, lifecycles, token scopes
- [api.md](./api.md) - Workers API, multipart, conditional requests, presigned URLs
- [patterns.md](./patterns.md) - Streaming, caching, client uploads, public buckets
- [gotchas.md](./gotchas.md) - List truncation, etag format, stream length, S3 SDK region
## See Also
- [workers](../workers/) - Worker runtime and fetch handlers
- [kv](../kv/) - Metadata storage for R2 objects
- [d1](../d1/) - Store R2 URLs in relational database
- [queues](../queues/) - Process R2 uploads asynchronously

View File

@@ -0,0 +1,200 @@
# R2 API Reference
## PUT (Upload)
```typescript
// Basic
await env.MY_BUCKET.put(key, value);
// With metadata
await env.MY_BUCKET.put(key, value, {
httpMetadata: {
contentType: 'image/jpeg',
contentDisposition: 'attachment; filename="photo.jpg"',
cacheControl: 'max-age=3600'
},
customMetadata: { userId: '123', version: '2' },
storageClass: 'Standard', // or 'InfrequentAccess'
sha256: arrayBufferOrHex, // Integrity check
ssecKey: arrayBuffer32bytes // SSE-C encryption
});
// Value types: ReadableStream | ArrayBuffer | string | Blob
```
## GET (Download)
```typescript
const object = await env.MY_BUCKET.get(key);
if (!object) return new Response('Not found', { status: 404 });
// Body: arrayBuffer(), text(), json(), blob(), body (ReadableStream)
// Ranged reads
const object = await env.MY_BUCKET.get(key, { range: { offset: 0, length: 1024 } });
// Conditional GET
const object = await env.MY_BUCKET.get(key, { onlyIf: { etagMatches: '"abc123"' } });
```
## HEAD (Metadata Only)
```typescript
const object = await env.MY_BUCKET.head(key); // Returns R2Object without body
```
## DELETE
```typescript
await env.MY_BUCKET.delete(key);
await env.MY_BUCKET.delete([key1, key2, key3]); // Batch (max 1000)
```
## LIST
```typescript
const listed = await env.MY_BUCKET.list({
limit: 1000,
prefix: 'photos/',
cursor: cursorFromPrevious,
delimiter: '/',
include: ['httpMetadata', 'customMetadata']
});
// Pagination (always use truncated flag)
while (listed.truncated) {
const next = await env.MY_BUCKET.list({ cursor: listed.cursor });
listed.objects.push(...next.objects);
listed.truncated = next.truncated;
listed.cursor = next.cursor;
}
```
## Multipart Uploads
```typescript
const multipart = await env.MY_BUCKET.createMultipartUpload(key, {
httpMetadata: { contentType: 'video/mp4' }
});
const uploadedParts: R2UploadedPart[] = [];
for (let i = 0; i < partCount; i++) {
const part = await multipart.uploadPart(i + 1, partData);
uploadedParts.push(part);
}
const object = await multipart.complete(uploadedParts);
// OR: await multipart.abort();
// Resume
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
```
## Presigned URLs (S3 SDK)
```typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY }
});
const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ Bucket: 'my-bucket', Key: key }), { expiresIn: 3600 });
return Response.json({ uploadUrl });
```
## TypeScript Interfaces
```typescript
interface R2Bucket {
head(key: string): Promise<R2Object | null>;
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>;
put(key: string, value: ReadableStream | ArrayBuffer | string | Blob, options?: R2PutOptions): Promise<R2Object | null>;
delete(keys: string | string[]): Promise<void>;
list(options?: R2ListOptions): Promise<R2Objects>;
createMultipartUpload(key: string, options?: R2MultipartOptions): Promise<R2MultipartUpload>;
resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload;
}
interface R2Object {
key: string; version: string; size: number;
etag: string; httpEtag: string; // httpEtag is quoted, use for headers
uploaded: Date; httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
storageClass: 'Standard' | 'InfrequentAccess';
checksums: R2Checksums;
writeHttpMetadata(headers: Headers): void;
}
interface R2ObjectBody extends R2Object {
body: ReadableStream; bodyUsed: boolean;
arrayBuffer(): Promise<ArrayBuffer>; text(): Promise<string>;
json<T>(): Promise<T>; blob(): Promise<Blob>;
}
interface R2HTTPMetadata {
contentType?: string; contentDisposition?: string;
contentEncoding?: string; contentLanguage?: string;
cacheControl?: string; cacheExpiry?: Date;
}
interface R2PutOptions {
httpMetadata?: R2HTTPMetadata | Headers;
customMetadata?: Record<string, string>;
sha256?: ArrayBuffer | string; // Only ONE checksum allowed
storageClass?: 'Standard' | 'InfrequentAccess';
ssecKey?: ArrayBuffer;
}
interface R2GetOptions {
onlyIf?: R2Conditional | Headers;
range?: R2Range | Headers;
ssecKey?: ArrayBuffer;
}
interface R2ListOptions {
limit?: number; prefix?: string; cursor?: string; delimiter?: string;
startAfter?: string; include?: ('httpMetadata' | 'customMetadata')[];
}
interface R2Objects {
objects: R2Object[]; truncated: boolean;
cursor?: string; delimitedPrefixes: string[];
}
interface R2Conditional {
etagMatches?: string; etagDoesNotMatch?: string;
uploadedBefore?: Date; uploadedAfter?: Date;
}
interface R2Range { offset?: number; length?: number; suffix?: number; }
interface R2Checksums {
md5?: ArrayBuffer; sha1?: ArrayBuffer; sha256?: ArrayBuffer;
sha384?: ArrayBuffer; sha512?: ArrayBuffer;
}
interface R2MultipartUpload {
key: string;
uploadId: string;
uploadPart(partNumber: number, value: ReadableStream | ArrayBuffer | string | Blob): Promise<R2UploadedPart>;
abort(): Promise<void>;
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>;
}
interface R2UploadedPart {
partNumber: number;
etag: string;
}
```
## CLI Operations
```bash
wrangler r2 object put my-bucket/file.txt --file=./local.txt
wrangler r2 object get my-bucket/file.txt --file=./download.txt
wrangler r2 object delete my-bucket/file.txt
wrangler r2 object list my-bucket --prefix=photos/
```

View File

@@ -0,0 +1,165 @@
# R2 Configuration
## Workers Binding
**wrangler.jsonc:**
```jsonc
{
"r2_buckets": [
{
"binding": "MY_BUCKET",
"bucket_name": "my-bucket-name"
}
]
}
```
## TypeScript Types
```typescript
interface Env { MY_BUCKET: R2Bucket; }
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const object = await env.MY_BUCKET.get('file.txt');
return new Response(object?.body);
}
}
```
## S3 SDK Setup
```typescript
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY
}
});
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: data,
StorageClass: 'STANDARD' // or 'STANDARD_IA'
}));
```
## Location Hints
```bash
wrangler r2 bucket create my-bucket --location=enam
# Hints: wnam, enam, weur, eeur, apac, oc
# Jurisdictions (override hint): --jurisdiction=eu (or fedramp)
```
## CORS Configuration
CORS must be configured via S3 SDK or dashboard (not available in Workers API):
```typescript
import { S3Client, PutBucketCorsCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY
}
});
await s3.send(new PutBucketCorsCommand({
Bucket: 'my-bucket',
CORSConfiguration: {
CORSRules: [{
AllowedOrigins: ['https://example.com'],
AllowedMethods: ['GET', 'PUT', 'HEAD'],
AllowedHeaders: ['*'],
ExposeHeaders: ['ETag'],
MaxAgeSeconds: 3600
}]
}
}));
```
## Object Lifecycles
```typescript
import { PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';
await s3.send(new PutBucketLifecycleConfigurationCommand({
Bucket: 'my-bucket',
LifecycleConfiguration: {
Rules: [
{
ID: 'expire-old-logs',
Status: 'Enabled',
Prefix: 'logs/',
Expiration: { Days: 90 }
},
{
ID: 'transition-to-ia',
Status: 'Enabled',
Prefix: 'archives/',
Transitions: [{ Days: 30, StorageClass: 'STANDARD_IA' }]
}
]
}
}));
```
## API Token Scopes
When creating R2 tokens, set minimal permissions:
| Permission | Use Case |
|------------|----------|
| Object Read | Public serving, downloads |
| Object Write | Uploads only |
| Object Read & Write | Full object operations |
| Admin Read & Write | Bucket management, CORS, lifecycles |
**Best practice:** Separate tokens for Workers (read/write) vs admin tasks (CORS, lifecycles).
## Event Notifications
```jsonc
// wrangler.jsonc
{
"r2_buckets": [
{
"binding": "MY_BUCKET",
"bucket_name": "my-bucket",
"event_notifications": [
{
"queue": "r2-events",
"actions": ["PutObject", "DeleteObject", "CompleteMultipartUpload"]
}
]
}
],
"queues": {
"producers": [{ "binding": "R2_EVENTS", "queue": "r2-events" }],
"consumers": [{ "queue": "r2-events", "max_batch_size": 10 }]
}
}
```
## Bucket Management
```bash
wrangler r2 bucket create my-bucket --location=enam --storage-class=Standard
wrangler r2 bucket list
wrangler r2 bucket info my-bucket
wrangler r2 bucket delete my-bucket # Must be empty
wrangler r2 bucket update-storage-class my-bucket --storage-class=InfrequentAccess
# Public bucket via dashboard
wrangler r2 bucket domain add my-bucket --domain=files.example.com
```

View File

@@ -0,0 +1,190 @@
# R2 Gotchas & Troubleshooting
## List Truncation
```typescript
// ❌ WRONG: Don't compare object count when using include
while (listed.objects.length < options.limit) { ... }
// ✅ CORRECT: Always use truncated property
while (listed.truncated) {
const next = await env.MY_BUCKET.list({ cursor: listed.cursor });
// ...
}
```
**Reason:** `include` with metadata may return fewer objects per page to fit metadata.
## ETag Format
```typescript
// ❌ WRONG: Using etag (unquoted) in headers
headers.set('etag', object.etag); // Missing quotes
// ✅ CORRECT: Use httpEtag (quoted)
headers.set('etag', object.httpEtag);
```
## Checksum Limits
Only ONE checksum algorithm allowed per PUT:
```typescript
// ❌ WRONG: Multiple checksums
await env.MY_BUCKET.put(key, data, { md5: hash1, sha256: hash2 }); // Error
// ✅ CORRECT: Pick one
await env.MY_BUCKET.put(key, data, { sha256: hash });
```
## Multipart Requirements
- All parts must be uniform size (except last part)
- Part numbers start at 1 (not 0)
- Uncompleted uploads auto-abort after 7 days
- `resumeMultipartUpload` doesn't validate uploadId existence
## Conditional Operations
```typescript
// Precondition failure returns object WITHOUT body
const object = await env.MY_BUCKET.get(key, {
onlyIf: { etagMatches: '"wrong"' }
});
// Check for body, not just null
if (!object) return new Response('Not found', { status: 404 });
if (!object.body) return new Response(null, { status: 304 }); // Precondition failed
```
## Key Validation
```typescript
// ❌ DANGEROUS: Path traversal
const key = url.pathname.slice(1); // Could be ../../../etc/passwd
await env.MY_BUCKET.get(key);
// ✅ SAFE: Validate keys
if (!key || key.includes('..') || key.startsWith('/')) {
return new Response('Invalid key', { status: 400 });
}
```
## Storage Class Pitfalls
- InfrequentAccess: 30-day minimum billing (even if deleted early)
- Can't transition IA → Standard via lifecycle (use S3 CopyObject)
- Retrieval fees apply for IA reads
## Stream Length Requirement
```typescript
// ❌ WRONG: Streaming unknown length fails silently
const response = await fetch(url);
await env.MY_BUCKET.put(key, response.body); // May fail without error
// ✅ CORRECT: Buffer or use Content-Length
const data = await response.arrayBuffer();
await env.MY_BUCKET.put(key, data);
// OR: Pass Content-Length if known
const object = await env.MY_BUCKET.put(key, request.body, {
httpMetadata: {
contentLength: parseInt(request.headers.get('content-length') || '0')
}
});
```
**Reason:** R2 requires known length for streams. Unknown length may cause silent truncation.
## S3 SDK Region Configuration
```typescript
// ❌ WRONG: Missing region breaks ALL S3 SDK calls
const s3 = new S3Client({
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { ... }
});
// ✅ CORRECT: MUST set region='auto'
const s3 = new S3Client({
region: 'auto', // REQUIRED
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { ... }
});
```
**Reason:** S3 SDK requires region. R2 uses 'auto' as placeholder.
## Local Development Limits
```typescript
// ❌ Miniflare/wrangler dev: Limited R2 support
// - No multipart uploads
// - No presigned URLs (requires S3 SDK + network)
// - Memory-backed storage (lost on restart)
// ✅ Use remote bindings for full features
wrangler dev --remote
// OR: Conditional logic
if (env.ENVIRONMENT === 'development') {
// Fallback for local dev
} else {
// Full R2 features
}
```
## Presigned URL Expiry
```typescript
// ❌ WRONG: URL expires but no client validation
const url = await getSignedUrl(s3, command, { expiresIn: 60 });
// 61 seconds later: 403 Forbidden
// ✅ CORRECT: Return expiry to client
return Response.json({
uploadUrl: url,
expiresAt: new Date(Date.now() + 60000).toISOString()
});
```
## Limits
| Limit | Value |
|-------|-------|
| Object size | 5 TB |
| Multipart part count | 10,000 |
| Multipart part min size | 5 MB (except last) |
| Batch delete | 1,000 keys |
| List limit | 1,000 per request |
| Key size | 1024 bytes |
| Custom metadata | 2 KB per object |
| Presigned URL max expiry | 7 days |
## Common Errors
### "Stream upload failed" / Silent Truncation
**Cause:** Stream length unknown or Content-Length missing
**Solution:** Buffer data or pass explicit Content-Length
### "Invalid credentials" / S3 SDK
**Cause:** Missing `region: 'auto'` in S3Client config
**Solution:** Always set `region: 'auto'` for R2
### "Object not found"
**Cause:** Object key doesn't exist or was deleted
**Solution:** Verify object key correct, check if object was deleted, ensure bucket correct
### "List compatibility error"
**Cause:** Missing or old compatibility_date, or flag not enabled
**Solution:** Set `compatibility_date >= 2022-08-04` or enable `r2_list_honor_include` flag
### "Multipart upload failed"
**Cause:** Part sizes not uniform or incorrect part number
**Solution:** Ensure uniform size except final part, verify part numbers start at 1

View File

@@ -0,0 +1,193 @@
# R2 Patterns & Best Practices
## Streaming Large Files
```typescript
const object = await env.MY_BUCKET.get(key);
if (!object) return new Response('Not found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
return new Response(object.body, { headers });
```
## Conditional GET (304 Not Modified)
```typescript
const ifNoneMatch = request.headers.get('if-none-match');
const object = await env.MY_BUCKET.get(key, {
onlyIf: { etagDoesNotMatch: ifNoneMatch?.replace(/"/g, '') || '' }
});
if (!object) return new Response('Not found', { status: 404 });
if (!object.body) return new Response(null, { status: 304, headers: { 'etag': object.httpEtag } });
return new Response(object.body, { headers: { 'etag': object.httpEtag } });
```
## Upload with Validation
```typescript
const key = url.pathname.slice(1);
if (!key || key.includes('..')) return new Response('Invalid key', { status: 400 });
const object = await env.MY_BUCKET.put(key, request.body, {
httpMetadata: { contentType: request.headers.get('content-type') || 'application/octet-stream' },
customMetadata: { uploadedAt: new Date().toISOString(), ip: request.headers.get('cf-connecting-ip') || 'unknown' }
});
return Response.json({ key: object.key, size: object.size, etag: object.httpEtag });
```
## Multipart with Progress
```typescript
const PART_SIZE = 5 * 1024 * 1024; // 5MB
const partCount = Math.ceil(file.size / PART_SIZE);
const multipart = await env.MY_BUCKET.createMultipartUpload(key, { httpMetadata: { contentType: file.type } });
const uploadedParts: R2UploadedPart[] = [];
try {
for (let i = 0; i < partCount; i++) {
const start = i * PART_SIZE;
const part = await multipart.uploadPart(i + 1, file.slice(start, start + PART_SIZE));
uploadedParts.push(part);
onProgress?.(Math.round(((i + 1) / partCount) * 100));
}
return await multipart.complete(uploadedParts);
} catch (error) {
await multipart.abort();
throw error;
}
```
## Batch Delete
```typescript
async function deletePrefix(prefix: string, env: Env) {
let cursor: string | undefined;
let truncated = true;
while (truncated) {
const listed = await env.MY_BUCKET.list({ prefix, limit: 1000, cursor });
if (listed.objects.length > 0) {
await env.MY_BUCKET.delete(listed.objects.map(o => o.key));
}
truncated = listed.truncated;
cursor = listed.cursor;
}
}
```
## Checksum Validation & Storage Transitions
```typescript
// Upload with checksum
const hash = await crypto.subtle.digest('SHA-256', data);
await env.MY_BUCKET.put(key, data, { sha256: hash });
// Transition storage class (requires S3 SDK)
import { S3Client, CopyObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new CopyObjectCommand({
Bucket: 'my-bucket', Key: key,
CopySource: `/my-bucket/${key}`,
StorageClass: 'STANDARD_IA'
}));
```
## Client-Side Uploads (Presigned URLs)
```typescript
import { S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand } from '@aws-sdk/client-s3';
// Worker: Generate presigned upload URL
const s3 = new S3Client({
region: 'auto',
endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY }
});
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: 'my-bucket', Key: key }), { expiresIn: 3600 });
return Response.json({ uploadUrl: url });
// Client: Upload directly
const { uploadUrl } = await fetch('/api/upload-url').then(r => r.json());
await fetch(uploadUrl, { method: 'PUT', body: file });
```
## Caching with Cache API
```typescript
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default;
const url = new URL(request.url);
const cacheKey = new Request(url.toString(), request);
// Check cache first
let response = await cache.match(cacheKey);
if (response) return response;
// Fetch from R2
const key = url.pathname.slice(1);
const object = await env.MY_BUCKET.get(key);
if (!object) return new Response('Not found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('cache-control', 'public, max-age=31536000, immutable');
response = new Response(object.body, { headers });
// Cache for subsequent requests
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
};
```
## Public Bucket with Custom Domain
```typescript
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, HEAD',
'access-control-max-age': '86400'
}
});
}
const key = new URL(request.url).pathname.slice(1);
if (!key) return Response.redirect('/index.html', 302);
const object = await env.MY_BUCKET.get(key);
if (!object) return new Response('Not found', { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('access-control-allow-origin', '*');
headers.set('cache-control', 'public, max-age=31536000, immutable');
return new Response(object.body, { headers });
}
};
```
## r2.dev Public URLs
Enable r2.dev in dashboard for simple public access: `https://pub-${hashId}.r2.dev/${key}`
Or add custom domain via dashboard: `https://files.example.com/${key}`
**Limitations:** No auth, bucket-level CORS, no cache override.