diff --git a/packages/core/package.json b/packages/core/package.json index 4469d2c58..81ccfda46 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@rslib/tsconfig": "workspace:*", "@types/fs-extra": "^11.0.4", + "chokidar": "^4.0.1", "commander": "^12.1.0", "fs-extra": "^11.2.0", "memfs": "^4.14.0", diff --git a/packages/core/prebundle.config.mjs b/packages/core/prebundle.config.mjs index 21b450c7e..81cbefc54 100644 --- a/packages/core/prebundle.config.mjs +++ b/packages/core/prebundle.config.mjs @@ -13,6 +13,11 @@ export default { }, dependencies: [ 'commander', + { + name: 'chokidar', + // strip sourcemap comment + prettier: true, + }, { name: 'rslog', afterBundle(task) { diff --git a/packages/core/rslib.config.ts b/packages/core/rslib.config.ts index f96c1f93a..ac2a90e47 100644 --- a/packages/core/rslib.config.ts +++ b/packages/core/rslib.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ externals: { picocolors: '../compiled/picocolors/index.js', commander: '../compiled/commander/index.js', + chokidar: '../compiled/chokidar/index.js', rslog: '../compiled/rslog/index.js', }, }, diff --git a/packages/core/src/cli/build.ts b/packages/core/src/cli/build.ts index 13386b8ba..c305c84d5 100644 --- a/packages/core/src/cli/build.ts +++ b/packages/core/src/cli/build.ts @@ -2,6 +2,7 @@ import { type RsbuildInstance, createRsbuild } from '@rsbuild/core'; import { composeRsbuildEnvironments, pruneEnvironments } from '../config'; import type { RslibConfig } from '../types/config'; import type { BuildOptions } from './commands'; +import { onBeforeRestart } from './restart'; export async function build( config: RslibConfig, @@ -14,9 +15,15 @@ export async function build( }, }); - await rsbuildInstance.build({ + const buildInstance = await rsbuildInstance.build({ watch: options.watch, }); + if (options.watch) { + onBeforeRestart(buildInstance.close); + } else { + await buildInstance.close(); + } + return rsbuildInstance; } diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 11714be33..32720dab8 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -5,6 +5,7 @@ import { build } from './build'; import { loadRslibConfig } from './init'; import { inspect } from './inspect'; import { startMFDevServer } from './mf'; +import { watchFilesForRestart } from './restart'; export type CommonOptions = { root?: string; @@ -62,11 +63,20 @@ export function runCli(): void { .description('build the library for production') .action(async (options: BuildOptions) => { try { - const rslibConfig = await loadRslibConfig(options); - await build(rslibConfig, { - lib: options.lib, - watch: options.watch, - }); + const cliBuild = async () => { + const { content: rslibConfig, filePath } = + await loadRslibConfig(options); + + await build(rslibConfig, options); + + if (options.watch) { + watchFilesForRestart([filePath], async () => { + await cliBuild(); + }); + } + }; + + await cliBuild(); } catch (err) { logger.error('Failed to build.'); logger.error(err); @@ -90,7 +100,7 @@ export function runCli(): void { .action(async (options: InspectOptions) => { try { // TODO: inspect should output Rslib's config - const rslibConfig = await loadRslibConfig(options); + const { content: rslibConfig } = await loadRslibConfig(options); await inspect(rslibConfig, { lib: options.lib, mode: options.mode, @@ -108,9 +118,18 @@ export function runCli(): void { .description('start Rsbuild dev server of Module Federation format') .action(async (options: CommonOptions) => { try { - const rslibConfig = await loadRslibConfig(options); - // TODO: support lib option in mf dev server - await startMFDevServer(rslibConfig); + const cliMfDev = async () => { + const { content: rslibConfig, filePath } = + await loadRslibConfig(options); + // TODO: support lib option in mf dev server + await startMFDevServer(rslibConfig); + + watchFilesForRestart([filePath], async () => { + await cliMfDev(); + }); + }; + + await cliMfDev(); } catch (err) { logger.error('Failed to start mf dev.'); logger.error(err); diff --git a/packages/core/src/cli/init.ts b/packages/core/src/cli/init.ts index e77035b2d..555d177ba 100644 --- a/packages/core/src/cli/init.ts +++ b/packages/core/src/cli/init.ts @@ -3,17 +3,16 @@ import type { RslibConfig } from '../types'; import { getAbsolutePath } from '../utils/helper'; import type { CommonOptions } from './commands'; -export async function loadRslibConfig( - options: CommonOptions, -): Promise { +export async function loadRslibConfig(options: CommonOptions): Promise<{ + content: RslibConfig; + filePath: string; +}> { const cwd = process.cwd(); const root = options.root ? getAbsolutePath(cwd, options.root) : cwd; - const rslibConfig = await loadConfig({ + return loadConfig({ cwd: root, path: options.config, envMode: options.envMode, }); - - return rslibConfig; } diff --git a/packages/core/src/cli/mf.ts b/packages/core/src/cli/mf.ts index 9c96d6235..be87ae68d 100644 --- a/packages/core/src/cli/mf.ts +++ b/packages/core/src/cli/mf.ts @@ -2,6 +2,7 @@ import { createRsbuild, mergeRsbuildConfig } from '@rsbuild/core'; import type { RsbuildConfig, RsbuildInstance } from '@rsbuild/core'; import { composeCreateRsbuildConfig } from '../config'; import type { RslibConfig } from '../types'; +import { onBeforeRestart } from './restart'; export async function startMFDevServer( config: RslibConfig, @@ -27,7 +28,9 @@ async function initMFRsbuild( const rsbuildInstance = await createRsbuild({ rsbuildConfig: mfRsbuildConfig.config, }); - await rsbuildInstance.startDevServer(); + const devServer = await rsbuildInstance.startDevServer(); + + onBeforeRestart(devServer.server.close); return rsbuildInstance; } diff --git a/packages/core/src/cli/restart.ts b/packages/core/src/cli/restart.ts new file mode 100644 index 000000000..667393b15 --- /dev/null +++ b/packages/core/src/cli/restart.ts @@ -0,0 +1,76 @@ +import path from 'node:path'; +import chokidar from 'chokidar'; +import { color, debounce, isTTY } from '../utils/helper'; +import { logger } from '../utils/logger'; + +export async function watchFilesForRestart( + files: string[], + restart: () => Promise, +): Promise { + if (!files.length) { + return; + } + + const watcher = chokidar.watch(files, { + ignoreInitial: true, + // If watching fails due to read permissions, the errors will be suppressed silently. + ignorePermissionErrors: true, + ignored: ['**/node_modules/**', '**/.git/**', '**/.DS_Store/**'], + }); + + const callback = debounce( + async (filePath) => { + watcher.close(); + + await beforeRestart({ filePath }); + await restart(); + }, + // set 300ms debounce to avoid restart frequently + 300, + ); + + watcher.on('add', callback); + watcher.on('change', callback); + watcher.on('unlink', callback); +} + +type Cleaner = () => Promise | unknown; + +let cleaners: Cleaner[] = []; + +/** + * Add a cleaner to handle side effects + */ +export const onBeforeRestart = (cleaner: Cleaner): void => { + cleaners.push(cleaner); +}; + +const clearConsole = () => { + if (isTTY() && !process.env.DEBUG) { + process.stdout.write('\x1B[H\x1B[2J'); + } +}; + +const beforeRestart = async ({ + filePath, + clear = true, +}: { + filePath?: string; + clear?: boolean; +} = {}): Promise => { + if (clear) { + clearConsole(); + } + + if (filePath) { + const filename = path.basename(filePath); + logger.info(`Restart because ${color.yellow(filename)} is changed.\n`); + } else { + logger.info('Restarting...\n'); + } + + for (const cleaner of cleaners) { + await cleaner(); + } + cleaners = []; +}; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index df4916ffe..a4fc97f78 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -117,7 +117,10 @@ export async function loadConfig({ cwd?: string; path?: string; envMode?: string; -}): Promise { +}): Promise<{ + content: RslibConfig; + filePath: string; +}> { const configFilePath = resolveConfigPath(cwd, path); const { content } = await loadRsbuildConfig({ cwd: dirname(configFilePath), @@ -125,7 +128,7 @@ export async function loadConfig({ envMode, }); - return content as RslibConfig; + return { content: content as RslibConfig, filePath: configFilePath }; } const composeExternalsWarnConfig = ( diff --git a/packages/core/src/utils/helper.ts b/packages/core/src/utils/helper.ts index d0cde5bc2..7a061039e 100644 --- a/packages/core/src/utils/helper.ts +++ b/packages/core/src/utils/helper.ts @@ -205,4 +205,31 @@ export function checkMFPlugin(config: LibConfig): boolean { return added; } +export function debounce void>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return (...args: Parameters) => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + func(...args); + }, wait); + }; +} + +/** + * Check if running in a TTY context + */ +export const isTTY = (type: 'stdin' | 'stdout' = 'stdout'): boolean => { + return ( + (type === 'stdin' ? process.stdin.isTTY : process.stdout.isTTY) && + !process.env.CI + ); +}; + export { color }; diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index d0516958c..1932c272a 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -13,7 +13,7 @@ describe('Should load config file correctly', () => { test('Load config.js in cjs project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/cjs'); const configFilePath = join(fixtureDir, 'rslib.config.js'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -30,7 +30,7 @@ describe('Should load config file correctly', () => { test('Load config.mjs in cjs project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/cjs'); const configFilePath = join(fixtureDir, 'rslib.config.mjs'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -47,7 +47,7 @@ describe('Should load config file correctly', () => { test('Load config.ts in cjs project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/cjs'); const configFilePath = join(fixtureDir, 'rslib.config.ts'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -64,7 +64,7 @@ describe('Should load config file correctly', () => { test('Load config.cjs with defineConfig in cjs project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/cjs'); const configFilePath = join(fixtureDir, 'rslib.config.cjs'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -81,7 +81,7 @@ describe('Should load config file correctly', () => { test('Load config.js in esm project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/esm'); const configFilePath = join(fixtureDir, 'rslib.config.js'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -98,7 +98,7 @@ describe('Should load config file correctly', () => { test('Load config.cjs in esm project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/esm'); const configFilePath = join(fixtureDir, 'rslib.config.cjs'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -115,7 +115,7 @@ describe('Should load config file correctly', () => { test('Load config.ts in esm project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/esm'); const configFilePath = join(fixtureDir, 'rslib.config.ts'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toEqual({ lib: [], source: { @@ -132,7 +132,7 @@ describe('Should load config file correctly', () => { test('Load config.mjs with defineConfig in esm project', async () => { const fixtureDir = join(__dirname, 'fixtures/config/esm'); const configFilePath = join(fixtureDir, 'rslib.config.mjs'); - const config = await loadConfig({ path: configFilePath }); + const { content: config } = await loadConfig({ path: configFilePath }); expect(config).toMatchObject({ lib: [], source: { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 248a3738f..0632ef420 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -12,6 +12,7 @@ "moduleResolution": "Bundler", "paths": { "commander": ["./compiled/commander"], + "chokidar": ["./compiled/chokidar"], "picocolors": ["./compiled/picocolors"], "rslog": ["./compiled/rslog"] } diff --git a/packages/plugin-dts/src/index.ts b/packages/plugin-dts/src/index.ts index a39467ea6..f278937a2 100644 --- a/packages/plugin-dts/src/index.ts +++ b/packages/plugin-dts/src/index.ts @@ -1,4 +1,4 @@ -import { fork } from 'node:child_process'; +import { type ChildProcess, fork } from 'node:child_process'; import { dirname, extname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { type RsbuildConfig, type RsbuildPlugin, logger } from '@rsbuild/core'; @@ -68,6 +68,7 @@ export const pluginDts = (options: PluginDtsOptions = {}): RsbuildPlugin => ({ const dtsPromises: Promise[] = []; let promisesResult: TaskResult[] = []; + let childProcesses: ChildProcess[] = []; api.onBeforeEnvironmentCompile( async ({ isWatch, isFirstCompile, environment }) => { @@ -107,6 +108,8 @@ export const pluginDts = (options: PluginDtsOptions = {}): RsbuildPlugin => ({ stdio: 'inherit', }); + childProcesses.push(childProcess); + const { cleanDistPath } = config.output; // clean dts files @@ -183,5 +186,17 @@ export const pluginDts = (options: PluginDtsOptions = {}): RsbuildPlugin => ({ }, order: 'post', }); + + const killProcesses = () => { + for (const childProcess of childProcesses) { + if (!childProcess.killed) { + childProcess.kill(); + } + } + childProcesses = []; + }; + + api.onCloseBuild(killProcesses); + api.onCloseDevServer(killProcesses); }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ead196d3..87f67eff4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 + chokidar: + specifier: ^4.0.1 + version: 4.0.1 commander: specifier: ^12.1.0 version: 12.1.0 @@ -544,6 +547,8 @@ importers: tests/integration/cli/build: {} + tests/integration/cli/build-watch: {} + tests/integration/cli/build/custom-root: {} tests/integration/cli/inspect: {} @@ -2932,6 +2937,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -5236,6 +5245,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.0.2: + resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} + engines: {node: '>= 14.16.0'} + recast@0.23.9: resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} engines: {node: '>= 4'} @@ -8820,6 +8833,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.1: + dependencies: + readdirp: 4.0.2 + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} @@ -11518,6 +11535,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.0.2: {} + recast@0.23.9: dependencies: ast-types: 0.16.1 diff --git a/tests/integration/cli/build-watch/build.test.ts b/tests/integration/cli/build-watch/build.test.ts new file mode 100644 index 000000000..7329f26e3 --- /dev/null +++ b/tests/integration/cli/build-watch/build.test.ts @@ -0,0 +1,50 @@ +import { exec } from 'node:child_process'; +import path from 'node:path'; +import fse from 'fs-extra'; +import { awaitFileExists } from 'test-helper'; +import { describe, test } from 'vitest'; + +describe('build --watch command', async () => { + test('basic', async () => { + const distPath = path.join(__dirname, 'dist'); + fse.removeSync(distPath); + + const tempConfigFile = path.join(__dirname, 'test-temp-rslib.config.mjs'); + + fse.outputFileSync( + tempConfigFile, + `import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], +}); + `, + ); + + const process = exec(`npx rslib build --watch -c ${tempConfigFile}`, { + cwd: __dirname, + }); + + const distEsmIndexFile = path.join(__dirname, 'dist/esm/index.js'); + + await awaitFileExists(distEsmIndexFile); + + fse.removeSync(distPath); + + fse.outputFileSync( + tempConfigFile, + `import { defineConfig } from '@rslib/core'; +import { generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [generateBundleEsmConfig()], +}); + `, + ); + + await awaitFileExists(distEsmIndexFile); + + process.kill(); + }); +}); diff --git a/tests/integration/cli/build-watch/package.json b/tests/integration/cli/build-watch/package.json new file mode 100644 index 000000000..180c1ba16 --- /dev/null +++ b/tests/integration/cli/build-watch/package.json @@ -0,0 +1,6 @@ +{ + "name": "cli-build-watch-test", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/tests/integration/cli/build-watch/rslib.config.ts b/tests/integration/cli/build-watch/rslib.config.ts new file mode 100644 index 000000000..e5affdca1 --- /dev/null +++ b/tests/integration/cli/build-watch/rslib.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rslib/core'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; + +export default defineConfig({ + lib: [ + generateBundleEsmConfig({ + dts: true, + }), + generateBundleCjsConfig({ + dts: true, + }), + ], +}); diff --git a/tests/integration/cli/build-watch/src/index.ts b/tests/integration/cli/build-watch/src/index.ts new file mode 100644 index 000000000..3329a7d97 --- /dev/null +++ b/tests/integration/cli/build-watch/src/index.ts @@ -0,0 +1 @@ +export const foo = 'foo'; diff --git a/tests/integration/cli/build-watch/tsconfig.json b/tests/integration/cli/build-watch/tsconfig.json new file mode 100644 index 000000000..fcb8d6133 --- /dev/null +++ b/tests/integration/cli/build-watch/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@rslib/tsconfig/base", + "compilerOptions": { + "rootDir": "src", + "baseUrl": ".", + "composite": true + }, + "include": ["src"] +} diff --git a/tests/scripts/helper.ts b/tests/scripts/helper.ts index ef2860f45..bc693803f 100644 --- a/tests/scripts/helper.ts +++ b/tests/scripts/helper.ts @@ -61,3 +61,33 @@ export const proxyConsole = ( }, }; }; + +export const waitFor = async ( + fn: () => boolean, + { + maxChecks = 100, + interval = 20, + }: { + maxChecks?: number; + interval?: number; + } = {}, +) => { + let checks = 0; + + while (checks < maxChecks) { + if (fn()) { + return true; + } + checks++; + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + return false; +}; + +export const awaitFileExists = async (dir: string) => { + const result = await waitFor(() => fse.existsSync(dir), { interval: 50 }); + if (!result) { + throw new Error(`awaitFileExists failed: ${dir}`); + } +}; diff --git a/tests/scripts/shared.ts b/tests/scripts/shared.ts index ce04e5057..31a60cfbd 100644 --- a/tests/scripts/shared.ts +++ b/tests/scripts/shared.ts @@ -200,7 +200,7 @@ export async function rslibBuild({ path?: string; modifyConfig?: (config: RslibConfig) => void; }) { - const rslibConfig = await loadConfig({ + const { content: rslibConfig } = await loadConfig({ cwd, path, });