Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hot-reloading for env file changes #38483

Merged
merged 12 commits into from Aug 11, 2022
37 changes: 25 additions & 12 deletions packages/next-env/index.ts
Expand Up @@ -4,12 +4,13 @@ import * as path from 'path'
import * as dotenv from 'dotenv'
import { expand as dotenvExpand } from 'dotenv-expand'

export type Env = { [key: string]: string }
export type Env = { [key: string]: string | undefined }
export type LoadedEnvFiles = Array<{
path: string
contents: string
}>

let initialEnv: Env | undefined = undefined
let combinedEnv: Env | undefined = undefined
let cachedLoadedEnvFiles: LoadedEnvFiles = []

Expand All @@ -21,18 +22,24 @@ type Log = {
export function processEnv(
loadedEnvFiles: LoadedEnvFiles,
dir?: string,
log: Log = console
log: Log = console,
forceReload = false
) {
// don't reload env if we already have since this breaks escaped
// environment values e.g. \$ENV_FILE_KEY
if (process.env.__NEXT_PROCESSED_ENV || loadedEnvFiles.length === 0) {
if (!initialEnv) {
initialEnv = Object.assign({}, process.env)
}
// only reload env when forceReload is specified
if (
!forceReload &&
(process.env.__NEXT_PROCESSED_ENV || loadedEnvFiles.length === 0)
) {
return process.env as Env
}
// flag that we processed the environment values in case a serverless
// function is re-used or we are running in `next start` mode
process.env.__NEXT_PROCESSED_ENV = 'true'

const origEnv = Object.assign({}, process.env)
const origEnv = Object.assign({}, initialEnv)
const parsed: dotenv.DotenvParseOutput = {}

for (const envFile of loadedEnvFiles) {
Expand Down Expand Up @@ -61,21 +68,27 @@ export function processEnv(
)
}
}

return Object.assign(process.env, parsed)
}

export function loadEnvConfig(
dir: string,
dev?: boolean,
log: Log = console
log: Log = console,
forceReload = false
): {
combinedEnv: Env
loadedEnvFiles: LoadedEnvFiles
} {
// don't reload env if we already have since this breaks escaped
// environment values e.g. \$ENV_FILE_KEY
if (combinedEnv) return { combinedEnv, loadedEnvFiles: cachedLoadedEnvFiles }
if (!initialEnv) {
initialEnv = Object.assign({}, process.env)
}
// only reload env when forceReload is specified
if (combinedEnv && !forceReload) {
return { combinedEnv, loadedEnvFiles: cachedLoadedEnvFiles }
}
process.env = Object.assign({}, initialEnv)
cachedLoadedEnvFiles = []

const isTest = process.env.NODE_ENV === 'test'
const mode = isTest ? 'test' : dev ? 'development' : 'production'
Expand Down Expand Up @@ -112,6 +125,6 @@ export function loadEnvConfig(
}
}
}
combinedEnv = processEnv(cachedLoadedEnvFiles, dir, log)
combinedEnv = processEnv(cachedLoadedEnvFiles, dir, log, forceReload)
return { combinedEnv, loadedEnvFiles: cachedLoadedEnvFiles }
}
5 changes: 4 additions & 1 deletion packages/next/server/base-server.ts
Expand Up @@ -264,7 +264,10 @@ export default abstract class Server<ServerOptions extends Options = Options> {
res: BaseNextResponse
): void

protected abstract loadEnvConfig(params: { dev: boolean }): void
protected abstract loadEnvConfig(params: {
dev: boolean
forceReload?: boolean
}): void

public constructor(options: ServerOptions) {
const {
Expand Down
4 changes: 4 additions & 0 deletions packages/next/server/dev/hot-reloader.ts
Expand Up @@ -899,6 +899,10 @@ export default class HotReloader {
)
})

for (const key of Object.keys(entries)) {
ijjk marked this conversation as resolved.
Show resolved Hide resolved
delete entries[key]
}

this.onDemandEntries = onDemandEntryHandler({
multiCompiler,
pagesDir: this.pagesDir,
Expand Down
42 changes: 39 additions & 3 deletions packages/next/server/dev/next-dev-server.ts
Expand Up @@ -263,7 +263,7 @@ export default class DevServer extends Server {
)

let resolved = false
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
// Watchpack doesn't emit an event for an empty directory
fs.readdir(this.pagesDir, (_, files) => {
if (files?.length) {
Expand All @@ -276,7 +276,9 @@ export default class DevServer extends Server {
}
})

const wp = (this.webpackWatcher = new Watchpack())
const wp = (this.webpackWatcher = new Watchpack({
ignored: /(node_modules|\.next)/,
}))
const pages = [this.pagesDir]
const app = this.appDir ? [this.appDir] : []
const directories = [...pages, ...app]
Expand All @@ -286,7 +288,16 @@ export default class DevServer extends Server {
)
let nestedMiddleware: string[] = []

wp.watch(files, directories, 0)
const envFiles = [
'.env.development.local',
'.env.local',
'.env.development',
'.env',
].map((file) => pathJoin(this.dir, file))

files.push(...envFiles)
wp.watch({ directories: [this.dir], startTime: 0 })
const envFileTimes = new Map()

wp.on('aggregated', async () => {
const routedMiddleware: string[] = []
Expand All @@ -295,8 +306,27 @@ export default class DevServer extends Server {
const knownFiles = wp.getTimeInfoEntries()
const appPaths: Record<string, string> = {}
const ssrMiddleware = new Set<string>()
let envChange = false

for (const [fileName, meta] of knownFiles) {
if (
!files.includes(fileName) &&
!directories.some((dir) => fileName.startsWith(dir))
) {
continue
}

if (envFiles.includes(fileName)) {
if (
envFileTimes.get(fileName) &&
envFileTimes.get(fileName) !== meta.timestamp
) {
envChange = true
}
envFileTimes.set(fileName, meta.timestamp)
continue
}

if (
meta?.accuracy === undefined ||
!regexPageExtension.test(fileName)
Expand Down Expand Up @@ -373,6 +403,12 @@ export default class DevServer extends Server {
routedPages.push(pageName)
}

if (envChange) {
this.loadEnvConfig({ dev: true, forceReload: true })
await this.hotReloader?.stop()
await this.hotReloader?.start()
}

if (nestedMiddleware.length > 0) {
Log.error(
new NestedMiddlewareError(nestedMiddleware, this.dir, this.pagesDir)
Expand Down
10 changes: 8 additions & 2 deletions packages/next/server/next-server.ts
Expand Up @@ -160,8 +160,14 @@ export default class NextNodeServer extends BaseServer {
? (compression() as ExpressMiddleware)
: undefined

protected loadEnvConfig({ dev }: { dev: boolean }) {
loadEnvConfig(this.dir, dev, Log)
protected loadEnvConfig({
dev,
forceReload,
}: {
dev: boolean
forceReload?: boolean
}) {
loadEnvConfig(this.dir, dev, Log, forceReload)
}

protected getPublicDir(): string {
Expand Down
6 changes: 6 additions & 0 deletions packages/next/types/misc.d.ts
Expand Up @@ -356,6 +356,12 @@ declare module 'next/dist/compiled/watchpack' {

class Watchpack extends EventEmitter {
constructor(options?: any)
watch(params: {
files?: string[]
directories?: string[]
startTime?: number
missing?: string[]
}): void
watch(files: string[], directories: string[], startTime?: number): void
close(): void

Expand Down
1 change: 1 addition & 0 deletions test/integration/env-config/app/pages/another-global.js
@@ -0,0 +1 @@
export default () => <p>{process.env.NEXT_PUBLIC_HELLO_WORLD}</p>
20 changes: 12 additions & 8 deletions test/integration/env-config/app/pages/api/all.js
Expand Up @@ -21,18 +21,22 @@ const variables = [
'ENV_FILE_EXPANDED_CONCAT',
'ENV_FILE_EXPANDED_ESCAPED',
'ENV_FILE_KEY_EXCLAMATION',
'NEW_ENV_KEY',
'NEW_ENV_LOCAL_KEY',
'NEW_ENV_DEV_KEY',
'NEXT_PUBLIC_HELLO_WORLD',
]

const items = {
nextConfigEnv: process.env.nextConfigEnv,
nextConfigPublicEnv: process.env.nextConfigPublicEnv,
}
export default async (req, res) => {
const items = {
nextConfigEnv: process.env.nextConfigEnv,
nextConfigPublicEnv: process.env.nextConfigPublicEnv,
}

variables.forEach((variable) => {
items[variable] = process.env[variable]
})
variables.forEach((variable) => {
items[variable] = process.env[variable]
})

export default async (req, res) => {
// Only for testing, don't do this...
res.json(items)
}
4 changes: 4 additions & 0 deletions test/integration/env-config/app/pages/index.js
Expand Up @@ -21,6 +21,10 @@ const variables = [
'ENV_FILE_EXPANDED_CONCAT',
'ENV_FILE_EXPANDED_ESCAPED',
'ENV_FILE_KEY_EXCLAMATION',
'NEW_ENV_KEY',
'NEW_ENV_LOCAL_KEY',
'NEW_ENV_DEV_KEY',
'NEXT_PUBLIC_HELLO_WORLD',
]

export async function getStaticProps() {
Expand Down
4 changes: 4 additions & 0 deletions test/integration/env-config/app/pages/some-ssg.js
Expand Up @@ -21,6 +21,10 @@ const variables = [
'ENV_FILE_EXPANDED_CONCAT',
'ENV_FILE_EXPANDED_ESCAPED',
'ENV_FILE_KEY_EXCLAMATION',
'NEW_ENV_KEY',
'NEW_ENV_LOCAL_KEY',
'NEW_ENV_DEV_KEY',
'NEXT_PUBLIC_HELLO_WORLD',
]

export async function getStaticProps() {
Expand Down
4 changes: 4 additions & 0 deletions test/integration/env-config/app/pages/some-ssp.js
Expand Up @@ -21,6 +21,10 @@ const variables = [
'ENV_FILE_EXPANDED_CONCAT',
'ENV_FILE_EXPANDED_ESCAPED',
'ENV_FILE_KEY_EXCLAMATION',
'NEW_ENV_KEY',
'NEW_ENV_LOCAL_KEY',
'NEW_ENV_DEV_KEY',
'NEXT_PUBLIC_HELLO_WORLD',
]

export async function getServerSideProps() {
Expand Down