/* ============================================================================ SERVER ENVIRONMENT ---------------------------------------------------------------------------- The `.server` suffix is enforced by the React Router Vite plugin: this module is stripped from the browser bundle, and importing it from component code fails the build rather than leaking STRAPI_TOKEN to the client. Validation is lazy and memoised. Reading it at module load would crash the whole server on a typo in one variable; reading it on first use lets the error surface as an ordinary 500 with every problem listed at once. ========================================================================= */ export type NodeEnv = "development" | "production" | "test"; export interface ServerEnv { readonly NODE_ENV: NodeEnv; /** Strapi REST origin, no trailing slash. Default http://localhost:1337 */ readonly STRAPI_URL: string; /** Optional API token. Sent as a bearer token when present. */ readonly STRAPI_TOKEN?: string; /** Public origin of this site, no trailing slash. Used for canonical URLs. */ readonly SITE_URL: string; readonly isProduction: boolean; readonly isDevelopment: boolean; } /** Thrown when one or more variables are missing or malformed. */ export class EnvError extends Error { override readonly name = "EnvError"; readonly issues: readonly string[]; constructor(issues: readonly string[]) { super(`Invalid server environment:\n - ${issues.join("\n - ")}`); this.issues = issues; } } const DEFAULTS = { STRAPI_URL: "http://localhost:1337", SITE_URL: "https://sharper.ly", } as const; function trimTrailingSlash(value: string): string { return value.replace(/\/+$/, ""); } function readOrigin( name: string, raw: string | undefined, fallback: string, issues: string[], ): string { const value = raw?.trim() || fallback; try { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") { issues.push(`${name} must be an http(s) URL, received "${value}"`); return trimTrailingSlash(fallback); } return trimTrailingSlash(value); } catch { issues.push(`${name} must be an absolute URL, received "${value}"`); return trimTrailingSlash(fallback); } } function readNodeEnv(raw: string | undefined): NodeEnv { return raw === "production" || raw === "test" ? raw : "development"; } let cached: ServerEnv | undefined; /** The validated environment. Memoised after the first successful read. */ export function getServerEnv(): ServerEnv { if (cached) return cached; const source = process.env; const issues: string[] = []; const NODE_ENV = readNodeEnv(source.NODE_ENV); const STRAPI_URL = readOrigin( "STRAPI_URL", source.STRAPI_URL, DEFAULTS.STRAPI_URL, issues, ); const SITE_URL = readOrigin( "SITE_URL", source.SITE_URL, DEFAULTS.SITE_URL, issues, ); const token = source.STRAPI_TOKEN?.trim(); if (issues.length > 0) throw new EnvError(issues); cached = { NODE_ENV, STRAPI_URL, SITE_URL, ...(token ? { STRAPI_TOKEN: token } : {}), isProduction: NODE_ENV === "production", isDevelopment: NODE_ENV === "development", }; return cached; } /** Drop the memoised value. Only useful in tests. */ export function resetServerEnv(): void { cached = undefined; } /** * The origin canonical URLs and hreflang alternates are built from. * * SITE_URL wins when it is set to something other than the default, so a * preview deployment behind a proxy still emits its own canonical. Otherwise * the incoming request is trusted, honouring the standard forwarding headers. */ export function getSiteUrl(request: Request): string { const env = getServerEnv(); if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL; const url = new URL(request.url); const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); const forwardedProto = request.headers.get("x-forwarded-proto"); const protocol = forwardedProto?.split(",")[0]?.trim() ?? url.protocol.replace(":", ""); const host = forwardedHost?.split(",")[0]?.trim() ?? url.host; return trimTrailingSlash(`${protocol}://${host}`); }