-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
454 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} |
Oops, something went wrong.