merged-config
v1.0.0
Published
A flexible, hierarchical configuration service with remote config support, caching, and runtime overrides
Maintainers
Readme
merged-config
A flexible, hierarchical configuration management service with remote config support, caching, runtime overrides, and React integration.
Features
- Hierarchical Configuration - Merge configs from multiple sources with clear precedence
- Remote Config - Fetch and cache configuration from a remote API
- Runtime Overrides - Modify config values at runtime (user and debug overrides)
- Development Mode Support - Special debug overrides that persist across app restarts
- React Integration - Context provider and hooks for seamless React/React Native integration
- Platform Agnostic - Works with AsyncStorage (React Native), localStorage (Web), or custom storage
- TypeScript - Full type safety with comprehensive TypeScript definitions
- Wildcard Defaults - Support for default values with wildcard matching
- Subscription System - React to config changes anywhere in your app
Installation
npm install merged-configPeer Dependencies
For React/React Native projects:
npm install reactFor React Native with AsyncStorage:
npm install @react-native-async-storage/async-storageQuick Start
1. Initialize the Config Service
import { ConfigService, AsyncStorageAdapter } from 'merged-config';
import AsyncStorage from '@react-native-async-storage/async-storage';
import localConfig from './local-config.json';
import appSettings from './app-settings.json';
import debugConfig from './debug-config.json'; // optional
// Initialize the singleton
ConfigService.initialize({
localConfig,
appSettings: {
remoteConfigUrl: appSettings.remoteConfigUrl,
remoteConfigTTL: appSettings.remoteConfigTTL,
},
debugConfig,
storageAdapter: new AsyncStorageAdapter(AsyncStorage),
isDev: __DEV__, // or process.env.NODE_ENV === 'development'
logger: (tag, level, message, data) => {
console.log(`[${tag}] [${level}] ${message}`, data);
},
});
// Get the instance
const configService = ConfigService.getInstance();2. Use in React/React Native
import { ConfigProvider, useConfig, useConfigValue } from 'merged-config/react';
// Wrap your app with ConfigProvider
function App() {
return (
<ConfigProvider>
<YourApp />
</ConfigProvider>
);
}
// Use config in components
function MyComponent() {
const { config, meta, status, reload } = useConfig();
// Or get a specific value
const apiUrl = useConfigValue('api.weather.url', 'https://default.api.com');
return (
<div>
<p>Config Status: {status}</p>
<p>Config Source: {meta.source}</p>
<p>API URL: {apiUrl}</p>
<button onClick={reload}>Reload Config</button>
</div>
);
}3. Use Without React
import { ConfigService } from 'merged-config';
const configService = ConfigService.getInstance();
// Get config values
const apiUrl = configService.get('api.weather.url', 'https://default.api.com');
const timeout = configService.get('api.weather.timeout', 5000);
// Subscribe to changes
const unsubscribe = configService.subscribe(() => {
console.log('Config updated!');
const newUrl = configService.get('api.weather.url');
});
// Cleanup
unsubscribe();Configuration Files
app-settings.json
Contains settings for remote config loading:
{
"remoteConfigUrl": "https://api.example.com/config",
"remoteConfigTTL": 3600000,
"remoteConfigRetryDelay": 30000
}local-config.json
Your base configuration with fallback values:
{
"app": {
"name": "Weather App",
"version": "1.0.0",
"defaults": {
"api": {
"*": {
"units": "e",
"language": "en-US",
"format": "json",
"timeout": 5000
}
}
}
},
"api": {
"weather": {
"url": "https://api.weather.com",
"timeout": 10000
}
}
}debug-config.json (DEV mode only)
Overrides for development/testing:
{
"api": {
"weather": {
"url": "https://staging.api.weather.com"
}
},
"debug": {
"enableLogging": true,
"bypassCache": false
}
}Config Merge Order (Lowest to Highest Priority)
- Local Config (
local-config.json) - Base configuration - Remote Config - Fetched from API, cached locally
- Debug Config (
debug-config.json) - DEV mode only - User Overrides - Runtime-only, set via
updateConfigByPath()ormergeConfig() - Debug Overrides - DEV mode only, set via
setDebugOverride(), persisted in cache
API Reference
ConfigService Methods
get(path: string, defaultValue?: any): any
Get a config value by dot-separated path.
const url = configService.get('api.weather.url');
const timeout = configService.get('api.weather.timeout', 5000);
const allConfig = configService.get(''); // Get entire config with _metaupdateConfigByPath(path: string, value: any): Promise<void>
Update a config value at runtime (not persisted).
await configService.updateConfigByPath('api.weather.timeout', 15000);mergeConfig(configObject: any): Promise<void>
Merge an object into the config at runtime (not persisted).
await configService.mergeConfig({
api: {
weather: {
timeout: 15000,
retries: 3
}
}
});getUserOverrides(): any
Get current runtime user overrides.
const overrides = configService.getUserOverrides();clearUserOverrides(): Promise<void>
Clear all user overrides.
await configService.clearUserOverrides();subscribe(fn: () => void): () => void
Subscribe to config changes. Returns an unsubscribe function.
const unsubscribe = configService.subscribe(() => {
console.log('Config changed!');
});
// Later...
unsubscribe();reload(background?: boolean): Promise<void>
Reload config from remote source.
await configService.reload(); // Throws on error
await configService.reload(true); // Silent background reloadisReady(): boolean
Check if config is ready (initialized).
if (configService.isReady()) {
// Config is ready
}waitForReady(): Promise<void>
Wait for config to be ready.
await configService.waitForReady();
// Config is now readyDEV Mode Methods
These methods only work when isDev is true:
setDebugOverride(path: string, value: any): void
Set a debug override (persisted across app restarts in DEV mode).
configService.setDebugOverride('api.weather.url', 'https://localhost:3000');clearDebugOverrides(): void
Clear all debug overrides.
configService.clearDebugOverrides();getDebugOverrides(): any
Get current debug overrides.
const overrides = configService.getDebugOverrides();toggleCacheBypass(): boolean
Toggle cache bypass for debugging. Returns new state.
const enabled = configService.toggleCacheBypass();React Hooks
useConfig()
Access the full config context.
const { config, meta, status, reload } = useConfig();Returns:
config- The full config object with_metameta- Config metadata (source, timestamp, etc.)status- Loading state:'loading' | 'ready' | 'error'reload- Function to reload config
useConfigValue<T>(path: string, defaultValue?: T): T
Get a specific config value that updates on changes.
const apiUrl = useConfigValue<string>('api.weather.url', 'https://default.com');
const timeout = useConfigValue<number>('api.weather.timeout', 5000);Storage Adapters
AsyncStorageAdapter (React Native)
import { AsyncStorageAdapter } from 'merged-config';
import AsyncStorage from '@react-native-async-storage/async-storage';
const adapter = new AsyncStorageAdapter(AsyncStorage);LocalStorageAdapter (Web)
import { LocalStorageAdapter } from 'merged-config';
const adapter = new LocalStorageAdapter();MemoryStorageAdapter (Testing/No Persistence)
import { MemoryStorageAdapter } from 'merged-config';
const adapter = new MemoryStorageAdapter();Custom Storage Adapter
Implement the StorageAdapter interface:
import type { StorageAdapter } from 'merged-config';
class MyStorageAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
// Your implementation
}
async setItem(key: string, value: string): Promise<void> {
// Your implementation
}
async removeItem(key: string): Promise<void> {
// Your implementation
}
}Wildcard Defaults
The config service supports wildcard defaults using * in the app.defaults section:
{
"app": {
"defaults": {
"api": {
"*": {
"units": "e",
"language": "en-US"
}
}
}
}
}Now any API config can fall back to these defaults:
// Even if api.weather.units doesn't exist, it returns "e"
const units = configService.get('api.weather.units', 'imperial'); // Returns "e"
const lang = configService.get('api.forecast.language'); // Returns "en-US"Utility Functions
getConfigValue(config: any, path: string, defaultValue?: any): any
Standalone function to get a value from a config object:
import { getConfigValue } from 'merged-config';
const config = { api: { url: 'https://example.com' } };
const url = getConfigValue(config, 'api.url', 'default');deepMerge(target: any, source: any): any
Deep merge two objects (arrays are replaced, not merged):
import { deepMerge } from 'merged-config';
const merged = deepMerge(
{ a: 1, b: { c: 2 } },
{ b: { d: 3 } }
);
// Result: { a: 1, b: { c: 2, d: 3 } }Best Practices
1. Initialize Early
Initialize the ConfigService as early as possible in your app lifecycle, before rendering any components.
2. Use Wildcard Defaults
Define common defaults under app.defaults with wildcard paths to reduce duplication:
{
"app": {
"defaults": {
"api": {
"*": {
"timeout": 5000,
"retries": 3
}
}
}
}
}3. Wait for Ready in Critical Paths
For services that need config on initialization:
class MyService {
async init() {
await ConfigService.getInstance().waitForReady();
const apiUrl = ConfigService.getInstance().get('api.myservice.url');
// Now safe to use config
}
}4. Use React Context for Components
Always use useConfig() or useConfigValue() in React components to automatically re-render on config changes.
5. Subscribe for Non-React Code
In services or utilities, use subscribe() to react to config changes:
class CacheManager {
constructor() {
const configService = ConfigService.getInstance();
this.unsubscribe = configService.subscribe(() => {
this.updateCacheSettings();
});
}
destroy() {
this.unsubscribe();
}
}6. Keep User Overrides Runtime-Only
User overrides (via updateConfigByPath or mergeConfig) are intentionally not persisted. Use debug overrides in DEV mode if you need persistence.
Architecture
┌─────────────────────────────────────────────────────────┐
│ ConfigService │
│ (Singleton) │
└─────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Local │ │ Remote │ │ Debug │
│ Config │ │ Config │ │ Config │
│ (Base) │ │ (Cached) │ │ (DEV) │
└──────────┘ └──────────────┘ └──────────┘
│ │ │
└───────────────┼───────────────┘
│
deepMerge (in order)
│
▼
┌──────────────┐
│ User │
│ Overrides │
│ (Runtime) │
└──────────────┘
│
deepMerge
│
▼
┌──────────────┐
│ Debug │
│ Overrides │
│(DEV,Cached) │
└──────────────┘
│
▼
┌──────────────┐
│ Final │
│ Config │
└──────────────┘Config Loading Process
- Load local config (base)
- Check for cached remote config
- If cached config is valid, use it
- Attempt to fetch remote config in background
- On success, cache and merge remote config
- On failure, use cached config or fallback to local
- If in DEV mode, merge debug config
- Apply user overrides (runtime-only)
- If in DEV mode, apply debug overrides (cached)
- Provide final merged config to application
License
MIT
Support
For issues and feature requests, please open an issue on GitHub.
