Skip to content

Commit b86f754

Browse files
authored
feat: support watch rslib.config to rebuild (#489)
1 parent f97ff83 commit b86f754

File tree

21 files changed

+314
-29
lines changed

21 files changed

+314
-29
lines changed

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"devDependencies": {
4545
"@rslib/tsconfig": "workspace:*",
4646
"@types/fs-extra": "^11.0.4",
47+
"chokidar": "^4.0.1",
4748
"commander": "^12.1.0",
4849
"fs-extra": "^11.2.0",
4950
"memfs": "^4.14.0",

packages/core/prebundle.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export default {
1313
},
1414
dependencies: [
1515
'commander',
16+
{
17+
name: 'chokidar',
18+
// strip sourcemap comment
19+
prettier: true,
20+
},
1621
{
1722
name: 'rslog',
1823
afterBundle(task) {

packages/core/rslib.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default defineConfig({
2525
externals: {
2626
picocolors: '../compiled/picocolors/index.js',
2727
commander: '../compiled/commander/index.js',
28+
chokidar: '../compiled/chokidar/index.js',
2829
rslog: '../compiled/rslog/index.js',
2930
},
3031
},

packages/core/src/cli/build.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type RsbuildInstance, createRsbuild } from '@rsbuild/core';
22
import { composeRsbuildEnvironments, pruneEnvironments } from '../config';
33
import type { RslibConfig } from '../types/config';
44
import type { BuildOptions } from './commands';
5+
import { onBeforeRestart } from './restart';
56

67
export async function build(
78
config: RslibConfig,
@@ -14,9 +15,15 @@ export async function build(
1415
},
1516
});
1617

17-
await rsbuildInstance.build({
18+
const buildInstance = await rsbuildInstance.build({
1819
watch: options.watch,
1920
});
2021

22+
if (options.watch) {
23+
onBeforeRestart(buildInstance.close);
24+
} else {
25+
await buildInstance.close();
26+
}
27+
2128
return rsbuildInstance;
2229
}

packages/core/src/cli/commands.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { build } from './build';
55
import { loadRslibConfig } from './init';
66
import { inspect } from './inspect';
77
import { startMFDevServer } from './mf';
8+
import { watchFilesForRestart } from './restart';
89

910
export type CommonOptions = {
1011
root?: string;
@@ -62,11 +63,20 @@ export function runCli(): void {
6263
.description('build the library for production')
6364
.action(async (options: BuildOptions) => {
6465
try {
65-
const rslibConfig = await loadRslibConfig(options);
66-
await build(rslibConfig, {
67-
lib: options.lib,
68-
watch: options.watch,
69-
});
66+
const cliBuild = async () => {
67+
const { content: rslibConfig, filePath } =
68+
await loadRslibConfig(options);
69+
70+
await build(rslibConfig, options);
71+
72+
if (options.watch) {
73+
watchFilesForRestart([filePath], async () => {
74+
await cliBuild();
75+
});
76+
}
77+
};
78+
79+
await cliBuild();
7080
} catch (err) {
7181
logger.error('Failed to build.');
7282
logger.error(err);
@@ -90,7 +100,7 @@ export function runCli(): void {
90100
.action(async (options: InspectOptions) => {
91101
try {
92102
// TODO: inspect should output Rslib's config
93-
const rslibConfig = await loadRslibConfig(options);
103+
const { content: rslibConfig } = await loadRslibConfig(options);
94104
await inspect(rslibConfig, {
95105
lib: options.lib,
96106
mode: options.mode,
@@ -108,9 +118,18 @@ export function runCli(): void {
108118
.description('start Rsbuild dev server of Module Federation format')
109119
.action(async (options: CommonOptions) => {
110120
try {
111-
const rslibConfig = await loadRslibConfig(options);
112-
// TODO: support lib option in mf dev server
113-
await startMFDevServer(rslibConfig);
121+
const cliMfDev = async () => {
122+
const { content: rslibConfig, filePath } =
123+
await loadRslibConfig(options);
124+
// TODO: support lib option in mf dev server
125+
await startMFDevServer(rslibConfig);
126+
127+
watchFilesForRestart([filePath], async () => {
128+
await cliMfDev();
129+
});
130+
};
131+
132+
await cliMfDev();
114133
} catch (err) {
115134
logger.error('Failed to start mf dev.');
116135
logger.error(err);

packages/core/src/cli/init.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,16 @@ import type { RslibConfig } from '../types';
33
import { getAbsolutePath } from '../utils/helper';
44
import type { CommonOptions } from './commands';
55

6-
export async function loadRslibConfig(
7-
options: CommonOptions,
8-
): Promise<RslibConfig> {
6+
export async function loadRslibConfig(options: CommonOptions): Promise<{
7+
content: RslibConfig;
8+
filePath: string;
9+
}> {
910
const cwd = process.cwd();
1011
const root = options.root ? getAbsolutePath(cwd, options.root) : cwd;
1112

12-
const rslibConfig = await loadConfig({
13+
return loadConfig({
1314
cwd: root,
1415
path: options.config,
1516
envMode: options.envMode,
1617
});
17-
18-
return rslibConfig;
1918
}

packages/core/src/cli/mf.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createRsbuild, mergeRsbuildConfig } from '@rsbuild/core';
22
import type { RsbuildConfig, RsbuildInstance } from '@rsbuild/core';
33
import { composeCreateRsbuildConfig } from '../config';
44
import type { RslibConfig } from '../types';
5+
import { onBeforeRestart } from './restart';
56

67
export async function startMFDevServer(
78
config: RslibConfig,
@@ -27,7 +28,9 @@ async function initMFRsbuild(
2728
const rsbuildInstance = await createRsbuild({
2829
rsbuildConfig: mfRsbuildConfig.config,
2930
});
30-
await rsbuildInstance.startDevServer();
31+
const devServer = await rsbuildInstance.startDevServer();
32+
33+
onBeforeRestart(devServer.server.close);
3134
return rsbuildInstance;
3235
}
3336

packages/core/src/cli/restart.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import path from 'node:path';
2+
import chokidar from 'chokidar';
3+
import { color, debounce, isTTY } from '../utils/helper';
4+
import { logger } from '../utils/logger';
5+
6+
export async function watchFilesForRestart(
7+
files: string[],
8+
restart: () => Promise<void>,
9+
): Promise<void> {
10+
if (!files.length) {
11+
return;
12+
}
13+
14+
const watcher = chokidar.watch(files, {
15+
ignoreInitial: true,
16+
// If watching fails due to read permissions, the errors will be suppressed silently.
17+
ignorePermissionErrors: true,
18+
ignored: ['**/node_modules/**', '**/.git/**', '**/.DS_Store/**'],
19+
});
20+
21+
const callback = debounce(
22+
async (filePath) => {
23+
watcher.close();
24+
25+
await beforeRestart({ filePath });
26+
await restart();
27+
},
28+
// set 300ms debounce to avoid restart frequently
29+
300,
30+
);
31+
32+
watcher.on('add', callback);
33+
watcher.on('change', callback);
34+
watcher.on('unlink', callback);
35+
}
36+
37+
type Cleaner = () => Promise<unknown> | unknown;
38+
39+
let cleaners: Cleaner[] = [];
40+
41+
/**
42+
* Add a cleaner to handle side effects
43+
*/
44+
export const onBeforeRestart = (cleaner: Cleaner): void => {
45+
cleaners.push(cleaner);
46+
};
47+
48+
const clearConsole = () => {
49+
if (isTTY() && !process.env.DEBUG) {
50+
process.stdout.write('\x1B[H\x1B[2J');
51+
}
52+
};
53+
54+
const beforeRestart = async ({
55+
filePath,
56+
clear = true,
57+
}: {
58+
filePath?: string;
59+
clear?: boolean;
60+
} = {}): Promise<void> => {
61+
if (clear) {
62+
clearConsole();
63+
}
64+
65+
if (filePath) {
66+
const filename = path.basename(filePath);
67+
logger.info(`Restart because ${color.yellow(filename)} is changed.\n`);
68+
} else {
69+
logger.info('Restarting...\n');
70+
}
71+
72+
for (const cleaner of cleaners) {
73+
await cleaner();
74+
}
75+
cleaners = [];
76+
};

packages/core/src/config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,18 @@ export async function loadConfig({
117117
cwd?: string;
118118
path?: string;
119119
envMode?: string;
120-
}): Promise<RslibConfig> {
120+
}): Promise<{
121+
content: RslibConfig;
122+
filePath: string;
123+
}> {
121124
const configFilePath = resolveConfigPath(cwd, path);
122125
const { content } = await loadRsbuildConfig({
123126
cwd: dirname(configFilePath),
124127
path: configFilePath,
125128
envMode,
126129
});
127130

128-
return content as RslibConfig;
131+
return { content: content as RslibConfig, filePath: configFilePath };
129132
}
130133

131134
const composeExternalsWarnConfig = (

packages/core/src/utils/helper.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,31 @@ export function checkMFPlugin(config: LibConfig): boolean {
205205
return added;
206206
}
207207

208+
export function debounce<T extends (...args: any[]) => void>(
209+
func: T,
210+
wait: number,
211+
): (...args: Parameters<T>) => void {
212+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
213+
214+
return (...args: Parameters<T>) => {
215+
if (timeoutId !== null) {
216+
clearTimeout(timeoutId);
217+
}
218+
219+
timeoutId = setTimeout(() => {
220+
func(...args);
221+
}, wait);
222+
};
223+
}
224+
225+
/**
226+
* Check if running in a TTY context
227+
*/
228+
export const isTTY = (type: 'stdin' | 'stdout' = 'stdout'): boolean => {
229+
return (
230+
(type === 'stdin' ? process.stdin.isTTY : process.stdout.isTTY) &&
231+
!process.env.CI
232+
);
233+
};
234+
208235
export { color };

packages/core/tests/config.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('Should load config file correctly', () => {
1313
test('Load config.js in cjs project', async () => {
1414
const fixtureDir = join(__dirname, 'fixtures/config/cjs');
1515
const configFilePath = join(fixtureDir, 'rslib.config.js');
16-
const config = await loadConfig({ path: configFilePath });
16+
const { content: config } = await loadConfig({ path: configFilePath });
1717
expect(config).toEqual({
1818
lib: [],
1919
source: {
@@ -30,7 +30,7 @@ describe('Should load config file correctly', () => {
3030
test('Load config.mjs in cjs project', async () => {
3131
const fixtureDir = join(__dirname, 'fixtures/config/cjs');
3232
const configFilePath = join(fixtureDir, 'rslib.config.mjs');
33-
const config = await loadConfig({ path: configFilePath });
33+
const { content: config } = await loadConfig({ path: configFilePath });
3434
expect(config).toEqual({
3535
lib: [],
3636
source: {
@@ -47,7 +47,7 @@ describe('Should load config file correctly', () => {
4747
test('Load config.ts in cjs project', async () => {
4848
const fixtureDir = join(__dirname, 'fixtures/config/cjs');
4949
const configFilePath = join(fixtureDir, 'rslib.config.ts');
50-
const config = await loadConfig({ path: configFilePath });
50+
const { content: config } = await loadConfig({ path: configFilePath });
5151
expect(config).toEqual({
5252
lib: [],
5353
source: {
@@ -64,7 +64,7 @@ describe('Should load config file correctly', () => {
6464
test('Load config.cjs with defineConfig in cjs project', async () => {
6565
const fixtureDir = join(__dirname, 'fixtures/config/cjs');
6666
const configFilePath = join(fixtureDir, 'rslib.config.cjs');
67-
const config = await loadConfig({ path: configFilePath });
67+
const { content: config } = await loadConfig({ path: configFilePath });
6868
expect(config).toEqual({
6969
lib: [],
7070
source: {
@@ -81,7 +81,7 @@ describe('Should load config file correctly', () => {
8181
test('Load config.js in esm project', async () => {
8282
const fixtureDir = join(__dirname, 'fixtures/config/esm');
8383
const configFilePath = join(fixtureDir, 'rslib.config.js');
84-
const config = await loadConfig({ path: configFilePath });
84+
const { content: config } = await loadConfig({ path: configFilePath });
8585
expect(config).toEqual({
8686
lib: [],
8787
source: {
@@ -98,7 +98,7 @@ describe('Should load config file correctly', () => {
9898
test('Load config.cjs in esm project', async () => {
9999
const fixtureDir = join(__dirname, 'fixtures/config/esm');
100100
const configFilePath = join(fixtureDir, 'rslib.config.cjs');
101-
const config = await loadConfig({ path: configFilePath });
101+
const { content: config } = await loadConfig({ path: configFilePath });
102102
expect(config).toEqual({
103103
lib: [],
104104
source: {
@@ -115,7 +115,7 @@ describe('Should load config file correctly', () => {
115115
test('Load config.ts in esm project', async () => {
116116
const fixtureDir = join(__dirname, 'fixtures/config/esm');
117117
const configFilePath = join(fixtureDir, 'rslib.config.ts');
118-
const config = await loadConfig({ path: configFilePath });
118+
const { content: config } = await loadConfig({ path: configFilePath });
119119
expect(config).toEqual({
120120
lib: [],
121121
source: {
@@ -132,7 +132,7 @@ describe('Should load config file correctly', () => {
132132
test('Load config.mjs with defineConfig in esm project', async () => {
133133
const fixtureDir = join(__dirname, 'fixtures/config/esm');
134134
const configFilePath = join(fixtureDir, 'rslib.config.mjs');
135-
const config = await loadConfig({ path: configFilePath });
135+
const { content: config } = await loadConfig({ path: configFilePath });
136136
expect(config).toMatchObject({
137137
lib: [],
138138
source: {

packages/core/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"moduleResolution": "Bundler",
1313
"paths": {
1414
"commander": ["./compiled/commander"],
15+
"chokidar": ["./compiled/chokidar"],
1516
"picocolors": ["./compiled/picocolors"],
1617
"rslog": ["./compiled/rslog"]
1718
}

0 commit comments

Comments
 (0)