Files

136 lines
4.2 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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 { SITE_URL } = getServerEnv();
if (process.env.SITE_URL?.trim()) return 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}`);
}