import { ObjectAccumulator } from "typed-object-accumulator";
import { toENVFormat } from "./text";
import { isBrowser } from "./web";
import {
BrowserEnvKey,
DefaultLoggingConfig,
ENV_PATH_DELIMITER,
} from "./constants";
/**
* @description Factory type for creating Environment instances.
* @summary Describes factories that construct {@link Environment} derivatives with custom initialization.
* @template T - The type of object the Environment will accumulate.
* @template E - The specific Environment type to be created, extending Environment<T>.
* @typedef {function(unknown[]): E} EnvironmentFactory
* @memberOf module:Logging
*/
export type EnvironmentFactory<T extends object, E extends Environment<T>> = (
...args: unknown[]
) => E;
export type EnvironmentInstance<T extends object> = Environment<T> &
T & { orThrow(): EnvironmentInstance<T> };
/**
* @description Environment accumulator that lazily reads from runtime sources.
* @summary Extends {@link ObjectAccumulator} to merge configuration objects while resolving values from Node or browser environment variables on demand.
* @template T
* @class Environment
* @example
* const Config = Environment.accumulate({ logging: { level: "info" } });
* console.log(Config.logging.level);
* console.log(String(Config.logging.level)); // => LOGGING__LEVEL key when serialized
* @mermaid
* sequenceDiagram
* participant Client
* participant Env as Environment
* participant Process as process.env
* participant Browser as globalThis.ENV
* Client->>Env: accumulate(partialConfig)
* Env->>Env: expand(values)
* Client->>Env: Config.logging.level
* alt Browser runtime
* Env->>Browser: lookup ENV key
* Browser-->>Env: resolved value
* else Node runtime
* Env->>Process: lookup ENV key
* Process-->>Env: resolved value
* end
* Env-->>Client: merged value
*/
const EmptyValue = Symbol("EnvironmentEmpty");
const ModelSymbol = Symbol("EnvironmentModel");
export class Environment<T extends object> extends ObjectAccumulator<T> {
/**
* @static
* @protected
* @description A factory function for creating Environment instances.
* @summary Defines how new instances of the Environment class should be created.
* @return {Environment<any>} A new instance of the Environment class.
*/
protected static factory: EnvironmentFactory<any, any> =
(): Environment<any> => new Environment();
/**
* @static
* @private
* @description The singleton instance of the Environment class.
* @type {Environment<any>}
*/
private static _instance: Environment<any>;
protected constructor() {
super();
Object.defineProperty(this, ModelSymbol, {
value: {},
writable: true,
enumerable: false,
configurable: false,
});
}
/**
* @description Retrieves a value from the runtime environment.
* @summary Handles browser and Node.js environments by normalizing keys and parsing values.
* @param {string} k - Key to resolve from the environment.
* @return {unknown} Value resolved from the environment or `undefined` when absent.
*/
protected fromEnv(k: string) {
let env: Record<string, unknown>;
if (isBrowser()) {
env =
(
globalThis as typeof globalThis & {
[BrowserEnvKey]: Record<string, any>;
}
)[BrowserEnvKey] || {};
} else {
env = globalThis.process.env;
k = toENVFormat(k);
}
return this.parseEnvValue(env[k]);
}
/**
* @description Converts stringified environment values into native types.
* @summary Interprets booleans and numbers while leaving other types unchanged.
* @param {unknown} val - Raw value retrieved from the environment.
* @return {unknown} Parsed value converted to boolean, number, or left as-is.
*/
protected parseEnvValue(val: unknown) {
if (typeof val !== "string") return val;
if (val === "true") return true;
if (val === "false") return false;
const result = parseFloat(val);
if (!isNaN(result)) return result;
return val;
}
/**
* @description Expands an object into the environment.
* @summary Defines lazy properties that first consult runtime variables before falling back to seeded values.
* @template V - Type of the object being expanded.
* @param {V} value - Object to expose through environment getters and setters.
* @return {void}
*/
protected override expand<V extends object>(value: V): void {
Object.entries(value).forEach(([k, v]) => {
Environment.mergeModel((this as any)[ModelSymbol], k, v);
Object.defineProperty(this, k, {
get: () => {
const fromEnv = this.fromEnv(k);
if (typeof fromEnv !== "undefined") return fromEnv;
if (v && typeof v === "object") {
return Environment.buildEnvProxy(v as any, [k]);
}
// If the model provides an empty string, mark with EmptyValue so instance proxy can return undefined without enabling key composition
if (v === "") {
return EmptyValue as unknown as V[keyof V];
}
return v;
},
set: (val: V[keyof V]) => {
v = val;
},
configurable: true,
enumerable: true,
});
});
}
/**
* @description Returns a proxy enforcing required environment variables.
* @summary Accessing a property that resolves to `undefined` or an empty string when declared in the model throws an error.
* @return {this} Proxy of the environment enforcing required variables.
*/
orThrow(): EnvironmentInstance<T> {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const base = this;
const modelRoot = (base as any)[ModelSymbol] as Record<string, any>;
const buildKey = (path: string[]) =>
path.map((segment) => toENVFormat(segment)).join(ENV_PATH_DELIMITER);
const readRuntime = (key: string) => Environment.readRuntimeEnv(key);
const parseRuntime = (raw: unknown) =>
typeof raw !== "undefined" ? this.parseEnvValue(raw) : undefined;
const missing = (key: string, empty: boolean = false) =>
Environment.missingEnvError(key, empty);
const createNestedProxy = (model: any, path: string[]): any => {
const handler: ProxyHandler<any> = {
get(_target, prop) {
if (typeof prop !== "string") return undefined;
const nextPath = [...path, prop];
const envKey = buildKey(nextPath);
const runtimeRaw = readRuntime(envKey);
if (typeof runtimeRaw === "string" && runtimeRaw.length === 0)
throw missing(envKey, true);
const runtimeValue = parseRuntime(runtimeRaw);
if (typeof runtimeValue !== "undefined") {
if (typeof runtimeValue === "string" && runtimeValue.length === 0)
throw missing(envKey, true);
return runtimeValue;
}
const hasProp =
model && Object.prototype.hasOwnProperty.call(model, prop);
if (!hasProp) throw missing(envKey);
const modelValue = model[prop];
if (typeof modelValue === "undefined") return undefined;
if (modelValue === "") throw missing(envKey);
if (
modelValue &&
typeof modelValue === "object" &&
!Array.isArray(modelValue)
) {
return createNestedProxy(modelValue, nextPath);
}
return modelValue;
},
ownKeys() {
return model ? Reflect.ownKeys(model) : [];
},
getOwnPropertyDescriptor(_target, prop) {
if (!model) return undefined;
if (Object.prototype.hasOwnProperty.call(model, prop)) {
return {
enumerable: true,
configurable: true,
} as PropertyDescriptor;
}
return undefined;
},
};
return new Proxy({}, handler);
};
const handler: ProxyHandler<any> = {
get(target, prop, receiver) {
if (typeof prop !== "string")
return Reflect.get(target, prop, receiver);
const hasModelProp = Object.prototype.hasOwnProperty.call(
modelRoot,
prop
);
if (!hasModelProp) return Reflect.get(target, prop, receiver);
const envKey = buildKey([prop]);
const runtimeRaw = readRuntime(envKey);
if (typeof runtimeRaw === "string" && runtimeRaw.length === 0)
throw missing(envKey, true);
const runtimeValue = parseRuntime(runtimeRaw);
if (typeof runtimeValue !== "undefined") {
if (typeof runtimeValue === "string" && runtimeValue.length === 0)
throw missing(envKey, true);
return runtimeValue;
}
const modelValue = modelRoot[prop];
if (
modelValue &&
typeof modelValue === "object" &&
!Array.isArray(modelValue)
) {
return createNestedProxy(modelValue, [prop]);
}
if (typeof modelValue === "undefined")
return Reflect.get(target, prop, receiver);
const actual = Reflect.get(target, prop);
if (typeof actual === "undefined" || actual === "")
throw missing(envKey, actual === "");
return actual;
},
};
return new Proxy(base, handler) as EnvironmentInstance<T>;
}
/**
* @protected
* @static
* @description Retrieves or creates the singleton instance of the Environment class.
* @summary Ensures only one {@link Environment} instance is created, wrapping it in a proxy to compose ENV keys on demand.
* @template E
* @param {...unknown[]} args - Arguments forwarded to the factory when instantiating the singleton.
* @return {E} Singleton environment instance.
*/
protected static instance<E extends Environment<any>>(
...args: unknown[]
): E {
if (!Environment._instance) {
const base = Environment.factory(...args) as E;
const proxied = new Proxy(base as any, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (value === EmptyValue) return undefined;
// If the property exists on the instance but resolves to undefined, return undefined (no proxy)
if (
typeof prop === "string" &&
Object.prototype.hasOwnProperty.call(target, prop)
) {
if (typeof value === "undefined") return undefined;
}
if (typeof value !== "undefined") return value;
if (typeof prop === "string") {
// Avoid interfering with logging config lookups for optional fields like 'app'
if (prop === "app") return undefined;
return Environment.buildEnvProxy(undefined, [prop]);
}
return value;
},
});
Environment._instance = proxied as any;
}
return Environment._instance as E;
}
/**
* @static
* @description Accumulates the given value into the environment.
* @summary Adds new properties, hiding raw descriptors to avoid leaking enumeration semantics.
* @template T
* @template V
* @param {V} value - Object to merge into the environment.
* @return {Environment} Updated environment reference.
*/
static accumulate<V extends object, TBase extends object = object>(
value: V
): EnvironmentInstance<TBase & V> {
const instance = Environment.instance<Environment<TBase & V>>();
Object.keys(instance as any).forEach((key) => {
const desc = Object.getOwnPropertyDescriptor(instance as any, key);
if (desc && desc.configurable && desc.enumerable) {
Object.defineProperty(instance as any, key, {
...desc,
enumerable: false,
});
}
});
return instance.accumulate(value) as unknown as EnvironmentInstance<
TBase &
V
>;
}
/**
* @description Retrieves a value using a dot-path key from the accumulated environment.
* @summary Delegates to the singleton instance to access stored configuration.
* @param {string} key - Key to resolve from the environment store.
* @return {unknown} Stored value corresponding to the provided key.
*/
static get(key: string) {
return Environment._instance.get(key);
}
/**
* @description Builds a proxy that composes environment keys for nested properties.
* @summary Allows chained property access to emit uppercase ENV identifiers while honoring existing runtime overrides.
* @param {any} current - Seed model segment used when projecting nested structures.
* @param {string[]} path - Accumulated path segments leading to the proxy.
* @return {any} Proxy that resolves environment values or composes additional proxies for deeper paths.
*/
private static buildEnvProxy(current: any, path: string[]): any {
const buildKey = (p: string[]) =>
p.map((seg) => toENVFormat(seg)).join(ENV_PATH_DELIMITER);
// Helper to read from the active environment given a composed key
const readEnv = (key: string): unknown => {
return Environment.readRuntimeEnv(key);
};
const handler: ProxyHandler<any> = {
get(_target, prop: string | symbol) {
if (prop === Symbol.toPrimitive) {
return () => buildKey(path);
}
if (prop === "toString") {
return () => buildKey(path);
}
if (prop === "valueOf") {
return () => buildKey(path);
}
if (typeof prop === "symbol") return undefined;
const hasProp =
!!current && Object.prototype.hasOwnProperty.call(current, prop);
const nextModel = hasProp ? (current as any)[prop] : undefined;
const nextPath = [...path, prop];
const composedKey = buildKey(nextPath);
// If an ENV value exists for this path, return it directly
const envValue = readEnv(composedKey);
if (typeof envValue !== "undefined") return envValue;
// Otherwise, if the model has an object at this path, keep drilling with a proxy
const isNextObject = nextModel && typeof nextModel === "object";
if (isNextObject) return Environment.buildEnvProxy(nextModel, nextPath);
// If the model marks this leaf as an empty string, treat as undefined (no proxy)
if (hasProp && nextModel === "") return undefined;
// If the model explicitly contains the property with value undefined, treat as undefined (no proxy)
if (hasProp && typeof nextModel === "undefined") return undefined;
// Always return a proxy for further path composition when no ENV value;
// do not surface primitive model defaults here (this API is for key composition).
return Environment.buildEnvProxy(undefined, nextPath);
},
ownKeys() {
return current ? Reflect.ownKeys(current) : [];
},
getOwnPropertyDescriptor(_t, p) {
if (!current) return undefined as any;
if (Object.prototype.hasOwnProperty.call(current, p)) {
return { enumerable: true, configurable: true } as PropertyDescriptor;
}
return undefined as any;
},
};
const target = {} as any;
return new Proxy(target, handler);
}
/**
* @static
* @description Retrieves the keys of the environment, optionally converting them to ENV format.
* @summary Gets all keys in the environment, with an option to format them for environment variables.
* @param {boolean} [toEnv=true] - Whether to convert the keys to ENV format.
* @return {string[]} An array of keys from the environment.
*/
static keys(toEnv: boolean = true): string[] {
return Environment.instance()
.keys()
.map((k) => (toEnv ? toENVFormat(k) : k));
}
private static mergeModel(
model: Record<string, any>,
key: string,
value: any
) {
if (!model) return;
if (value && typeof value === "object" && !Array.isArray(value)) {
const existing = model[key];
const target =
existing && typeof existing === "object" && !Array.isArray(existing)
? existing
: {};
model[key] = target;
Object.entries(value).forEach(([childKey, childValue]) => {
Environment.mergeModel(target, childKey, childValue);
});
return;
}
model[key] = value;
}
private static readRuntimeEnv(key: string): unknown {
if (isBrowser()) {
const env = (
globalThis as typeof globalThis & {
[BrowserEnvKey]?: Record<string, unknown>;
}
)[BrowserEnvKey];
return env ? env[key] : undefined;
}
return (globalThis as any)?.process?.env?.[key];
}
private static missingEnvError(key: string, empty: boolean): Error {
const suffix = empty ? "an empty string" : "undefined";
return new Error(
`Environment variable ${key} is required but was ${suffix}.`
);
}
}
/**
* @description Singleton environment instance seeded with default logging configuration.
* @summary Combines {@link DefaultLoggingConfig} with runtime environment variables to provide consistent logging defaults across platforms.
* @const LoggedEnvironment
* @memberOf module:Logging
*/
export const LoggedEnvironment = Environment.accumulate(
Object.assign({}, DefaultLoggingConfig, {
env:
(isBrowser() && (globalThis as any)[BrowserEnvKey]
? (globalThis as any)[BrowserEnvKey]["NODE_ENV"]
: (globalThis as any).process.env["NODE_ENV"]) || "development",
})
);
Source