src/lib/runtime-config.ts
Rama main · réplica de lectura del repositorio original.
/**
* Everything an operator configures about this instance, read from the environment at
* request time rather than baked into the bundle.
*
* `NEXT_PUBLIC_*` values are inlined by the compiler wherever they are written as a
* literal, so anything read that way is frozen into a published image and cannot be
* changed without rebuilding it. That is the same trap `lib/api-base.ts` describes for
* the API's address, one layer up: a prebuilt image is only worth publishing if the
* whole of its configuration survives being handed to somebody else. So this module runs
* on the server, reads plain variables, and hands the browser's share of the answer to
* `contexts/ConfigContext.tsx`.
*
* See `DECISIONS.md`, "Web config is read at runtime, and the browser is handed it".
*/
import { resolveBasemap, type Basemap } from "@/lib/basemap";
/**
* The variables, as a plain bag of strings. Narrower than `NodeJS.ProcessEnv`, whose
* required `NODE_ENV` a test would otherwise have to name to hand over three variables.
*/
type Environment = Record<string, string | undefined>;
/** Where metadata URLs resolve against when `SITE_URL` says nothing. */
export const DEFAULT_SITE_URL = "http://localhost:3000";
/** The part of the configuration the browser is given. */
export interface PublicConfig {
/**
* Google's OAuth client ID, or `undefined` to hide "Continue with Google" entirely.
* Not a secret - it is meant to be seen by the browser - and it must match the API's
* `GOOGLE_CLIENT_ID`.
*/
googleClientId?: string;
/**
* The basemap every map draws, already resolved: a style URL pair or a raster
* template pair, plus the credit rendered over whichever is active.
*
* One field, because every map is drawn by MapLibre: an operator's
* `MAP_STYLE_URL` reaches every map.
*/
basemap: Basemap;
}
/** `PublicConfig` plus the parts only the server renders with. */
export interface RuntimeConfig extends PublicConfig {
/**
* This instance's own origin. In the web server only `metadataBase` uses it, so there a
* wrong value costs the canonical URLs that resolve against it; the map renderer also names
* the instance by it to the basemap and resolves a relative style URL against it.
*/
siteUrl: string;
/**
* Shown on `/support` as a fallback when a submission fails. Display only - the API's
* `CONTACT_FORM_EMAIL` is what actually routes mail - so it is deliberately unset by
* default rather than pointing a self-hoster's visitors at this project's inbox.
*/
contactEmail?: string;
/**
* Whether `src/proxy.ts` may send `Strict-Transport-Security`. On by default, and only
* ever acted on for a request that already arrived over HTTPS; `WEB_HSTS=off` is the
* escape hatch for an instance served over plain HTTP on a LAN, where a browser that
* recorded the pin would refuse to reach it again.
*/
hstsEnabled: boolean;
/**
* Whether to ask crawlers to stay out entirely - `app/robots.ts` plus an
* `X-Robots-Tag` on every page. Off by default, which is what a public instance wants;
* a private one sets `WEB_NOINDEX=true`.
*/
noindex: boolean;
}
/**
* A configured value, preferring the runtime name and falling back to the
* `NEXT_PUBLIC_`-prefixed one.
*
* The fallback is reached through a *computed* key on purpose. Written as the literal
* `process.env.NEXT_PUBLIC_X`, the compiler replaces the whole expression with whatever
* the build machine had - and the published image is built with none of these set, so
* the fallback would be frozen to `undefined` while still reading like it worked. A
* computed key is left alone, which is what keeps a build-time-configured deployment
* working.
*
* Blank counts as unset at both levels. A compose file that names a variable it has no
* value for (`GOOGLE_CLIENT_ID=`) has configured nothing, and reading that as "explicitly
* empty" would suppress the fallback rather than fall through to it.
*/
function configured(env: Environment, name: string): string | undefined {
return set(env[name]) ?? set(env[`NEXT_PUBLIC_${name}`]);
}
function set(value: string | undefined): string | undefined {
return value?.trim() || undefined;
}
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
/**
* A boolean variable, with anything unrecognized falling back rather than counting as
* one of the two answers. `WEB_NOINDEX=enabled` is somebody asking for it on, and
* silently reading as off - or as on, which is worse - is not the way to tell them it
* did nothing.
*/
function flag(
name: string,
value: string | undefined,
fallback: boolean,
): boolean {
if (value === undefined) return fallback;
const normalized = value.toLowerCase();
if (TRUE_VALUES.has(normalized)) return true;
if (FALSE_VALUES.has(normalized)) return false;
console.warn(
`[runtime-config] Ignoring unrecognized ${name}=${value}. ` +
`Use one of ${[...TRUE_VALUES].join("/")} or ${[...FALSE_VALUES].join("/")}; ` +
`leaving it at ${fallback}.`,
);
return fallback;
}
/**
* A validated absolute origin, or the localhost default.
*
* `metadataBase` is a `new URL(...)`, which throws on a malformed value - and it is
* built in the root layout, so a typo'd `SITE_URL` would take every page down over
* page metadata. Failing back to the default and saying so is the same trade
* `lib/api-base.ts` and `lib/basemap.ts` make for the CSP.
*/
function resolveSiteUrl(value: string | undefined): string {
if (!value) return DEFAULT_SITE_URL;
try {
const { protocol } = new URL(value);
// As in `apiCspSource`: `new URL` only throws when there is no parseable scheme at
// all, so "htp://example.com" would sail through and reach crawlers as an
// unfetchable canonical URL.
if (protocol !== "http:" && protocol !== "https:")
throw new Error(protocol);
return value;
} catch {
console.warn(
`[runtime-config] Ignoring malformed SITE_URL: ${value}. ` +
`Give an absolute origin such as https://dives.example.com; ` +
`falling back to ${DEFAULT_SITE_URL}.`,
);
return DEFAULT_SITE_URL;
}
}
/**
* Reads a configuration out of a given environment.
*
* Exported for tests; everything else wants `runtimeConfig()`, which reads the real one
* once. Warnings here are not gated on `NODE_ENV` for that reason - they are emitted
* once per process, and a self-hoster who mistyped a variable is exactly who needs to
* see them.
*/
export function readRuntimeConfig(
env: Environment = process.env,
): RuntimeConfig {
return {
siteUrl: resolveSiteUrl(configured(env, "SITE_URL")),
contactEmail: configured(env, "CONTACT_EMAIL"),
googleClientId: configured(env, "GOOGLE_CLIENT_ID"),
// `MAP_ATTRIBUTION` belongs to neither mode, which is why it carries no
// `MAP_TILE_` prefix: it is one credit, applying to whichever basemap is
// active, and an operator who configures a vector style must be able to set
// it. Inside the raster group a style could be configured with no way to
// credit it, and the app would render the bundled pair's OpenMapTiles credit
// over somebody else's tiles: false, and a licence breach for any style that
// is not OpenFreeMap's.
basemap: resolveBasemap({
styleUrl: configured(env, "MAP_STYLE_URL"),
styleUrlDark: configured(env, "MAP_STYLE_URL_DARK"),
tileUrl: configured(env, "MAP_TILE_URL"),
tileUrlDark: configured(env, "MAP_TILE_URL_DARK"),
attribution: configured(env, "MAP_ATTRIBUTION"),
apiKey: configured(env, "MAP_TILE_API_KEY"),
}),
// Read directly rather than through `configured`: these two have no `NEXT_PUBLIC_`
// spelling to fall back to, and offering one would invite an operator to set a
// variable that reaches the browser for a decision the server makes alone.
hstsEnabled: flag("WEB_HSTS", set(env.WEB_HSTS), true),
noindex: flag("WEB_NOINDEX", set(env.WEB_NOINDEX), false),
};
}
let cached: RuntimeConfig | undefined;
/**
* The configuration this process is running with.
*
* Read on first use rather than at module load - which would be a build-time read for
* anything the compiler decides to evaluate early - and then kept, because the
* environment cannot change while the process lives and the diagnostics above should be
* said once rather than on every request.
*/
export function runtimeConfig(): RuntimeConfig {
return (cached ??= readRuntimeConfig());
}
/**
* The subset handed to the browser through `ConfigProvider`.
*
* Built field by field rather than by deleting the rest: what reaches the client is
* worth being able to read off one expression, and a future server-only value should
* have to be added here deliberately before it ships to anyone.
*/
export function publicConfig(
config: RuntimeConfig = runtimeConfig(),
): PublicConfig {
return {
googleClientId: config.googleClientId,
basemap: config.basemap,
};
}