From de8e5d5e2b5aaee7845ba4a9b3ff340bc863494f Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Thu, 9 Jan 2025 15:42:10 -0500 Subject: [PATCH 1/4] fix readme typos, drop beta notice --- README.md | 3 +-- src/client/index.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 07665bc..22e09a7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![npm version](https://badge.fury.io/js/@convex-dev%2Fr2.svg)](https://badge.fury.io/js/@convex-dev%2Fr2) -**Note: Convex Components are currently in beta.** - Store and serve files with Cloudflare R2. @@ -474,6 +472,7 @@ export const getMetadata = query({ return await r2.getMetadata(args.key); }, }); +``` This is an example of the returned document: diff --git a/src/client/index.ts b/src/client/index.ts index b6f9dc5..5a2fe50 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -37,7 +37,6 @@ import { } from "../shared"; import { DataModel, Id } from "../component/_generated/dataModel"; -// Note: this value is hard-coded in the docstring below. Please keep in sync. export const DEFAULT_BATCH_SIZE = 10; export class R2 { From 0899d9ac9ccc7d4edc6d771312d986408713f89c Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Fri, 10 Jan 2025 10:49:01 -0500 Subject: [PATCH 2/4] wip --- README.md | 10 +++--- example/convex/_generated/api.d.ts | 18 +--------- example/convex/example.ts | 12 ------- src/client/index.ts | 40 +++------------------- src/component/_generated/api.d.ts | 18 +--------- src/component/_generated/dataModel.d.ts | 34 ++++++++++--------- src/component/lib.ts | 38 +++------------------ src/component/schema.ts | 12 +++++++ src/shared.ts | 45 +------------------------ 9 files changed, 47 insertions(+), 180 deletions(-) create mode 100644 src/component/schema.ts diff --git a/README.md b/README.md index 22e09a7..4331680 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,11 @@ export default app; Set your API credentials using the values you recorded earlier: ```sh -npx convex env set R2_TOKEN=xxxxx -npx convex env set R2_ACCESS_KEY_ID=xxxxx -npx convex env set R2_SECRET_ACCESS_KEY=xxxxx -npx convex env set R2_ENDPOINT=xxxxx -npx convex env set R2_BUCKET=xxxxx +npx convex env set R2_TOKEN xxxxx +npx convex env set R2_ACCESS_KEY_ID xxxxx +npx convex env set R2_SECRET_ACCESS_KEY xxxxx +npx convex env set R2_ENDPOINT xxxxx +npx convex env set R2_BUCKET xxxxx ``` Instantiate a R2 Component client in a file in your app's `convex/` folder: diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 2f316e5..5b9f6dd 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -54,24 +54,8 @@ export declare const components: { }, any >; - exportConvexFilesToR2: FunctionReference< - "action", - "internal", - { - accessKeyId: string; - batchSize?: number; - bucket: string; - deleteFn: string; - endpoint: string; - listFn: string; - nextFn: string; - secretAccessKey: string; - uploadFn: string; - }, - any - >; generateUploadUrl: FunctionReference< - "action", + "mutation", "internal", { accessKeyId: string; diff --git a/example/convex/example.ts b/example/convex/example.ts index b4fddd9..0521436 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -14,18 +14,6 @@ export const listConvexFiles = r2.listConvexFiles(); export const uploadFile = r2.uploadFile(); export const deleteFile = r2.deleteFile(); -export const exportConvexFilesToR2 = internalAction({ - handler: async (ctx) => { - await r2.exportConvexFilesToR2(ctx, { - listFn: internal.example.listConvexFiles, - uploadFn: internal.example.uploadFile, - nextFn: internal.example.exportConvexFilesToR2, - deleteFn: internal.example.deleteFile, - batchSize: 10, - }); - }, -}); - export const generateUploadUrl = action(() => { return r2.generateUploadUrl(); }); diff --git a/src/client/index.ts b/src/client/index.ts index 5a2fe50..cbce8c3 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,11 +1,11 @@ import { ActionBuilder, - createFunctionHandle, Expand, FunctionHandle, FunctionReference, GenericActionCtx, GenericDataModel, + GenericMutationCtx, httpActionGeneric, HttpRouter, internalActionGeneric, @@ -27,14 +27,7 @@ import { } from "@aws-sdk/client-s3"; import { S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { - createR2Client, - DeleteArgs, - ListArgs, - ListResult, - r2ConfigValidator, - UploadArgs, -} from "../shared"; +import { createR2Client, r2ConfigValidator } from "../shared"; import { DataModel, Id } from "../component/_generated/dataModel"; export const DEFAULT_BATCH_SIZE = 10; @@ -210,32 +203,6 @@ export class R2 { Promise >; } - async exportConvexFilesToR2( - ctx: GenericActionCtx, - { - listFn, - uploadFn, - nextFn, - deleteFn, - batchSize, - }: { - listFn: FunctionReference<"query", "internal", ListArgs, ListResult>; - uploadFn: FunctionReference<"action", "internal", UploadArgs>; - deleteFn: FunctionReference<"mutation", "internal", DeleteArgs>; - nextFn: FunctionReference<"action", "internal">; - batchSize?: number; - } - ) { - return await ctx.runAction(this.component.lib.exportConvexFilesToR2, { - ...this.r2Config, - listFn: await createFunctionHandle(listFn), - uploadFn: await createFunctionHandle(uploadFn), - nextFn: await createFunctionHandle(nextFn), - deleteFn: await createFunctionHandle(deleteFn), - batchSize: - batchSize ?? this.options?.defaultBatchSize ?? DEFAULT_BATCH_SIZE, - }); - } registerRoutes( http: HttpRouter, { @@ -297,6 +264,9 @@ export class R2 { type RunActionCtx = { runAction: GenericActionCtx["runAction"]; }; +type RunMutationCtx = { + runMutation: GenericMutationCtx["runMutation"]; +}; export type OpaqueIds = T extends GenericId diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index 30c790f..d778f8b 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -40,24 +40,8 @@ export type Mounts = { }, any >; - exportConvexFilesToR2: FunctionReference< - "action", - "public", - { - accessKeyId: string; - batchSize?: number; - bucket: string; - deleteFn: string; - endpoint: string; - listFn: string; - nextFn: string; - secretAccessKey: string; - uploadFn: string; - }, - any - >; generateUploadUrl: FunctionReference< - "action", + "mutation", "public", { accessKeyId: string; diff --git a/src/component/_generated/dataModel.d.ts b/src/component/_generated/dataModel.d.ts index fb12533..8541f31 100644 --- a/src/component/_generated/dataModel.d.ts +++ b/src/component/_generated/dataModel.d.ts @@ -8,29 +8,29 @@ * @module */ -import { AnyDataModel } from "convex/server"; +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; import type { GenericId } from "convex/values"; - -/** - * No `schema.ts` file found! - * - * This generated code has permissive types like `Doc = any` because - * Convex doesn't know your schema. If you'd like more type safety, see - * https://docs.convex.dev/using/schemas for instructions on how to add a - * schema file. - * - * After you change a schema, rerun codegen with `npx convex dev`. - */ +import schema from "../schema.js"; /** * The names of all of your Convex tables. */ -export type TableNames = string; +export type TableNames = TableNamesInDataModel; /** * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = any; +export type Doc = DocumentByName< + DataModel, + TableName +>; /** * An identifier for a document in Convex. @@ -42,8 +42,10 @@ export type Doc = any; * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Id = +export type Id = GenericId; /** @@ -55,4 +57,4 @@ export type Id = * This type is used to parameterize methods like `queryGeneric` and * `mutationGeneric` to make them type-safe. */ -export type DataModel = AnyDataModel; +export type DataModel = DataModelFromSchemaDefinition; diff --git a/src/component/lib.ts b/src/component/lib.ts index 9242625..22fe859 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -1,4 +1,4 @@ -import { action, query } from "./_generated/server"; +import { action, mutation, query } from "./_generated/server"; import { DeleteObjectCommand, GetObjectCommand, @@ -7,21 +7,13 @@ import { } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { v } from "convex/values"; -import { - r2ConfigValidator, - createR2Client, - exportArgs, - ListArgs, - ListResult, - UploadArgs, -} from "../shared"; -import { FunctionHandle } from "convex/server"; +import { r2ConfigValidator, createR2Client } from "../shared"; -export const generateUploadUrl = action({ +export const generateUploadUrl = mutation({ args: { ...r2ConfigValidator.fields, }, - handler: async (ctx, args) => { + handler: async (_ctx, args) => { const r2 = createR2Client(args); const key = crypto.randomUUID(); const url = await getSignedUrl( @@ -105,25 +97,3 @@ export const getMetadata = action({ }; }, }); - -export const exportConvexFilesToR2 = action({ - args: exportArgs, - handler: async (ctx, args) => { - const files = await ctx.runQuery( - args.listFn as FunctionHandle<"query", ListArgs, ListResult>, - { batchSize: args.batchSize } - ); - if (files.length === 0) { - return; - } - await ctx.runAction(args.uploadFn as FunctionHandle<"action", UploadArgs>, { - files, - deleteFn: args.deleteFn, - }); - await ctx.scheduler.runAfter( - 0, - args.nextFn as FunctionHandle<"action", typeof args>, - args - ); - }, -}); diff --git a/src/component/schema.ts b/src/component/schema.ts new file mode 100644 index 0000000..af3b38a --- /dev/null +++ b/src/component/schema.ts @@ -0,0 +1,12 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + images: defineTable({ + key: v.string(), + sha256: v.string(), + contentType: v.string(), + size: v.number(), + bucket: v.string(), + }).index("key", ["key"]), +}); diff --git a/src/shared.ts b/src/shared.ts index 36c29ef..d00ae57 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,6 +1,5 @@ -import { Infer, ObjectType, v } from "convex/values"; +import { Infer, v } from "convex/values"; import { S3Client } from "@aws-sdk/client-s3"; -import { Id } from "./component/_generated/dataModel"; export const r2ConfigValidator = v.object({ bucket: v.string(), @@ -19,45 +18,3 @@ export const createR2Client = (args: Infer) => { }, }); }; - -export const listArgs = { - batchSize: v.optional(v.number()), -}; -export type ListArgs = ObjectType; - -export type ListResult = { - _id: Id<"_storage">; - _creationTime: number; - contentType?: string; - sha256: string; - size: number; -}[]; - -export const uploadArgs = { - files: v.array( - v.object({ - _id: v.id("_storage"), - _creationTime: v.number(), - contentType: v.optional(v.string()), - sha256: v.string(), - size: v.number(), - }) - ), - deleteFn: v.string(), -}; -export type UploadArgs = ObjectType; - -export const exportArgs = { - ...r2ConfigValidator.fields, - listFn: v.string(), - uploadFn: v.string(), - deleteFn: v.string(), - nextFn: v.string(), - batchSize: v.optional(v.number()), -}; -export type ExportArgs = ObjectType; - -export const deleteArgs = { - fileId: v.id("_storage"), -}; -export type DeleteArgs = ObjectType; From 95dc0acae95cc501adaa458d819a0e495dee4aac Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Fri, 10 Jan 2025 17:40:57 -0500 Subject: [PATCH 3/4] sync object metadata --- example/convex/_generated/api.d.ts | 30 ++++++++++++++ example/convex/example.ts | 7 ++++ example/src/App.tsx | 12 ++++-- src/client/index.ts | 9 +++-- src/component/_generated/api.d.ts | 30 ++++++++++++++ src/component/lib.ts | 65 +++++++++++++++++++++++++++++- src/component/schema.ts | 2 +- 7 files changed, 146 insertions(+), 9 deletions(-) diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 5b9f6dd..5b9daf3 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -42,6 +42,12 @@ export declare const internal: FilterApi< export declare const components: { r2: { lib: { + deleteMetadata: FunctionReference< + "mutation", + "internal", + { key: string }, + any + >; deleteObject: FunctionReference< "action", "internal", @@ -89,6 +95,18 @@ export declare const components: { }, any >; + insertMetadata: FunctionReference< + "mutation", + "internal", + { + bucket: string; + contentType: string; + key: string; + sha256: string; + size: number; + }, + any + >; store: FunctionReference< "action", "internal", @@ -101,6 +119,18 @@ export declare const components: { }, any >; + syncMetadata: FunctionReference< + "action", + "internal", + { + accessKeyId: string; + bucket: string; + endpoint: string; + key: string; + secretAccessKey: string; + }, + any + >; }; }; }; diff --git a/example/convex/example.ts b/example/convex/example.ts index 0521436..4f705d7 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -18,6 +18,13 @@ export const generateUploadUrl = action(() => { return r2.generateUploadUrl(); }); +export const syncMetadata = action({ + args: { key: v.string() }, + handler: async (ctx, args) => { + await r2.syncMetadata(ctx, args.key); + }, +}); + export const getRecentImages = query({ args: {}, handler: async (ctx) => { diff --git a/example/src/App.tsx b/example/src/App.tsx index d31627b..0768c4b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -4,13 +4,18 @@ import { FormEvent, useRef, useState } from "react"; import { api } from "../convex/_generated/api"; // Set to true to use HTTP Action instead of signed URL -const GET_VIA_HTTP = true; -const SEND_VIA_HTTP = true; +const GET_VIA_HTTP = false; +const SEND_VIA_HTTP = false; +const convexSiteUrl = (import.meta.env.VITE_CONVEX_URL as string).replace( + ".cloud", + ".site" +); export function App() { const generateUploadUrl = useAction(api.example.generateUploadUrl); const sendImage = useMutation(api.example.sendImage); const deleteImage = useAction(api.example.deleteImage); + const syncMetadata = useAction(api.example.syncMetadata); const images = useQuery(api.example.getRecentImages); const imageInput = useRef(null); const [sending, setSending] = useState(false); @@ -39,6 +44,7 @@ export function App() { throw new Error(`Failed to upload image: ${error}`); } // Step 3: Save the newly allocated storage id to the database + await syncMetadata({ key }); await sendImage({ key, author: name }); setSending(false); setSelectedImage(null); @@ -51,7 +57,7 @@ export function App() { const sendImageUrl = new URL( // Use Convex Action URL - "https://giant-kangaroo-636.convex.site/r2/send" + `${convexSiteUrl}/r2/send` ); sendImageUrl.searchParams.set("author", name); diff --git a/src/client/index.ts b/src/client/index.ts index cbce8c3..25c443e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -65,6 +65,12 @@ export class R2 { ); return { key, url }; } + async syncMetadata(ctx: RunActionCtx, key: string) { + return await ctx.runAction(this.component.lib.syncMetadata, { + key, + ...this.r2Config, + }); + } async store(ctx: RunActionCtx, url: string) { return await ctx.runAction(this.component.lib.store, { url, @@ -264,9 +270,6 @@ export class R2 { type RunActionCtx = { runAction: GenericActionCtx["runAction"]; }; -type RunMutationCtx = { - runMutation: GenericMutationCtx["runMutation"]; -}; export type OpaqueIds = T extends GenericId diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index d778f8b..5c35975 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -28,6 +28,12 @@ declare const fullApi: ApiFromModules<{ }>; export type Mounts = { lib: { + deleteMetadata: FunctionReference< + "mutation", + "public", + { key: string }, + any + >; deleteObject: FunctionReference< "action", "public", @@ -75,6 +81,18 @@ export type Mounts = { }, any >; + insertMetadata: FunctionReference< + "mutation", + "public", + { + bucket: string; + contentType: string; + key: string; + sha256: string; + size: number; + }, + any + >; store: FunctionReference< "action", "public", @@ -87,6 +105,18 @@ export type Mounts = { }, any >; + syncMetadata: FunctionReference< + "action", + "public", + { + accessKeyId: string; + bucket: string; + endpoint: string; + key: string; + secretAccessKey: string; + }, + any + >; }; }; // For now fullApiWithMounts is only fullApi which provides diff --git a/src/component/lib.ts b/src/component/lib.ts index 22fe859..68015c5 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -8,6 +8,7 @@ import { import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { v } from "convex/values"; import { r2ConfigValidator, createR2Client } from "../shared"; +import { api } from "./_generated/api"; export const generateUploadUrl = mutation({ args: { @@ -27,12 +28,69 @@ export const generateUploadUrl = mutation({ }, }); +export const insertMetadata = mutation({ + args: { + key: v.string(), + contentType: v.string(), + size: v.number(), + sha256: v.string(), + bucket: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.insert("metadata", { + key: args.key, + contentType: args.contentType, + size: args.size, + sha256: args.sha256, + bucket: args.bucket, + }); + }, +}); + +export const syncMetadata = action({ + args: { + ...r2ConfigValidator.fields, + key: v.string(), + }, + handler: async (ctx, args) => { + const r2 = createR2Client(args); + const command = new HeadObjectCommand({ + Bucket: args.bucket, + Key: args.key, + }); + const response = await r2.send(command); + console.log("response", response); + await ctx.scheduler.runAfter(0, api.lib.insertMetadata, { + key: args.key, + contentType: response.ContentType ?? "", + size: response.ContentLength ?? 0, + sha256: response.ChecksumSHA256 ?? "", + bucket: args.bucket, + }); + }, +}); + +export const deleteMetadata = mutation({ + args: { + key: v.string(), + }, + handler: async (ctx, args) => { + const metadata = await ctx.db + .query("metadata") + .withIndex("key", (q) => q.eq("key", args.key)) + .unique(); + if (metadata) { + await ctx.db.delete(metadata._id); + } + }, +}); + export const store = action({ args: { ...r2ConfigValidator.fields, url: v.string(), }, - handler: async (ctx, args) => { + handler: async (_ctx, args) => { const r2 = createR2Client(args); const response = await fetch(args.url); const blob = await response.blob(); @@ -75,6 +133,9 @@ export const deleteObject = action({ await r2.send( new DeleteObjectCommand({ Bucket: args.bucket, Key: args.key }) ); + await ctx.scheduler.runAfter(0, api.lib.deleteMetadata, { + key: args.key, + }); }, }); @@ -83,7 +144,7 @@ export const getMetadata = action({ key: v.string(), ...r2ConfigValidator.fields, }, - handler: async (ctx, args) => { + handler: async (_ctx, args) => { const r2 = createR2Client(args); const command = new HeadObjectCommand({ Bucket: args.bucket, diff --git a/src/component/schema.ts b/src/component/schema.ts index af3b38a..f9e8f45 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -2,7 +2,7 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ - images: defineTable({ + metadata: defineTable({ key: v.string(), sha256: v.string(), contentType: v.string(), From 1aaf70816eb576c2c911729c184943e5e53e56b7 Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Fri, 10 Jan 2025 22:33:25 -0500 Subject: [PATCH 4/4] slim down api, focus on useUploadFile and reactive metadata --- README.md | 304 ++--------------------------- example/convex/_generated/api.d.ts | 52 +---- example/convex/example.ts | 29 +-- example/convex/http.ts | 13 -- example/src/App.tsx | 80 +------- package.json | 4 +- src/client/index.ts | 252 +++++------------------- src/component/_generated/api.d.ts | 50 +---- src/component/lib.ts | 86 +------- src/react/index.ts | 34 +++- 10 files changed, 115 insertions(+), 789 deletions(-) delete mode 100644 example/convex/http.ts diff --git a/README.md b/README.md index 4331680..a91d0a4 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,15 @@ npx convex env set R2_ENDPOINT xxxxx npx convex env set R2_BUCKET xxxxx ``` -Instantiate a R2 Component client in a file in your app's `convex/` folder: +## Uploading files +File uploads to R2 typically use signed urls. The R2 component provides a React +hook that handles the entire upload processs: +- generates the signed url +- uploads the file to R2 +- stores the file's metadata in your Convex database + +1. Instantiate a R2 component client in a file in your app's `convex/` folder: ```ts // convex/example.ts import { R2 } from "@convex-dev/r2"; @@ -73,56 +80,26 @@ import { components } from "./_generated/api"; export const r2 = new R2(components.r2); -// Example usage: create an action to generate an R2 upload URL -export const generateUploadUrl = action(() => { - return r2.generateUploadUrl(); -}); +export const { generateUploadUrl, syncMetadata } = r2.api(); ``` -## Uploading and Storing Files - -Upload files to R2 by generated upload URL or HTTP Action. - -### Uploading files via upload URLs -Arbitrarily large files can be uploaded directly to your backend using a generated upload URL. This requires the client to make 3 requests: - -1. Generate an upload URL and object key using an action that calls `r2.generateUploadUrl()`. -1. Send a POST request with the object key and file contents to the upload URL. -1. Save the object key into your data model via another mutation. - -In the first mutation that generates the upload URL you can control who can upload files to your R2 storage. - -#### Calling the upload APIs from a web page -Here's an example of uploading an image via a form submission handler to an upload URL generated by a mutation: +2. Use the `useUploadFile` hook in a React component to upload files: ```tsx // src/App.tsx import { FormEvent, useRef, useState } from "react"; import { useAction } from "convex/react"; import { api } from "../convex/_generated/api"; +import { useUploadFile } from "@convex-dev/r2/react"; export default function App() { - const generateUploadUrl = useAction(api.messages.generateUploadUrl); - const sendImage = useMutation(api.messages.sendImage); - + const uploadFile = useUploadFile(api.example); const imageInput = useRef(null); const [selectedImage, setSelectedImage] = useState(null); - const [name] = useState(() => "User " + Math.floor(Math.random() * 10000)); - async function handleSendImage(event: FormEvent) { + async function handleUpload(event: FormEvent) { event.preventDefault(); - - // Step 1: Get a short-lived upload URL - const { url, key } = await generateUploadUrl(); - // Step 2: POST the file to the URL - await fetch(url, { - method: "POST", - headers: { "Content-Type": selectedImage!.type }, - body: selectedImage, - }); - // Step 3: Save the newly allocated object key to the database - await sendImage({ key, author: name }); - + const key = await uploadFile(selectedImage!); setSelectedImage(null); imageInput.current!.value = ""; } @@ -137,7 +114,7 @@ export default function App() { /> @@ -145,212 +122,7 @@ export default function App() { } ``` -#### Generating the upload URL -An upload URL can be generated by the `generateUploadUrl` function of the R2 component client: - -```ts -// convex/messages.ts -TS -import { components } from "./_generated/api"; -import { mutation } from "./_generated/server"; -import { R2 } from "@convex-dev/r2"; - -const r2 = new R2(components.r2); - -export const generateUploadUrl = action((ctx) => { - return r2.generateUploadUrl(); -}); -``` - -This mutation can control who is allowed to upload files. - -#### Writing the new object key to the database -Since the object key is returned to the client it is likely you will want to persist it in the database via another mutation: - -```ts -// convex/messages.ts -import { components } from "./_generated/api"; -import { mutation } from "./_generated/server"; -import { R2 } from "@convex-dev/r2"; - -const r2 = new R2(components.r2); - -export const sendImage = mutation({ - args: { key: v.string(), author: v.string() }, - handler: async (ctx, args) => { - await ctx.db.insert("messages", { - body: args.key, - author: args.author, - format: "image", - }); - }, -}); -``` - -### Uploading files via an HTTP action -The file upload process can be more tightly controlled by leveraging HTTP actions, performing the whole upload flow using a single request. - -The custom upload HTTP action can control who can upload files to your Convex storage. But note that the HTTP action request size is currently limited to 20MB. For larger files you need to use upload URLs as described above. - -#### Calling the upload HTTP action from a web page -Here's an example of uploading an image via a form submission handler to the `sendImage` HTTP action provided by the R2 component: - -The highlighted lines make the actual request to the HTTP action: - -```tsx -// src/App.tsx -import { FormEvent, useRef, useState } from "react"; - -const convexSiteUrl = import.meta.env.VITE_CONVEX_SITE_URL; - -export default function App() { - const imageInput = useRef(null); - const [selectedImage, setSelectedImage] = useState(null); - - async function handleSendImage(event: FormEvent) { - event.preventDefault(); - - // e.g. https://happy-animal-123.convex.site/r2/sendImage?author=User+123 - const sendImageUrl = new URL(`${convexSiteUrl}/r2/sendImage`); - sendImageUrl.searchParams.set("author", "Jack Smith"); - - await fetch(sendImageUrl, { - method: "POST", - headers: { "Content-Type": selectedImage!.type }, - body: selectedImage, - }); - - setSelectedImage(null); - imageInput.current!.value = ""; - } - return ( -
- setSelectedImage(event.target.files![0])} - disabled={selectedImage !== null} - /> - -
- ); -} -``` - -#### Defining the upload HTTP action -The R2 component provides a `registerRoutes` method to enable http uploads. You -can optionally provide an `onSend` function reference to store information about -the image after upload. You can also create multiple routes for different -purposes based on your application's needs by calling `r2.registerRoutes` -multiple times with different `pathPrefix` values. - -```ts -// convex/http.ts -import { R2 } from "@convex-dev/r2"; -import { httpRouter } from "convex/server"; -import { components, internal } from "./_generated/api"; - -const http = httpRouter(); - -const r2 = new R2(components.r2); - -r2.registerRoutes(http, { - onSend: internal.messages.sendImage, - // Optional, default value is '/r2' - pathPrefix: '/r2' -}); - -export default http; -``` - -The `sendImage` mutation is called by the HTTP action with the object key and -request URL as arguments when the file is uploaded. It saves the object key to the -database: - -```ts -// convex/messages.ts -import { v } from "convex/values"; -import { internalMutation } from "./_generated/server"; -import { components, internal } from "./_generated/api"; -import { R2 } from "@convex-dev/r2"; -const r2 = new R2(components.r2); - -export const sendImage = internalMutation({ - args: { key: v.string(), requestUrl: v.string() }, - handler: async (ctx, args) => { - const author = new URL(args.requestUrl).searchParams.get("author"); - if (!author) { - throw new Error("Author is required"); - } - await ctx.db.insert("messages", { - body: args.key, - author, - format: "image", - }); - }, -}); -``` - -## Storing Generated Files -Files can be uploaded to R2 from a client and stored directly, see [Uploading and storing files](#uploading-and-storing-files). - -Alternatively, files can be stored after they've been fetched or generated in actions and HTTP actions. For example you might call a third-party API to generate an image based on a user prompt and then store that image in R2. - -### Storing files in actions -Storing files in actions is similar to uploading a file via an HTTP action. - -The action takes these steps: - -1. Fetch or generate an image. -1. Store the image by sending the image URL to the `r2.store` action and receive an object key. -1. Save the object key into your data model via a mutation. - -```ts -// convex/images.ts -import { action, internalMutation, query } from "./_generated/server"; -import { internal } from "./_generated/api"; -import { v } from "convex/values"; -import { Id } from "./_generated/dataModel"; -import { R2 } from "@convex-dev/r2"; - -const r2 = new R2(components.r2); - -export const generateAndStore = action({ - args: { prompt: v.string() }, - handler: async (ctx, args) => { - // Not shown: generate imageUrl from `prompt` - const imageUrl = "https://...."; - - // Store the image in R2 - const key = await r2.store(imageUrl); - - // Write `key` to a document - await ctx.runMutation(internal.images.storeResult, { - key, - prompt: args.prompt, - }); - }, -}); - -export const storeResult = internalMutation({ - args: { - key: v.string(), - prompt: v.string(), - }, - handler: async (ctx, args) => { - const { key, prompt } = args; - await ctx.db.insert("images", { key, prompt }); - }, -}); -``` - ## Serving Files - Files stored in R2 can be served to your users by generating a URL pointing to a given file. ### Generating file URLs in queries @@ -362,7 +134,7 @@ R2 component client. ```ts // convex/listMessages.ts import { components } from "./_generated/api"; -import { query, mutation } from "./_generated/server"; +import { query } from "./_generated/server"; import { R2 } from "@convex-dev/r2"; const r2 = new R2(components.r2); @@ -370,14 +142,12 @@ const r2 = new R2(components.r2); export const list = query({ args: {}, handler: async (ctx) => { + // In this example, messages have an imageKey field with the object key const messages = await ctx.db.query("messages").collect(); return Promise.all( messages.map(async (message) => ({ ...message, - // If the message is an "image" its `body` is an object key - ...(message.format === "image" - ? { url: await r2.getUrl(message.body) } - : {}), + imageUrl: await r2.getUrl(message.imageKey), })), ); }, @@ -393,44 +163,6 @@ function Image({ message }: { message: { url: string } }) { } ``` -### Serving files from HTTP actions -You can serve R2 files directly from HTTP actions. - -This enables access control at the time the file is served, such as when an image is displayed on a website. But note that the HTTP actions response size is currently limited to 20MB. For larger files you need to use file URLs as described above. - -A file Blob object can be returned from the `/r2/get/:key` HTTP action: - -```ts -// convex/http.ts -TS -import { R2 } from "@convex-dev/r2"; -import { httpRouter } from "convex/server"; -import { components, internal } from "./_generated/api"; - -const http = httpRouter(); - -const r2 = new R2(components.r2); - -r2.registerRoutes(http, { - onSend: internal.example.httpSendImage, -}); - -export default http; -``` - -The URL of the HTTP action can be used directly in img elements to render images: - -```tsx -// src/App.tsx -const convexSiteUrl = import.meta.env.VITE_CONVEX_SITE_URL; - -function Image({ key }: { key: string }) { - // e.g. https://happy-animal-123.convex.site/r2/get/456 - const getImageUrl = new URL(`${convexSiteUrl}/r2/get/${key}`); - return ; -} -``` - ## Deleting Files Files stored in R2 can be deleted from actions via the `r2.delete` function, which accepts an object key. diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 5b9daf3..77c4fe5 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -9,7 +9,6 @@ */ import type * as example from "../example.js"; -import type * as http from "../http.js"; import type { ApiFromModules, @@ -26,7 +25,6 @@ import type { */ declare const fullApi: ApiFromModules<{ example: typeof example; - http: typeof http; }>; declare const fullApiWithMounts: typeof fullApi; @@ -60,41 +58,7 @@ export declare const components: { }, any >; - generateUploadUrl: FunctionReference< - "mutation", - "internal", - { - accessKeyId: string; - bucket: string; - endpoint: string; - secretAccessKey: string; - }, - any - >; - getMetadata: FunctionReference< - "action", - "internal", - { - accessKeyId: string; - bucket: string; - endpoint: string; - key: string; - secretAccessKey: string; - }, - any - >; - getUrl: FunctionReference< - "query", - "internal", - { - accessKeyId: string; - bucket: string; - endpoint: string; - key: string; - secretAccessKey: string; - }, - any - >; + getMetadata: FunctionReference<"query", "internal", { key: string }, any>; insertMetadata: FunctionReference< "mutation", "internal", @@ -107,18 +71,6 @@ export declare const components: { }, any >; - store: FunctionReference< - "action", - "internal", - { - accessKeyId: string; - bucket: string; - endpoint: string; - secretAccessKey: string; - url: string; - }, - any - >; syncMetadata: FunctionReference< "action", "internal", @@ -129,7 +81,7 @@ export declare const components: { key: string; secretAccessKey: string; }, - any + null >; }; }; diff --git a/example/convex/example.ts b/example/convex/example.ts index 4f705d7..0a67337 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -10,20 +10,7 @@ import { components, internal } from "./_generated/api"; import { R2 } from "@convex-dev/r2"; const r2 = new R2(components.r2); -export const listConvexFiles = r2.listConvexFiles(); -export const uploadFile = r2.uploadFile(); -export const deleteFile = r2.deleteFile(); - -export const generateUploadUrl = action(() => { - return r2.generateUploadUrl(); -}); - -export const syncMetadata = action({ - args: { key: v.string() }, - handler: async (ctx, args) => { - await r2.syncMetadata(ctx, args.key); - }, -}); +export const { generateUploadUrl, syncMetadata } = r2.api(); export const getRecentImages = query({ args: {}, @@ -45,20 +32,6 @@ export const sendImage = mutation({ }, }); -export const httpSendImage = internalMutation({ - args: { key: v.string(), requestUrl: v.string() }, - handler: async (ctx, args) => { - const author = new URL(args.requestUrl).searchParams.get("author"); - if (!author) { - throw new Error("Author is required"); - } - await ctx.db.insert("images", { - key: args.key, - author, - }); - }, -}); - export const deleteImageRef = internalMutation({ args: { key: v.string() }, handler: async (ctx, args) => { diff --git a/example/convex/http.ts b/example/convex/http.ts deleted file mode 100644 index e7db751..0000000 --- a/example/convex/http.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { R2 } from "@convex-dev/r2"; -import { httpRouter } from "convex/server"; -import { components, internal } from "./_generated/api"; - -const http = httpRouter(); - -const r2 = new R2(components.r2); - -r2.registerRoutes(http, { - onSend: internal.example.httpSendImage, -}); - -export default http; diff --git a/example/src/App.tsx b/example/src/App.tsx index 0768c4b..a7e3e10 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -2,20 +2,12 @@ import "./App.css"; import { useAction, useMutation, useQuery } from "convex/react"; import { FormEvent, useRef, useState } from "react"; import { api } from "../convex/_generated/api"; - -// Set to true to use HTTP Action instead of signed URL -const GET_VIA_HTTP = false; -const SEND_VIA_HTTP = false; -const convexSiteUrl = (import.meta.env.VITE_CONVEX_URL as string).replace( - ".cloud", - ".site" -); +import { useUploadFile } from "@convex-dev/r2/react"; export function App() { - const generateUploadUrl = useAction(api.example.generateUploadUrl); + const uploadFile = useUploadFile(api.example); const sendImage = useMutation(api.example.sendImage); const deleteImage = useAction(api.example.deleteImage); - const syncMetadata = useAction(api.example.syncMetadata); const images = useQuery(api.example.getRecentImages); const imageInput = useRef(null); const [sending, setSending] = useState(false); @@ -23,73 +15,21 @@ export function App() { const [name] = useState(() => "User " + Math.floor(Math.random() * 10000)); - async function handleSendImageViaSignedUrl(event: FormEvent) { + async function handleSendImage(event: FormEvent) { event.preventDefault(); setSending(true); - // Step 1: Get a short-lived upload URL - const { url, key } = await generateUploadUrl(); - // Step 2: PUT the file to the URL - try { - const result = await fetch(url, { - method: "PUT", - headers: { "Content-Type": selectedImage!.type }, - body: selectedImage, - }); - if (!result.ok) { - setSending(false); - throw new Error(`Failed to upload image: ${result.statusText}`); - } - } catch (error) { - setSending(false); - throw new Error(`Failed to upload image: ${error}`); - } - // Step 3: Save the newly allocated storage id to the database - await syncMetadata({ key }); + const key = await uploadFile(selectedImage!); await sendImage({ key, author: name }); setSending(false); setSelectedImage(null); imageInput.current!.value = ""; } - async function handleSendImageViaHttp(event: FormEvent) { - event.preventDefault(); - setSending(true); - - const sendImageUrl = new URL( - // Use Convex Action URL - `${convexSiteUrl}/r2/send` - ); - sendImageUrl.searchParams.set("author", name); - - try { - const result = await fetch(sendImageUrl, { - method: "POST", - headers: { "Content-Type": selectedImage!.type }, - body: selectedImage, - }); - if (!result.ok) { - setSending(false); - throw new Error(`Failed to upload image: ${result.statusText}`); - } - } catch (error) { - setSending(false); - throw new Error(`Failed to upload image: ${error}`); - } - - setSending(false); - setSelectedImage(null); - imageInput.current!.value = ""; - } - return ( <>

