@beignet/core
Version:
Core framework primitives for Beignet
142 lines (127 loc) • 3.93 kB
text/typescript
/**
* 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,
};
},
};
}