Skip to content

Commit

Permalink
feat: add a shared option for NODE_ENV etc (#90)
Browse files Browse the repository at this point in the history
  • Loading branch information
juliusmarminge authored Jun 25, 2023
1 parent fb7f591 commit 1f6de0f
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 36 deletions.
9 changes: 9 additions & 0 deletions .changeset/fluffy-shirts-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@t3-oss/env-nextjs": minor
"@t3-oss/env-core": minor
"@t3-oss/env-nuxt": minor
---

feat: add `shared` section for shared variables

shared variables are variables that are available in all runtimes despite them not being prefixed by anything, and is not manually supplied by the user.
10 changes: 6 additions & 4 deletions examples/nextjs/app/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
client: {
NEXT_PUBLIC_GREETING: z.string(),
},
server: {
NODE_ENV: z.enum(["development", "production"]).optional(),
SECRET: z.string(),
},
client: {
NEXT_PUBLIC_GREETING: z.string(),
shared: {
NODE_ENV: z.enum(["development", "production"]),
},

experimental__runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_GREETING: process.env.NEXT_PUBLIC_GREETING,
},
});
51 changes: 36 additions & 15 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ type Impossible<T extends Record<string, any>> = Partial<
Record<keyof T, never>
>;

export interface BaseOptions {
export interface BaseOptions<TShared extends Record<string, ZodType>> {
/**
* 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;

/**
* Called when validation fails. By default the error is logged,
* and an error is thrown telling what environment variables are invalid.
Expand All @@ -37,7 +43,8 @@ export interface BaseOptions {
skipValidation?: boolean;
}

export interface LooseOptions extends BaseOptions {
export interface LooseOptions<TShared extends Record<string, ZodType>>
extends BaseOptions<TShared> {
runtimeEnvStrict?: never;
/**
* Runtime Environment variables to use for validation - `process.env`, `import.meta.env` or similar.
Expand All @@ -49,8 +56,9 @@ export interface LooseOptions extends BaseOptions {
export interface StrictOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
> extends BaseOptions {
TClient extends Record<string, ZodType>,
TShared extends Record<string, ZodType>
> extends BaseOptions<TShared> {
/**
* 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.
Expand All @@ -65,7 +73,10 @@ export interface StrictOptions<
[TKey in keyof TServer]: TKey extends `${TPrefix}${string}`
? never
: TKey;
}[keyof TServer],
}[keyof TServer]
| {
[TKey in keyof TShared]: TKey extends string ? TKey : never;
}[keyof TShared],
string | boolean | number | undefined
>;
runtimeEnv?: never;
Expand Down Expand Up @@ -124,19 +135,25 @@ export type ServerClientOptions<
export type EnvOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
TClient extends Record<string, ZodType>,
TShared extends Record<string, ZodType>
> =
| (LooseOptions & ServerClientOptions<TPrefix, TServer, TClient>)
| (StrictOptions<TPrefix, TServer, TClient> &
| (LooseOptions<TShared> & ServerClientOptions<TPrefix, TServer, TClient>)
| (StrictOptions<TPrefix, TServer, TClient, TShared> &
ServerClientOptions<TPrefix, TServer, TClient>);

export function createEnv<
TPrefix extends string = "",
TServer extends Record<string, ZodType> = NonNullable<unknown>,
TClient extends Record<string, ZodType> = NonNullable<unknown>
TClient extends Record<string, ZodType> = NonNullable<unknown>,
TShared extends Record<string, ZodType> = NonNullable<unknown>
>(
opts: EnvOptions<TPrefix, TServer, TClient>
): Simplify<z.infer<ZodObject<TServer>> & z.infer<ZodObject<TClient>>> {
opts: EnvOptions<TPrefix, TServer, TClient, TShared>
): Simplify<
z.infer<ZodObject<TServer>> &
z.infer<ZodObject<TClient>> &
z.infer<ZodObject<TShared>>
> {
const runtimeEnv = opts.runtimeEnvStrict ?? opts.runtimeEnv ?? process.env;

const skip = !!opts.skipValidation;
Expand All @@ -145,14 +162,17 @@ export function createEnv<

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 = z.object(_client);
const server = z.object(_server);
const shared = z.object(_shared);
const isServer = opts.isServer ?? typeof window === "undefined";

const merged = server.merge(client);
const allClient = client.merge(shared);
const allServer = server.merge(shared).merge(client);
const parsed = isServer
? merged.safeParse(runtimeEnv) // on server we can validate all env vars
: client.safeParse(runtimeEnv); // on client we can only validate the ones that are exposed
? allServer.safeParse(runtimeEnv) // on server we can validate all env vars
: allClient.safeParse(runtimeEnv); // on client we can only validate the ones that are exposed

const onValidationError =
opts.onValidationError ??
Expand Down Expand Up @@ -182,7 +202,8 @@ export function createEnv<
if (
!isServer &&
opts.clientPrefix &&
!prop.startsWith(opts.clientPrefix)
!prop.startsWith(opts.clientPrefix) &&
shared.shape[prop as keyof typeof shared.shape] === undefined
) {
return onInvalidAccess(prop);
}
Expand Down
51 changes: 51 additions & 0 deletions packages/core/test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,54 @@ describe("client/server only mode", () => {
});
});
});

describe("shared can be accessed on both server and client", () => {
process.env = {
NODE_ENV: "development",
BAR: "bar",
FOO_BAR: "foo",
};

const env = createEnv({
shared: {
NODE_ENV: z.enum(["development", "production", "test"]),
},
clientPrefix: "FOO_",
server: { BAR: z.string() },
client: { FOO_BAR: z.string() },
runtimeEnv: process.env,
});

expectTypeOf(env).toEqualTypeOf<{
NODE_ENV: "development" | "production" | "test";
BAR: string;
FOO_BAR: string;
}>();

test("server", () => {
const { window } = globalThis;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
globalThis.window = undefined as any;

expect(env).toMatchObject({
NODE_ENV: "development",
BAR: "bar",
FOO_BAR: "foo",
});

globalThis.window = window;
});

test("client", () => {
const { window } = globalThis;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
globalThis.window = {} as any;

expect(env).toMatchObject({
NODE_ENV: "development",
FOO_BAR: "foo",
});

globalThis.window = window;
});
});
33 changes: 21 additions & 12 deletions packages/nextjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ type ClientPrefix = typeof CLIENT_PREFIX;

type Options<
TServer extends Record<string, ZodType>,
TClient extends Record<`${ClientPrefix}${string}`, ZodType>
TClient extends Record<`${ClientPrefix}${string}`, ZodType>,
TShared extends Record<string, ZodType>
> = Omit<
StrictOptions<ClientPrefix, TServer, TClient> &
StrictOptions<ClientPrefix, TServer, TClient, TShared> &
ServerClientOptions<ClientPrefix, TServer, TClient>,
"runtimeEnvStrict" | "runtimeEnv" | "clientPrefix"
> &
Expand All @@ -25,7 +26,8 @@ type Options<
runtimeEnv: StrictOptions<
ClientPrefix,
TServer,
TClient
TClient,
TShared
>["runtimeEnvStrict"];
experimental__runtimeEnv?: never;
}
Expand All @@ -36,11 +38,14 @@ type Options<
* 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 TClient]: TKey extends `${ClientPrefix}${string}`
? TKey
: never;
}[keyof TClient]
| {
[TKey in keyof TShared]: TKey extends string ? TKey : never;
}[keyof TShared],
string | boolean | number | undefined
>;
}
Expand All @@ -51,21 +56,25 @@ export function createEnv<
TClient extends Record<
`${ClientPrefix}${string}`,
ZodType
> = NonNullable<unknown>
>(opts: Options<TServer, TClient>) {
> = NonNullable<unknown>,
TShared extends Record<string, ZodType> = NonNullable<unknown>
>(opts: Options<TServer, TClient, TShared>) {
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 runtimeEnv = opts.runtimeEnv
? opts.runtimeEnv
: {
...process.env,
...opts.experimental__runtimeEnv,
NODE_ENV: process.env.NODE_ENV,
};

return createEnvCore<ClientPrefix, TServer, TClient>({
return createEnvCore<ClientPrefix, TServer, TClient, TShared>({
...opts,
// FIXME: don't require this `as any` cast
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
shared: shared as any,
client,
server,
clientPrefix: CLIENT_PREFIX,
Expand Down
7 changes: 7 additions & 0 deletions packages/nextjs/test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,31 @@ test("new experimental runtime option only requires client vars", () => {
process.env = {
BAR: "bar",
NEXT_PUBLIC_BAR: "foo",
NODE_ENV: "development",
};

const env = createEnv({
shared: {
NODE_ENV: z.enum(["development", "production"]),
},
server: { BAR: z.string() },
client: { NEXT_PUBLIC_BAR: z.string() },
experimental__runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BAR: process.env.NEXT_PUBLIC_BAR,
},
});

expectTypeOf(env).toEqualTypeOf<{
BAR: string;
NEXT_PUBLIC_BAR: string;
NODE_ENV: "development" | "production";
}>();

expect(env).toMatchObject({
BAR: "bar",
NEXT_PUBLIC_BAR: "foo",
NODE_ENV: "development",
});
});

Expand Down
16 changes: 11 additions & 5 deletions packages/nuxt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,28 @@ type ClientPrefix = typeof CLIENT_PREFIX;

type Options<
TServer extends Record<string, ZodType>,
TClient extends Record<`${ClientPrefix}${string}`, ZodType>
TClient extends Record<`${ClientPrefix}${string}`, ZodType>,
TShared extends Record<string, ZodType>
> = Omit<
StrictOptions<ClientPrefix, TServer, TClient> &
StrictOptions<ClientPrefix, TServer, TClient, TShared> &
ServerClientOptions<ClientPrefix, TServer, TClient>,
"runtimeEnvStrict" | "runtimeEnv" | "clientPrefix"
>;

export function createEnv<
TServer extends Record<string, ZodType> = NonNullable<unknown>,
TClient extends Record<string, ZodType> = NonNullable<unknown>
>(opts: Options<TServer, TClient>) {
TClient extends Record<string, ZodType> = NonNullable<unknown>,
TShared extends Record<string, ZodType> = NonNullable<unknown>
>(opts: Options<TServer, TClient, TShared>) {
const client = typeof opts.client === "object" ? opts.client : {};
const server = typeof opts.server === "object" ? opts.server : {};
const shared = typeof opts.shared === "object" ? opts.shared : {};

return createEnvCore<ClientPrefix, TServer, TClient>({
return createEnvCore<ClientPrefix, TServer, TClient, TShared>({
...opts,
// FIXME: don't require this `as any` cast
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
shared: shared as any,
client,
server,
clientPrefix: CLIENT_PREFIX,
Expand Down

2 comments on commit 1f6de0f

@vercel
Copy link

@vercel vercel bot commented on 1f6de0f Jun 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

t3-env – ./docs

env.t3.gg
t3-env-git-main-t3-oss.vercel.app
t3-env.vercel.app
t3-env-t3-oss.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 1f6de0f Jun 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

t3-env-nextjs – ./examples/nextjs

t3-env-nextjs.vercel.app
t3-env-nextjs-git-main-t3-oss.vercel.app
t3-env-nextjs-t3-oss.vercel.app

Please sign in to comment.