Convex R2 Component Example

-
+ (

{image.author}

- {image.author} + {image.author} diff --git a/package.json b/package.json index 9e39ef8..3871366 100644 --- a/package.json +++ b/package.json @@ -67,11 +67,13 @@ } }, "peerDependencies": { - "convex": "~1.16.5 || ~1.17.0" + "convex": "~1.16.5 || ~1.17.0", + "react": "^18.3.1" }, "devDependencies": { "@eslint/js": "^9.9.1", "@types/node": "^18.17.0", + "@types/react": "^18.3.3", "convex-test": "^0.0.33", "eslint": "^9.9.1", "globals": "^15.9.0", diff --git a/src/client/index.ts b/src/client/index.ts index 25c443e..78c485d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,42 +1,31 @@ import { - ActionBuilder, + actionGeneric, + ApiFromModules, Expand, - FunctionHandle, FunctionReference, GenericActionCtx, GenericDataModel, - GenericMutationCtx, - httpActionGeneric, - HttpRouter, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, - MutationBuilder, - QueryBuilder, - RegisteredAction, - RegisteredMutation, - RegisteredQuery, + GenericQueryCtx, + mutationGeneric, } from "convex/server"; import { GenericId, Infer, v } from "convex/values"; -import { corsRouter } from "convex-helpers/server/cors"; -import { api } from "../component/_generated/api"; -import { - GetObjectCommand, - HeadObjectCommand, - PutObjectCommand, -} from "@aws-sdk/client-s3"; +import { Mounts } from "../component/_generated/api"; +import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { createR2Client, r2ConfigValidator } from "../shared"; -import { DataModel, Id } from "../component/_generated/dataModel"; export const DEFAULT_BATCH_SIZE = 10; +export type Api = ApiFromModules<{ + api: ReturnType; +}>["api"]; + export class R2 { public readonly r2Config: Infer; public readonly r2: S3Client; constructor( - public component: UseApi, + public component: UseApi, public options: { R2_BUCKET?: string; R2_ENDPOINT?: string; @@ -54,29 +43,12 @@ export class R2 { }; this.r2 = createR2Client(this.r2Config); } - async generateUploadUrl() { - const key = crypto.randomUUID(); - const url = await getSignedUrl( - this.r2, - new PutObjectCommand({ - Bucket: this.r2Config.bucket, - Key: key, - }) - ); - return { key, url }; - } async syncMetadata(ctx: RunActionCtx, key: string) { return await ctx.runAction(this.component.lib.syncMetadata, { key, ...this.r2Config, }); } - async store(ctx: RunActionCtx, url: string) { - return await ctx.runAction(this.component.lib.store, { - url, - ...this.r2Config, - }); - } async getUrl(key: string) { return await getSignedUrl( this.r2, @@ -92,177 +64,44 @@ export class R2 { ...this.r2Config, }); } - async getMetadata(ctx: RunActionCtx, key: string) { - return await ctx.runAction(this.component.lib.getMetadata, { + async getMetadata(ctx: RunQueryCtx, key: string) { + return await ctx.runQuery(this.component.lib.getMetadata, { key, - ...this.r2Config, }); } - listConvexFiles({ - batchSize: functionDefaultBatchSize, - }: { - batchSize?: number; - } = {}) { - const defaultBatchSize = - functionDefaultBatchSize ?? - this.options?.defaultBatchSize ?? - DEFAULT_BATCH_SIZE; - return (internalQueryGeneric as QueryBuilder)({ - args: { - batchSize: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const numItems = args.batchSize || defaultBatchSize; - if (args.batchSize === 0) { - console.warn(`Batch size is zero. Using the default: ${numItems}\n`); - } - return await ctx.db.system.query("_storage").take(numItems); - }, - }) satisfies RegisteredQuery< - "internal", - { batchSize: number }, - Promise< - { - _id: Id<"_storage">; - _creationTime: number; - contentType?: string | undefined; - sha256: string; - size: number; - }[] - > - >; - } - uploadFile() { - return (internalActionGeneric as ActionBuilder)({ - args: { - files: v.array( - v.object({ - _id: v.id("_storage"), - _creationTime: v.number(), - contentType: v.optional(v.string()), - sha256: v.string(), - size: v.number(), - }) - ), - deleteFn: v.string(), - }, - handler: async (ctx, args) => { - const deleteFn = args.deleteFn as FunctionHandle< - "mutation", - { fileId: Id<"_storage"> }, - void - >; - await Promise.all( - args.files.map(async (file) => { - const blob = await ctx.storage.get(file._id); - if (!blob) { - return; - } - await this.r2.send( - new PutObjectCommand({ - Bucket: this.r2Config.bucket, - Key: file._id, - Body: blob, - ContentType: file.contentType ?? undefined, - ChecksumSHA256: file.sha256, - }) - ); - const metadata = await this.r2.send( - new HeadObjectCommand({ - Bucket: this.r2Config.bucket, - Key: file._id, - }) - ); - if (metadata.ChecksumSHA256 !== file.sha256) { - throw new Error("Checksum mismatch"); - } - await ctx.runMutation(deleteFn, { fileId: file._id }); - }) - ); - }, - }) satisfies RegisteredAction< - "internal", - { - files: { - contentType?: string | undefined; - sha256: string; - size: number; - _creationTime: number; - _id: Id<"_storage">; - }[]; - deleteFn: string; - }, - Promise - >; - } - deleteFile() { - return (internalMutationGeneric as MutationBuilder)({ - args: { - fileId: v.id("_storage"), - }, - handler: async (ctx, args) => { - await ctx.storage.delete(args.fileId); - }, - }) satisfies RegisteredMutation< - "internal", - { fileId: Id<"_storage"> }, - Promise - >; - } - registerRoutes( - http: HttpRouter, - { - pathPrefix = "/r2", - onSend, - }: { - onSend?: FunctionReference< - "mutation", - "internal", - { key: string; requestUrl: string } - >; - pathPrefix?: string; - } = {} - ) { - const cors = corsRouter(http); - cors.route({ - pathPrefix: `${pathPrefix}/get/`, - method: "GET", - handler: httpActionGeneric(async (_ctx, request) => { - const { pathname } = new URL(request.url); - const key = pathname.split("/").pop()!; - const command = new GetObjectCommand({ - Bucket: this.r2Config.bucket, - Key: key, - }); - const response = await this.r2.send(command); - - if (!response.Body) { - return new Response("Image not found", { - status: 404, - }); - } - return new Response(await response.Body.transformToByteArray()); + api() { + return { + generateUploadUrl: mutationGeneric({ + args: {}, + returns: v.object({ + key: v.string(), + url: v.string(), + }), + handler: async () => { + const key = crypto.randomUUID(); + const url = await getSignedUrl( + this.r2, + new PutObjectCommand({ + Bucket: this.r2Config.bucket, + Key: key, + }) + ); + return { key, url }; + }, }), - }); - cors.route({ - path: `${pathPrefix}/send`, - method: "POST", - handler: httpActionGeneric(async (ctx, request) => { - const blob = await request.blob(); - const key = crypto.randomUUID(); - const command = new PutObjectCommand({ - Bucket: this.r2Config.bucket, - Key: key, - Body: blob, - ContentType: request.headers.get("Content-Type") ?? undefined, - }); - await this.r2.send(command); - if (onSend) { - await ctx.runMutation(onSend, { key, requestUrl: request.url }); - } - return new Response(null); + syncMetadata: actionGeneric({ + args: { + key: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.runAction(this.component.lib.syncMetadata, { + key: args.key, + ...this.r2Config, + }); + }, }), - }); + }; } } @@ -270,6 +109,9 @@ export class R2 { type RunActionCtx = { runAction: GenericActionCtx["runAction"]; }; +type RunQueryCtx = { + runQuery: GenericQueryCtx["runQuery"]; +}; export type OpaqueIds = T extends GenericId diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index 5c35975..fd94a7e 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -46,41 +46,7 @@ export type Mounts = { }, any >; - generateUploadUrl: FunctionReference< - "mutation", - "public", - { - accessKeyId: string; - bucket: string; - endpoint: string; - secretAccessKey: string; - }, - any - >; - getMetadata: FunctionReference< - "action", - "public", - { - accessKeyId: string; - bucket: string; - endpoint: string; - key: string; - secretAccessKey: string; - }, - any - >; - getUrl: FunctionReference< - "query", - "public", - { - accessKeyId: string; - bucket: string; - endpoint: string; - key: string; - secretAccessKey: string; - }, - any - >; + getMetadata: FunctionReference<"query", "public", { key: string }, any>; insertMetadata: FunctionReference< "mutation", "public", @@ -93,18 +59,6 @@ export type Mounts = { }, any >; - store: FunctionReference< - "action", - "public", - { - accessKeyId: string; - bucket: string; - endpoint: string; - secretAccessKey: string; - url: string; - }, - any - >; syncMetadata: FunctionReference< "action", "public", @@ -115,7 +69,7 @@ export type Mounts = { key: string; secretAccessKey: string; }, - any + null >; }; }; diff --git a/src/component/lib.ts b/src/component/lib.ts index 68015c5..ca87173 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -1,33 +1,9 @@ import { action, mutation, query } from "./_generated/server"; -import { - DeleteObjectCommand, - GetObjectCommand, - HeadObjectCommand, - PutObjectCommand, -} from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { DeleteObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"; import { v } from "convex/values"; import { r2ConfigValidator, createR2Client } from "../shared"; import { api } from "./_generated/api"; -export const generateUploadUrl = mutation({ - args: { - ...r2ConfigValidator.fields, - }, - handler: async (_ctx, args) => { - const r2 = createR2Client(args); - const key = crypto.randomUUID(); - const url = await getSignedUrl( - r2, - new PutObjectCommand({ - Bucket: args.bucket, - Key: key, - }) - ); - return { key, url }; - }, -}); - export const insertMetadata = mutation({ args: { key: v.string(), @@ -52,6 +28,7 @@ export const syncMetadata = action({ ...r2ConfigValidator.fields, key: v.string(), }, + returns: v.null(), handler: async (ctx, args) => { const r2 = createR2Client(args); const command = new HeadObjectCommand({ @@ -59,7 +36,6 @@ export const syncMetadata = action({ Key: args.key, }); const response = await r2.send(command); - console.log("response", response); await ctx.scheduler.runAfter(0, api.lib.insertMetadata, { key: args.key, contentType: response.ContentType ?? "", @@ -85,44 +61,6 @@ export const deleteMetadata = mutation({ }, }); -export const store = action({ - args: { - ...r2ConfigValidator.fields, - url: v.string(), - }, - handler: async (_ctx, args) => { - const r2 = createR2Client(args); - const response = await fetch(args.url); - const blob = await response.blob(); - const key = crypto.randomUUID(); - const command = new PutObjectCommand({ - Bucket: args.bucket, - Key: key, - Body: blob, - ContentType: response.headers.get("Content-Type") ?? undefined, - }); - await r2.send(command); - return key; - }, -}); - -export const getUrl = query({ - args: { - key: v.string(), - ...r2ConfigValidator.fields, - }, - handler: async (_ctx, args) => { - const r2 = createR2Client(args); - return await getSignedUrl( - r2, - new GetObjectCommand({ - Bucket: args.bucket, - Key: args.key, - }) - ); - }, -}); - export const deleteObject = action({ args: { key: v.string(), @@ -139,22 +77,14 @@ export const deleteObject = action({ }, }); -export const getMetadata = action({ +export const getMetadata = query({ args: { key: v.string(), - ...r2ConfigValidator.fields, }, - handler: async (_ctx, args) => { - const r2 = createR2Client(args); - const command = new HeadObjectCommand({ - Bucket: args.bucket, - Key: args.key, - }); - const response = await r2.send(command); - return { - ContentType: response.ContentType, - ContentLength: response.ContentLength, - LastModified: response.LastModified?.toISOString(), - }; + handler: async (ctx, args) => { + return await ctx.db + .query("metadata") + .withIndex("key", (q) => q.eq("key", args.key)) + .unique(); }, }); diff --git a/src/react/index.ts b/src/react/index.ts index bf4dec2..88cde9b 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,8 +1,30 @@ -// This is where React components go. -if (typeof window === "undefined") { - throw new Error("this is frontend code, but it's running somewhere else!"); -} +import { useAction, useMutation } from "convex/react"; +import { useCallback } from "react"; +import { Api } from "../client"; + +export function useUploadFile(api: Api) { + const generateUploadUrl = useMutation(api.generateUploadUrl); + const syncMetadata = useAction(api.syncMetadata); -export function subtract(a: number, b: number): number { - return a - b; + const upload = useCallback( + async (file: File) => { + const { url, key } = await generateUploadUrl(); + try { + const result = await fetch(url, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: file, + }); + if (!result.ok) { + throw new Error(`Failed to upload image: ${result.statusText}`); + } + } catch (error) { + throw new Error(`Failed to upload image: ${error}`); + } + await syncMetadata({ key }); + return key; + }, + [generateUploadUrl, syncMetadata] + ); + return upload; }