@meltstudio/config-loader 3.5.0 → 3.7.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @meltstudio/config-loader
2
2
 
3
- A type-safe configuration loader for Node.js. Define your schema once, load from YAML or JSON files, `.env` files, environment variables, and CLI arguments — and get a fully typed result with zero manual type annotations.
3
+ A type-safe configuration loader for Node.js. Define your schema once, load from YAML, JSON, or TOML files, `.env` files, environment variables, and CLI arguments — and get a fully typed result with zero manual type annotations.
4
4
 
5
- > **Upgrading from v1?** v1.x is deprecated. v2 includes breaking changes to the public API, object schema syntax, and requires Node.js >= 20. Install the latest version with `npm install @meltstudio/config-loader@latest` or `yarn add @meltstudio/config-loader@latest`.
5
+ > **Upgrading from v1?** v1.x is deprecated and no longer maintained. Install the latest version with `npm install @meltstudio/config-loader@latest` or `yarn add @meltstudio/config-loader@latest`.
6
6
 
7
7
  **[Full documentation](https://meltstudio.github.io/config-loader/)**
8
8
 
@@ -16,47 +16,34 @@ import c from "@meltstudio/config-loader";
16
16
  const config = c
17
17
  .schema({
18
18
  port: c.number({ required: true, env: "PORT" }),
19
+ host: c.string({ env: "HOST", defaultValue: "localhost" }),
20
+ env: c.string({
21
+ env: "NODE_ENV",
22
+ defaultValue: "development",
23
+ oneOf: ["development", "staging", "production"],
24
+ }),
25
+ apiKey: c.string({ env: "API_KEY", sensitive: true }),
19
26
  database: c.object({
20
27
  item: {
21
28
  host: c.string({ required: true }),
22
- credentials: c.object({
23
- item: {
24
- username: c.string(),
25
- password: c.string({ env: "DB_PASSWORD" }),
26
- },
27
- }),
29
+ password: c.string({ env: "DB_PASSWORD", sensitive: true }),
28
30
  },
29
31
  }),
30
- features: c.array({
31
- required: true,
32
- item: c.object({
33
- item: {
34
- name: c.string(),
35
- enabled: c.bool(),
36
- },
37
- }),
38
- }),
39
32
  })
40
33
  .load({
41
34
  env: true,
42
35
  args: true,
43
36
  files: "./config.yaml",
37
+ envFile: "./.env",
44
38
  });
45
39
 
46
- // config is fully typed:
47
- // {
48
- // port: number;
49
- // database: { host: string; credentials: { username: string; password: string } };
50
- // features: { name: string; enabled: boolean }[];
51
- // }
40
+ // config is fully typed — no `as` casts, no separate interfaces
52
41
  ```
53
42
 
54
- No separate interface to maintain. No `as` casts. The types flow from the schema.
55
-
56
43
  ## Features
57
44
 
58
45
  - **Full type inference** — schema definition produces typed output automatically
59
- - **Multiple sources** — YAML files, JSON files, `.env` files, environment variables, CLI arguments
46
+ - **Multiple sources** — YAML, JSON, TOML files, `.env` files, environment variables, CLI arguments
60
47
  - **Priority resolution** — CLI > process.env > `.env` files > Config files > Defaults
61
48
  - **`.env` file support** — load environment variables from `.env` files with automatic line tracking
62
49
  - **Nested objects and arrays** — deeply nested configs with full type safety
@@ -67,10 +54,7 @@ No separate interface to maintain. No `as` casts. The types flow from the schema
67
54
  - **Strict mode** — promote warnings to errors for production safety
68
55
  - **Default values** — static or computed (via functions)
69
56
  - **Multiple files / directory loading** — load from a list of files or an entire directory
70
-
71
- ## Requirements
72
-
73
- - Node.js >= 20
57
+ - **File watching** — `watch()` reloads config on file changes with debouncing, change detection, and error recovery
74
58
 
75
59
  ## Installation
76
60
 
@@ -82,753 +66,28 @@ npm install @meltstudio/config-loader
82
66
  yarn add @meltstudio/config-loader
83
67
  ```
84
68
 
85
- ## Quick Start
86
-
87
- **config.yaml:**
88
-
89
- ```yaml
90
- version: 1.0.0
91
- website:
92
- title: My Website
93
- description: A simple and elegant website
94
- isProduction: false
95
-
96
- database:
97
- host: localhost
98
- port: 5432
99
- credentials:
100
- username: admin
101
- password: secret
102
-
103
- socialMedia: [https://twitter.com/example, https://instagram.com/example]
104
-
105
- features:
106
- - name: Store
107
- enabled: true
108
- - name: Admin
109
- enabled: false
110
- ```
111
-
112
- **index.ts:**
113
-
114
- ```typescript
115
- import path from "path";
116
- import c from "@meltstudio/config-loader";
117
-
118
- const config = c
119
- .schema({
120
- version: c.string({ required: true, cli: true }),
121
- website: c.object({
122
- item: {
123
- title: c.string({ required: true }),
124
- url: c.string({
125
- required: false,
126
- defaultValue: "www.mywebsite.dev",
127
- }),
128
- description: c.string({ required: true }),
129
- isProduction: c.bool({ required: true }),
130
- },
131
- }),
132
- database: c.object({
133
- item: {
134
- host: c.string({ required: true }),
135
- port: c.number({ required: true }),
136
- credentials: c.object({
137
- item: {
138
- username: c.string(),
139
- password: c.string(),
140
- },
141
- }),
142
- },
143
- }),
144
- socialMedia: c.array({
145
- required: true,
146
- item: c.string({ required: true }),
147
- }),
148
- features: c.array({
149
- required: true,
150
- item: c.object({
151
- item: {
152
- name: c.string(),
153
- enabled: c.bool(),
154
- },
155
- }),
156
- }),
157
- })
158
- .load({
159
- env: false,
160
- args: true,
161
- files: path.join(__dirname, "./config.yaml"),
162
- });
163
-
164
- console.log(JSON.stringify(config, null, 2));
165
- ```
166
-
167
- Output:
168
-
169
- ```json
170
- {
171
- "version": "1.0.0",
172
- "website": {
173
- "title": "My Website",
174
- "url": "www.mywebsite.dev",
175
- "description": "A simple and elegant website",
176
- "isProduction": false
177
- },
178
- "database": {
179
- "host": "localhost",
180
- "port": 5432,
181
- "credentials": {
182
- "username": "admin",
183
- "password": "secret"
184
- }
185
- },
186
- "socialMedia": [
187
- "https://twitter.com/example",
188
- "https://instagram.com/example"
189
- ],
190
- "features": [
191
- { "name": "Store", "enabled": true },
192
- { "name": "Admin", "enabled": false }
193
- ]
194
- }
195
- ```
196
-
197
- ## Schema API
198
-
199
- ### Primitives
200
-
201
- ```typescript
202
- c.string({
203
- required: true,
204
- env: "MY_VAR",
205
- cli: true,
206
- defaultValue: "fallback",
207
- });
208
- c.number({ required: true, env: "PORT" });
209
- c.bool({ env: "DEBUG", defaultValue: false });
210
- ```
211
-
212
- ### Objects
213
-
214
- Use `c.object()` to declare nested object schemas:
215
-
216
- ```typescript
217
- c.object({
218
- item: {
219
- host: c.string(),
220
- port: c.number(),
221
- },
222
- });
223
- ```
224
-
225
- Objects can be nested arbitrarily deep:
226
-
227
- ```typescript
228
- c.schema({
229
- database: c.object({
230
- item: {
231
- host: c.string(),
232
- port: c.number(),
233
- credentials: c.object({
234
- item: {
235
- username: c.string(),
236
- password: c.string({ env: "DB_PASSWORD" }),
237
- },
238
- }),
239
- },
240
- }),
241
- });
242
- ```
243
-
244
- `c.object()` accepts a `required` option (defaults to `false`). When the entire subtree is absent from all sources, child `required` options will trigger errors through normal validation.
245
-
246
- ### Arrays
247
-
248
- ```typescript
249
- c.array({ required: true, item: c.string() }); // string[]
250
- c.array({ required: true, item: c.number() }); // number[]
251
- c.array({
252
- item: c.object({
253
- item: { name: c.string(), age: c.number() },
254
- }),
255
- }); // { name: string; age: number }[]
256
- ```
257
-
258
- ## Enum Constraints (`oneOf`)
259
-
260
- Use `oneOf` to restrict a field to a fixed set of allowed values. The check runs after type coercion and before any `validate` schema:
261
-
262
- ```typescript
263
- const config = c
264
- .schema({
265
- env: c.string({
266
- env: "NODE_ENV",
267
- defaultValue: "development",
268
- oneOf: ["development", "staging", "production"],
269
- }),
270
- logLevel: c.number({
271
- env: "LOG_LEVEL",
272
- defaultValue: 1,
273
- oneOf: [0, 1, 2, 3],
274
- }),
275
- })
276
- .load({ env: true, args: false });
277
- ```
278
-
279
- If a value is not in the allowed set, a `ConfigLoadError` is thrown with `kind: "validation"`.
280
-
281
- ### Type Narrowing
282
-
283
- When `oneOf` is provided, the inferred type is automatically narrowed to the union of the allowed values:
284
-
285
- ```typescript
286
- const config = c
287
- .schema({
288
- env: c.string({ oneOf: ["dev", "staging", "prod"] }),
289
- })
290
- .load({ env: false, args: false });
291
-
292
- // config.env is typed as "dev" | "staging" | "prod", not string
293
- ```
294
-
295
- When used with `cli: true`, the `--help` output automatically lists the allowed values.
296
-
297
- ## Sensitive Fields
298
-
299
- Mark fields as `sensitive: true` to prevent their values from being exposed in logs or debug output:
300
-
301
- ```typescript
302
- const schema = {
303
- host: c.string({ defaultValue: "localhost" }),
304
- apiKey: c.string({ env: "API_KEY", sensitive: true }),
305
- db: c.object({
306
- item: {
307
- host: c.string({ defaultValue: "db.local" }),
308
- password: c.string({ env: "DB_PASS", sensitive: true }),
309
- },
310
- }),
311
- };
312
-
313
- const config = c.schema(schema).load({ env: true, args: false });
314
- ```
315
-
316
- Sensitive values load normally — `config.apiKey` returns the real value. The flag only affects masking utilities.
317
-
318
- ### `printConfig()` auto-masking
319
-
320
- `printConfig()` automatically masks sensitive fields:
321
-
322
- ```typescript
323
- const result = c.schema(schema).loadExtended({ env: true, args: false });
324
- printConfig(result);
325
- // apiKey shows "***" instead of the real value
326
- // db.password shows "***" instead of the real value
327
- ```
328
-
329
- ### `maskSecrets()`
330
-
331
- Use `maskSecrets()` to create a safe-to-log copy of your config:
332
-
333
- ```typescript
334
- import c, { maskSecrets } from "@meltstudio/config-loader";
335
-
336
- // With a plain config from load()
337
- const config = c.schema(schema).load({ env: true, args: false });
338
- console.log(maskSecrets(config, schema));
339
- // { host: "localhost", apiKey: "***", db: { host: "db.local", password: "***" } }
340
-
341
- // With an extended result from loadExtended()
342
- const result = c.schema(schema).loadExtended({ env: true, args: false });
343
- const masked = maskSecrets(result);
344
- // masked.data contains ConfigNodes with "***" for sensitive values
345
- ```
346
-
347
- The original config object is never mutated — `maskSecrets()` always returns a new copy.
348
-
349
- ## Validation
350
-
351
- Add per-field validation using the `validate` option. config-loader accepts any [Standard Schema v1](https://github.com/standard-schema/standard-schema) implementation — including **Zod**, **Valibot**, and **ArkType** — or a custom validator.
352
-
353
- Validation runs **after** type coercion, so validators see the final typed value (e.g., the number `3000`, not the string `"3000"` from an env var).
354
-
355
- ### With Zod
356
-
357
- ```typescript
358
- import c from "@meltstudio/config-loader";
359
- import { z } from "zod";
360
-
361
- const config = c
362
- .schema({
363
- port: c.number({
364
- required: true,
365
- env: "PORT",
366
- validate: z.number().min(1).max(65535),
367
- }),
368
- host: c.string({
369
- required: true,
370
- validate: z.string().url(),
371
- }),
372
- env: c.string({
373
- defaultValue: "development",
374
- validate: z.enum(["development", "staging", "production"]),
375
- }),
376
- })
377
- .load({ env: true, args: false, files: "./config.yaml" });
378
- ```
379
-
380
- ### With a custom validator
381
-
382
- Any object with a `~standard.validate()` method works:
383
-
384
- ```typescript
385
- const portValidator = {
386
- "~standard": {
387
- version: 1,
388
- vendor: "my-app",
389
- validate(value: unknown) {
390
- if (typeof value === "number" && value >= 1 && value <= 65535) {
391
- return { value };
392
- }
393
- return { issues: [{ message: "must be a valid port (1-65535)" }] };
394
- },
395
- },
396
- };
397
-
398
- c.number({ required: true, env: "PORT", validate: portValidator });
399
- ```
400
-
401
- Validation errors are collected alongside other config errors and thrown as `ConfigLoadError` with `kind: "validation"`:
402
-
403
- ```typescript
404
- try {
405
- const config = c.schema({ ... }).load({ ... });
406
- } catch (err) {
407
- if (err instanceof ConfigLoadError) {
408
- for (const entry of err.errors) {
409
- if (entry.kind === "validation") {
410
- console.error(`Validation: ${entry.path} — ${entry.message}`);
411
- }
412
- }
413
- }
414
- }
415
- ```
416
-
417
- ## Loading Sources
418
-
419
- ```typescript
420
- .load({
421
- env: true, // Read from process.env
422
- args: true, // Read from CLI arguments (--database.port 3000)
423
- files: "./config.yaml", // Single YAML file
424
- files: "./config.json", // Single JSON file
425
- files: ["./base.yaml", "./overrides.json"], // Mix YAML and JSON (first takes priority)
426
- dir: "./config.d/", // All files in a directory (sorted)
427
- envFile: "./.env", // Single .env file
428
- envFile: ["./.env", "./.env.local"], // Multiple .env files (later overrides earlier)
429
- defaults: { port: 3000 }, // Programmatic defaults
430
- })
431
- ```
432
-
433
- Both YAML (`.yaml`, `.yml`) and JSON (`.json`) files are supported. The format is detected automatically from the file extension.
434
-
435
- **Priority order:** CLI arguments > `process.env` > `.env` files > Config files > Defaults
436
-
437
- ## Extended Loading (Source Metadata)
438
-
439
- Use `loadExtended()` instead of `load()` to get each value wrapped in a `ConfigNode` that includes source metadata — where the value came from, which file, environment variable, or CLI argument provided it.
440
-
441
- ```typescript
442
- import c from "@meltstudio/config-loader";
443
-
444
- const { data, warnings } = c
445
- .schema({
446
- port: c.number({ required: true, env: "PORT" }),
447
- host: c.string({ defaultValue: "localhost" }),
448
- })
449
- .loadExtended({
450
- env: true,
451
- args: false,
452
- files: "./config.yaml",
453
- });
454
-
455
- // `warnings` is a string[] of non-fatal issues (e.g. type coercions, unused env mappings)
456
- if (warnings.length > 0) {
457
- warnings.forEach((w) => console.warn(w));
458
- }
459
-
460
- // Each leaf in `data` is a ConfigNode with:
461
- // {
462
- // value: 3000,
463
- // path: "port",
464
- // sourceType: "env" | "envFile" | "file" | "args" | "default",
465
- // file: "./config.yaml" | "./.env" | null,
466
- // variableName: "PORT" | null,
467
- // argName: null,
468
- // line: 5 | null, // source line (1-based) for YAML, JSON, and .env files; null for env/args/default
469
- // column: 3 | null // source column (1-based) for YAML, JSON, and .env files; null for env/args/default
470
- // }
471
- console.log(data.port.value); // 3000
472
- console.log(data.port.sourceType); // "env"
473
- console.log(data.port.variableName); // "PORT"
474
- ```
475
-
476
- This is useful for debugging configuration resolution, building admin UIs that show where each setting originated, or auditing which sources are active.
477
-
478
- ### Debug Helper
479
-
480
- Use `printConfig()` to format the result of `loadExtended()` as a readable table:
481
-
482
- ```typescript
483
- import c, { printConfig } from "@meltstudio/config-loader";
484
-
485
- const result = c
486
- .schema({
487
- host: c.string({ defaultValue: "localhost" }),
488
- port: c.number({ env: "PORT" }),
489
- debug: c.bool({ cli: true }),
490
- })
491
- .loadExtended({ env: true, args: true, files: "./config.yaml" });
492
-
493
- printConfig(result);
494
- ```
495
-
496
- Output:
497
-
498
- ```
499
- ┌───────┬───────────┬─────────┬────────────────┐
500
- │ Path │ Value │ Source │ Detail │
501
- ├───────┼───────────┼─────────┼────────────────┤
502
- │ host │ localhost │ default │ │
503
- │ port │ 8080 │ env │ PORT │
504
- │ debug │ true │ args │ --debug │
505
- └───────┴───────────┴─────────┴────────────────┘
506
- ```
507
-
508
- Options:
69
+ Requires Node.js >= 20.
509
70
 
510
- - `printConfig(result, { silent: true })` — returns the string without printing to console
511
- - `printConfig(result, { maxValueLength: 30 })` — truncate long values (default: 50)
71
+ ## Documentation
512
72
 
513
- ## Error Handling
73
+ See the **[full documentation](https://meltstudio.github.io/config-loader/)** for:
514
74
 
515
- When validation fails, config-loader throws a `ConfigLoadError` with structured error details:
75
+ - [Schema API](https://meltstudio.github.io/config-loader/schema-api) primitives, objects, arrays, `oneOf`, `sensitive`, validation
76
+ - [Loading & Sources](https://meltstudio.github.io/config-loader/loading-and-sources) — `load()`, `loadExtended()`, `watch()`, file/env/CLI/.env sources, `printConfig()`, `maskSecrets()`, error handling, strict mode
77
+ - [TypeScript Utilities](https://meltstudio.github.io/config-loader/typescript-utilities) — `SchemaValue`, exported types, type narrowing
516
78
 
517
- ```typescript
518
- import c, { ConfigLoadError } from "@meltstudio/config-loader";
519
-
520
- try {
521
- const config = c.schema({ port: c.number({ required: true }) }).load({
522
- env: false,
523
- args: false,
524
- files: "./config.yaml",
525
- });
526
- } catch (err) {
527
- if (err instanceof ConfigLoadError) {
528
- for (const entry of err.errors) {
529
- console.error(`[${entry.kind}] ${entry.message}`);
530
- // e.g. [required] Required option 'port' not provided.
531
- }
532
- }
533
- }
534
- ```
79
+ ## Examples
535
80
 
536
- Warnings (non-fatal issues like type coercions) are never printed to the console. Use `loadExtended()` to access them, or they are included in `ConfigLoadError.warnings` when errors occur.
537
-
538
- ### Strict Mode
539
-
540
- Enable `strict: true` to promote all warnings to errors, causing `ConfigLoadError` to be thrown for any ambiguous or lossy configuration:
541
-
542
- ```typescript
543
- .load({
544
- env: true,
545
- args: false,
546
- files: "./config.yaml",
547
- strict: true,
548
- })
549
- ```
81
+ The [`example/`](./example) directory contains runnable examples:
550
82
 
551
- This is useful in production environments where you want to catch type coercions, null values, and other ambiguous config early rather than silently accepting them.
552
-
553
- ## TypeScript Utilities
554
-
555
- config-loader exports several types for advanced use cases:
556
-
557
- ```typescript
558
- import c, {
559
- type SchemaValue, // Infer the resolved config type from a schema
560
- type SettingsSources, // Type for the sources object passed to load()
561
- type ExtendedResult, // Return type of loadExtended()
562
- type NodeTree, // Tree of ConfigNode objects (ExtendedResult.data)
563
- ConfigNode, // Class representing a resolved value with source metadata
564
- ConfigNodeArray, // Class representing an array of ConfigNode values
565
- type RecursivePartial, // Deep partial utility used by the defaults option
566
- type StandardSchemaV1, // Standard Schema v1 interface for validators
567
- maskSecrets, // Create a safe-to-log copy with sensitive values masked
568
- printConfig, // Format loadExtended() result as a readable table
569
- } from "@meltstudio/config-loader";
570
- ```
571
-
572
- The most commonly needed is `SchemaValue`, which infers the plain TypeScript type from a schema:
573
-
574
- ```typescript
575
- const mySchema = {
576
- port: c.number({ env: "PORT" }),
577
- db: c.object({ item: { host: c.string(), port: c.number() } }),
578
- };
579
-
580
- type MyConfig = SchemaValue<typeof mySchema>;
581
- // { port: number; db: { host: string; port: number } }
582
- ```
583
-
584
- ## CLI Arguments
585
-
586
- Set `cli: true` on an option to allow overriding via command line:
587
-
588
- ```typescript
589
- c.schema({
590
- version: c.string({ required: true, cli: true }),
591
- });
592
- ```
83
+ - **[Basic](./example/basic)** Schema definition, YAML file loading, nested objects and arrays, CLI arguments
84
+ - **[Advanced](./example/advanced)** — TOML config, `.env` files, `oneOf` constraints, `sensitive` fields, validation, `printConfig()`, `maskSecrets()`, error handling
593
85
 
594
86
  ```bash
595
- node app.js --version 2.0.0
596
- ```
597
-
598
- ## Environment Variables
599
-
600
- Set `env: "VAR_NAME"` on an option and `env: true` in the load options:
601
-
602
- ```typescript
603
- c.schema({
604
- database: c.object({
605
- item: {
606
- password: c.string({ env: "DB_PASSWORD" }),
607
- },
608
- }),
609
- }).load({ env: true, args: false, files: "./config.yaml" });
610
- ```
611
-
612
- ## `.env` File Support
613
-
614
- Load environment variables from `.env` files using the `envFile` option. Options with an `env` mapping automatically pick up values from `.env` files — no new syntax needed on individual fields.
615
-
616
- **.env:**
617
-
618
- ```bash
619
- DB_HOST=localhost
620
- DB_PORT=5432
621
- DB_PASSWORD="s3cret"
622
- APP_NAME='My App'
623
- # This is a comment
624
- ```
625
-
626
- **Usage:**
627
-
628
- ```typescript
629
- const config = c
630
- .schema({
631
- host: c.string({ env: "DB_HOST" }),
632
- port: c.number({ env: "DB_PORT" }),
633
- password: c.string({ env: "DB_PASSWORD" }),
634
- })
635
- .load({
636
- env: true,
637
- args: false,
638
- envFile: "./.env",
639
- });
640
- ```
641
-
642
- `process.env` always takes precedence over `.env` file values. This means you can use `.env` files for development defaults while overriding them in production via real environment variables.
643
-
644
- **Multiple `.env` files:**
645
-
646
- ```typescript
647
- .load({
648
- env: true,
649
- args: false,
650
- envFile: ["./.env", "./.env.local"], // .env.local overrides .env
651
- })
652
- ```
653
-
654
- When using multiple files, later files override earlier ones for the same key.
655
-
656
- The `.env` parser supports:
657
-
658
- - `KEY=VALUE` pairs (whitespace trimmed)
659
- - Comments (lines starting with `#`)
660
- - Quoted values (double `"..."` or single `'...'` quotes stripped)
661
- - Empty values (`KEY=`)
662
-
663
- When using `loadExtended()`, values from `.env` files have `sourceType: "envFile"` with `file`, `line`, and `column` metadata pointing to the `.env` file location.
664
-
665
- ## Common Patterns
666
-
667
- ### Load from YAML with env overrides
668
-
669
- ```typescript
670
- import c from "@meltstudio/config-loader";
671
-
672
- const config = c
673
- .schema({
674
- port: c.number({ required: true, env: "PORT", defaultValue: 3000 }),
675
- host: c.string({ required: true, env: "HOST", defaultValue: "localhost" }),
676
- })
677
- .load({ env: true, args: false, files: "./config.yaml" });
678
- ```
679
-
680
- ### Strict mode for production
681
-
682
- ```typescript
683
- const config = c
684
- .schema({
685
- port: c.number({ required: true, env: "PORT" }),
686
- dbUrl: c.string({ required: true, env: "DATABASE_URL" }),
687
- })
688
- .load({
689
- env: true,
690
- args: false,
691
- files: "./config.yaml",
692
- strict: true, // any type coercion or ambiguity throws an error
693
- });
694
- ```
695
-
696
- ### Catch and inspect errors
697
-
698
- ```typescript
699
- import c, { ConfigLoadError } from "@meltstudio/config-loader";
700
-
701
- try {
702
- const config = c
703
- .schema({ port: c.number({ required: true }) })
704
- .load({ env: false, args: false, files: "./config.yaml" });
705
- } catch (err) {
706
- if (err instanceof ConfigLoadError) {
707
- for (const entry of err.errors) {
708
- console.error(`[${entry.kind}] ${entry.path}: ${entry.message}`);
709
- }
710
- // err.warnings contains non-fatal issues
711
- }
712
- }
87
+ yarn example:basic
88
+ yarn example:advanced
713
89
  ```
714
90
 
715
- ### Load from a directory of config files
716
-
717
- ```typescript
718
- const config = c
719
- .schema({
720
- port: c.number({ required: true }),
721
- host: c.string({ required: true }),
722
- })
723
- .load({ env: false, args: false, dir: "./config.d/" });
724
- // All YAML/JSON files in the directory are loaded and merged (sorted by filename)
725
- ```
726
-
727
- ### Access source metadata with loadExtended
728
-
729
- ```typescript
730
- const { data, warnings } = c
731
- .schema({
732
- port: c.number({ required: true, env: "PORT" }),
733
- })
734
- .loadExtended({ env: true, args: false, files: "./config.yaml" });
735
-
736
- const portNode = data.port; // ConfigNode
737
- console.log(portNode.value); // 3000
738
- console.log(portNode.sourceType); // "env" | "file" | "default" | "args" | "envFile"
739
- console.log(portNode.file); // "./config.yaml" or null
740
- console.log(portNode.line); // source line number or null
741
- ```
742
-
743
- ### Combine .env files with process.env
744
-
745
- ```typescript
746
- const config = c
747
- .schema({
748
- apiKey: c.string({ required: true, env: "API_KEY" }),
749
- debug: c.bool({ env: "DEBUG", defaultValue: false }),
750
- })
751
- .load({
752
- env: true, // reads process.env
753
- args: false,
754
- envFile: ["./.env", "./.env.local"], // .env.local overrides .env
755
- });
756
- // Priority: process.env > .env.local > .env > defaults
757
- ```
758
-
759
- ## Common Mistakes
760
-
761
- ### Forgetting `item` in `c.object()`
762
-
763
- ```typescript
764
- // WRONG — fields are passed directly
765
- c.object({ host: c.string(), port: c.number() });
766
-
767
- // CORRECT — fields must be inside `item`
768
- c.object({ item: { host: c.string(), port: c.number() } });
769
- ```
770
-
771
- ### Setting `env` on an option but not enabling env loading
772
-
773
- ```typescript
774
- // WRONG — env: "PORT" is set but env loading is disabled
775
- c.schema({ port: c.number({ env: "PORT" }) }).load({
776
- env: false,
777
- args: false,
778
- files: "./config.yaml",
779
- });
780
- // This emits a warning: "Options [port] have env mappings but env loading is disabled"
781
-
782
- // CORRECT — set env: true in load options
783
- c.schema({ port: c.number({ env: "PORT" }) }).load({
784
- env: true,
785
- args: false,
786
- files: "./config.yaml",
787
- });
788
- ```
789
-
790
- ### Expecting `.env` files to work without `envFile`
791
-
792
- ```typescript
793
- // WRONG — .env files are not loaded by default
794
- c.schema({ key: c.string({ env: "API_KEY" }) }).load({
795
- env: true,
796
- args: false,
797
- });
798
- // This only reads process.env, not .env files
799
-
800
- // CORRECT — explicitly pass envFile
801
- c.schema({ key: c.string({ env: "API_KEY" }) }).load({
802
- env: true,
803
- args: false,
804
- envFile: "./.env",
805
- });
806
- ```
807
-
808
- ### Not catching `ConfigLoadError`
809
-
810
- ```typescript
811
- // WRONG — unhandled error crashes the process with an unhelpful stack trace
812
- const config = c
813
- .schema({ port: c.number({ required: true }) })
814
- .load({ env: false, args: false });
815
-
816
- // CORRECT — catch and handle structured errors
817
- try {
818
- const config = c
819
- .schema({ port: c.number({ required: true }) })
820
- .load({ env: false, args: false });
821
- } catch (err) {
822
- if (err instanceof ConfigLoadError) {
823
- console.error(err.errors); // structured error details
824
- }
825
- }
826
- ```
827
-
828
- ### Using `required: true` on nested fields without the parent object
829
-
830
- If the parent object is entirely absent from all sources, child `required` fields will still trigger errors. Use `required` on the parent `c.object()` only if the entire subtree must be present.
831
-
832
91
  ## Documentation for AI Agents
833
92
 
834
93
  This project provides machine-readable documentation for AI coding agents at the docs site:
@@ -840,4 +99,4 @@ These files follow the [llms.txt standard](https://llmstxt.org/) and are generat
840
99
 
841
100
  ## License
842
101
 
843
- This package is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
102
+ Built by [Melt Studio](https://meltstudio.co). Licensed under the [MIT License](./LICENSE).
package/dist/index.d.ts CHANGED
@@ -207,6 +207,40 @@ declare class ObjectOption<T extends Node = Node> extends OptionBase<"object"> {
207
207
 
208
208
  type OptionTypes = PrimitiveOption | ArrayOption<OptionTypes> | ObjectOption<Node>;
209
209
 
210
+ /** A single change detected between two config loads. */
211
+ interface ConfigChange {
212
+ /** Dot-separated path to the changed key (e.g. "db.url"). */
213
+ path: string;
214
+ /** The previous value (undefined if key was added). */
215
+ oldValue: unknown;
216
+ /** The new value (undefined if key was removed). */
217
+ newValue: unknown;
218
+ /** Whether the change was an addition, removal, or modification. */
219
+ type: "added" | "removed" | "changed";
220
+ }
221
+ /**
222
+ * Compares two plain config objects and returns a list of changes.
223
+ * Sensitive fields (from the schema) are masked in the output.
224
+ */
225
+ declare function diffConfig(oldConfig: Record<string, unknown>, newConfig: Record<string, unknown>, schema?: Node): ConfigChange[];
226
+
227
+ /** Options for the `watch()` method. */
228
+ interface WatchOptions<T> {
229
+ /** Called after a successful reload with the new config, old config, and list of changes. */
230
+ onChange: (newConfig: T, oldConfig: T, changes: ConfigChange[]) => void;
231
+ /** Called when a reload fails (parse error, validation error). The previous config is retained. */
232
+ onError?: (error: Error) => void;
233
+ /** Debounce interval in milliseconds. Default: 100. */
234
+ debounce?: number;
235
+ }
236
+ /** Handle returned by `watch()`. Provides access to the current config and a `close()` method. */
237
+ interface ConfigWatcher<T> {
238
+ /** The current resolved configuration. Updated on each successful reload. */
239
+ readonly config: T;
240
+ /** Stop watching all files. Idempotent. */
241
+ close(): void;
242
+ }
243
+
210
244
  /** Fluent builder that takes a schema and resolves configuration from multiple sources. */
211
245
  declare class SettingsBuilder<T extends Node> {
212
246
  private readonly schema;
@@ -225,6 +259,15 @@ declare class SettingsBuilder<T extends Node> {
225
259
  * @throws {ConfigLoadError} If validation fails.
226
260
  */
227
261
  loadExtended(sources: SettingsSources<SchemaValue<T>>): ExtendedResult;
262
+ /**
263
+ * Watches config files for changes and reloads automatically.
264
+ * File watchers are `.unref()`'d so they don't prevent the process from exiting.
265
+ * @param sources - Which sources to read (env, args, files, etc.).
266
+ * @param options - Callbacks and debounce configuration.
267
+ * @returns A `ConfigWatcher` with the current config and a `close()` method.
268
+ * @throws {ConfigLoadError} If the initial load fails.
269
+ */
270
+ watch(sources: SettingsSources<SchemaValue<T>>, options: WatchOptions<SchemaValue<T>>): ConfigWatcher<SchemaValue<T>>;
228
271
  }
229
272
 
230
273
  /**
@@ -363,4 +406,4 @@ declare const option: {
363
406
  schema: <T extends Node>(theSchema: T) => SettingsBuilder<T>;
364
407
  };
365
408
 
366
- export { type ConfigErrorEntry, ConfigFileError, ConfigLoadError, ConfigNode, ConfigNodeArray, type ExtendedResult, type NodeTree, type RecursivePartial, type SchemaValue, type SettingsSources, type StandardSchemaV1, option as default, maskSecrets, printConfig };
409
+ export { type ConfigChange, type ConfigErrorEntry, ConfigFileError, ConfigLoadError, ConfigNode, ConfigNodeArray, type ConfigWatcher, type ExtendedResult, type NodeTree, type RecursivePartial, type SchemaValue, type SettingsSources, type StandardSchemaV1, type WatchOptions, option as default, diffConfig, maskSecrets, printConfig };
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  ConfigNode: () => configNode_default,
36
36
  ConfigNodeArray: () => configNodeArray_default,
37
37
  default: () => index_default,
38
+ diffConfig: () => diffConfig,
38
39
  maskSecrets: () => maskSecrets,
39
40
  printConfig: () => printConfig
40
41
  });
@@ -126,6 +127,7 @@ var fs = __toESM(require("fs"));
126
127
  var import_js_yaml = __toESM(require("js-yaml"));
127
128
  var import_js_yaml_source_map = __toESM(require("js-yaml-source-map"));
128
129
  var path = __toESM(require("path"));
130
+ var import_smol_toml = require("smol-toml");
129
131
  var fileCache = /* @__PURE__ */ new Map();
130
132
  var JsonSourceMap = class {
131
133
  locations = /* @__PURE__ */ new Map();
@@ -188,6 +190,47 @@ var JsonSourceMap = class {
188
190
  return this.locations.get(key);
189
191
  }
190
192
  };
193
+ function escapeRegex(str) {
194
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195
+ }
196
+ function walkTomlObject(obj, prefix, lines, locations) {
197
+ for (const key of Object.keys(obj)) {
198
+ const fullPath = [...prefix, key].join(".");
199
+ for (let i = 0; i < lines.length; i++) {
200
+ const line = lines[i];
201
+ const keyPattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
202
+ if (keyPattern.test(line)) {
203
+ const idx = line.indexOf(key);
204
+ locations.set(fullPath, {
205
+ line: i + 1,
206
+ column: idx + 1,
207
+ position: 0
208
+ });
209
+ break;
210
+ }
211
+ }
212
+ const val = obj[key];
213
+ if (val && typeof val === "object" && !Array.isArray(val)) {
214
+ walkTomlObject(
215
+ val,
216
+ [...prefix, key],
217
+ lines,
218
+ locations
219
+ );
220
+ }
221
+ }
222
+ }
223
+ function buildTomlSourceMap(content, data) {
224
+ const locations = /* @__PURE__ */ new Map();
225
+ const lines = content.split("\n");
226
+ walkTomlObject(data, [], lines, locations);
227
+ return {
228
+ lookup(lookupPath) {
229
+ const key = Array.isArray(lookupPath) ? lookupPath.join(".") : lookupPath;
230
+ return locations.get(key);
231
+ }
232
+ };
233
+ }
191
234
  function loadConfigFile(filePath) {
192
235
  const cached = fileCache.get(filePath);
193
236
  if (cached) return cached;
@@ -202,6 +245,15 @@ function loadConfigFile(filePath) {
202
245
  fileCache.set(filePath, result2);
203
246
  return result2;
204
247
  }
248
+ if (ext === ".toml") {
249
+ const data2 = (0, import_smol_toml.parse)(content);
250
+ const result2 = {
251
+ data: data2,
252
+ sourceMap: buildTomlSourceMap(content, data2)
253
+ };
254
+ fileCache.set(filePath, result2);
255
+ return result2;
256
+ }
205
257
  const sourceMap = new import_js_yaml_source_map.default();
206
258
  const data = import_js_yaml.default.load(content, { listener: sourceMap.listen() });
207
259
  const result = { data, sourceMap };
@@ -1102,6 +1154,176 @@ var Settings = class {
1102
1154
  };
1103
1155
  var settings_default = Settings;
1104
1156
 
1157
+ // src/watcher.ts
1158
+ var fs4 = __toESM(require("fs"));
1159
+
1160
+ // src/diffConfig.ts
1161
+ var MASK = "***";
1162
+ function collectSensitivePaths(schema2, prefix = "") {
1163
+ const paths = /* @__PURE__ */ new Set();
1164
+ for (const key of Object.keys(schema2)) {
1165
+ const opt = schema2[key];
1166
+ const fullPath = prefix ? `${prefix}.${key}` : key;
1167
+ if (opt instanceof PrimitiveOption && opt.params.sensitive) {
1168
+ paths.add(fullPath);
1169
+ }
1170
+ if (opt instanceof ObjectOption) {
1171
+ const childPaths = collectSensitivePaths(opt.item, fullPath);
1172
+ for (const p of childPaths) {
1173
+ paths.add(p);
1174
+ }
1175
+ }
1176
+ }
1177
+ return paths;
1178
+ }
1179
+ function diffObjects(oldObj, newObj, sensitivePaths, prefix = "") {
1180
+ const changes = [];
1181
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
1182
+ for (const key of allKeys) {
1183
+ const fullPath = prefix ? `${prefix}.${key}` : key;
1184
+ const isSensitive = sensitivePaths.has(fullPath);
1185
+ const oldVal = oldObj[key];
1186
+ const newVal = newObj[key];
1187
+ const hasOld = key in oldObj;
1188
+ const hasNew = key in newObj;
1189
+ if (hasNew && !hasOld) {
1190
+ changes.push({
1191
+ path: fullPath,
1192
+ type: "added",
1193
+ oldValue: void 0,
1194
+ newValue: isSensitive ? MASK : newVal
1195
+ });
1196
+ } else if (hasOld && !hasNew) {
1197
+ changes.push({
1198
+ path: fullPath,
1199
+ type: "removed",
1200
+ oldValue: isSensitive ? MASK : oldVal,
1201
+ newValue: void 0
1202
+ });
1203
+ } else if (oldVal !== null && newVal !== null && typeof oldVal === "object" && typeof newVal === "object" && !Array.isArray(oldVal) && !Array.isArray(newVal)) {
1204
+ changes.push(
1205
+ ...diffObjects(
1206
+ oldVal,
1207
+ newVal,
1208
+ sensitivePaths,
1209
+ fullPath
1210
+ )
1211
+ );
1212
+ } else if (!Object.is(oldVal, newVal)) {
1213
+ if (Array.isArray(oldVal) && Array.isArray(newVal) && JSON.stringify(oldVal) === JSON.stringify(newVal)) {
1214
+ continue;
1215
+ }
1216
+ changes.push({
1217
+ path: fullPath,
1218
+ type: "changed",
1219
+ oldValue: isSensitive ? MASK : oldVal,
1220
+ newValue: isSensitive ? MASK : newVal
1221
+ });
1222
+ }
1223
+ }
1224
+ return changes;
1225
+ }
1226
+ function diffConfig(oldConfig, newConfig, schema2) {
1227
+ const sensitivePaths = schema2 ? collectSensitivePaths(schema2) : /* @__PURE__ */ new Set();
1228
+ return diffObjects(oldConfig, newConfig, sensitivePaths);
1229
+ }
1230
+
1231
+ // src/watcher.ts
1232
+ function resolveFilePaths(sources) {
1233
+ const paths = [];
1234
+ const configFiles = validateFiles(
1235
+ sources.files ?? false,
1236
+ sources.dir ?? false
1237
+ );
1238
+ if (Array.isArray(configFiles)) {
1239
+ paths.push(...configFiles);
1240
+ } else if (configFiles) {
1241
+ paths.push(configFiles);
1242
+ }
1243
+ if (sources.envFile) {
1244
+ const envFiles = Array.isArray(sources.envFile) ? sources.envFile : [sources.envFile];
1245
+ paths.push(...envFiles);
1246
+ }
1247
+ return paths;
1248
+ }
1249
+ function createWatcher(schema2, sources, options) {
1250
+ const debounceMs = options.debounce ?? 100;
1251
+ const initialSettings = new settings_default(schema2, sources);
1252
+ let currentConfig = initialSettings.get();
1253
+ const filePaths = resolveFilePaths(sources);
1254
+ const watchers = [];
1255
+ let debounceTimer = null;
1256
+ let closed = false;
1257
+ function reload() {
1258
+ if (closed) return;
1259
+ try {
1260
+ clearFileCache();
1261
+ const settings = new settings_default(schema2, sources);
1262
+ const newConfig = settings.get();
1263
+ const changes = diffConfig(
1264
+ currentConfig,
1265
+ newConfig,
1266
+ schema2
1267
+ );
1268
+ if (changes.length > 0) {
1269
+ const oldConfig = currentConfig;
1270
+ currentConfig = newConfig;
1271
+ options.onChange(newConfig, oldConfig, changes);
1272
+ }
1273
+ } catch (err) {
1274
+ if (options.onError) {
1275
+ options.onError(err instanceof Error ? err : new Error(String(err)));
1276
+ }
1277
+ }
1278
+ }
1279
+ function scheduleReload() {
1280
+ if (closed) return;
1281
+ if (debounceTimer) {
1282
+ clearTimeout(debounceTimer);
1283
+ }
1284
+ debounceTimer = setTimeout(reload, debounceMs);
1285
+ debounceTimer.unref();
1286
+ }
1287
+ for (const filePath of filePaths) {
1288
+ try {
1289
+ const watcher = fs4.watch(filePath, () => {
1290
+ scheduleReload();
1291
+ });
1292
+ watcher.unref();
1293
+ watchers.push(watcher);
1294
+ } catch {
1295
+ }
1296
+ }
1297
+ if (sources.dir && typeof sources.dir === "string") {
1298
+ try {
1299
+ const dirWatcher = fs4.watch(sources.dir, () => {
1300
+ scheduleReload();
1301
+ });
1302
+ dirWatcher.unref();
1303
+ watchers.push(dirWatcher);
1304
+ } catch {
1305
+ }
1306
+ }
1307
+ function close() {
1308
+ if (closed) return;
1309
+ closed = true;
1310
+ if (debounceTimer) {
1311
+ clearTimeout(debounceTimer);
1312
+ debounceTimer = null;
1313
+ }
1314
+ for (const watcher of watchers) {
1315
+ watcher.close();
1316
+ }
1317
+ watchers.length = 0;
1318
+ }
1319
+ return {
1320
+ get config() {
1321
+ return currentConfig;
1322
+ },
1323
+ close
1324
+ };
1325
+ }
1326
+
1105
1327
  // src/builder/settings.ts
1106
1328
  var SettingsBuilder = class {
1107
1329
  schema;
@@ -1131,17 +1353,28 @@ var SettingsBuilder = class {
1131
1353
  warnings: settings.getWarnings()
1132
1354
  };
1133
1355
  }
1356
+ /**
1357
+ * Watches config files for changes and reloads automatically.
1358
+ * File watchers are `.unref()`'d so they don't prevent the process from exiting.
1359
+ * @param sources - Which sources to read (env, args, files, etc.).
1360
+ * @param options - Callbacks and debounce configuration.
1361
+ * @returns A `ConfigWatcher` with the current config and a `close()` method.
1362
+ * @throws {ConfigLoadError} If the initial load fails.
1363
+ */
1364
+ watch(sources, options) {
1365
+ return createWatcher(this.schema, sources, options);
1366
+ }
1134
1367
  };
1135
1368
 
1136
1369
  // src/maskSecrets.ts
1137
- var MASK = "***";
1370
+ var MASK2 = "***";
1138
1371
  function maskNodeTree(tree) {
1139
1372
  const result = {};
1140
1373
  for (const [key, entry] of Object.entries(tree)) {
1141
1374
  if (entry instanceof configNode_default) {
1142
1375
  if (entry.sensitive) {
1143
1376
  const masked = new configNode_default(
1144
- MASK,
1377
+ MASK2,
1145
1378
  entry.path,
1146
1379
  entry.sourceType,
1147
1380
  entry.file,
@@ -1182,7 +1415,7 @@ function maskPlainObject(obj, schema2) {
1182
1415
  result[key] = value;
1183
1416
  }
1184
1417
  } else if (schemaNode instanceof OptionBase) {
1185
- result[key] = schemaNode.params.sensitive ? MASK : value;
1418
+ result[key] = schemaNode.params.sensitive ? MASK2 : value;
1186
1419
  } else {
1187
1420
  result[key] = value;
1188
1421
  }
@@ -1210,7 +1443,7 @@ function truncate(str, max) {
1210
1443
  }
1211
1444
  function formatValue(val) {
1212
1445
  if (val === null || val === void 0) return "";
1213
- if (typeof val === "object") return JSON.stringify(val) ?? "";
1446
+ if (typeof val === "object") return JSON.stringify(val);
1214
1447
  if (typeof val === "string") return val;
1215
1448
  return `${val}`;
1216
1449
  }
@@ -1382,6 +1615,7 @@ var index_default = option;
1382
1615
  ConfigLoadError,
1383
1616
  ConfigNode,
1384
1617
  ConfigNodeArray,
1618
+ diffConfig,
1385
1619
  maskSecrets,
1386
1620
  printConfig
1387
1621
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meltstudio/config-loader",
3
- "version": "3.5.0",
4
- "description": "Type-safe configuration loader with full TypeScript inference. Load from YAML, JSON, .env, environment variables, and CLI args.",
3
+ "version": "3.7.0",
4
+ "description": "Type-safe configuration loader with full TypeScript inference. Load from YAML, JSON, TOML, .env, environment variables, and CLI args.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "repository": "https://github.com/MeltStudio/config-loader",
@@ -15,6 +15,7 @@
15
15
  "config",
16
16
  "yaml",
17
17
  "json",
18
+ "toml",
18
19
  "loader",
19
20
  "env",
20
21
  "environment",
@@ -38,7 +39,8 @@
38
39
  "type-check": "tsc --noEmit",
39
40
  "test": "jest --verbose",
40
41
  "replace-tspaths": "./scripts/replace-tspaths/index.mjs",
41
- "example:run": "ts-node -r tsconfig-paths/register ./example/index.ts",
42
+ "example:basic": "ts-node -r tsconfig-paths/register ./example/basic/index.ts",
43
+ "example:advanced": "ts-node -r tsconfig-paths/register ./example/advanced/index.ts",
42
44
  "prepare": "husky",
43
45
  "docs:build": "docusaurus build && node scripts/fix-llms-urls.mjs",
44
46
  "docs:serve": "docusaurus serve",
@@ -48,6 +50,7 @@
48
50
  "commander": "^14.0.0",
49
51
  "js-yaml": "^4.1.0",
50
52
  "js-yaml-source-map": "^0.2.2",
53
+ "smol-toml": "^1.6.0",
51
54
  "tslib": "^2.3.0"
52
55
  },
53
56
  "devDependencies": {