diff --git a/README.md b/README.md index d6272b76..c8c82d1e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ find the npm package in [./packages/convex-helpers](./packages/convex-helpers). | [Filter db queries with JS](./packages/convex-helpers/README.md#filter) | [Manual pagination](./packages/convex-helpers/README.md#manual-pagination) | [Query caching with ConvexQueryCacheProvider](./packages/convex-helpers/README.md#query-caching) +| [HttpRouter with automatic CORS support](./packages/convex-helpers/README.md#cors) | In this directory for copy-pasting: | ----------------------------------- diff --git a/convex/README.md b/convex/README.md new file mode 100644 index 00000000..4d82e136 --- /dev/null +++ b/convex/README.md @@ -0,0 +1,90 @@ +# Welcome to your Convex functions directory! + +Write your Convex functions here. +See https://docs.convex.dev/functions for more. + +A query function that takes two arguments looks like: + +```ts +// functions.js +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const myQueryFunction = query({ + // Validators for arguments. + args: { + first: v.number(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Read the database as many times as you need here. + // See https://docs.convex.dev/database/reading-data. + const documents = await ctx.db.query("tablename").collect(); + + // Arguments passed from the client are properties of the args object. + console.log(args.first, args.second); + + // Write arbitrary JavaScript here: filter, aggregate, build derived data, + // remove non-public properties, or create new objects. + return documents; + }, +}); +``` + +Using this query function in a React component looks like: + +```ts +const data = useQuery(api.functions.myQueryFunction, { + first: 10, + second: "hello", +}); +``` + +A mutation function looks like: + +```ts +// functions.js +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export const myMutationFunction = mutation({ + // Validators for arguments. + args: { + first: v.string(), + second: v.string(), + }, + + // Function implementation. + handler: async (ctx, args) => { + // Insert or modify documents in the database here. + // Mutations can also read from the database like queries. + // See https://docs.convex.dev/database/writing-data. + const message = { body: args.first, author: args.second }; + const id = await ctx.db.insert("messages", message); + + // Optionally, return a value from your mutation. + return await ctx.db.get(id); + }, +}); +``` + +Using this mutation function in a React component looks like: + +```ts +const mutation = useMutation(api.functions.myMutationFunction); +function handleButtonPress() { + // fire and forget, the most common way to use mutations + mutation({ first: "Hello!", second: "me" }); + // OR + // use the result once the mutation has completed + mutation({ first: "Hello!", second: "me" }).then((result) => + console.log(result), + ); +} +``` + +Use the Convex CLI to push your functions to a deployment. See everything +the Convex CLI can do by running `npx convex -h` in your project root +directory. To learn more, launch the docs with `npx convex docs`. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 8c15772a..a59d2b30 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -4,7 +4,7 @@ * * THIS CODE IS AUTOMATICALLY GENERATED. * - * Generated by convex@1.13.0. + * Generated by convex@1.13.2. * To regenerate, run `npx convex dev`. * @module */ diff --git a/convex/_generated/api.js b/convex/_generated/api.js index 64288beb..cc851235 100644 --- a/convex/_generated/api.js +++ b/convex/_generated/api.js @@ -4,7 +4,7 @@ * * THIS CODE IS AUTOMATICALLY GENERATED. * - * Generated by convex@1.13.0. + * Generated by convex@1.13.2. * To regenerate, run `npx convex dev`. * @module */ diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts index 223e4c44..3f4afe7a 100644 --- a/convex/_generated/dataModel.d.ts +++ b/convex/_generated/dataModel.d.ts @@ -4,7 +4,7 @@ * * THIS CODE IS AUTOMATICALLY GENERATED. * - * Generated by convex@1.13.0. + * Generated by convex@1.13.2. * To regenerate, run `npx convex dev`. * @module */ diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts index 71918ac6..4c207bc1 100644 --- a/convex/_generated/server.d.ts +++ b/convex/_generated/server.d.ts @@ -4,7 +4,7 @@ * * THIS CODE IS AUTOMATICALLY GENERATED. * - * Generated by convex@1.13.0. + * Generated by convex@1.13.2. * To regenerate, run `npx convex dev`. * @module */ diff --git a/convex/_generated/server.js b/convex/_generated/server.js index 2095ed98..87aa13f5 100644 --- a/convex/_generated/server.js +++ b/convex/_generated/server.js @@ -4,7 +4,7 @@ * * THIS CODE IS AUTOMATICALLY GENERATED. * - * Generated by convex@1.13.0. + * Generated by convex@1.13.2. * To regenerate, run `npx convex dev`. * @module */ diff --git a/convex/corsHttpRouterExample.test.ts b/convex/corsHttpRouterExample.test.ts new file mode 100644 index 00000000..4526283a --- /dev/null +++ b/convex/corsHttpRouterExample.test.ts @@ -0,0 +1,143 @@ +import { convexTest } from "convex-test"; +import { expect, test, describe, beforeAll, afterAll } from "vitest"; + +beforeAll(() => { + //setup +}); + +afterAll(() => { + //teardown +}); + +describe("HTTP routes", () => { + const expectedHeaders = ({ method }: { method: string }) => { + return { + "access-control-allow-headers": "Content-Type", + "access-control-allow-methods": `${method}`, + "access-control-allow-origin": "*", + "access-control-max-age": "86400", + "content-type": "application/json", + }; + }; + + const verifyHeaders = (method: string, headers: Headers) => { + expect(headers.get("access-control-allow-headers")).toBe( + expectedHeaders({ method })["access-control-allow-headers"], + ); + expect(headers.get("access-control-allow-methods")).toBe( + expectedHeaders({ method })["access-control-allow-methods"], + ); + expect(headers.get("access-control-allow-origin")).toBe( + expectedHeaders({ method })["access-control-allow-origin"], + ); + expect(headers.get("access-control-max-age")).toBe( + expectedHeaders({ method })["access-control-max-age"], + ); + expect(headers.get("content-type")).toBe( + expectedHeaders({ method })["content-type"], + ); + }; + + test("GET /fact", async () => { + const t = convexTest(); + const response = await t.fetch("/fact", { method: "GET" }); + expect(response.status).toBe(200); + verifyHeaders("GET", response.headers); + const body = await response.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0]).toHaveProperty("fact"); + expect(typeof body[0].fact).toBe("string"); + expect(body[0].fact).toBe("Hello, world!"); + }); + + test("POST /fact", async () => { + const t = convexTest(); + const response = await t.fetch("/fact", { + method: "POST", + }); + verifyHeaders("POST", response.headers); + const body = await response.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0]).toHaveProperty("fact"); + expect(typeof body[0].fact).toBe("string"); + expect(body[0].fact).toBe("Hello, world!"); + }); + + test("GET /dynamicFact/123", async () => { + const t = convexTest(); + const response = await t.fetch("/dynamicFact/123", { method: "GET" }); + expect(response.status).toBe(200); + verifyHeaders("GET", response.headers); + const body = await response.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0]).toHaveProperty("fact"); + expect(typeof body[0].fact).toBe("string"); + expect(body[0].fact).toBe("Hello, world!"); + }); + + test("PATCH /dynamicFact/123", async () => { + const t = convexTest(); + const response = await t.fetch("/dynamicFact/123", { method: "PATCH" }); + expect(response.status).toBe(200); + verifyHeaders("PATCH", response.headers); + const body = await response.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0]).toHaveProperty("fact"); + expect(typeof body[0].fact).toBe("string"); + expect(body[0].fact).toBe("Hello, world!"); + }); + + test("OPTIONS /fact (CORS preflight)", async () => { + const t = convexTest(); + const response = await t.fetch("/fact", { method: "OPTIONS" }); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain( + "GET", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain( + "POST", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain( + "PATCH", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain( + "DELETE", + ); + }); + + test("Route with custom allowedOrigins", async () => { + const t = convexTest(); + const response = await t.fetch("/specialRouteOnlyForThisOrigin", { + method: "GET", + }); + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:3000", + ); + const body = await response.json(); + expect(body).toEqual({ message: "Custom allowed origins! Wow!" }); + }); + + test("OPTIONS for route with custom allowedOrigins", async () => { + const t = convexTest(); + const response = await t.fetch("/specialRouteOnlyForThisOrigin", { + method: "OPTIONS", + }); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:3000", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET"); + }); + + test("Non-existent route", async () => { + const t = convexTest(); + const response = await t.fetch("/nonexistent", { method: "GET" }); + expect(response.status).toBe(404); + }); +}); diff --git a/convex/corsHttpRouterExample.ts b/convex/corsHttpRouterExample.ts new file mode 100644 index 00000000..20645f8b --- /dev/null +++ b/convex/corsHttpRouterExample.ts @@ -0,0 +1,109 @@ +import { corsHttpRouter } from "../packages/convex-helpers/server/corsHttpRouter"; +import { httpAction } from "./_generated/server"; + +const everythingHandler = httpAction(async () => { + return new Response(JSON.stringify([{ fact: "Hello, world!" }])); +}); + +const http = corsHttpRouter({ + allowedOrigins: ["*"], +}); + +/** + * Exact routes will match /fact exactly + */ +http.corsRoute({ + path: "/fact", + method: "GET", + handler: everythingHandler, +}); + +http.corsRoute({ + path: "/fact", + method: "POST", + handler: everythingHandler, +}); + +http.corsRoute({ + path: "/fact", + method: "PATCH", + handler: everythingHandler, +}); + +http.corsRoute({ + path: "/fact", + method: "DELETE", + handler: everythingHandler, +}); + +/** + * Non-CORS routes + */ +http.route({ + path: "/nocors/fact", + method: "GET", + handler: everythingHandler, +}); + +http.route({ + path: "/nocors/fact", + method: "POST", + handler: everythingHandler, +}); + +/** + * Prefix routes will match /dynamicFact/123 and /dynamicFact/456 etc. + */ +http.corsRoute({ + pathPrefix: "/dynamicFact/", + method: "GET", + handler: everythingHandler, +}); + +http.corsRoute({ + pathPrefix: "/dynamicFact/", + method: "PATCH", + handler: everythingHandler, +}); + +/** + * Per-path "allowedOrigins" will override the default "allowedOrigins" for that route + */ +http.corsRoute({ + path: "/specialRouteOnlyForThisOrigin", + method: "GET", + handler: httpAction(async () => { + return new Response( + JSON.stringify({ message: "Custom allowed origins! Wow!" }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }), + allowedOrigins: ["http://localhost:3000"], +}); + +/** + * Disable CORS for this route + */ +http.route({ + path: "/routeWithoutCors", + method: "GET", + handler: httpAction(async () => { + return new Response( + JSON.stringify({ message: "No CORS allowed here, pal." }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }), +}); + +// Convex expects the router to be the default export of `convex/http.js`. +export default http; diff --git a/packages/convex-helpers/README.md b/packages/convex-helpers/README.md index efd9330d..838388fd 100644 --- a/packages/convex-helpers/README.md +++ b/packages/convex-helpers/README.md @@ -19,6 +19,7 @@ Table of contents: - [Filter db queries with JS](#filter) - [Manual pagination](#manual-pagination) - [Query caching with ConvexQueryCacheProvider](#query-caching) +- [HttpRouter with automatic CORS support](#httprouter-with-automatic-cors-support) ## Custom Functions @@ -772,3 +773,138 @@ import { useQuery } from "convex-helpers/react/cache/hooks"; const users = useQuery(api.users.getAll); ``` + +## HttpRouter with automatic CORS support + +### Overview + +This project demonstrates how to enhance your Convex HTTP endpoints with CORS (Cross-Origin Resource Sharing) support using a custom `corsHttpRouter`. This router add functionality to Convex's standard `httpRouter`, making it easy to add CORS headers to your HTTP routes. + +### Features + +- **CORS Support**: Automatically adds CORS headers to your HTTP responses. +- **Preflight Requests**: Handles OPTIONS requests for CORS preflight checks. +- **Flexible Configuration**: Specify allowed origins and methods for your routes. + +### How It Works + +The `corsHttpRouter` extends Convex's `httpRouter` to include CORS functionality. It add a new method `corsRoute` which adds CORS headers to all non-OPTIONS requests and automatically adds an OPTIONS route to handle CORS preflight requests. + +Here's a snippet from our `http.ts` file demonstrating how to use the `corsHttpRouter`: + +```typescript +import { getFact } from "./myHttpApi"; +import { corsHttpRouter } from "./helpers/corsHttpRouter"; + +// Your standard Convex http router: +// const router = httpRouter(); + +// Your CORS router: +const router = corsHttpRouter({ + allowedOrigins: ["http://localhost:3000"], // or '*' to allow all +}); + +/** + * CORS routes + */ +http.corsRoute({ + path: "/fact", + method: "GET", + handler: getFact, +}); + +http.corsRoute({ + path: "/fact", + method: "POST", + handler: getFact, +}); + +/** + * Non-CORS routes + */ +http.route({ + path: "/nocors/fact", + method: "GET", + handler: getFact, +}); + +http.route({ + path: "/nocors/fact", + method: "POST", + handler: getFact, +}); +``` + +You can provide optional allowedOrigins per route: + +```typescript +/** + * Per-path "allowedOrigins" will override the default "allowedOrigins" for that route + */ +http.corsRoute({ + path: "/specialRouteOnlyForThisOrigin", + method: "GET", + handler: httpAction(async () => { + return new Response( + JSON.stringify({ message: "Custom allowed origins! Wow!" }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ); + }), + allowedOrigins: ["http://localhost:3000"], +}); +``` + +### Getting Started + +#### Installation + +To get started with this project, clone the repository and install the dependencies: + +```bash +npm install +``` + +#### Running the Project + +To run the project in development mode, use the following command: + +```bash +npm run dev +``` + +#### Running Tests + +To run the tests, use the following command: + +```bash +npm run test +``` + +### Example Routes + +#### Exact Routes + +These routes match the exact path specified: + +- **GET /fact**: Fetch a random fact. +- **POST /fact**: Add a new fact. +- **PATCH /fact**: Update an existing fact. +- **DELETE /fact**: Delete a fact. + +#### Prefix Routes + +These routes match any path that starts with the specified prefix: + +- **GET /dynamicFact/**: Fetch a dynamic fact. +- **PATCH /dynamicFact/**: Update a dynamic fact. + +### Conclusion + +This project simplifies the process of adding CORS support to your Convex HTTP endpoints. With `corsHttpRouter`, you can ensure your endpoints are accessible to web applications hosted on different domains while maintaining proper CORS configuration. + +Happy coding! diff --git a/packages/convex-helpers/package.json b/packages/convex-helpers/package.json index e1ca7799..4d590210 100644 --- a/packages/convex-helpers/package.json +++ b/packages/convex-helpers/package.json @@ -32,6 +32,10 @@ "types": "./react/cache/provider.d.ts", "default": "./react/cache/provider.js" }, + "./server/corsHttpRouter": { + "types": "./server/corsHttpRouter.d.ts", + "default": "./server/corsHttpRouter.js" + }, "./server/customFunctions": { "types": "./server/customFunctions.d.ts", "default": "./server/customFunctions.js" diff --git a/packages/convex-helpers/server/corsHttpRouter.test.ts b/packages/convex-helpers/server/corsHttpRouter.test.ts new file mode 100644 index 00000000..3d337e75 --- /dev/null +++ b/packages/convex-helpers/server/corsHttpRouter.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect, vi } from "vitest"; +import { corsHttpRouter } from "./corsHttpRouter"; +import { httpActionGeneric } from "convex/server"; + +describe("CorsHttpRouter", () => { + test("creates a router with allowed origins", () => { + const router = corsHttpRouter({ allowedOrigins: ["https://example.com"] }); + expect(router).toBeDefined(); + expect(router.allowedOrigins).toEqual(["https://example.com"]); + }); + + test("configures exact routes correctly", () => { + const router = corsHttpRouter({ allowedOrigins: ["https://example.com"] }); + const handler = vi.fn(); + + router.corsRoute({ + path: "/test", + method: "GET", + handler: httpActionGeneric(handler), + }); + + const routeMap = router.exactRoutes.get("/test"); + expect(routeMap).toBeDefined(); + expect(routeMap?.has("GET")).toBe(true); + expect(routeMap?.has("OPTIONS")).toBe(true); + }); + + test("configures prefix routes correctly", () => { + const router = corsHttpRouter({ allowedOrigins: ["https://example.com"] }); + const handler = vi.fn(); + + router.corsRoute({ + pathPrefix: "/test/", + method: "POST", + handler: httpActionGeneric(handler), + }); + + const postRoutes = router.prefixRoutes.get("POST"); + expect(postRoutes).toBeDefined(); + expect(postRoutes?.has("/test/")).toBe(true); + + const optionsRoutes = router.prefixRoutes.get("OPTIONS"); + expect(optionsRoutes).toBeDefined(); + expect(optionsRoutes?.has("/test/")).toBe(true); + }); + + test("handles multiple methods for the same path", () => { + const router = corsHttpRouter({ allowedOrigins: ["https://example.com"] }); + const handlerGet = vi.fn(); + const handlerPost = vi.fn(); + + router.corsRoute({ + path: "/test", + method: "GET", + handler: httpActionGeneric(handlerGet), + }); + + router.corsRoute({ + path: "/test", + method: "POST", + handler: httpActionGeneric(handlerPost), + }); + + const routeMap = router.exactRoutes.get("/test"); + expect(routeMap).toBeDefined(); + expect(routeMap?.has("GET")).toBe(true); + expect(routeMap?.has("POST")).toBe(true); + expect(routeMap?.has("OPTIONS")).toBe(true); + }); + + test("adds CORS headers to handlers", () => { + const router = corsHttpRouter({ allowedOrigins: ["https://example.com"] }); + const handler = vi.fn(); + + router.corsRoute({ + path: "/test", + method: "GET", + handler: httpActionGeneric(handler), + }); + + const routeMap = router.exactRoutes.get("/test"); + const corsHandler = routeMap?.get("GET"); + + expect(corsHandler).toBeDefined(); + }); + + test("configures OPTIONS handler with correct allowed methods", () => { + const router = corsHttpRouter({ allowedOrigins: ["https://example.com"] }); + const handlerGet = vi.fn(); + const handlerPost = vi.fn(); + + router.corsRoute({ + path: "/test", + method: "GET", + handler: httpActionGeneric(handlerGet), + }); + + router.corsRoute({ + path: "/test", + method: "POST", + handler: httpActionGeneric(handlerPost), + }); + + const routeMap = router.exactRoutes.get("/test"); + const optionsHandler = routeMap?.get("OPTIONS"); + + expect(optionsHandler).toBeDefined(); + }); +}); diff --git a/packages/convex-helpers/server/corsHttpRouter.ts b/packages/convex-helpers/server/corsHttpRouter.ts new file mode 100644 index 00000000..60c3cdb0 --- /dev/null +++ b/packages/convex-helpers/server/corsHttpRouter.ts @@ -0,0 +1,338 @@ +/** + * This file defines a CorsHttpRouter class that extends Convex's HttpRouter. + * It provides CORS (Cross-Origin Resource Sharing) support for HTTP routes. + * + * The CorsHttpRouter: + * 1. Allows specifying allowed origins for CORS. + * 2. Overrides the route method to add CORS headers to all non-OPTIONS requests. + * 3. Automatically adds an OPTIONS route to handle CORS preflight requests. + * 4. Uses the handleCors helper function to apply CORS headers consistently. + * + * This router simplifies the process of making Convex HTTP endpoints + * accessible to web applications hosted on different domains while + * maintaining proper CORS configuration. + */ +import { + GenericActionCtx, + httpActionGeneric, + httpRouter, + HttpRouter, + PublicHttpAction, + RouteSpec, + RouteSpecWithPath, + RouteSpecWithPathPrefix, +} from "convex/server"; + +type RouteSpecWithCors = RouteSpec & { + allowedOrigins?: string[]; +}; + +/** + * Factory function to create a new CorsHttpRouter instance. + * @param allowedOrigins An array of allowed origins for CORS. + * @returns A new CorsHttpRouter instance. + */ +export const corsHttpRouter = ({ + allowedOrigins, +}: { + allowedOrigins: string[]; +}) => new CorsHttpRouter({ allowedOrigins }); + +export class CorsHttpRouter extends HttpRouter { + allowedOrigins: string[]; + + /** + * Constructor for CorsHttpRouter. + * @param allowedOrigins An array of allowed origins for CORS. + */ + constructor({ allowedOrigins }: { allowedOrigins: string[] }) { + super(); + this.allowedOrigins = allowedOrigins; + } + + /** + * Overrides the route method to add CORS support. + * @param routeSpec The route specification to be added. + */ + corsRoute = (routeSpec: RouteSpecWithCors): void => { + const tempRouter = httpRouter(); + tempRouter.exactRoutes = this.exactRoutes; + tempRouter.prefixRoutes = this.prefixRoutes; + + const allowedOrigins = routeSpec.allowedOrigins ?? this.allowedOrigins; + + const routeSpecWithCors = this.createRouteSpecWithCors( + routeSpec, + allowedOrigins, + ); + tempRouter.route(routeSpecWithCors); + + /** + * Figure out what kind of route we're adding: exact or prefix and handle + * accordingly. + */ + if ("path" in routeSpec) { + this.handleExactRoute(tempRouter, routeSpec, allowedOrigins); + } else { + this.handlePrefixRoute(tempRouter, routeSpec, allowedOrigins); + } + + /** + * Copy the routes from the temporary router to the main router. + */ + this.mergeRoutes(tempRouter); + }; + + /** + * Handles exact route matching and adds OPTIONS handler. + * @param tempRouter Temporary router instance. + * @param routeSpec Route specification for exact matching. + */ + private handleExactRoute( + tempRouter: HttpRouter, + routeSpec: RouteSpecWithPath, + allowedOrigins: string[], + ): void { + /** + * exactRoutes is defined as a Map> + * where the KEY is the PATH and the VALUE is a map of methods and handlers + */ + const currentMethodsForPath = tempRouter.exactRoutes.get(routeSpec.path); + + /** + * createOptionsHandlerForMethods is a helper function that creates + * an OPTIONS handler for all registered HTTP methods for the given path + */ + const optionsHandler = this.createOptionsHandlerForMethods( + Array.from(currentMethodsForPath?.keys() ?? []), + allowedOrigins, + ); + + /** + * Add the OPTIONS handler for the given path + */ + currentMethodsForPath?.set("OPTIONS", optionsHandler); + + /** + * Add the updated methods for the given path to the exactRoutes map + */ + tempRouter.exactRoutes.set(routeSpec.path, new Map(currentMethodsForPath)); + } + + /** + * Handles prefix route matching and adds OPTIONS handler. + * @param tempRouter Temporary router instance. + * @param routeSpec Route specification for prefix matching. + */ + private handlePrefixRoute( + tempRouter: HttpRouter, + routeSpec: RouteSpecWithPathPrefix, + allowedOrigins: string[], + ): void { + /** + * prefixRoutes is structured differently than exactRoutes. It's defined as + * a Map> where the KEY is the + * METHOD and the VALUE is a map of paths and handlers. + */ + const currentMethods = tempRouter.prefixRoutes.keys(); + const optionsHandler = this.createOptionsHandlerForMethods( + Array.from(currentMethods ?? []), + allowedOrigins, + ); + + /** + * Add the OPTIONS handler for the given path prefix + */ + const optionsPrefixes = + tempRouter.prefixRoutes.get("OPTIONS") || + new Map(); + optionsPrefixes.set(routeSpec.pathPrefix, optionsHandler); + + /** + * Add the updated methods for the given path to the prefixRoutes map + */ + tempRouter.prefixRoutes.set("OPTIONS", optionsPrefixes); + } + + /** + * Creates a new route specification with CORS support. + * @param routeSpec Original route specification. + * @returns Modified route specification with CORS handler. + */ + private createRouteSpecWithCors( + routeSpec: RouteSpec, + allowedOrigins: string[], + ): RouteSpec { + const httpCorsHandler = handleCors({ + originalHandler: routeSpec.handler, + allowedOrigins: allowedOrigins, + allowedMethods: [routeSpec.method], + }); + return { + ...("path" in routeSpec + ? { path: routeSpec.path } + : { pathPrefix: routeSpec.pathPrefix }), + method: routeSpec.method, + handler: httpCorsHandler, + }; + + throw new Error("Invalid routeSpec"); + } + + /** + * Creates an OPTIONS handler for the given HTTP methods. + * @param methods Array of HTTP methods to be allowed. + * @returns A CORS-enabled OPTIONS handler. + */ + private createOptionsHandlerForMethods( + methods: string[], + allowedOrigins: string[], + ): PublicHttpAction { + return handleCors({ + allowedOrigins: allowedOrigins, + allowedMethods: methods, + }); + } + + /** + * Finalizes the routes by copying them from the temporary router. + * @param router Temporary router with updated routes. + */ + private mergeRoutes(router: HttpRouter): void { + this.exactRoutes = new Map(router.exactRoutes); + this.prefixRoutes = new Map(router.prefixRoutes); + } +} + +export default corsHttpRouter; + +/** + * handleCors() is a higher-order function that wraps a Convex HTTP action handler to add CORS support. + * It allows for customization of allowed HTTP methods and origins for cross-origin requests. + * + * The function: + * 1. Validates and normalizes the allowed HTTP methods. + * 2. Generates appropriate CORS headers based on the provided configuration. + * 3. Handles preflight OPTIONS requests automatically. + * 4. Wraps the original handler to add CORS headers to its response. + * + * This helper simplifies the process of making Convex HTTP actions accessible + * to web applications hosted on different domains. + */ + +import { ROUTABLE_HTTP_METHODS, RoutableMethod } from "convex/server"; + +const SECONDS_IN_A_DAY = 60 * 60 * 24; + +/** + * Example CORS origins: + * - "*" (allow all origins) + * - "https://example.com" (allow a specific domain) + * - "https://*.example.com" (allow all subdomains of example.com) + * - "https://example1.com, https://example2.com" (allow multiple specific domains) + * - "null" (allow requests from data URLs or local files) + */ + +const defaultCorsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": SECONDS_IN_A_DAY.toString(), +}; + +const handleCors = ({ + originalHandler, + allowedMethods = ["OPTIONS"], + allowedOrigins = ["*"], +}: { + originalHandler?: PublicHttpAction; + allowedMethods?: string[]; + allowedOrigins?: string[]; +}) => { + const uniqueMethods = Array.from( + new Set( + allowedMethods.map((method) => method.toUpperCase() as RoutableMethod), + ), + ); + const filteredMethods = uniqueMethods.filter((method) => + ROUTABLE_HTTP_METHODS.includes(method), + ); + + if (filteredMethods.length === 0) { + throw new Error("No valid HTTP methods provided"); + } + + /** + * Ensure OPTIONS is not duplicated if it was passed in + * E.g. if allowedMethods = ["GET", "OPTIONS"] + */ + const allowMethods = filteredMethods.includes("OPTIONS") + ? filteredMethods.join(", ") + : [...filteredMethods].join(", "); + + /** + * Format origins correctly + * E.g. "https://example1.com, https://example2.com" + */ + const allowOrigins = allowedOrigins.join(", "); + + /** + * Build up the set of CORS headers + */ + const corsHeaders = { + ...defaultCorsHeaders, + "Access-Control-Allow-Methods": allowMethods, + "Access-Control-Allow-Origin": allowOrigins, + }; + + /** + * Return our modified HTTP action + */ + return httpActionGeneric( + async (_: GenericActionCtx, request: Request) => { + /** + * OPTIONS has no handler and just returns headers + */ + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: new Headers(corsHeaders), + }); + } + + /** + * If the method is not OPTIONS, it must pass a handler + */ + if (!originalHandler) { + throw new Error("No PublicHttpAction provider to CORS handler"); + } + + /** + * First, execute the original handler + */ + const originalResponse = await originalHandler(_, request); + + /** + * Second, get a copy of the original response's headers + */ + const newHeaders = new Headers(originalResponse.headers); + + /** + * Third, add or update our CORS headers + */ + Object.entries(corsHeaders).forEach(([key, value]) => { + newHeaders.set(key, value); + }); + + /** + * Fourth, return the modified Response. + * A Response object is immutable, so we create a new one to return here. + */ + return new Response(originalResponse.body, { + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: newHeaders, + }); + }, + ); +};