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"
}
]
}