diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 611ff23..2a6b44b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,4 +24,3 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test - - run: pnpm attw . --pack diff --git a/README.md b/README.md index cbab1f2..1ff0e55 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ optionally provide defaults (which can be matched against `NODE_ENV` values like `production` or `development`), as well as help strings that will be included in the error thrown when an env var is missing. +## Features + +- No dependencies +- Fully type-safe +- Compatible with serverless environments (import `znv/compat` instead of `znv`) + ## Status Unstable: znv has not yet hit v1.0.0, and per semver there may be breaking @@ -31,7 +37,7 @@ about final API design are welcome. - [Quickstart](#quickstart) - [Motivation](#motivation) - [Usage](#usage) - - [`parseEnv`](#parseenvenvironment-schemas) + - [`parseEnv`](#parseenvenvironment-schemas-reporterOrFormatters) - [Extra schemas](#extra-schemas) - [Coercion rules](#coercion-rules) - [Comparison to other libraries](#comparison-to-other-libraries) @@ -43,6 +49,8 @@ about final API design are welcome. ```bash npm i znv zod # or +pnpm add znv zod +# or yarn add znv zod ``` @@ -200,7 +208,7 @@ environments is not straightforward. ## Usage -### `parseEnv(environment, schemas)` +### `parseEnv(environment, schemas, reporterOrFormatters?)` Parse the given `environment` using the given `schemas`. Returns a read-only object that maps the keys of the `schemas` object to their respective parsed @@ -209,6 +217,13 @@ values. Throws if any schema fails to parse its respective env var. The error aggregates all parsing failures for the schemas. +Optionally, you can pass a custom error reporter as the third parameter to +`parseEnv` to customize how errors are displayed. The reporter is a function +that receives error details and returns a `string`. Alternately, you can pass an +object of _token formatters_ as the third parameter to `parseEnv`; this can be +useful if you want to retain the default error reporting format but want to +customize some aspects of it (for example, by redacting secrets). + #### `environment: Record` You usually want to pass in `process.env` as the first argument. @@ -308,6 +323,66 @@ pass a `DetailedSpec` object that has the following fields: `NODE_ENV: z.enum(["production", "development", "test", "ci"])` to enforce that `NODE_ENV` is always defined and is one of those four expected values. +#### `reporterOrFormatters?: Reporter | TokenFormatters` + +An optional error reporter or object of error token formatters, for customizing +the displayed output when a validation error occurs. + +- `Reporter: (errors: ErrorWithContext[], schemas: Schemas) => string` + + A reporter is a function that takes a list of errors and the schemas you + passed to `parseEnv` and returns a `string`. Each error has the following + format: + + ```ts + { + /** The env var name. */ + key: string; + /** The actual value present in `process.env[key]`, or undefined. */ + receivedValue: unknown; + /** `ZodError` if Zod parsing failed, or `Error` if a preprocessor threw. */ + error: unknown; + /** If a default was provided, whether the default value was used. */ + defaultUsed: boolean; + /** If a default was provided, the given default value. */ + defaultValue: unknown; + } + ``` + +- `TokenFormatters` + + An object with the following structure: + + ```ts + { + /** Formatter for the env var name. */ + formatVarName?: (key: string) => string; + + /** For parsed objects with errors, formatter for object keys. */ + formatObjKey?: (key: string) => string; + + /** Formatter for the actual value we received for the env var. */ + formatReceivedValue?: (val: unknown) => string; + + /** Formatter for the default value provided for the schema. */ + formatDefaultValue?: (val: unknown) => string; + + /** Formatter for the error summary header. */ + formatHeader?: (header: string) => string; + } + ``` + + For example, if you want to redact value names, you can invoke `parseEnv` like + this: + + ```ts + export const { SOME_VAL } = parseEnv( + process.env, + { SOME_VAL: z.number().nonnegative() }, + { formatReceivedValue: () => "" }, + ); + ``` + ### Extra schemas znv exports a very small number of extra schemas for common env var types. diff --git a/eslint.config.mjs b/eslint.config.mjs index 78a133a..c638742 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,6 +8,7 @@ export default defineConfig([ rules: { "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-base-to-string": "off", }, }, ]); diff --git a/package.json b/package.json index 7f9ccf2..f7ca95f 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,13 @@ "name": "znv", "version": "0.4.0", "description": "Parse your environment with Zod schemas", - "type": "module", + "license": "MIT", "keywords": [ "env", "process.env", "zod", "validation" ], - "main": "dist-cjs/index.js", - "types": "dist/index.d.ts", - "module": "dist/index.js", "author": "s ", "homepage": "https://github.com/lostfictions/znv", "repository": { @@ -21,6 +18,10 @@ "bugs": { "url": "https://github.com/lostfictions/znv/issues" }, + "type": "module", + "main": "dist-cjs/index.js", + "types": "dist/index.d.ts", + "module": "dist/index.js", "files": [ "dist/", "dist-cjs/" @@ -35,9 +36,18 @@ "types": "./dist-cjs/index.d.ts", "default": "./dist-cjs/index.js" } + }, + "./compat": { + "import": { + "types": "./dist/compat.d.ts", + "default": "./dist/compat.js" + }, + "require": { + "types": "./dist-cjs/compat.d.ts", + "default": "./dist-cjs/compat.js" + } } }, - "license": "MIT", "scripts": { "build": "run-s -l \"build:*\"", "build:clean": "rm -rf dist/ dist-cjs/", @@ -49,13 +59,11 @@ "test:eslint": "eslint --color src", "test:jest": "jest --colors", "test:prettier": "prettier -l \"src/**/*\"", + "test:attw": "attw . --pack --profile node16", "prettier": "prettier \"src/**/*\" --write", "jest": "jest --colors --watch", "prepublishOnly": "run-s -l test build" }, - "dependencies": { - "colorette": "^2.0.19" - }, "peerDependencies": { "zod": "^3.24.2" }, @@ -64,9 +72,9 @@ "@types/node": "^22.13.10", "eslint": "^9.22.0", "eslint-config-lostfictions": "^7.0.0-beta.0", - "jest": "^29.6.4", + "jest": "^29.7.0", "npm-run-all": "^4.1.5", - "prettier": "^3.0.3", + "prettier": "^3.5.3", "ts-jest": "^29.2.6", "ts-node": "^10.9.2", "typescript": "^5.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51e2f70..bb85e64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - colorette: - specifier: ^2.0.19 - version: 2.0.20 devDependencies: '@arethetypeswrong/cli': specifier: ^0.17.4 @@ -28,13 +24,13 @@ importers: specifier: ^7.0.0-beta.0 version: 7.0.0-beta.0(@typescript-eslint/parser@8.27.0(eslint@9.23.0)(typescript@5.8.2))(@typescript-eslint/utils@8.27.0(eslint@9.23.0)(typescript@5.8.2))(eslint@9.23.0)(typescript@5.8.2) jest: - specifier: ^29.6.4 + specifier: ^29.7.0 version: 29.7.0(@types/node@22.13.11)(ts-node@10.9.2(@types/node@22.13.11)(typescript@5.8.2)) npm-run-all: specifier: ^4.1.5 version: 4.1.5 prettier: - specifier: ^3.0.3 + specifier: ^3.5.3 version: 3.5.3 ts-jest: specifier: ^29.2.6 @@ -897,9 +893,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -3692,8 +3685,6 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - commander@10.0.1: {} concat-map@0.0.1: {} diff --git a/src/compat.ts b/src/compat.ts new file mode 100644 index 0000000..487f485 --- /dev/null +++ b/src/compat.ts @@ -0,0 +1,23 @@ +export { z } from "zod"; +export * from "./parse-env.js"; +export * from "./preprocessors.js"; +export * from "./extra-schemas.js"; + +export type { + DeepReadonly, + DeepReadonlyArray, + DeepReadonlyObject, +} from "./util/type-helpers.js"; + +import { parseEnvImpl, type ParseEnv } from "./parse-env.js"; + +/** + * Parses the passed environment object using the provided map of Zod schemas + * and returns the immutably-typed, parsed environment. Compatible with + * serverless and browser environments. + */ +export const parseEnv: ParseEnv = ( + env, + schemas, + reporterOrTokenFormatters = {}, +) => parseEnvImpl(env, schemas, reporterOrTokenFormatters); diff --git a/src/extra-schemas.test.ts b/src/extra-schemas.test.ts index ca2a2e7..fae90a4 100644 --- a/src/extra-schemas.test.ts +++ b/src/extra-schemas.test.ts @@ -1,4 +1,4 @@ -import { parseEnv } from "./parse-env.js"; +import { parseEnv } from "./index.js"; import { deprecate } from "./extra-schemas.js"; describe("extra schemas", () => { diff --git a/src/index.ts b/src/index.ts index 22372a5..1709a40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,31 @@ export { z } from "zod"; export * from "./parse-env.js"; export * from "./preprocessors.js"; export * from "./extra-schemas.js"; + export type { DeepReadonly, DeepReadonlyArray, DeepReadonlyObject, -} from "./util.js"; +} from "./util/type-helpers.js"; + +import { parseEnvImpl, type ParseEnv } from "./parse-env.js"; +import { cyan, green, red, yellow } from "./util/tty-colors.js"; + +// This entrypoint provides a colorized reporter by default; this requires tty +// detection, which in turn relies on Node's built-in `tty` module. + +/** + * Parses the passed environment object using the provided map of Zod schemas + * and returns the immutably-typed, parsed environment. + */ +export const parseEnv: ParseEnv = ( + env, + schemas, + reporterOrTokenFormatters = { + formatVarName: yellow, + formatObjKey: green, + formatReceivedValue: cyan, + formatDefaultValue: cyan, + formatHeader: red, + }, +) => parseEnvImpl(env, schemas, reporterOrTokenFormatters); diff --git a/src/parse-env.test.ts b/src/parse-env.test.ts index 0cb0da7..e9f0f95 100644 --- a/src/parse-env.test.ts +++ b/src/parse-env.test.ts @@ -1,6 +1,6 @@ import * as z from "zod"; -import { parseEnv } from "./parse-env.js"; +import { parseEnv } from "./index.js"; import { port } from "./extra-schemas.js"; // FIXME: many of these don't need to be part of parseCore tests, or at minimum diff --git a/src/parse-env.ts b/src/parse-env.ts index c189ce2..1c9b1ca 100644 --- a/src/parse-env.ts +++ b/src/parse-env.ts @@ -1,9 +1,15 @@ import * as z from "zod"; import { getSchemaWithPreprocessor } from "./preprocessors.js"; -import { ErrorWithContext, reportErrors, errorMap } from "./reporter.js"; +import { + makeDefaultReporter, + errorMap, + type TokenFormatters, + type ErrorWithContext, + type Reporter, +} from "./reporter.js"; -import type { DeepReadonlyObject } from "./util.js"; +import type { DeepReadonlyObject } from "./util/type-helpers.js"; export type SimpleSchema = z.ZodType< TOut, @@ -98,14 +104,31 @@ export const inferSchemas = >( schemas: T, ): T & RestrictSchemas => schemas; +export type ParseEnv = >( + env: Record, + schemas: T, + reporterOrTokenFormatters?: Reporter | TokenFormatters, +) => DeepReadonlyObject>; + /** * Parses the passed environment object using the provided map of Zod schemas - * and returns the immutably-typed, parsed environment.. + * and returns the immutably-typed, parsed environment. + * + * This version of `parseEnv` is intended for internal use and requires a + * reporter or token formatters to be passed in. The versions exported in + * `index.js` and `compat.js` provide defaults for this third parameter, making + * it optional. */ -export function parseEnv>( +export function parseEnvImpl>( env: Record, schemas: T, + reporterOrTokenFormatters: Reporter | TokenFormatters, ): DeepReadonlyObject> { + const reporter = + typeof reporterOrTokenFormatters === "function" + ? reporterOrTokenFormatters + : makeDefaultReporter(reporterOrTokenFormatters); + const parsed: Record = {} as DeepReadonlyObject< ParsedSchema >; @@ -173,7 +196,7 @@ export function parseEnv>( } if (errors.length > 0) { - throw new Error(reportErrors(errors, schemas)); + throw new Error(reporter(errors, schemas)); } return parsed as DeepReadonlyObject>; diff --git a/src/preprocessors.ts b/src/preprocessors.ts index 34935aa..9bc4755 100644 --- a/src/preprocessors.ts +++ b/src/preprocessors.ts @@ -1,6 +1,6 @@ import * as z from "zod"; -import { assertNever } from "./util.js"; +import { assertNever } from "./util/type-helpers.js"; const { ZodFirstPartyTypeKind: TypeName } = z; diff --git a/src/reporter.ts b/src/reporter.ts index 90c03d1..8445d65 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -1,12 +1,11 @@ import { ZodError, ZodErrorMap, ZodIssueCode } from "zod"; -import { yellow, red, cyan, green } from "colorette"; -import { Schemas } from "./parse-env.js"; +import type { Schemas } from "./parse-env.js"; // Even though we also have our own formatter, we pass a custom error map to // Zod's `.parse()` for two reasons: // - to ensure that no other consumer of zod in the codebase has set a default // error map that might override our formatting -// - to return slightly friendly error messages in some common scenarios. +// - to return slightly friendlier error messages in some common scenarios. export const errorMap: ZodErrorMap = (issue, ctx) => { if ( issue.code === ZodIssueCode.invalid_type && @@ -18,22 +17,60 @@ export const errorMap: ZodErrorMap = (issue, ctx) => { }; export interface ErrorWithContext { + /** The env var name. */ key: string; + /** The actual value present in `process.env[key]`, or undefined. */ receivedValue: unknown; + /** `ZodError` if Zod parsing failed, or `Error` if a preprocessor threw. */ error: unknown; + /** If a default was provided, whether the default value was used. */ defaultUsed: boolean; + /** If a default was provided, the given default value. */ defaultValue: unknown; } +export interface TokenFormatters { + /** Formatter for the env var name. */ + formatVarName?: (key: string) => string; + + /** For parsed objects with errors, formatter for object keys. */ + formatObjKey?: (key: string) => string; + + /** Formatter for the actual value we received for the env var. */ + formatReceivedValue?: (val: unknown) => string; + + /** Formatter for the default value provided for the schema. */ + formatDefaultValue?: (val: unknown) => string; + + /** Formatter for the error summary header. */ + formatHeader?: (header: string) => string; +} + const indent = (str: string, amt: number) => `${" ".repeat(amt)}${str}`; +export type Reporter = (errors: ErrorWithContext[], schemas: Schemas) => string; + +export function makeDefaultReporter(formatters: TokenFormatters) { + const reporter: Reporter = (errors, schemas) => + reportErrors(errors, schemas, formatters); + + return reporter; +} + export function reportErrors( errors: ErrorWithContext[], schemas: Schemas, + { + formatVarName = String, + formatObjKey = String, + formatReceivedValue = String, + formatDefaultValue = String, + formatHeader = String, + }: TokenFormatters = {}, ): string { const formattedErrors = errors.map( ({ key, receivedValue, error, defaultUsed, defaultValue }) => { - let title = `[${yellow(key)}]:`; + let title = `[${formatVarName(key)}]:`; const desc = schemas[key]?.description; if (desc) { @@ -49,7 +86,7 @@ export function reportErrors( if (fieldErrorEntries.length > 0) { message.push(indent("Errors on object keys:", 2)); for (const [objKey, keyErrors] of fieldErrorEntries) { - message.push(indent(`[${green(objKey)}]:`, 4)); + message.push(indent(`[${formatObjKey(objKey)}]:`, 4)); if (keyErrors) { for (const fe of keyErrors) message.push(indent(fe, 6)); } @@ -67,7 +104,7 @@ export function reportErrors( message.push( indent( - `(received ${cyan( + `(received ${formatReceivedValue( receivedValue === undefined ? "undefined" : JSON.stringify(receivedValue), @@ -79,7 +116,7 @@ export function reportErrors( if (defaultUsed) { message.push( indent( - `(used default of ${cyan( + `(used default of ${formatDefaultValue( defaultValue === undefined ? "undefined" : JSON.stringify(defaultValue), @@ -93,7 +130,7 @@ export function reportErrors( }, ); - return `${red( + return `${formatHeader( "Errors found while parsing environment:", )}\n${formattedErrors.join("\n\n")}\n`; } diff --git a/src/util/tty-colors.ts b/src/util/tty-colors.ts new file mode 100644 index 0000000..6ac2215 --- /dev/null +++ b/src/util/tty-colors.ts @@ -0,0 +1,84 @@ +/* + * This file adapted from module "colorette", originally found here: + * https://github.com/jorgebucaran/colorette/blob/0928e67466a34e50b53c7a908f32e738c3904846/index.js + * + * Copyright © Jorge Bucaran <> + * + * 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. + */ + +import { isatty } from "tty"; + +const { + env = {}, + argv = [], + platform = "", +} = typeof process === "undefined" ? {} : process; + +const isDisabled = "NO_COLOR" in env || argv.includes("--no-color"); +const isForced = "FORCE_COLOR" in env || argv.includes("--color"); +const isWindows = platform === "win32"; +const isDumbTerminal = env["TERM"] === "dumb"; + +const isCompatibleTerminal = Boolean( + isatty(1) && env["TERM"] && !isDumbTerminal, +); + +const isCI = + "CI" in env && + ("GITHUB_ACTIONS" in env || "GITLAB_CI" in env || "CIRCLECI" in env); + +export const isColorSupported = + !isDisabled && + (isForced || (isWindows && !isDumbTerminal) || isCompatibleTerminal || isCI); + +const replaceClose = ( + index: number, + str: string, + close: string, + replace: string, + head = str.slice(0, index) + replace, + tail = str.slice(index + close.length), + next = tail.indexOf(close), +): string => + head + (next < 0 ? tail : replaceClose(next, tail, close, replace)); + +const clearBleed = (index: number, str: string, open: string, close: string) => + index < 0 + ? open + str + close + : open + replaceClose(index, str, close, open) + close; + +const filterEmpty = (open: string, close: string) => (str: unknown) => + str || !(str === "" || str === undefined) + ? clearBleed( + String(str).indexOf(close, open.length + 1), + String(str), + open, + close, + ) + : ""; + +const create = (open: number, close: number) => + isColorSupported ? filterEmpty(`\x1b[${open}m`, `\x1b[${close}m`) : String; + +export const red = create(31, 39); +export const green = create(32, 39); +export const yellow = create(33, 39); +export const blue = create(34, 39); +export const magenta = create(35, 39); +export const cyan = create(36, 39); diff --git a/src/util.ts b/src/util/type-helpers.ts similarity index 100% rename from src/util.ts rename to src/util/type-helpers.ts