136 lines
4.2 KiB
TypeScript
136 lines
4.2 KiB
TypeScript
/* ============================================================================
|
|
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}`);
|
|
}
|