R2 Storage
Cloudflare R2 is S3-compatible object storage with zero egress fees. CruzJS provides two layers of access: a low-level R2Service for direct R2 operations and a high-level StorageService that abstracts over R2 and local filesystem storage.
StorageService
Section titled “StorageService”The StorageService is the recommended way to interact with file storage. It automatically selects the appropriate driver (R2 in production, local filesystem in development):
import { injectable, inject } from 'inversify';import { StorageService } from '@cruzjs/core/shared/storage/storage.service';
@injectable()export class AvatarService { constructor(@inject(StorageService) private readonly storage: StorageService) {}
async uploadAvatar(userId: string, file: Buffer, contentType: string): Promise<string> { const key = `avatars/${userId}/${Date.now()}.jpg`;
await this.storage.disk().put(key, file, { contentType, metadata: { userId }, });
return key; }
async getAvatarUrl(key: string): Promise<string> { return this.storage.disk().url(key); }
async deleteAvatar(key: string): Promise<void> { await this.storage.disk().delete(key); }}StorageDriver Interface
Section titled “StorageDriver Interface”Both R2 and local drivers implement the same interface:
type StorageDriver = { put(key: string, content: Buffer | string, options?: PutOptions): Promise<string>; get(key: string): Promise<Buffer>; delete(key: string): Promise<void>; exists(key: string): Promise<boolean>; getMetadata(key: string): Promise<{ size: number; contentType: string; lastModified: Date; etag?: string; } | null>; url(key: string): Promise<string>; signedUrl(key: string, expiresIn?: number): Promise<string>; getPresignedUploadUrl( key: string, contentType: string, maxSize: number, expiresIn?: number ): Promise<{ url: string; expiresAt: Date }>;};Driver Selection
Section titled “Driver Selection”The active driver is determined by the STORAGE_DRIVER environment variable:
# Use R2 (default in production)STORAGE_DRIVER=r2
# Use local filesystem (default in local dev when R2 is unavailable)STORAGE_DRIVER=localIf STORAGE_DRIVER=r2 but the R2 binding is not available (local dev without wrangler), the service automatically falls back to local storage.
R2Service (Low-Level)
Section titled “R2Service (Low-Level)”For direct R2 operations, use R2Service:
import { injectable, inject } from 'inversify';import { R2Service } from '@cruzjs/core/shared/cloudflare/r2.service';
@injectable()export class DocumentService { constructor(@inject(R2Service) private readonly r2: R2Service) {}
async uploadDocument(key: string, content: ArrayBuffer): Promise<void> { await this.r2.put(key, content, { httpMetadata: { contentType: 'application/pdf', cacheControl: 'public, max-age=86400', }, customMetadata: { uploadedBy: 'system', }, }); }
async downloadDocument(key: string): Promise<Buffer | null> { return this.r2.getAsBuffer(key); }
async getDocumentStream(key: string) { const result = await this.r2.get(key); if (!result) return null;
return { body: result.body, // ReadableStream contentType: result.metadata.httpMetadata?.contentType, size: result.metadata.size, }; }
async listDocuments(prefix: string) { const result = await this.r2.list({ prefix, limit: 100, });
return result.objects.map(obj => ({ key: obj.key, size: obj.size, uploaded: obj.uploaded, })); }}Upload Flow
Section titled “Upload Flow”CruzJS includes a built-in upload system. The typical flow is:
1. Client Requests Upload
Section titled “1. Client Requests Upload”// In your tRPC routerexport const documentRouter = router({ requestUpload: protectedProcedure .input(z.object({ filename: z.string(), contentType: z.string(), size: z.number(), })) .mutation(async ({ ctx, input }) => { const key = `documents/${ctx.session.user.id}/${input.filename}`;
// Create upload record in database const upload = await ctx.container .get(UploadService) .createUpload({ userId: ctx.session.user.id, filename: input.filename, contentType: input.contentType, size: input.size, key, });
return { uploadId: upload.id, key }; }),});2. Upload File Content
Section titled “2. Upload File Content”// Server-side upload handlerasync uploadFile(key: string, file: Buffer, contentType: string) { const storage = this.storage.disk(); await storage.put(key, file, { contentType });}3. Serve Files
Section titled “3. Serve Files”// In a React Router loaderexport async function loader({ params }: LoaderFunctionArgs) { const r2 = CloudflareContext.r2; if (!r2) throw new Response('Storage unavailable', { status: 503 });
const object = await r2.get(params.key); if (!object) throw new Response('Not found', { status: 404 });
return new Response(object.body, { headers: { 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream', 'Cache-Control': 'public, max-age=86400', }, });}Public Access
Section titled “Public Access”R2 buckets can be configured for public access via custom domains:
[[r2_buckets]]binding = "UPLOADS_BUCKET"bucket_name = "my-app-uploads"Set the R2_PUBLIC_URL environment variable to enable public URL generation:
R2_PUBLIC_URL=https://assets.myapp.comThen R2Service.getPublicUrl(key) returns https://assets.myapp.com/path/to/file.
CORS Configuration
Section titled “CORS Configuration”Configure CORS for client-side uploads in the Cloudflare dashboard or via the API:
{ "cors": [ { "allowedOrigins": ["https://myapp.com"], "allowedMethods": ["GET", "PUT", "POST", "DELETE"], "allowedHeaders": ["Content-Type", "Authorization"], "maxAgeSeconds": 3600 } ]}Local Development
Section titled “Local Development”In local development, the LocalStorageDriver stores files on disk instead of R2. Files are written to a configurable directory (default: ./data/storage/). The local driver implements the same StorageDriver interface, so your application code works identically.
wrangler.toml Configuration
Section titled “wrangler.toml Configuration”# Create a bucket:# npx wrangler r2 bucket create my-app-uploads
[[r2_buckets]]binding = "UPLOADS_BUCKET"bucket_name = "my-app-uploads"
# Or use the alternative binding name[[r2_buckets]]binding = "STORAGE"bucket_name = "my-app-storage"R2 Limits
Section titled “R2 Limits”| Resource | Free | Paid |
|---|---|---|
| Storage | 10 GB | $0.015/GB/month |
| Class A operations (write) | 1M/month | $4.50/M |
| Class B operations (read) | 10M/month | $0.36/M |
| Egress | Free | Free |
| Max object size | 5 TB | 5 TB |
The zero egress cost is R2’s primary advantage over S3 — serving files to users costs nothing regardless of bandwidth.
Next Steps
Section titled “Next Steps”- Workers — Process uploads in background Workers
- Queues — Queue upload processing tasks
- D1 Database — Store upload metadata