diff --git a/.changeset/tender-months-explode.md b/.changeset/tender-months-explode.md new file mode 100644 index 0000000000..d5cdbd76c2 --- /dev/null +++ b/.changeset/tender-months-explode.md @@ -0,0 +1,5 @@ +--- +'@rsbuild/core': patch +--- + +feat(cli): support reload .env files and restart dev server diff --git a/e2e/cases/cli/restart/.gitignore b/e2e/cases/cli/reload-config/.gitignore similarity index 100% rename from e2e/cases/cli/restart/.gitignore rename to e2e/cases/cli/reload-config/.gitignore diff --git a/e2e/cases/cli/restart/index.test.ts b/e2e/cases/cli/reload-config/index.test.ts similarity index 88% rename from e2e/cases/cli/restart/index.test.ts rename to e2e/cases/cli/reload-config/index.test.ts index 31ac135743..3928970ecb 100644 --- a/e2e/cases/cli/restart/index.test.ts +++ b/e2e/cases/cli/reload-config/index.test.ts @@ -3,6 +3,7 @@ import { exec } from 'child_process'; import { test } from '@playwright/test'; import { fse } from '@rsbuild/shared'; import { awaitFileExists } from '@scripts/helper'; +import { getRandomPort } from '@scripts/shared'; test('should restart dev server and reload config when config file changed', async () => { const dist1 = path.join(__dirname, 'dist'); @@ -20,6 +21,7 @@ test('should restart dev server and reload config when config file changed', asy root: 'dist', }, }, + server: { port: ${getRandomPort()} } };`, ); @@ -37,6 +39,7 @@ test('should restart dev server and reload config when config file changed', asy root: 'dist-2', }, }, + server: { port: ${getRandomPort()} } };`, ); diff --git a/e2e/cases/cli/restart/src/index.js b/e2e/cases/cli/reload-config/src/index.js similarity index 100% rename from e2e/cases/cli/restart/src/index.js rename to e2e/cases/cli/reload-config/src/index.js diff --git a/e2e/cases/cli/restart/tsconfig.json b/e2e/cases/cli/reload-config/tsconfig.json similarity index 100% rename from e2e/cases/cli/restart/tsconfig.json rename to e2e/cases/cli/reload-config/tsconfig.json diff --git a/e2e/cases/cli/reload-env/.gitignore b/e2e/cases/cli/reload-env/.gitignore new file mode 100644 index 0000000000..02a8a0dc1f --- /dev/null +++ b/e2e/cases/cli/reload-env/.gitignore @@ -0,0 +1 @@ +rsbuild.config.mjs diff --git a/e2e/cases/cli/reload-env/index.test.ts b/e2e/cases/cli/reload-env/index.test.ts new file mode 100644 index 0000000000..38babc7dab --- /dev/null +++ b/e2e/cases/cli/reload-env/index.test.ts @@ -0,0 +1,44 @@ +import path from 'path'; +import { exec } from 'child_process'; +import { test, expect } from '@playwright/test'; +import { fse } from '@rsbuild/shared'; +import { awaitFileExists } from '@scripts/helper'; +import { getRandomPort } from '@scripts/shared'; + +test('should restart dev server when .env file is changed', async () => { + const dist = path.join(__dirname, 'dist'); + const configFile = path.join(__dirname, 'rsbuild.config.mjs'); + const envLocalFile = path.join(__dirname, '.env.local'); + const distIndex = path.join(dist, 'static/js/index.js'); + fse.removeSync(dist); + fse.removeSync(configFile); + fse.removeSync(envLocalFile); + + fse.writeFileSync(envLocalFile, ``); + fse.writeFileSync( + configFile, + `export default { + output: { + distPath: { + root: 'dist', + }, + disableFilenameHash: true, + }, + server: { port: ${getRandomPort()} } + };`, + ); + + const process = exec('npx rsbuild dev', { + cwd: __dirname, + }); + + await awaitFileExists(distIndex); + expect(fse.readFileSync(distIndex, 'utf-8')).not.toContain('jack'); + fse.removeSync(distIndex); + + fse.writeFileSync(envLocalFile, `PUBLIC_NAME=jack`); + await awaitFileExists(distIndex); + expect(fse.readFileSync(distIndex, 'utf-8')).toContain('jack'); + + process.kill(); +}); diff --git a/e2e/cases/cli/reload-env/src/index.js b/e2e/cases/cli/reload-env/src/index.js new file mode 100644 index 0000000000..47b5b41c18 --- /dev/null +++ b/e2e/cases/cli/reload-env/src/index.js @@ -0,0 +1 @@ +console.log('hello!', process.env.PUBLIC_NAME); diff --git a/e2e/cases/cli/reload-env/tsconfig.json b/e2e/cases/cli/reload-env/tsconfig.json new file mode 100644 index 0000000000..7f0bdc0141 --- /dev/null +++ b/e2e/cases/cli/reload-env/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@rsbuild/tsconfig/base", + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": "./", + "outDir": "./dist", + "paths": { + "@scripts/*": ["../../../scripts/*"] + } + }, + "include": ["src", "*.test.ts"] +} diff --git a/e2e/scripts/helper.ts b/e2e/scripts/helper.ts index f5ff3971d8..08e99dc62f 100644 --- a/e2e/scripts/helper.ts +++ b/e2e/scripts/helper.ts @@ -35,16 +35,18 @@ export const globContentJSON = async (path: string, options?: GlobOptions) => { return ret; }; -export const awaitFileExists = async (dir: string, checks = 0) => { +export const awaitFileExists = async (dir: string) => { const maxChecks = 100; const interval = 100; - if (fse.existsSync(dir)) { - expect(true).toBe(true); - } else if (checks < maxChecks) { + let checks = 0; + + while (checks < maxChecks) { + if (fse.existsSync(dir)) { + return; + } checks++; await new Promise((resolve) => setTimeout(resolve, interval)); - await awaitFileExists(dir, checks); - } else { - expect(false).toBe(true); } + + throw new Error('awaitFileExists failed: ' + dir); }; diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 828cd5cbcd..7fe1b80a3b 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -40,8 +40,9 @@ export async function init({ } try { - const { publicVars } = await loadEnv(); - const config = await loadConfig(commonOpts.config); + const root = process.cwd(); + const { publicVars } = await loadEnv({ dir: root }); + const config = await loadConfig(root, commonOpts.config); const { createRsbuild } = await import('../createRsbuild'); config.source ||= {}; @@ -66,6 +67,7 @@ export async function init({ } return await createRsbuild({ + cwd: root, rsbuildConfig: config, provider: config.provider, }); diff --git a/packages/core/src/cli/config.ts b/packages/core/src/cli/config.ts index bd5bf733d8..39d461335a 100644 --- a/packages/core/src/cli/config.ts +++ b/packages/core/src/cli/config.ts @@ -6,6 +6,7 @@ import { debounce, type RsbuildConfig as BaseRsbuildConfig, } from '@rsbuild/shared'; +import { getEnvFiles } from '../loadEnv'; import { restartDevServer } from '../server/restart'; export type RsbuildConfig = BaseRsbuildConfig & { @@ -36,9 +37,7 @@ export function defineConfig(config: RsbuildConfigExport) { return config; } -const resolveConfigPath = (customConfig?: string) => { - const root = process.cwd(); - +const resolveConfigPath = (root: string, customConfig?: string) => { if (customConfig) { const customConfigPath = isAbsolute(customConfig) ? customConfig @@ -69,31 +68,36 @@ const resolveConfigPath = (customConfig?: string) => { return null; }; -async function watchConfig(configFile: string) { +async function watchConfig(root: string, configFile: string) { const chokidar = await import('@rsbuild/shared/chokidar'); + const envFiles = getEnvFiles().map((filename) => join(root, filename)); - const watcher = chokidar.watch(configFile, { + const watcher = chokidar.watch([configFile, ...envFiles], { + // do not trigger add for initial files + ignoreInitial: true, // If watching fails due to read permissions, the errors will be suppressed silently. ignorePermissionErrors: true, }); const callback = debounce( - async () => { + async (filePath) => { watcher.close(); - await restartDevServer({ filePath: configFile }); + await restartDevServer({ filePath }); }, // set 300ms debounce to avoid restart frequently 300, ); + watcher.on('add', callback); watcher.on('change', callback); watcher.on('unlink', callback); } export async function loadConfig( + root: string, customConfig?: string, ): Promise { - const configFile = resolveConfigPath(customConfig); + const configFile = resolveConfigPath(root, customConfig); if (!configFile) { return {}; @@ -110,7 +114,7 @@ export async function loadConfig( const command = process.argv[2]; if (command === 'dev') { - watchConfig(configFile); + watchConfig(root, configFile); } const configExport = loadConfig(configFile) as RsbuildConfigExport; diff --git a/packages/core/src/loadEnv.ts b/packages/core/src/loadEnv.ts index 7eb25c0783..7c10a58036 100644 --- a/packages/core/src/loadEnv.ts +++ b/packages/core/src/loadEnv.ts @@ -2,6 +2,11 @@ import fs from 'fs'; import { join } from 'path'; import { isFileSync } from '@rsbuild/shared'; +export const getEnvFiles = () => { + const { NODE_ENV } = process.env; + return ['.env', '.env.local', `.env.${NODE_ENV}`, `.env.${NODE_ENV}.local`]; +}; + export async function loadEnv({ dir = process.cwd(), prefixes = ['PUBLIC_'], @@ -9,15 +14,7 @@ export async function loadEnv({ const { parse } = await import('../compiled/dotenv'); const { expand } = await import('../compiled/dotenv-expand'); - const { NODE_ENV } = process.env; - const files = [ - '.env', - '.env.local', - `.env.${NODE_ENV}`, - `.env.${NODE_ENV}.local`, - ]; - - const envPaths = files + const envPaths = getEnvFiles() .map((filename) => join(dir, filename)) .filter(isFileSync);