diff --git a/.eslintrc b/.eslintrc index 56f9dc3..72a8e8f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,5 +3,8 @@ "parserOptions": { "sourceType": "module", "requireConfigFile": false + }, + "rules": { + "no-sync": "off" } } diff --git a/.github/workflows/auto-url.yml b/.github/workflows/auto-url.yml index f6a67fe..ed40c35 100644 --- a/.github/workflows/auto-url.yml +++ b/.github/workflows/auto-url.yml @@ -21,13 +21,13 @@ jobs: fail-fast: false steps: - name: Harden Runner - uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit - - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version: ${{ matrix.node-version }} - name: Install dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8fff531..d75494d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,16 +41,16 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v3.6.0 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f079b8493333aace61c81488f8bd40919487bd9f # v3.25.7 + uses: github/codeql-action/init@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@f079b8493333aace61c81488f8bd40919487bd9f # v3.25.7 + uses: github/codeql-action/autobuild@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 # ℹī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f079b8493333aace61c81488f8bd40919487bd9f # v3.25.7 + uses: github/codeql-action/analyze@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml new file mode 100644 index 0000000..e1f160d --- /dev/null +++ b/.github/workflows/config.yml @@ -0,0 +1,36 @@ +name: Config CI + +on: + push: + branches: [main] + paths: + - src/config/** + pull_request: + paths: + - src/config/** + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + fail-fast: false + steps: + - name: Harden Runner + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 + with: + egress-policy: audit + + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: npm ci + - name: Run tests + run: npm run test --workspace=src/config diff --git a/.github/workflows/ephemeral-map.yml b/.github/workflows/ephemeral-map.yml index fdadf35..11810c3 100644 --- a/.github/workflows/ephemeral-map.yml +++ b/.github/workflows/ephemeral-map.yml @@ -21,13 +21,13 @@ jobs: fail-fast: false steps: - name: Harden Runner - uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit - - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version: ${{ matrix.node-version }} - name: Install dependencies diff --git a/.github/workflows/mutex.yml b/.github/workflows/mutex.yml index 18400c5..0647c4b 100644 --- a/.github/workflows/mutex.yml +++ b/.github/workflows/mutex.yml @@ -21,13 +21,13 @@ jobs: fail-fast: false steps: - name: Harden Runner - uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit - - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version: ${{ matrix.node-version }} - name: Install dependencies diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index d274c7f..10c0d9b 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -31,12 +31,12 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit - name: "Checkout code" - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v3.6.0 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: persist-credentials: false @@ -63,7 +63,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: name: SARIF file path: results.sarif @@ -71,6 +71,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f079b8493333aace61c81488f8bd40919487bd9f # v3.25.7 + uses: github/codeql-action/upload-sarif@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 with: sarif_file: results.sarif diff --git a/.github/workflows/timestore.yml b/.github/workflows/timestore.yml index 3fd9aea..ec454f4 100644 --- a/.github/workflows/timestore.yml +++ b/.github/workflows/timestore.yml @@ -21,13 +21,13 @@ jobs: fail-fast: false steps: - name: Harden Runner - uses: step-security/harden-runner@f086349bfa2bd1361f7909c78558e816508cdc10 # v2.8.0 + uses: step-security/harden-runner@17d0e2bd7d51742c71671bd19fa12bdc9d40a3d6 # v2.8.1 with: egress-policy: audit - - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 with: node-version: ${{ matrix.node-version }} - name: Install dependencies diff --git a/README.md b/README.md index dda50d9..d074fea 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Click on one of the links to access the documentation of the package: | mutex | [@openally/mutex](./src/mutex) | | result | [@openally/result](./src/result) | | auto-url | [@openally/auto-url](./src/auto-url) | +| config | [@openally/config](./src/config) | These packages are available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com). ```bash diff --git a/package-lock.json b/package-lock.json index fc0f491..10cecd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "src/ephemeral-map", "src/mutex", "src/result", - "src/auto-url" + "src/auto-url", + "src/config" ], "devDependencies": { "@faker-js/faker": "^8.1.0", @@ -1151,6 +1152,10 @@ "resolved": "src/auto-url", "link": true }, + "node_modules/@openally/config": { + "resolved": "src/config", + "link": true + }, "node_modules/@openally/ephemeral-map": { "resolved": "src/ephemeral-map", "link": true @@ -1455,6 +1460,13 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/zen-observable": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.7.tgz", + "integrity": "sha512-LKzNTjj+2j09wAo/vvVjzgw5qckJJzhdGgWHW7j69QIGdq/KnZrMAMIHQiWGl3Ccflh5/CudBAntTPYdprPltA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -2568,8 +2580,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.12", @@ -2611,6 +2622,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", + "license": "MIT" + }, "node_modules/fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -3677,6 +3694,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3903,6 +3929,15 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.2.2.tgz", + "integrity": "sha512-fVEjX2ybKdJKzFL46VshQbj9PuA4IUKivalgp48/3zwS9vXzyykzQ6AX92UxHSvWJagziMRLeHMgEzoGO7A8hQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + } + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -4608,11 +4643,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zen-observable": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", + "integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==", + "license": "MIT" + }, "src/auto-url": { "name": "@openally/auto-url", "version": "1.0.1", "license": "MIT" }, + "src/config": { + "name": "@openally/config", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "smol-toml": "^1.2.2", + "zen-observable": "^0.10.0" + }, + "devDependencies": { + "@types/zen-observable": "^0.8.7" + } + }, + "src/config/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "src/config/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "src/ephemeral-map": { "name": "@openally/ephemeral-map", "version": "1.3.2", @@ -5373,6 +5449,33 @@ "@openally/auto-url": { "version": "file:src/auto-url" }, + "@openally/config": { + "version": "file:src/config", + "requires": { + "@types/zen-observable": "^0.8.7", + "ajv": "^8.17.1", + "smol-toml": "^1.2.2", + "zen-observable": "^0.10.0" + }, + "dependencies": { + "ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + } + } + }, "@openally/ephemeral-map": { "version": "file:src/ephemeral-map", "requires": { @@ -5598,6 +5701,12 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "@types/zen-observable": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.7.tgz", + "integrity": "sha512-LKzNTjj+2j09wAo/vvVjzgw5qckJJzhdGgWHW7j69QIGdq/KnZrMAMIHQiWGl3Ccflh5/CudBAntTPYdprPltA==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -6371,8 +6480,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.12", @@ -6410,6 +6518,11 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-uri": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -7181,6 +7294,11 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7340,6 +7458,11 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "smol-toml": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.2.2.tgz", + "integrity": "sha512-fVEjX2ybKdJKzFL46VshQbj9PuA4IUKivalgp48/3zwS9vXzyykzQ6AX92UxHSvWJagziMRLeHMgEzoGO7A8hQ==" + }, "source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -7825,6 +7948,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zen-observable": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", + "integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==" } } } diff --git a/package.json b/package.json index 58e014e..63187e7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "src/ephemeral-map", "src/mutex", "src/result", - "src/auto-url" + "src/auto-url", + "src/config" ], "license": "MIT", "bugs": { diff --git a/src/config/README.md b/src/config/README.md new file mode 100644 index 0000000..5c21ec0 --- /dev/null +++ b/src/config/README.md @@ -0,0 +1,165 @@ +

