Skip to content

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.

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);
}
}

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 }>;
};

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=local

If STORAGE_DRIVER=r2 but the R2 binding is not available (local dev without wrangler), the service automatically falls back to local storage.

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,
}));
}
}

CruzJS includes a built-in upload system. The typical flow is:

// In your tRPC router
export 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 };
}),
});
// Server-side upload handler
async uploadFile(key: string, file: Buffer, contentType: string) {
const storage = this.storage.disk();
await storage.put(key, file, { contentType });
}
// In a React Router loader
export 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',
},
});
}

R2 buckets can be configured for public access via custom domains:

wrangler.toml
[[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.com

Then R2Service.getPublicUrl(key) returns https://assets.myapp.com/path/to/file.

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
}
]
}

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.

# 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"
ResourceFreePaid
Storage10 GB$0.015/GB/month
Class A operations (write)1M/month$4.50/M
Class B operations (read)10M/month$0.36/M
EgressFreeFree
Max object size5 TB5 TB

The zero egress cost is R2’s primary advantage over S3 — serving files to users costs nothing regardless of bandwidth.

  • Workers — Process uploads in background Workers
  • Queues — Queue upload processing tasks
  • D1 Database — Store upload metadata