npm package: @rawnodes/config-loader
Description: Flexible YAML config loader with environment overrides, Zod validation, and Docker-friendly features
Author: RawNodes
Latest version: 1.7.0

# @rawnodes/config-loader

Flexible YAML configuration loader for Node.js applications with environment overrides, Zod validation, and Docker-friendly features.

## Features

- **YAML Configuration** - Load config from YAML files with environment-specific overrides
- **Environment Variables** - Replace `${VAR}` placeholders with env values
- **HashiCorp Vault** - Read secrets from Vault using `${vault:path:field}` syntax
- **AWS Secrets Manager** - Read secrets from AWS using `${aws:secret-name:field}` syntax
- **Zod Validation** - Optional schema validation with detailed error messages
- **Docker/K8s Ready** - Mount additional config files via `overrideDir`
- **Secret Masking** - Automatic masking of sensitive values in logs
- **TypeScript First** - Full type safety with generics

## Installation

```bash
pnpm add @rawnodes/config-loader
# or
npm install @rawnodes/config-loader
```

For Zod validation (optional):
```bash
pnpm add zod
```

## Quick Start

### 1. Create config files

```yaml
# config/base.yml
server:
  port: 3000
  host: localhost

database:
  host: localhost
  port: 5432
  name: myapp
```

```yaml
# config/production.yml
server:
  host: 0.0.0.0

database:
  host: ${DATABASE_HOST}
  password: ${DATABASE_PASSWORD}
```

### 2. Load configuration

```typescript
import { loadConfig } from '@rawnodes/config-loader';

interface AppConfig {
  server: { port: number; host: string };
  database: { host: string; port: number; name: string; password?: string };
}

const { config } = loadConfig<AppConfig>({
  configDir: './config',
  dotenv: true,
});

console.log(config.server.port); // 3000
```

## Configuration Options

```typescript
interface ConfigLoaderOptions<T> {
  // Directory with config files (default: process.cwd())
  configDir?: string;

  // Base config filename without extension (default: 'base')
  baseFileName?: string;

  // Environment name (default: process.env.NODE_ENV || 'local')
  environment?: string;

  // File extension (default: 'yml')
  extension?: 'yml' | 'yaml';

  // Custom post-processing function
  postProcess?: (config: T) => T;

  // Zod schema for validation
  schema?: z.ZodType<T>;

  // Logger callback (config will be logged with masked secrets)
  logger?: (message: string) => void;

  // Load .env file (default: false)
  dotenv?: boolean | { path?: string };

  // Directory with additional YAML files to merge (default: '/etc/app/config')
  // Set to false to disable
  overrideDir?: string | false;

  // Remove empty strings and empty objects from config (default: false)
  // Useful for optional fields with ${VAR:} placeholders
  stripEmpty?: boolean;

  // HashiCorp Vault options (requires loadConfigAsync)
  vault?: {
    endpoint: string;     // Vault server URL
    roleId: string;       // AppRole role_id
    secretId: string;     // AppRole secret_id
    namespace?: string;   // Optional Vault Enterprise namespace
  };

  // AWS Secrets Manager options (requires loadConfigAsync)
  aws?: {
    region: string;          // AWS region
    accessKeyId: string;     // AWS access key ID
    secretAccessKey: string; // AWS secret access key
  };
}
```

## Environment Variables

Use `${VAR}` syntax with optional defaults:

```yaml
database:
  host: ${DB_HOST:localhost}
  port: ${DB_PORT:5432}
  password: ${DB_PASSWORD}  # Required - throws if not set
```

## Optional Fields

Use `${VAR:}` (empty default) with `stripEmpty: true` to support optional fields:

```yaml
# config/base.yml
server:
  port: 3000

monitoring:
  serviceId: ${MONITORING_SERVICE_ID:}
  apiKey: ${MONITORING_API_KEY:}
  healthcheckUrls:
    cleanup: ${MONITORING_CLEANUP_URL:}
    sync: ${MONITORING_SYNC_URL:}
```

```typescript
const schema = z.object({
  server: z.object({ port: z.number() }),
  monitoring: z.object({
    serviceId: z.string(),
    apiKey: z.string(),
    healthcheckUrls: z.object({
      cleanup: z.string().url().optional(),
      sync: z.string().url().optional(),
    }).optional(),
  }).optional(),
});

const { config } = loadConfig({
  schema,
  stripEmpty: true,  // Empty strings → undefined, empty objects removed
});

// If no MONITORING_* env vars are set, config.monitoring will be undefined
```

**How `stripEmpty` works:**
- `""` (empty string) → `undefined`
- Objects with all `undefined` values → removed
- Arrays: empty strings filtered out
- Other falsy values (`0`, `false`, `null`) are preserved

## HashiCorp Vault Integration

Read secrets directly from HashiCorp Vault using AppRole authentication.

### Setup

```yaml
# config/base.yml
database:
  host: localhost
  password: ${vault:secret/data/api:DB_PASSWORD}
  port: ${vault:secret/data/api:DB_PORT:5432}  # with default value
```

```typescript
import { loadConfigAsync } from '@rawnodes/config-loader';

const { config } = await loadConfigAsync({
  configDir: './config',
  vault: {
    endpoint: 'https://vault.example.com',
    roleId: process.env.VAULT_ROLE_ID!,
    secretId: process.env.VAULT_SECRET_ID!,
    namespace: 'optional-namespace',  // for Vault Enterprise
  },
});
```

### Syntax

```
${vault:PATH:FIELD}           - Required secret
${vault:PATH:FIELD:DEFAULT}   - With default value (used if secret not found)
```

