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 @@ -265,7 +265,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
5 changes: 5 additions & 0 deletions packages/next/server/dev/hot-reloader.ts
Expand Up @@ -31,6 +31,7 @@ import { findPageFile } from '../lib/find-page-file'
import {
BUILDING,
entries,
getInvalidator,
onDemandEntryHandler,
} from './on-demand-entry-handler'
import { denormalizePagePath } from '../../shared/lib/page-path/denormalize-page-path'
Expand Down Expand Up @@ -941,6 +942,10 @@ export default class HotReloader {
edgeServerStats: () => this.edgeServerStats,
}),
]

// trigger invalidation to ensure any previous callbacks
// are handled in the on-demand-entry-handler
getInvalidator()?.invalidate()
}

public async stop(): Promise<void> {
Expand Down
42 changes: 39 additions & 3 deletions packages/next/server/dev/next-dev-server.ts
Expand Up @@ -252,7 +252,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 @@ -265,7 +265,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 @@ -275,16 +277,44 @@ 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 () => {
let middlewareMatcher: RegExp | undefined
const routedPages: string[] = []
const knownFiles = wp.getTimeInfoEntries()
const appPaths: Record<string, string> = {}
const edgeRoutesSet = 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 @@ -358,6 +388,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
31 changes: 12 additions & 19 deletions packages/next/server/dev/on-demand-entry-handler.ts
Expand Up @@ -127,6 +127,10 @@ export const entries: {
let invalidator: Invalidator
export const getInvalidator = () => invalidator

const doneCallbacks: EventEmitter | null = new EventEmitter()
const lastClientAccessPages = ['']
const lastServerAccessPagesForAppDir = ['']

export function onDemandEntryHandler({
maxInactiveAge,
multiCompiler,
Expand All @@ -145,9 +149,6 @@ export function onDemandEntryHandler({
appDir?: string
}) {
invalidator = new Invalidator(multiCompiler)
const doneCallbacks: EventEmitter | null = new EventEmitter()
const lastClientAccessPages = ['']
const lastServerAccessPagesForAppDir = ['']

const startBuilding = (_compilation: webpack.Compilation) => {
invalidator.startBuilding()
Expand Down Expand Up @@ -223,11 +224,7 @@ export function onDemandEntryHandler({
const pingIntervalTime = Math.max(1000, Math.min(5000, maxInactiveAge))

setInterval(function () {
disposeInactiveEntries(
lastClientAccessPages,
lastServerAccessPagesForAppDir,
maxInactiveAge
)
disposeInactiveEntries(maxInactiveAge)
}, pingIntervalTime + 1000).unref()

function handleAppDirPing(
Expand Down Expand Up @@ -397,11 +394,7 @@ export function onDemandEntryHandler({
}
}

function disposeInactiveEntries(
lastClientAccessPages: string[],
lastServerAccessPagesForAppDir: string[],
maxInactiveAge: number
) {
function disposeInactiveEntries(maxInactiveAge: number) {
Object.keys(entries).forEach((page) => {
const { lastActiveTime, status, dispose, bundlePath } = entries[page]

Expand Down Expand Up @@ -467,19 +460,19 @@ class Invalidator {
this.building = true

if (!keys || keys.length === 0) {
this.multiCompiler.compilers[0].watching.invalidate()
this.multiCompiler.compilers[1].watching.invalidate()
this.multiCompiler.compilers[2].watching.invalidate()
this.multiCompiler.compilers[0].watching?.invalidate()
this.multiCompiler.compilers[1].watching?.invalidate()
this.multiCompiler.compilers[2].watching?.invalidate()
return
}

for (const key of keys) {
if (key === 'client') {
this.multiCompiler.compilers[0].watching.invalidate()
this.multiCompiler.compilers[0].watching?.invalidate()
} else if (key === 'server') {
this.multiCompiler.compilers[1].watching.invalidate()
this.multiCompiler.compilers[1].watching?.invalidate()
} else if (key === 'edgeServer') {
this.multiCompiler.compilers[2].watching.invalidate()
this.multiCompiler.compilers[2].watching?.invalidate()
}
}
}
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 @@ -361,6 +361,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