From fd7e659fd31e5bbb07cbfc5bf1077d7177ee8ef3 Mon Sep 17 00:00:00 2001 From: Parnav <45985534+parnavh@users.noreply.github.com> Date: Tue, 30 May 2023 01:12:11 +0530 Subject: [PATCH] server/client only environment support (#64) Co-authored-by: juliusmarminge --- .changeset/wild-toys-impress.md | 5 ++ packages/core/index.ts | 121 ++++++++++++++++++----------- packages/core/test/smoke.test.ts | 49 ++++++++++++ packages/nextjs/index.ts | 32 +++++++- packages/nextjs/test/smoke.test.ts | 30 +++++++ packages/nuxt/index.ts | 32 +++++++- 6 files changed, 221 insertions(+), 48 deletions(-) create mode 100644 .changeset/wild-toys-impress.md diff --git a/.changeset/wild-toys-impress.md b/.changeset/wild-toys-impress.md new file mode 100644 index 00000000..9e7f440b --- /dev/null +++ b/.changeset/wild-toys-impress.md @@ -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" diff --git a/packages/core/index.ts b/packages/core/index.ts index 22af49f0..7fa85c5f 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -1,4 +1,4 @@ -import { z, type ZodError, type ZodObject, type ZodType } from "zod"; +import { z, type ZodError, type ZodObject, type ZodType } from "zod"; export type ErrorMessage = T; export type Simplify = { @@ -6,40 +6,7 @@ export type Simplify = { // eslint-disable-next-line @typescript-eslint/ban-types } & {}; -export interface BaseOptions< - TPrefix extends string, - TServer extends Record, - TClient extends Record -> { - /** - * 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" @@ -65,11 +32,57 @@ export interface BaseOptions< skipValidation?: boolean; } -export interface LooseOptions< +export interface ClientOptions< TPrefix extends string, - TServer extends Record, TClient extends Record -> extends BaseOptions { +> { + /** + * 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 +> { + /** + * 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. @@ -82,7 +95,7 @@ export interface StrictOptions< TPrefix extends string, TServer extends Record, TClient extends Record -> extends BaseOptions { +> 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. @@ -103,14 +116,30 @@ export interface StrictOptions< runtimeEnv?: never; } -export function createEnv< +export type ServerClientOptions< + TPrefix extends string, + TServer extends Record, + TClient extends Record +> = + | (ClientOptions & ServerOptions) + | (WithoutClientOptions & ServerOptions) + | (ClientOptions & WithoutServerOptions); + +export type createEnvParams< TPrefix extends string, + TServer extends Record, + TClient extends Record +> = + | (LooseOptions & ServerClientOptions) + | (StrictOptions & + ServerClientOptions); + +export function createEnv< + TPrefix extends string = "", TServer extends Record = NonNullable, TClient extends Record = NonNullable >( - opts: - | LooseOptions - | StrictOptions + opts: createEnvParams ): Simplify> & z.infer>> { const runtimeEnv = opts.runtimeEnvStrict ?? opts.runtimeEnv ?? process.env; @@ -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]; diff --git a/packages/core/test/smoke.test.ts b/packages/core/test/smoke.test.ts index 10e833d8..01c0060b 100644 --- a/packages/core/test/smoke.test.ts +++ b/packages/core/test/smoke.test.ts @@ -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: {}, + }); + }); + }); +}); diff --git a/packages/nextjs/index.ts b/packages/nextjs/index.ts index 8c81d13c..0748ec05 100644 --- a/packages/nextjs/index.ts +++ b/packages/nextjs/index.ts @@ -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; @@ -9,7 +13,8 @@ interface Options< TServer extends Record, TClient extends Record<`${ClientPrefix}${string}`, ZodType> > extends Omit< - StrictOptions, + StrictOptions & + ServerClientOptions, "runtimeEnvStrict" | "runtimeEnv" | "clientPrefix" > { /** @@ -25,8 +30,31 @@ export function createEnv< ZodType > = NonNullable >({ runtimeEnv, ...opts }: Options) { + 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({ ...opts, + client, + server, clientPrefix: CLIENT_PREFIX, runtimeEnvStrict: runtimeEnv, }); diff --git a/packages/nextjs/test/smoke.test.ts b/packages/nextjs/test/smoke.test.ts index 0157ab57..083602ee 100644 --- a/packages/nextjs/test/smoke.test.ts +++ b/packages/nextjs/test/smoke.test.ts @@ -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", + }); +}); diff --git a/packages/nuxt/index.ts b/packages/nuxt/index.ts index 65baa48d..50cc8178 100644 --- a/packages/nuxt/index.ts +++ b/packages/nuxt/index.ts @@ -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; @@ -8,7 +12,8 @@ type Options< TServer extends Record, TClient extends Record<`${ClientPrefix}${string}`, ZodType> > = Omit< - StrictOptions, + StrictOptions & + ServerClientOptions, "runtimeEnvStrict" | "runtimeEnv" | "clientPrefix" >; @@ -16,8 +21,31 @@ export function createEnv< TServer extends Record = NonNullable, TClient extends Record = NonNullable >(opts: Options) { + 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({ ...opts, + client, + server, clientPrefix: CLIENT_PREFIX, runtimeEnv: process.env, });