UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

142 lines (127 loc) 3.93 kB
/** * CORS hook utilities for @beignet/core/server */ import type { HttpRequestLike, ServerHook } from "../types.js"; /** * CORS configuration for `createCorsHooks(...)`. */ export interface CorsConfig { /** * Allowed origins. Use `"*"` only for non-credentialed requests. * Credentialed CORS requires an explicit origin allow-list. */ origins?: string[] | "*"; /** * Allowed HTTP methods. */ methods?: string[]; /** * Allowed request headers. */ headers?: string[]; /** * Whether credentialed requests are allowed. */ credentials?: boolean; } const DEFAULT_CORS: Required<CorsConfig> = { origins: "*", methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], headers: ["Content-Type", "Authorization"], credentials: false, }; function resolveCorsConfig(config: CorsConfig): Required<CorsConfig> { const resolved = { origins: config.origins ?? DEFAULT_CORS.origins, methods: config.methods ?? DEFAULT_CORS.methods, headers: config.headers ?? DEFAULT_CORS.headers, credentials: config.credentials ?? DEFAULT_CORS.credentials, }; if ( resolved.credentials && (resolved.origins === "*" || (Array.isArray(resolved.origins) && resolved.origins.includes("*"))) ) { throw new Error( 'Credentialed CORS cannot use a wildcard origin. Use origins: ["https://app.example.com"] with credentials: true.', ); } return resolved; } function appendVaryOrigin(headers: Record<string, string>): void { const varyKey = Object.keys(headers).find((key) => key.toLowerCase() === "vary") ?? "Vary"; const current = headers[varyKey]; const values = current?.split(",").map((value) => value.trim().toLowerCase()); if (values?.includes("origin")) return; headers[varyKey] = current ? `${current}, Origin` : "Origin"; } /** * Apply CORS response headers to a mutable header record. * * Credentialed CORS rejects wildcard origins. Use an explicit origin allow-list * when cookies or authorization headers are allowed cross-origin. */ export function applyCorsHeaders( headers: Record<string, string>, req: HttpRequestLike, corsConfig: CorsConfig, ): void { const { origins, methods, headers: allowedHeaders, credentials, } = resolveCorsConfig(corsConfig); if (origins === "*") { headers["Access-Control-Allow-Origin"] = "*"; } else if (Array.isArray(origins)) { const requestOrigin = req.headers.get("Origin"); if (requestOrigin && origins.includes(requestOrigin)) { headers["Access-Control-Allow-Origin"] = requestOrigin; appendVaryOrigin(headers); } } headers["Access-Control-Allow-Methods"] = methods.join(", "); headers["Access-Control-Allow-Headers"] = allowedHeaders.join(", "); if (credentials) { headers["Access-Control-Allow-Credentials"] = "true"; } } /** * Create CORS hooks for preflight and regular responses. * * CORS preflight requests short-circuit with a 204 response. Explicit * `OPTIONS` routes without `Access-Control-Request-Method` continue through the * normal route pipeline. All responses are decorated in `beforeSend`. */ export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> { const corsConfig = resolveCorsConfig(config); return { name: "cors", onRequest: ({ req }) => { if ( req.method !== "OPTIONS" || !req.headers.get("Origin") || !req.headers.get("Access-Control-Request-Method") ) { return undefined; } const headers: Record<string, string> = {}; applyCorsHeaders(headers, req, corsConfig); return { status: 204, headers, body: null, }; }, beforeSend: ({ req, response }) => { const headers = { ...(response.headers ?? {}) }; applyCorsHeaders(headers, req, corsConfig); return { ...response, headers, }; }, }; }