diff --git a/README.md b/README.md index 3f92dfb..98043ff 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Smart Configuration Loader. - Reads config from the nearest `package.json` file - [Extends configurations](https://github.com/unjs/c12#extending-configuration) from multiple local or git sources - Overwrite with [environment-specific configuration](#environment-specific-configuration) +- Config watcher with auto reload ## Usage @@ -36,10 +37,10 @@ Import: ```js // ESM -import { loadConfig } from "c12"; +import { loadConfig, watchConfig } from "c12"; // CommonJS -const { loadConfig } = require("c12"); +const { loadConfig, watchConfig } = require("c12"); ``` Load configuration: @@ -234,6 +235,29 @@ c12 tries to match [`envName`](#envname) and override environment config if spec } ``` +## Watching Configuration + +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, + +```ts +import { watchConfig } from "c12"; + +const config = watchConfig({ + cwd: ".", + // chokidarOptions: {}, // Default is { ignoreInitial: true } + // debounce: 200 // Default is 100. You can set to fale to disable debounced watcher + onChange: ({ config, oldConfig, path, type }) => { + console.log("[watcher]", type, path); + }, +}); + +console.log("initial config", config.config, config.layers); +console.log("watching config files:", config.watchingFiles); + +// When exiting process +await config.unwatch(); +``` + ## 💻 Development - Clone this repository diff --git a/package.json b/package.json index fe03419..31a5449 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "test:types": "tsc --noEmit" }, "dependencies": { + "chokidar": "^3.5.3", "defu": "^6.1.2", "dotenv": "^16.0.3", "giget": "^1.1.2", "jiti": "^1.18.2", "mlly": "^1.2.0", "pathe": "^1.1.0", + "perfect-debounce": "^0.1.3", "pkg-types": "^1.0.2", "rc9": "^2.1.0" }, diff --git a/test/test.ts b/playground/load.ts similarity index 89% rename from test/test.ts rename to playground/load.ts index a46cabf..42d8864 100644 --- a/test/test.ts +++ b/playground/load.ts @@ -4,7 +4,7 @@ import { loadConfig } from "../src"; const r = (path: string) => fileURLToPath(new URL(path, import.meta.url)); async function main() { - const fixtureDir = r("./fixture"); + const fixtureDir = r("../test/fixture"); const config = await loadConfig({ cwd: fixtureDir, dotenv: true }); console.log(config); } diff --git a/playground/watch.ts b/playground/watch.ts new file mode 100644 index 0000000..f710cef --- /dev/null +++ b/playground/watch.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from "node:url"; +import { watchConfig } from "../src"; + +const r = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +async function main() { + const fixtureDir = r("../test/fixture"); + const config = await watchConfig({ + cwd: fixtureDir, + dotenv: true, + packageJson: ["c12", "c12-alt"], + globalRc: true, + envName: "test", + extend: { + extendKey: ["theme", "extends"], + }, + onChange: ({ config, path, type }) => { + console.log("[watcher]", type, path); + console.log(config.config); + }, + }); + console.log("initial config", config.config, config.layers); + console.log("watching config files:", config.watchingFiles); +} + +// eslint-disable-next-line unicorn/prefer-top-level-await +main().catch(console.error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9c4a61..50452ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,9 @@ lockfileVersion: '6.0' dependencies: + chokidar: + specifier: ^3.5.3 + version: 3.5.3 defu: specifier: ^6.1.2 version: 6.1.2 @@ -19,6 +22,9 @@ dependencies: pathe: specifier: ^1.1.0 version: 1.1.0 + perfect-debounce: + specifier: ^0.1.3 + version: 0.1.3 pkg-types: specifier: ^1.0.2 version: 1.0.2 @@ -972,6 +978,14 @@ packages: engines: {node: '>=10'} dev: true + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: false + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1037,6 +1051,11 @@ packages: engines: {node: '>=0.6'} dev: true + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: false + /blueimp-md5@2.19.0: resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} dev: true @@ -1066,7 +1085,6 @@ packages: engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /browserslist@4.21.5: resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} @@ -1213,6 +1231,21 @@ packages: resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} dev: true + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: false + /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -1990,7 +2023,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} @@ -2062,7 +2094,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /function-bind@1.1.1: @@ -2141,7 +2172,6 @@ packages: engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -2372,6 +2402,13 @@ packages: has-bigints: 1.0.2 dev: true + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: false + /is-boolean-object@1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} @@ -2420,7 +2457,6 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} @@ -2432,7 +2468,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} @@ -2461,7 +2496,6 @@ packages: /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} @@ -2861,6 +2895,11 @@ packages: validate-npm-package-license: 3.0.4 dev: true + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: false + /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -3055,6 +3094,10 @@ packages: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true + /perfect-debounce@0.1.3: + resolution: {integrity: sha512-NOT9AcKiDGpnV/HBhI22Str++XWcErO/bALvHCuhv33owZW/CjH8KAFLZDCmu3727sihe0wTxpDhyGc6M8qacQ==} + dev: false + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} dev: true @@ -3062,7 +3105,6 @@ packages: /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pkg-types@1.0.2: resolution: {integrity: sha512-hM58GKXOcj8WTqUXnsQyJYXdeAPbythQgEF3nTcEo+nkD49chjQ9IKm/QJy9xf6JakXptz86h7ecP2024rrLaQ==} @@ -3149,6 +3191,13 @@ packages: type-fest: 0.6.0 dev: true + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: false + /regexp-tree@0.1.24: resolution: {integrity: sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw==} hasBin: true @@ -3519,7 +3568,6 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 - dev: true /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} diff --git a/src/index.ts b/src/index.ts index a4aa8cb..f274ee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from "./dotenv"; export * from "./loader"; export * from "./types"; +export * from "./watch"; diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..80d4b99 --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,110 @@ +import { watch, WatchOptions } from "chokidar"; +import { debounce } from "perfect-debounce"; +import { resolve } from "pathe"; +import type { + UserInputConfig, + ConfigLayerMeta, + ResolvedConfig, + LoadConfigOptions, +} from "./types"; +import { loadConfig } from "./loader"; + +export type ConfigWatcher< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> = ResolvedConfig<T, MT> & { + watchingFiles: string[]; + unwatch: () => Promise<void>; +}; + +export type WatchConfigOptions< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +> = { + chokidarOptions?: WatchOptions; + debounce?: false | number; + onChange?: (payload: { + type: "created" | "updated" | "removed"; + path: string; + config: ResolvedConfig<T, MT>; + oldConfig: ResolvedConfig<T, MT>; + }) => void; +}; + +const eventMap = { + add: "created", + change: "updated", + unlink: "removed", +} as const; + +export async function watchConfig< + T extends UserInputConfig = UserInputConfig, + MT extends ConfigLayerMeta = ConfigLayerMeta +>( + options: LoadConfigOptions<T, MT> & WatchConfigOptions +): Promise<ConfigWatcher<T, MT>> { + let config = await loadConfig<T, MT>(options); + + const configName = options.name || "config"; + const watchingFiles = [ + ...new Set( + (config.layers || []) + .filter((l) => l.cwd) + .flatMap((l) => [ + ...["ts", "js", "mjs", "cjs", "cts", "mts", "json"].map((ext) => + resolve(l.cwd!, (options.name || "config") + "." + ext) + ), + l.source && resolve(l.cwd!, l.source), + // TODO: Support watching rc from home and workspace + options.rcFile && + resolve( + l.cwd!, + typeof options.rcFile === "string" + ? options.rcFile + : `.${configName}rc` + ), + options.packageJson && resolve(l.cwd!, "package.json"), + ]) + .filter(Boolean) + ), + ] as string[]; + + const _fswatcher = watch(watchingFiles, { + ignoreInitial: true, + ...options.chokidarOptions, + }); + + const onChange = async (event: string, path: string) => { + const type = eventMap[event as keyof typeof eventMap]; + if (!type) { + return; + } + const oldConfig = config; + config = await loadConfig(options); + if (options.onChange) { + options.onChange({ type, path, config, oldConfig }); + } + }; + + if (options.debounce !== false) { + _fswatcher.on("all", debounce(onChange, options.debounce)); + } else { + _fswatcher.on("all", onChange); + } + + const utils: Partial<ConfigWatcher<T, MT>> = { + watchingFiles, + unwatch: async () => { + await _fswatcher.close(); + }, + }; + + return new Proxy<ConfigWatcher<T, MT>>(utils as ConfigWatcher<T, MT>, { + get(_, prop) { + if (prop in utils) { + return utils[prop as keyof typeof utils]; + } + return config[prop as keyof ResolvedConfig<T, MT>]; + }, + }); +} diff --git a/tsconfig.json b/tsconfig.json index 7d08257..9bdae7c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,5 @@ "esModuleInterop": true, "strict": true }, - "include": ["src", "test"], "exclude": ["node_modules"] }