diff --git a/lib/api/client.ts b/lib/api/client.ts index 301a9e4..c52ad7c 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -1,3 +1,5 @@ +import { publicEnv } from '@/lib/env'; + import type { ApiEnvelope, ApiErrorDetail, ApiErrorEnvelope } from './types'; const API_PREFIX = '/api'; @@ -28,13 +30,7 @@ export class ApiError extends Error { } function resolveBaseUrl(): string { - const origin = process.env.NEXT_PUBLIC_API_URL; - if (!origin) { - throw new Error( - 'NEXT_PUBLIC_API_URL is not set. Copy .env.example to .env.local.' - ); - } - return `${origin.replace(/\/+$/, '')}${API_PREFIX}`; + return `${publicEnv.apiUrl.replace(/\/+$/, '')}${API_PREFIX}`; } async function readJson(response: Response): Promise { diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..fd5378b --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,57 @@ +const invalidVariables: string[] = []; + +function readHttpUrl( + name: string, + value: string | undefined, + required = false +): string | undefined { + const normalized = value?.trim(); + if (!normalized) { + if (required) invalidVariables.push(name); + return undefined; + } + + try { + const url = new URL(normalized); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + !url.hostname + ) { + invalidVariables.push(name); + return undefined; + } + } catch { + invalidVariables.push(name); + return undefined; + } + + return normalized; +} + +const apiUrl = readHttpUrl( + 'NEXT_PUBLIC_API_URL', + process.env.NEXT_PUBLIC_API_URL, + true +); +const siteUrl = readHttpUrl( + 'NEXT_PUBLIC_SITE_URL', + process.env.NEXT_PUBLIC_SITE_URL +); +const boundlessAppUrl = readHttpUrl( + 'NEXT_PUBLIC_BOUNDLESS_APP_URL', + process.env.NEXT_PUBLIC_BOUNDLESS_APP_URL +); + +// `apiUrl` is only undefined when it was already reported above; checking it +// here as well lets TypeScript narrow it to a string for `publicEnv`. +if (apiUrl === undefined || invalidVariables.length > 0) { + throw new Error( + `Invalid or missing public environment variable(s): ${invalidVariables.join(', ')}. Copy .env.example to .env.local and set each URL to an absolute http(s) URL.` + ); +} + +export const publicEnv = { + apiUrl, + siteUrl, + boundlessAppUrl, +}; diff --git a/lib/seo/site.ts b/lib/seo/site.ts index 2608ed0..4dd2677 100644 --- a/lib/seo/site.ts +++ b/lib/seo/site.ts @@ -3,6 +3,8 @@ * structured data both read from here, so the same strings are never * duplicated across routes. */ +import { publicEnv } from '@/lib/env'; + /** * Read an origin from the environment. Trims whitespace and trailing slashes * so callers can append a path without producing a double slash. @@ -17,7 +19,7 @@ export const siteConfig = { organizationName: 'Boundless', /** Canonical origin for this app, with no trailing slash. */ url: readOrigin( - process.env.NEXT_PUBLIC_SITE_URL, + publicEnv.siteUrl, 'https://builders.boundlessfi.xyz' ), description: @@ -26,7 +28,7 @@ export const siteConfig = { locale: 'en_US', /** The main Boundless app, where builders sign up and create. */ parentUrl: readOrigin( - process.env.NEXT_PUBLIC_BOUNDLESS_APP_URL, + publicEnv.boundlessAppUrl, 'https://boundlessfi.xyz' ), /** Public path to the white Boundless logo used by structured data. */ diff --git a/next.config.ts b/next.config.ts index 1be5f11..66a70e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next'; +import './lib/env'; const nextConfig: NextConfig = { images: {