Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:29 +08:00
commit 571bc8c17c
12 changed files with 2689 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"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"
},
"skills": [
"./"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# cloudflare-r2
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.

385
SKILL.md Normal file
View File

@@ -0,0 +1,385 @@
---
name: cloudflare-r2
description: |
Store objects with R2's S3-compatible storage on Cloudflare's 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.
license: MIT
---
# Cloudflare R2 Object Storage
**Status**: Production Ready ✅
**Last Updated**: 2025-11-24
**Dependencies**: cloudflare-worker-base (for Worker setup)
**Latest Versions**: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0, aws4fetch@1.0.20
**Recent Updates (2025)**:
- **September 2025**: R2 SQL open beta (serverless query engine for Apache Iceberg), Pipelines GA (real-time stream ingestion), Remote bindings GA (local dev connects to deployed R2)
- **May 2025**: Dashboard redesign (deeplink support, bucket settings centralization), Super Slurper 5x faster (rebuilt with Workers/Queues/Durable Objects)
- **April 2025**: R2 Data Catalog open beta (managed Apache Iceberg catalog), Event Notifications open beta (5,000 msg/s per Queue)
- **2025**: Bucket limits increased (1 million max), CRC-64/NVME checksums, Server-side encryption with customer keys, Infrequent Access storage class (beta), Oceania region, S3 API enhancements (sha256/sha1 checksums, ListParts, conditional CopyObject)
---
## Quick Start (5 Minutes)
```bash
# 1. Create bucket
npx wrangler r2 bucket create my-bucket
# 2. Add binding to wrangler.jsonc
# {
# "r2_buckets": [{
# "binding": "MY_BUCKET",
# "bucket_name": "my-bucket",
# "preview_bucket_name": "my-bucket-preview" // Optional: separate dev/prod
# }]
# }
# 3. Upload/download from Worker
type Bindings = { MY_BUCKET: R2Bucket };
// Upload
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: { contentType: 'text/plain' }
});
// Download
const object = await env.MY_BUCKET.get('file.txt');
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,
},
});
# 4. Deploy
npx wrangler deploy
```
---
## R2 Workers API
### Core Methods
```typescript
// put() - Upload objects
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: {
contentType: 'text/plain',
cacheControl: 'public, max-age=3600',
},
customMetadata: { userId: '123' },
md5: await crypto.subtle.digest('MD5', data), // Checksum verification
});
// Conditional upload (prevent overwrites)
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: { uploadedBefore: new Date('2020-01-01') }
});
if (!object) return c.json({ error: 'File already exists' }, 409);
// get() - Download objects
const object = await env.MY_BUCKET.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);
const text = await object.text(); // As string
const json = await object.json(); // As JSON
const buffer = await object.arrayBuffer(); // As ArrayBuffer
// Range requests (partial downloads)
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 1024 * 1024 } // First 1MB
});
// head() - Get metadata only (no body download)
const object = await env.MY_BUCKET.head('file.txt');
console.log(object.size, object.etag, object.customMetadata);
// delete() - Delete objects
await env.MY_BUCKET.delete('file.txt'); // Single delete (idempotent)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt']); // Bulk delete (max 1000)
// list() - List objects
const listed = await env.MY_BUCKET.list({
prefix: 'images/', // Filter by prefix
limit: 100,
cursor: cursor, // Pagination
delimiter: '/', // Folder-like listing
});
for (const object of listed.objects) {
console.log(`${object.key}: ${object.size} bytes`);
}
```
---
## Multipart Uploads
For files >100MB or resumable uploads. Use when: large files, browser uploads, parallelization needed.
```typescript
// 1. Create multipart upload
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', {
httpMetadata: { contentType: 'application/zip' }
});
// 2. Upload parts (5MB-100MB each, max 10,000 parts)
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const part1 = await multipart.uploadPart(1, chunk1);
const part2 = await multipart.uploadPart(2, chunk2);
// 3. Complete upload
const object = await multipart.complete([
{ partNumber: 1, etag: part1.etag },
{ partNumber: 2, etag: part2.etag },
]);
// 4. Abort if needed
await multipart.abort();
```
**Limits**: Parts 5MB-100MB, max 10,000 parts per upload. Don't use for files <5MB (overhead).
---
## Presigned URLs
Allow clients to upload/download directly to/from R2 (bypasses Worker). Use aws4fetch library.
```typescript
import { AwsClient } from 'aws4fetch';
const r2Client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 hour expiry
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }), // or 'GET' for downloads
{ aws: { signQuery: true } }
);
// Client uploads directly to R2
await fetch(signed.url, { method: 'PUT', body: file });
```
**CRITICAL Security:**
-**NEVER** expose R2 access keys in client-side code
-**ALWAYS** generate presigned URLs server-side
-**ALWAYS** set expiry times (1-24 hours typical)
-**ALWAYS** add authentication before generating URLs
-**CONSIDER** scoping to user folders: `users/${userId}/${filename}`
---
## CORS Configuration
Configure CORS in bucket settings (Dashboard → R2 → Bucket → Settings → CORS Policy) before browser access.
```json
{
"CORSRules": [{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["Content-Type", "Content-MD5", "x-amz-meta-*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}
```
**For presigned URLs**: CORS handled by R2 directly (configure on bucket, not Worker).
---
## HTTP Metadata & Custom Metadata
```typescript
// HTTP metadata (standard headers)
await env.MY_BUCKET.put('file.pdf', data, {
httpMetadata: {
contentType: 'application/pdf',
cacheControl: 'public, max-age=31536000, immutable',
contentDisposition: 'attachment; filename="report.pdf"',
contentEncoding: 'gzip',
},
customMetadata: {
userId: '12345',
version: '1.0',
} // Max 2KB total, keys/values must be strings
});
// Read metadata
const object = await env.MY_BUCKET.head('file.pdf');
console.log(object.httpMetadata, object.customMetadata);
```
---
## Error Handling
### Common R2 Errors
```typescript
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
```typescript
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
```typescript
// Batch delete (up to 1000 keys)
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']);
// Range requests for large files
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 10 * 1024 * 1024 } // First 10MB
});
// Cache headers for immutable assets
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
httpMetadata: { cacheControl: 'public, max-age=31536000, immutable' }
});
// Checksums for data integrity
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('important.dat', fileData, { md5: md5Hash });
```
---
## Best Practices Summary
**Always Do:**
- Set `contentType` for all uploads
- Use batch delete for multiple objects (up to 1000)
- Set cache headers for static assets
- Use presigned URLs for large client uploads
- Use multipart for files >100MB
- Set CORS before browser uploads
- Set expiry times on presigned URLs (1-24 hours)
- Use `head()` when you only need metadata
- Use conditional operations to prevent overwrites
**Never Do:**
- Never expose R2 access keys in client-side code
- Never skip `contentType` (files download as binary)
- Never delete in loops (use batch delete)
- Never skip CORS for browser uploads
- Never use multipart for small files (<5MB)
- Never delete >1000 keys in single call
- Never skip presigned URL expiry (security risk)
---
## 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
```bash
# 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!** 🚀

77
plugin.lock.json Normal file
View File

@@ -0,0 +1,77 @@
{
"$schema": "internal://schemas/plugin.lock.v1.json",
"pluginId": "gh:jezweb/claude-skills:skills/cloudflare-r2",
"normalized": {
"repo": null,
"ref": "refs/tags/v20251128.0",
"commit": "be2a7e22a7360db75b5abca56d1205fc69d236cd",
"treeHash": "761ddf23acde8eaec331b15b7c3aa3956d8ecfa2dcc41b10750ca7624adc45b3",
"generatedAt": "2025-11-28T10:18:56.163002Z",
"toolVersion": "publish_plugins.py@0.2.0"
},
"origin": {
"remote": "git@github.com:zhongweili/42plugin-data.git",
"branch": "master",
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
},
"manifest": {
"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"
},
"content": {
"files": [
{
"path": "README.md",
"sha256": "b4207461ca05f93fa1eeeebe79b75069fd5c9fb6ba82cab57bb9857ed86c0ff6"
},
{
"path": "SKILL.md",
"sha256": "abc8b8ba5a549b58859dd7cdd4c5815badb81ccc849c60f6c0a51031cf8ef52e"
},
{
"path": "references/s3-compatibility.md",
"sha256": "555b9a7164b23e69f1be92a72158bc386051784f05ca7ef2596c5c22ac76e25c"
},
{
"path": "references/common-patterns.md",
"sha256": "cce79b3888e0b4337e34d387d2a3bfc3e0f042091f23138a63f8e23279cb6d88"
},
{
"path": "references/workers-api.md",
"sha256": "9525c983661590ff04bafc0ea0bc09f147e9aba4c10820545079de2e36070884"
},
{
"path": ".claude-plugin/plugin.json",
"sha256": "7c9c5f8a4390fbabc3999079abbb2684fc87df820352678282f8c5f3315bd1f6"
},
{
"path": "templates/r2-simple-upload.ts",
"sha256": "81eabe6a55ecf5908eac02db6b07523ae20c5cd20326c7575f29e2b53f6098d9"
},
{
"path": "templates/r2-presigned-urls.ts",
"sha256": "9477be62fa75bc5a6aafa0fc7d240ea221e2ab97662e39cef375ac8c94720716"
},
{
"path": "templates/r2-cors-config.json",
"sha256": "503d55e97fe6319b15159fcfb59e0bf2a466fef1082df346e65135cd99470194"
},
{
"path": "templates/r2-multipart-upload.ts",
"sha256": "c1cb00916c20fbbf026e4abd428e430a4d44efcf03136cb5241d9e638a42e3a5"
},
{
"path": "templates/wrangler-r2-config.jsonc",
"sha256": "5339f8b4bd0a5f7b991c2029666270cc15ab0a8638509ac6ed077e7931e46134"
}
],
"dirSha256": "761ddf23acde8eaec331b15b7c3aa3956d8ecfa2dcc41b10750ca7624adc45b3"
},
"security": {
"scannedAt": null,
"scannerVersion": null,
"flags": []
}
}

View File

@@ -0,0 +1,469 @@
# R2 Common Patterns
**Last Updated**: 2025-10-21
---
## Image Upload & Serving
### Upload with Automatic Content-Type Detection
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
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
```typescript
// 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
```typescript
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

View File

@@ -0,0 +1,343 @@
# 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
```bash
npm install @aws-sdk/client-s3
npm install @aws-sdk/s3-request-presigner
```
### Basic Usage
```typescript
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
```typescript
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
```bash
npm install aws4fetch
```
### Usage
```typescript
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
```typescript
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
```typescript
// 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.
```typescript
await s3Client.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: data,
StorageClass: 'STANDARD',
}));
```
---
## Migration from S3
### 1. Update Endpoint
```diff
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
```diff
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.com` endpoint
- 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/

465
references/workers-api.md Normal file
View File

@@ -0,0 +1,465 @@
# 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.
```typescript
put(
key: string,
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob,
options?: R2PutOptions
): Promise<R2Object | null>
```
**Parameters:**
- `key` - Object key (path) in the bucket
- `value` - Object data
- `options` - Optional upload options
**Returns:**
- `R2Object` - Metadata of uploaded object
- `null` - If precondition failed (onlyIf clause)
**Options (R2PutOptions):**
```typescript
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.
```typescript
get(
key: string,
options?: R2GetOptions
): Promise<R2ObjectBody | null>
```
**Parameters:**
- `key` - Object key (path) in the bucket
- `options` - Optional download options
**Returns:**
- `R2ObjectBody` - Object with metadata and body stream
- `null` - If object doesn't exist or precondition failed
**Options (R2GetOptions):**
```typescript
interface R2GetOptions {
onlyIf?: R2Conditional | Headers;
range?: R2Range;
}
```
---
### head()
Get object metadata without downloading body.
```typescript
head(key: string): Promise<R2Object | null>
```
**Parameters:**
- `key` - Object key (path) in the bucket
**Returns:**
- `R2Object` - Object metadata only
- `null` - 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.
```typescript
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.
```typescript
list(options?: R2ListOptions): Promise<R2Objects>
```
**Parameters:**
- `options` - Optional listing options
**Returns:**
- `R2Objects` - List of objects and metadata
**Options (R2ListOptions):**
```typescript
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):**
```typescript
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.
```typescript
createMultipartUpload(
key: string,
options?: R2MultipartOptions
): Promise<R2MultipartUpload>
```
**Parameters:**
- `key` - Object key for the upload
- `options` - Optional metadata
**Returns:**
- `R2MultipartUpload` - Object for managing the upload
**Options (R2MultipartOptions):**
```typescript
interface R2MultipartOptions {
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
}
```
---
### resumeMultipartUpload()
Resume an existing multipart upload.
```typescript
resumeMultipartUpload(
key: string,
uploadId: string
): R2MultipartUpload
```
**Parameters:**
- `key` - Object key for the upload
- `uploadId` - 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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
interface R2UploadedPart {
partNumber: number; // Part number (1-10,000)
etag: string; // Part ETag
}
```
---
## R2HTTPMetadata Interface
HTTP headers for object.
```typescript
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).
```typescript
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-Match`
- `If-None-Match`
- `If-Modified-Since`
- `If-Unmodified-Since`
---
## R2Range Interface
Byte range for partial downloads.
```typescript
interface R2Range {
offset?: number; // Start byte
length?: number; // Number of bytes
suffix?: number; // Last N bytes
}
```
**Examples:**
```typescript
// 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.
```typescript
interface R2Checksums {
md5?: ArrayBuffer;
sha1?: ArrayBuffer;
sha256?: ArrayBuffer;
sha384?: ArrayBuffer;
sha512?: ArrayBuffer;
}
```
---
## Complete Example
```typescript
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;
```

View File

@@ -0,0 +1,133 @@
{
"_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
}
]
}
}

View File

@@ -0,0 +1,238 @@
/**
* 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())
*/

View File

@@ -0,0 +1,289 @@
/**
* 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
*/

View File

@@ -0,0 +1,226 @@
/**
* 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;

View File

@@ -0,0 +1,49 @@
{
"$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"
}
}