alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
196 lines (173 loc) • 5.57 kB
text/typescript
import { $atom, $hook, $inject, $state, type Static, z } from "alepha";
import { $logger } from "alepha/logger";
import { ServerRouterProvider } from "alepha/server";
// ---------------------------------------------------------------------------------------------------------------------
/**
* CORS configuration atom (global defaults)
*/
export const corsOptions = $atom({
name: "alepha.server.cors.options",
schema: z.object({
origin: z
.string()
.describe(
"Allowed origins (* for all, string for single, comma-separated for multiple)",
)
.default("*")
.optional(),
methods: z
.array(z.string())
.describe("Allowed HTTP methods")
.default(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]),
headers: z
.array(z.string())
.describe("Allowed headers")
.default(["Content-Type", "Authorization"]),
credentials: z
.boolean()
.describe("Allow credentials")
.default(false)
.optional(),
maxAge: z
.number()
.describe("Preflight cache duration in seconds")
.optional(),
}),
default: {
origin: "*",
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
headers: ["Content-Type", "Authorization"],
credentials: false,
},
});
export type CorsOptions = Static<typeof corsOptions.schema>;
declare module "alepha" {
interface State {
[corsOptions.key]: CorsOptions;
}
}
// ---------------------------------------------------------------------------------------------------------------------
export interface CorsRegistration extends Partial<CorsOptions> {
/**
* Name identifier for this CORS config.
*/
name?: string;
/**
* Path patterns to match (supports wildcards like /api/*).
*/
paths?: string[];
}
export class ServerCorsProvider {
protected readonly log = $logger();
protected readonly serverRouterProvider = $inject(ServerRouterProvider);
protected readonly globalOptions = $state(corsOptions);
/**
* Registered CORS configurations with their path patterns
*/
public readonly registeredConfigs: CorsRegistration[] = [];
/**
* Register a CORS configuration (called by primitives)
*/
public registerCors(config: CorsRegistration): void {
this.registeredConfigs.push(config);
}
protected readonly onStart = $hook({
on: "start",
handler: async () => {
// Apply path-specific CORS configs to routes
for (const config of this.registeredConfigs) {
if (config.paths) {
for (const pattern of config.paths) {
const matchedRoutes = this.serverRouterProvider.getRoutes(pattern);
for (const route of matchedRoutes) {
route.cors = this.buildCorsOptions(config);
}
}
}
}
if (this.registeredConfigs.length > 0) {
this.log.info(
`Initialized with ${this.registeredConfigs.length} registered CORS configurations.`,
);
}
},
});
/**
* Build complete CORS options by merging with global defaults
*/
public buildCorsOptions(config: Partial<CorsOptions>): CorsOptions {
return {
origin: config.origin ?? this.globalOptions.origin,
methods: config.methods ?? this.globalOptions.methods,
headers: config.headers ?? this.globalOptions.headers,
credentials: config.credentials ?? this.globalOptions.credentials,
maxAge: config.maxAge ?? this.globalOptions.maxAge,
};
}
/**
* Apply CORS headers to the response
*/
public applyCorsHeaders(
request: {
headers: { origin?: string };
reply: { setHeader: (name: string, value: string) => void };
},
options: CorsOptions,
): void {
const reqOrigin = request.headers.origin;
const { origin, methods, headers, credentials, maxAge } = options;
if (reqOrigin && this.isOriginAllowed(reqOrigin, origin)) {
request.reply.setHeader("Access-Control-Allow-Origin", reqOrigin);
}
if (credentials) {
request.reply.setHeader("Access-Control-Allow-Credentials", "true");
}
request.reply.setHeader("Access-Control-Allow-Methods", methods.join(", "));
request.reply.setHeader("Access-Control-Allow-Headers", headers.join(", "));
if (maxAge != null) {
request.reply.setHeader("Access-Control-Max-Age", String(maxAge));
}
}
protected readonly configure = $hook({
on: "start",
handler: () => {
const routes = this.serverRouterProvider.getRoutes();
for (const route of routes) {
if (
!route.method ||
route.method === "GET" ||
route.method === "OPTIONS"
) {
continue;
}
this.serverRouterProvider.createRoute({
path: route.path,
method: "OPTIONS",
handler: ({ reply }) => {
reply.setStatus(204);
},
});
}
},
});
protected readonly onRequest = $hook({
on: "server:onRequest",
handler: ({ route, request }) => {
// Use route-specific CORS if defined, otherwise use global options
const corsConfig = route.cors ?? this.globalOptions;
this.applyCorsHeaders(request, corsConfig);
},
});
public isOriginAllowed(
origin: string | undefined,
allowed: CorsOptions["origin"],
): boolean {
if (!allowed) return false;
if (allowed === "*") return true;
return allowed
.split(",")
.map((o) => o.trim())
.includes(origin ?? "");
}
}
export type ServerCorsProviderOptions = CorsOptions;