From fde8e8c5ed4be28dc8d9383f4d4f6e561cb6f017 Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 3 Aug 2026 10:12:53 -0700 Subject: [PATCH 1/6] [config] create package --- README.md | 2 ++ npm-shrinkwrap.json | 19 +++++++++-- packages/config/.tshy/build.json | 8 +++++ packages/config/.tshy/commonjs.json | 16 +++++++++ packages/config/.tshy/esm.json | 15 ++++++++ packages/config/CHANGELOG.md | 3 ++ packages/config/LICENSE.md | 22 ++++++++++++ packages/config/README.md | 22 ++++++++++++ packages/config/package.json | 53 +++++++++++++++++++++++++++++ packages/config/src/index.test.ts | 12 +++++++ packages/config/src/index.ts | 3 ++ packages/config/tsconfig.json | 3 ++ 12 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 packages/config/.tshy/build.json create mode 100644 packages/config/.tshy/commonjs.json create mode 100644 packages/config/.tshy/esm.json create mode 100644 packages/config/CHANGELOG.md create mode 100644 packages/config/LICENSE.md create mode 100644 packages/config/README.md create mode 100644 packages/config/package.json create mode 100644 packages/config/src/index.test.ts create mode 100644 packages/config/src/index.ts create mode 100644 packages/config/tsconfig.json diff --git a/README.md b/README.md index ebcf90f..1b698be 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ An every-growing collection of JavaScript utility packages targeting Node.js and ## Packages +* [@bust/config](packages/config) + * Schema-driven configuration management for Node.js or the browser * [@bust/numeric-id-hasher](packages/numeric-id-hasher) * Encode / decode bigints as string hashes for use in shortened urls * [@bust/periodical](packages/periodical) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 50e8ab0..905ec84 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -58,6 +58,10 @@ "node": ">=6.9.0" } }, + "node_modules/@bust/config": { + "resolved": "packages/config", + "link": true + }, "node_modules/@bust/numeric-id-hasher": { "resolved": "packages/numeric-id-hasher", "link": true @@ -8035,9 +8039,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/config": { + "name": "@bust/config", + "version": "0.0.0", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">=22", + "npm": ">=10" + } + }, "packages/numeric-id-hasher": { "name": "@bust/numeric-id-hasher", - "version": "1.0.1", + "version": "2.0.0", "license": "(MIT OR Apache-2.0)", "engines": { "node": ">=22", @@ -8046,7 +8059,7 @@ }, "packages/periodical": { "name": "@bust/periodical", - "version": "1.0.1", + "version": "2.0.0", "license": "(MIT OR Apache-2.0)", "engines": { "node": ">=22", @@ -8055,7 +8068,7 @@ }, "packages/timer": { "name": "@bust/timer", - "version": "1.0.1", + "version": "2.0.0", "license": "(MIT OR Apache-2.0)", "engines": { "node": ">=22", diff --git a/packages/config/.tshy/build.json b/packages/config/.tshy/build.json new file mode 100644 index 0000000..aea1a9e --- /dev/null +++ b/packages/config/.tshy/build.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../src", + "module": "nodenext", + "moduleResolution": "nodenext" + } +} diff --git a/packages/config/.tshy/commonjs.json b/packages/config/.tshy/commonjs.json new file mode 100644 index 0000000..7c9db50 --- /dev/null +++ b/packages/config/.tshy/commonjs.json @@ -0,0 +1,16 @@ +{ + "extends": "./build.json", + "include": [ + "../src/**/*.ts", + "../src/**/*.cts", + "../src/**/*.tsx", + "../src/**/*.json" + ], + "exclude": [ + "../src/**/*.mts", + "../src/package.json" + ], + "compilerOptions": { + "outDir": "../.tshy-build/commonjs" + } +} diff --git a/packages/config/.tshy/esm.json b/packages/config/.tshy/esm.json new file mode 100644 index 0000000..959294a --- /dev/null +++ b/packages/config/.tshy/esm.json @@ -0,0 +1,15 @@ +{ + "extends": "./build.json", + "include": [ + "../src/**/*.ts", + "../src/**/*.mts", + "../src/**/*.tsx", + "../src/**/*.json" + ], + "exclude": [ + "../src/package.json" + ], + "compilerOptions": { + "outDir": "../.tshy-build/esm" + } +} diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md new file mode 100644 index 0000000..d645a8c --- /dev/null +++ b/packages/config/CHANGELOG.md @@ -0,0 +1,3 @@ +# `@bust/config` Changelog + + diff --git a/packages/config/LICENSE.md b/packages/config/LICENSE.md new file mode 100644 index 0000000..97e7b62 --- /dev/null +++ b/packages/config/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Busticated + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/config/README.md b/packages/config/README.md new file mode 100644 index 0000000..ece9ec7 --- /dev/null +++ b/packages/config/README.md @@ -0,0 +1,22 @@ +# `@bust/config` + +Schema-driven configuration management for Node.js or the browser + + +## Installation + +```shell +npm install @bust/config --save +``` + +## API + + + +_NOTE: When in doubt, check usage in [tests](./src/index.test.ts)_ + + +## License + +_See [LICENSE.md](./LICENSE.md)_ + diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..9f732fd --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,53 @@ +{ + "name": "@bust/config", + "description": "Schema-driven configuration management for Node.js or the browser", + "version": "0.0.0", + "author": "Busticated", + "license": "(MIT OR Apache-2.0)", + "type": "module", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/commonjs/index.d.ts", + "files": [ + "CHANGELOG.md", + "LICENSE.md", + "dist/**/!(*.test.js)" + ], + "tshy": { + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + } + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, + "scripts": { + "version": "npm run changelog:update && npm run docs:build", + "prepare": "npm run build", + "test": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "typecheck": "tsc --project .tshy/esm.json --noemit", + "build": "tshy", + "clean": "rm -rf ./dist && rm -rf ./tmp/*", + "clean:modules": "rm -rf ./node_modules", + "docs:build": "npm run docs:build:api && npm run docs:build:readme", + "docs:build:api": "typedoc --plugin typedoc-plugin-markdown --hideBreadcrumbs true --githubPages false --sourceLinkTemplate '/{path}#L{line}' --readme none src/index.ts", + "docs:build:readme": "node ../../bin/update-package-readme.mjs @bust/config", + "changelog:show": "node ../../bin/show-unpublished-changes.mjs @bust/config", + "changelog:update": "node ../../bin/update-changelog.mjs @bust/config" + }, + "engines": { + "node": ">=22", + "npm": ">=10" + } +} diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts new file mode 100644 index 0000000..7e62439 --- /dev/null +++ b/packages/config/src/index.test.ts @@ -0,0 +1,12 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as config from './index.js'; + + +describe('@bust/config', () => { + it('has tests', () => { + assert(config); + assert.equal(config.greet(), 'hello world!'); + }); +}); + diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000..6a158a4 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,3 @@ +export const greet = () => { + return 'hello world!'; +}; diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000..41716a7 --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig" +} From 762d8221657993aa28799773ccdf3bf2929e84d2 Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 3 Aug 2026 16:25:15 -0700 Subject: [PATCH 2/6] [numeric-id-hasher] split unit and end-to-end test tasks --- packages/numeric-id-hasher/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/numeric-id-hasher/package.json b/packages/numeric-id-hasher/package.json index 38d230f..4616862 100644 --- a/packages/numeric-id-hasher/package.json +++ b/packages/numeric-id-hasher/package.json @@ -38,7 +38,8 @@ "scripts": { "version": "npm run changelog:update && npm run docs:build", "prepare": "npm run build", - "test": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:unit": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:e2e": "echo \"Zero e2e tests to run!\" && exit 0", "typecheck": "tsc --noemit", "build": "tshy", "clean": "rm -rf ./dist && rm -rf ./tmp/*", From 092ede30d43637a615b152c739f2e2f4407efeaf Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 3 Aug 2026 16:25:55 -0700 Subject: [PATCH 3/6] [periodical] split unit and end-to-end test tasks --- packages/periodical/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/periodical/package.json b/packages/periodical/package.json index fbf3166..a500d7b 100644 --- a/packages/periodical/package.json +++ b/packages/periodical/package.json @@ -38,7 +38,8 @@ "scripts": { "version": "npm run changelog:update && npm run docs:build", "prepare": "npm run build", - "test": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:unit": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:e2e": "echo \"Zero e2e tests to run!\" && exit 0", "typecheck": "tsc --noemit", "build": "tshy", "clean": "rm -rf ./dist && rm -rf ./tmp/*", From 8a640f2663ca435f583ef66fe1fc80882035d31c Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 3 Aug 2026 16:26:07 -0700 Subject: [PATCH 4/6] [timer] split unit and end-to-end test tasks --- packages/timer/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/timer/package.json b/packages/timer/package.json index e697ffc..11b5b16 100644 --- a/packages/timer/package.json +++ b/packages/timer/package.json @@ -38,7 +38,8 @@ "scripts": { "version": "npm run changelog:update && npm run docs:build", "prepare": "npm run build", - "test": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:unit": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:e2e": "echo \"Zero e2e tests to run!\" && exit 0", "typecheck": "tsc --noemit", "build": "tshy", "clean": "rm -rf ./dist && rm -rf ./tmp/*", From f8794fa64eafb060aa03f5d8cd237b57eab2893f Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 3 Aug 2026 16:28:24 -0700 Subject: [PATCH 5/6] wire up new unit and e2e test tasks --- bin/templates/package.json.tmpl | 3 ++- package.json | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bin/templates/package.json.tmpl b/bin/templates/package.json.tmpl index d199a91..7988793 100644 --- a/bin/templates/package.json.tmpl +++ b/bin/templates/package.json.tmpl @@ -18,7 +18,8 @@ "scripts": { "version": "npm run changelog:update && npm run docs:build", "prepare": "npm run build", - "test": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:unit": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:e2e": "echo \"Zero e2e tests to run!\" && exit 0", "typecheck": "tsc --project .tshy/esm.json --noemit", "build": "tshy", "clean": "rm -rf ./dist && rm -rf ./tmp/*", diff --git a/package.json b/package.json index 2d3d1c8..ce8b414 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ ], "scripts": { "start": "echo \"Error: no source code available\" && exit 1", - "test": "npm run lint && npm run typecheck && npm run test --workspaces", + "test": "npm run lint && npm run typecheck && npm run test:unit", "lint": "eslint .", "lint:fix": "npm run lint -- --fix", "typecheck": "npm run typecheck --workspaces", + "test:unit": "npm run test:unit --workspaces", + "test:e2e": "npm run test:e2e --workspaces", "build": "npm run build --workspaces", "clean": "npm run clean --workspaces", "clean:modules": "npm run clean:modules --workspaces", From a82a60d58385454f635bcab38efaad154d9b8af5 Mon Sep 17 00:00:00 2001 From: busticated Date: Mon, 3 Aug 2026 16:28:55 -0700 Subject: [PATCH 6/6] [config] initial implementation --- npm-shrinkwrap.json | 775 ++++++++++++++++++ packages/config/README.md | 57 +- packages/config/docs/README.md | 11 + packages/config/docs/browser/README.md | 34 + .../docs/browser/functions/createConfig.md | 43 + .../docs/browser/functions/getBrowserEnv.md | 18 + packages/config/docs/config/README.md | 13 + packages/config/docs/config/classes/Config.md | 141 ++++ .../config/variables/CONFIG_GLOBAL_NAME.md | 12 + packages/config/docs/node/README.md | 27 + .../docs/node/functions/createConfig.md | 45 + .../docs/node/functions/getBrowserDefine.md | 38 + .../docs/node/interfaces/ConfigEnvVars.md | 15 + .../node/interfaces/SettingsSchemaTree.md | 26 + packages/config/package.json | 46 +- packages/config/src/browser.mts | 49 ++ packages/config/src/browser.test.mts | 45 + packages/config/src/config.test.ts | 516 ++++++++++++ packages/config/src/config.ts | 279 +++++++ packages/config/src/index.test.ts | 12 - packages/config/src/index.ts | 3 - packages/config/src/lib.e2e.mts | 124 +++ packages/config/src/node.test.ts | 60 ++ packages/config/src/node.ts | 61 ++ packages/config/src/types.ts | 118 +++ 25 files changed, 2540 insertions(+), 28 deletions(-) create mode 100644 packages/config/docs/README.md create mode 100644 packages/config/docs/browser/README.md create mode 100644 packages/config/docs/browser/functions/createConfig.md create mode 100644 packages/config/docs/browser/functions/getBrowserEnv.md create mode 100644 packages/config/docs/config/README.md create mode 100644 packages/config/docs/config/classes/Config.md create mode 100644 packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md create mode 100644 packages/config/docs/node/README.md create mode 100644 packages/config/docs/node/functions/createConfig.md create mode 100644 packages/config/docs/node/functions/getBrowserDefine.md create mode 100644 packages/config/docs/node/interfaces/ConfigEnvVars.md create mode 100644 packages/config/docs/node/interfaces/SettingsSchemaTree.md create mode 100644 packages/config/src/browser.mts create mode 100644 packages/config/src/browser.test.mts create mode 100644 packages/config/src/config.test.ts create mode 100644 packages/config/src/config.ts delete mode 100644 packages/config/src/index.test.ts delete mode 100644 packages/config/src/index.ts create mode 100644 packages/config/src/lib.e2e.mts create mode 100644 packages/config/src/node.test.ts create mode 100644 packages/config/src/node.ts create mode 100644 packages/config/src/types.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 905ec84..3223044 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1682,6 +1682,279 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -3418,6 +3691,16 @@ "dev": true, "license": "ISC" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4067,6 +4350,21 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5101,6 +5399,279 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.3.tgz", @@ -5537,6 +6108,25 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6793,6 +7383,35 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-selector-parser": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", @@ -7117,6 +7736,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -7309,6 +7961,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -7830,6 +8492,101 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", @@ -8043,11 +8800,29 @@ "name": "@bust/config", "version": "0.0.0", "license": "(MIT OR Apache-2.0)", + "dependencies": { + "dotenv": "^17.4.2" + }, + "devDependencies": { + "vite": "^8.2.0" + }, "engines": { "node": ">=22", "npm": ">=10" } }, + "packages/config/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "packages/numeric-id-hasher": { "name": "@bust/numeric-id-hasher", "version": "2.0.0", diff --git a/packages/config/README.md b/packages/config/README.md index ece9ec7..09da784 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -9,14 +9,67 @@ Schema-driven configuration management for Node.js or the browser npm install @bust/config --save ``` + +## Usage + +The heart of `@bust/config` is a schema that you define for your application. With this in place, `@bust/config` can read relevant variables from the environment, coerce them to the correct type, validate them, and provide a consistent interface for accessing them. + +Here's how you get started: + +```ts +// config.ts +import { createConfig } from '@bust/config'; + +export const config = createConfig({ + app: { + name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + secret: { default: '', env: 'MY_APP_SECRET' }, + }, +}); +``` + +Then simply `import` your config instance wherever you need it. + +```ts +// elsewhere.ts +import { config } from './config.ts'; + +config.get('app.name'); +``` + +`@bust/config` works in both Node.js and the browser by leveraging import maps defined in its `package.json` file. Bundlers +that respect the `browser` condition (Vite, webpack, Rollup, etc.) automatically get a browser-safe build with no Node.js-only dependencies. + +Within a Node.js context, environment variables are loaded from a `.env` file courtesy of the [`dotenv`](https://github.com/motdotla/dotenv) library. + + +### Exposing settings to the browser + +The browser receives your _public_ settings (marked `public: true` in your schema) via a global variable injected by your bundler. Here's how to set that up using `vite`: + +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import { getBrowserDefine } from '@bust/config'; +import { config } from './config.ts'; // as shown above + +export default defineConfig({ + define: getBrowserDefine(config), +}); +``` + +> [!CAUTION] +> Secrets should _NEVER_ be included in your schema. Instead, assign safe default / placeholder values then include the actual values in your `.env` file or directly in the environment. + + ## API +see [here](https://github.com/busticated/jsville/tree/%40bust%2Fconfig%400.0.0/packages/config/docs) -_NOTE: When in doubt, check usage in [tests](./src/index.test.ts)_ +_NOTE: When in doubt, check usage in the [tests](./src/config.test.ts)_ ## License _See [LICENSE.md](./LICENSE.md)_ - diff --git a/packages/config/docs/README.md b/packages/config/docs/README.md new file mode 100644 index 0000000..b3b9ffa --- /dev/null +++ b/packages/config/docs/README.md @@ -0,0 +1,11 @@ +**@bust/config** + +*** + +# @bust/config + +## Modules + +- [browser](browser/README.md) +- [config](config/README.md) +- [node](node/README.md) diff --git a/packages/config/docs/browser/README.md b/packages/config/docs/browser/README.md new file mode 100644 index 0000000..f139256 --- /dev/null +++ b/packages/config/docs/browser/README.md @@ -0,0 +1,34 @@ +[**@bust/config**](../README.md) + +*** + +# browser + +## Functions + +- [createConfig](functions/createConfig.md) +- [getBrowserEnv](functions/getBrowserEnv.md) + +## References + +### Config + +Re-exports [Config](../config/classes/Config.md) + +*** + +### CONFIG\_GLOBAL\_NAME + +Re-exports [CONFIG_GLOBAL_NAME](../config/variables/CONFIG_GLOBAL_NAME.md) + +*** + +### ConfigEnvVars + +Re-exports [ConfigEnvVars](../node/interfaces/ConfigEnvVars.md) + +*** + +### SettingsSchemaTree + +Re-exports [SettingsSchemaTree](../node/interfaces/SettingsSchemaTree.md) diff --git a/packages/config/docs/browser/functions/createConfig.md b/packages/config/docs/browser/functions/createConfig.md new file mode 100644 index 0000000..0ed2e0a --- /dev/null +++ b/packages/config/docs/browser/functions/createConfig.md @@ -0,0 +1,43 @@ +[**@bust/config**](../../README.md) + +*** + +# Function: createConfig() + +> **createConfig**(`schema`): [`Config`](../../config/classes/Config.md) + +Defined in: [browser.mts:47](/packages/config/src/browser.mts#L47) + +Builds a [Config](../../config/classes/Config.md) instance for use in the browser. Settings are +sourced from the `public: true` subset of a Node-side config, baked in +at build time via `getBrowserDefine()` and your bundler - this entry +point has no dependency on any Node.js-only API. + +## Parameters + +### schema + +[`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) + +## Returns + +[`Config`](../../config/classes/Config.md) + +## Example + +```ts +// config.ts - construct the config once and export it for the rest of +// your app to import and share +import { createConfig } from '@bust/config'; +import { schema } from './schema.js'; + +export const config = createConfig(schema); +``` +```ts +// elsewhere.ts +import { config } from './config.js'; + +config.get('app.name'); +``` + +See `getBrowserDefine()` in `node.ts` for the bundler side of this. diff --git a/packages/config/docs/browser/functions/getBrowserEnv.md b/packages/config/docs/browser/functions/getBrowserEnv.md new file mode 100644 index 0000000..337557c --- /dev/null +++ b/packages/config/docs/browser/functions/getBrowserEnv.md @@ -0,0 +1,18 @@ +[**@bust/config**](../../README.md) + +*** + +# Function: getBrowserEnv() + +> **getBrowserEnv**(): [`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) + +Defined in: [browser.mts:19](/packages/config/src/browser.mts#L19) + +Reads the settings blob the end-user's bundler baked in via +`getBrowserDefine()` (see `node.ts`), falling back to an empty object when +it's unavailable (e.g. running un-bundled, or the consumer hasn't wired up +`define` yet) - in which case settings just fall back to their defaults. + +## Returns + +[`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) diff --git a/packages/config/docs/config/README.md b/packages/config/docs/config/README.md new file mode 100644 index 0000000..7feceff --- /dev/null +++ b/packages/config/docs/config/README.md @@ -0,0 +1,13 @@ +[**@bust/config**](../README.md) + +*** + +# config + +## Classes + +- [Config](classes/Config.md) + +## Variables + +- [CONFIG\_GLOBAL\_NAME](variables/CONFIG_GLOBAL_NAME.md) diff --git a/packages/config/docs/config/classes/Config.md b/packages/config/docs/config/classes/Config.md new file mode 100644 index 0000000..efe19ad --- /dev/null +++ b/packages/config/docs/config/classes/Config.md @@ -0,0 +1,141 @@ +[**@bust/config**](../../README.md) + +*** + +# Class: Config + +Defined in: [config.ts:46](/packages/config/src/config.ts#L46) + +Schema-driven configuration store. Given a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md) and a +map of environment variables, hydrates each leaf setting's value from the +environment (falling back to its declared default), coercing and +validating it against its format along the way. + +`Config` itself is environment-agnostic - it doesn't know about +`process.env`, `.env` files, or `import.meta.env`. Node/browser-specific +env-sourcing lives in `createConfig()` in `node.ts`/`browser.mts`. + +## Example + +```ts +const config = new Config({ + schema: { + app: { + name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + }, + }, + env: process.env, +}); + +config.get('app.name'); // 'My App', or the value of `process.env.MY_APP_NAME`, if set +``` + +## Constructors + +### Constructor + +> **new Config**(`__namedParameters?`): `Config` + +Defined in: [config.ts:49](/packages/config/src/config.ts#L49) + +#### Parameters + +##### \_\_namedParameters? + +`ConfigOptions` = `{}` + +#### Returns + +`Config` + +## Properties + +### settings + +> **settings**: `Settings` + +Defined in: [config.ts:47](/packages/config/src/config.ts#L47) + +## Methods + +### get() + +> **get**(`key`): `SettingsValue` + +Defined in: [config.ts:58](/packages/config/src/config.ts#L58) + +Looks up a single setting's hydrated value by its dot-delimited key. + +#### Parameters + +##### key + +`string` + +#### Returns + +`SettingsValue` + +#### Throws + +if `key` isn't present in the hydrated schema + +*** + +### getPublicEnvVars() + +> **getPublicEnvVars**(): [`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) + +Defined in: [config.ts:87](/packages/config/src/config.ts#L87) + +Returns every public setting's value keyed by its *environment variable +name* rather than its schema path. + +#### Returns + +[`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) + +*** + +### getPublicSettings() + +> **getPublicSettings**(): `PublicSettings` + +Defined in: [config.ts:69](/packages/config/src/config.ts#L69) + +Returns every hydrated setting marked `public: true`, keyed by dot-delimited path. + +#### Returns + +`PublicSettings` + +*** + +### hydrate() + +> **hydrate**(`data`, `env?`): `Settings` + +Defined in: [config.ts:110](/packages/config/src/config.ts#L110) + +Walks a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md), resolving each leaf's format, +coercing/validating its value from `env` (or its default), and +flattening the tree into a dot-delimited Settings map. + +#### Parameters + +##### data + +[`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) + +##### env? + +[`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md) = `{}` + +#### Returns + +`Settings` + +#### Throws + +if a leaf is missing a value and has no default, uses an +unrecognized format, or its resolved value fails that format's validator diff --git a/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md b/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md new file mode 100644 index 0000000..176f8fd --- /dev/null +++ b/packages/config/docs/config/variables/CONFIG_GLOBAL_NAME.md @@ -0,0 +1,12 @@ +[**@bust/config**](../../README.md) + +*** + +# Variable: CONFIG\_GLOBAL\_NAME + +> `const` **CONFIG\_GLOBAL\_NAME**: `"__BUST_CONFIG__"` = `'__BUST_CONFIG__'` + +Defined in: [config.ts:20](/packages/config/src/config.ts#L20) + +The name of the global variable `@bust/config` expects to find settings stored +in when runnning in a browser. diff --git a/packages/config/docs/node/README.md b/packages/config/docs/node/README.md new file mode 100644 index 0000000..073659d --- /dev/null +++ b/packages/config/docs/node/README.md @@ -0,0 +1,27 @@ +[**@bust/config**](../README.md) + +*** + +# node + +## Interfaces + +- [ConfigEnvVars](interfaces/ConfigEnvVars.md) +- [SettingsSchemaTree](interfaces/SettingsSchemaTree.md) + +## Functions + +- [createConfig](functions/createConfig.md) +- [getBrowserDefine](functions/getBrowserDefine.md) + +## References + +### Config + +Re-exports [Config](../config/classes/Config.md) + +*** + +### CONFIG\_GLOBAL\_NAME + +Re-exports [CONFIG_GLOBAL_NAME](../config/variables/CONFIG_GLOBAL_NAME.md) diff --git a/packages/config/docs/node/functions/createConfig.md b/packages/config/docs/node/functions/createConfig.md new file mode 100644 index 0000000..2b3ec91 --- /dev/null +++ b/packages/config/docs/node/functions/createConfig.md @@ -0,0 +1,45 @@ +[**@bust/config**](../../README.md) + +*** + +# Function: createConfig() + +> **createConfig**(`schema`): [`Config`](../../config/classes/Config.md) + +Defined in: [node.ts:36](/packages/config/src/node.ts#L36) + +Builds a [Config](../../config/classes/Config.md) for use in Node.js: a local `.env` file (if +present in the current working directory) is loaded into `process.env` +before `schema` is hydrated, so local development values can live in a +git-ignored `.env` file instead of real environment variables. + +## Parameters + +### schema + +[`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md) + +## Returns + +[`Config`](../../config/classes/Config.md) + +## Example + +```ts +// config.ts - construct the config once and export it for the rest of +// your application to import and share +import { createConfig } from '@bust/config'; + +export const config = createConfig({ + app: { + name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + secret: { default: '', env: 'MY_APP_SECRET' }, + }, +}); +``` +```ts +// elsewhere.ts +import { config } from './config.ts'; + +config.get('app.name'); +``` diff --git a/packages/config/docs/node/functions/getBrowserDefine.md b/packages/config/docs/node/functions/getBrowserDefine.md new file mode 100644 index 0000000..ab20196 --- /dev/null +++ b/packages/config/docs/node/functions/getBrowserDefine.md @@ -0,0 +1,38 @@ +[**@bust/config**](../../README.md) + +*** + +# Function: getBrowserDefine() + +> **getBrowserDefine**(`config`): `Record`\<`string`, [`ConfigEnvVars`](../interfaces/ConfigEnvVars.md)\> + +Defined in: [node.ts:59](/packages/config/src/node.ts#L59) + +Produces a Vite `define` entry that exposes only `config`'s `public: true` +settings to a browser build - `createConfig()` in the browser reads this +same blob back out at runtime via [CONFIG\_GLOBAL\_NAME](../../config/variables/CONFIG_GLOBAL_NAME.md). Values never +leave the Node process beyond what `getPublicEnvVars()` already returns, so +there's no separate `VITE_`-prefixed env var to keep in sync with `schema`. + +## Parameters + +### config + +[`Config`](../../config/classes/Config.md) + +## Returns + +`Record`\<`string`, [`ConfigEnvVars`](../interfaces/ConfigEnvVars.md)\> + +## Example + +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import { getBrowserDefine } from '@bust/config'; +import { config } from './config.ts'; + +export default defineConfig({ + define: getBrowserDefine(config), +}); +``` diff --git a/packages/config/docs/node/interfaces/ConfigEnvVars.md b/packages/config/docs/node/interfaces/ConfigEnvVars.md new file mode 100644 index 0000000..a8674fe --- /dev/null +++ b/packages/config/docs/node/interfaces/ConfigEnvVars.md @@ -0,0 +1,15 @@ +[**@bust/config**](../../README.md) + +*** + +# Interface: ConfigEnvVars + +Defined in: [types.ts:104](/packages/config/src/types.ts#L104) + +A map of environment variable names to values. Values are typically raw +strings (as they'd come from `process.env`), but pre-coerced values are +also accepted, which is handy when constructing a [Config](../../config/classes/Config.md) in tests. + +## Indexable + +> \[`key`: `string`\]: `SettingsValue` \| `undefined` diff --git a/packages/config/docs/node/interfaces/SettingsSchemaTree.md b/packages/config/docs/node/interfaces/SettingsSchemaTree.md new file mode 100644 index 0000000..e44acb3 --- /dev/null +++ b/packages/config/docs/node/interfaces/SettingsSchemaTree.md @@ -0,0 +1,26 @@ +[**@bust/config**](../../README.md) + +*** + +# Interface: SettingsSchemaTree + +Defined in: [types.ts:87](/packages/config/src/types.ts#L87) + +An author-supplied configuration schema: an arbitrarily-nested tree whose +leaves are SettingsSpecInputs. Nesting maps to dot-delimited +SettingsKeys - `{ app: { name: {...} } }` produces the key `'app.name'`. + +## Example + +```ts +const schema: SettingsSchemaTree = { + app: { + name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + url: { default: 'http://localhost:1234', format: 'url', env: 'MY_APP_URL' }, + }, +}; +``` + +## Indexable + +> \[`key`: `string`\]: `SettingsSpecInput` \| `SettingsSchemaTree` diff --git a/packages/config/package.json b/packages/config/package.json index 9f732fd..0b4a585 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -5,43 +5,67 @@ "author": "Busticated", "license": "(MIT OR Apache-2.0)", "type": "module", - "main": "./dist/commonjs/index.js", - "module": "./dist/esm/index.js", - "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/node.js", + "module": "./dist/esm/node.js", + "types": "./dist/commonjs/node.d.ts", "files": [ "CHANGELOG.md", "LICENSE.md", - "dist/**/!(*.test.js)" + "dist/**/!(*.test.js|*.test.mjs|*.e2e.js|*.e2e.mjs)" ], + "dependencies": { + "dotenv": "^17.4.2" + }, + "devDependencies": { + "vite": "^8.2.0" + }, "tshy": { "exports": { "./package.json": "./package.json", - ".": "./src/index.ts" + ".": { + "browser": { + "types": "./dist/esm/browser.d.mts", + "default": "./dist/esm/browser.mjs" + }, + "import": { + "types": "./dist/esm/node.d.ts", + "default": "./dist/esm/node.js" + }, + "require": { + "types": "./dist/commonjs/node.d.ts", + "default": "./dist/commonjs/node.js" + } + } } }, "exports": { "./package.json": "./package.json", ".": { + "browser": { + "types": "./dist/esm/browser.d.mts", + "default": "./dist/esm/browser.mjs" + }, "import": { - "types": "./dist/esm/index.d.ts", - "default": "./dist/esm/index.js" + "types": "./dist/esm/node.d.ts", + "default": "./dist/esm/node.js" }, "require": { - "types": "./dist/commonjs/index.d.ts", - "default": "./dist/commonjs/index.js" + "types": "./dist/commonjs/node.d.ts", + "default": "./dist/commonjs/node.js" } } }, "scripts": { "version": "npm run changelog:update && npm run docs:build", "prepare": "npm run build", - "test": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js", + "test:unit": "npm run build && node --test --test-reporter spec --experimental-test-coverage --test-coverage-lines=96 --test-coverage-branches=100 --test-coverage-functions=100 --enable-source-maps dist/esm/*.test.js dist/esm/*.test.mjs", + "test:e2e": "npm run build && node --test --test-reporter spec --enable-source-maps dist/esm/*.e2e.mjs", "typecheck": "tsc --project .tshy/esm.json --noemit", "build": "tshy", "clean": "rm -rf ./dist && rm -rf ./tmp/*", "clean:modules": "rm -rf ./node_modules", "docs:build": "npm run docs:build:api && npm run docs:build:readme", - "docs:build:api": "typedoc --plugin typedoc-plugin-markdown --hideBreadcrumbs true --githubPages false --sourceLinkTemplate '/{path}#L{line}' --readme none src/index.ts", + "docs:build:api": "typedoc --plugin typedoc-plugin-markdown --hideBreadcrumbs true --githubPages false --sourceLinkTemplate '/{path}#L{line}' --readme none src/config.ts src/node.ts src/browser.mts", "docs:build:readme": "node ../../bin/update-package-readme.mjs @bust/config", "changelog:show": "node ../../bin/show-unpublished-changes.mjs @bust/config", "changelog:update": "node ../../bin/update-changelog.mjs @bust/config" diff --git a/packages/config/src/browser.mts b/packages/config/src/browser.mts new file mode 100644 index 0000000..3f5af2b --- /dev/null +++ b/packages/config/src/browser.mts @@ -0,0 +1,49 @@ +import { Config, CONFIG_GLOBAL_NAME } from './config.js'; +import type { SettingsSchemaTree, ConfigEnvVars } from './types.js'; + +export { type SettingsSchemaTree, type ConfigEnvVars, Config, CONFIG_GLOBAL_NAME }; + + +// NOTE: this must be the literal string `CONFIG_GLOBAL_NAME` resolves to in +// config.ts ('__BUST_CONFIG__') - bundlers' `define` replaces bare identifiers +// referenced like this via static text/AST matching, not a dynamic lookup, so +// it can't be written as `globalThis[CONFIG_GLOBAL_NAME]`. +declare const __BUST_CONFIG__: ConfigEnvVars | undefined; + +/** + * Reads the settings blob the end-user's bundler baked in via + * `getBrowserDefine()` (see `node.ts`), falling back to an empty object when + * it's unavailable (e.g. running un-bundled, or the consumer hasn't wired up + * `define` yet) - in which case settings just fall back to their defaults. + */ +export function getBrowserEnv(): ConfigEnvVars { + return typeof __BUST_CONFIG__ === 'undefined' ? {} : __BUST_CONFIG__; +} + +/** + * Builds a {@link Config} instance for use in the browser. Settings are + * sourced from the `public: true` subset of a Node-side config, baked in + * at build time via `getBrowserDefine()` and your bundler - this entry + * point has no dependency on any Node.js-only API. + * + * @example + * ```ts + * // config.ts - construct the config once and export it for the rest of + * // your app to import and share + * import { createConfig } from '@bust/config'; + * import { schema } from './schema.js'; + * + * export const config = createConfig(schema); + * ``` + * ```ts + * // elsewhere.ts + * import { config } from './config.js'; + * + * config.get('app.name'); + * ``` + * + * See `getBrowserDefine()` in `node.ts` for the bundler side of this. + */ +export function createConfig(schema: SettingsSchemaTree): Config { + return new Config({ schema, env: getBrowserEnv() }); +} diff --git a/packages/config/src/browser.test.mts b/packages/config/src/browser.test.mts new file mode 100644 index 0000000..708e7ae --- /dev/null +++ b/packages/config/src/browser.test.mts @@ -0,0 +1,45 @@ +import { describe, it, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { createConfig, getBrowserEnv } from './browser.mjs'; + + +// stand-in for the schema an end-user app would author for itself +const schema = { + app: { + name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + }, +}; + +type GlobalWithBustConfig = typeof globalThis & { __BUST_CONFIG__?: Record }; + +describe('@bust/config/browser', () => { + afterEach(() => { + delete (globalThis as GlobalWithBustConfig).__BUST_CONFIG__; + }); + + describe('createConfig', () => { + it('Builds a config sourced from its schema defaults when nothing was baked in', () => { + const config = createConfig(schema); + assert.strictEqual(config.get('app.name'), 'My App'); + }); + + it('Builds a config sourced from a `getBrowserDefine()`-baked settings blob', () => { + (globalThis as GlobalWithBustConfig).__BUST_CONFIG__ = { MY_APP_NAME: 'Baked In By Vite' }; + + const config = createConfig(schema); + assert.strictEqual(config.get('app.name'), 'Baked In By Vite'); + }); + }); + + describe('getBrowserEnv', () => { + it('Falls back to an empty object when nothing was baked in', () => { + assert.deepEqual(getBrowserEnv(), {}); + }); + + it('Returns the settings blob a bundler baked in via `define`', () => { + (globalThis as GlobalWithBustConfig).__BUST_CONFIG__ = { MY_APP_NAME: 'Baked In By Vite' }; + + assert.deepEqual(getBrowserEnv(), { MY_APP_NAME: 'Baked In By Vite' }); + }); + }); +}); diff --git a/packages/config/src/config.test.ts b/packages/config/src/config.test.ts new file mode 100644 index 0000000..093233a --- /dev/null +++ b/packages/config/src/config.test.ts @@ -0,0 +1,516 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck -- these tests deliberately feed malformed/mistyped schema & env +// values to exercise runtime validation, which fights static typing by design. +import { describe, it, beforeEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { Config, ConfigEnvVars } from './config.js'; + + +describe('@bust/config', () => { + let config!: Config; + let schema!: object; + + beforeEach(() => { + schema = { + one: { default: 'one', env: 'TEST_ONE', public: true }, + two: { default: 'two', format: ['two', '2', 'II'], env: 'TEST_TWO' }, + three: { default: 'http://example.com', format: 'url', env: 'TEST_THREE' }, + four: { + a: { default: 'a' }, + b: { + default: 4, + format: 'int', + env: 'TEST_FOUR', + public: true, + }, + c: { default: 'c' }, + }, + five: { a: { b: { default: 'ok' }, c: { default: 'ok' } } }, + six: { default: /foo/, public: true }, + seven: { default: { ok: true } }, + eight: { default: 1 }, + nine: { default: true }, + ten: { + default: ['a', 'b'], + format: 'array', + env: 'TEST_TEN', + public: true, + }, + }; + config = new Config({ schema }); + }); + + describe('Getting configuration settings', () => { + it('Gets a setting', () => { + const value = config.get('five.a.b'); + assert.strictEqual(value, 'ok'); + }); + + it('Throws when setting isn\'t available', () => { + assert.throws( + () => config.get('NOPE!'), + { + 'message': '\'NOPE!\' is not available - please ensure you\'ve set it' + }, + ); + }); + }); + + describe('Getting `public` configuration settings', () => { + it('Gets all publicly available settings', () => { + const settings = config.getPublicSettings(); + assert.deepEqual(settings, { + 'one': { + default: 'one', + value: 'one', + format: 'string', + env: 'TEST_ONE', + public: true, + }, + 'four.b': { + default: 4, + value: 4, + format: 'int', + env: 'TEST_FOUR', + public: true, + }, + 'six': { + default: /foo/, + value: /foo/, + format: 'regexp', + public: true, + }, + 'ten': { + default: ['a', 'b'], + value: ['a', 'b'], + format: 'array', + env: 'TEST_TEN', + public: true, + }, + }); + }); + }); + + describe('Getting `public` environment variables', () => { + it('Gets all publicly available environment variables', () => { + const env = config.getPublicEnvVars(); + assert.deepEqual(env, { + 'TEST_FOUR': 4, + 'TEST_ONE': 'one', + 'TEST_TEN': [ + 'a', + 'b', + ], + }); + }); + }); + + describe('Hydrating schema', () => { + let fakeEnv!: ConfigEnvVars; + + beforeEach(() => { + fakeEnv = { + TEST_ONE: 'one-updated', + TEST_TWO: '2', + TEST_THREE: 'http://example.com/updated', + }; + }); + + it('Establishes settings', () => { + const settings = config.hydrate(schema); + const keys = [...settings.keys()]; + + assert.deepEqual(keys, [ + 'one', + 'two', + 'three', + 'four.a', + 'four.b', + 'four.c', + 'five.a.b', + 'five.a.c', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + ]); + assert.deepEqual(settings.get('two'), { + default: 'two', + value: 'two', + format: ['two', '2', 'II'], + env: 'TEST_TWO', + }); + assert.deepEqual(settings.get('five.a.b'), { + default: 'ok', + value: 'ok', + format: 'string', + }); + assert.deepEqual(settings.get('ten'), { + default: ['a', 'b'], + value: ['a', 'b'], + format: 'array', + env: 'TEST_TEN', + public: true, + }); + }); + + it('Establishes settings when environment variables are provided', () => { + const settings = config.hydrate(schema, fakeEnv); + const keys = [...settings.keys()]; + + assert.deepEqual(keys, [ + 'one', + 'two', + 'three', + 'four.a', + 'four.b', + 'four.c', + 'five.a.b', + 'five.a.c', + 'six', + 'seven', + 'eight', + 'nine', + 'ten', + ]); + assert.deepEqual(settings.get('one'), { + default: 'one', + value: 'one-updated', + format: 'string', + env: 'TEST_ONE', + public: true, + }); + }); + + it('Throws when schema uses unknown format', () => { + const schema = { + test: { default: false, format: 'NOPE!', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\' uses an unrecognized format: NOPE!' + } + ); + }); + + it('Throws when schema has null / undefined values', () => { + const schema = { + test: { default: null, format: 'string', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\' is missing - please set it!', + } + ); + }); + + it('Throws when `enum` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: 'NOPE!', format: ['a', 'b', 'c'], env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be one of a|b|c', + } + ); + + schema.test.default = 'a'; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be one of a|b|c', + } + ); + + schema.test.default = 'a'; + env.TEST = 'b'; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `int` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: 'NOPE!', format: 'int', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be an integer', + } + ); + + schema.test.default = 1.1; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be an integer', + } + ); + + schema.test.default = 1; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be an integer', + } + ); + + schema.test.default = 1; + env.TEST = 2; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `nat` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: 'NOPE!', format: 'nat', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a positive integer', + } + ); + + schema.test.default = -1; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a positive integer', + } + ); + + schema.test.default = 1.0; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be a positive integer', + } + ); + + schema.test.default = 1.0; + env.TEST = 2.5; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `number` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: 'NOPE!', format: 'number', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a number', + } + ); + + schema.test.default = -1.5; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be a number', + } + ); + + schema.test.default = -1.5; + env.TEST = -2.5; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `url` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: 'NOPE!', format: 'url', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a valid url', + } + ); + + schema.test.default = 'http://example.com'; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be a valid url', + } + ); + + schema.test.default = 'http://example.com'; + env.TEST = 'http://example.com/updated'; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `boolean` value is invalid', () => { + const env = { TEST: 'NOPE!' }; // valid b/c of coercion + const schema = { + test: { default: 'NOPE!', format: 'boolean', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a boolean', + } + ); + + schema.test.default = false; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `string` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: false, format: 'string', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a string', + } + ); + + schema.test.default = 'ok'; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `regexp` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: false, format: 'regexp', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be a regular expression (RegExp)', + } + ); + + schema.test.default = /ok/; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `object` value is invalid', () => { + const env = { TEST: 'NOPE!' }; + const schema = { + test: { default: false, format: 'object', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be an object', + } + ); + + schema.test.default = {}; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be an object', + } + ); + + schema.test.default = {}; + env.TEST = '{}'; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + + it('Throws when `array` value is invalid', () => { + const schema = { + test: { default: false, format: 'array', env: 'TEST' }, + }; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be an array', + } + ); + + schema.test.default = 'one,two,three'; + + assert.throws( + () => config.hydrate(schema), + { + message: '\'test\': must be an array', + } + ); + + const env = { TEST: false }; + schema.test.default = []; + + assert.throws( + () => config.hydrate(schema, env), + { + message: '\'test\': must be an array', + } + ); + + schema.test.default = []; + env.TEST = 'one,two,three'; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + + schema.test.default = []; + env.TEST = ['one', 'two', 'three']; + + assert.doesNotThrow( + () => config.hydrate(schema, env), + ); + }); + }); +}); diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts new file mode 100644 index 0000000..92c03f2 --- /dev/null +++ b/packages/config/src/config.ts @@ -0,0 +1,279 @@ +import type { + SettingsKey, + SettingsValue, + SettingsFormat, + SettingsFormatName, + SettingsSpecInput, + SettingsSpec, + SettingsSchemaTree, + Settings, + PublicSettings, + ConfigEnvVars, + ConfigOptions, + Validator, +} from './types.js'; + +/** + * The name of the global variable `@bust/config` expects to find settings stored + * in when runnning in a browser. + */ +export const CONFIG_GLOBAL_NAME = '__BUST_CONFIG__'; + +/** + * Schema-driven configuration store. Given a {@link SettingsSchemaTree} and a + * map of environment variables, hydrates each leaf setting's value from the + * environment (falling back to its declared default), coercing and + * validating it against its format along the way. + * + * `Config` itself is environment-agnostic - it doesn't know about + * `process.env`, `.env` files, or `import.meta.env`. Node/browser-specific + * env-sourcing lives in `createConfig()` in `node.ts`/`browser.mts`. + * + * @example + * ```ts + * const config = new Config({ + * schema: { + * app: { + * name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + * }, + * }, + * env: process.env, + * }); + * + * config.get('app.name'); // 'My App', or the value of `process.env.MY_APP_NAME`, if set + * ``` + */ +export class Config { + settings: Settings; + + constructor({ schema = {}, env = {} }: ConfigOptions = {}) { + this.settings = this.hydrate(schema as SettingsSchemaTree, env); + } + + /** + * Looks up a single setting's hydrated value by its dot-delimited key. + * + * @throws if `key` isn't present in the hydrated schema + */ + get(key: SettingsKey): SettingsValue { + const spec = this.settings.get(key); + + if (!spec) { + throw new Error(`'${key}' is not available - please ensure you've set it`); + } + + return spec.value; + } + + /** Returns every hydrated setting marked `public: true`, keyed by dot-delimited path. */ + getPublicSettings(): PublicSettings { + const out: PublicSettings = {}; + + for (const [key, spec] of this.settings) { + if (spec.public !== true) { + continue; + } + + out[key] = spec; + } + + return out; + } + + /** + * Returns every public setting's value keyed by its *environment variable + * name* rather than its schema path. + */ + getPublicEnvVars(): ConfigEnvVars { + const settings = this.getPublicSettings(); + const out: ConfigEnvVars = {}; + + for (const spec of Object.values(settings)) { + if (!spec.env) { + continue; + } + + out[spec.env] = spec.value; + } + + return out; + } + + /** + * Walks a {@link SettingsSchemaTree}, resolving each leaf's format, + * coercing/validating its value from `env` (or its default), and + * flattening the tree into a dot-delimited {@link Settings} map. + * + * @throws if a leaf is missing a value and has no default, uses an + * unrecognized format, or its resolved value fails that format's validator + */ + hydrate(data: SettingsSchemaTree, env: ConfigEnvVars = {}): Settings { + const settings: Settings = new Map(); + + function traverse(schema: SettingsSchemaTree | SettingsSpecInput, path: string): boolean { + if (isSpecInput(schema)) { + return true; + } + + for (const name in schema) { + if (schema.hasOwnProperty(name)) { + const node = schema[name] as SettingsSpecInput | SettingsSchemaTree; + const key = path + ? `${path}.${name}` + : name; + + if (isObject(node) && traverse(node, key)) { + const spec = node as unknown as SettingsSpecInput; + const hydrated = spec as SettingsSpec; + hydrated.format = getFormatter(spec); + hydrated.value = format(spec.env ? env[spec.env] : undefined, key, spec); + validate(key, hydrated); + settings.set(key, hydrated); + } + } + } + + return false; + } + + traverse(data, ''); + + return settings; + } +} + +function isSpecInput(node: SettingsSchemaTree | SettingsSpecInput): node is SettingsSpecInput { + return isObject(node) && 'default' in node; +} + +function getFormatter(spec: SettingsSpecInput): SettingsFormat { + if (!spec.format) { + return getDefaultFormatter(spec.default); + } + + return spec.format; +} + +function format(x: SettingsValue | undefined, key: SettingsKey, spec: SettingsSpecInput): SettingsValue { + if (x == null) { + if (spec.default == null) { + throw new Error(`'${key}' is missing - please set it!`); + } + + return spec.default; + } + + try { + switch (spec.format) { + case 'nat': + case 'int': + return parseInt(x as string, 10); + + case 'number': + return parseFloat(x as string); + + case 'boolean': + return String(x).toLowerCase() !== 'false'; + + case 'url': // TODO (busticated): return URL object? + case 'string': + return String(x); + + case 'array': + return Array.isArray(x) + ? x + : (x as string).split(','); + + case 'object': + return JSON.parse(x as string); + + case 'regexp': + return new RegExp(x as string); + + default: + return x; + } + } catch { + return null; + } +} + +const validators: Record = { + enum: function(key, spec) { + const allowed = spec.format as string[]; + + if (!allowed.includes(spec.value as string)) { + throw new Error(`'${key}': must be one of ${allowed.join('|')}`); + } + }, + int: function(key, spec) { + if (!Number.isInteger(spec.value)) { + throw new Error(`'${key}': must be an integer`); + } + }, + nat: function(key, spec) { + if (!Number.isInteger(spec.value) || (spec.value as number) < 0) { + throw new Error(`'${key}': must be a positive integer`); + } + }, + url: function(key, spec) { + try { + new URL(spec.value as string); + } catch { + throw new Error(`'${key}': must be a valid url`); + } + }, + boolean: function(key, spec) { + if (typeof spec.value !== 'boolean') { + throw new Error(`'${key}': must be a boolean`); + } + }, + string: function(key, spec) { + if (typeof spec.value !== 'string') { + throw new Error(`'${key}': must be a string`); + } + }, + number: function(key, spec) { + if (typeof spec.value !== 'number' || isNaN(spec.value)) { + throw new Error(`'${key}': must be a number`); + } + }, + regexp: function(key, spec) { + if (spec?.value?.constructor?.name !== 'RegExp') { + throw new Error(`'${key}': must be a regular expression (RegExp)`); + } + }, + object: function(key, spec) { + if (!isObject(spec.value)) { + throw new Error(`'${key}': must be an object`); + } + }, + array: function(key, spec) { + if (!Array.isArray(spec.value)) { + throw new Error(`'${key}': must be an array`); + } + }, +}; + +function validate(key: SettingsKey, spec: SettingsSpec): void { + const validator = Array.isArray(spec.format) + ? validators.enum + : validators[spec.format as string]; + + if (typeof validator !== 'function') { + throw new Error(`'${key}' uses an unrecognized format: ${spec.format}`); + } + + validator(key, spec); +} + +function isObject(x: unknown): x is Record { + return !!x && typeof x === 'object' && !Array.isArray(x); +} + +const objTypePtn = /\[.* |]/g; + +function getDefaultFormatter(x: SettingsValue): SettingsFormatName { + const type = Object.prototype.toString.call(x); + return type.replace(objTypePtn, '').toLowerCase() as SettingsFormatName; +} diff --git a/packages/config/src/index.test.ts b/packages/config/src/index.test.ts deleted file mode 100644 index 7e62439..0000000 --- a/packages/config/src/index.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it } from 'node:test'; -import { strict as assert } from 'node:assert'; -import * as config from './index.js'; - - -describe('@bust/config', () => { - it('has tests', () => { - assert(config); - assert.equal(config.greet(), 'hello world!'); - }); -}); - diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts deleted file mode 100644 index 6a158a4..0000000 --- a/packages/config/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const greet = () => { - return 'hello world!'; -}; diff --git a/packages/config/src/lib.e2e.mts b/packages/config/src/lib.e2e.mts new file mode 100644 index 0000000..40725f6 --- /dev/null +++ b/packages/config/src/lib.e2e.mts @@ -0,0 +1,124 @@ +import { describe, it, before } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + + +// dist/esm/lib.e2e.js -> packages/config +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const tmpRoot = path.join(packageRoot, 'tmp'); + + +describe('@bust/config end-to-end usage', () => { + describe('Node.js', () => { + const dir = path.join(tmpRoot, 'e2e-node'); + + before(() => { + writeFixture(dir, { + '.env': 'MY_APP_NAME=E2E Node Value\n', + 'main.mjs': ` + import { createConfig } from '@bust/config'; + + const config = createConfig({ + app: { name: { default: 'Default App', env: 'MY_APP_NAME', public: true } }, + }); + + console.log(JSON.stringify({ name: config.get('app.name') })); + `, + }); + }); + + it('Resolves the Node build via the real package specifier and loads a local `.env` file', () => { + const stdout = execFileSync(process.execPath, ['main.mjs'], { cwd: dir, encoding: 'utf8' }); + assert.deepEqual(JSON.parse(stdout), { name: 'E2E Node Value' }); + }); + }); + + describe('Browser (via Vite)', () => { + const MY_APP_NAME = 'E2E Browser Test'; + const MY_APP_SECRET = 'fake-secret-value'; + const dir = path.join(tmpRoot, 'e2e-browser'); + const bundlePath = path.join(dir, 'dist', 'bundle.js'); + + before(async () => { + const cwd = process.cwd(); + + writeFixture(dir, { + 'package.json': JSON.stringify({ name: 'e2e-browser-fixture', private: true, type: 'module' }), + '.env': `MY_APP_NAME=${MY_APP_NAME}\nMY_APP_SECRET=${MY_APP_SECRET}\n`, + 'schema.mjs': ` + export const schema = { + app: { + name: { default: 'Default App', env: 'MY_APP_NAME', public: true }, + secret: { default: '', env: 'MY_APP_SECRET' }, + }, + }; + `, + 'main.mjs': ` + import { createConfig } from '@bust/config'; + import { schema } from './schema.mjs'; + + const config = createConfig(schema); + + console.log(JSON.stringify({ + name: config.get('app.name'), + secret: config.get('app.secret'), + })); + `, + 'vite.config.mjs': ` + import { defineConfig } from 'vite'; + import { createConfig, getBrowserDefine } from '@bust/config'; + import { schema } from './schema.mjs'; + + const config = createConfig(schema); + + export default defineConfig({ + define: getBrowserDefine(config), + build: { + outDir: 'dist', + lib: { + entry: './main.mjs', + formats: ['es'], + fileName: () => 'bundle.js', + }, + }, + }); + `, + }); + + process.chdir(dir); + + try { + await build({ root: dir, logLevel: 'silent' }); + } finally { + process.chdir(cwd); + } + }); + + it('Bundles the browser-safe build with no dotenv/fs dependency', () => { + const bundle = fs.readFileSync(bundlePath, 'utf8'); + assert.equal(bundle.includes('dotenv'), false); + assert.equal(bundle.includes(MY_APP_SECRET), false); + }); + + it('Bakes in only `public: true` settings, leaving the rest at their defaults', () => { + const stdout = execFileSync(process.execPath, [bundlePath], { encoding: 'utf8' }); + assert.deepEqual(JSON.parse(stdout), { + name: MY_APP_NAME, + secret: '', + }); + }); + }); +}); + +function writeFixture(dir: string, files: Record): void { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), content); + } +} diff --git a/packages/config/src/node.test.ts b/packages/config/src/node.test.ts new file mode 100644 index 0000000..c383fe9 --- /dev/null +++ b/packages/config/src/node.test.ts @@ -0,0 +1,60 @@ +import { describe, it, before, after } from 'node:test'; +import { strict as assert } from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createConfig, getBrowserDefine, CONFIG_GLOBAL_NAME } from './node.js'; + + +describe('@bust/config/node', () => { + const schema = { + app: { + name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + secret: { default: 'shh', env: 'MY_APP_SECRET' }, + }, + }; + + describe('createConfig()', () => { + it('Builds a config sourced from `process.env`', async () => { + const config = createConfig(schema); + assert.strictEqual(config.get('app.name'), 'My App'); + }); + }); + + describe('getBrowserDefine()', () => { + it('Includes public settings in a bundler-friendly `define` object', async () => { + const config = createConfig(schema); + assert.deepEqual(getBrowserDefine(config), { + [CONFIG_GLOBAL_NAME]: { MY_APP_NAME: 'My App' }, + }); + }); + }); + + describe('.env file loading', () => { + let cwd: string; + let dir: string; + + before(() => { + cwd = process.cwd(); + // TODO (busticated): retarget to local `./tmp` directory + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bust-config-')); + fs.writeFileSync(path.join(dir, '.env'), 'MY_APP_NAME=Loaded From Dotenv\n'); + process.chdir(dir); + }); + + after(() => { + process.chdir(cwd); + fs.rmSync(dir, { recursive: true, force: true }); + delete process.env.MY_APP_NAME; + }); + + it('Loads values from a `.env` file before building the config', async () => { + const mod = await import(`./node.js?bust-config-test=${Date.now()}`); + const config = mod.createConfig(schema); + assert.strictEqual(config.get('app.name'), 'Loaded From Dotenv'); + assert.deepEqual(mod.getBrowserDefine(config), { + [mod.CONFIG_GLOBAL_NAME]: { MY_APP_NAME: 'Loaded From Dotenv' }, + }); + }); + }); +}); diff --git a/packages/config/src/node.ts b/packages/config/src/node.ts new file mode 100644 index 0000000..faced7e --- /dev/null +++ b/packages/config/src/node.ts @@ -0,0 +1,61 @@ +import type { SettingsSchemaTree, ConfigEnvVars } from './types.js'; +import dotenv from 'dotenv'; +import { Config, CONFIG_GLOBAL_NAME } from './config.js'; + +export { type SettingsSchemaTree, type ConfigEnvVars, Config, CONFIG_GLOBAL_NAME }; + + +dotenv.config({ quiet: true }); // silence log spam / ads from dotenv v17 | https://github.com/motdotla/dotenv/issues/876 + +/** + * Builds a {@link Config} for use in Node.js: a local `.env` file (if + * present in the current working directory) is loaded into `process.env` + * before `schema` is hydrated, so local development values can live in a + * git-ignored `.env` file instead of real environment variables. + * + * @example + * ```ts + * // config.ts - construct the config once and export it for the rest of + * // your application to import and share + * import { createConfig } from '@bust/config'; + * + * export const config = createConfig({ + * app: { + * name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + * secret: { default: '', env: 'MY_APP_SECRET' }, + * }, + * }); + * ``` + * ```ts + * // elsewhere.ts + * import { config } from './config.ts'; + * + * config.get('app.name'); + * ``` + */ +export function createConfig(schema: SettingsSchemaTree): Config { + return new Config({ schema, env: process.env }); +} + +/** + * Produces a Vite `define` entry that exposes only `config`'s `public: true` + * settings to a browser build - `createConfig()` in the browser reads this + * same blob back out at runtime via {@link CONFIG_GLOBAL_NAME}. Values never + * leave the Node process beyond what `getPublicEnvVars()` already returns, so + * there's no separate `VITE_`-prefixed env var to keep in sync with `schema`. + * + * @example + * ```ts + * // vite.config.ts + * import { defineConfig } from 'vite'; + * import { getBrowserDefine } from '@bust/config'; + * import { config } from './config.ts'; + * + * export default defineConfig({ + * define: getBrowserDefine(config), + * }); + * ``` + */ +export function getBrowserDefine(config: Config): Record { + return { [CONFIG_GLOBAL_NAME]: config.getPublicEnvVars() }; +} diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts new file mode 100644 index 0000000..bf03eaf --- /dev/null +++ b/packages/config/src/types.ts @@ -0,0 +1,118 @@ +/** + * The dot-delimited path used to look up a setting, e.g. `'app.name'` for a + * schema shaped like `{ app: { name: { default: 'My App' } } }`. + */ +export type SettingsKey = string; + +/** A single, already-coerced primitive setting value. */ +export type SettingsPrimitive = string | number | boolean | null; + +/** Any value a setting can hold once it's been coerced to its target format. */ +export type SettingsValue = SettingsPrimitive | SettingsPrimitive[] | RegExp | Record; + +/** The built-in coercion/validation formats a setting can declare. */ +export type SettingsFormatName = + | 'int' + | 'nat' + | 'number' + | 'boolean' + | 'string' + | 'url' + | 'array' + | 'object' + | 'regexp'; + +/** + * A setting's format: one of the built-in {@link SettingsFormatName}s, an + * array of allowed string values (treated as an enum), or `undefined` to + * infer the format from the JS type of `default`. + */ +export type SettingsFormat = SettingsFormatName | string[] | undefined; + +/** Human-readable description of a setting, surfaced in generated docs. */ +export type SettingsDoc = string | undefined; + +/** The environment variable name a setting is sourced from, if any. */ +export type SettingsEnv = string | undefined; + +/** Whether a setting is safe to expose outside the server (see {@link Config.getPublicSettings}). */ +export type SettingsPublic = boolean | undefined; + +/** + * A single leaf node in an author-supplied schema tree - one setting's + * default value plus its optional format/doc/env/visibility metadata. + * + * @example + * ```ts + * const nameSetting: SettingsSpecInput = { + * default: 'My App', + * doc: 'The official name of the application', + * env: 'MY_APP_NAME', + * public: true, + * }; + * ``` + */ +export interface SettingsSpecInput { + default: SettingsValue; + format?: SettingsFormat; + doc?: SettingsDoc; + env?: SettingsEnv; + public?: SettingsPublic; +} + +/** + * A {@link SettingsSpecInput} after `Config.hydrate()` has resolved its + * format and coerced its value from the environment (or its default). + */ +export interface SettingsSpec extends Omit { + format: SettingsFormat; + value: SettingsValue; +} + +/** + * An author-supplied configuration schema: an arbitrarily-nested tree whose + * leaves are {@link SettingsSpecInput}s. Nesting maps to dot-delimited + * {@link SettingsKey}s - `{ app: { name: {...} } }` produces the key `'app.name'`. + * + * @example + * ```ts + * const schema: SettingsSchemaTree = { + * app: { + * name: { default: 'My App', env: 'MY_APP_NAME', public: true }, + * url: { default: 'http://localhost:1234', format: 'url', env: 'MY_APP_URL' }, + * }, + * }; + * ``` + */ +export interface SettingsSchemaTree { + [key: string]: SettingsSpecInput | SettingsSchemaTree; +} + +/** The flattened, hydrated form of a {@link SettingsSchemaTree}: dot-delimited key to resolved spec. */ +export type Settings = Map; + +/** The subset of hydrated {@link Settings} whose specs are marked `public: true`, keyed the same way. */ +export interface PublicSettings { + [key: string]: SettingsSpec; +} + +/** + * A map of environment variable names to values. Values are typically raw + * strings (as they'd come from `process.env`), but pre-coerced values are + * also accepted, which is handy when constructing a {@link Config} in tests. + */ +export interface ConfigEnvVars { + [key: string]: SettingsValue | undefined; +} + +/** Constructor options for {@link Config}. */ +export interface ConfigOptions { + // NOTE: authored schemas are recursive and checked against `SettingsSchemaTree` at + // runtime by `hydrate()` - TS's structural checker can't reliably verify a deeply + // nested literal against a recursive union, so we accept `object` here and cast. + schema?: object; + env?: ConfigEnvVars; +} + +/** The shape of a per-format validation function used internally by `Config.hydrate()`. */ +export type Validator = (key: SettingsKey, spec: SettingsSpec) => void;