**Examples:**
```yaml
database:
  # Read DB_PASSWORD from secret/data/api
  password: ${vault:secret/data/api:DB_PASSWORD}

  # With default value (supports colons in default)
  url: ${vault:secret/data/db:URL:postgres://localhost:5432/app}

  # Mix with env variables
  host: ${DB_HOST:localhost}
```

### Notes

- Use `loadConfigAsync()` instead of `loadConfig()` when using Vault
- Secrets are cached per path during config load (multiple fields from same path = 1 API call)
- Supports Vault KV v1 and v2 secret engines

## AWS Secrets Manager Integration

Read secrets from AWS Secrets Manager.

### Setup

```yaml
# config/base.yml
database:
  password: ${aws:my-app/database:DB_PASSWORD}
  host: ${aws:my-app/database:DB_HOST:localhost}
api:
  key: ${aws:my-app/api-key}  # plain string secret (no field)
```

```typescript
import { loadConfigAsync } from '@rawnodes/config-loader';

const { config } = await loadConfigAsync({
  configDir: './config',
  aws: {
    region: 'us-east-1',
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});
```

### Syntax

```
${aws:SECRET_NAME}              - Plain string secret (entire value)
${aws:SECRET_NAME:FIELD}        - JSON secret, extract field
${aws:SECRET_NAME:FIELD:DEFAULT} - With default value
```

**Examples:**
```yaml
database:
  # JSON secret: {"DB_PASSWORD": "secret", "DB_USER": "admin"}
  password: ${aws:my-app/database:DB_PASSWORD}
  user: ${aws:my-app/database:DB_USER}

  # Plain string secret (no field)
  api_key: ${aws:my-app/api-key}

  # With default value
  host: ${aws:my-app/database:DB_HOST:localhost}
```

### Notes

- Use `loadConfigAsync()` instead of `loadConfig()` when using AWS
- Secrets are cached per secret name during config load
- Supports both JSON and plain string secrets

## Zod Validation

```typescript
import { z } from 'zod';
import { loadConfig } from '@rawnodes/config-loader';

const AppConfigSchema = z.object({
  server: z.object({
    port: z.number().min(1).max(65535),
    host: z.string(),
  }),
  database: z.object({
    host: z.string(),
    port: z.number(),
    name: z.string(),
    password: z.string().optional(),
  }),
});

type AppConfig = z.infer<typeof AppConfigSchema>;

const { config } = loadConfig<AppConfig>({
  schema: AppConfigSchema,
  logger: console.log,
});
```

## Docker/Kubernetes Override

Mount additional config files in `/etc/app/config/`:

```yaml
# /etc/app/config/01-secrets.yml
database:
  password: super-secret

# /etc/app/config/02-overrides.yml
server:
  port: 8080
```

Files are merged in alphabetical order.

## Environment Helpers

### `env`

Environment detection helpers:

```typescript
import { env } from '@rawnodes/config-loader';

env.nodeEnv       // process.env.NODE_ENV || 'local'
env.isProduction  // true if NODE_ENV === 'production'
env.isDevelopment // true if NODE_ENV === 'development'
env.isTest        // true if NODE_ENV === 'test'
env.isLocal       // true if NODE_ENV is 'local' or not set
```

### Usage with Vault/AWS

```typescript
import { loadConfigAsync, env } from '@rawnodes/config-loader';

const { config } = await loadConfigAsync({
  configDir: './config',
  vault: !env.isLocal && {
    endpoint: process.env.VAULT_ENDPOINT,
    roleId: process.env.VAULT_ROLE_ID,
    secretId: process.env.VAULT_SECRET_ID,
  },
});
// If any value is missing, you'll get a clear error:
// "Vault endpoint is not set" or "Vault roleId is not set"
```

## NestJS Integration

```typescript
// config.module.ts
import { Module, Global } from '@nestjs/common';
import { loadConfig } from '@rawnodes/config-loader';
import { AppConfigSchema, AppConfig } from './app.config';

const { config } = loadConfig<AppConfig>({
  schema: AppConfigSchema,
  dotenv: true,
});

@Global()
@Module({
  providers: [
    {
      provide: 'CONFIG',
      useValue: config,
    },
  ],
  exports: ['CONFIG'],
})
export class ConfigModule {}
```

## API

### `loadConfig<T>(options?): ConfigLoaderResult<T>`

Loads and merges configuration files synchronously.

**Returns:**
```typescript
interface ConfigLoaderResult<T> {
  config: T;           // Loaded configuration
  environment: string; // Resolved environment name
  configDir: string;   // Resolved config directory path
}
```

### `loadConfigAsync<T>(options?): Promise<ConfigLoaderResult<T>>`

Async version with Vault support. Required when using `vault` option.

```typescript
const { config } = await loadConfigAsync({
  configDir: './config',
  vault: {
    endpoint: 'https://vault.example.com',
    roleId: 'role-id',
    secretId: 'secret-id',
  },
});
```

### `maskSecrets(obj): unknown`

Masks sensitive values in an object. Useful for logging.

```typescript
import { maskSecrets } from '@rawnodes/config-loader';

const masked = maskSecrets({
  user: 'admin',
  password: 'secret123',
  url: 'postgres://user:pass@localhost/db',
});
// { user: 'admin', password: 'se***23', url: 'postgres://user:***@localhost/db' }
```

### `deepMerge(base, override): object`

Deep merges two objects.

### `replacePlaceholders(obj): unknown`

Replaces `${VAR}` placeholders with environment variable values.

### `stripEmpty(obj): unknown`

Removes empty strings and empty objects recursively. Useful for cleaning up config before validation.

```typescript
import { stripEmpty } from '@rawnodes/config-loader';

const cleaned = stripEmpty({
  server: { port: 3000 },
  optional: { url: '', name: '' },
});
// { server: { port: 3000 } }
```

## License

MIT