UNPKG

convex-helpers

Version:

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

304 lines (303 loc) 12.3 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 { httpActionGeneric, httpRouter, HttpRouter, ROUTABLE_HTTP_METHODS, } from "convex/server"; export const DEFAULT_EXPOSED_HEADERS = [ // For Range requests "Content-Range", "Accept-Ranges", ]; /** * 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, corsConfig) => { const allowedExactMethodsByPath = new Map(); const allowedPrefixMethodsByPath = new Map(); return { http, route: (routeSpec) => { 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(); 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(); 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, routeSpec, config, allowedMethods) { 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, routeSpec, config, allowedMethods) { /** * 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(); 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, config) { 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, }) => { const uniqueMethods = Array.from(new Set(allowedMethods.map((method) => method.toUpperCase()))); 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 = { 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) { return Array.isArray(allowedOrigins) ? allowedOrigins : await allowedOrigins(request); } // Helper function to check if origin is allowed (including wildcard subdomain matching) async function isAllowedOrigin(request) { 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, 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 = 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"] : originalHandler); 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, }); }); };