@meltstudio/config-loader 3.6.0 → 3.7.1

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
@@ -2,7 +2,7 @@
2
2
 
3
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
 
@@ -54,6 +54,7 @@ const config = c
54
54
  - **Strict mode** — promote warnings to errors for production safety
55
55
  - **Default values** — static or computed (via functions)
56
56
  - **Multiple files / directory loading** — load from a list of files or an entire directory
57
+ - **File watching** — `watch()` reloads config on file changes with debouncing, change detection, and error recovery
57
58
 
58
59
  ## Installation
59
60
 
@@ -67,14 +68,57 @@ yarn add @meltstudio/config-loader
67
68
 
68
69
  Requires Node.js >= 20.
69
70
 
71
+ ## Watch Mode
72
+
73
+ Automatically reload config when files change. Watchers use `.unref()` so they don't prevent the process from exiting.
74
+
75
+ ```typescript
76
+ const watcher = c
77
+ .schema({
78
+ port: c.number({ env: "PORT", defaultValue: 3000 }),
79
+ host: c.string({ defaultValue: "localhost" }),
80
+ })
81
+ .watch(
82
+ { env: true, args: false, files: "./config.yaml" },
83
+ {
84
+ onChange: (newConfig, oldConfig, changes) => {
85
+ for (const change of changes) {
86
+ console.log(
87
+ `${change.path}: ${String(change.oldValue)} → ${String(change.newValue)}`,
88
+ );
89
+ }
90
+ },
91
+ onError: (err) => console.error("Reload failed:", err.message),
92
+ },
93
+ );
94
+
95
+ // Access current config anytime
96
+ console.log(watcher.config.port);
97
+
98
+ // Stop watching
99
+ watcher.close();
100
+ ```
101
+
70
102
  ## Documentation
71
103
 
72
104
  See the **[full documentation](https://meltstudio.github.io/config-loader/)** for:
73
105
 
74
106
  - [Schema API](https://meltstudio.github.io/config-loader/schema-api) — primitives, objects, arrays, `oneOf`, `sensitive`, validation
75
- - [Loading & Sources](https://meltstudio.github.io/config-loader/loading-and-sources) — `load()`, `loadExtended()`, file/env/CLI/.env sources, `printConfig()`, `maskSecrets()`, error handling, strict mode
107
+ - [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
76
108
  - [TypeScript Utilities](https://meltstudio.github.io/config-loader/typescript-utilities) — `SchemaValue`, exported types, type narrowing
77
109
 
110
+ ## Examples
111
+
112
+ The [`example/`](./example) directory contains runnable examples:
113
+
114
+ - **[Basic](./example/basic)** — Schema definition, YAML file loading, nested objects and arrays, CLI arguments
115
+ - **[Advanced](./example/advanced)** — TOML config, `.env` files, `oneOf` constraints, `sensitive` fields, validation, `printConfig()`, `maskSecrets()`, error handling
116
+
117
+ ```bash
118
+ yarn example:basic
119
+ yarn example:advanced
120
+ ```
121
+
78
122
  ## Documentation for AI Agents
79
123
 
80
124
  This project provides machine-readable documentation for AI coding agents at the docs site:
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
  });
@@ -1153,6 +1154,177 @@ var Settings = class {
1153
1154
  };
1154
1155
  var settings_default = Settings;
1155
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
+ clearEnvFileCache();
1262
+ const settings = new settings_default(schema2, sources);
1263
+ const newConfig = settings.get();
1264
+ const changes = diffConfig(
1265
+ currentConfig,
1266
+ newConfig,
1267
+ schema2
1268
+ );
1269
+ if (changes.length > 0) {
1270
+ const oldConfig = currentConfig;
1271
+ currentConfig = newConfig;
1272
+ options.onChange(newConfig, oldConfig, changes);
1273
+ }
1274
+ } catch (err) {
1275
+ if (options.onError) {
1276
+ options.onError(err instanceof Error ? err : new Error(String(err)));
1277
+ }
1278
+ }
1279
+ }
1280
+ function scheduleReload() {
1281
+ if (closed) return;
1282
+ if (debounceTimer) {
1283
+ clearTimeout(debounceTimer);
1284
+ }
1285
+ debounceTimer = setTimeout(reload, debounceMs);
1286
+ debounceTimer.unref();
1287
+ }
1288
+ for (const filePath of filePaths) {
1289
+ try {
1290
+ const watcher = fs4.watch(filePath, () => {
1291
+ scheduleReload();
1292
+ });
1293
+ watcher.unref();
1294
+ watchers.push(watcher);
1295
+ } catch {
1296
+ }
1297
+ }
1298
+ if (sources.dir && typeof sources.dir === "string") {
1299
+ try {
1300
+ const dirWatcher = fs4.watch(sources.dir, () => {
1301
+ scheduleReload();
1302
+ });
1303
+ dirWatcher.unref();
1304
+ watchers.push(dirWatcher);
1305
+ } catch {
1306
+ }
1307
+ }
1308
+ function close() {
1309
+ if (closed) return;
1310
+ closed = true;
1311
+ if (debounceTimer) {
1312
+ clearTimeout(debounceTimer);
1313
+ debounceTimer = null;
1314
+ }
1315
+ for (const watcher of watchers) {
1316
+ watcher.close();
1317
+ }
1318
+ watchers.length = 0;
1319
+ }
1320
+ return {
1321
+ get config() {
1322
+ return currentConfig;
1323
+ },
1324
+ close
1325
+ };
1326
+ }
1327
+
1156
1328
  // src/builder/settings.ts
