From 73f547a6dec52562fcf6967675f8ed3e54d2cfc5 Mon Sep 17 00:00:00 2001 From: Jiwon Choi Date: Thu, 21 Nov 2024 20:47:08 +0900 Subject: [PATCH] Display where the env was loaded from when enabled `typedEnv` (#70951) ### Why? This PR added an indication of where the env was loaded from when `experimental.typedEnv` was enabled. Also, it allows the user to set `NODE_ENV=production` to enable the `typedEnv` feature for `.env.production*` files. ![CleanShot 2024-11-21 at 19 48 20](https://github.com/user-attachments/assets/ce3c7180-f26a-4378-a74f-a5998a363211) ### How? Modified `@next/env` to pass parsed envs along with the `loadedEnvFiles` value and used the location to indicate via JSDoc. --- .../05-config/02-typescript.mdx | 2 +- packages/next-env/index.ts | 5 + .../create-env-definitions.test.ts | 81 +++++++++++++-- .../experimental/create-env-definitions.ts | 24 +++-- .../lib/router-utils/setup-dev-bundler.ts | 13 ++- .../app-dir/typed-env/.env.development | 1 + .../app-dir/typed-env/.env.development.local | 2 +- .../app-dir/typed-env/.env.production | 1 + .../app-dir/typed-env/.env.production.local | 2 +- .../app-dir/typed-env/typed-env-prod.test.ts | 44 +++++++++ .../app-dir/typed-env/typed-env.test.ts | 99 ++++++++++--------- 11 files changed, 207 insertions(+), 67 deletions(-) create mode 100644 test/development/app-dir/typed-env/.env.development create mode 100644 test/development/app-dir/typed-env/.env.production create mode 100644 test/development/app-dir/typed-env/typed-env-prod.test.ts diff --git a/docs/01-app/03-api-reference/05-config/02-typescript.mdx b/docs/01-app/03-api-reference/05-config/02-typescript.mdx index d38fa56224de4..8e31555c798ec 100644 --- a/docs/01-app/03-api-reference/05-config/02-typescript.mdx +++ b/docs/01-app/03-api-reference/05-config/02-typescript.mdx @@ -160,7 +160,7 @@ function Card({ href }: { href: Route | URL }) { > > When running `next dev` or `next build`, Next.js generates a hidden `.d.ts` file inside `.next` that contains information about all existing routes in your application (all valid routes as the `href` type of `Link`). This `.d.ts` file is included in `tsconfig.json` and the TypeScript compiler will check that `.d.ts` and provide feedback in your editor about invalid links. -### With Async Server Componens +### With Async Server Components To use an `async` Server Component with TypeScript, ensure you are using TypeScript `5.1.3` or higher and `@types/react` `18.2.8` or higher. diff --git a/packages/next-env/index.ts b/packages/next-env/index.ts index d302869900379..392670c7817e2 100644 --- a/packages/next-env/index.ts +++ b/packages/next-env/index.ts @@ -8,6 +8,7 @@ export type Env = { [key: string]: string | undefined } export type LoadedEnvFiles = Array<{ path: string contents: string + env: Env }> export let initialEnv: Env | undefined = undefined @@ -91,6 +92,9 @@ export function processEnv( parsed[key] = result.parsed?.[key]! } } + + // Add the parsed env to the loadedEnvFiles + envFile.env = result.parsed || {} } catch (err) { log.error( `Failed to load env from ${path.join(dir || '', envFile.path)}`, @@ -157,6 +161,7 @@ export function loadEnvConfig( cachedLoadedEnvFiles.push({ path: envFile, contents, + env: {}, // This will be populated in processEnv }) } catch (err: any) { if (err.code !== 'ENOENT') { diff --git a/packages/next/src/server/lib/experimental/create-env-definitions.test.ts b/packages/next/src/server/lib/experimental/create-env-definitions.test.ts index 816d0fd79f64d..4387c7c22911c 100644 --- a/packages/next/src/server/lib/experimental/create-env-definitions.test.ts +++ b/packages/next/src/server/lib/experimental/create-env-definitions.test.ts @@ -2,24 +2,43 @@ import { createEnvDefinitions } from './create-env-definitions' describe('create-env-definitions', () => { it('should create env definitions', async () => { - const env = { - FROM_DEV_ENV_LOCAL: 'FROM_DEV_ENV_LOCAL', - FROM_ENV_LOCAL: 'FROM_ENV_LOCAL', - FROM_ENV: 'FROM_ENV', - FROM_NEXT_CONFIG: 'FROM_NEXT_CONFIG', - } + const loadedEnvFiles = [ + { + path: '.env.local', + contents: '', + env: { + FROM_ENV_LOCAL: 'FROM_ENV_LOCAL', + }, + }, + { + path: '.env.development.local', + contents: '', + env: { + FROM_ENV_DEV_LOCAL: 'FROM_ENV_DEV_LOCAL', + }, + }, + { + path: 'next.config.js', + contents: '', + env: { + FROM_NEXT_CONFIG: 'FROM_NEXT_CONFIG', + }, + }, + ] const definitionStr = await createEnvDefinitions({ distDir: '/dist', - env, + loadedEnvFiles, }) expect(definitionStr).toMatchInlineSnapshot(` "// Type definitions for Next.js environment variables declare global { namespace NodeJS { interface ProcessEnv { - FROM_DEV_ENV_LOCAL?: string + /** Loaded from \`.env.local\` */ FROM_ENV_LOCAL?: string - FROM_ENV?: string + /** Loaded from \`.env.development.local\` */ + FROM_ENV_DEV_LOCAL?: string + /** Loaded from \`next.config.js\` */ FROM_NEXT_CONFIG?: string } } @@ -31,7 +50,7 @@ describe('create-env-definitions', () => { it('should allow empty env', async () => { const definitionStr = await createEnvDefinitions({ distDir: '/dist', - env: {}, + loadedEnvFiles: [], }) expect(definitionStr).toMatchInlineSnapshot(` "// Type definitions for Next.js environment variables @@ -45,4 +64,46 @@ describe('create-env-definitions', () => { export {}" `) }) + + it('should dedupe env definitions in order of priority', async () => { + const loadedEnvFiles = [ + { + path: '.env.local', + contents: '', + env: { + DUPLICATE_ENV: 'DUPLICATE_ENV', + }, + }, + { + path: '.env.development.local', + contents: '', + env: { + DUPLICATE_ENV: 'DUPLICATE_ENV', + }, + }, + { + path: 'next.config.js', + contents: '', + env: { + DUPLICATE_ENV: 'DUPLICATE_ENV', + }, + }, + ] + const definitionStr = await createEnvDefinitions({ + distDir: '/dist', + loadedEnvFiles, + }) + expect(definitionStr).toMatchInlineSnapshot(` + "// Type definitions for Next.js environment variables + declare global { + namespace NodeJS { + interface ProcessEnv { + /** Loaded from \`.env.local\` */ + DUPLICATE_ENV?: string + } + } + } + export {}" + `) + }) }) diff --git a/packages/next/src/server/lib/experimental/create-env-definitions.ts b/packages/next/src/server/lib/experimental/create-env-definitions.ts index ccdfbbe9c1f11..ab1b7791f030a 100644 --- a/packages/next/src/server/lib/experimental/create-env-definitions.ts +++ b/packages/next/src/server/lib/experimental/create-env-definitions.ts @@ -1,23 +1,33 @@ -import type { Env } from '@next/env' +import type { LoadedEnvFiles } from '@next/env' import { join } from 'node:path' import { writeFile } from 'node:fs/promises' export async function createEnvDefinitions({ distDir, - env, + loadedEnvFiles, }: { distDir: string - env: Env + loadedEnvFiles: LoadedEnvFiles }) { - const envKeysStr = Object.keys(env) - .map((key) => ` ${key}?: string`) - .join('\n') + const envLines = [] + const seenKeys = new Set() + // env files are in order of priority + for (const { path, env } of loadedEnvFiles) { + for (const key in env) { + if (!seenKeys.has(key)) { + envLines.push(` /** Loaded from \`${path}\` */`) + envLines.push(` ${key}?: string`) + seenKeys.add(key) + } + } + } + const envStr = envLines.join('\n') const definitionStr = `// Type definitions for Next.js environment variables declare global { namespace NodeJS { interface ProcessEnv { -${envKeysStr} +${envStr} } } } diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index 03dc055e1dbb3..953efd257c26a 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -583,9 +583,9 @@ async function startWatcher(opts: SetupOpts) { if (envChange || tsconfigChange) { if (envChange) { - const { parsedEnv } = loadEnvConfig( + const { loadedEnvFiles } = loadEnvConfig( dir, - true, + process.env.NODE_ENV === 'development', Log, true, (envFilePath) => { @@ -597,7 +597,14 @@ async function startWatcher(opts: SetupOpts) { // do not await, this is not essential for further process createEnvDefinitions({ distDir, - env: { ...parsedEnv, ...nextConfig.env }, + loadedEnvFiles: [ + ...loadedEnvFiles, + { + path: nextConfig.configFileName, + env: nextConfig.env, + contents: '', + }, + ], }) } diff --git a/test/development/app-dir/typed-env/.env.development b/test/development/app-dir/typed-env/.env.development new file mode 100644 index 0000000000000..0353f610f512e --- /dev/null +++ b/test/development/app-dir/typed-env/.env.development @@ -0,0 +1 @@ +FROM_ENV_DEV="FROM_ENV_DEV" \ No newline at end of file diff --git a/test/development/app-dir/typed-env/.env.development.local b/test/development/app-dir/typed-env/.env.development.local index c16babb743d2f..91d5abc189ca7 100644 --- a/test/development/app-dir/typed-env/.env.development.local +++ b/test/development/app-dir/typed-env/.env.development.local @@ -1 +1 @@ -FROM_DEV_ENV_LOCAL="FROM_DEV_ENV_LOCAL" \ No newline at end of file +FROM_ENV_DEV_LOCAL="FROM_ENV_DEV_LOCAL" \ No newline at end of file diff --git a/test/development/app-dir/typed-env/.env.production b/test/development/app-dir/typed-env/.env.production new file mode 100644 index 0000000000000..a99a86702aa63 --- /dev/null +++ b/test/development/app-dir/typed-env/.env.production @@ -0,0 +1 @@ +FROM_ENV_PROD="FROM_ENV_PROD" diff --git a/test/development/app-dir/typed-env/.env.production.local b/test/development/app-dir/typed-env/.env.production.local index 6810f88ae18c9..96a047a7a8779 100644 --- a/test/development/app-dir/typed-env/.env.production.local +++ b/test/development/app-dir/typed-env/.env.production.local @@ -1 +1 @@ -FROM_PROD_ENV_LOCAL="FROM_PROD_ENV_LOCAL" \ No newline at end of file +FROM_ENV_PROD_LOCAL="FROM_ENV_PROD_LOCAL" diff --git a/test/development/app-dir/typed-env/typed-env-prod.test.ts b/test/development/app-dir/typed-env/typed-env-prod.test.ts new file mode 100644 index 0000000000000..6fad829a316ca --- /dev/null +++ b/test/development/app-dir/typed-env/typed-env-prod.test.ts @@ -0,0 +1,44 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('typed-env', () => { + const { next } = nextTestSetup({ + files: __dirname, + env: { + NODE_ENV: 'production', + }, + }) + + it('should have env types from next config', async () => { + await retry(async () => { + const envDTS = await next.readFile('.next/types/env.d.ts') + // since NODE_ENV is production, env types will + // not include development-specific env + expect(envDTS).not.toContain('FROM_ENV_DEV') + expect(envDTS).not.toContain('FROM_ENV_DEV_LOCAL') + + expect(envDTS).toMatchInlineSnapshot(` + "// Type definitions for Next.js environment variables + declare global { + namespace NodeJS { + interface ProcessEnv { + /** Loaded from \`.env.production.local\` */ + FROM_ENV_PROD_LOCAL?: string + /** Loaded from \`.env.local\` */ + FROM_ENV_LOCAL?: string + /** Loaded from \`.env.production\` */ + FROM_ENV_PROD?: string + /** Loaded from \`.env\` */ + FROM_ENV?: string + /** Loaded from \`next.config.js\` */ + FROM_NEXT_CONFIG?: string + } + } + } + export {}" + `) + }) + }) + + // TODO: test for deleting .env & updating env.d.ts +}) diff --git a/test/development/app-dir/typed-env/typed-env.test.ts b/test/development/app-dir/typed-env/typed-env.test.ts index b838ea6aa33b8..4ca8e571fc86d 100644 --- a/test/development/app-dir/typed-env/typed-env.test.ts +++ b/test/development/app-dir/typed-env/typed-env.test.ts @@ -1,5 +1,5 @@ import { nextTestSetup } from 'e2e-utils' -import { check } from 'next-test-utils' +import { retry } from 'next-test-utils' describe('typed-env', () => { const { next } = nextTestSetup({ @@ -7,36 +7,41 @@ describe('typed-env', () => { }) it('should have env types from next config', async () => { - await check( - async () => { - return await next.readFile('.next/types/env.d.ts') - }, + await retry(async () => { + const envDTS = await next.readFile('.next/types/env.d.ts') + // since NODE_ENV is development, env types will + // not include production-specific env + expect(envDTS).not.toContain('FROM_ENV_PROD') + expect(envDTS).not.toContain('FROM_ENV_PROD_LOCAL') - // should not include from production-specific env - // e.g. FROM_PROD_ENV_LOCAL?: string - `// Type definitions for Next.js environment variables -declare global { - namespace NodeJS { - interface ProcessEnv { - FROM_DEV_ENV_LOCAL?: string - FROM_ENV_LOCAL?: string - FROM_ENV?: string - FROM_NEXT_CONFIG?: string - } - } -} -export {}` - ) + expect(envDTS).toMatchInlineSnapshot(` + "// Type definitions for Next.js environment variables + declare global { + namespace NodeJS { + interface ProcessEnv { + /** Loaded from \`.env.development.local\` */ + FROM_ENV_DEV_LOCAL?: string + /** Loaded from \`.env.local\` */ + FROM_ENV_LOCAL?: string + /** Loaded from \`.env.development\` */ + FROM_ENV_DEV?: string + /** Loaded from \`.env\` */ + FROM_ENV?: string + /** Loaded from \`next.config.js\` */ + FROM_NEXT_CONFIG?: string + } + } + } + export {}" + `) + }) }) it('should rewrite env types if .env is modified', async () => { - await check( - async () => { - return await next.readFile('.next/types/env.d.ts') - }, - // env.d.ts is written from original .env - /FROM_ENV/ - ) + await retry(async () => { + const content = await next.readFile('.next/types/env.d.ts') + expect(content).toContain('FROM_ENV') + }) // modify .env await next.patchFile('.env', 'MODIFIED_ENV="MODIFIED_ENV"') @@ -44,23 +49,29 @@ export {}` // should not include from original .env // e.g. FROM_ENV?: string // but have MODIFIED_ENV?: string - await check( - async () => { - return await next.readFile('.next/types/env.d.ts') - }, - `// Type definitions for Next.js environment variables -declare global { - namespace NodeJS { - interface ProcessEnv { - FROM_DEV_ENV_LOCAL?: string - FROM_ENV_LOCAL?: string - MODIFIED_ENV?: string - FROM_NEXT_CONFIG?: string - } - } -} -export {}` - ) + await retry(async () => { + const content = await next.readFile('.next/types/env.d.ts') + expect(content).toMatchInlineSnapshot(` + "// Type definitions for Next.js environment variables + declare global { + namespace NodeJS { + interface ProcessEnv { + /** Loaded from \`.env.development.local\` */ + FROM_ENV_DEV_LOCAL?: string + /** Loaded from \`.env.local\` */ + FROM_ENV_LOCAL?: string + /** Loaded from \`.env.development\` */ + FROM_ENV_DEV?: string + /** Loaded from \`.env\` */ + FROM_ENV?: string + /** Loaded from \`next.config.js\` */ + FROM_NEXT_CONFIG?: string + } + } + } + export {}" + `) + }) }) // TODO: test for deleting .env & updating env.d.ts