@onebun/envs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -0
- package/package.json +41 -0
- package/src/helpers.ts +259 -0
- package/src/index.ts +7 -0
- package/src/loader.ts +150 -0
- package/src/parser.ts +149 -0
- package/src/typed-env.ts +323 -0
- package/src/types.ts +115 -0
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @onebun/envs
|
|
2
|
+
|
|
3
|
+
Environment variables management package for OneBun framework with strict TypeScript support, validation, and Effect integration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🔒 **Type-safe**: Full TypeScript support with type inference
|
|
8
|
+
- 🛡️ **Validation**: Built-in validators and custom validation support
|
|
9
|
+
- 📊 **Effect Integration**: Uses Effect library for error handling
|
|
10
|
+
- 🔧 **Environment Loading**: Support for `.env` files and environment variables
|
|
11
|
+
- 🎭 **Sensitive Data**: Automatic masking of sensitive values in logs
|
|
12
|
+
- 🏗️ **Nested Configuration**: Support for nested configuration objects
|
|
13
|
+
- 📝 **Validation Helpers**: Pre-built validators for common use cases
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @onebun/envs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { TypedEnv, Env } from '@onebun/envs';
|
|
25
|
+
|
|
26
|
+
// Define your configuration schema
|
|
27
|
+
const schema = {
|
|
28
|
+
app: {
|
|
29
|
+
port: Env.number({ default: 3000, validate: Env.port() }),
|
|
30
|
+
host: Env.string({ default: 'localhost' }),
|
|
31
|
+
env: Env.string({
|
|
32
|
+
default: 'development',
|
|
33
|
+
validate: Env.oneOf(['development', 'production', 'test'])
|
|
34
|
+
})
|
|
35
|
+
},
|
|
36
|
+
database: {
|
|
37
|
+
url: Env.string({
|
|
38
|
+
required: true,
|
|
39
|
+
validate: Env.url()
|
|
40
|
+
}),
|
|
41
|
+
password: Env.string({
|
|
42
|
+
sensitive: true,
|
|
43
|
+
required: true
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Create typed configuration
|
|
49
|
+
const config = await TypedEnv.createAsync(schema);
|
|
50
|
+
|
|
51
|
+
// Access values with full type safety
|
|
52
|
+
const port = config.get('app.port'); // number
|
|
53
|
+
const dbUrl = config.get('database.url'); // string
|
|
54
|
+
|
|
55
|
+
// Get safe config for logging (sensitive data masked)
|
|
56
|
+
console.log(config.getSafeConfig());
|
|
57
|
+
// Output: { app: { port: 3000, host: 'localhost', env: 'development' }, database: { url: 'postgres://...', password: '***' } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API Reference
|
|
61
|
+
|
|
62
|
+
### Environment Variable Types
|
|
63
|
+
|
|
64
|
+
- `Env.string(options)` - String configuration
|
|
65
|
+
- `Env.number(options)` - Number configuration with range validation
|
|
66
|
+
- `Env.boolean(options)` - Boolean configuration
|
|
67
|
+
- `Env.array(options)` - Array configuration with length validation
|
|
68
|
+
|
|
69
|
+
### Built-in Validators
|
|
70
|
+
|
|
71
|
+
- `Env.regex(pattern, message?)` - Regular expression validation
|
|
72
|
+
- `Env.oneOf(values, message?)` - Enum validation
|
|
73
|
+
- `Env.url(message?)` - URL validation
|
|
74
|
+
- `Env.email(message?)` - Email validation
|
|
75
|
+
- `Env.port(message?)` - Port number validation (1-65535)
|
|
76
|
+
|
|
77
|
+
### Configuration Options
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
interface EnvVariableConfig<T> {
|
|
81
|
+
env?: string; // Custom environment variable name
|
|
82
|
+
description?: string; // Variable description
|
|
83
|
+
type: EnvValueType; // Variable type
|
|
84
|
+
default?: T; // Default value
|
|
85
|
+
required?: boolean; // Required field
|
|
86
|
+
sensitive?: boolean; // Sensitive field (masked in logs)
|
|
87
|
+
validate?: Function; // Custom validation function
|
|
88
|
+
separator?: string; // Array separator (default: ',')
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Testing
|
|
93
|
+
|
|
94
|
+
The package includes comprehensive unit tests with high coverage:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Run tests
|
|
98
|
+
bun test
|
|
99
|
+
|
|
100
|
+
# Run tests with coverage
|
|
101
|
+
bun run test:coverage
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Test Structure
|
|
105
|
+
|
|
106
|
+
- **Unit Tests**: 95+ tests covering all core functionality
|
|
107
|
+
- **Integration Tests**: Real-world scenarios and complex configurations
|
|
108
|
+
- **Error Handling**: Comprehensive error scenarios and edge cases
|
|
109
|
+
- **Performance Tests**: Large configuration handling and efficiency
|
|
110
|
+
|
|
111
|
+
### Test Coverage
|
|
112
|
+
|
|
113
|
+
- ✅ **Types**: Error classes and type definitions
|
|
114
|
+
- ✅ **Parser**: String-to-type conversion and validation
|
|
115
|
+
- ✅ **Loader**: .env file loading and process.env handling
|
|
116
|
+
- ✅ **TypedEnv**: Configuration creation and management
|
|
117
|
+
- ✅ **Helpers**: All built-in validators and utilities
|
|
118
|
+
- ✅ **Integration**: End-to-end workflows and complex scenarios
|
|
119
|
+
|
|
120
|
+
### Running Specific Tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Run specific test file
|
|
124
|
+
bun test tests/parser.test.ts
|
|
125
|
+
|
|
126
|
+
# Run tests matching pattern
|
|
127
|
+
bun test --match "*validation*"
|
|
128
|
+
|
|
129
|
+
# Run tests in watch mode
|
|
130
|
+
bun test --watch
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Environment Variable Loading
|
|
134
|
+
|
|
135
|
+
The package supports multiple sources for environment variables:
|
|
136
|
+
|
|
137
|
+
1. **Process Environment**: `process.env` variables
|
|
138
|
+
2. **.env Files**: Support for `.env` file loading
|
|
139
|
+
3. **Priority Control**: Configure which source takes precedence
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const config = await TypedEnv.createAsync(schema, {
|
|
143
|
+
envFilePath: '.env.production',
|
|
144
|
+
envOverridesDotEnv: true, // process.env overrides .env file
|
|
145
|
+
loadDotEnv: true // enable .env file loading
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Advanced Usage
|
|
150
|
+
|
|
151
|
+
### Custom Validation
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
const schema = {
|
|
155
|
+
apiKey: Env.string({
|
|
156
|
+
required: true,
|
|
157
|
+
sensitive: true,
|
|
158
|
+
validate: (value: string) => {
|
|
159
|
+
if (value.length < 32) {
|
|
160
|
+
return Effect.fail(new Error('API key too short'));
|
|
161
|
+
}
|
|
162
|
+
return Effect.succeed(value);
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
};
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Nested Configuration
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
const schema = {
|
|
172
|
+
server: {
|
|
173
|
+
http: {
|
|
174
|
+
port: Env.number({ default: 3000 }),
|
|
175
|
+
host: Env.string({ default: 'localhost' })
|
|
176
|
+
},
|
|
177
|
+
https: {
|
|
178
|
+
port: Env.number({ default: 3443 }),
|
|
179
|
+
cert: Env.string({ sensitive: true })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Access nested values
|
|
185
|
+
const httpPort = config.get('server.http.port');
|
|
186
|
+
const httpsCert = config.get('server.https.cert');
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Error Handling
|
|
190
|
+
|
|
191
|
+
All validation errors are properly typed and provide detailed context:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
try {
|
|
195
|
+
const config = await TypedEnv.createAsync(schema);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof EnvValidationError) {
|
|
198
|
+
console.log(`Validation failed for ${error.variable}: ${error.reason}`);
|
|
199
|
+
console.log(`Got value: ${error.value}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onebun/envs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Environment variables management package for OneBun framework",
|
|
5
|
+
"license": "LGPL-3.0",
|
|
6
|
+
"author": "RemRyahirev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/RemRyahirev/onebun.git",
|
|
10
|
+
"directory": "packages/envs"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/RemRyahirev/onebun/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/RemRyahirev/onebun/tree/master/packages/envs#readme",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"main": "src/index.ts",
|
|
25
|
+
"module": "src/index.ts",
|
|
26
|
+
"types": "src/index.ts",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"test:coverage": "bun test --coverage",
|
|
30
|
+
"lint": "eslint --fix --config ../../eslint.config.js ./"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"effect": "^3.13.10"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"bun-types": "1.2.2"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": "1.2.2"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
import { EnvValidationError, type EnvVariableConfig } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Helpers for creating environment variable configurations
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
9
|
+
export const Env = {
|
|
10
|
+
/**
|
|
11
|
+
* Create configuration for string variable
|
|
12
|
+
*/
|
|
13
|
+
string(
|
|
14
|
+
options: {
|
|
15
|
+
env?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
default?: string;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
sensitive?: boolean;
|
|
20
|
+
separator?: string;
|
|
21
|
+
validate?: (value: string) => Effect.Effect<string, EnvValidationError>;
|
|
22
|
+
} = {},
|
|
23
|
+
): EnvVariableConfig<string> {
|
|
24
|
+
return {
|
|
25
|
+
type: 'string',
|
|
26
|
+
separator: ',',
|
|
27
|
+
...options,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create configuration for number variable
|
|
33
|
+
*/
|
|
34
|
+
number(
|
|
35
|
+
options: {
|
|
36
|
+
env?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
default?: number;
|
|
39
|
+
required?: boolean;
|
|
40
|
+
sensitive?: boolean;
|
|
41
|
+
min?: number;
|
|
42
|
+
max?: number;
|
|
43
|
+
validate?: (value: number) => Effect.Effect<number, EnvValidationError>;
|
|
44
|
+
} = {},
|
|
45
|
+
): EnvVariableConfig<number> {
|
|
46
|
+
let validator = options.validate;
|
|
47
|
+
|
|
48
|
+
// Add range validation if specified
|
|
49
|
+
if (options.min !== undefined || options.max !== undefined) {
|
|
50
|
+
const rangeValidator = (value: number) => {
|
|
51
|
+
if (options.min !== undefined && value < options.min) {
|
|
52
|
+
return Effect.fail(new EnvValidationError('', value, `Value must be >= ${options.min}`));
|
|
53
|
+
}
|
|
54
|
+
if (options.max !== undefined && value > options.max) {
|
|
55
|
+
return Effect.fail(new EnvValidationError('', value, `Value must be <= ${options.max}`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Effect.succeed(value);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (validator) {
|
|
62
|
+
const originalValidator = validator;
|
|
63
|
+
validator = (value: number) =>
|
|
64
|
+
rangeValidator(value).pipe(Effect.flatMap(originalValidator));
|
|
65
|
+
} else {
|
|
66
|
+
validator = rangeValidator;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
type: 'number',
|
|
72
|
+
description: options.description,
|
|
73
|
+
default: options.default,
|
|
74
|
+
required: options.required,
|
|
75
|
+
sensitive: options.sensitive,
|
|
76
|
+
env: options.env,
|
|
77
|
+
validate: validator,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create configuration for boolean variable
|
|
83
|
+
*/
|
|
84
|
+
boolean(
|
|
85
|
+
options: {
|
|
86
|
+
env?: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
default?: boolean;
|
|
89
|
+
required?: boolean;
|
|
90
|
+
sensitive?: boolean;
|
|
91
|
+
validate?: (value: boolean) => Effect.Effect<boolean, EnvValidationError>;
|
|
92
|
+
} = {},
|
|
93
|
+
): EnvVariableConfig<boolean> {
|
|
94
|
+
return {
|
|
95
|
+
type: 'boolean',
|
|
96
|
+
...options,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create configuration for string array variable
|
|
102
|
+
*/
|
|
103
|
+
array(
|
|
104
|
+
options: {
|
|
105
|
+
env?: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
default?: string[];
|
|
108
|
+
required?: boolean;
|
|
109
|
+
sensitive?: boolean;
|
|
110
|
+
separator?: string;
|
|
111
|
+
minLength?: number;
|
|
112
|
+
maxLength?: number;
|
|
113
|
+
validate?: (value: string[]) => Effect.Effect<string[], EnvValidationError>;
|
|
114
|
+
} = {},
|
|
115
|
+
): EnvVariableConfig<string[]> {
|
|
116
|
+
let validator = options.validate;
|
|
117
|
+
|
|
118
|
+
// Add length validation if specified
|
|
119
|
+
if (options.minLength !== undefined || options.maxLength !== undefined) {
|
|
120
|
+
const lengthValidator = (value: string[]) => {
|
|
121
|
+
if (options.minLength !== undefined && value.length < options.minLength) {
|
|
122
|
+
return Effect.fail(
|
|
123
|
+
new EnvValidationError(
|
|
124
|
+
'',
|
|
125
|
+
value,
|
|
126
|
+
`Array must have at least ${options.minLength} items`,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (options.maxLength !== undefined && value.length > options.maxLength) {
|
|
131
|
+
return Effect.fail(
|
|
132
|
+
new EnvValidationError('', value, `Array must have at most ${options.maxLength} items`),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Effect.succeed(value);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (validator) {
|
|
140
|
+
const originalValidator = validator;
|
|
141
|
+
validator = (value: string[]) =>
|
|
142
|
+
lengthValidator(value).pipe(Effect.flatMap(originalValidator));
|
|
143
|
+
} else {
|
|
144
|
+
validator = lengthValidator;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
type: 'array',
|
|
150
|
+
description: options.description,
|
|
151
|
+
default: options.default,
|
|
152
|
+
required: options.required,
|
|
153
|
+
sensitive: options.sensitive,
|
|
154
|
+
env: options.env,
|
|
155
|
+
separator: options.separator || ',',
|
|
156
|
+
validate: validator,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create validator for string with regular expression
|
|
162
|
+
*/
|
|
163
|
+
regex(
|
|
164
|
+
pattern: RegExp,
|
|
165
|
+
errorMessage?: string,
|
|
166
|
+
): (value: string) => Effect.Effect<string, EnvValidationError> {
|
|
167
|
+
return (value: string) => {
|
|
168
|
+
if (!pattern.test(value)) {
|
|
169
|
+
return Effect.fail(
|
|
170
|
+
new EnvValidationError('', value, errorMessage || `Value must match pattern ${pattern}`),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return Effect.succeed(value);
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create validator for string from allowed values list
|
|
180
|
+
*/
|
|
181
|
+
oneOf<T extends string>(
|
|
182
|
+
allowedValues: readonly T[],
|
|
183
|
+
errorMessage?: string,
|
|
184
|
+
): (value: string) => Effect.Effect<T, EnvValidationError> {
|
|
185
|
+
return (value: string) => {
|
|
186
|
+
if (!allowedValues.includes(value as T)) {
|
|
187
|
+
return Effect.fail(
|
|
188
|
+
new EnvValidationError(
|
|
189
|
+
'',
|
|
190
|
+
value,
|
|
191
|
+
errorMessage || `Value must be one of: ${allowedValues.join(', ')}`,
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return Effect.succeed(value as T);
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create validator for URL
|
|
202
|
+
*/
|
|
203
|
+
url(errorMessage?: string): (value: string) => Effect.Effect<string, EnvValidationError> {
|
|
204
|
+
return (value: string) => {
|
|
205
|
+
try {
|
|
206
|
+
const url = new URL(value);
|
|
207
|
+
|
|
208
|
+
// Reject potentially dangerous schemes
|
|
209
|
+
const dangerousSchemes = ['javascript', 'data', 'vbscript'];
|
|
210
|
+
if (dangerousSchemes.includes(url.protocol.slice(0, -1))) {
|
|
211
|
+
throw new Error('Dangerous URL scheme');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return Effect.succeed(value);
|
|
215
|
+
} catch {
|
|
216
|
+
return Effect.fail(
|
|
217
|
+
new EnvValidationError('', value, errorMessage || 'Value must be a valid URL'),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create validator for email
|
|
225
|
+
*/
|
|
226
|
+
email(errorMessage?: string): (value: string) => Effect.Effect<string, EnvValidationError> {
|
|
227
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
228
|
+
|
|
229
|
+
return (value: string) => {
|
|
230
|
+
if (!emailRegex.test(value)) {
|
|
231
|
+
return Effect.fail(
|
|
232
|
+
new EnvValidationError('', value, errorMessage || 'Value must be a valid email address'),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return Effect.succeed(value);
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create validator for port number
|
|
242
|
+
*/
|
|
243
|
+
port(errorMessage?: string): (value: number) => Effect.Effect<number, EnvValidationError> {
|
|
244
|
+
return (value: number) => {
|
|
245
|
+
// eslint-disable-next-line no-magic-numbers
|
|
246
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
247
|
+
return Effect.fail(
|
|
248
|
+
new EnvValidationError(
|
|
249
|
+
'',
|
|
250
|
+
value,
|
|
251
|
+
errorMessage || 'Port must be an integer between 1 and 65535',
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return Effect.succeed(value);
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
};
|
package/src/index.ts
ADDED
package/src/loader.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
import { EnvLoadError, type EnvLoadOptions } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Environment variables loader
|
|
7
|
+
*/
|
|
8
|
+
export class EnvLoader {
|
|
9
|
+
/**
|
|
10
|
+
* Load environment variables from various sources.
|
|
11
|
+
* Priority (highest to lowest):
|
|
12
|
+
* 1. valueOverrides (if provided)
|
|
13
|
+
* 2. process.env (if envOverridesDotEnv = true)
|
|
14
|
+
* 3. .env file
|
|
15
|
+
* 4. process.env (if envOverridesDotEnv = false)
|
|
16
|
+
*/
|
|
17
|
+
static load(options: EnvLoadOptions = {}): Effect.Effect<Record<string, string>, EnvLoadError> {
|
|
18
|
+
const {
|
|
19
|
+
envFilePath = '.env',
|
|
20
|
+
loadDotEnv = true,
|
|
21
|
+
envOverridesDotEnv = true,
|
|
22
|
+
valueOverrides,
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
const loadDotEnvVars = loadDotEnv
|
|
26
|
+
? EnvLoader.loadDotEnvFile(envFilePath)
|
|
27
|
+
: Effect.succeed({} as Record<string, string>);
|
|
28
|
+
|
|
29
|
+
const processEnvVars = EnvLoader.loadProcessEnv();
|
|
30
|
+
|
|
31
|
+
return loadDotEnvVars.pipe(
|
|
32
|
+
Effect.map((dotEnvVars) => {
|
|
33
|
+
let result: Record<string, string>;
|
|
34
|
+
|
|
35
|
+
if (envOverridesDotEnv) {
|
|
36
|
+
// Process environment variables have priority over .env
|
|
37
|
+
result = { ...dotEnvVars, ...processEnvVars };
|
|
38
|
+
} else {
|
|
39
|
+
// .env file has priority over process.env
|
|
40
|
+
result = { ...processEnvVars, ...dotEnvVars };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Apply valueOverrides with highest priority
|
|
44
|
+
if (valueOverrides) {
|
|
45
|
+
for (const [key, value] of Object.entries(valueOverrides)) {
|
|
46
|
+
result[key] = String(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load variables from .env file
|
|
57
|
+
*/
|
|
58
|
+
private static loadDotEnvFile(
|
|
59
|
+
filePath: string,
|
|
60
|
+
): Effect.Effect<Record<string, string>, EnvLoadError> {
|
|
61
|
+
return Effect.tryPromise({
|
|
62
|
+
async try() {
|
|
63
|
+
const file = Bun.file(filePath);
|
|
64
|
+
const exists = await file.exists();
|
|
65
|
+
|
|
66
|
+
if (!exists) {
|
|
67
|
+
return {}; // File not found - not an error, just return empty object
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const content = await file.text();
|
|
71
|
+
|
|
72
|
+
return EnvLoader.parseDotEnvContent(content);
|
|
73
|
+
},
|
|
74
|
+
catch: (error) =>
|
|
75
|
+
new EnvLoadError(
|
|
76
|
+
filePath,
|
|
77
|
+
`Failed to read .env file: ${error instanceof Error ? error.message : String(error)}`,
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse .env file content
|
|
84
|
+
*/
|
|
85
|
+
private static parseDotEnvContent(content: string): Record<string, string> {
|
|
86
|
+
const variables: Record<string, string> = {};
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const line = lines[i].trim();
|
|
91
|
+
|
|
92
|
+
// Skip empty lines and comments
|
|
93
|
+
if (!line || line.startsWith('#')) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find equals sign
|
|
98
|
+
const equalIndex = line.indexOf('=');
|
|
99
|
+
if (equalIndex === -1) {
|
|
100
|
+
continue; // Skip lines without equals sign
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const key = line.slice(0, equalIndex).trim();
|
|
104
|
+
let value = line.slice(equalIndex + 1).trim();
|
|
105
|
+
|
|
106
|
+
// Remove quotes if present
|
|
107
|
+
if (
|
|
108
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
109
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
110
|
+
) {
|
|
111
|
+
value = value.slice(1, -1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Process escape sequences
|
|
115
|
+
value = value
|
|
116
|
+
.replace(/\\n/g, '\n')
|
|
117
|
+
.replace(/\\r/g, '\r')
|
|
118
|
+
.replace(/\\t/g, '\t')
|
|
119
|
+
.replace(/\\\\/g, '\\')
|
|
120
|
+
.replace(/\\"/g, '"')
|
|
121
|
+
.replace(/\\'/g, "'");
|
|
122
|
+
|
|
123
|
+
variables[key] = value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return variables;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load variables from process.env
|
|
131
|
+
*/
|
|
132
|
+
private static loadProcessEnv(): Record<string, string> {
|
|
133
|
+
const variables: Record<string, string> = {};
|
|
134
|
+
|
|
135
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
136
|
+
if (value !== undefined) {
|
|
137
|
+
variables[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return variables;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if .env file exists
|
|
146
|
+
*/
|
|
147
|
+
static checkDotEnvExists(filePath: string = '.env'): Effect.Effect<boolean, never> {
|
|
148
|
+
return Effect.promise(() => Bun.file(filePath).exists());
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type EnvLoadOptions,
|
|
5
|
+
EnvValidationError,
|
|
6
|
+
type EnvValueType,
|
|
7
|
+
type EnvVariableConfig,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Environment variable parser
|
|
12
|
+
*/
|
|
13
|
+
export class EnvParser {
|
|
14
|
+
/**
|
|
15
|
+
* Parse string value according to configuration
|
|
16
|
+
*/
|
|
17
|
+
static parse<T>(
|
|
18
|
+
variable: string,
|
|
19
|
+
value: string | undefined,
|
|
20
|
+
config: EnvVariableConfig<T>,
|
|
21
|
+
options: EnvLoadOptions = {},
|
|
22
|
+
): Effect.Effect<T, EnvValidationError> {
|
|
23
|
+
const resolveValue = Effect.sync(() => {
|
|
24
|
+
// If value is not set
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
if (config.default !== undefined) {
|
|
27
|
+
return config.default;
|
|
28
|
+
}
|
|
29
|
+
if (config.required) {
|
|
30
|
+
throw new EnvValidationError(variable, value, 'Required variable is not set');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return EnvParser.getDefaultForTypeSync(config.type);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return value;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const parseValue = (resolvedValue: unknown) => {
|
|
40
|
+
if (typeof resolvedValue === 'string') {
|
|
41
|
+
const separator = config.separator || options.defaultArraySeparator || ',';
|
|
42
|
+
|
|
43
|
+
return EnvParser.parseByType(variable, resolvedValue, config.type, separator);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Effect.succeed(resolvedValue);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const validateParsed = (parsed: unknown) =>
|
|
50
|
+
EnvParser.validateValue(variable, parsed as T, config);
|
|
51
|
+
|
|
52
|
+
return resolveValue.pipe(Effect.flatMap(parseValue), Effect.flatMap(validateParsed));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse value by type
|
|
57
|
+
*/
|
|
58
|
+
private static parseByType(
|
|
59
|
+
variable: string,
|
|
60
|
+
value: string,
|
|
61
|
+
type: EnvValueType,
|
|
62
|
+
separator = ',',
|
|
63
|
+
): Effect.Effect<unknown, EnvValidationError> {
|
|
64
|
+
return Effect.try({
|
|
65
|
+
try() {
|
|
66
|
+
switch (type) {
|
|
67
|
+
case 'string':
|
|
68
|
+
return value;
|
|
69
|
+
|
|
70
|
+
case 'number': {
|
|
71
|
+
// Empty string should be rejected for numbers
|
|
72
|
+
if (value.trim() === '') {
|
|
73
|
+
throw new Error(`"${value}" is not a valid number`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const num = Number(value);
|
|
77
|
+
if (isNaN(num)) {
|
|
78
|
+
throw new Error(`"${value}" is not a valid number`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return num;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
case 'boolean': {
|
|
85
|
+
const lower = value.toLowerCase();
|
|
86
|
+
if (['true', '1', 'yes', 'on'].includes(lower)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (['false', '0', 'no', 'off'].includes(lower)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`"${value}" is not a valid boolean`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case 'array': {
|
|
96
|
+
if (value.trim() === '') {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return value.split(separator).map((item) => item.trim());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
default:
|
|
104
|
+
throw new Error(`Unknown type: ${type}`);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
catch: (error) =>
|
|
108
|
+
new EnvValidationError(
|
|
109
|
+
variable,
|
|
110
|
+
value,
|
|
111
|
+
error instanceof Error ? error.message : String(error),
|
|
112
|
+
),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate value
|
|
118
|
+
*/
|
|
119
|
+
private static validateValue<T>(
|
|
120
|
+
variable: string,
|
|
121
|
+
value: T,
|
|
122
|
+
config: EnvVariableConfig<T>,
|
|
123
|
+
): Effect.Effect<T, EnvValidationError> {
|
|
124
|
+
if (config.validate) {
|
|
125
|
+
return config.validate(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Effect.succeed(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get default value for type (sync)
|
|
133
|
+
*/
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
|
+
private static getDefaultForTypeSync(type: EnvValueType): any {
|
|
136
|
+
switch (type) {
|
|
137
|
+
case 'string':
|
|
138
|
+
return '';
|
|
139
|
+
case 'number':
|
|
140
|
+
return 0;
|
|
141
|
+
case 'boolean':
|
|
142
|
+
return false;
|
|
143
|
+
case 'array':
|
|
144
|
+
return [];
|
|
145
|
+
default:
|
|
146
|
+
throw new EnvValidationError('unknown', undefined, `Unknown type: ${type}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/typed-env.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
import { EnvLoader } from './loader';
|
|
4
|
+
import { EnvParser } from './parser';
|
|
5
|
+
import {
|
|
6
|
+
type EnvLoadOptions,
|
|
7
|
+
type EnvSchema,
|
|
8
|
+
EnvValidationError,
|
|
9
|
+
type EnvVariableConfig,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Utility types for automatic type inference
|
|
14
|
+
*/
|
|
15
|
+
type DeepValue<T, Path extends string> = Path extends keyof T
|
|
16
|
+
? T[Path]
|
|
17
|
+
: Path extends `${infer K}.${infer Rest}`
|
|
18
|
+
? K extends keyof T
|
|
19
|
+
? T[K] extends object
|
|
20
|
+
? DeepValue<T[K], Rest>
|
|
21
|
+
: never
|
|
22
|
+
: never
|
|
23
|
+
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
any; // Fallback to any for complex paths
|
|
25
|
+
|
|
26
|
+
type DeepPaths<T> = T extends object
|
|
27
|
+
? {
|
|
28
|
+
[K in keyof T]: K extends string
|
|
29
|
+
? T[K] extends object
|
|
30
|
+
? K | `${K}.${DeepPaths<T[K]>}`
|
|
31
|
+
: K
|
|
32
|
+
: never;
|
|
33
|
+
}[keyof T]
|
|
34
|
+
: never;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sensitive value wrapper that sanitizes toString() output
|
|
38
|
+
*/
|
|
39
|
+
class SensitiveValue<T> {
|
|
40
|
+
constructor(private readonly _value: T) {}
|
|
41
|
+
|
|
42
|
+
get value(): T {
|
|
43
|
+
return this._value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
toString(): string {
|
|
47
|
+
return '***';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
toJSON(): string {
|
|
51
|
+
return '***';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
valueOf(): T {
|
|
55
|
+
return this._value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
[Symbol.toPrimitive](hint: string): string | number | T {
|
|
59
|
+
if (hint === 'string') {
|
|
60
|
+
return '***';
|
|
61
|
+
}
|
|
62
|
+
if (hint === 'number' && typeof this._value === 'number') {
|
|
63
|
+
return this._value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return this._value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Configuration proxy that intercepts access and provides type safety
|
|
72
|
+
*/
|
|
73
|
+
class ConfigProxy<T> {
|
|
74
|
+
private _isInitialized = false;
|
|
75
|
+
private _values: T | null = null;
|
|
76
|
+
private _sensitiveFields: Set<string> = new Set();
|
|
77
|
+
|
|
78
|
+
constructor(
|
|
79
|
+
private readonly _schema: EnvSchema<T>,
|
|
80
|
+
private readonly _options: EnvLoadOptions = {},
|
|
81
|
+
) {
|
|
82
|
+
this.extractSensitiveFields(this._schema, '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
private extractSensitiveFields(schema: any, prefix = ''): void {
|
|
87
|
+
for (const [key, config] of Object.entries(schema)) {
|
|
88
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
89
|
+
|
|
90
|
+
if (this.isEnvVariableConfig(config)) {
|
|
91
|
+
if ((config as EnvVariableConfig).sensitive) {
|
|
92
|
+
this._sensitiveFields.add(fullPath);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
this.extractSensitiveFields(config, fullPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private isEnvVariableConfig(config: unknown): boolean {
|
|
101
|
+
return Boolean(config) && typeof config === 'object' && 'type' in config!;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async ensureInitialized(): Promise<void> {
|
|
105
|
+
if (this._isInitialized) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rawVariables = await Effect.runPromise(EnvLoader.load(this._options));
|
|
110
|
+
this._values = this.parseNestedSchema(this._schema, rawVariables, '') as T;
|
|
111
|
+
this._isInitialized = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
private parseNestedSchema(
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
117
|
+
schema: any,
|
|
118
|
+
rawVariables: Record<string, string>,
|
|
119
|
+
prefix: string,
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
): any {
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
123
|
+
const result: any = {};
|
|
124
|
+
|
|
125
|
+
for (const [key, config] of Object.entries(schema)) {
|
|
126
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
127
|
+
|
|
128
|
+
if (this.isEnvVariableConfig(config)) {
|
|
129
|
+
const envConfig = config as EnvVariableConfig;
|
|
130
|
+
const envVar = envConfig.env || this.pathToEnvVar(fullPath);
|
|
131
|
+
const rawValue = rawVariables[envVar];
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const parsed = Effect.runSync(
|
|
135
|
+
EnvParser.parse(
|
|
136
|
+
envVar,
|
|
137
|
+
rawValue,
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
139
|
+
envConfig as any,
|
|
140
|
+
this._options,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
result[key] = parsed;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof EnvValidationError) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
throw new EnvValidationError(envVar, rawValue, String(error));
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
result[key] = this.parseNestedSchema(config, rawVariables, fullPath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private pathToEnvVar(path: string): string {
|
|
159
|
+
return path.toUpperCase().replace(/\./g, '_');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private getValueByPath(obj: Record<string, unknown>, path: string): unknown {
|
|
163
|
+
const keys = path.split('.');
|
|
164
|
+
let current: Record<string, unknown> | unknown = obj;
|
|
165
|
+
|
|
166
|
+
for (const key of keys) {
|
|
167
|
+
if (current && typeof current === 'object' && key in current) {
|
|
168
|
+
current = current[key as keyof typeof current];
|
|
169
|
+
} else {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Synchronous get method with automatic type inference and sensitive data handling
|
|
179
|
+
*/
|
|
180
|
+
get<P extends DeepPaths<T>>(path: P): DeepValue<T, P>;
|
|
181
|
+
get<P extends keyof T>(path: P): T[P];
|
|
182
|
+
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
get(path: string): any;
|
|
185
|
+
get(path: string): unknown {
|
|
186
|
+
if (!this._isInitialized || !this._values) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'Configuration not initialized. Call TypedEnv.create() or ensure initialization is complete.',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const value = this.getValueByPath(this._values, path);
|
|
193
|
+
|
|
194
|
+
// Wrap sensitive values
|
|
195
|
+
if (this._sensitiveFields.has(path)) {
|
|
196
|
+
return new SensitiveValue(value);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get the entire configuration object
|
|
204
|
+
*/
|
|
205
|
+
get values(): T {
|
|
206
|
+
if (!this._isInitialized || !this._values) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'Configuration not initialized. Call TypedEnv.create() or ensure initialization is complete.',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return this._values;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Initialize the configuration (async)
|
|
217
|
+
*/
|
|
218
|
+
async initialize(): Promise<void> {
|
|
219
|
+
await this.ensureInitialized();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Check if configuration is initialized
|
|
224
|
+
*/
|
|
225
|
+
get isInitialized(): boolean {
|
|
226
|
+
return this._isInitialized;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get safe configuration for logging (sensitive data masked)
|
|
231
|
+
*/
|
|
232
|
+
getSafeConfig(): T {
|
|
233
|
+
if (!this._isInitialized || !this._values) {
|
|
234
|
+
throw new Error('Configuration not initialized.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return this.applySensitiveMask(this._values, '') as T;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private applySensitiveMask<U = unknown>(obj: U, prefix = ''): U {
|
|
241
|
+
if (obj === null || obj === undefined) {
|
|
242
|
+
return obj;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (Array.isArray(obj)) {
|
|
246
|
+
return obj.map((item) =>
|
|
247
|
+
typeof item === 'object' ? this.applySensitiveMask<U>(item, prefix) : item,
|
|
248
|
+
) as U;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof obj === 'object') {
|
|
252
|
+
const result: Record<string, unknown> = {};
|
|
253
|
+
|
|
254
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
255
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
256
|
+
|
|
257
|
+
if (this._sensitiveFields.has(fullPath)) {
|
|
258
|
+
result[key] = '***';
|
|
259
|
+
} else if (value && typeof value === 'object') {
|
|
260
|
+
result[key] = this.applySensitiveMask<U>(value, fullPath);
|
|
261
|
+
} else {
|
|
262
|
+
result[key] = value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result as U;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return obj;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Static factory for creating typed environment configurations
|
|
275
|
+
*/
|
|
276
|
+
export class TypedEnv {
|
|
277
|
+
private static instances = new Map<string, ConfigProxy<unknown>>();
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Create or get existing typed environment configuration
|
|
281
|
+
*/
|
|
282
|
+
static create<T>(
|
|
283
|
+
schema: EnvSchema<T>,
|
|
284
|
+
options: EnvLoadOptions = {},
|
|
285
|
+
key = 'default',
|
|
286
|
+
): ConfigProxy<T> {
|
|
287
|
+
if (!TypedEnv.instances.has(key)) {
|
|
288
|
+
const proxy = new ConfigProxy(schema, options);
|
|
289
|
+
TypedEnv.instances.set(key, proxy);
|
|
290
|
+
|
|
291
|
+
// Auto-initialize
|
|
292
|
+
proxy.initialize();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return TypedEnv.instances.get(key) as ConfigProxy<T>;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Create typed environment configuration with immediate initialization
|
|
300
|
+
*/
|
|
301
|
+
static async createAsync<T>(
|
|
302
|
+
schema: EnvSchema<T>,
|
|
303
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
304
|
+
options: any = {},
|
|
305
|
+
key = 'default',
|
|
306
|
+
): Promise<ConfigProxy<T>> {
|
|
307
|
+
const proxy = TypedEnv.create(schema, options, key);
|
|
308
|
+
await proxy.initialize();
|
|
309
|
+
|
|
310
|
+
return proxy;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Clear all instances (useful for testing)
|
|
315
|
+
*/
|
|
316
|
+
static clear(): void {
|
|
317
|
+
TypedEnv.instances.clear();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Export type helpers for external use
|
|
322
|
+
export type { DeepPaths, DeepValue, SensitiveValue };
|
|
323
|
+
export { ConfigProxy };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Effect } from 'effect';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Environment variable value types
|
|
5
|
+
*/
|
|
6
|
+
export type EnvValueType = 'string' | 'number' | 'boolean' | 'array';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration for environment variable
|
|
10
|
+
*/
|
|
11
|
+
export interface EnvVariableConfig<T = unknown> {
|
|
12
|
+
/** Environment variable name (if different from schema key) */
|
|
13
|
+
env?: string;
|
|
14
|
+
/** Variable description */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Variable type */
|
|
17
|
+
type: EnvValueType;
|
|
18
|
+
/** Default value */
|
|
19
|
+
default?: T;
|
|
20
|
+
/** Required field - will throw error if not provided */
|
|
21
|
+
required?: boolean;
|
|
22
|
+
/** Sensitive field - will be masked in logs */
|
|
23
|
+
sensitive?: boolean;
|
|
24
|
+
/** Validation function */
|
|
25
|
+
validate?: (value: T) => Effect.Effect<T, EnvValidationError>;
|
|
26
|
+
/** Separator for arrays (default: ',') */
|
|
27
|
+
separator?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Environment variables schema supporting nested objects
|
|
32
|
+
*/
|
|
33
|
+
export type EnvSchema<T> = {
|
|
34
|
+
[K in keyof T]: T[K] extends string | number | boolean | string[] | number[] | boolean[]
|
|
35
|
+
? EnvVariableConfig<T[K]>
|
|
36
|
+
: T[K] extends Record<string, unknown>
|
|
37
|
+
? EnvSchema<T[K]>
|
|
38
|
+
: EnvVariableConfig<T[K]>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format value for error messages
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
function formatValue(value: any): string {
|
|
46
|
+
if (value === undefined) {
|
|
47
|
+
return 'undefined';
|
|
48
|
+
}
|
|
49
|
+
if (value === null) {
|
|
50
|
+
return 'null';
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
return `"${value}"`;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'object') {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.stringify(value);
|
|
58
|
+
} catch {
|
|
59
|
+
return '[object Object]';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return String(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Environment variable validation error
|
|
68
|
+
*/
|
|
69
|
+
export class EnvValidationError extends Error {
|
|
70
|
+
constructor(
|
|
71
|
+
public readonly variable: string,
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
|
73
|
+
public readonly value: any,
|
|
74
|
+
public readonly reason: string,
|
|
75
|
+
) {
|
|
76
|
+
super(
|
|
77
|
+
`Environment variable validation failed for "${variable}": ${reason}. Got: ${formatValue(value)}`,
|
|
78
|
+
);
|
|
79
|
+
this.name = 'EnvValidationError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Environment variable loading error
|
|
85
|
+
*/
|
|
86
|
+
export class EnvLoadError extends Error {
|
|
87
|
+
constructor(
|
|
88
|
+
public readonly variable: string,
|
|
89
|
+
public readonly reason: string,
|
|
90
|
+
) {
|
|
91
|
+
super(`Failed to load environment variable "${variable}": ${reason}`);
|
|
92
|
+
this.name = 'EnvLoadError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Environment loading options
|
|
98
|
+
*/
|
|
99
|
+
export interface EnvLoadOptions {
|
|
100
|
+
/** Path to .env file (default: '.env') */
|
|
101
|
+
envFilePath?: string;
|
|
102
|
+
/** Whether to load .env file (default: true) */
|
|
103
|
+
loadDotEnv?: boolean;
|
|
104
|
+
/** Environment variables override .env file (default: true) */
|
|
105
|
+
envOverridesDotEnv?: boolean;
|
|
106
|
+
/** Strict mode - only load variables defined in schema (default: false) */
|
|
107
|
+
strict?: boolean;
|
|
108
|
+
/** Default separator for arrays (default: ',') */
|
|
109
|
+
defaultArraySeparator?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Override values that take precedence over both process.env and .env file.
|
|
112
|
+
* Useful for multi-service setups where each service needs different values.
|
|
113
|
+
*/
|
|
114
|
+
valueOverrides?: Record<string, string | number | boolean>;
|
|
115
|
+
}
|