diff --git a/README.md b/README.md index 2427e87..7299eec 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Click on one of the links to access the documentation of the package: | result | [@openally/result](./src/result) | | auto-url | [@openally/auto-url](./src/auto-url) | | config | [@openally/config](./src/config) | +| env | [@openally/env](./src/env) | 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 cb0a949..aa73fe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "src/mutex", "src/result", "src/auto-url", - "src/config" + "src/config", + "src/env" ], "devDependencies": { "@faker-js/faker": "^9.2.0", @@ -1156,6 +1157,10 @@ "integrity": "sha512-qJ85Zil/pR1/eR2/4j6jsqRoAAjH+1D+0A7+FhvbLv4TcjvgYmjzDt5bl9/AK2wzPP5Ci+KetVR0tbc22Hdmqw==", "dev": true }, + "node_modules/@openally/env": { + "resolved": "src/env", + "link": true + }, "node_modules/@openally/ephemeral-map": { "resolved": "src/ephemeral-map", "link": true @@ -4249,6 +4254,12 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "node_modules/ts-pattern": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.3.1.tgz", + "integrity": "sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA==", + "license": "MIT" + }, "node_modules/tsup": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.3.5.tgz", @@ -5067,6 +5078,15 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "src/env": { + "name": "@openally/env", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@openally/result": "^1.3.0", + "ts-pattern": "^5.3.1" + } + }, "src/ephemeral-map": { "name": "@openally/ephemeral-map", "version": "1.3.2", @@ -5842,6 +5862,13 @@ "integrity": "sha512-qJ85Zil/pR1/eR2/4j6jsqRoAAjH+1D+0A7+FhvbLv4TcjvgYmjzDt5bl9/AK2wzPP5Ci+KetVR0tbc22Hdmqw==", "dev": true }, + "@openally/env": { + "version": "file:src/env", + "requires": { + "@openally/result": "^1.3.0", + "ts-pattern": "^5.3.1" + } + }, "@openally/ephemeral-map": { "version": "file:src/ephemeral-map", "requires": { @@ -8009,6 +8036,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "ts-pattern": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.3.1.tgz", + "integrity": "sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA==" + }, "tsup": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.3.5.tgz", diff --git a/package.json b/package.json index 8692899..f7f6702 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "src/mutex", "src/result", "src/auto-url", - "src/config" + "src/config", + "src/env" ], "license": "MIT", "bugs": { diff --git a/src/env/README.md b/src/env/README.md new file mode 100644 index 0000000..a209f67 --- /dev/null +++ b/src/env/README.md @@ -0,0 +1,46 @@ +

+ Env +

+ +

+ Safely load/parse ENV variables +

+ +

+ + npm version + + + license + + + download + + + + +

+ +## 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/env +# or +$ yarn add @openally/env +``` + +## Usage example + +```ts +``` + +## API +TBC + +## License +MIT diff --git a/src/env/package.json b/src/env/package.json new file mode 100644 index 0000000..2a2377e --- /dev/null +++ b/src/env/package.json @@ -0,0 +1,32 @@ +{ + "name": "@openally/env", + "version": "1.0.0", + "description": "Safely load/parse ENV variables", + "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" + } + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "prepublishOnly": "npm run build", + "test": "glob -c \"tsx --test\" \"./test/**/*.spec.ts\"", + "coverage": "c8 --all --src ./src -r html npm test", + "lint": "cross-env eslint src/**/*.ts" + }, + "files": [ + "dist" + ], + "keywords": [], + "author": "GENTILHOMME Thomas ", + "license": "MIT", + "dependencies": { + "@openally/result": "^1.3.0", + "ts-pattern": "^5.3.1" + } +} diff --git a/src/env/src/class/EnvLoader.class.ts b/src/env/src/class/EnvLoader.class.ts new file mode 100644 index 0000000..e7c2f53 --- /dev/null +++ b/src/env/src/class/EnvLoader.class.ts @@ -0,0 +1,135 @@ +// Import Third-party Dependencies +import { Err, Ok, Some, None, type Result } from "@openally/result"; +import { match } from "ts-pattern"; + +// Import Internal Dependencies +import { + SafeEnvNotFound, + EnvNotFound, + SafeUnknowEnvType, + UnknowEnvType +} from "../errors.js"; +import { + type ExtractDescriptorType, + type AllEnvTypeDescriptor, + type StringTypeParameters, + ENV_STRING, + ENV_BOOLEAN, + ENV_NUMBER +} from "../types.js"; +import * as utils from "../utils/index.js"; + +// CONSTANTS +function kNoopParsingHook() { + return void 0; +} + +export type ParsingState = "found" | "notFound" | "parsingError"; +export type ParsingHookFunction = (envName: string, state: ParsingState, value?: any) => void; + +export interface EnvLoaderOptions { + parseHook?: ParsingHookFunction; + /** + * Avoid leaking secrets and password values + * @default true + */ + redact?: boolean; + prefix?: string; +} + +export interface EnvLoaderParsingOptions { + env?: NodeJS.ProcessEnv; + prefix?: string; +} + +export class EnvLoader { + private parseHook: ParsingHookFunction; + private prefix: string | undefined; + + constructor( + options: EnvLoaderOptions = {} + ) { + const { + parseHook = kNoopParsingHook, + prefix + } = options; + + this.parseHook = parseHook; + this.prefix = prefix; + } + + unsafeParse( + name: string, + descriptor: T, + options: EnvLoaderParsingOptions = {} + ): ExtractDescriptorType { + return this.parse(name, descriptor, options).unwrap(); + } + + parse( + name: string, + descriptor: T, + options: EnvLoaderParsingOptions = {} + ): Result, SafeEnvNotFound | SafeUnknowEnvType> { + const { + env = process.env, + prefix = this.prefix + } = options; + + const finalizedName = prefix && name.startsWith(prefix) ? + name : `${prefix}${name}`; + const rawEnv = env[finalizedName]; + + if (typeof rawEnv === "undefined") { + this.parseHook(finalizedName, "notFound"); + + return Err(EnvNotFound.create(finalizedName)); + } + + const parsingResult = match(descriptor.type) + .with(ENV_STRING, () => this.#parseString(rawEnv, descriptor.parameters)) + .with(ENV_BOOLEAN, () => this.#parseBoolean(rawEnv)) + .with(ENV_NUMBER, () => this.#parseNumber(rawEnv)) + .otherwise(() => None); + + if (parsingResult.some) { + const parsedValue = parsingResult.safeUnwrap(); + this.parseHook( + finalizedName, "found", utils.redactEnv(finalizedName, parsedValue) + ); + + // TODO: not sure how to fix type here + return Ok(parsedValue as any); + } + + this.parseHook( + finalizedName, "parsingError", utils.redactEnv(finalizedName, rawEnv) + ); + + return Err(UnknowEnvType.create()); + } + + #parseString( + value: string, + parameters: StringTypeParameters + ) { + const { format } = parameters; + + const trimedValue = value.trim(); + if (format && !format.test(trimedValue)) { + return None; + } + + return Some(trimedValue); + } + + #parseBoolean(value: string) { + return Some(/^(?:y|yes|true|1|on)$/i.test(value.trim())); + } + + #parseNumber(value: string) { + const castedValue = Number(value); + + return Number.isNaN(castedValue) ? None : Some(castedValue); + } +} diff --git a/src/env/src/errors.ts b/src/env/src/errors.ts new file mode 100644 index 0000000..fcd9ae5 --- /dev/null +++ b/src/env/src/errors.ts @@ -0,0 +1,29 @@ +declare const __brand: unique symbol; +type Brand = { [__brand]: B }; +type Branded = T & Brand; + +function createBranded(instance: T): T & Brand { + return instance as T & Brand; +} + +export class EnvNotFound extends Error { + static create(envName: string): SafeEnvNotFound { + return createBranded(new EnvNotFound(envName)); + } + + private constructor(envName: string) { + super(`Unable to find and load '${envName}'.`); + } +} +export type SafeEnvNotFound = Branded; + +export class UnknowEnvType extends Error { + static create(): SafeUnknowEnvType { + return createBranded(new UnknowEnvType()); + } + + private constructor() { + super(`Unknown Env type`); + } +} +export type SafeUnknowEnvType = Branded; diff --git a/src/env/src/index.ts b/src/env/src/index.ts new file mode 100644 index 0000000..7d3918f --- /dev/null +++ b/src/env/src/index.ts @@ -0,0 +1,37 @@ +// Import Third-party Dependencies +import { type Result } from "@openally/result"; + +// Import Internal Dependencies +import { + SafeEnvNotFound, + SafeUnknowEnvType +} from "./errors.js"; +import { EnvLoader } from "./class/EnvLoader.class.js"; +import { + type EnvType, + type EnvTypeDescriptor, + type ExtractDescriptorSymbol, + type ExtractDescriptorType, + Types +} from "./types.js"; + +export interface PickOptions { + env?: NodeJS.ProcessEnv; +} + +export function pick< + T extends EnvType = typeof Types.string + >( + name: string, + descriptor: T = Types.string as T, + options: PickOptions = {} +): Result, SafeEnvNotFound | SafeUnknowEnvType> { + const finalizedDescriptor: EnvTypeDescriptor> = typeof descriptor === "function" ? + descriptor() : + descriptor; + + return new EnvLoader() + .parse(name, finalizedDescriptor, options); +} + +export { Types }; diff --git a/src/env/src/types.ts b/src/env/src/types.ts new file mode 100644 index 0000000..97e3a15 --- /dev/null +++ b/src/env/src/types.ts @@ -0,0 +1,66 @@ + +// CONSTANTS +export const ENV_STRING: unique symbol = Symbol("env.type.string"); +export const ENV_BOOLEAN: unique symbol = Symbol("env.type.boolean"); +export const ENV_NUMBER: unique symbol = Symbol("env.type.number"); + +export type AllEnvTypes = { + [ENV_STRING]: string; + [ENV_BOOLEAN]: boolean; + [ENV_NUMBER]: number; +}; + +export type EnvTypeDescriptor< + U extends symbol, + P extends Record = Record +> = { + type: U; + parameters: P; +} +export type AllEnvTypeDescriptor = { + [K in keyof AllEnvTypes]: EnvTypeDescriptor; +}[keyof AllEnvTypes]; + +export type EnvType = + ((parameters?: any) => EnvTypeDescriptor) | + EnvTypeDescriptor; + +export type ExtractPrimitiveType = + T extends keyof AllEnvTypes ? AllEnvTypes[T] : + never; + +export type ExtractDescriptorSymbol = + T extends () => EnvTypeDescriptor ? + U : + T extends EnvTypeDescriptor ? + U : + never; + +export type ExtractDescriptorType = ExtractPrimitiveType>; + +export type StringTypeParameters = { + format?: RegExp; +} + +export const Types = { + string( + parameters: StringTypeParameters = {} + ): EnvTypeDescriptor { + return { + type: ENV_STRING, + parameters + }; + }, + boolean(): EnvTypeDescriptor { + return { + type: ENV_BOOLEAN, + parameters: {} + }; + }, + number(): EnvTypeDescriptor { + return { + type: ENV_NUMBER, + parameters: {} + }; + } +} satisfies Record EnvTypeDescriptor>; diff --git a/src/env/src/utils/index.ts b/src/env/src/utils/index.ts new file mode 100644 index 0000000..57a1b09 --- /dev/null +++ b/src/env/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./redactEnv.js"; diff --git a/src/env/src/utils/redactEnv.ts b/src/env/src/utils/redactEnv.ts new file mode 100644 index 0000000..c1b6de7 --- /dev/null +++ b/src/env/src/utils/redactEnv.ts @@ -0,0 +1,5 @@ +export function redactEnv(name: string, value: any) { + return name.includes("secret") || name.includes("password") ? + "** REDACTED **" : + value; +} diff --git a/src/env/tsconfig.json b/src/env/tsconfig.json new file mode 100644 index 0000000..3e74d19 --- /dev/null +++ b/src/env/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json index 05ad96b..f9720f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,9 @@ }, { "path": "./src/config" + }, + { + "path": "./src/env" } ] }