Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions example/convex/example.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { v } from "convex/values";
import { ConvexError, v } from "convex/values";
import {
action,
internalMutation,
Expand Down Expand Up @@ -28,9 +28,17 @@ export const {
// The checkUpload callback is used for both `generateUploadUrl` and
// `syncMetadata`.
// In any of these checks, throw an error to reject the request.
checkUpload: async (ctx, bucket) => {
checkUpload: async (ctx, bucket, fileInfo) => {
// const user = await userFromAuth(ctx);
// ...validate that the user can upload to this bucket

// Example: enforce a 1MB file size limit
const maxSize = 1 * 1024 * 1024; // 1MB
if (fileInfo?.size && fileInfo.size > maxSize) {
throw new ConvexError(
`File is too large (${(fileInfo.size / 1024 / 1024).toFixed(1)}MB). Maximum size is ${maxSize / 1024 / 1024}MB.`,
);
}
},
checkReadKey: async (ctx, bucket, key) => {
// const user = await userFromAuth(ctx);
Expand Down
50 changes: 41 additions & 9 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { api } from "../convex/_generated/api";
import { ConvexError } from "convex/values";
import { useUploadFile } from "@convex-dev/r2/react";
import { Upload, Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
Expand All @@ -16,6 +17,8 @@ import { MetadataTable } from "./MetadataTable";
import { GalleryImage } from "@/GalleryImage";
import { useState } from "react";

const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB — should match the server-side limit

export default function App() {
const convex = useConvex();
const uploadFile = useUploadFile(api.example);
Expand All @@ -24,6 +27,7 @@ export default function App() {
);
const [isGenerating, setIsGenerating] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const updateImageCaption = useMutation(
api.example.updateImageCaption,
).withOptimisticUpdate((localStore, args) => {
Expand All @@ -50,16 +54,39 @@ export default function App() {

async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault();
setUploadError(null);

const file = event.target.files?.[0];
if (!file) return;

// Client-side check for instant feedback without a server round-trip.
// The server-side checkUpload callback still enforces this as a safety net.
if (file.size > MAX_FILE_SIZE) {
setUploadError(
`File is too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB.`,
);
return;
}

setUploadProgress(0);
// `uploadFile` returns the key of the uploaded file, which you can use to
// query that specific image
const key = await uploadFile(event.target.files![0], {
onProgress: ({ loaded, total }) => {
setUploadProgress(Math.round((loaded / total) * 100));
},
});
setUploadProgress(null);
console.log("Uploaded image with key:", key);
try {
// `uploadFile` returns the key of the uploaded file, which you can use to
// query that specific image
const key = await uploadFile(file, {
onProgress: ({ loaded, total }) => {
setUploadProgress(Math.round((loaded / total) * 100));
},
});
console.log("Uploaded image with key:", key);
} catch (error) {
const message =
error instanceof ConvexError
? (error.data as string)
: "Failed to upload file";
setUploadError(message);
} finally {
setUploadProgress(null);
}
}

// Debounce the updateImageCaption mutation to avoid blocking input changes.
Expand Down Expand Up @@ -112,6 +139,11 @@ export default function App() {
className="hidden"
/>
</div>
{uploadError && (
<div className="mb-4 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
{uploadError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images?.map((image) => (
<GalleryImage
Expand Down
43 changes: 37 additions & 6 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,21 @@ export class R2 {
* - `key` - The R2 object key.
* - `url` - A signed URL for uploading the object.
*/
async generateUploadUrl(customKey?: string) {
async generateUploadUrl(
customKey?: string,
opts?: { contentLength?: number; contentType?: string },
) {
const key = customKey || crypto.randomUUID();
const url = await getSignedUrl(
this.client,
new PutObjectCommand({ Bucket: this.config.bucket, Key: key }),
new PutObjectCommand({
Bucket: this.config.bucket,
Key: key,
...(opts?.contentLength !== undefined && {
ContentLength: opts.contentLength,
}),
...(opts?.contentType && { ContentType: opts.contentType }),
}),
);
return { key, url };
}
Expand Down Expand Up @@ -353,9 +363,15 @@ export class R2 {
ctx: GenericQueryCtx<DataModel>,
bucket: string,
) => void | Promise<void>;
/**
* Called during both `generateUploadUrl` (with `fileInfo`) and
* `syncMetadata` (without `fileInfo`). Implementations should
* treat `fileInfo` as optional — e.g. use `fileInfo?.size`.
*/
checkUpload?: (
ctx: GenericQueryCtx<DataModel>,
bucket: string,
fileInfo?: { size?: number; type?: string },
) => void | Promise<void>;
checkDelete?: (
ctx: GenericQueryCtx<DataModel>,
Expand Down Expand Up @@ -383,16 +399,31 @@ export class R2 {
* Generate a signed URL for uploading an object to R2.
*/
generateUploadUrl: mutationGeneric({
args: {},
args: {
fileSize: v.optional(v.number()),
contentType: v.optional(v.string()),
},
returns: v.object({
key: v.string(),
url: v.string(),
}),
handler: async (ctx) => {
handler: async (ctx, args) => {
if (
args.fileSize !== undefined &&
(!Number.isInteger(args.fileSize) || args.fileSize < 0)
) {
throw new Error("fileSize must be a non-negative integer");
}
if (opts?.checkUpload) {
await opts.checkUpload(ctx, this.config.bucket);
await opts.checkUpload(ctx, this.config.bucket, {
size: args.fileSize,
type: args.contentType,
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return this.generateUploadUrl();
return this.generateUploadUrl(undefined, {
contentLength: args.fileSize,
contentType: args.contentType,
});
},
}),
/**
Expand Down
5 changes: 4 additions & 1 deletion src/react/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ describe("react useUploadFile", () => {
const file = new File(["content"], "test.txt", { type: "text/plain" });
const result = await upload(file);

expect(mockGenerateUploadUrl).toHaveBeenCalled();
expect(mockGenerateUploadUrl).toHaveBeenCalledWith({
fileSize: file.size,
contentType: "text/plain",
});
expect(uploadWithProgress).toHaveBeenCalledWith(url, file, undefined);
expect(mockSyncMetadata).toHaveBeenCalledWith({ key });
expect(result).toBe(key);
Expand Down
6 changes: 5 additions & 1 deletion src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export function useUploadFile(
onProgress?: (progress: { loaded: number; total: number }) => void;
},
) => {
const { url, key } = await generateUploadUrl();
const { url, key } = await generateUploadUrl({
fileSize: file.size,
contentType: file.type,
});

await uploadWithProgress(url, file, options?.onProgress);
await syncMetadata({ key });
return key;
Expand Down
5 changes: 4 additions & 1 deletion src/svelte/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ describe("svelte useUploadFile", () => {
const file = new File(["content"], "test.txt", { type: "text/plain" });
const result = await upload(file);

expect(mockMutation).toHaveBeenCalledWith(mockApi.generateUploadUrl, {});
expect(mockMutation).toHaveBeenCalledWith(mockApi.generateUploadUrl, {
fileSize: file.size,
contentType: "text/plain",
});
expect(uploadWithProgress).toHaveBeenCalledWith(url, file, undefined);
expect(mockMutation).toHaveBeenCalledWith(mockApi.syncMetadata, { key });
expect(result).toBe(key);
Expand Down
5 changes: 4 additions & 1 deletion src/svelte/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export function useUploadFile(
onProgress?: (progress: { loaded: number; total: number }) => void;
},
) => {
const { url, key } = await client.mutation(api.generateUploadUrl, {});
const { url, key } = await client.mutation(api.generateUploadUrl, {
fileSize: file.size,
contentType: file.type,
});
await uploadWithProgress(url, file, options?.onProgress);
await client.mutation(api.syncMetadata, { key });
return key;
Expand Down
5 changes: 4 additions & 1 deletion src/vue/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ describe("vue useUploadFile", () => {
const file = new File(["content"], "test.txt", { type: "text/plain" });
const result = await upload(file);

expect(mockMutation).toHaveBeenCalledWith(mockApi.generateUploadUrl, {});
expect(mockMutation).toHaveBeenCalledWith(mockApi.generateUploadUrl, {
fileSize: file.size,
contentType: "text/plain",
});
expect(uploadWithProgress).toHaveBeenCalledWith(url, file, undefined);
expect(mockMutation).toHaveBeenCalledWith(mockApi.syncMetadata, { key });
expect(result).toBe(key);
Expand Down
5 changes: 4 additions & 1 deletion src/vue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export function useUploadFile(
onProgress?: (progress: { loaded: number; total: number }) => void;
},
) => {
const { url, key } = await client.mutation(api.generateUploadUrl, {});
const { url, key } = await client.mutation(api.generateUploadUrl, {
fileSize: file.size,
contentType: file.type,
});
await uploadWithProgress(url, file, options?.onProgress);
await client.mutation(api.syncMetadata, { key });
return key;
Expand Down