Skip to content

Commit

Permalink
feat: add valibot create env
Browse files Browse the repository at this point in the history
  • Loading branch information
nahoc committed Jun 14, 2024
1 parent 8e6515d commit 9720871
Show file tree
Hide file tree
Showing 6 changed files with 454 additions and 2 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
323 changes: 323 additions & 0 deletions libs/env-valibot/core.ts
Original file line number Diff line number Diff line change
@@ -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 extends string> = T;

/** @internal */
export type Simplify<T> = {
[P in keyof T]: T[P];
} & {};

type Impossible<T extends Record<string, any>> = Partial<Record<keyof T, never>>;

type UnReadonlyObject<T> = T extends Readonly<infer U> ? U : T;

type Reduce<TArr extends Record<string, unknown>[], TAcc> = TArr extends []
? TAcc
: TArr extends [infer Head, ...infer Tail]
? Tail extends Record<string, unknown>[]
? Head & Reduce<Tail, TAcc>
: never
: never;

/**
* The options that can be passed to the `createEnv` function.
*/
export interface BaseOptions<TShared extends Record<string, AnySchema>, TExtends extends Record<string, unknown>[]> {
/**
* 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<TShared extends Record<string, AnySchema>, TExtends extends Record<string, unknown>[]>
extends BaseOptions<TShared, TExtends> {
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<string, string | boolean | number | undefined>;
}

/**
* 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<string, AnySchema>,
TClient extends Record<string, AnySchema>,
TShared extends Record<string, AnySchema>,
TExtends extends Record<string, unknown>[],
> extends BaseOptions<TShared, TExtends> {
/**
* 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<TPrefix extends string | undefined, TClient extends Record<string, AnySchema>> {
/**
* 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<TPrefix extends string | undefined, TServer extends Record<string, AnySchema>> {
/**
* 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<string, AnySchema>,
TClient extends Record<string, AnySchema>,
> =
| (ClientOptions<TPrefix, TClient> & ServerOptions<TPrefix, TServer>)
| (ServerOptions<TPrefix, TServer> & Impossible<ClientOptions<never, never>>)
| (ClientOptions<TPrefix, TClient> & Impossible<ServerOptions<never, never>>);

export type EnvOptions<
TPrefix extends string | undefined,
TServer extends Record<string, AnySchema>,
TClient extends Record<string, AnySchema>,
TShared extends Record<string, AnySchema>,
TExtends extends Record<string, unknown>[],
> =
| (LooseOptions<TShared, TExtends> & ServerClientOptions<TPrefix, TServer, TClient>)
| (StrictOptions<TPrefix, TServer, TClient, TShared, TExtends> & ServerClientOptions<TPrefix, TServer, TClient>);

type TPrefixFormat = string | undefined;
type TServerFormat = Record<string, AnySchema>;
type TClientFormat = Record<string, AnySchema>;
type TSharedFormat = Record<string, AnySchema>;
type TExtendsFormat = Record<string, unknown>[];

/**
* Creates a new environment variable schema.
*/
export type CreateEnv<
TServer extends TServerFormat,
TClient extends TClientFormat,
TShared extends TSharedFormat,
TExtends extends TExtendsFormat,
> = Readonly<
Simplify<
InferOutput<ObjectSchema<TServer, "">> &
InferOutput<ObjectSchema<TClient, "">> &
InferOutput<ObjectSchema<TShared, "">> &
UnReadonlyObject<Reduce<TExtends, any>>
>
>;

/**
* Create a new environment variable schema.
*/
export function createEnv<
TPrefix extends TPrefixFormat,
TServer extends TServerFormat = NonNullable<unknown>,
TClient extends TClientFormat = NonNullable<unknown>,
TShared extends TSharedFormat = NonNullable<unknown>,
const TExtends extends TExtendsFormat = [],
>(opts: EnvOptions<TPrefix, TServer, TClient, TShared, TExtends>): CreateEnv<TServer, TClient, TShared, TExtends> {
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 #<Object>`
// : `Cannot assign to read only property of object #<Object>`
// );
// },
});

return env as any;
}
68 changes: 68 additions & 0 deletions libs/env-valibot/nextjs.ts
Original file line number Diff line number Diff line change
@@ -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<string, AnySchema> = NonNullable<unknown>,
TClient extends Record<`${ClientPrefix}${string}`, AnySchema> = NonNullable<unknown>,
TShared extends Record<string, AnySchema> = NonNullable<unknown>,
TExtends extends Record<string, unknown>[] = [],
> = Omit<
StrictOptions<ClientPrefix, TServer, TClient, TShared, TExtends> &
ServerClientOptions<ClientPrefix, TServer, TClient>,
"runtimeEnvStrict" | "runtimeEnv" | "clientPrefix"
> &
(
| {
/**
* Manual destruction of `process.env`. Required for Next.js < 13.4.4.
*/
runtimeEnv: StrictOptions<ClientPrefix, TServer, TClient, TShared, TExtends>["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<string, AnySchema> = NonNullable<unknown>,
TClient extends Record<`${ClientPrefix}${string}`, AnySchema> = NonNullable<unknown>,
TShared extends Record<string, AnySchema> = NonNullable<unknown>,
const TExtends extends Record<string, unknown>[] = [],
>(opts: Options<TServer, TClient, TShared, TExtends>): CreateEnv<TServer, TClient, TShared, TExtends> {
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<ClientPrefix, TServer, TClient, TShared, TExtends>({
...opts,
shared,
client,
server,
clientPrefix: CLIENT_PREFIX,
runtimeEnv,
});
}
Loading

0 comments on commit 9720871

Please sign in to comment.