1157
1329
  var SettingsBuilder = class {
1158
1330
  schema;
@@ -1182,17 +1354,28 @@ var SettingsBuilder = class {
1182
1354
  warnings: settings.getWarnings()
1183
1355
  };
1184
1356
  }
1357
+ /**
1358
+ * Watches config files for changes and reloads automatically.
1359
+ * File watchers are `.unref()`'d so they don't prevent the process from exiting.
1360
+ * @param sources - Which sources to read (env, args, files, etc.).
1361
+ * @param options - Callbacks and debounce configuration.
1362
+ * @returns A `ConfigWatcher` with the current config and a `close()` method.
1363
+ * @throws {ConfigLoadError} If the initial load fails.
1364
+ */
1365
+ watch(sources, options) {
1366
+ return createWatcher(this.schema, sources, options);
1367
+ }
1185
1368
  };
1186
1369
 
1187
1370
  // src/maskSecrets.ts
1188
- var MASK = "***";
1371
+ var MASK2 = "***";
1189
1372
  function maskNodeTree(tree) {
1190
1373
  const result = {};
1191
1374
  for (const [key, entry] of Object.entries(tree)) {
1192
1375
  if (entry instanceof configNode_default) {
1193
1376
  if (entry.sensitive) {
1194
1377
  const masked = new configNode_default(
1195
- MASK,
1378
+ MASK2,
1196
1379
  entry.path,
1197
1380
  entry.sourceType,
1198
1381
  entry.file,
@@ -1233,7 +1416,7 @@ function maskPlainObject(obj, schema2) {
1233
1416
  result[key] = value;
1234
1417
  }
1235
1418
  } else if (schemaNode instanceof OptionBase) {
1236
- result[key] = schemaNode.params.sensitive ? MASK : value;
1419
+ result[key] = schemaNode.params.sensitive ? MASK2 : value;
1237
1420
  } else {
1238
1421
  result[key] = value;
1239
1422
  }
@@ -1433,6 +1616,7 @@ var index_default = option;
1433
1616
  ConfigLoadError,
1434
1617
  ConfigNode,
1435
1618
  ConfigNodeArray,
1619
+ diffConfig,
1436
1620
  maskSecrets,
1437
1621
  printConfig
1438
1622
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meltstudio/config-loader",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
4
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",
@@ -39,7 +39,8 @@
39
39
  "type-check": "tsc --noEmit",
40
40
  "test": "jest --verbose",
41
41
  "replace-tspaths": "./scripts/replace-tspaths/index.mjs",
42
- "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",
43
44
  "prepare": "husky",
44
45
  "docs:build": "docusaurus build && node scripts/fix-llms-urls.mjs",
45
46
  "docs:serve": "docusaurus serve",