Skip to content

Commit

Permalink
Display where the env was loaded from when enabled typedEnv (vercel…
Browse files Browse the repository at this point in the history
…#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.
  • Loading branch information
devjiwonchoi authored Nov 21, 2024
1 parent 7885f88 commit 73f547a
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 67 deletions.
2 changes: 1 addition & 1 deletion docs/01-app/03-api-reference/05-config/02-typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function Card<T extends string>({ href }: { href: Route<T> | 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.

Expand Down
5 changes: 5 additions & 0 deletions packages/next-env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
Expand All @@ -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 {}"
`)
})
})
Original file line number Diff line number Diff line change
@@ -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}
}
}
}
Expand Down
13 changes: 10 additions & 3 deletions packages/next/src/server/lib/router-utils/setup-dev-bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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: '',
},
],
})
}

Expand Down
1 change: 1 addition & 0 deletions test/development/app-dir/typed-env/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM_ENV_DEV="FROM_ENV_DEV"
2 changes: 1 addition & 1 deletion test/development/app-dir/typed-env/.env.development.local
Original file line number Diff line number Diff line change
@@ -1 +1 @@
FROM_DEV_ENV_LOCAL="FROM_DEV_ENV_LOCAL"
FROM_ENV_DEV_LOCAL="FROM_ENV_DEV_LOCAL"
1 change: 1 addition & 0 deletions test/development/app-dir/typed-env/.env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM_ENV_PROD="FROM_ENV_PROD"
2 changes: 1 addition & 1 deletion test/development/app-dir/typed-env/.env.production.local
Original file line number Diff line number Diff line change
@@ -1 +1 @@
FROM_PROD_ENV_LOCAL="FROM_PROD_ENV_LOCAL"
FROM_ENV_PROD_LOCAL="FROM_ENV_PROD_LOCAL"
44 changes: 44 additions & 0 deletions test/development/app-dir/typed-env/typed-env-prod.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
99 changes: 55 additions & 44 deletions test/development/app-dir/typed-env/typed-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,77 @@
import { nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import { retry } from 'next-test-utils'

describe('typed-env', () => {
const { next } = nextTestSetup({
files: __dirname,
})

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"')

// 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
Expand Down

0 comments on commit 73f547a

Please sign in to comment.