UNPKG

convex-helpers

Version:

A collection of useful code to complement the official convex package.

446 lines (412 loc) 14.1 kB
/** * 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 { type GenericActionCtx, httpActionGeneric, httpRouter, HttpRouter, ROUTABLE_HTTP_METHODS, type RoutableMethod, type PublicHttpAction, type RouteSpec, type RouteSpecWithPath, type RouteSpecWithPathPrefix, } from "convex/server"; export const DEFAULT_EXPOSED_HEADERS = [ // For Range requests "Content-Range", "Accept-Ranges", ]; export type CorsConfig = { /** * Whether to allow credentials in the request. * When true, the request can include cookies and authentication headers. * @default false */ allowCredentials?: boolean; /** * An array of allowed origins: what domains are allowed to make requests. * For example, ["https://example.com"] would only allow requests from * https://example.com. * You can also use wildcards to allow all subdomains of a given domain. * E.g. ["*.example.com"] would allow requests from: * - https://subdomain.example.com * - https://example.com * @default ["*"] */ allowedOrigins?: string[] | ((req: Request) => Promise<string[]>); /** * An array of allowed headers: what headers are allowed to be sent in * the request. * @default ["Content-Type"] */ allowedHeaders?: string[]; /** * An array of exposed headers: what headers are allowed to be sent in * the response. * Note: if you pass in an empty array, it will not expose any headers. * If you want to extend the default exposed headers, you can do so by * passing in [...DEFAULT_EXPOSED_HEADERS, ...yourHeaders]. * @default {@link DEFAULT_EXPOSED_HEADERS} */ exposedHeaders?: string[]; /** * The maximum age of the preflight request in seconds. * @default 86400 (1 day) */ browserCacheMaxAge?: number; /** * Whether to block requests from origins that are not in the allowedOrigins list. * @default false */ enforceAllowOrigins?: boolean; /** * Whether to log debugging information about CORS requests. * @default false */ debug?: boolean; }; export type RouteSpecWithCors = RouteSpec & CorsConfig; /** * An HttpRouter with built in cors support. */ export type CorsHttpRouter = { http: HttpRouter; route: (routeSpec: RouteSpecWithCors) => void; }; /** * Factory function to create a router that adds CORS support to routes. * @param allowedOrigins An array of allowed origins for CORS. * @returns A function to use instead of http.route when you want CORS. */ export const corsRouter = ( http: HttpRouter, corsConfig?: CorsConfig, ): CorsHttpRouter => { const allowedExactMethodsByPath: Map<string, Set<string>> = new Map(); const allowedPrefixMethodsByPath: Map<string, Set<string>> = new Map(); return { http, route: (routeSpec: RouteSpecWithCors): void => { const tempRouter = httpRouter(); tempRouter.exactRoutes = http.exactRoutes; tempRouter.prefixRoutes = http.prefixRoutes; const config = { ...corsConfig, ...routeSpec, }; const httpCorsHandler = handleCors({ originalHandler: routeSpec.handler, allowedMethods: [routeSpec.method], ...config, }); /** * Figure out what kind of route we're adding: exact or prefix and handle * accordingly. */ if ("path" in routeSpec) { let methods = allowedExactMethodsByPath.get(routeSpec.path); if (!methods) { methods = new Set<string>(); allowedExactMethodsByPath.set(routeSpec.path, methods); } methods.add(routeSpec.method); tempRouter.route({ path: routeSpec.path, method: routeSpec.method, handler: httpCorsHandler, }); handleExactRoute(tempRouter, routeSpec, config, Array.from(methods)); } else { let methods = allowedPrefixMethodsByPath.get(routeSpec.pathPrefix); if (!methods) { methods = new Set<string>(); allowedPrefixMethodsByPath.set(routeSpec.pathPrefix, methods); } methods.add(routeSpec.method); tempRouter.route({ pathPrefix: routeSpec.pathPrefix, method: routeSpec.method, handler: httpCorsHandler, }); handlePrefixRoute(tempRouter, routeSpec, config, Array.from(methods)); } /** * Copy the routes from the temporary router to the main router. */ http.exactRoutes = new Map(tempRouter.exactRoutes); http.prefixRoutes = new Map(tempRouter.prefixRoutes); }, }; }; /** * Handles exact route matching and adds OPTIONS handler. * @param tempRouter Temporary router instance. * @param routeSpec Route specification for exact matching. */ function handleExactRoute( tempRouter: HttpRouter, routeSpec: RouteSpecWithPath, config: CorsConfig, allowedMethods: string[], ): void { const currentMethodsForPath = tempRouter.exactRoutes.get(routeSpec.path); /** * Add the OPTIONS handler for the given path */ const optionsHandler = createOptionsHandlerForMethods(allowedMethods, config); currentMethodsForPath?.set("OPTIONS", optionsHandler); 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. */ function handlePrefixRoute( tempRouter: HttpRouter, routeSpec: RouteSpecWithPathPrefix, config: CorsConfig, allowedMethods: string[], ): void { /** * prefixRoutes is structured differently than exactRoutes. It's defined as * a Map<string, Map<string, PublicHttpAction>> where the KEY is the * METHOD and the VALUE is a map of paths and handlers. */ const optionsHandler = createOptionsHandlerForMethods(allowedMethods, config); const optionsPrefixes = tempRouter.prefixRoutes.get("OPTIONS") || new Map<string, PublicHttpAction>(); optionsPrefixes.set(routeSpec.pathPrefix, optionsHandler); tempRouter.prefixRoutes.set("OPTIONS", optionsPrefixes); } /** * Creates an OPTIONS handler for the given HTTP methods. * @param methods Array of HTTP methods to be allowed. * @returns A CORS-enabled OPTIONS handler. */ function createOptionsHandlerForMethods( methods: string[], config: CorsConfig, ): PublicHttpAction { return handleCors({ ...config, allowedMethods: methods, }); } export default corsRouter; /** * 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. */ 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 handleCors = ({ originalHandler, allowedMethods = ["OPTIONS"], allowedOrigins = ["*"], allowedHeaders = ["Content-Type"], exposedHeaders = DEFAULT_EXPOSED_HEADERS, allowCredentials = false, browserCacheMaxAge = SECONDS_IN_A_DAY, enforceAllowOrigins = false, debug = false, }: { originalHandler?: PublicHttpAction; allowedMethods?: string[]; } & CorsConfig) => { 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(", "); /** * Build up the set of CORS headers */ const commonHeaders: Record<string, string> = { Vary: "Origin", }; if (allowCredentials) { commonHeaders["Access-Control-Allow-Credentials"] = "true"; } if (exposedHeaders.length > 0) { commonHeaders["Access-Control-Expose-Headers"] = exposedHeaders.join(", "); } async function parseAllowedOrigins(request: Request): Promise<string[]> { return Array.isArray(allowedOrigins) ? allowedOrigins : await allowedOrigins(request); } // Helper function to check if origin is allowed (including wildcard subdomain matching) async function isAllowedOrigin(request: Request): Promise<boolean> { const requestOrigin = request.headers.get("origin"); if (!requestOrigin) return false; return (await parseAllowedOrigins(request)).some((allowed) => { if (allowed === "*") return true; if (allowed === requestOrigin) return true; if (allowed.startsWith("*.")) { const wildcardDomain = allowed.slice(1); // ".bar.com" const rootDomain = allowed.slice(2); // "bar.com" try { const url = new URL(requestOrigin); return ( url.protocol === "https:" && (url.hostname.endsWith(wildcardDomain) || url.hostname === rootDomain) ); } catch { return false; // Invalid URL format } } return false; }); } /** * Return our modified HTTP action */ return httpActionGeneric( async (ctx: GenericActionCtx<any>, request: Request) => { if (debug) { console.log("CORS request", { path: request.url, origin: request.headers.get("origin"), headers: request.headers, method: request.method, body: request.body, }); } const requestOrigin = request.headers.get("origin"); const parsedAllowedOrigins = await parseAllowedOrigins(request); if (debug) { console.log("allowed origins", parsedAllowedOrigins); } // Handle origin matching let allowOrigins: string | null = null; if ( parsedAllowedOrigins.includes("*") && requestOrigin && !allowCredentials ) { allowOrigins = requestOrigin; } else if (requestOrigin) { // Check if the request origin matches any of the allowed origins // (including wildcard subdomain matching if configured) if (await isAllowedOrigin(request)) { allowOrigins = requestOrigin; } } if (enforceAllowOrigins && !allowOrigins) { // Origin not allowed console.error( `Request from origin ${requestOrigin} blocked, missing from allowed origins: ${parsedAllowedOrigins.join()}`, ); return new Response(null, { status: 403 }); } /** * OPTIONS has no handler and just returns headers */ if (request.method === "OPTIONS") { const responseHeaders = new Headers({ ...commonHeaders, ...(allowOrigins ? { "Access-Control-Allow-Origin": allowOrigins } : {}), "Access-Control-Allow-Methods": allowMethods, "Access-Control-Allow-Headers": allowedHeaders.join(", "), "Access-Control-Max-Age": browserCacheMaxAge.toString(), }); if (debug) { console.log("CORS OPTIONS response headers", responseHeaders); } return new Response(null, { status: 204, headers: responseHeaders, }); } /** * 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 innerHandler = ("_handler" in originalHandler ? (originalHandler["_handler"] as PublicHttpAction) : originalHandler) as unknown as ( ctx: GenericActionCtx<any>, request: Request, ) => Promise<Response>; const originalResponse = await innerHandler(ctx, request); /** * Second, get a copy of the original response's headers and add the * allow origin header if it's allowed */ const newHeaders = new Headers(originalResponse.headers); if (allowOrigins) { newHeaders.set("Access-Control-Allow-Origin", allowOrigins); } /** * Third, add or update our other CORS headers */ Object.entries(commonHeaders).forEach(([key, value]) => { newHeaders.set(key, value); }); if (debug) { console.log("CORS response headers", newHeaders); } /** * 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, }); }, ); };