c12 1.3.0 → 1.4.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
@@ -16,6 +16,7 @@ Smart Configuration Loader.
16
16
  - Reads config from the nearest `package.json` file
17
17
  - [Extends configurations](https://github.com/unjs/c12#extending-configuration) from multiple local or git sources
18
18
  - Overwrite with [environment-specific configuration](#environment-specific-configuration)
19
+ - Config watcher with auto-reload and HMR support
19
20
 
20
21
  ## Usage
21
22
 
@@ -36,10 +37,10 @@ Import:
36
37
 
37
38
  ```js
38
39
  // ESM
39
- import { loadConfig } from "c12";
40
+ import { loadConfig, watchConfig } from "c12";
40
41
 
41
42
  // CommonJS
42
- const { loadConfig } = require("c12");
43
+ const { loadConfig, watchConfig } = require("c12");
43
44
  ```
44
45
 
45
46
  Load configuration:
@@ -59,7 +60,7 @@ c12 merged config sources with [unjs/defu](https://github.com/unjs/defu) by belo
59
60
  1. Config overrides passed by options
60
61
  2. Config file in CWD
61
62
  3. RC file in CWD
62
- 4. Global RC file in user's home directory
63
+ 4. Global RC file in the user's home directory
63
64
  5. Config from `package.json`
64
65
  6. Default config passed by options
65
66
  7. Extended config layers
@@ -234,6 +235,46 @@ c12 tries to match [`envName`](#envname) and override environment config if spec
234
235
  }
235
236
  ```
236
237
 
238
+ ## Watching Configuration
239
+
240
+ you can use `watchConfig` instead of `loadConfig` to load config and watch for changes, add and removals in all expected configuration paths and auto reload with new config.
241
+
242
+ ### Lifecycle hooks
243
+
244
+ - `onWatch`: This function is always called when config is updated, added, or removed before attempting to reload the config.
245
+ - `acceptHMR`: By implementing this function, you can compare old and new functions and return `true` if a full reload is not needed.
246
+ - `onUpdate`: This function is always called after the new config is updated. If `acceptHMR` returns true, it will be skipped.
247
+
248
+ ```ts
249
+ import { watchConfig } from "c12";
250
+
251
+ const config = watchConfig({
252
+ cwd: ".",
253
+ // chokidarOptions: {}, // Default is { ignoreInitial: true }
254
+ // debounce: 200 // Default is 100. You can set it to false to disable debounced watcher
255
+ onWatch: (event) => {
256
+ console.log("[watcher]", event.type, event.path);
257
+ },
258
+ acceptHMR({ oldConfig, newConfig, getDiff }) {
259
+ const diff = getDiff();
260
+ if (diff.length === 0) {
261
+ console.log("No config changed detected!");
262
+ return true; // No changes!
263
+ }
264
+ },
265
+ onUpdate({ oldConfig, newConfig, getDiff }) {
266
+ const diff = getDiff();
267
+ console.log("Config updated:\n" + diff.map((i) => i.toJSON()).join("\n"));
268
+ },
269
+ });
270
+
271
+ console.log("watching config files:", config.watchingFiles);
272
+ console.log("initial config", config.config);
273
+
274
+ // Stop watcher when not needed anymore
275
+ // await config.unwatch();
276
+ ```
277
+
237
278
  ## 💻 Development
238
279
 
239
280
  - Clone this repository
package/dist/index.cjs CHANGED
@@ -9,6 +9,9 @@ const createJiti = require('jiti');
9
9
  const rc9 = require('rc9');
10
10
  const defu = require('defu');
11
11
  const pkgTypes = require('pkg-types');
12
+ const chokidar = require('chokidar');
13
+ const perfectDebounce = require('perfect-debounce');
14
+ const ohash = require('ohash');
12
15
 
13
16
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
14
17
 
@@ -318,7 +321,87 @@ function createDefineConfig() {
318
321
  return (input) => input;
319
322
  }
320
323
 
324
+ const eventMap = {
325
+ add: "created",
326
+ change: "updated",
327
+ unlink: "removed"
328
+ };
329
+ async function watchConfig(options) {
330
+ let config = await loadConfig(options);
331
+ const configName = options.name || "config";
332
+ const configFileName = options.configFile ?? (options.name !== "config" ? `${options.name}.config` : "config");
333
+ const watchingFiles = [
334
+ ...new Set(
335
+ (config.layers || []).filter((l) => l.cwd).flatMap((l) => [
336
+ ...["ts", "js", "mjs", "cjs", "cts", "mts", "json"].map(
337
+ (ext) => pathe.resolve(l.cwd, configFileName + "." + ext)
338
+ ),
339
+ l.source && pathe.resolve(l.cwd, l.source),
340
+ // TODO: Support watching rc from home and workspace
341
+ options.rcFile && pathe.resolve(
342
+ l.cwd,
343
+ typeof options.rcFile === "string" ? options.rcFile : `.${configName}rc`
344
+ ),
345
+ options.packageJson && pathe.resolve(l.cwd, "package.json")
346
+ ]).filter(Boolean)
347
+ )
348
+ ];
349
+ const _fswatcher = chokidar.watch(watchingFiles, {
350
+ ignoreInitial: true,
351
+ ...options.chokidarOptions
352
+ });
353
+ const onChange = async (event, path) => {
354
+ const type = eventMap[event];
355
+ if (!type) {
356
+ return;
357
+ }
358
+ if (options.onWatch) {
359
+ await options.onWatch({
360
+ type,
361
+ path
362
+ });
363
+ }
364
+ const oldConfig = config;
365
+ const newConfig = await loadConfig(options);
366
+ config = newConfig;
367
+ const changeCtx = {
368
+ newConfig,
369
+ oldConfig,
370
+ getDiff: () => ohash.diff(oldConfig.config, config.config)
371
+ };
372
+ if (options.acceptHMR) {
373
+ const changeHandled = await options.acceptHMR(changeCtx);
374
+ if (changeHandled) {
375
+ return;
376
+ }
377
+ }
378
+ if (options.onUpdate) {
379
+ await options.onUpdate(changeCtx);
380
+ }
381
+ };
382
+ if (options.debounce !== false) {
383
+ _fswatcher.on("all", perfectDebounce.debounce(onChange, options.debounce ?? 100));
384
+ } else {
385
+ _fswatcher.on("all", onChange);
386
+ }
387
+ const utils = {
388
+ watchingFiles,
389
+ unwatch: async () => {
390
+ await _fswatcher.close();
391
+ }
392
+ };
393
+ return new Proxy(utils, {
394
+ get(_, prop) {
395
+ if (prop in utils) {
396
+ return utils[prop];
397
+ }
398
+ return config[prop];
399
+ }
400
+ });
401
+ }
402
+
321
403
  exports.createDefineConfig = createDefineConfig;
322
404
  exports.loadConfig = loadConfig;
323
405
  exports.loadDotenv = loadDotenv;
324
406
  exports.setupDotenv = setupDotenv;
407
+ exports.watchConfig = watchConfig;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { JITI } from 'jiti';
2
2
  import { JITIOptions } from 'jiti/dist/types';
3
+ import { WatchOptions } from 'chokidar';
4
+ import { diff } from 'ohash';
3
5
 
4
6
  interface DotenvOptions {
5
7
  /**
@@ -91,4 +93,28 @@ declare function createDefineConfig<T extends UserInputConfig = UserInputConfig,
91
93
 
92
94
  declare function loadConfig<T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta>(options: LoadConfigOptions<T, MT>): Promise<ResolvedConfig<T, MT>>;
93
95
 
94
- export { C12InputConfig, ConfigLayer, ConfigLayerMeta, DefineConfig, DotenvOptions, Env, InputConfig, LoadConfigOptions, ResolvedConfig, SourceOptions, UserInputConfig, createDefineConfig, loadConfig, loadDotenv, setupDotenv };
96
+ type ConfigWatcher<T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta> = ResolvedConfig<T, MT> & {
97
+ watchingFiles: string[];
98
+ unwatch: () => Promise<void>;
99
+ };
100
+ interface WatchConfigOptions<T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta> extends LoadConfigOptions<T, MT> {
101
+ chokidarOptions?: WatchOptions;
102
+ debounce?: false | number;
103
+ onWatch?: (event: {
104
+ type: "created" | "updated" | "removed";
105
+ path: string;
106
+ }) => void | Promise<void>;
107
+ acceptHMR?: (context: {
108
+ getDiff: () => ReturnType<typeof diff>;
109
+ newConfig: ResolvedConfig<T, MT>;
110
+ oldConfig: ResolvedConfig<T, MT>;
111
+ }) => void | boolean | Promise<void | boolean>;
112
+ onUpdate?: (context: {
113
+ getDiff: () => ReturnType<typeof diff>;
114
+ newConfig: ResolvedConfig<T, MT>;
115
+ oldConfig: ResolvedConfig<T, MT>;
116
+ }) => void | Promise<void>;
117
+ }
118
+ declare function watchConfig<T extends UserInputConfig = UserInputConfig, MT extends ConfigLayerMeta = ConfigLayerMeta>(options: WatchConfigOptions<T, MT>): Promise<ConfigWatcher<T, MT>>;
119
+
120
+ export { C12InputConfig, ConfigLayer, ConfigLayerMeta, ConfigWatcher, DefineConfig, DotenvOptions, Env, InputConfig, LoadConfigOptions, ResolvedConfig, SourceOptions, UserInputConfig, WatchConfigOptions, createDefineConfig, loadConfig, loadDotenv, setupDotenv, watchConfig };
package/dist/index.mjs CHANGED
@@ -7,6 +7,9 @@ import createJiti from 'jiti';
7
7
  import * as rc9 from 'rc9';
8
8
  import { defu } from 'defu';
9
9
  import { findWorkspaceDir, readPackageJSON } from 'pkg-types';
10
+ import { watch } from 'chokidar';
11
+ import { debounce } from 'perfect-debounce';
12
+ import { diff } from 'ohash';
10
13
 
11
14
  async function setupDotenv(options) {
12
15
  const targetEnvironment = options.env ?? process.env;
@@ -298,4 +301,83 @@ function createDefineConfig() {
298
301
  return (input) => input;
299
302
  }
300
303
 
301
- export { createDefineConfig, loadConfig, loadDotenv, setupDotenv };
304
+ const eventMap = {
305
+ add: "created",
306
+ change: "updated",
307
+ unlink: "removed"
308
+ };
309
+ async function watchConfig(options) {
310
+ let config = await loadConfig(options);
311
+ const configName = options.name || "config";
312
+ const configFileName = options.configFile ?? (options.name !== "config" ? `${options.name}.config` : "config");
313
+ const watchingFiles = [
314
+ ...new Set(
315
+ (config.layers || []).filter((l) => l.cwd).flatMap((l) => [
316
+ ...["ts", "js", "mjs", "cjs", "cts", "mts", "json"].map(
317
+ (ext) => resolve(l.cwd, configFileName + "." + ext)
318
+ ),
319
+ l.source && resolve(l.cwd, l.source),
320
+ // TODO: Support watching rc from home and workspace
321
+ options.rcFile && resolve(
322
+ l.cwd,
323
+ typeof options.rcFile === "string" ? options.rcFile : `.${configName}rc`
324
+ ),
325
+ options.packageJson && resolve(l.cwd, "package.json")
326
+ ]).filter(Boolean)
327
+ )
328
+ ];
329
+ const _fswatcher = watch(watchingFiles, {
330
+ ignoreInitial: true,
331
+ ...options.chokidarOptions
332
+ });
333
+ const onChange = async (event, path) => {
334
+ const type = eventMap[event];
335
+ if (!type) {
336
+ return;
337
+ }
338
+ if (options.onWatch) {
339
+ await options.onWatch({
340
+ type,
341
+ path
342
+ });
343
+ }
344
+ const oldConfig = config;
345
+ const newConfig = await loadConfig(options);
346
+ config = newConfig;
347
+ const changeCtx = {
348
+ newConfig,
349
+ oldConfig,
350
+ getDiff: () => diff(oldConfig.config, config.config)
351
+ };
352
+ if (options.acceptHMR) {
353
+ const changeHandled = await options.acceptHMR(changeCtx);
354
+ if (changeHandled) {
355
+ return;
356
+ }
357
+ }
358
+ if (options.onUpdate) {
359
+ await options.onUpdate(changeCtx);
360
+ }
361
+ };
362
+ if (options.debounce !== false) {
363
+ _fswatcher.on("all", debounce(onChange, options.debounce ?? 100));
364
+ } else {
365
+ _fswatcher.on("all", onChange);
366
+ }
367
+ const utils = {
368
+ watchingFiles,
369
+ unwatch: async () => {
370
+ await _fswatcher.close();
371
+ }
372
+ };
373
+ return new Proxy(utils, {
374
+ get(_, prop) {
375
+ if (prop in utils) {
376
+ return utils[prop];
377
+ }
378
+ return config[prop];
379
+ }
380
+ });
381
+ }
382
+
383
+ export { createDefineConfig, loadConfig, loadDotenv, setupDotenv, watchConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c12",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Smart Config Loader",
5
5
  "repository": "unjs/c12",
6
6
  "license": "MIT",
@@ -30,12 +30,15 @@
30
30
  "test:types": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
+ "chokidar": "^3.5.3",
33
34
  "defu": "^6.1.2",
34
35
  "dotenv": "^16.0.3",
35
36
  "giget": "^1.1.2",
36
37
  "jiti": "^1.18.2",
37
38
  "mlly": "^1.2.0",
39
+ "ohash": "^1.1.1",
38
40
  "pathe": "^1.1.0",
41
+ "perfect-debounce": "^0.1.3",
39
42
  "pkg-types": "^1.0.2",
40
43
  "rc9": "^2.1.0"
41
44
  },
@@ -50,5 +53,5 @@
50
53
  "unbuild": "^1.2.1",
51
54
  "vitest": "^0.30.1"
52
55
  },
53
- "packageManager": "pnpm@8.2.0"
56
+ "packageManager": "pnpm@8.3.0"
54
57
  }