+ Config +

+ +

+ Reactive configuration loader with safe hot reload configuration upon JSON Schema +

+ +## Requirements +- [Node.js](https://nodejs.org/en/) v20 or higher + +## Getting Started + +This package is available in the Node Package Repository and can be easily installed with [npm](https://docs.npmjs.com/getting-started/what-is-npm) or [yarn](https://yarnpkg.com). + +```bash +$ npm i @openally/config +# or +$ yarn add @openally/config +``` + +## Features + +- Hot-reloading of configuration +- Reactive with observable key(s) +- Safe with [JSON Schema](https://json-schema.org/) validation +- Support [TOML](https://github.com/toml-lang/toml) as input (enable the parser when the file extension end with **.toml**) +- Read configuration with no extension that start with a dot (like `.nodesecurerc` for example). + + +## Usage example + +Create a simple json file for your project + +```json +{ + "loglevel": 5, + "logsize": 4048, + "login": "administrator" +} +``` + +Now, create a new Configuration instance and read it + +```js +import { AsynchronousConfig } from "@openally/config"; + +const config = new AsynchronousConfig("./path/to/config.json"); +await config.read(); +console.log(cfg.get("loglevel")); // stdout: 5 + +// Observe (with an Observable Like) the update made to login property +config.observableOf("login").subscribe(console.log); +config.set("login", "admin"); + +// Payload getter will return a deepClone with all configuration properties +console.log(config.payload); + +config.close(); +``` + +> [!IMPORTANT] +> `config.json` should exists otherwise it will throw an Error. Look at `createOnNoEntry` option for more information ! + +## Events +Configuration class is extended by a [Node.js EventEmitter](https://nodejs.org/api/events.html). The class can trigger several events: + +| event name | description | +| --- | --- | +| `configWritten` | The configuration payload has been written on the local disk | +| `watcherInitialized` | The file watcher has been initialized (it will hot reload the configuration on modification) | +| `reload` | The configuration has been hot reloaded successfully | +| `close` | Event triggered when the configuration is asked to be closed | + +## API + +### `AsynchronousConfig` + +```ts +const config = new AsynchronousConfig(path: string, options?: ConfigOptions); +``` + +Available options are: + +| name | type | default value | description | +| --- | --- | --- | --- | +| `createOnNoEntry` | `boolean` | `false` | Create the file with default payload value if he doesn't exist on the local disk | +| `writeOnSet` | `boolean` | `false` | Write the file on the disk after each time `.set()` is called | +| `autoReload` | `boolean` | `false` | Setup hot reload of the configuration file | +| `jsonSchema` | `object` | `null` | The default JSON Schema for the configuration | + +> [!NOTE] +> When no schema is provided, it will search for a file prefixed by `.schema` with the same config name. + +### `AsynchronousConfig.read(defaultPayload?: object): Promise` + +Will read the local configuration on disk. A default `payload` value can be provided in case the file doesn't exist ! + +> [!CAUTION] +> When the file doesn't exist, the configuration is written at the next loop iteration (with `lazyWriteOnDisk`). + +### `AsynchronousConfig.setupHotReload(): void` + +Setup the hot reload of the configuration file. It will watch the file for modification and reload the configuration when it happens. + +This method is automatically triggered if the Configuration has been created with the option `autoReload` set to true. + +### `AsynchronousConfig.get(fieldPath: string, depth?: number): T` + +Get a value from a key (fieldPath). For example, let take a json payload with a root `foo` field. + +```js +const config = new AsynchronousConfig("./path/to/file.json"); +await config.read(); +const fooValue = config.get("foo"); +``` + +If the retrieved value is a JavaScript object, you can limit the depth with `depth` option. + +### `AsynchronousConfig.set(fieldPath: string, fieldValue: string)` + +Set or udpate a given field in the configuration. The file will be written on the disk if the option `writeOnSet` is set to true. + +```js +const config = new AsynchronousConfig("./config.json", { + createOnNoEntry: true +}); + +await config.read(); +config.set("foo", "hello world!"); +``` + +### `AsynchronousConfig.observableOf(fieldPath: string): Observable` + +Return an Observable that will emit the value of the given fieldPath when it changes. + +```ts +const config = new AsynchronousConfig("./config.json"); +await config.read({ foo: "bar" }); + +// Observe initial and next value(s) of foo +config.observableOf("foo").subscribe(console.log); +config.set("foo", "baz"); +``` + +### `AsynchronousConfig.close(): Promise` + +Close the configuration. It will stop the file watcher, remove subscribers and emit the `close` event. + +### `AsynchronousConfig.payload: object` + +Return a deep clone of the configuration payload. + +### `AsynchronousConfig.writeOnDisk(): Promise` + +Write the configuration payload on the local disk. + +### `AsynchronousConfig.lazyWriteOnDisk(): void` + +Write the configuration payload on the local disk at the next loop iteration. + +Use `configWritten` event to know when the configuration has been written on the disk. + +## License +MIT diff --git a/src/config/package.json b/src/config/package.json new file mode 100644 index 0000000..5a48920 --- /dev/null +++ b/src/config/package.json @@ -0,0 +1,47 @@ +{ + "name": "@openally/config", + "version": "1.0.0", + "description": "Reactive configuration loader", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "type": "module", + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "prepublishOnly": "npm run build", + "test-only": "glob -c \"tsx --test\" \"./test/**/*.test.ts\"", + "test": "c8 --all --src ./src -r html npm run test-only", + "lint": "cross-env eslint src/**/*.ts" + }, + "files": [ + "dist" + ], + "keywords": [ + "openally", + "config", + "json", + "schema", + "safe", + "loader", + "observable", + "reactive", + "hotreload" + ], + "author": "GENTILHOMME Thomas ", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "smol-toml": "^1.2.2", + "zen-observable": "^0.10.0" + }, + "devDependencies": { + "@types/zen-observable": "^0.8.7" + } +} diff --git a/src/config/src/asyncConfig.ts b/src/config/src/asyncConfig.ts new file mode 100644 index 0000000..db079a7 --- /dev/null +++ b/src/config/src/asyncConfig.ts @@ -0,0 +1,340 @@ +// Import Node.js Dependencies +import path from "node:path"; +import fs from "node:fs"; +import { EventEmitter } from "node:events"; +import { isDeepStrictEqual } from "node:util"; + +// Import Third-party Dependencies +import Ajv, { JSONSchemaType } from "ajv"; +import Observable from "zen-observable"; +import * as TOML from "smol-toml"; + +// Import Internal Dependencies +import * as utils from "./utils.js"; + +// CONSTANTS +const kPayloadIdentifier = Symbol("payload"); +const kSchemaIdentifier = Symbol("schema"); +const kAjv = new Ajv({ useDefaults: true }); +const kDefaultExtension = ".json"; +const kSupportedExtensions = new Set([".json", ".toml"]); +const kDefaultSchema = { + title: "Default config", + type: "object", + additionalProperties: true +}; + +export interface ConfigOptions { + createOnNoEntry?: boolean; + autoReload?: boolean; + writeOnSet?: boolean; + jsonSchema?: JSONSchemaType; +} + +export class AsynchronousConfig = Record> extends EventEmitter { + #isDotFile = false; + #isTOML = false; + #configFilePath: string; + #configSchemaFilePath: string; + #createOnNoEntry: boolean; + #autoReload: boolean; + #writeOnSet: boolean; + #autoReloadActivated = false; + #configHasBeenRead = false; + #subscriptionObservers: ([string, ZenObservable.SubscriptionObserver])[] = []; + #jsonSchema?: object; + #cleanupTimeout: NodeJS.Timeout; + #watcherSignal = new AbortController(); + + constructor(configFilePath: string, options: ConfigOptions = Object.create(null)) { + super(); + if (typeof configFilePath !== "string") { + throw new TypeError("The configPath must be a string"); + } + if (typeof options !== "object") { + throw new TypeError("The options must be an object"); + } + + const { dir, name, ext } = path.parse(configFilePath); + this.#isDotFile = name.startsWith("."); + + if (this.#isDotFile) { + this.#configFilePath = configFilePath; + } + else { + let configFileExtension = ext; + if (ext === "") { + configFileExtension = fs.existsSync(`${configFilePath}.toml`) ? ".toml" : kDefaultExtension; + this.#configFilePath = `${configFilePath}${configFileExtension}`; + } + else { + this.#configFilePath = configFilePath; + } + + if (!kSupportedExtensions.has(configFileExtension)) { + throw new Error("The config file extension should be .json or .toml, got: " + configFileExtension); + } + + this.#isTOML = configFileExtension === ".toml"; + } + + this.#configSchemaFilePath = `${path.join(dir, name)}.schema.json`; + + this[kPayloadIdentifier] = Object.create(null); + this[kSchemaIdentifier] = null; + + const { + createOnNoEntry = false, + autoReload = false, + writeOnSet = false, + jsonSchema + } = options; + + this.#createOnNoEntry = Boolean(createOnNoEntry); + this.#autoReload = Boolean(autoReload); + this.#writeOnSet = Boolean(writeOnSet); + + this.#subscriptionObservers = []; + + // Assign defaultSchema is exist! + if (jsonSchema !== void 0) { + if (typeof jsonSchema !== "object") { + throw new TypeError("The options.jsonSchema must be an object"); + } + this.#jsonSchema = jsonSchema; + } + } + + get payload(): T { + return structuredClone(this[kPayloadIdentifier]); + } + + set payload(newPayload: T) { + if (!this.#configHasBeenRead) { + throw new Error("You must read config first before setting a new payload!"); + } + + if (!newPayload) { + throw new TypeError("Invalid payload argument (cannot be null or undefined)"); + } + + if (isDeepStrictEqual(this[kPayloadIdentifier], newPayload)) { + return; + } + const isValidPayload = this[kSchemaIdentifier](structuredClone(newPayload)); + if (isValidPayload === false) { + const ajvErrors = utils.formatAjvErrors(this[kSchemaIdentifier].errors); + const errorMessage = `Config.payload (setter) - AJV Validation failed with error(s) => ${ajvErrors}`; + + throw new Error(errorMessage); + } + + this[kPayloadIdentifier] = newPayload; + for (const [fieldPath, subscriptionObservers] of this.#subscriptionObservers) { + subscriptionObservers.next(this.get(fieldPath)); + } + } + + async read(defaultPayload?: T) { + if (typeof defaultPayload === "object" && !defaultPayload) { + throw new TypeError("The defaultPayload must be an object"); + } + + let JSONConfig: any; + let JSONSchema: object; + let writeOnDisk = false; + + // Get and parse the JSON Configuration file (if exist, else it will throw ENOENT). + // If he doesn't exist we replace it by the defaultPayload or the precedent loaded payload + try { + let configFileContent = await fs.promises.readFile(this.#configFilePath, "utf-8"); + if (this.#isTOML === false && configFileContent.trim() === "") { + configFileContent = "{}"; + writeOnDisk = true; + } + JSONConfig = this.#isTOML ? TOML.parse(configFileContent) : JSON.parse(configFileContent); + } + catch (err) { + const isSyntaxError = err.name === "SyntaxError" || err.name === "TomlError"; + + // If NodeJS Code is different from "ENOENTRY", then throw Error (only if createOnNoEntry is equal to false) + if (isSyntaxError || !this.#createOnNoEntry || (Reflect.has(err, "code") && err.code !== "ENOENT")) { + throw err; + } + + JSONConfig = defaultPayload ? defaultPayload : this[kPayloadIdentifier]; + + // Ask to write the configuration to the disk at the end.. + writeOnDisk = true; + } + + // Get and parse the JSON Schema file (only if he exist). + // If he doesn't exist we replace it with a default Schema + try { + const schemaFileContent = await fs.promises.readFile(this.#configSchemaFilePath, "utf-8"); + JSONSchema = JSON.parse(schemaFileContent); + } + catch (err) { + const fileExists = Reflect.has(err, "code") && err.code !== "ENOENT"; + if (fileExists) { + throw err; + } + + JSONSchema = this.#jsonSchema ?? kDefaultSchema; + } + + // Setup Schema + this[kSchemaIdentifier] = kAjv.compile(JSONSchema); + + if (!this.#configHasBeenRead) { + // Cleanup closed subscription every second + this.#cleanupTimeout = setInterval(() => { + this.#subscriptionObservers = this.#subscriptionObservers.filter( + ([, subscriptionObservers]) => !subscriptionObservers.closed + ); + }, 1_000); + this.#cleanupTimeout.unref(); + } + + this.#configHasBeenRead = true; + this.payload = JSONConfig; + + // Write the configuraton on the disk for the first time (if there is no one available!). + if (writeOnDisk) { + const autoReload = () => this.setupAutoReload(); + + this.once("error", () => { + this.removeListener("configWritten", autoReload); + }); + this.once("configWritten", autoReload); + this.lazyWriteOnDisk(); + } + else { + this.setupAutoReload(); + } + + return this; + } + + setupAutoReload() { + if (!this.#configHasBeenRead) { + throw new Error("You must read config first before setting up autoReload!"); + } + + if (!this.#autoReload || this.#autoReloadActivated) { + return false; + } + + fs.watch(this.#configFilePath, { signal: this.#watcherSignal.signal }, async() => { + try { + if (!this.#configHasBeenRead) { + return; + } + await this.read(); + this.emit("reload"); + } + catch (err) { + this.emit("error", err); + } + }); + this.#autoReloadActivated = true; + + this.emit("watcherInitialized"); + + return true; + } + + get(fieldPath: string, depth = Infinity): Y | null { + if (!this.#configHasBeenRead) { + throw new Error("You must read config first before getting a field!"); + } + if (typeof fieldPath !== "string") { + throw new TypeError("The fieldPath must be a string"); + } + + let value: Y | string[] | null = utils.deepGet(this.payload, fieldPath); + if (value === null) { + return null; + } + if (Number.isFinite(depth)) { + value = utils.limitObjectDepth(value as Record, depth); + } + + return value; + } + + observableOf(fieldPath: string, depth = Infinity) { + const fieldValue = this.get(fieldPath, depth); + + return new Observable((observer) => { + observer.next(fieldValue); + this.#subscriptionObservers.push([fieldPath, observer]); + }); + } + + set(fieldPath: string, fieldValue: any) { + if (!this.#configHasBeenRead) { + throw new Error("You must read config first before setting a field!"); + } + if (typeof fieldPath !== "string") { + throw new TypeError("The fieldPath must be a string"); + } + + this.payload = utils.deepSet(this.payload, fieldPath, fieldValue); + Object.assign(this.payload, { [fieldPath]: fieldValue }); + + if (this.#writeOnSet) { + this.lazyWriteOnDisk(); + } + + return this; + } + + async writeOnDisk() { + if (!this.#configHasBeenRead) { + throw new Error("You must read config first before writing it on the disk!"); + } + + const data = this.#isTOML ? TOML.stringify(this[kPayloadIdentifier]) : JSON.stringify(this[kPayloadIdentifier], null, 2); + await fs.promises.writeFile(this.#configFilePath, data); + + this.emit("configWritten"); + } + + lazyWriteOnDisk() { + if (!this.#configHasBeenRead) { + throw new Error("You must read config first before writing it on the disk!"); + } + + setImmediate(async() => { + try { + await this.writeOnDisk(); + } + catch (error) { + this.emit("error", error); + } + }); + } + async close() { + if (!this.#configHasBeenRead) { + throw new Error("Cannot close unreaded configuration"); + } + + if (this.#autoReloadActivated) { + this.#watcherSignal.abort(); + this.#autoReloadActivated = false; + } + + await this.writeOnDisk(); + this.#configHasBeenRead = false; + + for (const [, subscriptionObservers] of this.#subscriptionObservers) { + subscriptionObservers.complete(); + } + this.#subscriptionObservers = []; + + clearInterval(this.#cleanupTimeout); + + this.emit("close"); + } +} diff --git a/src/config/src/index.ts b/src/config/src/index.ts new file mode 100644 index 0000000..1960f3c --- /dev/null +++ b/src/config/src/index.ts @@ -0,0 +1,6 @@ +// Import Internal Dependencies +import { AsynchronousConfig } from "./asyncConfig.js"; + +export { + AsynchronousConfig +}; diff --git a/src/config/src/utils.ts b/src/config/src/utils.ts new file mode 100644 index 0000000..2ccb58c --- /dev/null +++ b/src/config/src/utils.ts @@ -0,0 +1,83 @@ +// Import Third-party Dependencies +import { type ErrorObject } from "ajv"; + +export function formatAjvErrors(ajvErrors: ErrorObject[]) { + if (!Array.isArray(ajvErrors)) { + return ""; + } + const stdout: string[] = []; + for (const ajvError of ajvErrors) { + const isProperty = ajvError.instancePath === "" ? "" : `property ${ajvError.instancePath} `; + + stdout.push(`${isProperty}${ajvError.message}\n`); + } + + return stdout.join(""); +} + +export function limitObjectDepth(obj: Record, depth = 0): T { + if (!obj || typeof obj !== "object") { + return obj; + } + + if (depth === 0) { + return Object.keys(obj) as T; + } + + // eslint-disable-next-line no-param-reassign + const subDepth = --depth; + for (const [key, value] of Object.entries(obj)) { + Reflect.set(obj, key, limitObjectDepth(value, subDepth)); + } + + return obj as T; +} + +/** + * Get a value in a deep object + * + * @example + * ```ts + * const obj = { a: { b: { c: 1 } } }; + * const value = deepGet(obj, "a.b.c"); + * console.log(value); // 1 + * ``` + */ +export function deepGet(obj: Record, path: string): T | null { + const keys = path.split("."); + let value = obj; + for (const key of keys) { + if (!Reflect.has(value, key)) { + return null; + } + value = Reflect.get(value, key); + } + + return value as T; +} + +/** + * Set a value in a deep object + * + * @example + * ```ts + * const obj = { a: { b: { c: 1 } } }; + * deepSet(obj, "a.b.c", 2); + * console.log(obj); // { a: { b: { c: 2 } } } + * ``` + */ +export function deepSet>(obj: T, path: string, value: any): T { + const keys = path.split("."); + const lastKey = keys.pop()!; + let current = obj; + for (const key of keys) { + if (!Reflect.has(current, key)) { + Reflect.set(current, key, {}); + } + current = Reflect.get(current, key); + } + + Reflect.set(current, lastKey, value); + + return obj; +} diff --git a/src/config/test/asyncConfig.test.ts b/src/config/test/asyncConfig.test.ts new file mode 100644 index 0000000..2557530 --- /dev/null +++ b/src/config/test/asyncConfig.test.ts @@ -0,0 +1,495 @@ +// Import Node.js Dependencies +import { describe, before, after, it } from "node:test"; +import assert from "assert"; +import path from "node:path"; +import url from "node:url"; +import fs from "node:fs"; +import os from "node:os"; +import { once } from "node:events"; + +// Import Third-party Dependencies +import { TomlDate } from "smol-toml"; + +// Import Internal Dependencies +import { AsynchronousConfig } from "../src/index.js"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + +type FooConfig = { foo: string }; + +describe("AsynchronousConfig", { concurrency: 1 }, () => { + describe("given bad parameters", () => { + it("should throw when path is not a string", () => { + assert.throws(() => { + new AsynchronousConfig(42 as any); + }, { + name: "TypeError", + message: "The configPath must be a string" + }); + }); + + it("should throw when config file has invalid extension", () => { + assert.throws(() => { + new AsynchronousConfig(path.join(__dirname, "fixtures", "config.txt")); + }, { + name: "Error", + message: "The config file extension should be .json or .toml, got: .txt" + }); + }); + + it("should throw when set payload before read", () => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + assert.throws(() => { + config.payload = null as any; + }, { + name: "Error", + message: "You must read config first before setting a new payload!" + }); + }); + + it("should throw when set empty payload", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + await config.read(); + assert.throws(() => { + config.payload = null as any; + }, { + name: "TypeError", + message: "Invalid payload argument (cannot be null or undefined)" + }); + await config.close(); + }); + + it("should throw when options is not object", () => { + assert.throws(() => { + new AsynchronousConfig(path.join(__dirname, "fixtures", "config.json"), 42 as any); + }, { + name: "TypeError", + message: "The options must be an object" + }); + }); + + it("should throw when options.jsonSchema is not an object", () => { + assert.throws(() => { + new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json"), { jsonSchema: 42 as any }); + }, { + name: "TypeError", + message: "The options.jsonSchema must be an object" + }); + }); + + it("should throw when default payload is null or undefined", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + await assert.rejects(async() => { + await config.read(null as any); + }, { + name: "TypeError", + message: "The defaultPayload must be an object" + }); + }); + + it("GET should throw fieldPath is not string", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + await config.read(); + assert.throws(() => { + config.get(42 as any); + }, { + name: "TypeError", + message: "The fieldPath must be a string" + }); + }); + it("SET should throw fieldPath is not string", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + await config.read(); + assert.throws(() => { + config.set(42 as any, { foo: "bar" }); + }, { + name: "TypeError", + message: "The fieldPath must be a string" + }); + }); + }); + + describe("JSON configuration", { concurrency: 1 }, () => { + const configPath = path.join(__dirname, "fixtures", "codeMirror.json"); + let config: AsynchronousConfig; + + before(() => { + const configToCreate = { + addons: { + cpu: { + active: false, + standalone: false + } + } + }; + fs.writeFileSync(configPath, JSON.stringify(configToCreate, null, 2)); + + config = new AsynchronousConfig(configPath, { + createOnNoEntry: true, + writeOnSet: true, + autoReload: true + }); + }); + + after(() => { + fs.rmSync(configPath); + }); + + it("should read and observe config", async() => { + await config.read({ + hostname: os.hostname(), + platform: os.platform(), + release: os.release(), + addons: {} + }); + + const observableResults: any = []; + config.observableOf("addons.cpu").subscribe((value: any) => { + observableResults.push(value); + }); + + config.set("addons.cpu.active", true); + await once(config, "configWritten"); + assert.strictEqual(observableResults.length, 2); + assert.deepEqual(observableResults[0], { active: false, standalone: false }); + assert.deepEqual(observableResults[1], { active: true, standalone: false }); + await config.close(); + }); + + it("should update multiple fields", async() => { + await config.read(); + + config.set("newField", "value"); + await once(config, "configWritten"); + config.set("addons.foo", "bar"); + await once(config, "configWritten"); + config.set("hostname", "localhost"); + await once(config, "configWritten"); + + assert.strictEqual(config.get("newField"), "value"); + assert.strictEqual(config.get("addons.foo"), "bar"); + assert.strictEqual(config.get("hostname"), "localhost"); + + await config.close(); + }); + + it("should get empty payload without calling read", () => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + assert.deepEqual(config.payload, {}); + }); + + it("should find withSchema.json when withSchema given (without extension)", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema")); + await config.read(); + assert.deepEqual(config.payload, { foo: "bar", name: 42 }); + await config.close(); + }); + + it("should create an empty config object when file is empty", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", ".empty")); + await config.read(); + assert.deepEqual(config.payload, {}); + await config.close(); + // reset file + fs.writeFileSync(path.join(__dirname, "fixtures", ".empty"), ""); + }); + + it("should return null when field does not exists", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json")); + await config.read(); + assert.strictEqual(config.get("doesNotExists"), null); + await config.close(); + }); + + it("should get keys when using depth", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "nested.json")); + await config.read(); + assert.deepEqual(config.get("user"), { + name: "John Doe", + nested: { + deep: { + value: 42 + } + } + }); + assert.deepEqual(config.get("user", 1), { name: "John Doe", nested: ["deep"] }); + await config.close(); + }); + }); + + describe("JSON configuration autoreload", () => { + it("payload should be updated when file is updated", async() => { + const configPath = path.join(__dirname, "fixtures", ".autoreload"); + const config = new AsynchronousConfig(configPath, { autoReload: true, createOnNoEntry: true, writeOnSet: true }); + await config.read({ foo: "bar" }); + await once(config, "configWritten"); + + const observableResults: string[] = []; + config.observableOf("foo").subscribe((value: any) => { + observableResults.push(value); + }); + + fs.writeFileSync(configPath, JSON.stringify({ foo: "foo" }, null, 2)); + await once(config, "reload"); + + assert.strictEqual(observableResults.length, 2); + assert.strictEqual(observableResults[0], "bar"); + assert.strictEqual(observableResults[1], "foo"); + + await config.close(); + fs.rmSync(configPath); + }); + + it("should observe the updated field multiple times", async() => { + const configPath = path.join(__dirname, "fixtures", ".autoreload"); + const config = new AsynchronousConfig(configPath, { autoReload: true, createOnNoEntry: true, writeOnSet: true }); + await config.read({ foo: "bar" }); + await once(config, "configWritten"); + + const observableResults: string[] = []; + config.observableOf("foo").subscribe((value: any) => { + observableResults.push(value); + }); + config.observableOf("foo").subscribe((value: any) => { + observableResults.push(value); + }); + config.observableOf("foo").subscribe((value: any) => { + observableResults.push(value); + }); + + fs.writeFileSync(configPath, JSON.stringify({ foo: "foo" }, null, 2)); + await once(config, "reload"); + + assert.strictEqual(observableResults.length, 6); + const uniquesResults = [...new Set(observableResults)]; + assert.strictEqual(uniquesResults[0], "bar"); + assert.strictEqual(uniquesResults[1], "foo"); + + await config.close(); + fs.rmSync(configPath); + }); + }); + + describe("JSON configuration with JSON Schema", () => { + after(() => { + fs.rmSync(path.join(__dirname, "fixtures", ".doesNotExists")); + }); + + it("should throw when set invalid value", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "withSchema.json"), { + writeOnSet: true + }); + await config.read(); + assert.throws(() => { + config.set("foo", 42); + }, { + name: "Error", + message: "Config.payload (setter) - AJV Validation failed with error(s) => property /foo must be string\n" + }); + await config.close(); + }); + + it("should throw with default payload", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", ".config"), { + writeOnSet: false, + jsonSchema: { + type: "object", + properties: { + foo: { + type: "number" + } + } + } as any + }); + await assert.rejects(async() => { + await config.read(); + }, { + name: "Error", + message: "Config.payload (setter) - AJV Validation failed with error(s) => property /foo must be number\n" + }); + await config.close(); + // reset the file + fs.writeFileSync(path.join(__dirname, "fixtures", ".config"), JSON.stringify({ foo: "bar" }, null, 2)); + }); + + it("should have a valid config once read", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", ".config"), { + writeOnSet: false, + jsonSchema: { + type: "object", + properties: { + foo: { + type: "string" + } + }, + required: ["foo"], + additionalProperties: false + } + }); + await config.read(); + assert.deepEqual(config.payload, { foo: "bar" }); + assert.strictEqual(config.get("foo"), "bar"); + await config.close(); + }); + + it("should create file with default payload when file does not exists and createOnNoEntry is true", async() => { + const configPath = path.join(__dirname, "fixtures", ".doesNotExists"); + assert(fs.existsSync(configPath) === false); + + const config = new AsynchronousConfig(configPath, { createOnNoEntry: true }); + await config.read({ boo: "boom" }); + + assert.deepEqual(config.payload, { boo: "boom" }); + await config.close(); + }); + + it("should throw when file does not exists and createOnNoEntry is false", async() => { + const configPath = path.join(__dirname, "fixtures", ".doesNotExists2"); + assert(fs.existsSync(configPath) === false); + + const config = new AsynchronousConfig(configPath); + await assert.rejects(async() => await config.read({ boo: "boom" }), { + name: "Error", + message: /ENOENT: no such file or directory/ + }); + }); + + it("should recreate file on read when file does not exists and createOnNoEntry is true", async() => { + const configPath = path.join(__dirname, "fixtures", ".doesNotExists3"); + assert(fs.existsSync(configPath) === false); + + const config = new AsynchronousConfig(configPath, { createOnNoEntry: true }); + await config.read({ boo: "boom" }); + await once(config, "configWritten"); + + assert(fs.existsSync(configPath) === true); + assert.deepEqual(config.payload, { boo: "boom" }); + + fs.rmSync(configPath); + assert(fs.existsSync(configPath) === false); + + await config.read(); + await once(config, "configWritten"); + assert(fs.existsSync(configPath) === true); + assert.deepEqual(config.payload, { boo: "boom" }); + + await config.close(); + fs.rmSync(configPath); + }); + }); + + describe("TOML configuration", () => { + it("should read config", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "config.toml")); + await config.read(); + + assert.deepEqual(config.payload, { + title: "TOML Example", + owner: { + dob: new TomlDate("1979-05-27T15:32:00.000Z"), + name: "Tom Preston-Werner" + } + }); + await config.close(); + }); + + it("should find config.toml when config given (without extension)", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "config")); + await config.read(); + + assert.deepEqual(config.payload, { + title: "TOML Example", + owner: { + dob: new TomlDate("1979-05-27T15:32:00.000Z"), + name: "Tom Preston-Werner" + } + }); + await config.close(); + }); + }); + + describe("Dotfile configuration", () => { + it("should read config", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", ".dummyConfig")); + await config.read(); + assert.deepEqual(config.payload, { + foo: "bar" + }); + assert.strictEqual(config.get("foo"), "bar"); + await config.close(); + }); + }); + + describe("JSON configuration with syntax error payload", () => { + it("should throw an error", async() => { + const config = new AsynchronousConfig(path.join(__dirname, "fixtures", "syntaxError.json")); + await assert.rejects(async() => { + await config.read(); + }, { + name: "SyntaxError" + }); + }); + }); + + describe("When config has not been read", () => { + let config: AsynchronousConfig; + + before(() => { + config = new AsynchronousConfig(path.join(__dirname, "fixtures", ".dummyConfig")); + }); + + it("should throw when lazyWriteOnDisk is called", () => { + assert.throws(() => { + config.lazyWriteOnDisk(); + }, { + name: "Error", + message: "You must read config first before writing it on the disk!" + }); + }); + + it("should throw when writeOnDisk is called", async() => { + await assert.rejects(async() => { + await config.writeOnDisk(); + }, { + name: "Error", + message: "You must read config first before writing it on the disk!" + }); + }); + + it("should throw when close is called", async() => { + await assert.rejects(async() => { + await config.close(); + }, { + name: "Error", + message: "Cannot close unreaded configuration" + }); + }); + + it("should throw when set is called", () => { + assert.throws(() => { + config.set("foo", "bar"); + }, { + name: "Error", + message: "You must read config first before setting a field!" + }); + }); + + it("should throw when get is called", () => { + assert.throws(() => { + config.get("foo"); + }, { + name: "Error", + message: "You must read config first before getting a field!" + }); + }); + + it("should throw when setup autoReload", () => { + assert.throws(() => { + config.setupAutoReload(); + }, { + name: "Error", + message: "You must read config first before setting up autoReload!" + }); + }); + }); +}); diff --git a/src/config/test/fixtures/.config b/src/config/test/fixtures/.config new file mode 100644 index 0000000..b42f309 --- /dev/null +++ b/src/config/test/fixtures/.config @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} \ No newline at end of file diff --git a/src/config/test/fixtures/.dummyConfig b/src/config/test/fixtures/.dummyConfig new file mode 100644 index 0000000..b42f309 --- /dev/null +++ b/src/config/test/fixtures/.dummyConfig @@ -0,0 +1,3 @@ +{ + "foo": "bar" +} \ No newline at end of file diff --git a/src/config/test/fixtures/.empty b/src/config/test/fixtures/.empty new file mode 100644 index 0000000..e69de29 diff --git a/src/config/test/fixtures/config.toml b/src/config/test/fixtures/config.toml new file mode 100644 index 0000000..78308b0 --- /dev/null +++ b/src/config/test/fixtures/config.toml @@ -0,0 +1,5 @@ +title = "TOML Example" + +[owner] +name = "Tom Preston-Werner" +dob = 1979-05-27T07:32:00.000-08:00 \ No newline at end of file diff --git a/src/config/test/fixtures/config.txt b/src/config/test/fixtures/config.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/config/test/fixtures/nested.json b/src/config/test/fixtures/nested.json new file mode 100644 index 0000000..b81a5ed --- /dev/null +++ b/src/config/test/fixtures/nested.json @@ -0,0 +1,10 @@ +{ + "user": { + "name": "John Doe", + "nested": { + "deep": { + "value": 42 + } + } + } +} \ No newline at end of file diff --git a/src/config/test/fixtures/syntaxError.json b/src/config/test/fixtures/syntaxError.json new file mode 100644 index 0000000..41a3065 --- /dev/null +++ b/src/config/test/fixtures/syntaxError.json @@ -0,0 +1,3 @@ +{ + "key" 10 +} diff --git a/src/config/test/fixtures/withSchema.json b/src/config/test/fixtures/withSchema.json new file mode 100644 index 0000000..070a251 --- /dev/null +++ b/src/config/test/fixtures/withSchema.json @@ -0,0 +1,4 @@ +{ + "foo": "bar", + "name": 42 +} \ No newline at end of file diff --git a/src/config/test/fixtures/withSchema.schema.json b/src/config/test/fixtures/withSchema.schema.json new file mode 100644 index 0000000..23f2a7c --- /dev/null +++ b/src/config/test/fixtures/withSchema.schema.json @@ -0,0 +1,12 @@ +{ + "title": "Config", + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] +} diff --git a/src/config/test/utils.test.ts b/src/config/test/utils.test.ts new file mode 100644 index 0000000..9cd2b11 --- /dev/null +++ b/src/config/test/utils.test.ts @@ -0,0 +1,92 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "assert"; + +// Import Internal Dependencies +import { formatAjvErrors, limitObjectDepth, deepGet, deepSet } from "../src/utils.js"; + +describe("Utils", () => { + describe("formatAjvErrors", () => { + it("should return an empty string", () => { + assert.strictEqual(formatAjvErrors(null as any), ""); + assert.strictEqual(formatAjvErrors(undefined as any), ""); + assert.strictEqual(formatAjvErrors([]), ""); + }); + + it("should return a formatted string", () => { + const errors: any = [ + { + instancePath: "", + message: "should have required property 'name'" + }, + { + instancePath: "", + message: "should have required property 'version'" + } + ]; + const expected = "should have required property 'name'\nshould have required property 'version'\n"; + + assert.strictEqual(formatAjvErrors(errors), expected); + }); + }); + + describe("limitObjectDepth", () => { + it("should return the keys if depth is 0 (by default)", () => { + const obj = { name: "hello", version: "1.0.0" }; + assert.deepStrictEqual(limitObjectDepth(obj), ["name", "version"]); + }); + + it("should return the keys if depth is 0 (explicit)", () => { + const obj = { name: "hello", version: "1.0.0" }; + assert.deepStrictEqual(limitObjectDepth(obj, 0), ["name", "version"]); + }); + + it("should return the given value if it's not an object", () => { + assert.strictEqual(limitObjectDepth("hello" as any), "hello"); + assert.strictEqual(limitObjectDepth(42 as any), 42); + assert.strictEqual(limitObjectDepth(null as any), null); + assert.strictEqual(limitObjectDepth(undefined as any), undefined); + }); + + it("should return an empty object", () => { + assert.deepStrictEqual(limitObjectDepth(null as any), null); + assert.deepStrictEqual(limitObjectDepth(undefined as any), undefined); + }); + + it("should return an object with only keys and depth 1", () => { + const obj = { name: "hello", version: "1.0.0", author: { name: "John Doe" } }; + const expected = { name: "hello", version: "1.0.0", author: ["name"] }; + + assert.deepStrictEqual(limitObjectDepth(obj, 1), expected); + }); + }); + + describe("deepGet", () => { + it("should return the value at the given path", () => { + const obj = { name: "hello", version: "1.0.0", author: { name: "John Doe" } }; + assert.strictEqual(deepGet(obj, "author.name"), "John Doe"); + }); + + it("should return null if the path is not found", () => { + const obj = { name: "hello", version: "1.0.0", author: { name: "John Doe" } }; + assert.strictEqual(deepGet(obj, "author.age"), null); + }); + }); + + describe("deepSet", () => { + it("should set the value at the given path", () => { + const obj = { name: "hello", version: "1.0.0", author: { name: "John Doe" } }; + const expected = { name: "hello", version: "1.0.0", author: { name: "Jane Doe" } }; + + assert.deepStrictEqual(deepSet(obj, "author.name", "Jane Doe"), expected); + }); + + it("should create the path if it does not exist", () => { + const obj = { name: "hello", version: "1.0.0" }; + const expected = { name: "hello", version: "1.0.0", author: { name: "John Doe" } }; + const result = deepSet(obj, "author.name", "John Doe"); + + assert.deepStrictEqual(result, expected); + }); + }); +}); diff --git a/src/config/tsconfig.json b/src/config/tsconfig.json new file mode 100644 index 0000000..e9d6a60 --- /dev/null +++ b/src/config/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "declaration": true, + "strictNullChecks": true, + "target": "ES2022", + "outDir": "dist", + "module": "ES2022", + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": false, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json index 815f7e6..05ad96b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "./src/auto-url" + }, + { + "path": "./src/config" } ] }