UNPKG

@nestjs/config

Version:

Configuration for NestJS: load .env files and custom configuration, validate it at startup, and read it through an injectable ConfigService.

376 lines (375 loc) • 17.5 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var ConfigModule_1; import { Module } from '@nestjs/common'; import { isObject } from '@nestjs/common/utils/shared.utils.js'; import { expand } from 'dotenv-expand'; import { resolve } from 'path'; import { ConfigLoadedModule, } from './config-loaded.module.js'; import { ConfigHostModule } from './config-host.module.js'; import { CONFIGURATION_LOADED, CONFIGURATION_LOADER, CONFIGURATION_SERVICE_TOKEN, CONFIGURATION_TOKEN, VALIDATED_ENV_LOADER, VALIDATED_ENV_PROPNAME, } from './config.constants.js'; import { ConfigService } from './config.service.js'; import { getDefaultParser } from './utils/index.js'; import { createConfigProvider } from './utils/create-config-factory.util.js'; import { getRegistrationToken } from './utils/get-registration-token.util.js'; import { mergeConfigObject } from './utils/merge-configs.util.js'; import { hasOwn } from './utils/property-path.util.js'; import { readEnvFiles } from './utils/read-env-files.util.js'; import { getDeclaredKeys, validateWithStandardSchema, } from './utils/validate-with-schema.util.js'; import { ValidatedEnv } from './utils/validated-env.util.js'; // Name under which "expandVariables" passes the value a variable had before // the env file was loaded, for the variable's references to itself const PREVIOUS_VALUE_PREFIX = 'NESTJS_CONFIG_PREVIOUS_VALUE_OF_'; /** * @publicApi */ let ConfigModule = ConfigModule_1 = class ConfigModule { /** * This promise resolves when parser completes loading environment variables. * When "ignoreEnvFile" is set to true, then it will resolve immediately after the * "ConfigModule#forRoot" method is called. * If "forRoot" is called more than once, it resolves once every call that is * in progress has loaded its environment variables. */ static get envVariablesLoaded() { return this._envVariablesLoaded.then(() => this.waitForPendingEnvLoads()); } /** * Loads environment variables based on the "ignoreEnvFile" flag and "envFilePath" value. * Additionally, registers custom configurations globally. * @param options */ static async forRoot(options = {}) { const loadingEnv = this.loadEnv(options); this.pendingEnvLoads.add(loadingEnv); let loadedEnv; try { loadedEnv = await loadingEnv; } finally { this.pendingEnvLoads.delete(loadingEnv); } this.environmentVariablesLoadedSignal(); return this.createDynamicModule(options, loadedEnv); } /** * Registers configuration object (partial registration). * @param config */ static forFeature(config) { const configProvider = createConfigProvider(config); const serviceProvider = { provide: ConfigService, useFactory: async (configService, _loader, configLoaded) => { await configLoaded?.promise; return configService; }, inject: [ CONFIGURATION_SERVICE_TOKEN, CONFIGURATION_LOADER, { token: CONFIGURATION_LOADED, optional: true }, ], }; return { module: ConfigModule_1, providers: [ configProvider, serviceProvider, { provide: CONFIGURATION_LOADER, useFactory: (host, partialConfig) => { this.mergePartial(host, partialConfig, configProvider); }, inject: [CONFIGURATION_TOKEN, configProvider.provide], }, ], exports: [ConfigService, configProvider.provide], }; } static async loadEnv(options) { const envFilePaths = Array.isArray(options.envFilePath) ? options.envFilePath : [options.envFilePath || resolve(process.cwd(), '.env')]; const parser = options.parser ?? getDefaultParser(); // Variable that still has the value an earlier "forRoot" loaded is not // predefined (e.g. app bootstrapped again in the same process) const predefinedEnv = new Map(options.override ? [] : Object.entries(process.env).filter(([key, value]) => this.loadedEnv.get(key) !== value)); const expandOptions = typeof options.expandVariables === 'object' ? options.expandVariables : {}; const envFileConfig = options.ignoreEnvFile ? {} : readEnvFiles(envFilePaths, parser); let config = { ...envFileConfig }; // Predefined variable that is empty is replaced by the expanded value from // the env file, same as in dotenv-expand let replaceableKeys = new Set(); if (options.expandVariables) { config = this.expandVariables(config, expandOptions, options.override); replaceableKeys = new Set(options.override ? [] : Object.keys(config).filter(key => process.env[key] === '')); } const fileKeys = new Set(Object.keys(config)); const env = { ...process.env }; replaceableKeys.forEach(key => delete env[key]); const validatePredefined = !options.ignoreEnvVars && options.validatePredefined !== false; if (validatePredefined) { config = options.override ? { ...env, ...config } : { ...config, ...env }; } // Copy of the validator's input, taken before a "validate" function can // mutate it const input = { ...config }; // Variables that only come from process.env const envOnlyKeys = new Set(validatePredefined ? Object.keys(env).filter(key => !fileKeys.has(key)) : []); let validatedEnvConfig = undefined; if (options.validate) { validatedEnvConfig = await options.validate(config); } else if (options.validationSchema) { const { error, value: validatedConfig } = await validateWithStandardSchema(options.validationSchema, config, options.validationOptions); if (error) { throw new Error(`Config validation error: ${error.message}`); } validatedEnvConfig = this.mergeUndeclaredVariables(input, validatedConfig, getDeclaredKeys(options.validationSchema), // "skipProcessEnv" keeps undeclared variables of process.env out options.skipProcessEnv ? envOnlyKeys : new Set()); } let validatedEnv; if (isObject(validatedEnvConfig)) { const hiddenKeys = new Set(); for (const key of Object.keys(validatedEnvConfig)) { // "undefined" is a removal only if the validator was given a value if (validatedEnvConfig[key] === undefined && input[key] === undefined) { hiddenKeys.add(key); } } // Unless predefined variables are validated, "get" returns them as // they are, not the different values from the env file that they take // precedence over (unless it skips process.env) if (!validatePredefined && !options.override && !options.skipProcessEnv) { for (const [key, value] of predefinedEnv) { if (!replaceableKeys.has(key) && value !== input[key]) { hiddenKeys.add(key); } } } validatedEnv = { values: validatedEnvConfig, hiddenKeys, convertedKeys: this.getConvertedKeys(validatedEnvConfig, input), }; this.assignVariablesToProcess(validatedEnvConfig, options.override, replaceableKeys); } else { this.assignVariablesToProcess(config, options.override, replaceableKeys); } return { envFileConfig, predefinedEnv, expandOptions, validatedEnv }; } static async createDynamicModule(options, { envFileConfig, predefinedEnv, expandOptions, validatedEnv }) { const isConfigToLoad = options.load && options.load.length; const configFactory = await Promise.all(options.load || []); const configProviders = configFactory .map(factory => createConfigProvider(factory)) .filter(item => item); const configProviderTokens = configProviders.map(item => item.provide); const providers = [...configProviders]; if (isConfigToLoad) { providers.push({ provide: CONFIGURATION_LOADER, useFactory: (host, ...configurations) => { configurations.forEach((item, index) => this.mergePartial(host, item, configProviders[index])); }, inject: [CONFIGURATION_TOKEN, ...configProviderTokens], }); } if (validatedEnv) { providers.push({ provide: VALIDATED_ENV_LOADER, useFactory: (host) => { (host[VALIDATED_ENV_PROPNAME] ??= new ValidatedEnv()).add(validatedEnv.values, validatedEnv.hiddenKeys, validatedEnv.convertedKeys); }, inject: [CONFIGURATION_TOKEN], }); } providers.push({ provide: ConfigService, useFactory: (configService, configLoaded) => { const untypedConfigService = configService; if (options.cache) { untypedConfigService.isCacheEnabled = true; } if (options.skipProcessEnv) { untypedConfigService.skipProcessEnv = true; } // Env file definitions are only used by "set", to update expanded // variables. They are the ones loaded (and validated) here, the files // are not read again untypedConfigService.setEnvFileConfig(options.expandVariables ? envFileConfig : {}); untypedConfigService.setExpansionEnv(expandOptions.processEnv); untypedConfigService.setPredefinedEnv(predefinedEnv, this.loadedEnv); configLoaded.resolve(); return configService; }, // Waits for the configuration to be loaded and validated inject: [ CONFIGURATION_SERVICE_TOKEN, CONFIGURATION_LOADED, ...(isConfigToLoad ? [CONFIGURATION_LOADER] : []), ...(validatedEnv ? [VALIDATED_ENV_LOADER] : []), ], }); return { module: ConfigModule_1, global: options.isGlobal, imports: [ConfigLoadedModule], providers, exports: [ConfigService, ...configProviderTokens], }; } static async waitForPendingEnvLoads() { while (this.pendingEnvLoads.size > 0) { await Promise.allSettled(this.pendingEnvLoads); } } static expandVariables(config, expandOptions, override) { // "dotenv-expand" writes the expanded values to "processEnv". Unless the // options name a target, it gets a copy of process.env: the values are // assigned to process.env once they have been validated const target = expandOptions.processEnv; const processEnv = { ...(target ?? process.env) }; if (override) { // "dotenv-expand" gives precedence to the values that are already // present in "processEnv" over the ones parsed from the file, which // would silently undo the "override" option. The variables the file // defines are removed from the copy, so that they are expanded from the // file instead (and a self-reference doesn't expand forever). A // variable's references to itself still mean the value it had before // (e.g. "PATH=/opt/bin:$PATH"), under another name for (const key of Object.keys(config)) { const previousValue = processEnv[key]; delete processEnv[key]; if (typeof config[key] !== 'string' || previousValue === undefined) { continue; } const alias = `${PREVIOUS_VALUE_PREFIX}${key}`; const value = this.renameSelfReferences(config[key], key, alias); if (value !== config[key]) { config[key] = value; processEnv[alias] = previousValue; } } } const expanded = expand({ ...expandOptions, parsed: config, processEnv }).parsed || config; if (target) { Object.assign(target, expanded); } return expanded; } /** * Object schemas strip the properties they don't declare (e.g. Zod), so the * undeclared variables are merged back in to keep them available through * process.env and ConfigService. Variables the schema declares are taken * from its output only, so a variable it removed (e.g. Joi's `strip()`) * stays removed. */ static mergeUndeclaredVariables(input, output, declaredKeys, excludedKeys) { const isDeclared = (key) => declaredKeys ? declaredKeys.has(key) : hasOwn(output, key); const merged = {}; for (const [key, value] of Object.entries(input)) { if (!isDeclared(key) && !excludedKeys.has(key)) { merged[key] = value; } } for (const [key, value] of Object.entries(output)) { if (isDeclared(key) || !excludedKeys.has(key)) { merged[key] = value; } } return merged; } /** * Returns the variables the validator converted (or added), as opposed to * the ones it passed through as they were */ static getConvertedKeys(validatedEnv, input) { return new Set(Object.keys(validatedEnv).filter(key => !hasOwn(input, key) || validatedEnv[key] !== input[key])); } /** * Replaces the references a value makes to its own variable ("key") with * references to "alias", in the syntax of dotenv-expand */ static renameSelfReferences(value, key, alias) { // Names with an operator character can't be referenced as a whole if (/[:+-]/.test(key)) { return value; } const name = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); let renamed = value.replace(new RegExp(`(?<!\\\\)\\$\\{${name}(?=[:+}-])`, 'g'), () => `\${${alias}`); if (/^[A-Za-z_]\w*$/.test(key)) { renamed = renamed.replace(new RegExp(`(?<!\\\\)\\$${name}(?!\\w)`, 'g'), () => `\${${alias}}`); } return renamed; } static assignVariablesToProcess(config, override, replaceableKeys = new Set()) { if (!isObject(config)) { return; } const keys = override ? Object.keys(config) : Object.keys(config).filter(key => !(key in process.env) || replaceableKeys.has(key)); keys.forEach(key => { const value = config[key]; if (typeof value === 'string') { process.env[key] = value; } else if (typeof value === 'boolean' || typeof value === 'number') { process.env[key] = `${value}`; } else { return; } this.loadedEnv.set(key, process.env[key]); }); } static mergePartial(host, item, provider) { const factoryRef = provider.useFactory; const token = getRegistrationToken(factoryRef); mergeConfigObject(host, item, token); } }; ConfigModule._envVariablesLoaded = new Promise(resolve => (ConfigModule_1.environmentVariablesLoadedSignal = resolve)); ConfigModule.pendingEnvLoads = new Set(); // Variables that "forRoot" (or "ConfigService#set") wrote to process.env, // with the value they were given ConfigModule.loadedEnv = new Map(); ConfigModule = ConfigModule_1 = __decorate([ Module({ imports: [ConfigHostModule], providers: [ { provide: ConfigService, // Waits until "forRoot" has loaded the configuration (e.g. from async // "load" factories), so that it isn't read before it's complete useFactory: async (configService, configLoaded) => { await configLoaded?.promise; return configService; }, inject: [ CONFIGURATION_SERVICE_TOKEN, { token: CONFIGURATION_LOADED, optional: true }, ], }, ], exports: [ConfigHostModule, ConfigService], }) ], ConfigModule); export { ConfigModule };