Skip to content

File Uploads

CruzJS provides a complete upload system built on top of the StorageService. It handles presigned URL generation, file validation (type and size), upload status tracking, and cleanup of failed uploads.

1. Client requests upload URL
2. Server validates file (type, size)
3. Server generates presigned URL + creates upload record (PENDING)
4. Client uploads file directly to R2 via presigned URL
5. Client confirms upload
6. Server verifies file in storage, updates status to COMPLETED

This two-step flow keeps large files off your server — clients upload directly to R2 using the presigned URL.

CruzJS includes built-in validation rules for common upload types:

TypeMax SizeAllowed TypesAllowed Extensions
avatar5 MBJPEG, PNG, WebP, GIF.jpg, .jpeg, .png, .webp, .gif
document10 MBPDF, Word, Plain text.pdf, .doc, .docx, .txt
image10 MBJPEG, PNG, WebP, GIF.jpg, .jpeg, .png, .webp, .gif
video100 MBMP4, WebM, QuickTime.mp4, .webm, .mov
general50 MBAnyAny

These rules can be customized in cruz.config.ts:

cruz.config.ts
export default {
upload: {
fileValidationRules: {
avatar: {
maxSize: 2 * 1024 * 1024, // 2 MB instead of 5 MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
allowedExtensions: ['.jpg', '.jpeg', '.png', '.webp'],
},
// ... other types inherit defaults
},
},
};
import { Injectable, Inject } from '@cruzjs/core/di';
import { UploadService } from '@cruzjs/core/upload/upload.service';
@Injectable()
export class ProfileService {
constructor(
@Inject(UploadService) private readonly uploadService: UploadService,
) {}
async requestAvatarUpload(userId: string, fileName: string, fileSize: number, contentType: string) {
const response = await this.uploadService.requestUpload(
{ userId, fileName, fileSize, contentType },
'avatar', // upload type for validation
);
return {
uploadId: response.id,
uploadUrl: response.uploadUrl, // presigned R2 URL
key: response.key, // storage key for confirmation
expiresAt: response.expiresAt, // URL expiration
maxSize: response.maxSize, // validated max size
};
}
}

The requestUpload method:

  1. Validates the file against the upload type rules (size, MIME type, extension)
  2. Generates a unique storage key with the pattern uploads/{userId}/{timestamp}-{random}-{filename}
  3. Creates a presigned upload URL from the storage driver
  4. Creates an upload record in D1 with status PENDING

After the client uploads the file to R2, confirm the upload to update its status:

async confirmAvatarUpload(uploadId: string, key: string) {
const upload = await this.uploadService.confirmUpload({ uploadId, key });
// upload.status is now 'COMPLETED'
return upload;
}

The confirmUpload method verifies the file exists in storage, checks the key matches the record, and updates the status to COMPLETED.

// Deletes both the storage object and the database record
await this.uploadService.deleteUpload(uploadId);
// All uploads for a user
const uploads = await this.uploadService.listUserUploads(userId);
// Only completed uploads
const completed = await this.uploadService.listUserUploads(userId, 'COMPLETED');

Remove stale FAILED uploads older than a specified number of hours:

// Clean up uploads that failed more than 24 hours ago
const deletedCount = await this.uploadService.cleanupFailedUploads(24);

This is useful as a scheduled job:

@Injectable()
export class UploadCleanupHandler implements JobHandler {
readonly metadata: JobHandlerMetadata = {
jobType: 'upload-cleanup',
statuses: ['PENDING'],
};
constructor(@Inject(UploadService) private uploadService: UploadService) {}
async run(): Promise<JobResult> {
const deleted = await this.uploadService.cleanupFailedUploads(24);
return { success: true, summary: { deletedCount: deleted } };
}
}
import { router, protectedProcedure } from '@cruzjs/core';
import { z } from 'zod';
import { getAppContainer } from '@cruzjs/core';
import { UploadService } from '@cruzjs/core/upload/upload.service';
export const uploadRouter = router({
requestUpload: protectedProcedure
.input(z.object({
fileName: z.string(),
fileSize: z.number().positive(),
contentType: z.string(),
uploadType: z.enum(['avatar', 'document', 'image', 'video', 'general']).default('general'),
}))
.mutation(async ({ ctx, input }) => {
const container = await getAppContainer();
const uploadService = container.resolve(UploadService);
return uploadService.requestUpload(
{
userId: ctx.user.id,
fileName: input.fileName,
fileSize: input.fileSize,
contentType: input.contentType,
},
input.uploadType,
);
}),
confirmUpload: protectedProcedure
.input(z.object({
uploadId: z.string(),
key: z.string(),
}))
.mutation(async ({ input }) => {
const container = await getAppContainer();
const uploadService = container.resolve(UploadService);
return uploadService.confirmUpload(input);
}),
deleteUpload: protectedProcedure
.input(z.object({ uploadId: z.string() }))
.mutation(async ({ input }) => {
const container = await getAppContainer();
const uploadService = container.resolve(UploadService);
await uploadService.deleteUpload(input.uploadId);
return { success: true };
}),
});
// In a React component
async function handleFileUpload(file: File) {
// Step 1: Request upload URL from server
const { uploadUrl, uploadId, key } = await trpc.upload.requestUpload.mutate({
fileName: file.name,
fileSize: file.size,
contentType: file.type,
uploadType: 'image',
});
// Step 2: Upload directly to R2
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
// Step 3: Confirm the upload
const upload = await trpc.upload.confirmUpload.mutate({ uploadId, key });
return upload;
}

Each upload is tracked in D1 with the following fields:

FieldTypeDescription
idstringUnique upload ID
userIdstringOwner of the upload
filenamestringSanitized filename in storage
originalFilenamestringOriginal filename from user
sizenumberFile size in bytes
mimeTypestringMIME content type
bucketstringR2 bucket name
keystringFull storage key
statusstringPENDING, UPLOADING, COMPLETED, FAILED
urlstring?Public URL (if bucket is public)
uploadedAtstring?When upload was completed
createdAtstringWhen record was created
  1. Always validate on the server. Even though client-side validation improves UX, the server must enforce file type and size limits. The UploadService validates before generating presigned URLs.

  2. Use upload types. Specify the appropriate upload type (avatar, document, image, etc.) to get automatic validation rules. Define custom types in cruz.config.ts for domain-specific needs.

  3. Clean up failed uploads. Run the cleanup job periodically to remove PENDING and FAILED uploads that are older than 24 hours. This prevents orphaned storage objects.

  4. Confirm uploads after client upload. Always call confirmUpload after the client finishes uploading. This verifies the file actually exists in storage and updates the database record.

  5. Delete storage when deleting records. The deleteUpload method handles both the storage object and the database record. If you delete records manually, remember to also delete the corresponding R2 object.