Skip to content

Commit

Permalink
server/client only environment support (#64)
Browse files Browse the repository at this point in the history
Co-authored-by: juliusmarminge <julius0216@outlook.com>
  • Loading branch information
parnavh and juliusmarminge committed May 29, 2023
1 parent f65a509 commit fd7e659
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 48 deletions.
5 changes: 5 additions & 0 deletions .changeset/wild-toys-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@t3-oss/env-core": minor
---

allow for passing only server or client configuration without needing to fill them with "dummy options"
121 changes: 77 additions & 44 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,12 @@
import { z, type ZodError, type ZodObject, type ZodType } from "zod";
import { z, type ZodError, type ZodObject, type ZodType } from "zod";

export type ErrorMessage<T extends string> = T;
export type Simplify<T> = {
[P in keyof T]: T[P];
// eslint-disable-next-line @typescript-eslint/ban-types
} & {};

export interface BaseOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
> {
/**
* Client-side environment variables are exposed to the client by default. Set what prefix they have
*/
clientPrefix: TPrefix;

/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {
[TKey in keyof TServer]: TKey extends `${TPrefix}${string}`
? ErrorMessage<`${TKey extends `${TPrefix}${string}`
? TKey
: never} should not prefixed with ${TPrefix}.`>
: TServer[TKey];
};

/**
* Specify your client-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
[TKey in keyof TClient]: TKey extends `${TPrefix}${string}`
? TClient[TKey]
: ErrorMessage<`${TKey extends string
? TKey
: never} is not prefixed with ${TPrefix}.`>;
};

export interface BaseOptions {
/**
* How to determine whether the app is running on the server or the client.
* @default typeof window === "undefined"
Expand All @@ -65,11 +32,57 @@ export interface BaseOptions<
skipValidation?: boolean;
}

export interface LooseOptions<
export interface ClientOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
> extends BaseOptions<TPrefix, TServer, TClient> {
> {
/**
* Client-side environment variables are exposed to the client by default. Set what prefix they have
*/
clientPrefix: TPrefix;

/**
* Specify your client-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars. To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*/
client: {
[TKey in keyof TClient]: TKey extends `${TPrefix}${string}`
? TClient[TKey]
: ErrorMessage<`${TKey extends string
? TKey
: never} is not prefixed with ${TPrefix}.`>;
};
}

export interface WithoutClientOptions {
clientPrefix?: never;
client?: never;
}

export interface ServerOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>
> {
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {
[TKey in keyof TServer]: TPrefix extends ""
? TServer[TKey]
: TKey extends `${TPrefix}${string}`
? ErrorMessage<`${TKey extends `${TPrefix}${string}`
? TKey
: never} should not prefixed with ${TPrefix}.`>
: TServer[TKey];
};
}

export interface WithoutServerOptions {
server?: never;
}

export interface LooseOptions extends BaseOptions {
runtimeEnvStrict?: never;
/**
* Runtime Environment variables to use for validation - `process.env`, `import.meta.env` or similar.
Expand All @@ -82,7 +95,7 @@ export interface StrictOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
> extends BaseOptions<TPrefix, TServer, TClient> {
> 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.
Expand All @@ -103,14 +116,30 @@ export interface StrictOptions<
runtimeEnv?: never;
}

export function createEnv<
export type ServerClientOptions<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
> =
| (ClientOptions<TPrefix, TClient> & ServerOptions<TPrefix, TServer>)
| (WithoutClientOptions & ServerOptions<TPrefix, TServer>)
| (ClientOptions<TPrefix, TClient> & WithoutServerOptions);

export type createEnvParams<
TPrefix extends string,
TServer extends Record<string, ZodType>,
TClient extends Record<string, ZodType>
> =
| (LooseOptions & ServerClientOptions<TPrefix, TServer, TClient>)
| (StrictOptions<TPrefix, TServer, TClient> &
ServerClientOptions<TPrefix, TServer, TClient>);

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

Expand Down Expand Up @@ -154,7 +183,11 @@ export function createEnv<
const env = new Proxy(parsed.data, {
get(target, prop) {
if (typeof prop !== "string") return undefined;
if (!isServer && !prop.startsWith(opts.clientPrefix)) {
if (
!isServer &&
opts.clientPrefix &&
!prop.startsWith(opts.clientPrefix)
) {
return onInvalidAccess(prop);
}
return target[prop as keyof typeof target];
Expand Down
49 changes: 49 additions & 0 deletions packages/core/test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,52 @@ describe("errors when server var is accessed on client", () => {
);
});
});

describe("client/server only mode", () => {
test("client only", () => {
const env = createEnv({
clientPrefix: "FOO_",
client: {
FOO_BAR: z.string(),
},
runtimeEnv: { FOO_BAR: "foo" },
});

expectTypeOf(env).toEqualTypeOf<{ FOO_BAR: string }>();
expect(env).toMatchObject({ FOO_BAR: "foo" });
});

test("server only", () => {
const env = createEnv({
server: {
BAR: z.string(),
},
runtimeEnv: { BAR: "bar" },
});

expectTypeOf(env).toEqualTypeOf<{ BAR: string }>();
expect(env).toMatchObject({ BAR: "bar" });
});

test("config with missing client", () => {
ignoreErrors(() => {
createEnv({
// @ts-expect-error - incomplete client config - client not present
clientPrefix: "FOO_",
server: {},
runtimeEnv: {},
});
});
});

test("config with missing clientPrefix", () => {
ignoreErrors(() => {
// @ts-expect-error - incomplete client config - clientPrefix not present
createEnv({
client: {},
server: {},
runtimeEnv: {},
});
});
});
});
32 changes: 30 additions & 2 deletions packages/nextjs/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { type ZodType } from "zod";

import { createEnv as createEnvCore, type StrictOptions } from "../core";
import {
createEnv as createEnvCore,
ServerClientOptions,
type StrictOptions,
} from "../core";

const CLIENT_PREFIX = "NEXT_PUBLIC_" as const;
type ClientPrefix = typeof CLIENT_PREFIX;
Expand All @@ -9,7 +13,8 @@ interface Options<
TServer extends Record<string, ZodType>,
TClient extends Record<`${ClientPrefix}${string}`, ZodType>
> extends Omit<
StrictOptions<ClientPrefix, TServer, TClient>,
StrictOptions<ClientPrefix, TServer, TClient> &
ServerClientOptions<ClientPrefix, TServer, TClient>,
"runtimeEnvStrict" | "runtimeEnv" | "clientPrefix"
> {
/**
Expand All @@ -25,8 +30,31 @@ export function createEnv<
ZodType
> = NonNullable<unknown>
>({ runtimeEnv, ...opts }: Options<TServer, TClient>) {
const client =
typeof opts.client === "object"
? opts.client
: ({} as {
[TKey in keyof TClient]: TKey extends `NEXT_PUBLIC_${string}`
? TClient[TKey]
: `${TKey extends string
? TKey
: never} is not prefixed with NEXT_PUBLIC_.`;
});
const server =
typeof opts.server === "object"
? opts.server
: ({} as {
[TKey in keyof TServer]: TKey extends `NEXT_PUBLIC_${string}`
? `${TKey extends `NEXT_PUBLIC_${string}`
? TKey
: never} should not prefixed with NEXT_PUBLIC_.`
: TServer[TKey];
});

return createEnvCore<ClientPrefix, TServer, TClient>({
...opts,
client,
server,
clientPrefix: CLIENT_PREFIX,
runtimeEnvStrict: runtimeEnv,
});
Expand Down
30 changes: 30 additions & 0 deletions packages/nextjs/test/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,33 @@ describe("return type is correctly inferred", () => {
});
});
});

test("can specify only server", () => {
const onlyServer = createEnv({
server: { BAR: z.string() },
runtimeEnv: { BAR: "FOO" },
});

expectTypeOf(onlyServer).toMatchTypeOf<{
BAR: string;
}>();

expect(onlyServer).toMatchObject({
BAR: "FOO",
});
});

test("can specify only client", () => {
const onlyClient = createEnv({
client: { NEXT_PUBLIC_BAR: z.string() },
runtimeEnv: { NEXT_PUBLIC_BAR: "FOO" },
});

expectTypeOf(onlyClient).toMatchTypeOf<{
NEXT_PUBLIC_BAR: string;
}>();

expect(onlyClient).toMatchObject({
NEXT_PUBLIC_BAR: "FOO",
});
});
32 changes: 30 additions & 2 deletions packages/nuxt/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { ZodType } from "zod";
import { createEnv as createEnvCore, StrictOptions } from "../core";
import {
createEnv as createEnvCore,
ServerClientOptions,
StrictOptions,
} from "../core";

const CLIENT_PREFIX = "NUXT_PUBLIC_" as const;
type ClientPrefix = typeof CLIENT_PREFIX;
Expand All @@ -8,16 +12,40 @@ type Options<
TServer extends Record<string, ZodType>,
TClient extends Record<`${ClientPrefix}${string}`, ZodType>
> = Omit<
StrictOptions<ClientPrefix, TServer, TClient>,
StrictOptions<ClientPrefix, TServer, TClient> &
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>) {
const client =
typeof opts.client === "object"
? opts.client
: ({} as {
[TKey in keyof TClient]: TKey extends `NUXT_PUBLIC_${string}`
? TClient[TKey]
: `${TKey extends string
? TKey
: never} is not prefixed with NUXT_PUBLIC_.`;
});
const server =
typeof opts.server === "object"
? opts.server
: ({} as {
[TKey in keyof TServer]: TKey extends `NUXT_PUBLIC_${string}`
? `${TKey extends `NUXT_PUBLIC_${string}`
? TKey
: never} should not prefixed with NUXT_PUBLIC_.`
: TServer[TKey];
});

return createEnvCore<ClientPrefix, TServer, TClient>({
...opts,
client,
server,
clientPrefix: CLIENT_PREFIX,
runtimeEnv: process.env,
});
Expand Down

1 comment on commit fd7e659

@vercel
Copy link

@vercel vercel bot commented on fd7e659 May 29, 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 – ./

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

Please sign in to comment.