diff --git a/.gitignore b/.gitignore index 857df7646..2aac385af 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +*.log # environment variables diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f52251449..3e9893e7e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -24,6 +24,13 @@ "problemMatcher": [], "label": "changeset", "detail": "changeset" + }, + { + "type": "npm", + "script": "test", + "problemMatcher": [], + "label": "test", + "detail": "vitest" } ] } diff --git a/apps/www/content/docs/arkenv/coercion.mdx b/apps/www/content/docs/arkenv/coercion.mdx index ff5f500f4..1859c5e7b 100644 --- a/apps/www/content/docs/arkenv/coercion.mdx +++ b/apps/www/content/docs/arkenv/coercion.mdx @@ -62,7 +62,9 @@ import arkenv from "arkenv"; const env = arkenv( { TAGS: "string[]" }, { - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, env: { TAGS: '["web", "app"]' } } ); diff --git a/arkenv.code-workspace b/arkenv.code-workspace index d0fd1dff2..129c6d7b2 100644 --- a/arkenv.code-workspace +++ b/arkenv.code-workspace @@ -45,10 +45,6 @@ "path": "packages/internal/scope", "name": " @repo/scope" }, - { - "path": "packages/internal/keywords", - "name": " @repo/keywords" - }, { "path": "tooling", "name": "tooling" @@ -129,6 +125,10 @@ "path": "examples/with-standard-schema", "name": " with-standard-schema" }, + { + "path": "examples/without-arktype", + "name": " without-arktype" + }, { "path": "examples/stackblitz", "name": " stackblitz" diff --git a/examples/README.md b/examples/README.md index 67759f68f..91a6e7274 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,10 +8,11 @@ This directory contains a collection of example projects that demonstrate variou | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | [basic](https://github.com/yamcodes/arkenv/tree/main/examples/basic) | Minimal example of _using ArkEnv in a [Node.js](https://nodejs.org/) app_ for learning the fundamentals. | | [with-standard-schema](https://github.com/yamcodes/arkenv/tree/main/examples/with-standard-schema) | Example of _mixing ArkType with [Standard Schema](https://standardschema.dev/) validators like [Zod](https://zod.dev/)_. | +| [without-arktype](https://github.com/yamcodes/arkenv/tree/main/examples/without-arktype) | Example of _using ArkEnv without ArkType_, using [Zod](https://zod.dev/) for validation. | | [with-bun](https://github.com/yamcodes/arkenv/tree/main/examples/with-bun) | Minimal example of _using ArkEnv in a [Bun](https://bun.sh/) app_. | | [with-bun-react](https://github.com/yamcodes/arkenv/tree/main/examples/with-bun-react) | Minimal example of _using ArkEnv in a [Bun + React](https://bun.com/docs/guides/ecosystem/react) full-stack app_. | | [with-vite-react](https://github.com/yamcodes/arkenv/tree/main/examples/with-vite-react) | Minimal example of _using ArkEnv in a [Vite](https://vite.dev/) + [React](https://react.dev/) app_. | -| [with-solid-start](https://github.com/yamcodes/arkenv/tree/main/examples/with-solid-start) | Minimal example of _using ArkEnv in a [SolidStart](https://start.solidjs.com) app_. | +| [with-solid-start](https://github.com/yamcodes/arkenv/tree/main/examples/with-solid-start) | Minimal example of _using ArkEnv in a [SolidStart](https://start.solidjs.com) app_. | > These examples are written in TypeScript, [the recommended way to work with ArkEnv](https://github.com/yamcodes/arkenv/blob/main/packages/arkenv/README.md#typescript-requirements). That said, ArkEnv works with plain JavaScript. See the [basic-js](https://github.com/yamcodes/arkenv/tree/main/examples/basic-js) example for details and tradeoffs. diff --git a/examples/without-arktype/package-lock.json b/examples/without-arktype/package-lock.json new file mode 100644 index 000000000..05b44fd0a --- /dev/null +++ b/examples/without-arktype/package-lock.json @@ -0,0 +1,506 @@ +{ + "name": "no-arktype-playground", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "no-arktype-playground", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "arkenv": "https://pkg.pr.new/arkenv@721", + "zod": "^4.3.5" + }, + "devDependencies": { + "tsx": "^4.21.0" + }, + "engines": { + "node": "24" + } + }, + "../../node_modules/.pnpm/@size-limit+esbuild-why@12.0.0_size-limit@12.0.0_jiti@2.6.1_/node_modules/@size-limit/esbuild-why": { + "version": "12.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "esbuild-visualizer": "^0.7.0", + "open": "^11.0.0" + }, + "devDependencies": { + "@size-limit/esbuild": "workspace:*", + "@size-limit/file": "workspace:*", + "esbuild": "^0.27.0", + "redux": "^4.2.1", + "size-limit": "workspace:*" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "size-limit": "12.0.0" + } + }, + "../../node_modules/.pnpm/@size-limit+preset-small-lib@12.0.0_size-limit@12.0.0_jiti@2.6.1_/node_modules/@size-limit/preset-small-lib": { + "version": "12.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@size-limit/esbuild": "12.0.0", + "@size-limit/file": "12.0.0", + "size-limit": "12.0.0" + }, + "peerDependencies": { + "size-limit": "12.0.0" + } + }, + "../../node_modules/.pnpm/@types+node@24.10.4/node_modules/@types/node": { + "version": "24.10.4", + "extraneous": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "../../node_modules/.pnpm/arktype@2.1.29/node_modules/arktype": { + "version": "2.1.29", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@ark/schema": "0.56.0", + "@ark/util": "0.56.0", + "arkregex": "0.0.5" + } + }, + "../../node_modules/.pnpm/rimraf@6.1.2/node_modules/rimraf": { + "version": "6.1.2", + "extraneous": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "devDependencies": { + "@types/node": "^24.9.2", + "mkdirp": "^3.0.1", + "prettier": "^3.6.2", + "tap": "^21.1.1", + "tshy": "^3.0.3", + "typedoc": "^0.28.14" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "../../node_modules/.pnpm/size-limit@12.0.0_jiti@2.6.1/node_modules/size-limit": { + "version": "12.0.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "bytes-iec": "^3.1.1", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "bin": { + "size-limit": "bin.js" + }, + "devDependencies": { + "@size-limit/esbuild": "workspace:*", + "@size-limit/file": "workspace:*", + "@size-limit/webpack": "workspace:*" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "jiti": "^2.0.0" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "../../node_modules/.pnpm/tsdown@0.18.4_typescript@5.9.3/node_modules/tsdown": { + "version": "0.18.4", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.2.0", + "cac": "^6.7.14", + "defu": "^6.1.4", + "empathic": "^2.0.0", + "hookable": "^6.0.1", + "import-without-cache": "^0.2.5", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "rolldown": "1.0.0-beta.57", + "rolldown-plugin-dts": "^0.20.0", + "semver": "^7.7.3", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.4.2", + "unrun": "^0.2.21" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "devDependencies": { + "@arethetypeswrong/core": "^0.18.2", + "@sxzz/eslint-config": "^7.4.4", + "@sxzz/prettier-config": "^2.2.6", + "@sxzz/test-utils": "^0.5.15", + "@types/node": "^25.0.3", + "@types/picomatch": "^4.0.2", + "@types/semver": "^7.7.1", + "@typescript/native-preview": "7.0.0-dev.20251230.1", + "@unocss/eslint-plugin": "^66.5.12", + "@vitejs/devtools": "^0.0.0-alpha.22", + "@vitest/coverage-v8": "4.0.16", + "@vitest/ui": "^4.0.16", + "@vueuse/core": "^14.1.0", + "bumpp": "^10.3.2", + "dedent": "^1.7.1", + "eslint": "^9.39.2", + "is-in-ci": "^2.0.0", + "lightningcss": "^1.30.2", + "memfs": "^4.51.1", + "pkg-types": "^2.3.0", + "prettier": "^3.7.4", + "publint": "^0.3.16", + "rolldown-plugin-dts-snapshot": "^0.3.2", + "rolldown-plugin-require-cjs": "^0.3.3", + "typescript": "~5.9.3", + "unocss": "^66.5.12", + "unplugin-lightningcss": "^0.4.4", + "unplugin-unused": "^0.5.6", + "vite": "^8.0.0-beta.5", + "vitest": "^4.0.16" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@vitejs/devtools": "*", + "publint": "^0.3.0", + "typescript": "^5.0.0", + "unplugin-lightningcss": "^0.4.0", + "unplugin-unused": "^0.5.0" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-lightningcss": { + "optional": true + }, + "unplugin-unused": { + "optional": true + } + } + }, + "../../node_modules/.pnpm/tsx@4.21.0/node_modules/tsx": { + "version": "4.21.0", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": { + "version": "5.9.3", + "extraneous": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "devDependencies": { + "@dprint/formatter": "^0.4.1", + "@dprint/typescript": "0.93.4", + "@esfx/canceltoken": "^1.0.0", + "@eslint/js": "^9.20.0", + "@octokit/rest": "^21.1.1", + "@types/chai": "^4.3.20", + "@types/diff": "^7.0.1", + "@types/minimist": "^1.2.5", + "@types/mocha": "^10.0.10", + "@types/ms": "^0.7.34", + "@types/node": "latest", + "@types/source-map-support": "^0.5.10", + "@types/which": "^3.0.4", + "@typescript-eslint/rule-tester": "^8.24.1", + "@typescript-eslint/type-utils": "^8.24.1", + "@typescript-eslint/utils": "^8.24.1", + "azure-devops-node-api": "^14.1.0", + "c8": "^10.1.3", + "chai": "^4.5.0", + "chokidar": "^4.0.3", + "diff": "^7.0.0", + "dprint": "^0.49.0", + "esbuild": "^0.25.0", + "eslint": "^9.20.1", + "eslint-formatter-autolinkable-stylish": "^1.4.0", + "eslint-plugin-regexp": "^2.7.0", + "fast-xml-parser": "^4.5.2", + "glob": "^10.4.5", + "globals": "^15.15.0", + "hereby": "^1.10.0", + "jsonc-parser": "^3.3.1", + "knip": "^5.44.4", + "minimist": "^1.2.8", + "mocha": "^10.8.2", + "mocha-fivemat-progress-reporter": "^0.1.0", + "monocart-coverage-reports": "^2.12.1", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "playwright": "^1.50.1", + "source-map-support": "^0.5.21", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "typescript-eslint": "^8.24.1", + "which": "^3.0.1" + }, + "engines": { + "node": ">=14.17" + } + }, + "../../node_modules/.pnpm/vitest@4.0.16_@opentelemetry+api@1.9.0_@types+node@24.10.4_@vitest+ui@4.0.16_jiti@2.6.1_c184208671c790f329b8a6bae22656f2/node_modules/vitest": { + "version": "4.0.16", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "devDependencies": { + "@antfu/install-pkg": "^1.1.0", + "@edge-runtime/vm": "^5.0.0", + "@jridgewell/trace-mapping": "0.3.31", + "@opentelemetry/api": "^1.9.0", + "@sinonjs/fake-timers": "14.0.0", + "@types/estree": "^1.0.8", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/jsdom": "^27.0.0", + "@types/node": "^24.10.1", + "@types/picomatch": "^4.0.2", + "@types/prompts": "^2.4.9", + "@types/sinonjs__fake-timers": "^8.1.5", + "acorn-walk": "^8.3.4", + "birpc": "^4.0.0", + "cac": "^6.7.14", + "empathic": "^2.0.0", + "flatted": "^3.3.3", + "happy-dom": "^20.0.11", + "jsdom": "^27.2.0", + "local-pkg": "^1.1.2", + "mime": "^4.1.0", + "prompts": "^2.4.2", + "strip-literal": "^3.1.0", + "ws": "^8.18.3" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "../../node_modules/.pnpm/zod@4.3.5/node_modules/zod": { + "version": "4.3.5", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "../../packages/arkenv": { + "version": "0.8.3", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@repo/scope": "workspace:*", + "@repo/types": "workspace:*", + "@size-limit/esbuild-why": "catalog:", + "@size-limit/preset-small-lib": "catalog:", + "@types/node": "catalog:", + "arktype": "catalog:", + "rimraf": "catalog:", + "size-limit": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" + }, + "peerDependencies": { + "arktype": "^2.1.22" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + } + } + }, + "../../packages/internal/keywords": { + "name": "@repo/keywords", + "version": "0.2.1", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@repo/scope": "workspace:*" + }, + "devDependencies": { + "arktype": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "arktype": "catalog:" + } + }, + "../../packages/internal/scope": { + "name": "@repo/scope", + "version": "0.1.2", + "extraneous": true, + "license": "MIT", + "devDependencies": { + "@types/node": "catalog:", + "arktype": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "arktype": "catalog:" + } + }, + "../../packages/internal/types": { + "name": "@repo/types", + "version": "0.0.6", + "extraneous": true, + "devDependencies": { + "@repo/scope": "workspace:*", + "arktype": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "@repo/scope": "workspace:*", + "arktype": "catalog:" + } + }, + "node_modules/arkenv": { + "version": "0.8.3", + "resolved": "https://pkg.pr.new/arkenv@721", + "integrity": "sha512-XDH6btL7cCeAl7oZ0vXMqy+z+j+0KQj3GN3QPPRjKA1CPHl9tUYAXFTLqtw0BqL10PBQhld0Q6gTTtIlcJfguQ==", + "license": "MIT", + "peerDependencies": { + "arktype": "^2.1.28" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + } + } + }, + "node_modules/tsx": { + "resolved": "../../node_modules/.pnpm/tsx@4.21.0/node_modules/tsx", + "link": true + }, + "node_modules/zod": { + "resolved": "../../node_modules/.pnpm/zod@4.3.5/node_modules/zod", + "link": true + } + } +} diff --git a/examples/without-arktype/package.json b/examples/without-arktype/package.json new file mode 100644 index 000000000..9a728d372 --- /dev/null +++ b/examples/without-arktype/package.json @@ -0,0 +1,27 @@ +{ + "name": "no-arktype-playground", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "dev": "tsx watch --env-file .env src/index.ts", + "start": "tsx --env-file .env src", + "build": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "tsx": "^4.21.0" + }, + "dependencies": { + "arkenv": "https://pkg.pr.new/arkenv@721", + "zod": "^4.3.5" + }, + "engines": { + "node": "24" + }, + "packageManager": "npm@11.6.4" +} diff --git a/examples/without-arktype/src/index.ts b/examples/without-arktype/src/index.ts new file mode 100644 index 000000000..4aff19ae5 --- /dev/null +++ b/examples/without-arktype/src/index.ts @@ -0,0 +1,13 @@ +import { createEnv } from "arkenv"; +import z from "zod"; + +const env = createEnv({ + TEST_VALUE: z.url(), + PORT: z.coerce.number(), + HOST: z.literal("localhost").or(z.url()), +}); + +console.log(`Value: ${String(env.TEST_VALUE)}`); +console.log(`Type: ${typeof env.TEST_VALUE}`); +console.log("---"); +console.log(env); diff --git a/examples/without-arktype/tsconfig.json b/examples/without-arktype/tsconfig.json new file mode 100644 index 000000000..ba3f54680 --- /dev/null +++ b/examples/without-arktype/tsconfig.json @@ -0,0 +1,44 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": [], + // For nodejs: + // "lib": ["esnext"], + // "types": ["node"], + // and npm install -D @types/node + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true + } +} diff --git a/packages/arkenv/package.json b/packages/arkenv/package.json index 307836b06..1242a3dff 100644 --- a/packages/arkenv/package.json +++ b/packages/arkenv/package.json @@ -40,7 +40,6 @@ "bugs": "https://github.com/yamcodes/arkenv/labels/arkenv", "author": "Yam Borodetsky ", "devDependencies": { - "@repo/keywords": "workspace:*", "@repo/scope": "workspace:*", "@repo/types": "workspace:*", "@size-limit/esbuild-why": "catalog:", @@ -51,10 +50,17 @@ "size-limit": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:", + "zod": "catalog:" }, "peerDependencies": { - "arktype": "^2.1.22" + "arktype": "^2.1.28" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + } }, "size-limit": [ { @@ -63,6 +69,7 @@ "import": "*", "ignore": [ "arktype", + "@repo/scope", "node:module" ] } diff --git a/packages/arkenv/src/arktype-optional.integration.test.ts b/packages/arkenv/src/arktype-optional.integration.test.ts new file mode 100644 index 000000000..b728f8106 --- /dev/null +++ b/packages/arkenv/src/arktype-optional.integration.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("ArkType Optional Contract", () => { + afterEach(async () => { + vi.clearAllMocks(); + + const G = globalThis as any; + + // Reset loader via global Symbol + const loader = G[Symbol.for("__ARKENV_ARKTYPE_LOADER__")]; + if (loader) { + loader.reset(); + } + + // Reset scope cache via global Symbol + const scopeCache = G[Symbol.for("__ARKENV_SCOPE_CACHE__")]; + if (scopeCache) { + scopeCache.scope = undefined; + } + + delete process.env.ARKENV_FORCE_MISSING; + }); + + describe("Scenario 1: ArkType Strings + ArkType Present", () => { + it("should validate correctly using ArkType string DSL", async () => { + const { createEnv } = await import("./index"); + const env = createEnv( + { + PORT: "number.port", + }, + { + env: { PORT: "3000" }, + }, + ); + expect(env.PORT).toBe(3000); + }); + }); + + describe("Scenario 2: Compiled ArkType Schema + ArkType Present", () => { + it("should validate correctly using type()", async () => { + const { createEnv, type } = await import("./index"); + const env = createEnv( + { + PORT: type("number.port"), + }, + { + env: { PORT: "8080" }, + }, + ); + expect(env.PORT).toBe(8080); + }); + }); + + describe("Scenario 3: Standard Schema (Zod) + ArkType Missing", () => { + it("should support Zod mapping even if ArkType is unavailable", async () => { + process.env.ARKENV_FORCE_MISSING = "true"; + + const G = globalThis as any; + const scopeCache = G[Symbol.for("__ARKENV_SCOPE_CACHE__")]; + if (scopeCache) scopeCache.scope = undefined; + + const { createEnv } = await import("./index"); + const { z } = await import("zod"); + + const env = createEnv( + { + PORT: z.coerce.number(), + }, + { + env: { PORT: "5432" }, + }, + ); + + expect(env.PORT).toBe(5432); + }); + + it("should not contain ArkType-specific terms in Zod validation errors", async () => { + process.env.ARKENV_FORCE_MISSING = "true"; + + const G = globalThis as any; + const scopeCache = G[Symbol.for("__ARKENV_SCOPE_CACHE__")]; + if (scopeCache) scopeCache.scope = undefined; + + const { createEnv } = await import("./index"); + const { z } = await import("zod"); + + try { + createEnv( + { + PORT: z.number(), + }, + { + env: { PORT: "not-a-number" }, + }, + ); + expect.fail("Should have thrown"); + } catch (e: any) { + expect(e.message).not.toContain("ArkType"); + expect(e.message).toMatch(/expected number/i); + } + }); + }); + + describe("Scenario 4: ArkType Strings + ArkType Missing", () => { + it("should provide a friendly, actionable error message", async () => { + process.env.ARKENV_FORCE_MISSING = "true"; + + const G = globalThis as any; + const scopeCache = G[Symbol.for("__ARKENV_SCOPE_CACHE__")]; + if (scopeCache) scopeCache.scope = undefined; + + const { createEnv } = await import("./index"); + expect(() => + createEnv( + { + // Use unique definition to avoid any potential internal ArkType caching + PORT: "number.port > 0", + }, + { + env: { PORT: "3000" }, + }, + ), + ).toThrow(/ArkType is required/); + }); + }); +}); diff --git a/packages/arkenv/src/coercion.integration.test.ts b/packages/arkenv/src/coercion.integration.test.ts index a2f0690fd..c7f0d7b8f 100644 --- a/packages/arkenv/src/coercion.integration.test.ts +++ b/packages/arkenv/src/coercion.integration.test.ts @@ -162,7 +162,9 @@ describe("coercion integration", () => { VERSION: "1.0.0", EXTRA: "unused", } as Record, - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, onUndeclaredKey: "delete", }, ); @@ -180,7 +182,9 @@ describe("coercion integration", () => { { TAGS: "string[]" }, { env: { TAGS: '["invalid-json' }, - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, }, ); }).toThrow("must be an array"); diff --git a/packages/arkenv/src/create-env.test.ts b/packages/arkenv/src/create-env.test.ts index d6127dfb8..79f5ee4a6 100644 --- a/packages/arkenv/src/create-env.test.ts +++ b/packages/arkenv/src/create-env.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; import { createEnv } from "./create-env"; import { type } from "./type"; import { indent, styleText } from "./utils"; @@ -272,6 +273,12 @@ describe("createEnv", () => { ); }); + it("should throw a clear error when an empty schema object is provided", () => { + expect(() => createEnv({})).toThrow( + "ArkEnv expects a mapping of environment variables to validators. Please provide at least one key (e.g., createEnv({ PORT: 'number' })) or pass a compiled ArkType schema directly.", + ); + }); + it("should validate against a custom environment", () => { const env = { TEST_STRING: "hello", @@ -507,7 +514,9 @@ describe("createEnv", () => { { TAGS: "string[]" }, { env: { TAGS: '["foo", "bar"]' }, - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, }, ); expect(env.TAGS).toEqual(["foo", "bar"]); @@ -519,7 +528,9 @@ describe("createEnv", () => { { TAGS: "string[]" }, { env: { TAGS: "foo,bar" }, // Comma separated, not JSON - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, }, ); }).toThrow("must be an array"); @@ -541,7 +552,9 @@ describe("createEnv", () => { { NUMBERS: "number[]" }, { env: { NUMBERS: "[1, 2, 3]" }, - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, }, ); expect(env.NUMBERS).toEqual([1, 2, 3]); @@ -565,7 +578,9 @@ describe("createEnv", () => { { TAGS: "string[]" }, { env: { TAGS: "[]" }, - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, }, ); expect(env.TAGS).toEqual([]); @@ -619,7 +634,9 @@ describe("createEnv", () => { SERVICES: '[{"NAME": "web", "PORT": "80"}, {"NAME": "api", "PORT": "3000"}]', }, - arrayFormat: "json", + coerce: { + arrayFormat: "json", + }, }, ); expect(env.SERVICES).toEqual([ @@ -628,4 +645,36 @@ describe("createEnv", () => { ]); }); }); + + describe("migration & hybrid support", () => { + it("should work with top-level compiled ArkType schema", () => { + const schema = type({ PORT: "number" }); + const env = createEnv(schema, { + env: { PORT: "3000" }, + }); + expect(env.PORT).toBe(3000); + }); + + it("should throw if top-level Standard Schema is passed directly", () => { + expect(() => + createEnv(z.object({ PORT: z.coerce.number() }) as any, { + env: { PORT: "8080" }, + }), + ).toThrow(/expects a mapping.*not a top-level Standard Schema/); + }); + + it("should support mixed ArkType DSL and Standard Schema validators", () => { + const env = createEnv( + { + PORT: "number.port", + HOST: z.string().min(1), + }, + { + env: { PORT: "3000", HOST: "localhost" }, + }, + ); + + expect(env).toEqual({ PORT: 3000, HOST: "localhost" }); + }); + }); }); diff --git a/packages/arkenv/src/create-env.ts b/packages/arkenv/src/create-env.ts index 8f2259604..8b3752fba 100644 --- a/packages/arkenv/src/create-env.ts +++ b/packages/arkenv/src/create-env.ts @@ -1,25 +1,25 @@ import { $ } from "@repo/scope"; -import type { EnvSchemaWithType, InferType, SchemaShape } from "@repo/types"; -import type { type as at, distill } from "arktype"; -import { ArkEnvError } from "./errors"; -import { type } from "./type"; -import { type CoerceOptions, coerce } from "./utils"; - -export type EnvSchema = at.validate; -type RuntimeEnvironment = Record; +import type { Dict, InferType, StandardSchemaV1 } from "@repo/types"; +import type { Type } from "arktype"; +import { ArkEnvError, type EnvIssue } from "./errors"; +import { type CoerceConfig, coerce } from "./utils/coerce"; /** - * Configuration options for `createEnv` + * The configuration for the ArkEnv library. */ export type ArkEnvConfig = { /** - * The environment variables to validate. Defaults to `process.env` + * The environment variables to validate. + * + * @default `process.env` */ - env?: RuntimeEnvironment; + env?: Dict; /** - * Whether to coerce environment variables to their defined types. Defaults to `true` + * Whether to coerce environment variables to their expected types. + * + * @default `true` */ - coerce?: boolean; + coerce?: boolean | Partial; /** * Control how ArkEnv handles environment variables that are not defined in your schema. * @@ -34,72 +34,251 @@ export type ArkEnvConfig = { * @default "delete" * @see https://arktype.io/docs/configuration#onundeclaredkey */ - onUndeclaredKey?: "ignore" | "delete" | "reject"; - - /** - * The format to use for array parsing when coercion is enabled. - * - * - `comma` (default): Strings are split by comma and trimmed. - * - `json`: Strings are parsed as JSON. - * - * @default "comma" - */ - arrayFormat?: CoerceOptions["arrayFormat"]; + onUndeclaredKey?: "ignore" | "reject" | "delete"; }; /** - * TODO: `SchemaShape` is basically `Record`. - * If possible, find a better type than "const T extends Record", - * and be as close as possible to the type accepted by ArkType's `type`. + * A flexible schema definition that can be a raw object, compiled ArkType, + * or a Standard Schema validator. */ +type SchemaShape = any; + +function detectValidatorType(def: unknown): { + isArkCompiled: boolean; + isStandard: boolean; +} { + const isArkCompiled = isArktype(def); + const isStandard = + !isArkCompiled && (def as StandardSchemaV1)?.["~standard"] !== undefined; + + return { isArkCompiled, isStandard }; +} + +/** + * Detects if a definition is an ArkType validator. + * + * MAINTAINER NOTE: This is best-effort detection for ArkType 2.0. + * Future ArkType internals could shift; fallback behavior should ensure + * we error cleanly if detection fails but a validator is actually required. + * Do not promise this logic as public contract. + */ +function isArktype(def: unknown): boolean { + if (typeof def !== "function" && (typeof def !== "object" || def === null)) { + return false; + } + + const defAny = def as any; + + // ArkType 2.0 markers or our own lazy proxy + if ( + defAny.isArktype === true || + defAny.arkKind === "type" || + defAny.arkKind === "generic" || + defAny.arkKind === "module" + ) { + return true; + } + + // ArkType 2.0 often has these internal ones + return ( + typeof defAny.traverseApply === "function" && + typeof defAny.traverseAllows === "function" && + defAny.json !== undefined + ); +} + +function isArkErrors(result: unknown): boolean { + if (!result || typeof result !== "object") return false; + + const arkKind = (result as any).arkKind; + return ( + arkKind === "errors" || + ("byPath" in (result as any) && "count" in (result as any)) + ); +} + +function validateArkType( + def: unknown, + config: ArkEnvConfig, + env: Dict, +): { success: true; value: unknown } | { success: false; issues: EnvIssue[] } { + const { isArkCompiled: isCompiledType } = detectValidatorType(def); + + let schema = isCompiledType + ? (def as Type) + : ($.type(def as any) as unknown as Type); + + // Apply undeclared key policy if specified or needed + if (config.onUndeclaredKey) { + schema = (schema as any).onUndeclaredKey(config.onUndeclaredKey); + } else if (!isCompiledType) { + // Default behavior for mappings: strip extra keys unless explicitly ignored + schema = (schema as any).onUndeclaredKey("delete"); + } + + // TODO: This can be simplified + const coercionConfig: CoerceConfig = { + numbers: + config.coerce === false + ? false + : typeof config.coerce === "object" + ? (config.coerce.numbers ?? true) + : true, + booleans: + config.coerce === false + ? false + : typeof config.coerce === "object" + ? (config.coerce.booleans ?? true) + : true, + objects: + config.coerce === false + ? false + : typeof config.coerce === "object" + ? (config.coerce.objects ?? true) + : true, + arrayFormat: + typeof config.coerce === "object" + ? (config.coerce.arrayFormat ?? "comma") + : "comma", + }; + + const coercedEnv = coerce(schema, env, coercionConfig); + + const result = schema(coercedEnv); + + if (isArkErrors(result)) { + const issues: EnvIssue[] = []; + const errors = result as any; + if (errors.byPath) { + for (const [path, error] of Object.entries(errors.byPath)) { + issues.push({ + // Preserve the path as-is since it's an environment variable name + // which may legitimately contain dots (e.g., "DATABASE.HOST") + path: [path], + message: (error as any).message, + }); + } + } else { + // Fallback + issues.push({ path: ["root"], message: String(result) }); + } + + return { success: false, issues }; + } + + return { success: true, value: result as any }; +} + +function validateStandardSchemaMapping( + mapping: Record, + env: Dict, +): { success: true; value: unknown } | { success: false; issues: EnvIssue[] } { + const result: Record = {}; + const allIssues: EnvIssue[] = []; + + for (const [key, validator] of Object.entries(mapping)) { + const value = env[key]; + const validationResult = validator["~standard"].validate(value); + + if (validationResult instanceof Promise) { + throw new Error("Async validation is not supported in createEnv."); + } + + if (validationResult.issues) { + for (const stdIssue of validationResult.issues) { + allIssues.push({ + path: [key, ...(stdIssue.path || [])].map(String), + message: stdIssue.message, + }); + } + } else { + result[key] = validationResult.value; + } + } + + if (allIssues.length > 0) { + return { success: false, issues: allIssues }; + } + + return { success: true, value: result }; +} + +function detectMappingType(mapping: SchemaShape): { + type: "arktype" | "standard-schema"; +} { + let hasStandard = false; + let hasArktype = false; + let hasKeys = false; + + for (const value of Object.values(mapping)) { + hasKeys = true; + if (typeof value === "string" || isArktype(value)) { + hasArktype = true; + } else if ((value as any)?.["~standard"]) { + hasStandard = true; + } + } + + if (!hasKeys) { + throw new Error( + "ArkEnv expects a mapping of environment variables to validators. Please provide at least one key (e.g., createEnv({ PORT: 'number' })) or pass a compiled ArkType schema directly.", + ); + } + + // Prioritize ArkType detection: if anything identifies as ArkType, + // we use the ArkType path which handles both ArkType and Standard Schema + // (via $.type wrapping). + if (hasArktype) return { type: "arktype" }; + if (hasStandard) return { type: "standard-schema" }; + + return { type: "arktype" }; +} /** - * Create an environment variables object from a schema and an environment - * @param def - The environment variable schema (raw object or type definition created with `type()`) - * @param config - Configuration options, see {@link ArkEnvConfig} - * @returns The validated environment variable schema - * @throws An {@link ArkEnvError | error} if the environment variables are invalid. + * Validate environment variables against a schema and return the parsed result. + * + * {@link https://arkenv.js.org | ArkEnv} is a typesafe environment variables validator from editor to runtime. + * + * @param def - The environment variable schema definition. Can be a mapping of keys to validators, or a compiled ArkType schema. + * @param config - Optional {@link ArkEnvConfig | configuration} for validation and coercion. + * @returns The validated and parsed environment variables. + * @throws An {@link ArkEnvError | error} If validation fails. */ export function createEnv( - def: EnvSchema, - config?: ArkEnvConfig, -): distill.Out>; -export function createEnv( def: T, - config?: ArkEnvConfig, -): InferType; -export function createEnv( - def: EnvSchema | EnvSchemaWithType, - config?: ArkEnvConfig, -): distill.Out> | InferType; -export function createEnv( - def: EnvSchema | EnvSchemaWithType, - { - env = process.env, - coerce: shouldCoerce = true, - onUndeclaredKey = "delete", - arrayFormat = "comma", - }: ArkEnvConfig = {}, -): distill.Out> | InferType { - // If def is a type definition (has assert method), use it directly - // Otherwise, use raw() to convert the schema definition - const isCompiledType = typeof def === "function" && "assert" in def; - let schema = isCompiledType ? def : $.type.raw(def as EnvSchema); - - // Apply the `onUndeclaredKey` option - schema = schema.onUndeclaredKey(onUndeclaredKey); - - // Apply coercion transformation to allow strings to be parsed as numbers/booleans - if (shouldCoerce) { - schema = coerce(schema, { arrayFormat }); + config: ArkEnvConfig = {}, +): InferType { + const env = config.env ?? process.env; + + const { isArkCompiled, isStandard } = detectValidatorType(def); + + if (isArkCompiled) { + const result = validateArkType(def, config, env); + if (!result.success) { + throw new ArkEnvError(result.issues); + } + return result.value as InferType; } - // Validate the environment variables - const validatedEnv = schema(env); + if (isStandard) { + throw new Error( + "ArkEnv expects a mapping of environment variables to validators, not a top-level Standard Schema (like a Zod object). Please pass an object instead, e.g. createEnv({ PORT: z.string() }).", + ); + } - if (validatedEnv instanceof type.errors) { - throw new ArkEnvError(validatedEnv); + const mappingType = detectMappingType(def as SchemaShape); + + const result = + mappingType.type === "standard-schema" + ? validateStandardSchemaMapping(def as SchemaShape, env) + : validateArkType(def, config, env); + + if (!result.success) { + // Use result.issues which is mapped from ArkErrors if needed + throw new ArkEnvError(result.issues); } - return validatedEnv; + return result.value as InferType; } + +export default createEnv; diff --git a/packages/arkenv/src/errors.ts b/packages/arkenv/src/errors.ts index e8101b2e0..1ca90614f 100644 --- a/packages/arkenv/src/errors.ts +++ b/packages/arkenv/src/errors.ts @@ -2,12 +2,36 @@ import type { ArkErrors } from "arktype"; import { indent, styleText } from "./utils"; /** - * Format the errors returned by ArkType to be more readable - * @param errors - The errors returned by ArkType + * Common issue shape for environment validation + */ +export type EnvIssue = { + path: string[]; + message: string; +}; + +/** + * Format the errors returned by ArkType or Standard Schema to be more readable + * @param errors - The errors returned by the validator * @returns A string of the formatted errors */ -export const formatErrors = (errors: ArkErrors): string => - Object.entries(errors.byPath) +export const formatErrors = (errors: ArkErrors | EnvIssue[]): string => { + if (Array.isArray(errors)) { + return errors + .map((err) => { + const path = err.path.length > 0 ? err.path.join(".") : "root"; + let message = err.message; + + // Consistent with ArkType: if message starts with path, trim it + if (message.startsWith(`${path} `)) { + message = message.slice(path.length + 1); + } + + return `${styleText("yellow", path)} ${message}`; + }) + .join("\n"); + } + + return Object.entries(errors.byPath) .map(([path, error]) => { const messageWithoutPath = error.message.startsWith(path) ? error.message.slice(path.length) @@ -25,10 +49,11 @@ export const formatErrors = (errors: ArkErrors): string => return `${styleText("yellow", path)} ${formattedMessage.trimStart()}`; }) .join("\n"); +}; export class ArkEnvError extends Error { constructor( - errors: ArkErrors, + errors: ArkErrors | EnvIssue[], message = "Errors found while validating environment variables", ) { super(`${styleText("red", message)}\n${indent(formatErrors(errors))}\n`); diff --git a/packages/arkenv/src/index.ts b/packages/arkenv/src/index.ts index 72677ad9b..d80d97165 100644 --- a/packages/arkenv/src/index.ts +++ b/packages/arkenv/src/index.ts @@ -1,7 +1,5 @@ import { createEnv } from "./create-env"; -export type { EnvSchema } from "./create-env"; - /** * `arkenv`'s main export, an alias for {@link createEnv} * diff --git a/packages/arkenv/src/lazy-type.test.ts b/packages/arkenv/src/lazy-type.test.ts new file mode 100644 index 000000000..18313cf3b --- /dev/null +++ b/packages/arkenv/src/lazy-type.test.ts @@ -0,0 +1,44 @@ +import { lazyType, resetScope } from "@repo/scope"; +import { afterEach, describe, expect, it } from "vitest"; + +describe("LazyType Proxy", () => { + afterEach(() => { + resetScope(); + }); + + it("should throw an error if .pipe() is called after realization via property access", () => { + const schema = lazyType("string"); + + // Accessing 'json' realizes the type + const _ = (schema as any).json; + + // Now '.pipe()' should throw + expect(() => { + (schema as any).pipe((v: any) => v + "!"); + }).toThrow("Cannot pipe after the lazy type has been realized"); + }); + + it("should throw an error if .pipe() is called after realization via apply", () => { + const schema = lazyType("string"); + + // Calling it realizes the type + schema("test"); + + // Now '.pipe()' should throw + expect(() => { + (schema as any).pipe((v: any) => v + "!"); + }).toThrow("Cannot pipe after the lazy type has been realized"); + }); + + it("should allow chaining .pipe() calls before realization", () => { + const schema = lazyType("string") + .pipe((v: any) => v + "!") + .pipe((v: any) => v + "?") as any; + + // Still not realized + expect(schema.isArktype).toBe(true); + + const result = schema("test"); + expect(result).toBe("test!?"); + }); +}); diff --git a/packages/arkenv/src/morphs.test.ts b/packages/arkenv/src/morphs.test.ts new file mode 100644 index 000000000..a34de9d5a --- /dev/null +++ b/packages/arkenv/src/morphs.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { coerceBoolean, coerceJson, coerceNumber } from "./morphs"; + +describe("Morph Functions", () => { + describe("coerceNumber", () => { + it("should return numbers as-is", () => { + expect(coerceNumber(42)).toBe(42); + expect(coerceNumber(0)).toBe(0); + expect(coerceNumber(-5.5)).toBe(-5.5); + }); + + it("should convert numeric strings to numbers", () => { + expect(coerceNumber("42")).toBe(42); + expect(coerceNumber("0")).toBe(0); + expect(coerceNumber("-5.5")).toBe(-5.5); + expect(coerceNumber(" 123 ")).toBe(123); + }); + + it("should handle NaN string", () => { + expect(coerceNumber("NaN")).toBeNaN(); + }); + + it("should return non-numeric strings as-is", () => { + expect(coerceNumber("hello")).toBe("hello"); + expect(coerceNumber("not a number")).toBe("not a number"); + }); + + it("should return empty strings as-is", () => { + expect(coerceNumber("")).toBe(""); + expect(coerceNumber(" ")).toBe(" "); + }); + + it("should return non-string, non-number values as-is", () => { + const obj = { foo: "bar" }; + expect(coerceNumber(obj)).toBe(obj); + expect(coerceNumber(null)).toBe(null); + expect(coerceNumber(undefined)).toBe(undefined); + }); + }); + + describe("coerceBoolean", () => { + it("should convert 'true' string to boolean true", () => { + expect(coerceBoolean("true")).toBe(true); + }); + + it("should convert 'false' string to boolean false", () => { + expect(coerceBoolean("false")).toBe(false); + }); + + it("should return other values as-is", () => { + expect(coerceBoolean("yes")).toBe("yes"); + expect(coerceBoolean("no")).toBe("no"); + expect(coerceBoolean("1")).toBe("1"); + expect(coerceBoolean("0")).toBe("0"); + expect(coerceBoolean(true)).toBe(true); + expect(coerceBoolean(false)).toBe(false); + expect(coerceBoolean(42)).toBe(42); + }); + }); + + describe("coerceJson", () => { + it("should parse valid JSON objects", () => { + expect(coerceJson('{"foo":"bar"}')).toEqual({ foo: "bar" }); + expect(coerceJson(' {"nested":{"value":123}} ')).toEqual({ + nested: { value: 123 }, + }); + }); + + it("should parse valid JSON arrays", () => { + expect(coerceJson("[1,2,3]")).toEqual([1, 2, 3]); + expect(coerceJson('["a","b","c"]')).toEqual(["a", "b", "c"]); + }); + + it("should return non-JSON strings as-is", () => { + expect(coerceJson("hello")).toBe("hello"); + expect(coerceJson("not json")).toBe("not json"); + }); + + it("should return strings that don't start with { or [ as-is", () => { + expect(coerceJson("plain text")).toBe("plain text"); + expect(coerceJson(" plain text ")).toBe(" plain text "); + }); + + it("should return invalid JSON as-is", () => { + expect(coerceJson("{invalid}")).toBe("{invalid}"); + expect(coerceJson("[broken")).toBe("[broken"); + }); + + it("should return non-string values as-is", () => { + expect(coerceJson(42)).toBe(42); + expect(coerceJson(true)).toBe(true); + const obj = { foo: "bar" }; + expect(coerceJson(obj)).toBe(obj); + }); + }); +}); diff --git a/packages/arkenv/src/morphs.ts b/packages/arkenv/src/morphs.ts new file mode 100644 index 000000000..636c0ddec --- /dev/null +++ b/packages/arkenv/src/morphs.ts @@ -0,0 +1,57 @@ +/** + * Attempts to coerce a value to a number. + * + * If the input is already a number, returns it unchanged. + * If the input is a string that can be parsed as a number, returns the parsed number. + * Otherwise, returns the original value unchanged. + * + * @internal + * @param s - The value to coerce + * @returns The coerced number or the original value + */ +export const coerceNumber = (s: unknown) => { + if (typeof s === "number") return s; + if (typeof s !== "string") return s; + const trimmed = s.trim(); + if (trimmed === "") return s; + if (trimmed === "NaN") return Number.NaN; + const n = Number(trimmed); + return Number.isNaN(n) ? s : n; +}; + +/** + * Attempts to coerce a value to a boolean. + * + * Converts the strings "true" and "false" to their boolean equivalents. + * All other values are returned unchanged. + * + * @internal + * @param s - The value to coerce + * @returns The coerced boolean or the original value + */ +export const coerceBoolean = (s: unknown) => { + if (s === "true") return true; + if (s === "false") return false; + return s; +}; + +/** + * Attempts to parse a value as JSON. + * + * If the input is a string that starts with `{` or `[` and can be parsed as JSON, + * returns the parsed object or array. Otherwise, returns the original value unchanged. + * + * @internal + * @param s - The value to parse + * @returns The parsed JSON or the original value + */ +export const coerceJson = (s: unknown) => { + if (typeof s !== "string") return s; + const trimmed = s.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return s; + try { + return JSON.parse(trimmed); + } catch { + return s; + } +}; diff --git a/packages/arkenv/src/standard-schema.test.ts b/packages/arkenv/src/standard-schema.test.ts new file mode 100644 index 000000000..74dafb06b --- /dev/null +++ b/packages/arkenv/src/standard-schema.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import createEnv from "./index"; + +describe("Standard Schema integration", () => { + it("should throw if a top-level zod schema is passed directly", () => { + const schema = z.object({ + PORT: z.coerce.number(), + HOST: z.string().default("localhost"), + }); + + expect(() => + createEnv(schema as any, { + env: { PORT: "3000" }, + }), + ).toThrow(/expects a mapping.*not a top-level Standard Schema/); + }); + + it("should support mixed validators in createEnv() mapping", () => { + const env = createEnv( + { + PORT: "number.port", // ArkType DSL + HOST: z.string().min(1), // Zod + }, + { + env: { PORT: "3000", HOST: "localhost" }, + }, + ); + + expect(env).toEqual({ + PORT: 3000, + HOST: "localhost", + }); + }); + + it("should verify createEnv accepts a Standard Schema when ArkType is present (via mapping)", () => { + const env = createEnv( + { + ZOD_VAL: z.string().email(), + }, + { + env: { ZOD_VAL: "test@example.com" }, + }, + ); + expect(env.ZOD_VAL).toBe("test@example.com"); + }); + + it("should throw when Standard Schema mapping fails validation", () => { + expect(() => + createEnv( + { + ZOD_VAL: z.string().email(), + }, + { + env: { ZOD_VAL: "not-an-email" }, + }, + ), + ).toThrow(); + }); +}); diff --git a/packages/arkenv/src/type.ts b/packages/arkenv/src/type.ts index 2410781c8..930ef7441 100644 --- a/packages/arkenv/src/type.ts +++ b/packages/arkenv/src/type.ts @@ -1,3 +1,53 @@ import { $ } from "@repo/scope"; -export const type = $.type; +/** + * Create type-safe environment variable validators using ArkType's syntax. + * + * This is a re-export of ArkType's `type` function with arkenv-specific keywords added: + * - `string.host` - Validates hostnames and IP addresses + * - `number.port` - Validates port numbers (0-65535) + * + * The proxy wrapper ensures that created types are properly marked as ArkType instances + * for internal detection and handling. + * + * @example + * ```ts + * // Create a simple type + * const Port = type("number.port"); + * + * // Create an object schema + * const Config = type({ + * HOST: "string.host", + * PORT: "number.port", + * DEBUG: "boolean" + * }); + * + * // Use with createEnv + * const env = createEnv(Config); + * ``` + * + * @see {@link https://arktype.io/docs | ArkType Documentation} + */ +export const type: typeof $.type = new Proxy((() => {}) as any, { + get(_, prop) { + if (prop === "isArktype") return true; + return Reflect.get($.type, prop); + }, + apply(_, thisArg, args) { + const result = Reflect.apply( + $.type, + thisArg === type ? $.type : thisArg, + args, + ); + // If the result is a function/object, ensure it also has the marker + if ( + result && + (typeof result === "object" || typeof result === "function") + ) { + (result as any).isArktype = true; + } + return result; + }, +}); + +export type { type as at } from "arktype"; diff --git a/packages/arkenv/src/utils/coerce.ts b/packages/arkenv/src/utils/coerce.ts index 214f8299c..6d43eb259 100644 --- a/packages/arkenv/src/utils/coerce.ts +++ b/packages/arkenv/src/utils/coerce.ts @@ -1,319 +1,395 @@ -import { maybeBoolean, maybeJson, maybeNumber } from "@repo/keywords"; -import { type BaseType, type JsonSchema, type } from "arktype"; +import { arktypeLoader } from "@repo/scope"; +import type { Dict } from "@repo/types"; +import type { Type } from "arktype"; +import { coerceBoolean, coerceJson, coerceNumber } from "@/morphs"; /** - * A marker used in the coercion path to indicate that the target - * is the *elements* of an array, rather than the array property itself. + * Configuration for the coercion engine. */ -const ARRAY_ITEM_MARKER = "*"; - -/** - * @internal - * Information about a path in the schema that requires coercion. - */ -type CoercionTarget = { - path: string[]; - type: "primitive" | "array" | "object"; -}; - -/** - * Options for coercion behavior. - */ -export type CoerceOptions = { +export type CoerceConfig = Readonly<{ + /** + * Whether to coerce environment variables to numbers. + * + * @default `true` + */ + numbers: boolean; /** - * format to use for array parsing + * Whether to coerce environment variables to booleans. + * + * @default `true` + */ + booleans: boolean; + /** + * Whether to coerce environment variables to objects. + * + * @default `true` + */ + objects: boolean; + /** + * The format to use for array coercion (when `coerce` is enabled). + * + * - `comma` (default): Strings are split by comma and trimmed. + * - `json`: Strings are parsed as JSON. + * * @default "comma" */ - arrayFormat?: "comma" | "json"; + arrayFormat: "comma" | "json"; +}>; + +const DEFAULT_CONFIG: CoerceConfig = { + numbers: true, + booleans: true, + objects: true, + arrayFormat: "comma", }; /** - * Recursively find all paths in a JSON Schema that require coercion. - * We prioritize "number", "integer", "boolean", "array", and "object" types. + * Coerce values in a record based on an ArkType schema's JSON representation. + * + * MAINTAINER NOTE: This is a mini-coercion engine. + * 1. Keep it isolated to arkenv's internal needs. + * 2. Resist feature creep (do not add specialized rules for every possible type). + * 3. Standard Schema validators (Zod, etc.) OWN their own coercion; arkenv only + * applies this engine to ArkType or raw object mappings. */ -const findCoercionPaths = ( - node: JsonSchema, - path: string[] = [], -): CoercionTarget[] => { - const results: CoercionTarget[] = []; - - if (typeof node === "boolean") { - return results; +export function coerce( + schema: Type, + env?: Dict | string, + config: CoerceConfig = DEFAULT_CONFIG, +): any { + // If env is not provided, return a wrapper function (compat with older tests) + if (arguments.length === 1) { + return (input: any) => { + const coerced = coerce(schema, input, config); + return schema(coerced); + }; } - if ("const" in node) { - if (typeof node.const === "number" || typeof node.const === "boolean") { - results.push({ path: [...path], type: "primitive" }); - } + // Get JSON representation (custom format in ArkType 2.0) + let json: any; + try { + json = (schema as any).json; + } catch { + // No-op } - if ("enum" in node && node.enum) { - if ( - node.enum.some((v) => typeof v === "number" || typeof v === "boolean") - ) { - results.push({ path: [...path], type: "primitive" }); - } + // Handle primitive root schema + if (typeof env !== "object" || env === null) { + if (env === undefined) return undefined; + return coerceSingleValue(json, env, config); } - if ("type" in node) { - if (node.type === "number" || node.type === "integer") { - results.push({ path: [...path], type: "primitive" }); - } else if (node.type === "boolean") { - results.push({ path: [...path], type: "primitive" }); - } else if (node.type === "object") { - // Check if this object has properties defined - // If it does, we want to coerce the whole object from a JSON string - // But we also want to recursively check nested properties - const hasProperties = - "properties" in node && - node.properties && - Object.keys(node.properties).length > 0; - - if (hasProperties) { - // Mark this path as needing object coercion (JSON parsing) - results.push({ path: [...path], type: "object" }); + const coercionMap = new Map(); + if (json) { + traverseRepresentation(json, "", coercionMap); + } + + const result = coerceInternal(env, "", coercionMap, config); + + return result; +} + +function coerceInternal( + input: any, + path: string, + coercionMap: Map, + config: CoerceConfig, +): any { + if (input === undefined || input === null) return input; + + const getTargetType = (p: string) => { + if (coercionMap.has(p)) return coercionMap.get(p); + // More aggressive wildcard: replace all numeric segments with '*' + // e.g. SERVICES.0.NAME -> SERVICES.*.NAME, or 0 -> * + const wildcard = p + .split(".") + .map((s) => (/^\d+$/.test(s) ? "*" : s)) + .join("."); + if (wildcard !== p && coercionMap.has(wildcard)) { + return coercionMap.get(wildcard); + } + return undefined; + }; + + if (Array.isArray(input)) { + return input.map((v, i) => { + const fullPath = path ? `${path}.${i}` : `${i}`; + const targetType = getTargetType(fullPath); + + let coerced = v; + if (typeof v !== "object" || v === null) { + coerced = applyCoercion(v, targetType, config); } - // Also recursively check nested properties for their own coercions - if ("properties" in node && node.properties) { - for (const [key, prop] of Object.entries(node.properties)) { - results.push( - ...findCoercionPaths(prop as JsonSchema, [...path, key]), - ); - } + if (typeof coerced === "object" && coerced !== null) { + return coerceInternal(coerced, fullPath, coercionMap, config); } - } else if (node.type === "array") { - // Mark the array itself as a target for splitting strings - results.push({ path: [...path], type: "array" }); - - if ("items" in node && node.items) { - if (Array.isArray(node.items)) { - // Tuple traversal - node.items.forEach((item, index) => { - results.push( - ...findCoercionPaths(item as JsonSchema, [...path, `${index}`]), - ); - }); - } else { - // List traversal - results.push( - ...findCoercionPaths(node.items as JsonSchema, [ - ...path, - ARRAY_ITEM_MARKER, - ]), - ); + return coerced; + }); + } + + if (typeof input === "object") { + const result: any = {}; + for (const [key, value] of Object.entries(input)) { + const fullPath = path ? `${path}.${key}` : key; + const targetType = getTargetType(fullPath); + + let coerced = value; + if (typeof value !== "object" || value === null) { + coerced = applyCoercion(value, targetType, config); + } + + if (typeof coerced === "object" && coerced !== null) { + result[key] = coerceInternal(coerced, fullPath, coercionMap, config); + } else if ( + typeof key === "string" && + key.includes(".") && + config.objects + ) { + const parts = key.split("."); + let current = result; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if ( + !(part in current) || + typeof current[part] !== "object" || + current[part] === null + ) { + // Note: This overwrites any existing non-object value. + // If both "FOO" and "FOO.BAR" are defined, "FOO.BAR" takes precedence + // and "FOO" becomes an object container. + current[part] = {}; + } + current = current[part] as Record; } + const lastPart = parts[parts.length - 1]; + const inferredTargetType = getTargetType(key); + current[lastPart] = applyCoercion(value, inferredTargetType, config); + } else { + result[key] = coerced; } } + return result; } - if ("anyOf" in node && node.anyOf) { - for (const branch of node.anyOf) { - results.push(...findCoercionPaths(branch as JsonSchema, path)); - } + return applyCoercion(input, getTargetType(path), config); +} + +function applyCoercion( + value: any, + targetType: string | undefined, + config: CoerceConfig, +): any { + if (!targetType) return value; + + let coerced = value; + const types = targetType.split("|"); + + // Priority: object (JSON Parsing) > array (Splitting) > number > boolean + if (types.includes("object") && config.objects) { + coerced = coerceJson(coerced); } - if ("allOf" in node && node.allOf) { - for (const branch of node.allOf) { - results.push(...findCoercionPaths(branch as JsonSchema, path)); + if (typeof coerced === "string") { + if (types.includes("array")) { + if ( + config.arrayFormat === "json" || + coerced.startsWith("[") || + coerced.startsWith("{") + ) { + const parsed = coerceJson(coerced); + if (Array.isArray(parsed)) return parsed; + } + + if (config.arrayFormat === "comma" || config.arrayFormat === undefined) { + return coerced === "" + ? [] + : coerced + .split(",") + .map((x) => x.trim()) + .filter((x) => x !== ""); + } } - } - if ("oneOf" in node && node.oneOf) { - for (const branch of node.oneOf) { - results.push(...findCoercionPaths(branch as JsonSchema, path)); + if (types.includes("number") && config.numbers) { + coerced = coerceNumber(coerced); + } + if ( + typeof coerced === "string" && + types.includes("boolean") && + config.booleans + ) { + coerced = coerceBoolean(coerced); } } - // Deduplicate by path and type combination - const seen = new Set(); - return results.filter((t) => { - const key = JSON.stringify(t.path) + t.type; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -}; + return coerced; +} + +function coerceSimpleValue(value: any, config: CoerceConfig): any { + let coerced = value; + // Priority: object (JSON Parsing) > number > boolean + // (arrays are not handled here since we don't have type information) + if (config.objects) coerced = coerceJson(coerced); + if (typeof coerced === "string" && config.numbers) { + coerced = coerceNumber(coerced); + } + if (typeof coerced === "string" && config.booleans) { + coerced = coerceBoolean(coerced); + } + return coerced; +} + +function coerceSingleValue(json: any, value: any, config: CoerceConfig): any { + if (!json) return coerceSimpleValue(value, config); + + const coercionMap = new Map(); + traverseRepresentation(json, "", coercionMap); + return applyCoercion(value, coercionMap.get(""), config); +} /** - * Apply coercion to a data object based on identified paths. + * Traverse ArkType 2.0 custom JSON representation or standard JSON Schema. */ -const applyCoercion = ( - data: unknown, - targets: CoercionTarget[], - options: CoerceOptions = {}, -) => { - const { arrayFormat = "comma" } = options; - - // Helper to split string to array - const splitString = (val: string) => { - if (arrayFormat === "json") { - try { - return JSON.parse(val); - } catch { - return val; - } +function traverseRepresentation( + schema: any, + path: string, + map: Map, +) { + if (schema === undefined || schema === null) return; + + const recordType = (type: string, p = path) => { + const existing = map.get(p); + if (!existing) { + map.set(p, type); + } else if (!existing.includes(type)) { + map.set(p, `${existing}|${type}`); } - - if (!val.trim()) return []; - return val.split(",").map((s) => s.trim()); }; - if (typeof data !== "object" || data === null) { - // If root data needs coercion - if (targets.some((t) => t.path.length === 0)) { - const rootTarget = targets.find((t) => t.path.length === 0); - - if (rootTarget?.type === "object" && typeof data === "string") { - return maybeJson(data); - } - - if (rootTarget?.type === "array" && typeof data === "string") { - return splitString(data); - } + // Handle string shorthand for domains + if (typeof schema === "string") { + if (schema === "number" || schema === "integer") recordType("number"); + else if (schema === "boolean") recordType("boolean"); + else if (schema === "object") recordType("object"); + return; + } - const asNumber = maybeNumber(data); - if (typeof asNumber === "number") { - return asNumber; - } - return maybeBoolean(data); + // Handle Unions represented as arrays + if (Array.isArray(schema)) { + for (const branch of schema) { + traverseRepresentation(branch, path, map); } - return data; + return; } - // Sort targets by path length to ensure parent objects/arrays are coerced before their children - const sortedTargets = [...targets].sort( - (a, b) => a.path.length - b.path.length, - ); + if (typeof schema !== "object") return; + + // Leaf nodes + if ( + schema.unit === "NaN" || + schema.unit === "Infinity" || + schema.unit === "-Infinity" || + typeof schema.unit === "number" + ) { + recordType("number"); + } else if (typeof schema.unit === "boolean") { + recordType("boolean"); + } - const walk = ( - current: unknown, - targetPath: string[], - type: "primitive" | "array" | "object", - ) => { - if (!current || typeof current !== "object") return; + // Nested domains (e.g. number.epoch) + let domain = schema.domain || schema.type; + if (domain && typeof domain === "object") { + domain = domain.domain || domain.type; + } - if (targetPath.length === 0) { - return; + // Handle Arrays + if ( + schema.proto === "Array" || + domain === "Array" || + schema.sequence || + schema.items + ) { + recordType("array"); + recordType("object"); + const elementSchema = schema.sequence || schema.items; + if (elementSchema) { + const elementPath = path ? `${path}.*` : "*"; + traverseRepresentation(elementSchema, elementPath, map); } + return; + } - // If we've reached the last key, apply coercion - if (targetPath.length === 1) { - const lastKey = targetPath[0]; - - if (lastKey === ARRAY_ITEM_MARKER) { - if (Array.isArray(current)) { - for (let i = 0; i < current.length; i++) { - const original = current[i]; - if (type === "primitive") { - const asNumber = maybeNumber(original); - if (typeof asNumber === "number") { - current[i] = asNumber; - } else { - current[i] = maybeBoolean(original); - } - } else if (type === "object") { - current[i] = maybeJson(original); - } - } - } - return; - } - - const record = current as Record; - // biome-ignore lint/suspicious/noPrototypeBuiltins: ES2020 compatibility - if (Object.prototype.hasOwnProperty.call(record, lastKey)) { - const original = record[lastKey]; - - if (type === "array" && typeof original === "string") { - record[lastKey] = splitString(original); - return; - } + // Mark current path as object if it's an object domain + if (domain === "object") { + recordType("object"); + } - if (type === "object" && typeof original === "string") { - record[lastKey] = maybeJson(original); - return; - } - - if (Array.isArray(original)) { - if (type === "primitive") { - for (let i = 0; i < original.length; i++) { - const item = original[i]; - const asNumber = maybeNumber(item); - if (typeof asNumber === "number") { - original[i] = asNumber; - } else { - original[i] = maybeBoolean(item); - } - } - } - } else { - if (type === "primitive") { - const asNumber = maybeNumber(original); - // If numeric parsing didn't produce a number, try boolean coercion - if (typeof asNumber === "number") { - record[lastKey] = asNumber; - } else { - record[lastKey] = maybeBoolean(original); - } - } + if (domain === "object") { + if (Array.isArray(schema.required)) { + for (const entry of schema.required) { + if (entry.key && entry.value) { + const fullPath = path ? `${path}.${entry.key}` : entry.key; + traverseRepresentation(entry.value, fullPath, map); } } - return; } - - // Recurse down - const [nextKey, ...rest] = targetPath; - - if (nextKey === ARRAY_ITEM_MARKER) { - if (Array.isArray(current)) { - for (const item of current) { - walk(item, rest, type); + if (Array.isArray(schema.optional)) { + for (const entry of schema.optional) { + if (entry.key && entry.value) { + const fullPath = path ? `${path}.${entry.key}` : entry.key; + traverseRepresentation(entry.value, fullPath, map); } } - return; } + if (schema.properties) { + for (const [prop, propSchema] of Object.entries(schema.properties)) { + const fullPath = path ? `${path}.${prop}` : prop; + traverseRepresentation(propSchema, fullPath, map); + } + } + } - const record = current as Record; - walk(record[nextKey], rest, type); - }; - - for (const target of sortedTargets) { - walk(data, target.path, target.type); + // Base leaf domains + if (domain === "number" || domain === "integer") { + recordType("number"); + } else if (domain === "boolean") { + recordType("boolean"); } - return data; -}; + // Handle standard JSON Schema union types (anyOf / etc) + const unions = + schema.anyOf || schema.oneOf || schema.allOf || schema.branches; + if (Array.isArray(unions)) { + for (const s of unions) { + traverseRepresentation(s, path, map); + } + } +} /** - * Create a coercing wrapper around an ArkType schema using JSON Schema introspection. - * Pre-process input data to coerce string values to numbers/booleans at identified paths - * before validation. + * Coerces a single value based on the inferred type from a schema. */ -export function coerce( - schema: BaseType, - options?: CoerceOptions, -): BaseType { - // Use a fallback to handle unjsonifiable parts of the schema (like predicates) - // by preserving the base schema. This ensures that even if part of the schema - // cannot be fully represented in JSON Schema, we can still perform coercion - // for the parts that can. - const json = schema.in.toJsonSchema({ - fallback: (ctx) => ctx.base, - }); - const targets = findCoercionPaths(json); - - if (targets.length === 0) { - return schema; +export function coerceValue(def: unknown, value: unknown): unknown { + if (typeof value !== "string") return value; + if ((def as any)?.["~standard"]) return value; + + const at = arktypeLoader.load(); + const { type } = at; + + let t: Type; + try { + t = typeof def === "string" ? type(def as any) : (def as Type); + } catch { + return value; } - /* - * We use `type("unknown")` to start the pipeline, which initializes a default scope. - * Integrating the original `schema` with its custom scope `$` into this pipeline - * creates a scope mismatch in TypeScript ({} vs $). - * We cast to `BaseType` to assert the final contract is maintained. - */ - return type("unknown") - .pipe((data) => applyCoercion(data, targets, options)) - .pipe(schema) as BaseType; + let json: any; + try { + json = (t as any).json; + } catch { + // No-op + } + + return coerceSingleValue(json, value, DEFAULT_CONFIG); } diff --git a/packages/arkenv/tsconfig.json b/packages/arkenv/tsconfig.json index 56793c550..02abaf57a 100644 --- a/packages/arkenv/tsconfig.json +++ b/packages/arkenv/tsconfig.json @@ -13,7 +13,11 @@ "resolveJsonModule": true, "rootDir": "src", "declaration": true, - "declarationMap": true + "declarationMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"] } diff --git a/packages/arkenv/tsdown.config.ts b/packages/arkenv/tsdown.config.ts index 8fc08e8db..3400b041f 100644 --- a/packages/arkenv/tsdown.config.ts +++ b/packages/arkenv/tsdown.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ minify: true, fixedExtension: false, dts: { - resolve: ["@repo/types"], + resolve: ["@repo/types", "@repo/scope", "@repo/keywords"], }, + external: ["arktype"], + // Force bundling of workspace packages + noExternal: ["@repo/scope", "@repo/keywords"], }); diff --git a/packages/arkenv/vitest.config.ts b/packages/arkenv/vitest.config.ts index ac7bda0bc..6d38c3bb2 100644 --- a/packages/arkenv/vitest.config.ts +++ b/packages/arkenv/vitest.config.ts @@ -1,7 +1,9 @@ +import tsconfigPaths from "vite-tsconfig-paths"; import { defineProject } from "vitest/config"; export default defineProject({ test: { name: "arkenv", }, + plugins: [tsconfigPaths()], }); diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index 5cb7c40a4..b4aa20330 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -22,6 +22,7 @@ "size-limit": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", + "vite-tsconfig-paths": "catalog:", "vitest": "catalog:" }, "peerDependencies": { diff --git a/packages/bun-plugin/src/index.test.ts b/packages/bun-plugin/src/index.test.ts index e4b9ee329..b1cacd390 100644 --- a/packages/bun-plugin/src/index.test.ts +++ b/packages/bun-plugin/src/index.test.ts @@ -66,4 +66,20 @@ describe("Bun Plugin", () => { expect(envMap.has("PORT")).toBe(false); expect(envMap.has("DATABASE_URL")).toBe(false); }); + + it("should work with a Standard Schema validator", () => { + process.env.BUN_PUBLIC_SS = "ss-value"; + const mockValidator = { + "~standard": { + version: 1, + validate: (val: any) => ({ value: val }), + }, + }; + const envMap = processEnvSchema({ + BUN_PUBLIC_SS: mockValidator, + } as any); + + expect(envMap.has("BUN_PUBLIC_SS")).toBe(true); + expect(envMap.get("BUN_PUBLIC_SS")).toBe(JSON.stringify("ss-value")); + }); }); diff --git a/packages/bun-plugin/src/index.ts b/packages/bun-plugin/src/index.ts index 9ba5881c4..aea352eb9 100644 --- a/packages/bun-plugin/src/index.ts +++ b/packages/bun-plugin/src/index.ts @@ -1,6 +1,5 @@ import { join } from "node:path"; import type { EnvSchemaWithType, SchemaShape } from "@repo/types"; -import type { EnvSchema } from "arkenv"; import { createEnv } from "arkenv"; import type { BunPlugin, Loader, PluginBuilder } from "bun"; @@ -10,7 +9,7 @@ export type { ProcessEnvAugmented } from "./types"; * Helper to process env schema and return envMap */ export function processEnvSchema( - options: EnvSchema | EnvSchemaWithType, + options: T | EnvSchemaWithType, ): Map { // Validate environment variables @@ -18,7 +17,7 @@ export function processEnvSchema( // "Type instantiation is excessively deep and possibly infinite" errors. // Ideally, we should find a way to avoid these assertions while maintaining type safety. - const env = createEnv(options, { env: process.env }); + const env = createEnv(options as any, { env: process.env }); // Get Bun's prefix for client-exposed environment variables const prefix = "BUN_PUBLIC_"; @@ -125,11 +124,9 @@ function registerLoader(build: PluginBuilder, envMap: Map) { * ``` */ export function arkenv(options: EnvSchemaWithType): BunPlugin; +export function arkenv(options: T): BunPlugin; export function arkenv( - options: EnvSchema, -): BunPlugin; -export function arkenv( - options: EnvSchema | EnvSchemaWithType, + options: T | EnvSchemaWithType, ): BunPlugin { const envMap = processEnvSchema(options); diff --git a/packages/bun-plugin/vitest.config.ts b/packages/bun-plugin/vitest.config.ts index 53978c91b..18f01b5fd 100644 --- a/packages/bun-plugin/vitest.config.ts +++ b/packages/bun-plugin/vitest.config.ts @@ -1,3 +1,4 @@ +import tsconfigPaths from "vite-tsconfig-paths"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -11,4 +12,5 @@ export default defineConfig({ arkenv: new URL("../arkenv/src/index.ts", import.meta.url).pathname, }, }, + plugins: [tsconfigPaths()], }); diff --git a/packages/internal/keywords/CHANGELOG.md b/packages/internal/keywords/CHANGELOG.md deleted file mode 100644 index c5175d487..000000000 --- a/packages/internal/keywords/CHANGELOG.md +++ /dev/null @@ -1,39 +0,0 @@ -# @repo/keywords - -## 0.2.1 - -### Patch Changes - -- #### Add `maybeJson` keyword _[`#694`](https://github.com/yamcodes/arkenv/pull/694) [`01c1704`](https://github.com/yamcodes/arkenv/commit/01c17041029a41f2dfcacd7dd7ed2d1cd5a8c058) [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)_ - - JSDoc: - - ```ts - /** - * A loose JSON morph. - * - * **In**: `unknown` - * - * **Out**: A parsed JSON object if the input is a valid JSON string; otherwise the original input. - * - * Useful for coercion in unions where failing on non-JSON strings would block other branches. - */ - ``` - -## 0.2.0 - -### Minor Changes - -- #### Rename `maybeParsedNumber`/`maybeParsedBoolean` to `maybeNumber`/`maybeBoolean` _[`#693`](https://github.com/yamcodes/arkenv/pull/693) [`7919b6d`](https://github.com/yamcodes/arkenv/commit/7919b6dcd171553d0e6e6e819a862408284e1f71) [@yamcodes](https://github.com/yamcodes)_ - - Rename these types for brevity and clarity. - -## 0.1.0 - -### Minor Changes - -- #### Simplify keywords for central coercion _[`#569`](https://github.com/yamcodes/arkenv/pull/569) [`adaada4`](https://github.com/yamcodes/arkenv/commit/adaada4d214c152e8d23c983aea1747d81a0e539) [@yamcodes](https://github.com/yamcodes)_ - - - **BREAKING**: The `boolean` keyword has been removed. Universal boolean coercion is now handled by the `arkenv` package. - - **BREAKING**: The `port` keyword has been changed from a `string -> number` morph to a pure `number` refinement. Numeric coercion is now handled centrally. - - Added `maybeParsedNumber` and `maybeParsedBoolean` internal morphs to support central coercion (including specific "NaN" support). diff --git a/packages/internal/keywords/README.md b/packages/internal/keywords/README.md deleted file mode 100644 index 9500dfd47..000000000 --- a/packages/internal/keywords/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# `@repo/keywords` - -Internal keywords for ArkEnv. diff --git a/packages/internal/keywords/package.json b/packages/internal/keywords/package.json deleted file mode 100644 index 61a39717f..000000000 --- a/packages/internal/keywords/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@repo/keywords", - "private": true, - "type": "module", - "version": "0.2.1", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "description": "ArkEnv keywords (internal usage)", - "exports": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "scripts": { - "build": "tsdown", - "size": "size-limit", - "typecheck": "tsc --noEmit", - "clean": "rimraf dist node_modules", - "test": "vitest", - "fix": "pnpm -w run fix" - }, - "files": [ - "dist" - ], - "keywords": [ - "keywords" - ], - "license": "MIT", - "homepage": "https://arkenv.js.org", - "repository": { - "type": "git", - "url": "git+https://github.com/yamcodes/arkenv.git" - }, - "author": "Yam Borodetsky ", - "devDependencies": { - "arktype": "catalog:", - "tsdown": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "arktype": "catalog:" - } -} diff --git a/packages/internal/keywords/src/index.test.ts b/packages/internal/keywords/src/index.test.ts deleted file mode 100644 index 4aeffb010..000000000 --- a/packages/internal/keywords/src/index.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { host, maybeJson, port } from "./index"; - -describe("port", () => { - it("should validate a valid port number", () => { - const result = port.assert(8080); - expect(result).toBe(8080); - }); - - it("should throw when the port is not a number", () => { - expect(() => port.assert("invalid")).toThrow(); - }); - - it("should throw when the port is negative", () => { - expect(() => port.assert("-2")).toThrow(); - }); - - it("should throw when the port is too large", () => { - expect(() => port.assert("65536")).toThrow(); - }); -}); - -describe("host", () => { - it("should validate a valid IP address", () => { - const result = host.assert("127.0.0.1"); - expect(result).toBe("127.0.0.1"); - }); - - it("should validate localhost", () => { - const result = host.assert("localhost"); - expect(result).toBe("localhost"); - }); - - it("should throw when the host is invalid", () => { - expect(() => host.assert("invalid")).toThrow(); - }); -}); - -describe("maybeJson", () => { - it("should parse a valid JSON object string", () => { - const result = maybeJson('{"key": "value"}'); - expect(result).toEqual({ key: "value" }); - }); - - it("should parse a valid JSON array string", () => { - const result = maybeJson("[1, 2, 3]"); - expect(result).toEqual([1, 2, 3]); - }); - - it("should parse nested JSON objects", () => { - const result = maybeJson('{"nested": {"key": "value"}}'); - expect(result).toEqual({ nested: { key: "value" } }); - }); - - it("should return the original string if not valid JSON", () => { - const result = maybeJson("not json"); - expect(result).toBe("not json"); - }); - - it("should return the original string if it doesn't look like JSON", () => { - const result = maybeJson("simple string"); - expect(result).toBe("simple string"); - }); - - it("should return the original value if not a string", () => { - const result = maybeJson(42); - expect(result).toBe(42); - }); - - it("should return the original object if already an object", () => { - const obj = { key: "value" }; - const result = maybeJson(obj); - expect(result).toStrictEqual(obj); - }); - - it("should handle JSON strings with whitespace", () => { - const result = maybeJson(' {"key": "value"} '); - expect(result).toEqual({ key: "value" }); - }); -}); diff --git a/packages/internal/keywords/src/index.ts b/packages/internal/keywords/src/index.ts deleted file mode 100644 index 2b192a78d..000000000 --- a/packages/internal/keywords/src/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type } from "arktype"; - -/** - * A loose numeric morph. - * - * **In**: `unknown` - * - * **Out**: A `number` if the input is a numeric string; otherwise the original input. - * - * Useful for coercion in unions where failing on non-numeric strings would block other branches. - */ -export const maybeNumber = type("unknown").pipe((s) => { - if (typeof s === "number") return s; - if (typeof s !== "string") return s; - const trimmed = s.trim(); - if (trimmed === "") return s; - if (trimmed === "NaN") return Number.NaN; - const n = Number(trimmed); - return Number.isNaN(n) ? s : n; -}); - -/** - * A loose boolean morph. - * - * **In**: `unknown` - * - * **Out**: `true` for `"true"`, `false` for `"false"`; otherwise the original input. - * - * Useful for coercion in unions where failing on non-boolean strings would block other branches. - */ -export const maybeBoolean = type("unknown").pipe((s) => { - if (s === "true") return true; - if (s === "false") return false; - return s; -}); - -/** - * A `number` integer between 0 and 65535. - */ -export const port = type("0 <= number.integer <= 65535"); - -/** - * An IP address or `"localhost"` - */ -export const host = type("string.ip | 'localhost'"); - -/** - * A loose JSON morph. - * - * **In**: `unknown` - * - * **Out**: A parsed JSON object if the input is a valid JSON string; otherwise the original input. - * - * Useful for coercion in unions where failing on non-JSON strings would block other branches. - */ -export const maybeJson = type("unknown").pipe((s) => { - if (typeof s !== "string") return s; - const trimmed = s.trim(); - // Only attempt to parse if it looks like JSON (starts with { or [) - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return s; - try { - return JSON.parse(trimmed); - } catch { - return s; - } -}); diff --git a/packages/internal/keywords/tsconfig.json b/packages/internal/keywords/tsconfig.json deleted file mode 100644 index 56793c550..000000000 --- a/packages/internal/keywords/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "module": "preserve", - "strict": true, - "esModuleInterop": true, - "moduleResolution": "bundler", - "skipLibCheck": true, - "exactOptionalPropertyTypes": true, - "noImplicitAny": true, - "noEmit": true, - "outDir": "dist", - "resolveJsonModule": true, - "rootDir": "src", - "declaration": true, - "declarationMap": true - }, - "include": ["src"] -} diff --git a/packages/internal/keywords/tsdown.config.ts b/packages/internal/keywords/tsdown.config.ts deleted file mode 100644 index 8fc08e8db..000000000 --- a/packages/internal/keywords/tsdown.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsdown"; - -export default defineConfig({ - format: ["esm", "cjs"], - minify: true, - fixedExtension: false, - dts: { - resolve: ["@repo/types"], - }, -}); diff --git a/packages/internal/keywords/vitest.config.ts b/packages/internal/keywords/vitest.config.ts deleted file mode 100644 index d3f85876b..000000000 --- a/packages/internal/keywords/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineProject } from "vitest/config"; - -export default defineProject({ - test: { - name: "@repo/keywords", - }, -}); diff --git a/packages/internal/scope/package.json b/packages/internal/scope/package.json index 4bd2d4575..959e00dc8 100644 --- a/packages/internal/scope/package.json +++ b/packages/internal/scope/package.json @@ -33,10 +33,9 @@ "url": "git+https://github.com/yamcodes/arkenv.git" }, "author": "Yam Borodetsky ", - "dependencies": { - "@repo/keywords": "workspace:*" - }, + "dependencies": {}, "devDependencies": { + "@types/node": "catalog:", "arktype": "catalog:", "tsdown": "catalog:", "typescript": "catalog:" diff --git a/packages/internal/scope/src/index.ts b/packages/internal/scope/src/index.ts index 031d92df7..01cafe682 100644 --- a/packages/internal/scope/src/index.ts +++ b/packages/internal/scope/src/index.ts @@ -1,20 +1,56 @@ -import { host, port } from "@repo/keywords"; -import { scope, type } from "arktype"; +import { host, port } from "./keywords"; +import { arktypeLoader } from "./lazy-type"; + +export * from "./keywords"; +export { arktypeLoader, lazyType } from "./lazy-type"; + +/** + * Global cache for the realized scope using Symbol.for for cross-module coordination. + */ +const SCOPE_CACHE_SYMBOL = Symbol.for("__ARKENV_SCOPE_CACHE__"); +const G = globalThis as any; +G[SCOPE_CACHE_SYMBOL] ??= { scope: undefined }; + +/** + * Reset the realized scope and loader (for tests). + */ +export function resetScope() { + G[SCOPE_CACHE_SYMBOL].scope = undefined; + arktypeLoader.reset(); +} /** * The root scope for the ArkEnv library, * containing extensions to the ArkType scopes with ArkEnv-specific types * like `string.host` and `number.port`. + * + * This is a lazy-loaded proxy to allow ArkType to be an optional dependency. */ -export const $ = scope({ - string: type.module({ - ...type.keywords.string, - host, - }), - number: type.module({ - ...type.keywords.number, - port, - }), -}); - -export type $ = (typeof $)["t"]; +import type { ArkEnvScope } from "./scope-def"; + +export type { ArkEnvScope } from "./scope-def"; + +export const $: ArkEnvScope = new Proxy( + {}, + { + get(_, prop) { + if (!G[SCOPE_CACHE_SYMBOL].scope) { + const { scope, type } = arktypeLoader.load(); + + G[SCOPE_CACHE_SYMBOL].scope = scope({ + string: type.module({ + ...type.keywords.string, + host, + }), + number: type.module({ + ...type.keywords.number, + port, + }), + }); + } + return (G[SCOPE_CACHE_SYMBOL].scope as any)[prop]; + }, + }, +) as any; + +export type $ = ArkEnvScope["t"]; diff --git a/packages/internal/scope/src/keywords.test.ts b/packages/internal/scope/src/keywords.test.ts new file mode 100644 index 000000000..df563ce5a --- /dev/null +++ b/packages/internal/scope/src/keywords.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { host, port } from "./keywords"; + +describe("port", () => { + it("should validate a valid port number", () => { + const result = port.assert(8080); + expect(result).toBe(8080); + }); + + it("should throw when the port is not a number", () => { + expect(() => port.assert("invalid")).toThrow(); + }); + + it("should throw when the port is negative", () => { + expect(() => port.assert(-2)).toThrow(); + }); + + it("should throw when the port is too large", () => { + expect(() => port.assert(65536)).toThrow(); + }); +}); + +describe("host", () => { + it("should validate a valid IP address", () => { + const result = host.assert("127.0.0.1"); + expect(result).toBe("127.0.0.1"); + }); + + it("should validate localhost", () => { + const result = host.assert("localhost"); + expect(result).toBe("localhost"); + }); + + it("should throw when the host is invalid", () => { + expect(() => host.assert("invalid")).toThrow(); + }); +}); diff --git a/packages/internal/scope/src/keywords.ts b/packages/internal/scope/src/keywords.ts new file mode 100644 index 000000000..4e672a2dc --- /dev/null +++ b/packages/internal/scope/src/keywords.ts @@ -0,0 +1,11 @@ +import { lazyType as type } from "./lazy-type"; + +/** + * A `number` integer between 0 and 65535. + */ +export const port = type("0 <= number.integer <= 65535"); + +/** + * An IP address or `"localhost"` + */ +export const host = type("string.ip | 'localhost'"); diff --git a/packages/internal/scope/src/lazy-type.ts b/packages/internal/scope/src/lazy-type.ts new file mode 100644 index 000000000..d989694cf --- /dev/null +++ b/packages/internal/scope/src/lazy-type.ts @@ -0,0 +1,129 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +/** + * Internal loader state using Symbol.for for cross-module coordination. + */ +const LOADER_SYMBOL = Symbol.for("__ARKENV_ARKTYPE_LOADER__"); +const G = globalThis as any; + +const MISSING_ERROR = + "ArkType is required when using ArkType-specific schemas (string definitions or type() calls). " + + "Please install 'arktype' as a dependency, or use Standard Schema validators like Zod instead."; + +if (!G[LOADER_SYMBOL]) { + G[LOADER_SYMBOL] = { + at: undefined, + forceMissing: false, + load: (): typeof import("arktype") => { + if ( + G[LOADER_SYMBOL].forceMissing || + process.env.ARKENV_FORCE_MISSING === "true" + ) { + throw new Error(MISSING_ERROR); + } + if (G[LOADER_SYMBOL].at) return G[LOADER_SYMBOL].at; + try { + G[LOADER_SYMBOL].at = require("arktype"); + return G[LOADER_SYMBOL].at!; + } catch (e: unknown) { + if ( + e instanceof Error && + "code" in e && + e.code === "MODULE_NOT_FOUND" && + e.message.includes("'arktype'") + ) { + throw new Error(MISSING_ERROR); + } + throw e; + } + }, + reset: () => { + G[LOADER_SYMBOL].at = undefined; + G[LOADER_SYMBOL].forceMissing = false; + delete process.env.ARKENV_FORCE_MISSING; + }, + }; +} + +export const arktypeLoader = G[LOADER_SYMBOL]; + +/** + * A lazy proxy that defers loading ArkType until it is actually needed. + */ + +type LazyTypeProxy = { + def: unknown; + morphs: Array<(value: unknown) => unknown>; + _realized?: any; +}; + +/** + * Create a lazy proxy for an ArkType definition. + */ +function createLazyProxy(state: LazyTypeProxy): any { + return new Proxy(() => {}, { + get(_, prop) { + if (prop === "isArktype") return true; + if (prop === "pipe") { + return (morph: (value: unknown) => unknown) => { + if (state._realized) { + throw new Error( + "Cannot pipe after the lazy type has been realized", + ); + } + state.morphs.push(morph); + return createLazyProxy(state); + }; + } + return realizeType(state)[prop]; + }, + apply(_, __, args) { + const realized = realizeType(state); + return realized(...args); + }, + }); +} + +/** + * Realize the actual ArkType type from the lazy proxy state. + */ +function realizeType(state: LazyTypeProxy): any { + if (state._realized) { + return state._realized; + } + + const at = arktypeLoader.load(); + const { type } = at; + + let realized = type(state.def as any); + + for (const morph of state.morphs) { + realized = realized.pipe(morph); + } + + state._realized = realized; + return realized; +} + +/** + * The lazy type function that mimics ArkType's `type()` API. + * This is typed as ArkType's type function for full DX compatibility, + * but returns a lazy proxy that defers loading ArkType until needed. + * + * MAINTAINER NOTE: This is a best-effort internal proxy. + * If you use this in tests, you MUST call resetScope() in beforeEach/afterEach + * to avoid leaking state between test cases. + */ +import type { type as ArkType } from "arktype"; +export const lazyType = new Proxy(() => {}, { + apply(_, __, [def]) { + return createLazyProxy({ def, morphs: [] }); + }, + get(_, prop) { + if (prop === "isArktype") return true; + const at = arktypeLoader.load(); + return (at.type as any)[prop]; + }, +}) as unknown as typeof ArkType; diff --git a/packages/internal/scope/src/scope-def.ts b/packages/internal/scope/src/scope-def.ts new file mode 100644 index 000000000..10a5f0409 --- /dev/null +++ b/packages/internal/scope/src/scope-def.ts @@ -0,0 +1,19 @@ +import { scope, type } from "arktype"; +import { host, port } from "./keywords"; + +/** + * Definition of the scope for type inference purposes only. + * This file is never imported at runtime to keep arktype optional. + */ +const $ = scope({ + string: type.module({ + ...type.keywords.string, + host, + }), + number: type.module({ + ...type.keywords.number, + port, + }), +}); + +export type ArkEnvScope = typeof $; diff --git a/packages/internal/scope/tsconfig.json b/packages/internal/scope/tsconfig.json index 56793c550..93caed97b 100644 --- a/packages/internal/scope/tsconfig.json +++ b/packages/internal/scope/tsconfig.json @@ -13,7 +13,8 @@ "resolveJsonModule": true, "rootDir": "src", "declaration": true, - "declarationMap": true + "declarationMap": true, + "types": ["node"] }, "include": ["src"] } diff --git a/packages/internal/types/src/filter-by-prefix.ts b/packages/internal/types/src/filter-by-prefix.ts index 31cc72674..0cb590d92 100644 --- a/packages/internal/types/src/filter-by-prefix.ts +++ b/packages/internal/types/src/filter-by-prefix.ts @@ -5,9 +5,8 @@ * @template T - The record of environment variables * @template Prefix - The prefix to filter by */ -export type FilterByPrefix< - T extends Record, - Prefix extends string, -> = { - [K in keyof T as K extends `${Prefix}${string}` ? K : never]: T[K]; -}; +export type FilterByPrefix = any extends T + ? any + : { + [K in keyof T as K extends `${Prefix}${string}` ? K : never]: T[K]; + }; diff --git a/packages/internal/types/src/helpers.ts b/packages/internal/types/src/helpers.ts new file mode 100644 index 000000000..46cf7cb01 --- /dev/null +++ b/packages/internal/types/src/helpers.ts @@ -0,0 +1 @@ +export type Dict = Record; diff --git a/packages/internal/types/src/index.ts b/packages/internal/types/src/index.ts index c405b87d8..11490eb65 100644 --- a/packages/internal/types/src/index.ts +++ b/packages/internal/types/src/index.ts @@ -1,13 +1,12 @@ /** - * Internal TypeScript/ArkType types shared across ArkEnv packages. + * Internal TypeScript types shared across ArkEnv packages. * * This package is not published to npm and is intended for internal use only * within the monorepo. */ -// Only TypeScript types export type * from "./filter-by-prefix"; +export * from "./helpers"; export type * from "./infer-type"; - -// Also includes ArkType types export * from "./schema"; +export * from "./standard-schema"; diff --git a/packages/internal/types/src/infer-type.ts b/packages/internal/types/src/infer-type.ts index d07009e68..0298ac7ba 100644 --- a/packages/internal/types/src/infer-type.ts +++ b/packages/internal/types/src/infer-type.ts @@ -1,17 +1,27 @@ -import type { type } from "arktype"; +import type { $ } from "@repo/scope"; +import type { distill, Type, type } from "arktype"; +import type { StandardSchemaV1 } from "./standard-schema"; /** - * Extract the inferred type from an ArkType type definition by checking its call signature. - * When a type definition is called, it returns either the validated value or type.errors. + * Extract the inferred type from an ArkType type definition or a Standard Schema validator. * - * @template T - The ArkType type definition to infer from + * @template T - The schema definition to infer from */ -export type InferType = T extends ( - value: Record, -) => infer R - ? R extends type.errors - ? never - : R - : T extends type.Any - ? U - : never; +export type InferType = + T extends StandardSchemaV1 + ? O + : T extends Type + ? distill.Out + : T extends { t: infer U } + ? distill.Out + : T extends { infer: infer U } + ? distill.Out + : T extends (value: unknown) => infer R + ? R extends type.errors + ? never + : distill.Out + : T extends string + ? distill.Out> + : T extends object + ? distill.Out> + : T; diff --git a/packages/internal/types/src/schema.ts b/packages/internal/types/src/schema.ts index 8a78603ee..28f9226a3 100644 --- a/packages/internal/types/src/schema.ts +++ b/packages/internal/types/src/schema.ts @@ -1,7 +1,16 @@ import type { $ } from "@repo/scope"; -import { type Type, type } from "arktype"; +import type { Type } from "arktype"; +import type { StandardSchemaV1 } from "./standard-schema"; -export const SchemaShape = type({ "[string]": "unknown" }); -export type SchemaShape = typeof SchemaShape.infer; +/** + * A schema shape representing a mapping of string keys to unknown values. + * This is a pure TypeScript type and does not require ArkType at runtime. + */ +export type SchemaShape = Record; -export type EnvSchemaWithType = Type; +/** + * A schema definition that is either an ArkType Type or a Standard Schema validator. + */ +export type EnvSchemaWithType = + | Type + | StandardSchemaV1; diff --git a/packages/internal/types/src/standard-schema.ts b/packages/internal/types/src/standard-schema.ts new file mode 100644 index 000000000..7d85f3948 --- /dev/null +++ b/packages/internal/types/src/standard-schema.ts @@ -0,0 +1,184 @@ +/** + * @see https://github.com/standard-schema/standard-schema/tree/3130ce43fdd848d9ab49dbb0458d04f18459961c/packages/spec + * + * Copied from standard-schema (MIT License) + * Copyright (c) 2024 Colin McDannell + */ + +// biome-ignore-all lint/style/useConsistentTypeDefinitions: this file is copied from the standard-schema repo + +// ######################### +// ### Standard Typed ### +// ######################### + +/** The Standard Typed interface. This is a base type extended by other specs. */ +export interface StandardTypedV1 { + /** The Standard properties. */ + readonly "~standard": StandardTypedV1.Props; +} + +export declare namespace StandardTypedV1 { + /** The Standard Typed properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The Standard Typed types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Typed. */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Typed. */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} + +// ########################## +// ### Standard Schema ### +// ########################## + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props + extends StandardTypedV1.Props { + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1.Options | undefined, + ) => Result | Promise>; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + } + + export interface Options { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard types interface. */ + export interface Types + extends StandardTypedV1.Types {} + + /** Infers the input type of a Standard. */ + export type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard. */ + export type InferOutput = + StandardTypedV1.InferOutput; +} + +// ############################### +// ### Standard JSON Schema ### +// ############################### + +/** The Standard JSON Schema interface. */ +export interface StandardJSONSchemaV1 { + /** The Standard JSON Schema properties. */ + readonly "~standard": StandardJSONSchemaV1.Props; +} + +export declare namespace StandardJSONSchemaV1 { + /** The Standard JSON Schema properties interface. */ + export interface Props + extends StandardTypedV1.Props { + /** Methods for generating the input/output JSON Schema. */ + readonly jsonSchema: StandardJSONSchemaV1.Converter; + } + + /** The Standard JSON Schema converter interface. */ + export interface Converter { + /** Converts the input type to JSON Schema. May throw if conversion is not supported. */ + readonly input: ( + options: StandardJSONSchemaV1.Options, + ) => Record; + /** Converts the output type to JSON Schema. May throw if conversion is not supported. */ + readonly output: ( + options: StandardJSONSchemaV1.Options, + ) => Record; + } + + /** + * The target version of the generated JSON Schema. + * + * It is *strongly recommended* that implementers support `"draft-2020-12"` and `"draft-07"`, as they are both in wide use. All other targets can be implemented on a best-effort basis. Libraries should throw if they don't support a specified target. + * + * The `"openapi-3.0"` target is intended as a standardized specifier for OpenAPI 3.0 which is a superset of JSON Schema `"draft-04"`. + */ + export type Target = + | "draft-2020-12" + | "draft-07" + | "openapi-3.0" + // Accepts any string for future targets while preserving autocomplete + | ({} & string); + + /** The options for the input/output methods. */ + export interface Options { + /** Specifies the target version of the generated JSON Schema. Support for all versions is on a best-effort basis. If a given version is not supported, the library should throw. */ + readonly target: Target; + + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The Standard types interface. */ + export interface Types + extends StandardTypedV1.Types {} + + /** Infers the input type of a Standard. */ + export type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard. */ + export type InferOutput = + StandardTypedV1.InferOutput; +} diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 8988c44fd..b792bc776 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -21,7 +21,8 @@ "typescript": "catalog:", "vite": "7.3.1", "vite-tsconfig-paths": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "zod": "catalog:" }, "peerDependencies": { "arktype": "^2.1.22", @@ -41,7 +42,6 @@ "keywords": [ "arktype", "arkenv", - "arkenv", "environment", "variables", "vite", diff --git a/packages/vite-plugin/src/__fixtures__/standard-schema/.env.test b/packages/vite-plugin/src/__fixtures__/standard-schema/.env.test new file mode 100644 index 000000000..62002d231 --- /dev/null +++ b/packages/vite-plugin/src/__fixtures__/standard-schema/.env.test @@ -0,0 +1 @@ +VITE_ZOD_VAR=hello-zod diff --git a/packages/vite-plugin/src/__fixtures__/standard-schema/config.ts b/packages/vite-plugin/src/__fixtures__/standard-schema/config.ts new file mode 100644 index 000000000..2f1811a77 --- /dev/null +++ b/packages/vite-plugin/src/__fixtures__/standard-schema/config.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const Env = { + VITE_ZOD_VAR: z.string().min(5), +}; diff --git a/packages/vite-plugin/src/__fixtures__/standard-schema/index.ts b/packages/vite-plugin/src/__fixtures__/standard-schema/index.ts new file mode 100644 index 000000000..546b5813b --- /dev/null +++ b/packages/vite-plugin/src/__fixtures__/standard-schema/index.ts @@ -0,0 +1 @@ +console.log(import.meta.env.VITE_ZOD_VAR); diff --git a/packages/vite-plugin/src/index.test.ts b/packages/vite-plugin/src/index.test.ts index b1e307cd5..4c8ec6977 100644 --- a/packages/vite-plugin/src/index.test.ts +++ b/packages/vite-plugin/src/index.test.ts @@ -2,6 +2,8 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import * as vite from "vite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import arkenvPlugin from "./index"; // Mock the arkenv module to capture calls // Mock the arkenv module with a spy that calls the real implementation by default @@ -23,8 +25,6 @@ vi.mock("vite", async (importActual) => { }; }); -import arkenvPlugin from "./index.js"; - const fixturesDir = join(__dirname, "__fixtures__"); // Get the mocked functions @@ -617,6 +617,42 @@ describe("Plugin Unit Tests", () => { }, ); }); + + it("should work with a Zod Standard Schema validator", async () => { + vi.stubEnv("VITE_SS_VAR", "valid-value"); + const schema = { + VITE_SS_VAR: z.string().min(5), + }; + + // Note: We use the real implementation for this test + const actual = await vi.importActual("arkenv"); + mockCreateEnv.mockImplementation(actual.createEnv); + + const pluginInstance = arkenvPlugin(schema); + + let result: any = {}; + if (pluginInstance.config && typeof pluginInstance.config === "function") { + const mockContext = { + meta: { + framework: "vite", + version: "1.0.0", + rollupVersion: "4.0.0", + viteVersion: "5.0.0", + }, + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + } as any; + result = pluginInstance.config.call( + mockContext, + {}, + { mode: "test", command: "build" }, + ); + } + + expect(result.define).toHaveProperty("import.meta.env.VITE_SS_VAR"); + }); }); // Integration tests using with-env-dir fixture for custom envDir configuration diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 3c95a2e6c..0e4fd4cdb 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -1,14 +1,9 @@ import type { EnvSchemaWithType, SchemaShape } from "@repo/types"; -import { createEnv, type EnvSchema } from "arkenv"; +import { createEnv } from "arkenv"; import { loadEnv, type Plugin } from "vite"; export type { ImportMetaEnvAugmented } from "./types"; -/** - * TODO: If possible, find a better type than "const T extends SchemaShape", - * and be as close as possible to the type accepted by ArkType's `type`. - */ - /** * Vite plugin to validate environment variables using ArkEnv and expose them to client code. * @@ -16,8 +11,7 @@ export type { ImportMetaEnvAugmented } from "./types"; * automatically filters them based on Vite's `envPrefix` configuration (defaults to `"VITE_"`). * Only environment variables matching the prefix are exposed to client code via `import.meta.env.*`. * - * @param options - The environment variable schema definition. Can be an `EnvSchema` object - * for typesafe validation or an ArkType `EnvSchemaWithType` for dynamic schemas. + * @param options - The environment variable schema definition. * @returns A Vite plugin that validates environment variables and exposes them to the client. * * @example @@ -35,19 +29,11 @@ export type { ImportMetaEnvAugmented } from "./types"; * ], * }); * ``` - * - * @example - * ```ts - * // In your client code - * console.log(import.meta.env.VITE_API_URL); // Typesafe access - * ``` */ export default function arkenv(options: EnvSchemaWithType): Plugin; +export default function arkenv(options: T): Plugin; export default function arkenv( - options: EnvSchema, -): Plugin; -export default function arkenv( - options: EnvSchema | EnvSchemaWithType, + options: T | EnvSchemaWithType, ): Plugin { return { name: "@arkenv/vite-plugin", @@ -63,7 +49,7 @@ export default function arkenv( // TODO: We're using type assertions and explicitly pass in the type arguments here to avoid // "Type instantiation is excessively deep and possibly infinite" errors. // Ideally, we should find a way to avoid these assertions while maintaining type safety. - const env = createEnv(options, { + const env = createEnv(options as any, { env: loadEnv(mode, envDir, ""), }); diff --git a/packages/vite-plugin/src/types.ts b/packages/vite-plugin/src/types.ts index c37094fe6..ab75b4f14 100644 --- a/packages/vite-plugin/src/types.ts +++ b/packages/vite-plugin/src/types.ts @@ -1,5 +1,4 @@ import type { FilterByPrefix, InferType } from "@repo/types"; -import type { type } from "arktype"; /** * Augment the `import.meta.env` object with typesafe environment variables @@ -43,6 +42,6 @@ import type { type } from "arktype"; * @see {@link https://github.com/Julien-R44/vite-plugin-validate-env#typing-importmetaenv | Original implementation by Julien-R44} */ export type ImportMetaEnvAugmented< - TSchema extends type.Any, + TSchema, Prefix extends string = "VITE_", > = FilterByPrefix, Prefix>; diff --git a/packages/vite-plugin/vitest.config.ts b/packages/vite-plugin/vitest.config.ts index c4b099d0d..2c973f25c 100644 --- a/packages/vite-plugin/vitest.config.ts +++ b/packages/vite-plugin/vitest.config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import tsconfigPaths from "vite-tsconfig-paths"; import { defineProject } from "vitest/config"; export default defineProject({ @@ -10,4 +11,5 @@ export default defineProject({ arkenv: path.resolve(__dirname, "../arkenv/src/index.ts"), }, }, + plugins: [tsconfigPaths()], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a7091d98..acfe58acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -533,9 +533,6 @@ importers: packages/arkenv: devDependencies: - '@repo/keywords': - specifier: workspace:* - version: link:../internal/keywords '@repo/scope': specifier: workspace:* version: link:../internal/scope @@ -566,9 +563,15 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vite-tsconfig-paths: + specifier: 'catalog:' + version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)) vitest: specifier: 'catalog:' version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + zod: + specifier: 'catalog:' + version: 4.3.5 packages/bun-plugin: dependencies: @@ -606,28 +609,18 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vite-tsconfig-paths: + specifier: 'catalog:' + version: 6.0.3(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)) vitest: specifier: 'catalog:' version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) - packages/internal/keywords: - devDependencies: - arktype: - specifier: 'catalog:' - version: 2.1.29 - tsdown: - specifier: 'catalog:' - version: 0.18.4(typescript@5.9.3) - typescript: - specifier: 'catalog:' - version: 5.9.3 - packages/internal/scope: - dependencies: - '@repo/keywords': - specifier: workspace:* - version: link:../keywords devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.10.4 arktype: specifier: 'catalog:' version: 2.1.29 @@ -686,6 +679,9 @@ importers: vitest: specifier: 'catalog:' version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0) + zod: + specifier: 'catalog:' + version: 4.3.5 tooling/playwright-www: dependencies: