Skip to content

Commit d8155b2

Browse files
ijjktimneutkensTimer
authored
Add initial support for new env handling (#10525)
* Add initial support for new env config file * Fix serverless processEnv call when no env is provided * Add missing await for test method * Update env config to .env.json and add dotenv loading * ncc dotenv package * Update type * Update with new discussed behavior removing .env.json * Update hot-reloader createEntrypoints * Make sure .env is loaded before next.config.js * Add tests for all separate .env files * Remove comments * Add override tests * Add test for overriding env vars based on local environment * Add support for .env.test * Apply suggestions from code review Co-Authored-By: Joe Haddad <[email protected]> * Use chalk for env loaded message * Remove constant as it’s not needed * Update test * Update errsh, taskr, and CNA template ignores * Make sure to only consider undefined missing * Remove old .env ignore * Update to not populate process.env with loaded env * Add experimental flag and add loading of global env values Co-authored-by: Tim Neutkens <[email protected]> Co-authored-by: Joe Haddad <[email protected]>
1 parent a391d32 commit d8155b2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1103
-10
lines changed

errors/missing-env-value.md

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Missing Env Value
2+
3+
#### Why This Error Occurred
4+
5+
One of your pages' config requested an env value that wasn't populated.
6+
7+
```js
8+
// pages/index.js
9+
export const config = {
10+
// this value isn't provided in `.env`
11+
env: ['MISSING_KEY'],
12+
}
13+
```
14+
15+
```
16+
// .env (notice no `MISSING_KEY` provided here)
17+
NOTION_KEY='...'
18+
```
19+
20+
#### Possible Ways to Fix It
21+
22+
Either remove the requested env value from the page's config, populate it in your `.env` file, or manually populate it in your environment before running `next dev` or `next build`.
23+
24+
### Useful Links
25+
26+
- [dotenv](https://npmjs.com/package/dotenv)
27+
- [dotenv-expand](https://npmjs.com/package/dotenv-expand)
28+
- [Environment Variables](https://en.wikipedia.org/wiki/Environment_variable)

packages/create-next-app/templates/default/gitignore

+6-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@
1717

1818
# misc
1919
.DS_Store
20-
.env*
2120

2221
# debug
2322
npm-debug.log*
2423
yarn-debug.log*
2524
yarn-error.log*
25+
26+
# local env files
27+
.env.local
28+
.env.development.local
29+
.env.test.local
30+
.env.production.local

packages/next/build/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
} from './utils'
7272
import getBaseWebpackConfig from './webpack-config'
7373
import { writeBuildId } from './write-build-id'
74+
import { loadEnvConfig } from '../lib/load-env-config'
7475

7576
const fsAccess = promisify(fs.access)
7677
const fsUnlink = promisify(fs.unlink)
@@ -110,6 +111,9 @@ export default async function build(dir: string, conf = null): Promise<void> {
110111
)
111112
}
112113

114+
// attempt to load global env values so they are available in next.config.js
115+
loadEnvConfig(dir)
116+
113117
const config = loadConfig(PHASE_PRODUCTION_BUILD, dir, conf)
114118
const { target } = config
115119
const buildId = await generateBuildId(config.generateBuildId, nanoid)

packages/next/build/webpack-config.ts

+11
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,17 @@ export default async function getBaseWebpackConfig(
707707
// This plugin makes sure `output.filename` is used for entry chunks
708708
new ChunkNamesPlugin(),
709709
new webpack.DefinePlugin({
710+
...(config.experimental.pageEnv
711+
? Object.keys(process.env).reduce(
712+
(prev: { [key: string]: string }, key: string) => {
713+
if (key.startsWith('NEXT_APP_')) {
714+
prev[key] = process.env[key]!
715+
}
716+
return prev
717+
},
718+
{}
719+
)
720+
: {}),
710721
...Object.keys(config.env).reduce((acc, key) => {
711722
if (/^(?:NODE_.+)|^(?:__.+)$/i.test(key)) {
712723
throw new Error(

packages/next/build/webpack/loaders/next-serverless-loader.ts

+2
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ const nextServerlessLoader: loader.Loader = function() {
181181
Object.assign({}, parsedUrl.query, params ),
182182
resolver,
183183
${encodedPreviewProps},
184+
process.env,
184185
onError
185186
)
186187
} catch (err) {
@@ -257,6 +258,7 @@ const nextServerlessLoader: loader.Loader = function() {
257258
assetPrefix: "${assetPrefix}",
258259
runtimeConfig: runtimeConfig.publicRuntimeConfig || {},
259260
previewProps: ${encodedPreviewProps},
261+
env: process.env,
260262
..._renderOpts
261263
}
262264
let _nextData = false

packages/next/export/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import loadConfig, {
3535
import { eventCliSession } from '../telemetry/events'
3636
import { Telemetry } from '../telemetry/storage'
3737
import { normalizePagePath } from '../next-server/server/normalize-page-path'
38+
import { loadEnvConfig } from '../lib/load-env-config'
3839

3940
const copyFile = promisify(copyFileOrig)
4041
const mkdir = promisify(mkdirOrig)
@@ -230,6 +231,7 @@ export default async function(
230231
dir,
231232
buildId,
232233
nextExport: true,
234+
env: loadEnvConfig(dir),
233235
assetPrefix: nextConfig.assetPrefix.replace(/\/$/, ''),
234236
distDir,
235237
dev: false,

packages/next/lib/find-pages-dir.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'fs'
22
import path from 'path'
33

4-
const existsSync = (f: string): boolean => {
4+
export const existsSync = (f: string): boolean => {
55
try {
66
fs.accessSync(f, fs.constants.F_OK)
77
return true

packages/next/lib/load-env-config.ts

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import chalk from 'chalk'
4+
import dotenvExpand from 'next/dist/compiled/dotenv-expand'
5+
import dotenv, { DotenvConfigOutput } from 'next/dist/compiled/dotenv'
6+
import findUp from 'find-up'
7+
8+
export type Env = { [key: string]: string }
9+
10+
export function loadEnvConfig(dir: string, dev?: boolean): Env | false {
11+
const packageJson = findUp.sync('package.json', { cwd: dir })
12+
13+
// only do new env loading if dotenv isn't installed since we
14+
// can't check for an experimental flag in next.config.js
15+
// since we want to load the env before loading next.config.js
16+
if (packageJson) {
17+
const { dependencies, devDependencies } = require(packageJson)
18+
const allPackages = Object.keys({
19+
...dependencies,
20+
...devDependencies,
21+
})
22+
23+
if (allPackages.some(pkg => pkg === 'dotenv')) {
24+
return false
25+
}
26+
} else {
27+
// we should always have a package.json but disable in case we don't
28+
return false
29+
}
30+
31+
const isTest = process.env.NODE_ENV === 'test'
32+
const mode = isTest ? 'test' : dev ? 'development' : 'production'
33+
const dotenvFiles = [
34+
`.env.${mode}.local`,
35+
`.env.${mode}`,
36+
// Don't include `.env.local` for `test` environment
37+
// since normally you expect tests to produce the same
38+
// results for everyone
39+
mode !== 'test' && `.env.local`,
40+
'.env',
41+
].filter(Boolean) as string[]
42+
43+
const combinedEnv: Env = {
44+
...(process.env as any),
45+
}
46+
47+
for (const envFile of dotenvFiles) {
48+
// only load .env if the user provided has an env config file
49+
const dotEnvPath = path.join(dir, envFile)
50+
51+
try {
52+
const contents = fs.readFileSync(dotEnvPath, 'utf8')
53+
let result: DotenvConfigOutput = {}
54+
result.parsed = dotenv.parse(contents)
55+
56+
result = dotenvExpand(result)
57+
58+
if (result.parsed) {
59+
console.log(`> ${chalk.cyan.bold('Info:')} Loaded env from ${envFile}`)
60+
}
61+
62+
Object.assign(combinedEnv, result.parsed)
63+
} catch (err) {
64+
if (err.code !== 'ENOENT') {
65+
console.log(
66+
`> ${chalk.cyan.bold('Error: ')} Failed to load env from ${envFile}`,
67+
err
68+
)
69+
}
70+
}
71+
}
72+
73+
// load global env values prefixed with `NEXT_APP_` to process.env
74+
for (const key of Object.keys(combinedEnv)) {
75+
if (
76+
key.startsWith('NEXT_APP_') &&
77+
typeof process.env[key] === 'undefined'
78+
) {
79+
process.env[key] = combinedEnv[key]
80+
}
81+
}
82+
83+
return combinedEnv
84+
}

packages/next/next-server/lib/utils.ts

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ComponentType } from 'react'
44
import { format, URLFormatOptions, UrlObject } from 'url'
55
import { ManifestItem } from '../server/load-components'
66
import { NextRouter } from './router/router'
7+
import { Env } from '../../lib/load-env-config'
78

89
/**
910
* Types used by both next and next-server
@@ -186,6 +187,8 @@ export type NextApiRequest = IncomingMessage & {
186187
}
187188

188189
body: any
190+
191+
env: Env
189192
}
190193

191194
/**

packages/next/next-server/server/api-utils.ts

+7-8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { isResSent, NextApiRequest, NextApiResponse } from '../lib/utils'
88
import { decryptWithSecret, encryptWithSecret } from './crypto-utils'
99
import { interopDefault } from './load-components'
1010
import { Params } from './router'
11+
import { collectEnv } from './utils'
12+
import { Env } from '../../lib/load-env-config'
1113

1214
export type NextApiRequestCookies = { [key: string]: string }
1315
export type NextApiRequestQuery = { [key: string]: string | string[] }
@@ -24,26 +26,23 @@ export async function apiResolver(
2426
params: any,
2527
resolverModule: any,
2628
apiContext: __ApiPreviewProps,
29+
env: Env | false,
2730
onError?: ({ err }: { err: any }) => Promise<void>
2831
) {
2932
const apiReq = req as NextApiRequest
3033
const apiRes = res as NextApiResponse
3134

3235
try {
33-
let config: PageConfig = {}
34-
let bodyParser = true
3536
if (!resolverModule) {
3637
res.statusCode = 404
3738
res.end('Not Found')
3839
return
3940
}
41+
const config: PageConfig = resolverModule.config || {}
42+
const bodyParser = config.api?.bodyParser !== false
43+
44+
apiReq.env = env ? collectEnv(req.url!, env, config.env) : {}
4045

41-
if (resolverModule.config) {
42-
config = resolverModule.config
43-
if (config.api && config.api.bodyParser === false) {
44-
bodyParser = false
45-
}
46-
}
4746
// Parsing of cookies
4847
setLazyProp({ req: apiReq }, 'cookies', getCookieParser(req))
4948
// Parsing query string

packages/next/next-server/server/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const defaultConfig: { [key: string]: any } = {
5454
workerThreads: false,
5555
basePath: '',
5656
sassOptions: {},
57+
pageEnv: false,
5758
},
5859
future: {
5960
excludeDefaultMomentLocales: false,

packages/next/next-server/server/next-server.ts

+6
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
setSprCache,
6262
} from './spr-cache'
6363
import { isBlockedPage } from './utils'
64+
import { loadEnvConfig, Env } from '../../lib/load-env-config'
6465

6566
const getCustomRouteMatcher = pathMatch(true)
6667

@@ -117,6 +118,7 @@ export default class Server {
117118
documentMiddlewareEnabled: boolean
118119
hasCssMode: boolean
119120
dev?: boolean
121+
env: Env | false
120122
previewProps: __ApiPreviewProps
121123
customServer?: boolean
122124
ampOptimizerConfig?: { [key: string]: any }
@@ -145,6 +147,8 @@ export default class Server {
145147
this.dir = resolve(dir)
146148
this.quiet = quiet
147149
const phase = this.currentPhase()
150+
const env = loadEnvConfig(this.dir, dev)
151+
148152
this.nextConfig = loadConfig(phase, this.dir, conf)
149153
this.distDir = join(this.dir, this.nextConfig.distDir)
150154
this.publicDir = join(this.dir, CLIENT_PUBLIC_FILES_PATH)
@@ -171,6 +175,7 @@ export default class Server {
171175
staticMarkup,
172176
buildId: this.buildId,
173177
generateEtags,
178+
env: this.nextConfig.experimental.pageEnv && env,
174179
previewProps: this.getPreviewProps(),
175180
customServer: customServer === true ? true : undefined,
176181
ampOptimizerConfig: this.nextConfig.experimental.amp?.optimizer,
@@ -684,6 +689,7 @@ export default class Server {
684689
query,
685690
pageModule,
686691
this.renderOpts.previewProps,
692+
this.renderOpts.env,
687693
this.onErrorMiddleware
688694
)
689695
return true

packages/next/next-server/server/render.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { tryGetPreviewData, __ApiPreviewProps } from './api-utils'
3838
import { getPageFiles } from './get-page-files'
3939
import { LoadComponentsReturnType, ManifestItem } from './load-components'
4040
import optimizeAmp from './optimize-amp'
41+
import { collectEnv } from './utils'
42+
import { Env } from '../../lib/load-env-config'
4143
import { UnwrapPromise } from '../../lib/coalesced-function'
4244
import { GetStaticProps, GetServerSideProps } from '../../types'
4345

@@ -154,6 +156,7 @@ export type RenderOptsPartial = {
154156
isDataReq?: boolean
155157
params?: ParsedUrlQuery
156158
previewProps: __ApiPreviewProps
159+
env: Env | false
157160
}
158161

159162
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial
@@ -288,6 +291,7 @@ export async function renderToHTML(
288291
staticMarkup = false,
289292
ampPath = '',
290293
App,
294+
env = {},
291295
Document,
292296
pageConfig = {},
293297
DocumentMiddleware,
@@ -303,6 +307,8 @@ export async function renderToHTML(
303307
previewProps,
304308
} = renderOpts
305309

310+
const curEnv = env ? collectEnv(pathname, env, pageConfig.env) : {}
311+
306312
const callMiddleware = async (method: string, args: any[], props = false) => {
307313
let results: any = props ? {} : []
308314

@@ -503,6 +509,7 @@ export async function renderToHTML(
503509

504510
try {
505511
data = await getStaticProps!({
512+
env: curEnv,
506513
...(pageIsDynamic ? { params: query as ParsedUrlQuery } : undefined),
507514
...(previewData !== false
508515
? { preview: true, previewData: previewData }
@@ -585,6 +592,7 @@ export async function renderToHTML(
585592
req,
586593
res,
587594
query,
595+
env: curEnv,
588596
...(pageIsDynamic ? { params: params as ParsedUrlQuery } : undefined),
589597
...(previewData !== false
590598
? { preview: true, previewData: previewData }

packages/next/next-server/server/utils.ts

+26
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BLOCKED_PAGES } from '../lib/constants'
2+
import { Env } from '../../lib/load-env-config'
23

34
export function isBlockedPage(pathname: string): boolean {
45
return BLOCKED_PAGES.indexOf(pathname) !== -1
@@ -14,3 +15,28 @@ export function cleanAmpPath(pathname: string): string {
1415
pathname = pathname.replace(/\?$/, '')
1516
return pathname
1617
}
18+
19+
export function collectEnv(page: string, env: Env, pageEnv?: string[]): Env {
20+
const missingEnvKeys = new Set()
21+
const collected = pageEnv
22+
? pageEnv.reduce((prev: Env, key): Env => {
23+
if (typeof env[key] !== 'undefined') {
24+
prev[key] = env[key]!
25+
} else {
26+
missingEnvKeys.add(key)
27+
}
28+
return prev
29+
}, {})
30+
: {}
31+
32+
if (missingEnvKeys.size > 0) {
33+
console.warn(
34+
`Missing env value${missingEnvKeys.size === 1 ? '' : 's'}: ${[
35+
...missingEnvKeys,
36+
].join(', ')} for ${page}.\n` +
37+
`Make sure to supply this value in either your .env file or in your environment.\n` +
38+
`See here for more info: https://err.sh/next.js/missing-env-value`
39+
)
40+
}
41+
return collected
42+
}

0 commit comments

Comments
 (0)