
Cloudflare R2
- 128 installs
- 51 repo stars
- Updated November 25, 2025
- ovachiever/droid-tings
Extends Claude Code with specialized agent capabilities for developer workflows.
About
Skill for managing Cloudflare R2 object storage operations in Claude Code agent workflows.
- Agent skill
- Developer productivity
- Workflow automation
Cloudflare R2 by the numbers
- 128 all-time installs (skills.sh)
- Ranked #3,636 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ovachiever/droid-tings --skill cloudflare-r2Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 128 |
|---|---|
| repo stars | ★ 51 |
| Last updated | November 25, 2025 |
| Repository | ovachiever/droid-tings ↗ |
What it does
Extends Claude Code with specialized agent capabilities for developer workflows.
Files
Cloudflare R2 Object Storage
Status: Production Ready ✅ Last Updated: 2025-10-21 Dependencies: cloudflare-worker-base (for Worker setup) Latest Versions: wrangler@4.43.0, @cloudflare/workers-types@4.20251014.0, aws4fetch@1.0.20
---
Quick Start (5 Minutes)
1. Create R2 Bucket
# Via Wrangler CLI (recommended)
npx wrangler r2 bucket create my-bucket
# Or via Cloudflare Dashboard
# https://dash.cloudflare.com → R2 Object Storage → Create bucketBucket Naming Rules:
- 3-63 characters
- Lowercase letters, numbers, hyphens only
- Must start/end with letter or number
- Globally unique within your account
2. Configure R2 Binding
Add to your wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"r2_buckets": [
{
"binding": "MY_BUCKET", // Available as env.MY_BUCKET in your Worker
"bucket_name": "my-bucket", // Name from wrangler r2 bucket create
"preview_bucket_name": "my-bucket-preview" // Optional: separate bucket for dev
}
]
}CRITICAL:
bindingis how you access the bucket in code (env.MY_BUCKET)bucket_nameis the actual R2 bucket namepreview_bucket_nameis optional but recommended for separate dev/prod data
3. Basic Upload/Download
// src/index.ts
import { Hono } from 'hono';
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Upload file
app.put('/upload/:filename', async (c) => {
const filename = c.req.param('filename');
const body = await c.req.arrayBuffer();
try {
const object = await c.env.MY_BUCKET.put(filename, body, {
httpMetadata: {
contentType: c.req.header('content-type') || 'application/octet-stream',
},
});
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
});
} catch (error: any) {
console.error('R2 Upload Error:', error.message);
return c.json({ error: 'Upload failed' }, 500);
}
});
// Download file
app.get('/download/:filename', async (c) => {
const filename = c.req.param('filename');
try {
const object = await c.env.MY_BUCKET.get(filename);
if (!object) {
return c.json({ error: 'File not found' }, 404);
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
'Cache-Control': object.httpMetadata?.cacheControl || 'public, max-age=3600',
},
});
} catch (error: any) {
console.error('R2 Download Error:', error.message);
return c.json({ error: 'Download failed' }, 500);
}
});
export default app;4. Deploy and Test
# Deploy
npx wrangler deploy
# Test upload
curl -X PUT https://my-worker.workers.dev/upload/test.txt \
-H "Content-Type: text/plain" \
-d "Hello, R2!"
# Test download
curl https://my-worker.workers.dev/download/test.txt---
R2 Workers API
Type Definitions
// Add to env.d.ts or worker-configuration.d.ts
interface Env {
MY_BUCKET: R2Bucket;
// ... other bindings
}
// For Hono
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();put() - Upload Objects
Signature:
put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, options?: R2PutOptions): Promise<R2Object | null>Basic Usage:
// Upload from request body
await env.MY_BUCKET.put('path/to/file.txt', request.body);
// Upload string
await env.MY_BUCKET.put('config.json', JSON.stringify({ foo: 'bar' }));
// Upload ArrayBuffer
await env.MY_BUCKET.put('image.png', await file.arrayBuffer());With Metadata:
const object = await env.MY_BUCKET.put('document.pdf', fileData, {
httpMetadata: {
contentType: 'application/pdf',
contentLanguage: 'en-US',
contentDisposition: 'attachment; filename="report.pdf"',
contentEncoding: 'gzip',
cacheControl: 'public, max-age=86400',
},
customMetadata: {
userId: '12345',
uploadDate: new Date().toISOString(),
version: '1.0',
},
});Conditional Uploads (Prevent Overwrites):
// Only upload if file doesn't exist
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: {
uploadedBefore: new Date('2020-01-01'), // Any date before R2 existed
},
});
if (!object) {
// File already exists, upload prevented
return c.json({ error: 'File already exists' }, 409);
}
// Only upload if etag matches (update specific version)
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: {
etagMatches: existingEtag,
},
});With Checksums:
// R2 will verify the checksum
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('file.txt', fileData, {
md5: md5Hash,
});get() - Download Objects
Signature:
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>Basic Usage:
// Get full object
const object = await env.MY_BUCKET.get('file.txt');
if (!object) {
return c.json({ error: 'Not found' }, 404);
}
// Return as response
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
},
});Read as Different Formats:
const object = await env.MY_BUCKET.get('data.json');
if (object) {
const text = await object.text(); // As string
const json = await object.json(); // As JSON object
const buffer = await object.arrayBuffer(); // As ArrayBuffer
const blob = await object.blob(); // As Blob
}Range Requests (Partial Downloads):
// Get first 1MB of file
const object = await env.MY_BUCKET.get('large-file.mp4', {
range: { offset: 0, length: 1024 * 1024 },
});
// Get bytes 100-200
const object = await env.MY_BUCKET.get('file.bin', {
range: { offset: 100, length: 100 },
});
// Get from offset to end
const object = await env.MY_BUCKET.get('file.bin', {
range: { offset: 1000 },
});Conditional Downloads:
// Only download if etag matches
const object = await env.MY_BUCKET.get('file.txt', {
onlyIf: {
etagMatches: cachedEtag,
},
});
if (!object) {
// Etag didn't match, file was modified
return c.json({ error: 'File changed' }, 412);
}head() - Get Metadata Only
Signature:
head(key: string): Promise<R2Object | null>Usage:
// Get object metadata without downloading body
const object = await env.MY_BUCKET.head('file.txt');
if (object) {
console.log({
key: object.key,
size: object.size,
etag: object.etag,
uploaded: object.uploaded,
contentType: object.httpMetadata?.contentType,
customMetadata: object.customMetadata,
});
}Use Cases:
- Check if file exists
- Get file size before downloading
- Check last modified date
- Validate etag for caching
delete() - Delete Objects
Signature:
delete(key: string | string[]): Promise<void>Single Delete:
// Delete single object
await env.MY_BUCKET.delete('file.txt');
// No error if file doesn't exist (idempotent)Bulk Delete (Up to 1000 keys):
// Delete multiple objects at once
const keysToDelete = [
'old-file-1.txt',
'old-file-2.txt',
'temp/cache-data.json',
];
await env.MY_BUCKET.delete(keysToDelete);
// Much faster than individual deletesDelete with Confirmation:
app.delete('/files/:filename', async (c) => {
const filename = c.req.param('filename');
// Check if exists first
const exists = await c.env.MY_BUCKET.head(filename);
if (!exists) {
return c.json({ error: 'File not found' }, 404);
}
await c.env.MY_BUCKET.delete(filename);
return c.json({ success: true, deleted: filename });
});list() - List Objects
Signature:
list(options?: R2ListOptions): Promise<R2Objects>Basic Listing:
// List all objects (up to 1000)
const listed = await env.MY_BUCKET.list();
console.log({
objects: listed.objects, // Array of R2Object
truncated: listed.truncated, // true if more results exist
cursor: listed.cursor, // For pagination
});
// Process objects
for (const object of listed.objects) {
console.log(`${object.key}: ${object.size} bytes`);
}Pagination:
app.get('/api/files', async (c) => {
const cursor = c.req.query('cursor');
const listed = await c.env.MY_BUCKET.list({
limit: 100,
cursor: cursor || undefined,
});
return c.json({
files: listed.objects.map(obj => ({
name: obj.key,
size: obj.size,
uploaded: obj.uploaded,
etag: obj.etag,
})),
hasMore: listed.truncated,
nextCursor: listed.cursor,
});
});Prefix Filtering (List by Directory):
// List all files in 'images/' folder
const images = await env.MY_BUCKET.list({
prefix: 'images/',
});
// List all user files
const userFiles = await env.MY_BUCKET.list({
prefix: `users/${userId}/`,
});Delimiter (Folder-like Listing):
// List only top-level items in 'uploads/'
const listed = await env.MY_BUCKET.list({
prefix: 'uploads/',
delimiter: '/',
});
console.log('Files:', listed.objects); // Files directly in uploads/
console.log('Folders:', listed.delimitedPrefixes); // Sub-folders like uploads/2024/---
Multipart Uploads
For files larger than 100MB or for resumable uploads, use multipart upload API.
When to Use Multipart Upload
✅ Use multipart when:
- File size > 100MB
- Need resumable uploads
- Want to parallelize upload
- Uploading from browser/client
❌ Don't use multipart when:
- File size < 5MB (overhead not worth it)
- Uploading within Worker (use direct put())
Basic Multipart Upload Flow
app.post('/api/upload/start', async (c) => {
const { filename } = await c.req.json();
// Create multipart upload
const multipart = await c.env.MY_BUCKET.createMultipartUpload(filename, {
httpMetadata: {
contentType: 'application/octet-stream',
},
});
return c.json({
key: multipart.key,
uploadId: multipart.uploadId,
});
});
app.put('/api/upload/part', async (c) => {
const { key, uploadId, partNumber } = await c.req.json();
const body = await c.req.arrayBuffer();
// Resume the multipart upload
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
// Upload a part
const uploadedPart = await multipart.uploadPart(partNumber, body);
return c.json({
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
});
});
app.post('/api/upload/complete', async (c) => {
const { key, uploadId, parts } = await c.req.json();
// Resume the multipart upload
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
// Complete the upload
const object = await multipart.complete(parts);
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
});
});
app.post('/api/upload/abort', async (c) => {
const { key, uploadId } = await c.req.json();
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
await multipart.abort();
return c.json({ success: true });
});Complete Multipart Upload Example
// Full Worker implementing multipart upload API
interface Env {
MY_BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1);
const action = url.searchParams.get('action');
switch (request.method) {
case 'POST': {
switch (action) {
case 'mpu-create': {
// Create multipart upload
const multipart = await env.MY_BUCKET.createMultipartUpload(key);
return Response.json({
key: multipart.key,
uploadId: multipart.uploadId,
});
}
case 'mpu-complete': {
// Complete multipart upload
const uploadId = url.searchParams.get('uploadId')!;
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const parts = await request.json();
const object = await multipart.complete(parts);
return Response.json({
key: object.key,
etag: object.etag,
size: object.size,
});
}
default:
return new Response(`Unknown action: ${action}`, { status: 400 });
}
}
case 'PUT': {
switch (action) {
case 'mpu-uploadpart': {
// Upload a part
const uploadId = url.searchParams.get('uploadId')!;
const partNumber = parseInt(url.searchParams.get('partNumber')!);
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const uploadedPart = await multipart.uploadPart(partNumber, request.body!);
return Response.json({
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
});
}
default:
return new Response(`Unknown action: ${action}`, { status: 400 });
}
}
case 'DELETE': {
switch (action) {
case 'mpu-abort': {
// Abort multipart upload
const uploadId = url.searchParams.get('uploadId')!;
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
await multipart.abort();
return new Response(null, { status: 204 });
}
default:
return new Response(`Unknown action: ${action}`, { status: 400 });
}
}
default:
return new Response('Method Not Allowed', { status: 405 });
}
},
} satisfies ExportedHandler<Env>;---
Presigned URLs
Presigned URLs allow clients to upload/download objects directly to/from R2 without going through your Worker.
When to Use Presigned URLs
✅ Use presigned URLs when:
- Client uploads directly to R2 (saves Worker bandwidth)
- Temporary access to private objects
- Sharing download links with expiry
- Avoiding Worker request size limits
❌ Don't use presigned URLs when:
- You need to process/validate uploads
- Files are already public
- Using custom domains (presigned URLs only work with R2 endpoint)
Generate Presigned URLs with aws4fetch
npm install aws4fetchimport { AwsClient } from 'aws4fetch';
interface Env {
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
ACCOUNT_ID: string;
MY_BUCKET: R2Bucket;
}
const app = new Hono<{ Bindings: Env }>();
app.post('/api/presigned-upload', async (c) => {
const { filename } = await c.req.json();
// Create AWS client for R2
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket';
const accountId = c.env.ACCOUNT_ID;
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
// Set expiry (1 hour)
url.searchParams.set('X-Amz-Expires', '3600');
// Sign the URL for PUT
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return c.json({
uploadUrl: signed.url,
expiresIn: 3600,
});
});
app.post('/api/presigned-download', async (c) => {
const { filename } = await c.req.json();
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket';
const accountId = c.env.ACCOUNT_ID;
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600');
const signed = await r2Client.sign(
new Request(url, { method: 'GET' }),
{ aws: { signQuery: true } }
);
return c.json({
downloadUrl: signed.url,
expiresIn: 3600,
});
});Client-Side Upload with Presigned URL
// 1. Get presigned URL from your Worker
const response = await fetch('https://my-worker.workers.dev/api/presigned-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'photo.jpg' }),
});
const { uploadUrl } = await response.json();
// 2. Upload file directly to R2
const file = document.querySelector('input[type="file"]').files[0];
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});Presigned URL Security
CRITICAL:
- ❌ NEVER expose R2 access keys in client-side code
- ✅ ALWAYS generate presigned URLs server-side
- ✅ ALWAYS set appropriate expiry times (1-24 hours typical)
- ✅ CONSIDER adding authentication before generating URLs
- ✅ CONSIDER rate limiting presigned URL generation
// Example with auth check
app.post('/api/presigned-upload', async (c) => {
// Verify user is authenticated
const authHeader = c.req.header('Authorization');
if (!authHeader) {
return c.json({ error: 'Unauthorized' }, 401);
}
// Validate user has permission
const userId = await verifyToken(authHeader);
if (!userId) {
return c.json({ error: 'Invalid token' }, 401);
}
// Only allow uploads to user's own folder
const { filename } = await c.req.json();
const key = `users/${userId}/${filename}`;
// Generate presigned URL...
});---
CORS Configuration
Configure CORS to allow browser requests to your R2 bucket.
Public Bucket CORS
{
"CORSRules": [
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
}Allow All Origins (Public Assets)
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range"],
"MaxAgeSeconds": 3600
}
]
}Upload with CORS
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": [
"Content-Type",
"Content-MD5",
"x-amz-meta-*"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}Apply CORS via Dashboard
1. Go to Cloudflare Dashboard → R2 2. Select your bucket 3. Go to Settings tab 4. Under CORS Policy → Add CORS policy 5. Paste JSON configuration 6. Save
CORS for Presigned URLs
When using presigned URLs, CORS is handled by R2 directly. Configure CORS on the bucket, not in your Worker.
---
HTTP Metadata
Content-Type
// Set content type on upload
await env.MY_BUCKET.put('image.jpg', imageData, {
httpMetadata: {
contentType: 'image/jpeg',
},
});
// Will be returned in Content-Type header when downloadedCache-Control
// Set caching headers
await env.MY_BUCKET.put('static/logo.png', logoData, {
httpMetadata: {
contentType: 'image/png',
cacheControl: 'public, max-age=31536000, immutable',
},
});
// For frequently updated content
await env.MY_BUCKET.put('api/data.json', jsonData, {
httpMetadata: {
contentType: 'application/json',
cacheControl: 'public, max-age=60, must-revalidate',
},
});Content-Disposition
// Force download with specific filename
await env.MY_BUCKET.put('report.pdf', pdfData, {
httpMetadata: {
contentType: 'application/pdf',
contentDisposition: 'attachment; filename="monthly-report.pdf"',
},
});
// Display inline
await env.MY_BUCKET.put('image.jpg', imageData, {
httpMetadata: {
contentType: 'image/jpeg',
contentDisposition: 'inline',
},
});Content-Encoding
// Indicate gzip compression
await env.MY_BUCKET.put('data.json.gz', gzippedData, {
httpMetadata: {
contentType: 'application/json',
contentEncoding: 'gzip',
},
});---
Custom Metadata
Store arbitrary key-value metadata with objects.
Setting Custom Metadata
await env.MY_BUCKET.put('document.pdf', pdfData, {
customMetadata: {
userId: '12345',
department: 'engineering',
uploadDate: new Date().toISOString(),
version: '1.0',
approved: 'true',
},
});Reading Custom Metadata
const object = await env.MY_BUCKET.head('document.pdf');
if (object) {
console.log(object.customMetadata);
// {
// userId: '12345',
// department: 'engineering',
// uploadDate: '2025-10-21T10:00:00.000Z',
// version: '1.0',
// approved: 'true'
// }
}Limitations
- Max 2KB total size for all custom metadata
- Keys and values must be strings
- Keys are case-insensitive
- No limit on number of keys (within 2KB total)
---
Error Handling
Common R2 Errors
try {
await env.MY_BUCKET.put(key, data);
} catch (error: any) {
const message = error.message;
if (message.includes('R2_ERROR')) {
// Generic R2 error
} else if (message.includes('exceeded')) {
// Quota exceeded
} else if (message.includes('precondition')) {
// Conditional operation failed
} else if (message.includes('multipart')) {
// Multipart upload error
}
console.error('R2 Error:', message);
return c.json({ error: 'Storage operation failed' }, 500);
}Retry Logic
async function r2WithRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
const message = error.message;
// Retry on transient errors
const isRetryable =
message.includes('network') ||
message.includes('timeout') ||
message.includes('temporarily unavailable');
if (!isRetryable || attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Retry logic failed');
}
// Usage
const object = await r2WithRetry(() =>
env.MY_BUCKET.get('important-file.txt')
);---
Performance Optimization
Batch Operations
// ❌ DON'T: Delete files one by one
for (const file of filesToDelete) {
await env.MY_BUCKET.delete(file);
}
// ✅ DO: Batch delete (up to 1000 keys)
await env.MY_BUCKET.delete(filesToDelete);Range Requests for Large Files
// Download only the first 10MB of a large video
const object = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 10 * 1024 * 1024 },
});
// Return range to client
return new Response(object.body, {
status: 206, // Partial Content
headers: {
'Content-Type': 'video/mp4',
'Content-Range': `bytes 0-${10 * 1024 * 1024 - 1}/${object.size}`,
},
});Cache Headers
// Immutable assets (hashed filenames)
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
httpMetadata: {
contentType: 'application/javascript',
cacheControl: 'public, max-age=31536000, immutable',
},
});
// Dynamic content
await env.MY_BUCKET.put('api/latest.json', jsonData, {
httpMetadata: {
contentType: 'application/json',
cacheControl: 'public, max-age=60, stale-while-revalidate=300',
},
});Checksums for Data Integrity
// Compute MD5 checksum
const md5Hash = await crypto.subtle.digest('MD5', fileData);
// R2 will verify checksum on upload
await env.MY_BUCKET.put('important.dat', fileData, {
md5: md5Hash,
});
// If checksum doesn't match, upload will fail---
Best Practices Summary
✅ Always Do:
1. Set appropriate `contentType` for all uploads 2. Use batch delete for multiple objects (up to 1000) 3. Set cache headers (cacheControl) for static assets 4. Use presigned URLs for large client uploads 5. Use multipart upload for files > 100MB 6. Set CORS policy before allowing browser uploads 7. Set expiry times on presigned URLs (1-24 hours) 8. Handle errors gracefully with try/catch 9. Use `head()` instead of get() when you only need metadata 10. Use conditional operations to prevent overwrites
❌ Never Do:
1. Never expose R2 access keys in client-side code 2. Never skip `contentType` (files will download as binary) 3. Never delete in loops (use batch delete) 4. Never upload without error handling 5. Never skip CORS for browser uploads 6. Never use multipart for small files (< 5MB) 7. Never delete >1000 keys in single call (will fail) 8. Never assume uploads succeed (always check response) 9. Never skip presigned URL expiry (security risk) 10. Never hardcode bucket names (use bindings)
---
Known Issues Prevented
| Issue | Description | How to Avoid |
|---|---|---|
| CORS errors in browser | Browser can't upload/download due to missing CORS policy | Configure CORS in bucket settings before browser access |
| Files download as binary | Missing content-type causes browsers to download files instead of display | Always set httpMetadata.contentType on upload |
| Presigned URL expiry | URLs never expire, posing security risk | Always set X-Amz-Expires (1-24 hours typical) |
| Multipart upload limits | Parts exceed 100MB or >10,000 parts | Keep parts 5MB-100MB, max 10,000 parts per upload |
| Bulk delete limits | Trying to delete >1000 keys fails | Chunk deletes into batches of 1000 |
| Custom metadata overflow | Metadata exceeds 2KB limit | Keep custom metadata under 2KB total |
---
Wrangler Commands Reference
# Bucket management
wrangler r2 bucket create <BUCKET_NAME>
wrangler r2 bucket list
wrangler r2 bucket delete <BUCKET_NAME>
# Object management
wrangler r2 object put <BUCKET_NAME>/<KEY> --file=<FILE_PATH>
wrangler r2 object get <BUCKET_NAME>/<KEY> --file=<OUTPUT_PATH>
wrangler r2 object delete <BUCKET_NAME>/<KEY>
# List objects
wrangler r2 object list <BUCKET_NAME>
wrangler r2 object list <BUCKET_NAME> --prefix="folder/"---
Official Documentation
- R2 Overview: https://developers.cloudflare.com/r2/
- Get Started: https://developers.cloudflare.com/r2/get-started/
- Workers API: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
- Multipart Upload: https://developers.cloudflare.com/r2/api/workers/workers-multipart-usage/
- Presigned URLs: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- CORS Configuration: https://developers.cloudflare.com/r2/buckets/cors/
- Public Buckets: https://developers.cloudflare.com/r2/buckets/public-buckets/
---
Ready to store with R2! 🚀
{
"name": "cloudflare-r2",
"description": "Store objects with R2s S3-compatible storage on Cloudflares edge. Use when: uploading/downloading files, configuring CORS, generating presigned URLs, multipart uploads, managing metadata, or troubleshooting R2_ERROR, CORS failures, presigned URL issues, or quota errors.",
"version": "1.0.0",
"author": {
"name": "Jeremy Dawes",
"email": "jeremy@jezweb.net"
},
"license": "MIT",
"repository": "https://github.com/jezweb/claude-skills",
"keywords": []
}
Cloudflare R2 Object Storage
Complete knowledge domain for Cloudflare R2 - S3-compatible object storage on Cloudflare's global network.
---
Auto-Trigger Keywords
Primary Keywords
- r2 storage
- cloudflare r2
- r2 upload
- r2 download
- r2 bucket
- r2 binding
- object storage
- r2 api
- r2 workers
Secondary Keywords
- s3 compatible
- r2 cors
- presigned urls
- multipart upload
- r2 get
- r2 put
- r2 delete
- r2 list
- file upload
- asset storage
- image storage
- r2 metadata
- custom metadata
- http metadata
- r2 wrangler
Error-Based Keywords
- R2_ERROR
- CORS error r2
- presigned url failed
- multipart upload failed
- r2 quota exceeded
- content-type missing
- r2 bucket not found
- r2 access denied
- bulk delete failed
Framework Integration Keywords
- r2 hono
- r2 workers api
- r2 cloudflare workers
- wrangler r2
- r2 bindings
---
What This Skill Does
This skill provides complete R2 knowledge including:
- ✅ R2 Workers API - put(), get(), head(), delete(), list()
- ✅ Multipart Uploads - For files >100MB with resumable uploads
- ✅ Presigned URLs - Client-side direct uploads/downloads
- ✅ CORS Configuration - Browser access to R2 buckets
- ✅ HTTP Metadata - Content-Type, Cache-Control, Content-Disposition
- ✅ Custom Metadata - User-defined key-value pairs
- ✅ Bucket Configuration - wrangler.jsonc setup and bindings
- ✅ Error Handling - Retry strategies and common errors
- ✅ Performance Optimization - Batch operations, range requests, caching
---
Known Issues Prevented
| Issue | Description | Prevention |
|---|---|---|
| CORS errors | Browser uploads fail | Configure CORS before browser access |
| Binary downloads | Files download as binary blob | Always set contentType on upload |
| Presigned URL security | URLs never expire | Set X-Amz-Expires parameter |
| Multipart limits | Upload fails with large files | Use 5MB-100MB parts, max 10,000 parts |
| Bulk delete limits | Deleting >1000 keys fails | Chunk into batches of 1000 |
| Metadata overflow | Custom metadata exceeds 2KB | Keep total metadata under 2KB |
---
When to Use This Skill
✅ Use this skill when:
- Storing user uploads (images, documents, videos)
- Serving static assets (CSS, JS, images)
- Building file management systems
- Implementing direct client uploads
- Setting up CORS for browser access
- Generating presigned URLs
- Uploading large files (multipart)
- Migrating from S3 to R2
❌ When NOT to use:
- You need a relational database (use cloudflare-d1)
- You need key-value storage (use cloudflare-kv)
- Files are <1KB (KV might be better)
- You need vector search (use cloudflare-vectorize)
---
Quick Example
import { Hono } from 'hono';
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Upload file
app.put('/upload/:filename', async (c) => {
const filename = c.req.param('filename');
const body = await c.req.arrayBuffer();
const object = await c.env.MY_BUCKET.put(filename, body, {
httpMetadata: {
contentType: c.req.header('content-type') || 'application/octet-stream',
},
});
return c.json({
success: true,
key: object.key,
size: object.size,
});
});
// Download file
app.get('/download/:filename', async (c) => {
const filename = c.req.param('filename');
const object = await c.env.MY_BUCKET.get(filename);
if (!object) {
return c.json({ error: 'Not found' }, 404);
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
},
});
});
export default app;---
Token Efficiency
- Manual Setup: 10,000-14,000 tokens
- With This Skill: 4,000-5,000 tokens
- Savings: ~60%
---
Files Included
SKILL.md- Complete R2 knowledge domaintemplates/wrangler-r2-config.jsonc- R2 binding configurationtemplates/r2-simple-upload.ts- Basic upload/download Workertemplates/r2-multipart-upload.ts- Multipart upload Workertemplates/r2-presigned-urls.ts- Presigned URL generatortemplates/r2-cors-config.json- CORS policy examplesreference/workers-api.md- Complete Workers API referencereference/s3-compatibility.md- S3 API compatibility notesreference/common-patterns.md- Common R2 patterns
---
Dependencies
- cloudflare-worker-base - For Hono + Vite + Worker setup
- wrangler - For R2 bucket management
- aws4fetch (optional) - For presigned URL generation
---
Production Status
✅ Production Ready
This skill is based on:
- Official Cloudflare R2 documentation
- Cloudflare Workers SDK examples
- Production-tested patterns
- Latest package versions (verified 2025-10-21)
---
Related Skills
- cloudflare-worker-base - Base Worker setup with Hono
- cloudflare-d1 - Serverless SQLite database
- cloudflare-kv - Key-value storage
- cloudflare-workers-ai - AI inference on Workers
---
Last Updated: 2025-10-21 Status: Production Ready ✅ Maintainer: Jeremy Dawes | jeremy@jezweb.net
R2 Common Patterns
Last Updated: 2025-10-21
---
Image Upload & Serving
Upload with Automatic Content-Type Detection
import { Hono } from 'hono';
type Bindings = {
IMAGES: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post('/upload/image', async (c) => {
const formData = await c.req.formData();
const file = formData.get('image') as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return c.json({ error: 'Invalid file type' }, 400);
}
// Generate unique filename
const extension = file.name.split('.').pop();
const filename = `${crypto.randomUUID()}.${extension}`;
const key = `images/${filename}`;
// Upload to R2
const arrayBuffer = await file.arrayBuffer();
const object = await c.env.IMAGES.put(key, arrayBuffer, {
httpMetadata: {
contentType: file.type,
cacheControl: 'public, max-age=31536000, immutable',
},
customMetadata: {
originalFilename: file.name,
uploadedAt: new Date().toISOString(),
},
});
return c.json({
success: true,
url: `/images/${filename}`,
key: object.key,
size: object.size,
});
});
// Serve image
app.get('/images/:filename', async (c) => {
const filename = c.req.param('filename');
const key = `images/${filename}`;
const object = await c.env.IMAGES.get(key);
if (!object) {
return c.json({ error: 'Image not found' }, 404);
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': object.httpEtag,
},
});
});
export default app;---
User File Storage with Folder Organization
app.post('/users/:userId/files', async (c) => {
const userId = c.req.param('userId');
const formData = await c.req.formData();
const file = formData.get('file') as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Organize by user ID and date
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const filename = file.name;
const key = `users/${userId}/${date}/${filename}`;
const arrayBuffer = await file.arrayBuffer();
const object = await c.env.MY_BUCKET.put(key, arrayBuffer, {
httpMetadata: {
contentType: file.type,
contentDisposition: `attachment; filename="${filename}"`,
},
customMetadata: {
userId,
uploadDate: date,
originalSize: file.size.toString(),
},
});
return c.json({
success: true,
fileId: object.key,
size: object.size,
});
});
// List user's files
app.get('/users/:userId/files', async (c) => {
const userId = c.req.param('userId');
const cursor = c.req.query('cursor');
const listed = await c.env.MY_BUCKET.list({
prefix: `users/${userId}/`,
limit: 100,
cursor: cursor || undefined,
});
return c.json({
files: listed.objects.map(obj => ({
key: obj.key,
filename: obj.key.split('/').pop(),
size: obj.size,
uploaded: obj.uploaded,
metadata: obj.customMetadata,
})),
hasMore: listed.truncated,
cursor: listed.cursor,
});
});---
Thumbnail Generation & Caching
app.get('/thumbnails/:filename', async (c) => {
const filename = c.req.param('filename');
const width = parseInt(c.req.query('w') || '200');
const height = parseInt(c.req.query('h') || '200');
const thumbnailKey = `thumbnails/${width}x${height}/${filename}`;
// Check if thumbnail already exists
let thumbnail = await c.env.IMAGES.get(thumbnailKey);
if (!thumbnail) {
// Get original image
const original = await c.env.IMAGES.get(`images/${filename}`);
if (!original) {
return c.json({ error: 'Image not found' }, 404);
}
// Generate thumbnail (using Cloudflare Images or external service)
// This is a placeholder - use actual image processing
const thumbnailData = await generateThumbnail(
await original.arrayBuffer(),
width,
height
);
// Store thumbnail for future requests
await c.env.IMAGES.put(thumbnailKey, thumbnailData, {
httpMetadata: {
contentType: 'image/jpeg',
cacheControl: 'public, max-age=31536000, immutable',
},
});
thumbnail = await c.env.IMAGES.get(thumbnailKey);
}
return new Response(thumbnail!.body, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
});
async function generateThumbnail(
imageData: ArrayBuffer,
width: number,
height: number
): Promise<ArrayBuffer> {
// Use Cloudflare Images API, sharp, or other image processing library
// This is a placeholder
return imageData;
}---
Versioned File Storage
app.put('/files/:filename', async (c) => {
const filename = c.req.param('filename');
const body = await c.req.arrayBuffer();
// Get current version number
const versionKey = `versions/${filename}/latest`;
const currentVersion = await c.env.MY_BUCKET.head(versionKey);
let version = 1;
if (currentVersion?.customMetadata?.version) {
version = parseInt(currentVersion.customMetadata.version) + 1;
}
// Store new version
const versionedKey = `versions/${filename}/v${version}`;
await c.env.MY_BUCKET.put(versionedKey, body, {
httpMetadata: {
contentType: c.req.header('content-type') || 'application/octet-stream',
},
customMetadata: {
version: version.toString(),
createdAt: new Date().toISOString(),
},
});
// Update "latest" pointer
await c.env.MY_BUCKET.put(versionKey, body, {
httpMetadata: {
contentType: c.req.header('content-type') || 'application/octet-stream',
},
customMetadata: {
version: version.toString(),
latestVersion: 'true',
},
});
return c.json({
success: true,
version,
key: versionedKey,
});
});
// Get specific version
app.get('/files/:filename/v/:version', async (c) => {
const filename = c.req.param('filename');
const version = c.req.param('version');
const key = `versions/${filename}/v${version}`;
const object = await c.env.MY_BUCKET.get(key);
if (!object) {
return c.json({ error: 'Version not found' }, 404);
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
},
});
});---
Backup & Archive Pattern
// Daily database backup to R2
async function backupDatabase(env: Bindings) {
const date = new Date().toISOString().split('T')[0];
const key = `backups/database/${date}/dump.sql.gz`;
// Generate backup (placeholder)
const backupData = await generateDatabaseDump();
await env.BACKUPS.put(key, backupData, {
httpMetadata: {
contentType: 'application/gzip',
contentEncoding: 'gzip',
},
customMetadata: {
backupDate: date,
backupType: 'full',
database: 'production',
},
});
// Delete backups older than 30 days
await cleanupOldBackups(env, 30);
}
async function cleanupOldBackups(env: Bindings, retentionDays: number) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const listed = await env.BACKUPS.list({
prefix: 'backups/database/',
});
const oldBackups = listed.objects.filter(
obj => obj.uploaded < cutoffDate
);
if (oldBackups.length > 0) {
const keysToDelete = oldBackups.map(obj => obj.key);
await env.BACKUPS.delete(keysToDelete);
}
}---
Static Site Hosting with SPA Fallback
app.get('/*', async (c) => {
const url = new URL(c.req.url);
let key = url.pathname.slice(1); // Remove leading slash
if (key === '' || key.endsWith('/')) {
key += 'index.html';
}
let object = await c.env.STATIC.get(key);
// SPA fallback: if file not found, try index.html
if (!object && !key.includes('.')) {
object = await c.env.STATIC.get('index.html');
}
if (!object) {
return c.json({ error: 'Not found' }, 404);
}
const headers = new Headers();
object.writeHttpMetadata(headers);
// Set appropriate cache headers
if (key.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$/)) {
headers.set('Cache-Control', 'public, max-age=31536000, immutable');
} else {
headers.set('Cache-Control', 'public, max-age=3600, must-revalidate');
}
return new Response(object.body, { headers });
});---
CDN with Origin Fallback
// Use R2 as CDN with external origin fallback
app.get('/cdn/*', async (c) => {
const url = new URL(c.req.url);
const key = url.pathname.replace('/cdn/', '');
// Check R2 cache first
let object = await c.env.CDN_CACHE.get(key);
if (!object) {
// Fetch from origin
const originUrl = `https://origin.example.com/${key}`;
const response = await fetch(originUrl);
if (!response.ok) {
return c.json({ error: 'Not found on origin' }, 404);
}
const data = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'application/octet-stream';
// Cache in R2
await c.env.CDN_CACHE.put(key, data, {
httpMetadata: {
contentType,
cacheControl: 'public, max-age=31536000',
},
});
object = await c.env.CDN_CACHE.get(key);
}
return new Response(object!.body, {
headers: {
'Content-Type': object!.httpMetadata?.contentType || 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000',
'X-Cache': object ? 'HIT' : 'MISS',
},
});
});---
Signed Upload with Quota Limits
app.post('/request-upload', async (c) => {
const { userId, filename, fileSize } = await c.req.json();
// Check user's quota
const quota = await getUserQuota(userId);
if (quota.used + fileSize > quota.total) {
return c.json({ error: 'Quota exceeded' }, 403);
}
// Generate presigned URL
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const key = `users/${userId}/${filename}`;
const url = new URL(
`https://my-bucket.${c.env.ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`
);
url.searchParams.set('X-Amz-Expires', '3600');
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return c.json({
uploadUrl: signed.url,
expiresIn: 3600,
});
});
async function getUserQuota(userId: string) {
// Query database for user quota
return {
used: 1024 * 1024 * 100, // 100MB used
total: 1024 * 1024 * 1024, // 1GB total
};
}---
Best Practices Summary
1. Use meaningful key prefixes for organization (users/{id}/, images/, backups/) 2. Set appropriate cache headers for static assets 3. Store metadata for tracking and filtering 4. Use bulk delete instead of loops 5. Implement cleanup for old/temporary files 6. Add authentication before presigned URL generation 7. Validate file types before uploading 8. Use UUIDs for unique filenames 9. Set expiry times on presigned URLs 10. Monitor quota to prevent overages
R2 S3 API Compatibility
Last Updated: 2025-10-21 Official Docs: https://developers.cloudflare.com/r2/api/s3/api/
---
Overview
R2 implements a large portion of the Amazon S3 API, allowing you to use existing S3 SDKs and tools.
S3 Endpoint Format:
https://<account_id>.r2.cloudflarestorage.com---
Supported S3 Operations
Bucket Operations
- ✅ ListBuckets
- ❌ CreateBucket (use Cloudflare Dashboard or Wrangler)
- ❌ DeleteBucket (use Cloudflare Dashboard or Wrangler)
Object Operations
- ✅ GetObject
- ✅ PutObject
- ✅ DeleteObject
- ✅ DeleteObjects (bulk delete, max 1000)
- ✅ HeadObject
- ✅ ListObjectsV2
- ✅ CopyObject
- ✅ UploadPart
- ✅ CreateMultipartUpload
- ✅ CompleteMultipartUpload
- ✅ AbortMultipartUpload
- ✅ ListMultipartUploads
- ✅ ListParts
Presigned URLs
- ✅ GetObject (download)
- ✅ PutObject (upload)
- ✅ UploadPart (multipart)
Not Supported
- ❌ Versioning
- ❌ Object Lock
- ❌ ACLs (use CORS instead)
- ❌ Bucket policies
- ❌ Object tagging (use custom metadata)
- ❌ Server-side encryption config (use SSE-C instead)
---
Using AWS SDK for JavaScript
Installation
npm install @aws-sdk/client-s3
npm install @aws-sdk/s3-request-presignerBasic Usage
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
// Create S3 client for R2
const s3Client = new S3Client({
region: 'auto',
endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: '<R2_ACCESS_KEY_ID>',
secretAccessKey: '<R2_SECRET_ACCESS_KEY>',
},
});
// Upload object
const uploadParams = {
Bucket: 'my-bucket',
Key: 'path/to/file.txt',
Body: 'Hello, R2!',
ContentType: 'text/plain',
};
await s3Client.send(new PutObjectCommand(uploadParams));
// Download object
const downloadParams = {
Bucket: 'my-bucket',
Key: 'path/to/file.txt',
};
const response = await s3Client.send(new GetObjectCommand(downloadParams));
const text = await response.Body.transformToString();Presigned URLs with AWS SDK
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
// Generate presigned upload URL
const uploadCommand = new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'uploads/file.jpg',
});
const uploadUrl = await getSignedUrl(s3Client, uploadCommand, {
expiresIn: 3600, // 1 hour
});
// Generate presigned download URL
const downloadCommand = new GetObjectCommand({
Bucket: 'my-bucket',
Key: 'uploads/file.jpg',
});
const downloadUrl = await getSignedUrl(s3Client, downloadCommand, {
expiresIn: 3600,
});---
Using aws4fetch (Lightweight Alternative)
Installation
npm install aws4fetchUsage
import { AwsClient } from 'aws4fetch';
const r2Client = new AwsClient({
accessKeyId: '<R2_ACCESS_KEY_ID>',
secretAccessKey: '<R2_SECRET_ACCESS_KEY>',
});
const endpoint = `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`;
// Upload object
await r2Client.fetch(`${endpoint}/my-bucket/file.txt`, {
method: 'PUT',
body: 'Hello, R2!',
headers: {
'Content-Type': 'text/plain',
},
});
// Download object
const response = await r2Client.fetch(`${endpoint}/my-bucket/file.txt`);
const text = await response.text();
// Delete object
await r2Client.fetch(`${endpoint}/my-bucket/file.txt`, {
method: 'DELETE',
});
// List objects
const listResponse = await r2Client.fetch(
`${endpoint}/my-bucket?list-type=2&max-keys=100`
);
const xml = await listResponse.text();Presigned URLs with aws4fetch
import { AwsClient } from 'aws4fetch';
const r2Client = new AwsClient({
accessKeyId: '<R2_ACCESS_KEY_ID>',
secretAccessKey: '<R2_SECRET_ACCESS_KEY>',
});
const url = new URL(
`https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/file.txt`
);
// Set expiry (in seconds)
url.searchParams.set('X-Amz-Expires', '3600');
// Sign for PUT (upload)
const signedUpload = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
console.log(signedUpload.url);
// Sign for GET (download)
const signedDownload = await r2Client.sign(
new Request(url, { method: 'GET' }),
{ aws: { signQuery: true } }
);
console.log(signedDownload.url);---
S3 vs R2 Workers API Comparison
| Feature | S3 API | R2 Workers API |
|---|---|---|
| Performance | External network call | Native binding (faster) |
| Authentication | Access keys required | Automatic via binding |
| Presigned URLs | Supported | Requires S3 API + access keys |
| Multipart Upload | Full S3 API | Simplified Workers API |
| Custom Metadata | x-amz-meta-* headers | customMetadata object |
| Conditional Ops | S3 headers | onlyIf object |
| Size Limits | 5GB per PUT | 100MB per PUT (200MB Business, 500MB Enterprise) |
---
When to Use S3 API vs Workers API
Use S3 API when:
- ✅ Migrating from AWS S3
- ✅ Using existing S3 tools (aws-cli, s3cmd)
- ✅ Generating presigned URLs
- ✅ Need S3 compatibility for external systems
Use Workers API when:
- ✅ Building new applications on Cloudflare
- ✅ Need better performance (native binding)
- ✅ Don't want to manage access keys
- ✅ Using R2 from Workers
---
R2-Specific Extensions
R2 adds some extensions to the S3 API:
Conditional Operations
// Only upload if file doesn't exist
await s3Client.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: data,
IfUnmodifiedSince: new Date('2020-01-01'), // Before R2 existed
}));Storage Class
R2 currently only supports 'Standard' storage class.
await s3Client.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: data,
StorageClass: 'STANDARD',
}));---
Migration from S3
1. Update Endpoint
const s3Client = new S3Client({
region: 'auto',
- endpoint: 'https://s3.amazonaws.com',
+ endpoint: 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
credentials: {
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});2. Remove Unsupported Features
await s3Client.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: data,
- ACL: 'public-read', // ❌ Not supported
- Tagging: 'key=value', // ❌ Not supported (use custom metadata)
+ Metadata: { // ✅ Use custom metadata instead
+ visibility: 'public',
+ },
}));3. Use CORS Instead of ACLs
R2 doesn't support S3 ACLs. Use CORS policies instead for browser access.
---
Common Issues
Issue: SignatureDoesNotMatch
Cause: Incorrect access keys or endpoint URL
Fix:
- Verify access key ID and secret
- Ensure endpoint includes your account ID
- Check region is set to 'auto'
Issue: Presigned URLs Don't Work with Custom Domains
Cause: Presigned URLs only work with R2 S3 endpoint
Fix:
- Use
<ACCOUNT_ID>.r2.cloudflarestorage.comendpoint - Or use Worker with R2 binding for custom domains
Issue: Upload Size Exceeds Limit
Cause: S3 API PUT has 5GB limit, but R2 Workers has 100-500MB limit
Fix:
- Use multipart upload for large files
- Or use S3 API directly (not through Worker)
---
Official Resources
- S3 API Compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- AWS SDK Examples: https://developers.cloudflare.com/r2/examples/aws/
- Presigned URLs: https://developers.cloudflare.com/r2/api/s3/presigned-urls/
R2 Workers API Complete Reference
Last Updated: 2025-10-21 Official Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/
---
R2Bucket Methods
put()
Upload an object to R2.
put(
key: string,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,
options?: R2PutOptions
): Promise<R2Object | null>Parameters:
key- Object key (path) in the bucketvalue- Object dataoptions- Optional upload options
Returns:
R2Object- Metadata of uploaded objectnull- If precondition failed (onlyIf clause)
Options (R2PutOptions):
interface R2PutOptions {
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
md5?: ArrayBuffer;
sha1?: ArrayBuffer;
sha256?: ArrayBuffer;
sha384?: ArrayBuffer;
sha512?: ArrayBuffer;
onlyIf?: R2Conditional;
storageClass?: 'Standard';
}---
get()
Download an object from R2.
get(
key: string,
options?: R2GetOptions
): Promise<R2ObjectBody | null>Parameters:
key- Object key (path) in the bucketoptions- Optional download options
Returns:
R2ObjectBody- Object with metadata and body streamnull- If object doesn't exist or precondition failed
Options (R2GetOptions):
interface R2GetOptions {
onlyIf?: R2Conditional | Headers;
range?: R2Range;
}---
head()
Get object metadata without downloading body.
head(key: string): Promise<R2Object | null>Parameters:
key- Object key (path) in the bucket
Returns:
R2Object- Object metadata onlynull- If object doesn't exist
Use Cases:
- Check if file exists
- Get file size
- Get last modified date
- Validate etag
---
delete()
Delete one or more objects.
delete(key: string | string[]): Promise<void>Parameters:
key- Single key or array of keys (max 1000)
Returns:
void- Always succeeds (idempotent)
Notes:
- No error if object doesn't exist
- Can delete up to 1000 objects at once
- Deletes are strongly consistent
---
list()
List objects in the bucket.
list(options?: R2ListOptions): Promise<R2Objects>Parameters:
options- Optional listing options
Returns:
R2Objects- List of objects and metadata
Options (R2ListOptions):
interface R2ListOptions {
limit?: number; // Max 1000, default 1000
prefix?: string; // Filter by prefix
cursor?: string; // Pagination cursor
delimiter?: string; // Folder delimiter (usually '/')
include?: ('httpMetadata' | 'customMetadata')[];
}Response (R2Objects):
interface R2Objects {
objects: R2Object[]; // Array of objects
truncated: boolean; // true if more results exist
cursor?: string; // Cursor for next page
delimitedPrefixes: string[]; // "Folder" names (if delimiter used)
}---
createMultipartUpload()
Create a new multipart upload.
createMultipartUpload(
key: string,
options?: R2MultipartOptions
): Promise<R2MultipartUpload>Parameters:
key- Object key for the uploadoptions- Optional metadata
Returns:
R2MultipartUpload- Object for managing the upload
Options (R2MultipartOptions):
interface R2MultipartOptions {
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
}---
resumeMultipartUpload()
Resume an existing multipart upload.
resumeMultipartUpload(
key: string,
uploadId: string
): R2MultipartUploadParameters:
key- Object key for the uploaduploadId- Upload ID from createMultipartUpload()
Returns:
R2MultipartUpload- Object for managing the upload
Notes:
- Does NOT validate uploadId or key
- No network request made
- Use to continue an upload after Worker restart
---
R2Object Interface
Metadata for an R2 object.
interface R2Object {
key: string; // Object key
version: string; // Version ID
size: number; // Size in bytes
etag: string; // ETag (without quotes)
httpEtag: string; // ETag with quotes (RFC 9110)
uploaded: Date; // Upload timestamp
httpMetadata?: R2HTTPMetadata; // HTTP metadata
customMetadata?: Record<string, string>; // Custom metadata
range?: R2Range; // Range (if partial)
checksums?: R2Checksums; // Checksums
storageClass: 'Standard'; // Storage class
ssecKeyMd5?: string; // SSE-C key hash
writeHttpMetadata(headers: Headers): void; // Apply metadata to headers
}---
R2ObjectBody Interface
Extends R2Object with body stream and read methods.
interface R2ObjectBody extends R2Object {
body: ReadableStream; // Object body stream
bodyUsed: boolean; // Whether body consumed
arrayBuffer(): Promise<ArrayBuffer>; // Read as ArrayBuffer
text(): Promise<string>; // Read as text
json<T>(): Promise<T>; // Read as JSON
blob(): Promise<Blob>; // Read as Blob
}---
R2MultipartUpload Interface
Manage a multipart upload.
interface R2MultipartUpload {
key: string; // Object key
uploadId: string; // Upload ID
uploadPart(
partNumber: number,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,
options?: R2MultipartOptions
): Promise<R2UploadedPart>;
abort(): Promise<void>;
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>;
}Methods:
- uploadPart() - Upload a single part (1-10,000)
- abort() - Cancel the multipart upload
- complete() - Finish upload with list of parts
---
R2UploadedPart Interface
Metadata for an uploaded part.
interface R2UploadedPart {
partNumber: number; // Part number (1-10,000)
etag: string; // Part ETag
}---
R2HTTPMetadata Interface
HTTP headers for object.
interface R2HTTPMetadata {
contentType?: string; // Content-Type header
contentLanguage?: string; // Content-Language header
contentDisposition?: string; // Content-Disposition header
contentEncoding?: string; // Content-Encoding header
cacheControl?: string; // Cache-Control header
cacheExpiry?: Date; // Expires header
}---
R2Conditional Interface
Conditional operations (onlyIf clause).
interface R2Conditional {
etagMatches?: string; // If-Match
etagDoesNotMatch?: string; // If-None-Match
uploadedBefore?: Date; // If-Unmodified-Since
uploadedAfter?: Date; // If-Modified-Since
}Alternatively, pass a Headers object with:
If-MatchIf-None-MatchIf-Modified-SinceIf-Unmodified-Since
---
R2Range Interface
Byte range for partial downloads.
interface R2Range {
offset?: number; // Start byte
length?: number; // Number of bytes
suffix?: number; // Last N bytes
}Examples:
// First 1000 bytes
{ offset: 0, length: 1000 }
// Bytes 100-200
{ offset: 100, length: 100 }
// From byte 1000 to end
{ offset: 1000 }
// Last 500 bytes
{ suffix: 500 }---
R2Checksums Interface
Stored checksums for object.
interface R2Checksums {
md5?: ArrayBuffer;
sha1?: ArrayBuffer;
sha256?: ArrayBuffer;
sha384?: ArrayBuffer;
sha512?: ArrayBuffer;
}---
Complete Example
import { Hono } from 'hono';
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Upload with all metadata
app.put('/files/:key', async (c) => {
const key = c.req.param('key');
const body = await c.req.arrayBuffer();
const object = await c.env.MY_BUCKET.put(key, body, {
httpMetadata: {
contentType: c.req.header('content-type') || 'application/octet-stream',
cacheControl: 'public, max-age=3600',
contentDisposition: `attachment; filename="${key}"`,
},
customMetadata: {
uploadedBy: 'api',
uploadedAt: new Date().toISOString(),
},
onlyIf: {
// Only upload if file doesn't exist
uploadedBefore: new Date('2020-01-01'),
},
});
if (!object) {
return c.json({ error: 'File already exists' }, 409);
}
return c.json({
key: object.key,
size: object.size,
etag: object.etag,
});
});
// Download with range support
app.get('/files/:key', async (c) => {
const key = c.req.param('key');
const rangeHeader = c.req.header('range');
let options: R2GetOptions | undefined;
if (rangeHeader) {
// Parse range header: bytes=0-1000
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : undefined;
options = {
range: {
offset: start,
length: end ? end - start + 1 : undefined,
},
};
}
}
const object = await c.env.MY_BUCKET.get(key, options);
if (!object) {
return c.json({ error: 'Not found' }, 404);
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
if (object.range) {
headers.set('content-range', `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`);
return new Response(object.body, {
status: 206,
headers,
});
}
return new Response(object.body, { headers });
});
export default app;{
"_comment": "R2 CORS Policy Examples - Apply via Cloudflare Dashboard",
"_instructions": [
"1. Go to Cloudflare Dashboard → R2",
"2. Select your bucket",
"3. Go to Settings tab",
"4. Under CORS Policy → Add CORS policy",
"5. Paste one of the configurations below",
"6. Save"
],
"public_assets_all_origins": {
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range"],
"MaxAgeSeconds": 3600
}
]
},
"public_assets_specific_origin": {
"CORSRules": [
{
"AllowedOrigins": ["https://example.com", "https://www.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range"],
"MaxAgeSeconds": 3600
}
]
},
"file_uploads": {
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": [
"Content-Type",
"Content-MD5",
"Content-Disposition",
"x-amz-meta-*"
],
"ExposeHeaders": ["ETag", "x-amz-version-id"],
"MaxAgeSeconds": 3600
}
]
},
"presigned_urls": {
"_comment": "For presigned URL uploads from browser",
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "POST"],
"AllowedHeaders": [
"Content-Type",
"Content-MD5",
"x-amz-*"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
},
"multiple_domains": {
"CORSRules": [
{
"AllowedOrigins": [
"https://app.example.com",
"https://admin.example.com",
"https://staging.example.com"
],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length"],
"MaxAgeSeconds": 86400
}
]
},
"development_localhost": {
"_comment": "For local development only - DO NOT USE IN PRODUCTION",
"CORSRules": [
{
"AllowedOrigins": ["http://localhost:3000", "http://localhost:5173"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
},
"strict_security": {
"_comment": "Minimal CORS for maximum security",
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["Range"],
"MaxAgeSeconds": 3600
}
]
},
"cdn_and_api": {
"_comment": "Separate rules for CDN assets and API uploads",
"CORSRules": [
{
"_comment": "Rule for CDN/static assets",
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range"],
"MaxAgeSeconds": 86400
},
{
"_comment": "Rule for authenticated API uploads",
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "POST", "DELETE"],
"AllowedHeaders": [
"Content-Type",
"Authorization",
"x-amz-meta-*"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}
}
/**
* R2 Multipart Upload Worker
*
* Enables large file uploads (>100MB) with:
* - Resumable uploads
* - Parallel part uploads
* - Progress tracking
* - Abort capability
*
* Flow:
* 1. POST /mpu/create - Create multipart upload
* 2. PUT /mpu/upload-part - Upload individual parts
* 3. POST /mpu/complete - Complete the upload
* 4. DELETE /mpu/abort - Abort the upload (optional)
*/
import { Hono } from 'hono';
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Create multipart upload
app.post('/mpu/create', async (c) => {
const { key, contentType } = await c.req.json<{
key: string;
contentType?: string;
}>();
if (!key) {
return c.json({
success: false,
error: 'Missing required field: key',
}, 400);
}
try {
const multipart = await c.env.MY_BUCKET.createMultipartUpload(key, {
httpMetadata: {
contentType: contentType || 'application/octet-stream',
},
});
return c.json({
success: true,
key: multipart.key,
uploadId: multipart.uploadId,
});
} catch (error: any) {
console.error('Create multipart error:', error.message);
return c.json({
success: false,
error: 'Failed to create multipart upload',
}, 500);
}
});
// Upload a part
app.put('/mpu/upload-part', async (c) => {
const key = c.req.query('key');
const uploadId = c.req.query('uploadId');
const partNumber = parseInt(c.req.query('partNumber') || '0');
if (!key || !uploadId || !partNumber) {
return c.json({
success: false,
error: 'Missing required parameters: key, uploadId, partNumber',
}, 400);
}
if (partNumber < 1 || partNumber > 10000) {
return c.json({
success: false,
error: 'Part number must be between 1 and 10000',
}, 400);
}
try {
const body = await c.req.arrayBuffer();
// Resume the multipart upload
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
// Upload the part
const uploadedPart = await multipart.uploadPart(partNumber, body);
return c.json({
success: true,
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
});
} catch (error: any) {
console.error('Upload part error:', error.message);
return c.json({
success: false,
error: 'Failed to upload part',
details: error.message,
}, 500);
}
});
// Complete multipart upload
app.post('/mpu/complete', async (c) => {
const { key, uploadId, parts } = await c.req.json<{
key: string;
uploadId: string;
parts: Array<{ partNumber: number; etag: string }>;
}>();
if (!key || !uploadId || !parts || !Array.isArray(parts)) {
return c.json({
success: false,
error: 'Missing required fields: key, uploadId, parts',
}, 400);
}
try {
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
// Complete the upload
const object = await multipart.complete(parts);
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
uploaded: object.uploaded,
});
} catch (error: any) {
console.error('Complete multipart error:', error.message);
return c.json({
success: false,
error: 'Failed to complete multipart upload',
details: error.message,
}, 500);
}
});
// Abort multipart upload
app.delete('/mpu/abort', async (c) => {
const key = c.req.query('key');
const uploadId = c.req.query('uploadId');
if (!key || !uploadId) {
return c.json({
success: false,
error: 'Missing required parameters: key, uploadId',
}, 400);
}
try {
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
await multipart.abort();
return c.json({
success: true,
message: 'Multipart upload aborted',
key,
uploadId,
});
} catch (error: any) {
console.error('Abort multipart error:', error.message);
return c.json({
success: false,
error: 'Failed to abort multipart upload',
}, 500);
}
});
// Health check
app.get('/health', (c) => {
return c.json({
status: 'healthy',
service: 'r2-multipart-worker',
timestamp: new Date().toISOString(),
});
});
export default app;
/**
* Example Python client for multipart upload:
*
* import requests
* from concurrent.futures import ThreadPoolExecutor
*
* WORKER_URL = "https://my-worker.workers.dev"
* FILE_PATH = "large-file.mp4"
* PART_SIZE = 10 * 1024 * 1024 # 10MB parts
*
* # 1. Create multipart upload
* response = requests.post(f"{WORKER_URL}/mpu/create", json={
* "key": "uploads/large-file.mp4",
* "contentType": "video/mp4"
* })
* data = response.json()
* upload_id = data["uploadId"]
* key = data["key"]
*
* # 2. Upload parts in parallel
* def upload_part(part_number, data):
* response = requests.put(
* f"{WORKER_URL}/mpu/upload-part",
* params={
* "key": key,
* "uploadId": upload_id,
* "partNumber": part_number
* },
* data=data
* )
* return response.json()
*
* with open(FILE_PATH, 'rb') as f:
* part_number = 1
* uploaded_parts = []
*
* with ThreadPoolExecutor(max_workers=4) as executor:
* while True:
* chunk = f.read(PART_SIZE)
* if not chunk:
* break
*
* result = executor.submit(upload_part, part_number, chunk)
* uploaded_parts.append(result.result())
* part_number += 1
*
* # 3. Complete upload
* response = requests.post(f"{WORKER_URL}/mpu/complete", json={
* "key": key,
* "uploadId": upload_id,
* "parts": uploaded_parts
* })
*
* print(response.json())
*/
/**
* R2 Presigned URL Generator Worker
*
* Generates presigned URLs for:
* - Direct client uploads to R2 (bypasses Worker)
* - Temporary download links with expiry
*
* IMPORTANT:
* - Never expose R2 access keys in client code
* - Always generate presigned URLs server-side
* - Set appropriate expiry times (1-24 hours)
* - Add authentication before generating URLs
*
* Setup:
* 1. Create R2 API token in Cloudflare dashboard
* 2. Add secrets to wrangler:
* wrangler secret put R2_ACCESS_KEY_ID
* wrangler secret put R2_SECRET_ACCESS_KEY
* wrangler secret put ACCOUNT_ID
*/
import { Hono } from 'hono';
import { AwsClient } from 'aws4fetch';
type Bindings = {
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
ACCOUNT_ID: string;
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Generate presigned upload URL
app.post('/presigned/upload', async (c) => {
// TODO: Add authentication here
// const authHeader = c.req.header('Authorization');
// if (!authHeader) {
// return c.json({ error: 'Unauthorized' }, 401);
// }
const { filename, expiresIn = 3600 } = await c.req.json<{
filename: string;
expiresIn?: number;
}>();
if (!filename) {
return c.json({
success: false,
error: 'Missing required field: filename',
}, 400);
}
// Validate expiry (max 7 days)
const maxExpiry = 7 * 24 * 60 * 60; // 7 days
const validExpiry = Math.min(expiresIn, maxExpiry);
try {
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket'; // Replace with your bucket name
const accountId = c.env.ACCOUNT_ID;
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
// Set expiry
url.searchParams.set('X-Amz-Expires', validExpiry.toString());
// Sign the URL for PUT
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return c.json({
success: true,
uploadUrl: signed.url,
filename,
expiresIn: validExpiry,
expiresAt: new Date(Date.now() + validExpiry * 1000).toISOString(),
});
} catch (error: any) {
console.error('Presigned upload URL error:', error.message);
return c.json({
success: false,
error: 'Failed to generate presigned upload URL',
}, 500);
}
});
// Generate presigned download URL
app.post('/presigned/download', async (c) => {
// TODO: Add authentication here
// const authHeader = c.req.header('Authorization');
// if (!authHeader) {
// return c.json({ error: 'Unauthorized' }, 401);
// }
const { filename, expiresIn = 3600 } = await c.req.json<{
filename: string;
expiresIn?: number;
}>();
if (!filename) {
return c.json({
success: false,
error: 'Missing required field: filename',
}, 400);
}
// Validate expiry (max 7 days)
const maxExpiry = 7 * 24 * 60 * 60;
const validExpiry = Math.min(expiresIn, maxExpiry);
try {
// Check if file exists first
const exists = await c.env.MY_BUCKET.head(filename);
if (!exists) {
return c.json({
success: false,
error: 'File not found',
}, 404);
}
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket'; // Replace with your bucket name
const accountId = c.env.ACCOUNT_ID;
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', validExpiry.toString());
// Sign the URL for GET
const signed = await r2Client.sign(
new Request(url, { method: 'GET' }),
{ aws: { signQuery: true } }
);
return c.json({
success: true,
downloadUrl: signed.url,
filename,
size: exists.size,
expiresIn: validExpiry,
expiresAt: new Date(Date.now() + validExpiry * 1000).toISOString(),
});
} catch (error: any) {
console.error('Presigned download URL error:', error.message);
return c.json({
success: false,
error: 'Failed to generate presigned download URL',
}, 500);
}
});
// Generate batch presigned URLs (upload)
app.post('/presigned/upload/batch', async (c) => {
const { filenames, expiresIn = 3600 } = await c.req.json<{
filenames: string[];
expiresIn?: number;
}>();
if (!filenames || !Array.isArray(filenames)) {
return c.json({
success: false,
error: 'Invalid request: filenames must be an array',
}, 400);
}
const maxExpiry = 7 * 24 * 60 * 60;
const validExpiry = Math.min(expiresIn, maxExpiry);
try {
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket';
const accountId = c.env.ACCOUNT_ID;
const urls = await Promise.all(
filenames.map(async (filename) => {
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', validExpiry.toString());
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return {
filename,
uploadUrl: signed.url,
};
})
);
return c.json({
success: true,
urls,
expiresIn: validExpiry,
expiresAt: new Date(Date.now() + validExpiry * 1000).toISOString(),
});
} catch (error: any) {
console.error('Batch presigned URLs error:', error.message);
return c.json({
success: false,
error: 'Failed to generate presigned URLs',
}, 500);
}
});
// Health check
app.get('/health', (c) => {
return c.json({
status: 'healthy',
service: 'r2-presigned-urls',
timestamp: new Date().toISOString(),
});
});
export default app;
/**
* Example client-side upload with presigned URL:
*
* // 1. Get presigned URL from your Worker
* const response = await fetch('https://my-worker.workers.dev/presigned/upload', {
* method: 'POST',
* headers: {
* 'Content-Type': 'application/json',
* 'Authorization': 'Bearer YOUR_TOKEN'
* },
* body: JSON.stringify({
* filename: 'uploads/photo.jpg',
* expiresIn: 3600
* })
* });
*
* const { uploadUrl } = await response.json();
*
* // 2. Upload file directly to R2
* const file = document.querySelector('input[type="file"]').files[0];
*
* await fetch(uploadUrl, {
* method: 'PUT',
* body: file,
* headers: {
* 'Content-Type': file.type
* }
* });
*
* console.log('Upload complete!');
*/
/**
* Wrangler setup for secrets:
*
* # Add R2 access key ID
* wrangler secret put R2_ACCESS_KEY_ID
*
* # Add R2 secret access key
* wrangler secret put R2_SECRET_ACCESS_KEY
*
* # Add account ID
* wrangler secret put ACCOUNT_ID
*
* # Create R2 API token:
* 1. Go to Cloudflare Dashboard → R2
* 2. Click "Manage R2 API Tokens"
* 3. Create API Token with:
* - Permissions: Object Read & Write
* - Buckets: Specific bucket or all buckets
* 4. Save the Access Key ID and Secret Access Key
*/
/**
* Simple R2 Upload/Download Worker
*
* Features:
* - Upload files with PUT requests
* - Download files with GET requests
* - Delete files with DELETE requests
* - List all files
* - Proper content-type handling
* - Error handling
*/
import { Hono } from 'hono';
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
// Upload a file
app.put('/files/:filename', async (c) => {
const filename = c.req.param('filename');
const body = await c.req.arrayBuffer();
const contentType = c.req.header('content-type') || 'application/octet-stream';
try {
const object = await c.env.MY_BUCKET.put(filename, body, {
httpMetadata: {
contentType: contentType,
cacheControl: 'public, max-age=3600',
},
customMetadata: {
uploadedAt: new Date().toISOString(),
uploadedBy: 'api',
},
});
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
uploaded: object.uploaded,
});
} catch (error: any) {
console.error('Upload error:', error.message);
return c.json({
success: false,
error: 'Failed to upload file',
}, 500);
}
});
// Download a file
app.get('/files/:filename', async (c) => {
const filename = c.req.param('filename');
try {
const object = await c.env.MY_BUCKET.get(filename);
if (!object) {
return c.json({
success: false,
error: 'File not found',
}, 404);
}
// Apply http metadata from R2
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
return new Response(object.body, { headers });
} catch (error: any) {
console.error('Download error:', error.message);
return c.json({
success: false,
error: 'Failed to download file',
}, 500);
}
});
// Get file metadata (without downloading body)
app.head('/files/:filename', async (c) => {
const filename = c.req.param('filename');
try {
const object = await c.env.MY_BUCKET.head(filename);
if (!object) {
return c.json({
success: false,
error: 'File not found',
}, 404);
}
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
uploaded: object.uploaded,
contentType: object.httpMetadata?.contentType,
customMetadata: object.customMetadata,
});
} catch (error: any) {
console.error('Head error:', error.message);
return c.json({
success: false,
error: 'Failed to get file metadata',
}, 500);
}
});
// Delete a file
app.delete('/files/:filename', async (c) => {
const filename = c.req.param('filename');
try {
// Check if file exists first
const exists = await c.env.MY_BUCKET.head(filename);
if (!exists) {
return c.json({
success: false,
error: 'File not found',
}, 404);
}
await c.env.MY_BUCKET.delete(filename);
return c.json({
success: true,
message: 'File deleted successfully',
key: filename,
});
} catch (error: any) {
console.error('Delete error:', error.message);
return c.json({
success: false,
error: 'Failed to delete file',
}, 500);
}
});
// List all files (with pagination)
app.get('/files', async (c) => {
const cursor = c.req.query('cursor');
const limit = parseInt(c.req.query('limit') || '100');
const prefix = c.req.query('prefix') || '';
try {
const listed = await c.env.MY_BUCKET.list({
limit: Math.min(limit, 1000), // Max 1000
cursor: cursor || undefined,
prefix: prefix || undefined,
});
return c.json({
success: true,
files: listed.objects.map(obj => ({
key: obj.key,
size: obj.size,
etag: obj.etag,
uploaded: obj.uploaded,
contentType: obj.httpMetadata?.contentType,
})),
truncated: listed.truncated,
cursor: listed.cursor,
count: listed.objects.length,
});
} catch (error: any) {
console.error('List error:', error.message);
return c.json({
success: false,
error: 'Failed to list files',
}, 500);
}
});
// Bulk delete (up to 1000 files)
app.post('/files/bulk-delete', async (c) => {
const { keys } = await c.req.json<{ keys: string[] }>();
if (!keys || !Array.isArray(keys)) {
return c.json({
success: false,
error: 'Invalid request: keys must be an array',
}, 400);
}
if (keys.length > 1000) {
return c.json({
success: false,
error: 'Cannot delete more than 1000 keys at once',
}, 400);
}
try {
await c.env.MY_BUCKET.delete(keys);
return c.json({
success: true,
message: `Deleted ${keys.length} files`,
count: keys.length,
});
} catch (error: any) {
console.error('Bulk delete error:', error.message);
return c.json({
success: false,
error: 'Failed to delete files',
}, 500);
}
});
// Health check
app.get('/health', (c) => {
return c.json({
status: 'healthy',
service: 'r2-worker',
timestamp: new Date().toISOString(),
});
});
export default app;
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-r2-worker",
"main": "src/index.ts",
"account_id": "YOUR_ACCOUNT_ID",
"compatibility_date": "2025-10-11",
// R2 Bucket Bindings
"r2_buckets": [
{
// The binding name - accessible as env.MY_BUCKET in your Worker
"binding": "MY_BUCKET",
// The actual bucket name in R2 (must exist)
"bucket_name": "my-bucket",
// Optional: Use a different bucket for local development
// This prevents dev/test data from polluting production bucket
"preview_bucket_name": "my-bucket-preview"
}
],
// Multiple buckets example
// "r2_buckets": [
// {
// "binding": "UPLOADS",
// "bucket_name": "user-uploads"
// },
// {
// "binding": "ASSETS",
// "bucket_name": "static-assets"
// },
// {
// "binding": "BACKUPS",
// "bucket_name": "database-backups"
// }
// ],
// Optional: Enable observability
"observability": {
"enabled": true
},
// Optional: Workers Static Assets (if serving frontend)
"assets": {
"directory": "./public/",
"binding": "ASSETS"
}
}