quickload
Version:
A simple utility to load json config files.
250 lines (249 loc) • 8.23 kB
JavaScript
/**
* @fileoverview Configuration Loader
* A robust and type-safe configuration loader for Node.js applications with
* support for environment-specific configs, validation, and caching.
* Compatible with both Backend and Frontend environments.
*
* Features:
* - Environment-specific configuration management
* - JSON Schema validation using Ajv
* - Configuration caching for performance optimization
* - Deep merging of configuration objects
* - Async/Promise-based operations
* - TypeScript support with strict typing
* - Comprehensive error handling
*
* @module config-loader
* @author Nasr Aldin <ns@nasraldin.com>
* @copyright 2024 Nasr Aldin
* @license MIT
* @version 1.0.0
*
* @see {@link https://github.com/nasraldin/quickload|GitHub Repository}
* @see {@link https://www.npmjs.com/package/quickload|NPM Package}
*/
import Ajv from 'ajv';
import ajvFormats from 'ajv-formats';
import { ENV, isSSR } from './env.checker';
let fsPromises = null;
let pathModule = null;
// Initialize Node.js modules only on server side
const loadServerModules = async () => {
if (typeof process === 'undefined')
return false;
try {
if (!fsPromises) {
fsPromises = await import('fs/promises').catch(() => null);
}
if (!pathModule) {
pathModule = await import('path').catch(() => null);
}
return true;
}
catch (error) {
// eslint-disable-next-line no-console
console.warn('Server-side modules not available:', error);
return false;
}
};
/**
* Configuration error codes
*/
export var ConfigErrorCode;
(function (ConfigErrorCode) {
ConfigErrorCode["INVALID_SCHEMA"] = "INVALID_SCHEMA";
ConfigErrorCode["FILE_NOT_FOUND"] = "FILE_NOT_FOUND";
ConfigErrorCode["PARSE_ERROR"] = "PARSE_ERROR";
ConfigErrorCode["VALIDATION_ERROR"] = "VALIDATION_ERROR";
ConfigErrorCode["MERGE_ERROR"] = "MERGE_ERROR";
ConfigErrorCode["LOAD_ERROR"] = "LOAD_ERROR";
ConfigErrorCode["FILE_READ_ERROR"] = "FILE_READ_ERROR";
ConfigErrorCode["DIR_READ_ERROR"] = "DIR_READ_ERROR";
})(ConfigErrorCode || (ConfigErrorCode = {}));
/**
* Custom error class for configuration-related errors
* @class ConfigurationError
* @extends Error
*/
export class ConfigurationError extends Error {
code;
errors;
/**
* @param message - Error message
* @param errors - Additional error details
*/
constructor(message, code, errors) {
super(message);
this.code = code;
this.errors = errors;
this.name = 'ConfigurationError';
}
}
/** Cache storage for configurations */
const configCache = new Map();
const defaultConfigDir = 'config';
/**
* Type-safe configuration loader
*/
export class ConfigLoader {
options;
ajv;
constructor(options) {
this.options = {
configDir: options.configDir ?? defaultConfigDir,
schema: options.schema,
cache: options.cache ?? false,
defaultEnv: options.defaultEnv ?? ENV.Development,
includeBaseConfig: options.includeBaseConfig ?? false,
};
this.ajv = new Ajv({
allErrors: true,
strict: true,
strictTypes: true,
strictTuples: true,
});
ajvFormats(this.ajv);
}
/**
* Loads and validates configuration
*/
async load() {
const env = process.env.NODE_ENV ?? this.options.defaultEnv;
if (!isSSR()) {
// Client-side implementation
return this.validateConfig({});
}
const isServerModulesLoaded = await loadServerModules();
if (!isServerModulesLoaded) {
// eslint-disable-next-line no-console
console.warn('Server-side modules not available, returning empty config');
return this.validateConfig({});
}
try {
const config = await this.loadAndMergeConfigs(env);
return this.validateConfig(config);
}
catch (error) {
if (error instanceof ConfigurationError) {
throw error;
}
throw new ConfigurationError('Failed to load configuration', ConfigErrorCode.LOAD_ERROR, error);
}
}
/**
* Reads and parses a JSON configuration file
*/
async loadFile(filePath) {
if (!isSSR() || !fsPromises) {
return {};
}
try {
const content = await fsPromises.readFile(filePath, 'utf-8');
return JSON.parse(content);
}
catch (err) {
if (err instanceof Error) {
throw new ConfigurationError(`Failed to load file: ${filePath}`, ConfigErrorCode.FILE_READ_ERROR, err);
}
throw err;
}
}
/**
* Loads configuration files from directory
*/
async loadConfigFiles(dirPath) {
if (!isSSR() || !fsPromises || !pathModule) {
return {};
}
try {
const files = await fsPromises.readdir(dirPath);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const configs = await Promise.all(jsonFiles.map(async (file) => {
const filePath = pathModule?.join(dirPath, file);
if (!filePath) {
return {};
}
return this.loadFile(filePath);
}));
return configs.reduce((acc, config) => this.mergeConfigs(acc, config), {});
}
catch (err) {
if (err instanceof Error && err.code === 'ENOENT') {
return {};
}
throw new ConfigurationError(`Failed to load config files from directory: ${dirPath}`, ConfigErrorCode.DIR_READ_ERROR, err);
}
}
/**
* Deep merges two configuration objects
* @param target - Target configuration object
* @param source - Source configuration object to merge
* @returns Merged configuration object
*/
mergeConfigs(target, source) {
return Object.entries(source).reduce((merged, [key, sourceValue]) => {
const targetValue = merged[key];
// If both target and source values are objects, recursively merge them
if (this.isConfigObject(sourceValue) && this.isConfigObject(targetValue)) {
return {
...merged,
[key]: this.mergeConfigs(targetValue, sourceValue),
};
}
// If source value is not undefined, update or add it to the merged config
if (sourceValue !== undefined) {
return {
...merged,
[key]: sourceValue,
};
}
return merged;
}, { ...target });
}
/**
* Type guard for config objects
*/
isConfigObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Loads and merges configuration files
*/
async loadAndMergeConfigs(env) {
if (!pathModule) {
return {};
}
const defaultPath = pathModule.join(this.options.configDir, 'default');
const envPath = pathModule.join(this.options.configDir, env);
const [defaultConfig, envConfig] = await Promise.all([
this.loadConfigFiles(defaultPath),
this.loadConfigFiles(envPath),
]);
return this.mergeConfigs(defaultConfig, envConfig);
}
/**
* Validates configuration against schema
*/
validateConfig(config) {
const validate = this.ajv.compile(this.options.schema);
if (!validate(config)) {
throw new ConfigurationError('Configuration validation failed', ConfigErrorCode.VALIDATION_ERROR, validate.errors);
}
return config;
}
}
/**
* Helper function to create a config loader with default options
*/
export function loadConfig(schema, options = {}) {
return new ConfigLoader({
schema,
...options,
});
}
/**
* Clears the configuration cache
*/
export function clearConfigCache() {
configCache.clear();
}