From 9720871ef9f9c612fb3588ded7a42faec17bf486 Mon Sep 17 00:00:00 2001 From: Cohan Carpentier Date: Fri, 14 Jun 2024 12:45:17 -0400 Subject: [PATCH] feat: add valibot create env --- README.md | 2 +- libs/env-valibot/core.ts | 323 ++++++++++++++++++++++++++++++++++++ libs/env-valibot/nextjs.ts | 68 ++++++++ libs/env-valibot/presets.ts | 56 +++++++ libs/env-valibot/utils.ts | 4 + package.json | 3 +- 6 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 libs/env-valibot/core.ts create mode 100644 libs/env-valibot/nextjs.ts create mode 100644 libs/env-valibot/presets.ts create mode 100644 libs/env-valibot/utils.ts diff --git a/README.md b/README.md index 8af9bdf..4ba71fe 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,4 @@ https://www.npmjs.com/package/@risc0/ui | Statements | Branches | Functions | Lines | | --------------------------- | ----------------------- | ------------------------- | ----------------- | -| ![Statements](https://img.shields.io/badge/statements-44.22%25-red.svg?style=flat) | ![Branches](https://img.shields.io/badge/branches-72.3%25-red.svg?style=flat) | ![Functions](https://img.shields.io/badge/functions-50%25-red.svg?style=flat) | ![Lines](https://img.shields.io/badge/lines-44.22%25-red.svg?style=flat) | +| ![Statements](https://img.shields.io/badge/statements-40.3%25-red.svg?style=flat) | ![Branches](https://img.shields.io/badge/branches-69.11%25-red.svg?style=flat) | ![Functions](https://img.shields.io/badge/functions-45.16%25-red.svg?style=flat) | ![Lines](https://img.shields.io/badge/lines-40.3%25-red.svg?style=flat) | diff --git a/libs/env-valibot/core.ts b/libs/env-valibot/core.ts new file mode 100644 index 0000000..b5b1852 --- /dev/null +++ b/libs/env-valibot/core.ts @@ -0,0 +1,323 @@ +/** + * This is the core package of t3-env. + * It contains the `createEnv` function that you can use to create your schema. + * @module + */ + +import type { InferOutput, ObjectSchema } from "valibot"; +import { flatten as vFlatten, object as vObject, safeParse as vSafeParse } from "valibot"; +import type { AnySchema } from "./utils"; + +/** @internal */ +export type ErrorMessage = T; + +/** @internal */ +export type Simplify = { + [P in keyof T]: T[P]; +} & {}; + +type Impossible> = Partial>; + +type UnReadonlyObject = T extends Readonly ? U : T; + +type Reduce[], TAcc> = TArr extends [] + ? TAcc + : TArr extends [infer Head, ...infer Tail] + ? Tail extends Record[] + ? Head & Reduce + : never + : never; + +/** + * The options that can be passed to the `createEnv` function. + */ +export interface BaseOptions, TExtends extends Record[]> { + /** + * How to determine whether the app is running on the server or the client. + * @default typeof window === "undefined" + */ + isServer?: boolean; + + /** + * Shared variables, often those that are provided by build tools and is available to both client and server, + * but isn't prefixed and doesn't require to be manually supplied. For example `NODE_ENV`, `VERCEL_URL` etc. + */ + shared?: TShared; + + /** + * Extend presets + */ + extends?: TExtends; + + /** + * Called when validation fails. By default the error is logged, + * and an error is thrown telling what environment variables are invalid. + */ + onValidationError?: (error) => never; + + /** + * Called when a server-side environment variable is accessed on the client. + * By default an error is thrown. + */ + onInvalidAccess?: (variable: string) => never; + + /** + * Whether to skip validation of environment variables. + * @default false + */ + skipValidation?: boolean; + + /** + * By default, this library will feed the environment variables directly to + * the Valibot validator. + * + * This means that if you have an empty string for a value that is supposed + * to be a number (e.g. `PORT=` in a ".env" file), Valibot will incorrectly flag + * it as a type mismatch violation. Additionally, if you have an empty string + * for a value that is supposed to be a string with a default value (e.g. + * `DOMAIN=` in an ".env" file), the default value will never be applied. + * + * In order to solve these issues, we recommend that all new projects + * explicitly specify this option as true. + */ + emptyStringAsUndefined?: boolean; +} + +/** + * Using this interface doesn't validate all environment variables are specified + * in the `runtimeEnv` object. You may want to use `StrictOptions` instead if + * your framework performs static analysis and tree-shakes unused variables. + */ +export interface LooseOptions, TExtends extends Record[]> + extends BaseOptions { + runtimeEnvStrict?: never; + + /** + * What object holds the environment variables at runtime. This is usually + * `process.env` or `import.meta.env`. + */ + // Unlike `runtimeEnvStrict`, this doesn't enforce that all environment variables are set. + runtimeEnv: Record; +} + +/** + * Using this interface validates all environment variables are specified + * in the `runtimeEnv` object. If you miss one, you'll get a type error. Useful + * if you want to make sure all environment variables are set for frameworks that + * perform static analysis and tree-shakes unused variables. + */ +export interface StrictOptions< + TPrefix extends string | undefined, + TServer extends Record, + TClient extends Record, + TShared extends Record, + TExtends extends Record[], +> extends BaseOptions { + /** + * Runtime Environment variables to use for validation - `process.env`, `import.meta.env` or similar. + * Enforces all environment variables to be set. Required in for example Next.js Edge and Client runtimes. + */ + runtimeEnvStrict: Record< + | { + [TKey in keyof TClient]: TPrefix extends undefined ? never : TKey extends `${TPrefix}${string}` ? TKey : never; + }[keyof TClient] + | { + [TKey in keyof TServer]: TPrefix extends undefined ? TKey : TKey extends `${TPrefix}${string}` ? never : TKey; + }[keyof TServer] + | { + [TKey in keyof TShared]: TKey extends string ? TKey : never; + }[keyof TShared], + string | boolean | number | undefined + >; + runtimeEnv?: never; +} + +/** + * This interface is used to define the client-side environment variables. + * It's used in conjunction with the `clientPrefix` option to ensure + * that all client-side variables are prefixed with the same string. + * Common examples of prefixes are `NEXT_PUBLIC_`, `NUXT_PUBLIC` or `PUBLIC_`. + */ +export interface ClientOptions> { + /** + * The prefix that client-side variables must have. This is enforced both at + * a type-level and at runtime. + */ + clientPrefix: TPrefix; + + /** + * Specify your client-side environment variables schema here. This way you can ensure the app isn't + * built with invalid env vars. + */ + client: Partial<{ + [TKey in keyof TClient]: TKey extends `${TPrefix}${string}` + ? TClient[TKey] + : ErrorMessage<`${TKey extends string ? TKey : never} is not prefixed with ${TPrefix}.`>; + }>; +} + +/** + * This interface is used to define the schema for your + * server-side environment variables. + */ +export interface ServerOptions> { + /** + * Specify your server-side environment variables schema here. This way you can ensure the app isn't + * built with invalid env vars. + */ + server: Partial<{ + [TKey in keyof TServer]: TPrefix extends undefined + ? TServer[TKey] + : TPrefix extends "" + ? TServer[TKey] + : TKey extends `${TPrefix}${string}` + ? ErrorMessage<`${TKey extends `${TPrefix}${string}` ? TKey : never} should not prefixed with ${TPrefix}.`> + : TServer[TKey]; + }>; +} + +export type ServerClientOptions< + TPrefix extends string | undefined, + TServer extends Record, + TClient extends Record, +> = + | (ClientOptions & ServerOptions) + | (ServerOptions & Impossible>) + | (ClientOptions & Impossible>); + +export type EnvOptions< + TPrefix extends string | undefined, + TServer extends Record, + TClient extends Record, + TShared extends Record, + TExtends extends Record[], +> = + | (LooseOptions & ServerClientOptions) + | (StrictOptions & ServerClientOptions); + +type TPrefixFormat = string | undefined; +type TServerFormat = Record; +type TClientFormat = Record; +type TSharedFormat = Record; +type TExtendsFormat = Record[]; + +/** + * Creates a new environment variable schema. + */ +export type CreateEnv< + TServer extends TServerFormat, + TClient extends TClientFormat, + TShared extends TSharedFormat, + TExtends extends TExtendsFormat, +> = Readonly< + Simplify< + InferOutput> & + InferOutput> & + InferOutput> & + UnReadonlyObject> + > +>; + +/** + * Create a new environment variable schema. + */ +export function createEnv< + TPrefix extends TPrefixFormat, + TServer extends TServerFormat = NonNullable, + TClient extends TClientFormat = NonNullable, + TShared extends TSharedFormat = NonNullable, + const TExtends extends TExtendsFormat = [], +>(opts: EnvOptions): CreateEnv { + const runtimeEnv = opts.runtimeEnvStrict ?? opts.runtimeEnv ?? process.env; + + const emptyStringAsUndefined = opts.emptyStringAsUndefined ?? false; + if (emptyStringAsUndefined) { + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value === "") { + delete runtimeEnv[key]; + } + } + } + + const skip = !!opts.skipValidation; + + if (skip) { + return runtimeEnv as any; + } + + const _client = typeof opts.client === "object" ? opts.client : {}; + const _server = typeof opts.server === "object" ? opts.server : {}; + const _shared = typeof opts.shared === "object" ? opts.shared : {}; + const client = vObject(_client); + const server = vObject(_server); + const shared = vObject(_shared); + const isServer = opts.isServer ?? (typeof window === "undefined" || "Deno" in window); + + const allClient = vObject({ ...client.entries, ...shared.entries }); + const allServer = vObject({ ...server.entries, ...shared.entries, ...client.entries }); + const parsed = isServer + ? vSafeParse(allServer, runtimeEnv) // on server we can validate all env vars + : vSafeParse(allClient, runtimeEnv); // on client we can only validate the ones that are exposed + + const onValidationError = + opts.onValidationError ?? + ((error) => { + console.error("❌ Invalid environment variables:", vFlatten(error).nested); + throw new Error("Invalid environment variables"); + }); + + const onInvalidAccess = + opts.onInvalidAccess ?? + ((_variable: string) => { + throw new Error("❌ Attempted to access a server-side environment variable on the client"); + }); + + if (parsed.success === false) { + return onValidationError(parsed.issues); + } + + const isServerAccess = (prop: string) => { + if (!opts.clientPrefix) { + return true; + } + return !prop.startsWith(opts.clientPrefix) && !(prop in shared.entries); + }; + const isValidServerAccess = (prop: string) => { + return isServer || !isServerAccess(prop); + }; + const ignoreProp = (prop: string) => { + return prop === "__esModule" || prop === "$$typeof"; + }; + + const extendedObj = (opts.extends ?? []).reduce((acc, curr) => { + return Object.assign(acc, curr); + }, {}); + const fullObj = Object.assign(parsed.output, extendedObj); + + const env = new Proxy(fullObj, { + get(target, prop) { + if (typeof prop !== "string") { + return undefined; + } + if (ignoreProp(prop)) { + return undefined; + } + if (!isValidServerAccess(prop)) { + return onInvalidAccess(prop); + } + return Reflect.get(target, prop); + }, + // Maybe reconsider this in the future: + // https://github.com/t3-oss/t3-env/pull/111#issuecomment-1682931526 + // set(_target, prop) { + // // Readonly - this is the error message you get from assigning to a frozen object + // throw new Error( + // typeof prop === "string" + // ? `Cannot assign to read only property ${prop} of object #` + // : `Cannot assign to read only property of object #` + // ); + // }, + }); + + return env as any; +} diff --git a/libs/env-valibot/nextjs.ts b/libs/env-valibot/nextjs.ts new file mode 100644 index 0000000..1fa12c2 --- /dev/null +++ b/libs/env-valibot/nextjs.ts @@ -0,0 +1,68 @@ +import { type CreateEnv, type ServerClientOptions, type StrictOptions, createEnv as createEnvCore } from "./core"; +import type { AnySchema } from "./utils"; + +const CLIENT_PREFIX = "NEXT_PUBLIC_" as const; +type ClientPrefix = typeof CLIENT_PREFIX; + +type Options< + TServer extends Record = NonNullable, + TClient extends Record<`${ClientPrefix}${string}`, AnySchema> = NonNullable, + TShared extends Record = NonNullable, + TExtends extends Record[] = [], +> = Omit< + StrictOptions & + ServerClientOptions, + "runtimeEnvStrict" | "runtimeEnv" | "clientPrefix" +> & + ( + | { + /** + * Manual destruction of `process.env`. Required for Next.js < 13.4.4. + */ + runtimeEnv: StrictOptions["runtimeEnvStrict"]; + experimental__runtimeEnv?: never; + } + | { + runtimeEnv?: never; + /** + * Can be used for Next.js ^13.4.4 since they stopped static analysis of server side `process.env`. + * Only client side `process.env` is statically analyzed and needs to be manually destructured. + */ + experimental__runtimeEnv: Record< + | { + [TKey in keyof TClient]: TKey extends `${ClientPrefix}${string}` ? TKey : never; + }[keyof TClient] + | { + [TKey in keyof TShared]: TKey extends string ? TKey : never; + }[keyof TShared], + string | boolean | number | undefined + >; + } + ); + +export function createNextjsEnv< + TServer extends Record = NonNullable, + TClient extends Record<`${ClientPrefix}${string}`, AnySchema> = NonNullable, + TShared extends Record = NonNullable, + const TExtends extends Record[] = [], +>(opts: Options): CreateEnv { + const client = typeof opts.client === "object" ? opts.client : {}; + const server = typeof opts.server === "object" ? opts.server : {}; + const shared = opts.shared; + + const runtimeEnv = opts.runtimeEnv + ? opts.runtimeEnv + : { + ...process.env, + ...opts.experimental__runtimeEnv, + }; + + return createEnvCore({ + ...opts, + shared, + client, + server, + clientPrefix: CLIENT_PREFIX, + runtimeEnv, + }); +} diff --git a/libs/env-valibot/presets.ts b/libs/env-valibot/presets.ts new file mode 100644 index 0000000..2415db3 --- /dev/null +++ b/libs/env-valibot/presets.ts @@ -0,0 +1,56 @@ +/** + * This contains presets for common environment variables used + * in 3rd party services so you don't have to write them yourself. + * Include them in your `createEnv.extends` option array. + * @module + */ + +import { optional as vOptional, picklist as vPicklist, string as vString } from "valibot"; +import { createEnv } from "./core"; + +/** + * Vercel System Environment Variables + * @see https://vercel.com/docs/projects/environment-variables/system-environment-variables#system-environment-variables + */ +export function vercel(): Readonly<{ + VERCEL?: string; + VERCEL_ENV?: "development" | "preview" | "production"; + VERCEL_URL?: string; + VERCEL_BRANCH_URL?: string; + VERCEL_REGION?: string; + VERCEL_AUTOMATION_BYPASS_SECRET?: string; + VERCEL_GIT_PROVIDER?: string; + VERCEL_GIT_REPO_SLUG?: string; + VERCEL_GIT_REPO_OWNER?: string; + VERCEL_GIT_REPO_ID?: string; + VERCEL_GIT_COMMIT_REF?: string; + VERCEL_GIT_COMMIT_SHA?: string; + VERCEL_GIT_COMMIT_MESSAGE?: string; + VERCEL_GIT_COMMIT_AUTHOR_LOGIN?: string; + VERCEL_GIT_COMMIT_AUTHOR_NAME?: string; + VERCEL_GIT_PREVIOUS_SHA?: string; + VERCEL_GIT_PULL_REQUEST_ID?: string; +}> { + return createEnv({ + server: { + VERCEL: vOptional(vString()), + VERCEL_ENV: vOptional(vPicklist(["development", "preview", "production"])), + VERCEL_URL: vOptional(vString()), + VERCEL_BRANCH_URL: vOptional(vString()), + VERCEL_REGION: vOptional(vString()), + VERCEL_AUTOMATION_BYPASS_SECRET: vOptional(vString()), + VERCEL_GIT_PROVIDER: vOptional(vString()), + VERCEL_GIT_REPO_SLUG: vOptional(vString()), + VERCEL_GIT_REPO_OWNER: vOptional(vString()), + VERCEL_GIT_REPO_ID: vOptional(vString()), + VERCEL_GIT_COMMIT_REF: vOptional(vString()), + VERCEL_GIT_COMMIT_SHA: vOptional(vString()), + VERCEL_GIT_COMMIT_MESSAGE: vOptional(vString()), + VERCEL_GIT_COMMIT_AUTHOR_LOGIN: vOptional(vString()), + VERCEL_GIT_COMMIT_AUTHOR_NAME: vOptional(vString()), + VERCEL_GIT_PREVIOUS_SHA: vOptional(vString()), + VERCEL_GIT_PULL_REQUEST_ID: vOptional(vString()), + }, + runtimeEnv: process.env, + }); +} diff --git a/libs/env-valibot/utils.ts b/libs/env-valibot/utils.ts new file mode 100644 index 0000000..26d1f88 --- /dev/null +++ b/libs/env-valibot/utils.ts @@ -0,0 +1,4 @@ +import type { GenericSchema } from "valibot"; + +/** @internal */ +export type AnySchema = GenericSchema; diff --git a/package.json b/package.json index 5f5d91f..2425934 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@risc0/ui", - "version": "0.0.100", + "version": "0.0.101", "sideEffects": false, "type": "module", "scripts": { @@ -42,6 +42,7 @@ "tailwindcss": "3.4.4", "tailwindcss-animate": "1.0.7", "typescript": "5.6.0-dev.20240614", + "valibot": "0.31.1", "vaul": "0.9.1" }, "devDependencies": {