@meltstudio/config-loader 3.6.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
@@ -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
 
@@ -72,9 +73,21 @@ Requires Node.js >= 20.
72
73
  See the **[full documentation](https://meltstudio.github.io/config-loader/)** for:
73
74
 
74
75
  - [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
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
76
77
  - [TypeScript Utilities](https://meltstudio.github.io/config-loader/typescript-utilities) — `SchemaValue`, exported types, type narrowing
77
78
 
79
+ ## Examples
80
+
81
+ The [`example/`](./example) directory contains runnable examples:
82
+
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
85
+
86
+ ```bash
87
+ yarn example:basic
88
+ yarn example:advanced
89
+ ```
90
+
78
91
  ## Documentation for AI Agents
79
92
 
80
93
  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,176 @@ 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
+ 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
+
1156
1327
  // src/builder/settings.ts
1157
1328
  var SettingsBuilder = class {
1158
1329
  schema;
@@ -1182,17 +1353,28 @@ var SettingsBuilder = class {
1182
1353
  warnings: settings.getWarnings()
1183
1354
  };
1184
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
+ }
1185
1367
  };
1186
1368
 
1187
1369
  // src/maskSecrets.ts
1188
- var MASK = "***";
1370
+ var MASK2 = "***";
1189
1371
  function maskNodeTree(tree) {
1190
1372
  const result = {};
1191
1373
  for (const [key, entry] of Object.entries(tree)) {
1192
1374
  if (entry instanceof configNode_default) {
1193
1375
  if (entry.sensitive) {
1194
1376
  const masked = new configNode_default(
1195
- MASK,
1377
+ MASK2,
1196
1378
  entry.path,
1197
1379
  entry.sourceType,
1198
1380
  entry.file,
@@ -1233,7 +1415,7 @@ function maskPlainObject(obj, schema2) {
1233
1415
  result[key] = value;
1234
1416
  }
1235
1417
  } else if (schemaNode instanceof OptionBase) {
1236
- result[key] = schemaNode.params.sensitive ? MASK : value;
1418
+ result[key] = schemaNode.params.sensitive ? MASK2 : value;
1237
1419
  } else {
1238
1420
  result[key] = value;
1239
1421
  }
@@ -1433,6 +1615,7 @@ var index_default = option;
1433
1615
  ConfigLoadError,
1434
1616
  ConfigNode,
1435
1617
  ConfigNodeArray,
1618
+ diffConfig,
1436
1619
  maskSecrets,
1437
1620
  printConfig
1438
1621
  });
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.0",
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",