mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-03-21 18:11:27 -07:00
update skills
This commit is contained in:
95
.agents/skills/cloudflare-deploy/references/r2/README.md
Normal file
95
.agents/skills/cloudflare-deploy/references/r2/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Cloudflare R2 Object Storage
|
||||
|
||||
S3-compatible object storage with zero egress fees, optimized for large file storage and delivery.
|
||||
|
||||
## Overview
|
||||
|
||||
R2 provides:
|
||||
- S3-compatible API (Workers API + S3 REST)
|
||||
- Zero egress fees globally
|
||||
- Strong consistency for writes/deletes
|
||||
- Storage classes (Standard/Infrequent Access)
|
||||
- SSE-C encryption support
|
||||
|
||||
**Use cases:** Media storage, backups, static assets, user uploads, data lakes
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
wrangler r2 bucket create my-bucket --location=enam
|
||||
wrangler r2 object put my-bucket/file.txt --file=./local.txt
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Upload
|
||||
await env.MY_BUCKET.put(key, data, {
|
||||
httpMetadata: { contentType: 'image/jpeg' }
|
||||
});
|
||||
|
||||
// Download
|
||||
const object = await env.MY_BUCKET.get(key);
|
||||
if (object) return new Response(object.body);
|
||||
```
|
||||
|
||||
## Core Operations
|
||||
|
||||
| Method | Purpose | Returns |
|
||||
|--------|---------|---------|
|
||||
| `put(key, value, options?)` | Upload object | `R2Object \| null` |
|
||||
| `get(key, options?)` | Download object | `R2ObjectBody \| R2Object \| null` |
|
||||
| `head(key)` | Get metadata only | `R2Object \| null` |
|
||||
| `delete(keys)` | Delete object(s) | `Promise<void>` |
|
||||
| `list(options?)` | List objects | `R2Objects` |
|
||||
|
||||
## Storage Classes
|
||||
|
||||
- **Standard**: Frequent access, low latency reads
|
||||
- **InfrequentAccess**: 30-day minimum storage, retrieval fees, lower storage cost
|
||||
|
||||
## Event Notifications
|
||||
|
||||
R2 integrates with Cloudflare Queues for reactive workflows:
|
||||
|
||||
```typescript
|
||||
// wrangler.jsonc
|
||||
{
|
||||
"event_notifications": [{
|
||||
"queue": "r2-notifications",
|
||||
"actions": ["PutObject", "DeleteObject"]
|
||||
}]
|
||||
}
|
||||
|
||||
// Consumer
|
||||
async queue(batch: MessageBatch, env: Env) {
|
||||
for (const message of batch.messages) {
|
||||
const event = message.body; // { action, bucket, object, timestamps }
|
||||
if (event.action === 'PutObject') {
|
||||
// Process upload: thumbnail generation, virus scan, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reading Order
|
||||
|
||||
**First-time users:** README → configuration.md → api.md → patterns.md
|
||||
**Specific tasks:**
|
||||
- Setup: configuration.md
|
||||
- Client uploads: patterns.md (presigned URLs)
|
||||
- Public static site: patterns.md (public access + custom domain)
|
||||
- Processing uploads: README (event notifications) + queues reference
|
||||
- Debugging: gotchas.md
|
||||
|
||||
## In This Reference
|
||||
|
||||
- [configuration.md](./configuration.md) - Bindings, S3 SDK, CORS, lifecycles, token scopes
|
||||
- [api.md](./api.md) - Workers API, multipart, conditional requests, presigned URLs
|
||||
- [patterns.md](./patterns.md) - Streaming, caching, client uploads, public buckets
|
||||
- [gotchas.md](./gotchas.md) - List truncation, etag format, stream length, S3 SDK region
|
||||
|
||||
## See Also
|
||||
|
||||
- [workers](../workers/) - Worker runtime and fetch handlers
|
||||
- [kv](../kv/) - Metadata storage for R2 objects
|
||||
- [d1](../d1/) - Store R2 URLs in relational database
|
||||
- [queues](../queues/) - Process R2 uploads asynchronously
|
||||
200
.agents/skills/cloudflare-deploy/references/r2/api.md
Normal file
200
.agents/skills/cloudflare-deploy/references/r2/api.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# R2 API Reference
|
||||
|
||||
## PUT (Upload)
|
||||
|
||||
```typescript
|
||||
// Basic
|
||||
await env.MY_BUCKET.put(key, value);
|
||||
|
||||
// With metadata
|
||||
await env.MY_BUCKET.put(key, value, {
|
||||
httpMetadata: {
|
||||
contentType: 'image/jpeg',
|
||||
contentDisposition: 'attachment; filename="photo.jpg"',
|
||||
cacheControl: 'max-age=3600'
|
||||
},
|
||||
customMetadata: { userId: '123', version: '2' },
|
||||
storageClass: 'Standard', // or 'InfrequentAccess'
|
||||
sha256: arrayBufferOrHex, // Integrity check
|
||||
ssecKey: arrayBuffer32bytes // SSE-C encryption
|
||||
});
|
||||
|
||||
// Value types: ReadableStream | ArrayBuffer | string | Blob
|
||||
```
|
||||
|
||||
## GET (Download)
|
||||
|
||||
```typescript
|
||||
const object = await env.MY_BUCKET.get(key);
|
||||
if (!object) return new Response('Not found', { status: 404 });
|
||||
|
||||
// Body: arrayBuffer(), text(), json(), blob(), body (ReadableStream)
|
||||
|
||||
// Ranged reads
|
||||
const object = await env.MY_BUCKET.get(key, { range: { offset: 0, length: 1024 } });
|
||||
|
||||
// Conditional GET
|
||||
const object = await env.MY_BUCKET.get(key, { onlyIf: { etagMatches: '"abc123"' } });
|
||||
```
|
||||
|
||||
## HEAD (Metadata Only)
|
||||
|
||||
```typescript
|
||||
const object = await env.MY_BUCKET.head(key); // Returns R2Object without body
|
||||
```
|
||||
|
||||
## DELETE
|
||||
|
||||
```typescript
|
||||
await env.MY_BUCKET.delete(key);
|
||||
await env.MY_BUCKET.delete([key1, key2, key3]); // Batch (max 1000)
|
||||
```
|
||||
## LIST
|
||||
|
||||
```typescript
|
||||
const listed = await env.MY_BUCKET.list({
|
||||
limit: 1000,
|
||||
prefix: 'photos/',
|
||||
cursor: cursorFromPrevious,
|
||||
delimiter: '/',
|
||||
include: ['httpMetadata', 'customMetadata']
|
||||
});
|
||||
|
||||
// Pagination (always use truncated flag)
|
||||
while (listed.truncated) {
|
||||
const next = await env.MY_BUCKET.list({ cursor: listed.cursor });
|
||||
listed.objects.push(...next.objects);
|
||||
listed.truncated = next.truncated;
|
||||
listed.cursor = next.cursor;
|
||||
}
|
||||
```
|
||||
|
||||
## Multipart Uploads
|
||||
|
||||
```typescript
|
||||
const multipart = await env.MY_BUCKET.createMultipartUpload(key, {
|
||||
httpMetadata: { contentType: 'video/mp4' }
|
||||
});
|
||||
|
||||
const uploadedParts: R2UploadedPart[] = [];
|
||||
for (let i = 0; i < partCount; i++) {
|
||||
const part = await multipart.uploadPart(i + 1, partData);
|
||||
uploadedParts.push(part);
|
||||
}
|
||||
|
||||
const object = await multipart.complete(uploadedParts);
|
||||
// OR: await multipart.abort();
|
||||
|
||||
// Resume
|
||||
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
|
||||
```
|
||||
|
||||
## Presigned URLs (S3 SDK)
|
||||
|
||||
```typescript
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY }
|
||||
});
|
||||
|
||||
const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({ Bucket: 'my-bucket', Key: key }), { expiresIn: 3600 });
|
||||
return Response.json({ uploadUrl });
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
```typescript
|
||||
interface R2Bucket {
|
||||
head(key: string): Promise<R2Object | null>;
|
||||
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>;
|
||||
put(key: string, value: ReadableStream | ArrayBuffer | string | Blob, options?: R2PutOptions): Promise<R2Object | null>;
|
||||
delete(keys: string | string[]): Promise<void>;
|
||||
list(options?: R2ListOptions): Promise<R2Objects>;
|
||||
createMultipartUpload(key: string, options?: R2MultipartOptions): Promise<R2MultipartUpload>;
|
||||
resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload;
|
||||
}
|
||||
|
||||
interface R2Object {
|
||||
key: string; version: string; size: number;
|
||||
etag: string; httpEtag: string; // httpEtag is quoted, use for headers
|
||||
uploaded: Date; httpMetadata?: R2HTTPMetadata;
|
||||
customMetadata?: Record<string, string>;
|
||||
storageClass: 'Standard' | 'InfrequentAccess';
|
||||
checksums: R2Checksums;
|
||||
writeHttpMetadata(headers: Headers): void;
|
||||
}
|
||||
|
||||
interface R2ObjectBody extends R2Object {
|
||||
body: ReadableStream; bodyUsed: boolean;
|
||||
arrayBuffer(): Promise<ArrayBuffer>; text(): Promise<string>;
|
||||
json<T>(): Promise<T>; blob(): Promise<Blob>;
|
||||
}
|
||||
|
||||
interface R2HTTPMetadata {
|
||||
contentType?: string; contentDisposition?: string;
|
||||
contentEncoding?: string; contentLanguage?: string;
|
||||
cacheControl?: string; cacheExpiry?: Date;
|
||||
}
|
||||
|
||||
interface R2PutOptions {
|
||||
httpMetadata?: R2HTTPMetadata | Headers;
|
||||
customMetadata?: Record<string, string>;
|
||||
sha256?: ArrayBuffer | string; // Only ONE checksum allowed
|
||||
storageClass?: 'Standard' | 'InfrequentAccess';
|
||||
ssecKey?: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface R2GetOptions {
|
||||
onlyIf?: R2Conditional | Headers;
|
||||
range?: R2Range | Headers;
|
||||
ssecKey?: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface R2ListOptions {
|
||||
limit?: number; prefix?: string; cursor?: string; delimiter?: string;
|
||||
startAfter?: string; include?: ('httpMetadata' | 'customMetadata')[];
|
||||
}
|
||||
|
||||
interface R2Objects {
|
||||
objects: R2Object[]; truncated: boolean;
|
||||
cursor?: string; delimitedPrefixes: string[];
|
||||
}
|
||||
|
||||
interface R2Conditional {
|
||||
etagMatches?: string; etagDoesNotMatch?: string;
|
||||
uploadedBefore?: Date; uploadedAfter?: Date;
|
||||
}
|
||||
|
||||
interface R2Range { offset?: number; length?: number; suffix?: number; }
|
||||
|
||||
interface R2Checksums {
|
||||
md5?: ArrayBuffer; sha1?: ArrayBuffer; sha256?: ArrayBuffer;
|
||||
sha384?: ArrayBuffer; sha512?: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface R2MultipartUpload {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
uploadPart(partNumber: number, value: ReadableStream | ArrayBuffer | string | Blob): Promise<R2UploadedPart>;
|
||||
abort(): Promise<void>;
|
||||
complete(uploadedParts: R2UploadedPart[]): Promise<R2Object>;
|
||||
}
|
||||
|
||||
interface R2UploadedPart {
|
||||
partNumber: number;
|
||||
etag: string;
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Operations
|
||||
|
||||
```bash
|
||||
wrangler r2 object put my-bucket/file.txt --file=./local.txt
|
||||
wrangler r2 object get my-bucket/file.txt --file=./download.txt
|
||||
wrangler r2 object delete my-bucket/file.txt
|
||||
wrangler r2 object list my-bucket --prefix=photos/
|
||||
```
|
||||
165
.agents/skills/cloudflare-deploy/references/r2/configuration.md
Normal file
165
.agents/skills/cloudflare-deploy/references/r2/configuration.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# R2 Configuration
|
||||
|
||||
## Workers Binding
|
||||
|
||||
**wrangler.jsonc:**
|
||||
```jsonc
|
||||
{
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MY_BUCKET",
|
||||
"bucket_name": "my-bucket-name"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
```typescript
|
||||
interface Env { MY_BUCKET: R2Bucket; }
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const object = await env.MY_BUCKET.get('file.txt');
|
||||
return new Response(object?.body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## S3 SDK Setup
|
||||
|
||||
```typescript
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'file.txt',
|
||||
Body: data,
|
||||
StorageClass: 'STANDARD' // or 'STANDARD_IA'
|
||||
}));
|
||||
```
|
||||
|
||||
## Location Hints
|
||||
|
||||
```bash
|
||||
wrangler r2 bucket create my-bucket --location=enam
|
||||
|
||||
# Hints: wnam, enam, weur, eeur, apac, oc
|
||||
# Jurisdictions (override hint): --jurisdiction=eu (or fedramp)
|
||||
```
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
CORS must be configured via S3 SDK or dashboard (not available in Workers API):
|
||||
|
||||
```typescript
|
||||
import { S3Client, PutBucketCorsCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
await s3.send(new PutBucketCorsCommand({
|
||||
Bucket: 'my-bucket',
|
||||
CORSConfiguration: {
|
||||
CORSRules: [{
|
||||
AllowedOrigins: ['https://example.com'],
|
||||
AllowedMethods: ['GET', 'PUT', 'HEAD'],
|
||||
AllowedHeaders: ['*'],
|
||||
ExposeHeaders: ['ETag'],
|
||||
MaxAgeSeconds: 3600
|
||||
}]
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
## Object Lifecycles
|
||||
|
||||
```typescript
|
||||
import { PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
await s3.send(new PutBucketLifecycleConfigurationCommand({
|
||||
Bucket: 'my-bucket',
|
||||
LifecycleConfiguration: {
|
||||
Rules: [
|
||||
{
|
||||
ID: 'expire-old-logs',
|
||||
Status: 'Enabled',
|
||||
Prefix: 'logs/',
|
||||
Expiration: { Days: 90 }
|
||||
},
|
||||
{
|
||||
ID: 'transition-to-ia',
|
||||
Status: 'Enabled',
|
||||
Prefix: 'archives/',
|
||||
Transitions: [{ Days: 30, StorageClass: 'STANDARD_IA' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
## API Token Scopes
|
||||
|
||||
When creating R2 tokens, set minimal permissions:
|
||||
|
||||
| Permission | Use Case |
|
||||
|------------|----------|
|
||||
| Object Read | Public serving, downloads |
|
||||
| Object Write | Uploads only |
|
||||
| Object Read & Write | Full object operations |
|
||||
| Admin Read & Write | Bucket management, CORS, lifecycles |
|
||||
|
||||
**Best practice:** Separate tokens for Workers (read/write) vs admin tasks (CORS, lifecycles).
|
||||
|
||||
## Event Notifications
|
||||
|
||||
```jsonc
|
||||
// wrangler.jsonc
|
||||
{
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MY_BUCKET",
|
||||
"bucket_name": "my-bucket",
|
||||
"event_notifications": [
|
||||
{
|
||||
"queue": "r2-events",
|
||||
"actions": ["PutObject", "DeleteObject", "CompleteMultipartUpload"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"queues": {
|
||||
"producers": [{ "binding": "R2_EVENTS", "queue": "r2-events" }],
|
||||
"consumers": [{ "queue": "r2-events", "max_batch_size": 10 }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bucket Management
|
||||
|
||||
```bash
|
||||
wrangler r2 bucket create my-bucket --location=enam --storage-class=Standard
|
||||
wrangler r2 bucket list
|
||||
wrangler r2 bucket info my-bucket
|
||||
wrangler r2 bucket delete my-bucket # Must be empty
|
||||
wrangler r2 bucket update-storage-class my-bucket --storage-class=InfrequentAccess
|
||||
|
||||
# Public bucket via dashboard
|
||||
wrangler r2 bucket domain add my-bucket --domain=files.example.com
|
||||
```
|
||||
190
.agents/skills/cloudflare-deploy/references/r2/gotchas.md
Normal file
190
.agents/skills/cloudflare-deploy/references/r2/gotchas.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# R2 Gotchas & Troubleshooting
|
||||
|
||||
## List Truncation
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Don't compare object count when using include
|
||||
while (listed.objects.length < options.limit) { ... }
|
||||
|
||||
// ✅ CORRECT: Always use truncated property
|
||||
while (listed.truncated) {
|
||||
const next = await env.MY_BUCKET.list({ cursor: listed.cursor });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Reason:** `include` with metadata may return fewer objects per page to fit metadata.
|
||||
|
||||
## ETag Format
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Using etag (unquoted) in headers
|
||||
headers.set('etag', object.etag); // Missing quotes
|
||||
|
||||
// ✅ CORRECT: Use httpEtag (quoted)
|
||||
headers.set('etag', object.httpEtag);
|
||||
```
|
||||
|
||||
## Checksum Limits
|
||||
|
||||
Only ONE checksum algorithm allowed per PUT:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Multiple checksums
|
||||
await env.MY_BUCKET.put(key, data, { md5: hash1, sha256: hash2 }); // Error
|
||||
|
||||
// ✅ CORRECT: Pick one
|
||||
await env.MY_BUCKET.put(key, data, { sha256: hash });
|
||||
```
|
||||
|
||||
## Multipart Requirements
|
||||
|
||||
- All parts must be uniform size (except last part)
|
||||
- Part numbers start at 1 (not 0)
|
||||
- Uncompleted uploads auto-abort after 7 days
|
||||
- `resumeMultipartUpload` doesn't validate uploadId existence
|
||||
|
||||
## Conditional Operations
|
||||
|
||||
```typescript
|
||||
// Precondition failure returns object WITHOUT body
|
||||
const object = await env.MY_BUCKET.get(key, {
|
||||
onlyIf: { etagMatches: '"wrong"' }
|
||||
});
|
||||
|
||||
// Check for body, not just null
|
||||
if (!object) return new Response('Not found', { status: 404 });
|
||||
if (!object.body) return new Response(null, { status: 304 }); // Precondition failed
|
||||
```
|
||||
|
||||
## Key Validation
|
||||
|
||||
```typescript
|
||||
// ❌ DANGEROUS: Path traversal
|
||||
const key = url.pathname.slice(1); // Could be ../../../etc/passwd
|
||||
await env.MY_BUCKET.get(key);
|
||||
|
||||
// ✅ SAFE: Validate keys
|
||||
if (!key || key.includes('..') || key.startsWith('/')) {
|
||||
return new Response('Invalid key', { status: 400 });
|
||||
}
|
||||
```
|
||||
|
||||
## Storage Class Pitfalls
|
||||
|
||||
- InfrequentAccess: 30-day minimum billing (even if deleted early)
|
||||
- Can't transition IA → Standard via lifecycle (use S3 CopyObject)
|
||||
- Retrieval fees apply for IA reads
|
||||
|
||||
## Stream Length Requirement
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Streaming unknown length fails silently
|
||||
const response = await fetch(url);
|
||||
await env.MY_BUCKET.put(key, response.body); // May fail without error
|
||||
|
||||
// ✅ CORRECT: Buffer or use Content-Length
|
||||
const data = await response.arrayBuffer();
|
||||
await env.MY_BUCKET.put(key, data);
|
||||
|
||||
// OR: Pass Content-Length if known
|
||||
const object = await env.MY_BUCKET.put(key, request.body, {
|
||||
httpMetadata: {
|
||||
contentLength: parseInt(request.headers.get('content-length') || '0')
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Reason:** R2 requires known length for streams. Unknown length may cause silent truncation.
|
||||
|
||||
## S3 SDK Region Configuration
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Missing region breaks ALL S3 SDK calls
|
||||
const s3 = new S3Client({
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { ... }
|
||||
});
|
||||
|
||||
// ✅ CORRECT: MUST set region='auto'
|
||||
const s3 = new S3Client({
|
||||
region: 'auto', // REQUIRED
|
||||
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: { ... }
|
||||
});
|
||||
```
|
||||
|
||||
**Reason:** S3 SDK requires region. R2 uses 'auto' as placeholder.
|
||||
|
||||
## Local Development Limits
|
||||
|
||||
```typescript
|
||||
// ❌ Miniflare/wrangler dev: Limited R2 support
|
||||
// - No multipart uploads
|
||||
// - No presigned URLs (requires S3 SDK + network)
|
||||
// - Memory-backed storage (lost on restart)
|
||||
|
||||
// ✅ Use remote bindings for full features
|
||||
wrangler dev --remote
|
||||
|
||||
// OR: Conditional logic
|
||||
if (env.ENVIRONMENT === 'development') {
|
||||
// Fallback for local dev
|
||||
} else {
|
||||
// Full R2 features
|
||||
}
|
||||
```
|
||||
|
||||
## Presigned URL Expiry
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: URL expires but no client validation
|
||||
const url = await getSignedUrl(s3, command, { expiresIn: 60 });
|
||||
// 61 seconds later: 403 Forbidden
|
||||
|
||||
// ✅ CORRECT: Return expiry to client
|
||||
return Response.json({
|
||||
uploadUrl: url,
|
||||
expiresAt: new Date(Date.now() + 60000).toISOString()
|
||||
});
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Object size | 5 TB |
|
||||
| Multipart part count | 10,000 |
|
||||
| Multipart part min size | 5 MB (except last) |
|
||||
| Batch delete | 1,000 keys |
|
||||
| List limit | 1,000 per request |
|
||||
| Key size | 1024 bytes |
|
||||
| Custom metadata | 2 KB per object |
|
||||
| Presigned URL max expiry | 7 days |
|
||||
|
||||
## Common Errors
|
||||
|
||||
### "Stream upload failed" / Silent Truncation
|
||||
|
||||
**Cause:** Stream length unknown or Content-Length missing
|
||||
**Solution:** Buffer data or pass explicit Content-Length
|
||||
|
||||
### "Invalid credentials" / S3 SDK
|
||||
|
||||
**Cause:** Missing `region: 'auto'` in S3Client config
|
||||
**Solution:** Always set `region: 'auto'` for R2
|
||||
|
||||
### "Object not found"
|
||||
|
||||
**Cause:** Object key doesn't exist or was deleted
|
||||
**Solution:** Verify object key correct, check if object was deleted, ensure bucket correct
|
||||
|
||||
### "List compatibility error"
|
||||
|
||||
**Cause:** Missing or old compatibility_date, or flag not enabled
|
||||
**Solution:** Set `compatibility_date >= 2022-08-04` or enable `r2_list_honor_include` flag
|
||||
|
||||
### "Multipart upload failed"
|
||||
|
||||
**Cause:** Part sizes not uniform or incorrect part number
|
||||
**Solution:** Ensure uniform size except final part, verify part numbers start at 1
|
||||
193
.agents/skills/cloudflare-deploy/references/r2/patterns.md
Normal file
193
.agents/skills/cloudflare-deploy/references/r2/patterns.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# R2 Patterns & Best Practices
|
||||
|
||||
## Streaming Large Files
|
||||
|
||||
```typescript
|
||||
const object = await env.MY_BUCKET.get(key);
|
||||
if (!object) return new Response('Not found', { status: 404 });
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set('etag', object.httpEtag);
|
||||
|
||||
return new Response(object.body, { headers });
|
||||
```
|
||||
|
||||
## Conditional GET (304 Not Modified)
|
||||
|
||||
```typescript
|
||||
const ifNoneMatch = request.headers.get('if-none-match');
|
||||
const object = await env.MY_BUCKET.get(key, {
|
||||
onlyIf: { etagDoesNotMatch: ifNoneMatch?.replace(/"/g, '') || '' }
|
||||
});
|
||||
|
||||
if (!object) return new Response('Not found', { status: 404 });
|
||||
if (!object.body) return new Response(null, { status: 304, headers: { 'etag': object.httpEtag } });
|
||||
|
||||
return new Response(object.body, { headers: { 'etag': object.httpEtag } });
|
||||
```
|
||||
|
||||
## Upload with Validation
|
||||
|
||||
```typescript
|
||||
const key = url.pathname.slice(1);
|
||||
if (!key || key.includes('..')) return new Response('Invalid key', { status: 400 });
|
||||
|
||||
const object = await env.MY_BUCKET.put(key, request.body, {
|
||||
httpMetadata: { contentType: request.headers.get('content-type') || 'application/octet-stream' },
|
||||
customMetadata: { uploadedAt: new Date().toISOString(), ip: request.headers.get('cf-connecting-ip') || 'unknown' }
|
||||
});
|
||||
|
||||
return Response.json({ key: object.key, size: object.size, etag: object.httpEtag });
|
||||
```
|
||||
|
||||
## Multipart with Progress
|
||||
|
||||
```typescript
|
||||
const PART_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const partCount = Math.ceil(file.size / PART_SIZE);
|
||||
const multipart = await env.MY_BUCKET.createMultipartUpload(key, { httpMetadata: { contentType: file.type } });
|
||||
|
||||
const uploadedParts: R2UploadedPart[] = [];
|
||||
try {
|
||||
for (let i = 0; i < partCount; i++) {
|
||||
const start = i * PART_SIZE;
|
||||
const part = await multipart.uploadPart(i + 1, file.slice(start, start + PART_SIZE));
|
||||
uploadedParts.push(part);
|
||||
onProgress?.(Math.round(((i + 1) / partCount) * 100));
|
||||
}
|
||||
return await multipart.complete(uploadedParts);
|
||||
} catch (error) {
|
||||
await multipart.abort();
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Delete
|
||||
|
||||
```typescript
|
||||
async function deletePrefix(prefix: string, env: Env) {
|
||||
let cursor: string | undefined;
|
||||
let truncated = true;
|
||||
|
||||
while (truncated) {
|
||||
const listed = await env.MY_BUCKET.list({ prefix, limit: 1000, cursor });
|
||||
if (listed.objects.length > 0) {
|
||||
await env.MY_BUCKET.delete(listed.objects.map(o => o.key));
|
||||
}
|
||||
truncated = listed.truncated;
|
||||
cursor = listed.cursor;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Checksum Validation & Storage Transitions
|
||||
|
||||
```typescript
|
||||
// Upload with checksum
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
await env.MY_BUCKET.put(key, data, { sha256: hash });
|
||||
|
||||
// Transition storage class (requires S3 SDK)
|
||||
import { S3Client, CopyObjectCommand } from '@aws-sdk/client-s3';
|
||||
await s3.send(new CopyObjectCommand({
|
||||
Bucket: 'my-bucket', Key: key,
|
||||
CopySource: `/my-bucket/${key}`,
|
||||
StorageClass: 'STANDARD_IA'
|
||||
}));
|
||||
```
|
||||
|
||||
## Client-Side Uploads (Presigned URLs)
|
||||
|
||||
```typescript
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
// Worker: Generate presigned upload URL
|
||||
const s3 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY }
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: 'my-bucket', Key: key }), { expiresIn: 3600 });
|
||||
return Response.json({ uploadUrl: url });
|
||||
|
||||
// Client: Upload directly
|
||||
const { uploadUrl } = await fetch('/api/upload-url').then(r => r.json());
|
||||
await fetch(uploadUrl, { method: 'PUT', body: file });
|
||||
```
|
||||
|
||||
## Caching with Cache API
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const cache = caches.default;
|
||||
const url = new URL(request.url);
|
||||
const cacheKey = new Request(url.toString(), request);
|
||||
|
||||
// Check cache first
|
||||
let response = await cache.match(cacheKey);
|
||||
if (response) return response;
|
||||
|
||||
// Fetch from R2
|
||||
const key = url.pathname.slice(1);
|
||||
const object = await env.MY_BUCKET.get(key);
|
||||
if (!object) return new Response('Not found', { status: 404 });
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set('etag', object.httpEtag);
|
||||
headers.set('cache-control', 'public, max-age=31536000, immutable');
|
||||
|
||||
response = new Response(object.body, { headers });
|
||||
|
||||
// Cache for subsequent requests
|
||||
ctx.waitUntil(cache.put(cacheKey, response.clone()));
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Public Bucket with Custom Domain
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
// CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET, HEAD',
|
||||
'access-control-max-age': '86400'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const key = new URL(request.url).pathname.slice(1);
|
||||
if (!key) return Response.redirect('/index.html', 302);
|
||||
|
||||
const object = await env.MY_BUCKET.get(key);
|
||||
if (!object) return new Response('Not found', { status: 404 });
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set('etag', object.httpEtag);
|
||||
headers.set('access-control-allow-origin', '*');
|
||||
headers.set('cache-control', 'public, max-age=31536000, immutable');
|
||||
|
||||
return new Response(object.body, { headers });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## r2.dev Public URLs
|
||||
|
||||
Enable r2.dev in dashboard for simple public access: `https://pub-${hashId}.r2.dev/${key}`
|
||||
Or add custom domain via dashboard: `https://files.example.com/${key}`
|
||||
|
||||
**Limitations:** No auth, bucket-level CORS, no cache override.
|
||||
Reference in New Issue
Block a user