Skip to content

Instantly share code, notes, and snippets.

@jrr
Last active April 23, 2022 03:06
Show Gist options
  • Save jrr/d89128fd6514660d424393c1b01741f1 to your computer and use it in GitHub Desktop.
Save jrr/d89128fd6514660d424393c1b01741f1 to your computer and use it in GitHub Desktop.
/*
Runtime Config
Create React App provides a mechanism for _build-time_ configuration,
via REACT_APP_ env vars.
In order to make a single build of the frontend portable across environments,
it's desirable to also be able to provide _run-time_ configuration.
So we built a way to do it. It works like this:
1) a specially-named env var is set server-side, e.g. REACT_RUNTIME_FOOBAR
2) a server endpoint (/api/runtime-config.js) provides a small snippet of javascript
that, when run on the client, stores configuration on the window object.
3) the client runs this script very early in execution (`<script src=` in index.html)
4) client code accesses configuration through getRuntimeConfig() below
*/
import camelCase from "lodash/camelCase";
import difference from "lodash/difference";
/**
* The set of server-side env vars necessary to populate the run-time configuration
* needed by the client. This is the source of truth; add new things here.
* (camel-case client types are derived from it)
*/
const runtimeConfigEnvVars = [
"REACT_RUNTIME_IDENTITY_PROVIDER_ID",
"REACT_RUNTIME_EXTERNAL_LINK_URL",
"REACT_RUNTIME_NAV_COLOR",
] as const;
export const runtimeConfigKeyName = "runtimeConfig";
const ENV_VAR_PREFIX = "REACT_RUNTIME_";
const envVarToShortName = (s: string) =>
camelCase(s.replace(ENV_VAR_PREFIX, ""));
const shortenedConfigNames = runtimeConfigEnvVars.map(envVarToShortName);
type KnownConfigName = typeof runtimeConfigEnvVars[number];
type CamelCase<S extends string> =
// https://stackoverflow.com/a/65015868
S extends `${infer P1}_${infer P2}${infer P3}`
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>;
type RemovePrefix<
S extends string
> = S extends `${typeof ENV_VAR_PREFIX}${infer P}` ? P : S;
type RuntimeConfigDict = Record<
CamelCase<RemovePrefix<KnownConfigName>>,
string
>;
/** Build config dict from environment variables. (pass in `process.env`) */
export function buildRuntimeConfigObject(input: unknown): RuntimeConfigDict {
if (typeof input !== "object" || input === null) {
throw new Error("invalid input to buildConfigObject()");
}
const matchingEnvVars = Object.entries(input).filter(([k, _]) =>
k.startsWith(ENV_VAR_PREFIX)
);
logDifferences(
runtimeConfigEnvVars,
matchingEnvVars.map(([a, _]) => a)
);
const obj = Object.fromEntries(
matchingEnvVars.map(([k, v]) => [envVarToShortName(k), v])
);
return obj as RuntimeConfigDict;
}
function hasConfig(x: unknown): x is { [runtimeConfigKeyName]: unknown } {
if (typeof x == "object" && x != null) {
return runtimeConfigKeyName in x;
}
return false;
}
/** Load runtime config on client. (pass in the 'window' object) */
export function getRuntimeConfig(window: unknown): RuntimeConfigDict {
if (!hasConfig(window)) {
throw new Error(
`invalid window given. (expected ${runtimeConfigKeyName} property)`
);
}
const runtimeConfig = window[runtimeConfigKeyName];
if (typeof runtimeConfig != "object" || runtimeConfig === null) {
throw new Error(`invalid ${runtimeConfigKeyName} (expected object)`);
}
logDifferences(shortenedConfigNames, Object.keys(runtimeConfig));
return runtimeConfig as RuntimeConfigDict;
}
function logDifferences(
expected: readonly string[],
actual: readonly string[]
) {
const missing = difference(expected, actual);
if (missing.length > 0) {
console.warn(
`Runtime config: the following item(s) are missing (expected by client): ${missing.join(
", "
)}`
);
}
const extra = difference(actual, expected);
if (extra.length > 0) {
console.warn(
`Runtime config: the following item(s) are extraneous (not expected by client): ${extra.join(
", "
)}`
);
}
}
@jrr
Copy link
Author

jrr commented Nov 29, 2021

This goes with a blog post: Runtime Configuration for SPAs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment