Skip to content

Commit 5c331c2

Browse files
committed
feat: restart dev server on config changes
1 parent 56fecd5 commit 5c331c2

6 files changed

Lines changed: 213 additions & 8 deletions

File tree

packages/rstack/src/config.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { loadConfig } from '@rstackjs/load-config';
1+
import { loadConfig, type LoadConfigResult } from '@rstackjs/load-config';
22
import type { RsbuildConfigDefinition } from '@rsbuild/core';
33
import type { RslibConfigDefinition } from '@rslib/core';
44
import type { RslintConfig } from '@rslint/core';
@@ -18,6 +18,10 @@ export type Configs = {
1818
staged?: StagedConfig;
1919
};
2020

21+
type LoadedRstackConfig = Pick<LoadConfigResult, 'filePath' | 'dependencies'> & {
22+
configs: Configs;
23+
};
24+
2125
type ConfigState = {
2226
configs: Configs;
2327
configPath?: string;
@@ -101,12 +105,12 @@ export const define: Define = {
101105
staged: (config) => setConfig('staged', config),
102106
};
103107

104-
export const loadRstackConfig = async (): Promise<Configs> => {
108+
export const loadRstackConfigWithMeta = async (): Promise<LoadedRstackConfig> => {
105109
const state = getConfigState();
106110
state.configs = {};
107111

108112
try {
109-
await loadConfig({
113+
const { filePath, dependencies } = await loadConfig({
110114
loader: 'native',
111115
exportName: false,
112116
fresh: true,
@@ -122,8 +126,17 @@ export const loadRstackConfig = async (): Promise<Configs> => {
122126
}),
123127
});
124128

125-
return state.configs;
129+
return {
130+
configs: state.configs,
131+
filePath,
132+
dependencies,
133+
};
126134
} finally {
127135
state.configs = {};
128136
}
129137
};
138+
139+
export const loadRstackConfig = async (): Promise<Configs> => {
140+
const { configs } = await loadRstackConfigWithMeta();
141+
return configs;
142+
};

packages/rstack/src/rsbuildConfig.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { RsbuildConfigDefinition, ConfigParams } from '@rsbuild/core';
2-
import { loadRstackConfig, type Configs } from './config.js';
1+
import { mergeRsbuildConfig, type RsbuildConfigDefinition, type ConfigParams } from '@rsbuild/core';
2+
import { loadRstackConfigWithMeta, type Configs } from './config.js';
33

44
const resolveRsbuildConfig = async (configs: Configs, params: ConfigParams) => {
55
const appConfig = configs.app;
@@ -13,8 +13,21 @@ const resolveRsbuildConfig = async (configs: Configs, params: ConfigParams) => {
1313
};
1414

1515
const loadRsbuildConfig: RsbuildConfigDefinition = async (params) => {
16-
const configs = await loadRstackConfig();
17-
return resolveRsbuildConfig(configs, params);
16+
const { configs, filePath, dependencies } = await loadRstackConfigWithMeta();
17+
const config = await resolveRsbuildConfig(configs, params);
18+
19+
if (!filePath) {
20+
return config;
21+
}
22+
23+
return mergeRsbuildConfig(config, {
24+
dev: {
25+
watchFiles: {
26+
paths: [filePath, ...dependencies],
27+
type: 'reload-server',
28+
},
29+
},
30+
});
1831
};
1932

2033
export default loadRsbuildConfig;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { type ChildProcess, spawn } from 'node:child_process';
2+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3+
import { createServer } from 'node:net';
4+
import path from 'node:path';
5+
import { setTimeout as delay } from 'node:timers/promises';
6+
import { test } from 'rstack/test';
7+
8+
const RSTACK_BIN_PATH = path.join(import.meta.dirname, '../../../bin/rs.js');
9+
10+
const getRandomPort = (): Promise<number> =>
11+
new Promise((resolve, reject) => {
12+
const server = createServer();
13+
server.once('error', reject);
14+
server.listen(0, '127.0.0.1', () => {
15+
const address = server.address();
16+
if (!address || typeof address === 'string') {
17+
server.close();
18+
reject(new Error('Failed to allocate a port.'));
19+
return;
20+
}
21+
server.close((error) => {
22+
if (error) {
23+
reject(error);
24+
} else {
25+
resolve(address.port);
26+
}
27+
});
28+
});
29+
});
30+
31+
const closeChild = async (child: ChildProcess): Promise<void> => {
32+
if (child.exitCode !== null) {
33+
return;
34+
}
35+
36+
child.kill();
37+
await Promise.race([
38+
new Promise<void>((resolve) => child.once('exit', () => resolve())),
39+
delay(5_000),
40+
]);
41+
42+
if (child.exitCode === null) {
43+
child.kill('SIGKILL');
44+
}
45+
};
46+
47+
const waitForOutput = async (
48+
child: ChildProcess,
49+
getOutput: () => string,
50+
predicate: (output: string) => boolean,
51+
): Promise<void> => {
52+
const deadline = Date.now() + 15_000;
53+
54+
while (Date.now() < deadline) {
55+
const output = getOutput();
56+
if (predicate(output)) {
57+
return;
58+
}
59+
if (child.exitCode !== null) {
60+
throw new Error(`Rstack exited unexpectedly.\n${output}`);
61+
}
62+
await delay(100);
63+
}
64+
65+
throw new Error(`Timed out waiting for Rstack output.\n${getOutput()}`);
66+
};
67+
68+
const createConfig = (value: string) => `import { define } from 'rstack';
69+
70+
console.log('RSTACK_CONFIG_VALUE:${value}');
71+
72+
define.app({
73+
server: {
74+
port: Number(process.env.PORT),
75+
},
76+
});
77+
`;
78+
79+
test('should restart the dev server when Rstack config changes', async () => {
80+
const cwd = await mkdtemp(path.join(import.meta.dirname, 'temp-'));
81+
const configPath = path.join(cwd, 'rstack.config.ts');
82+
await mkdir(path.join(cwd, 'src'));
83+
await writeFile(path.join(cwd, 'src/index.js'), 'console.log("hello");\n');
84+
await writeFile(configPath, createConfig('initial'));
85+
86+
const child = spawn(process.execPath, [RSTACK_BIN_PATH, 'dev'], {
87+
cwd,
88+
env: {
89+
...process.env,
90+
NO_COLOR: '1',
91+
PORT: String(await getRandomPort()),
92+
},
93+
stdio: ['ignore', 'pipe', 'pipe'],
94+
windowsHide: true,
95+
});
96+
let output = '';
97+
child.stdout?.on('data', (data) => {
98+
output += data.toString();
99+
});
100+
child.stderr?.on('data', (data) => {
101+
output += data.toString();
102+
});
103+
child.on('error', (error) => {
104+
output += error.stack ?? error.message;
105+
});
106+
107+
try {
108+
await waitForOutput(
109+
child,
110+
() => output,
111+
(logs) => logs.includes('built in'),
112+
);
113+
await writeFile(configPath, createConfig('updated'));
114+
await waitForOutput(
115+
child,
116+
() => output,
117+
(logs) => logs.includes('restarting server as rstack.config.ts changed'),
118+
);
119+
await waitForOutput(
120+
child,
121+
() => output,
122+
(logs) => logs.includes('RSTACK_CONFIG_VALUE:updated'),
123+
);
124+
await waitForOutput(
125+
child,
126+
() => output,
127+
(logs) => (logs.match(/built in/g) ?? []).length >= 2,
128+
);
129+
} finally {
130+
await closeChild(child);
131+
await rm(cwd, { recursive: true, force: true });
132+
}
133+
}, 30_000);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const appConfig = {
2+
dev: {
3+
watchFiles: {
4+
paths: 'extra.config.ts',
5+
},
6+
},
7+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import path from 'node:path';
2+
import { expect, test } from 'rstack/test';
3+
import { getConfigState } from '../../../src/config.ts';
4+
import loadRsbuildConfig from '../../../src/rsbuildConfig.ts';
5+
6+
test('should watch the Rstack config and its dependencies', async () => {
7+
const state = getConfigState();
8+
const configPath = path.join(import.meta.dirname, 'rstack.config.ts');
9+
const dependencyPath = path.join(import.meta.dirname, 'appConfig.ts');
10+
state.configPath = configPath;
11+
12+
try {
13+
if (typeof loadRsbuildConfig !== 'function') {
14+
throw new Error('Expected the Rsbuild config to be a function.');
15+
}
16+
17+
const config = await loadRsbuildConfig({
18+
env: 'development',
19+
command: 'dev',
20+
});
21+
22+
expect(config.dev?.watchFiles).toEqual([
23+
{
24+
paths: 'extra.config.ts',
25+
},
26+
{
27+
paths: [configPath, dependencyPath],
28+
type: 'reload-server',
29+
},
30+
]);
31+
} finally {
32+
state.configs = {};
33+
delete state.configPath;
34+
}
35+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { define } from 'rstack';
2+
import { appConfig } from './appConfig.ts';
3+
4+
define.app(appConfig);

0 commit comments

Comments
 (0)