Skip to content

Commit 10b9eeb

Browse files
committed
refactor(config): add internal config layer primitives
1 parent a5898b2 commit 10b9eeb

2 files changed

Lines changed: 241 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type {
2+
ConfigParams as AppConfigParams,
3+
RsbuildConfig,
4+
} from '@rsbuild/core';
5+
import type { ConfigParams as LibConfigParams, RslibConfig } from '@rslib/core';
6+
import type { RslintConfig } from '@rslint/core';
7+
import type { UserConfig as RspressConfig } from '@rspress/core';
8+
import type { RstestConfig } from '@rstest/core';
9+
import type { Configs } from './config.ts';
10+
import type { FmtConfig } from './fmt/types.ts';
11+
import type { StagedConfig } from './staged.ts';
12+
13+
/** Internal definitions, including the deferred lint factory created by define.lint. */
14+
export type ConfigLayer = Readonly<Configs>;
15+
16+
/** Ordered from the lowest to the highest precedence; no tool merge rules here. */
17+
export type ConfigLayers = readonly ConfigLayer[];
18+
19+
type ConfigValues = {
20+
app: RsbuildConfig;
21+
lib: RslibConfig;
22+
doc: RspressConfig;
23+
test: RstestConfig;
24+
lint: RslintConfig;
25+
fmt: FmtConfig;
26+
staged: StagedConfig;
27+
};
28+
29+
type ConfigArgs<K extends keyof Configs> = K extends 'app'
30+
? [params: AppConfigParams]
31+
: K extends 'lib'
32+
? [params: LibConfigParams]
33+
: [];
34+
35+
/** Collect definitions without running factories or modifying the supplied layers. */
36+
export const createConfigLayers = (
37+
project: ConfigLayer,
38+
inherited: ConfigLayers = [],
39+
): ConfigLayers => [...inherited, project];
40+
41+
/**
42+
* Resolve only the requested tool, in layer order. Missing definitions contribute
43+
* nothing; defaults and merging belong to the tool adapter. Each call evaluates
44+
* its factories anew, without caching across native parameters or loads.
45+
*/
46+
export const resolveConfigLayers = async <K extends keyof Configs>(
47+
layers: ConfigLayers,
48+
kind: K,
49+
...args: ConfigArgs<K>
50+
): Promise<ConfigValues[K][]> => {
51+
const configs: ConfigValues[K][] = [];
52+
53+
for (const layer of layers) {
54+
const definition = layer[kind];
55+
if (definition === undefined) {
56+
continue;
57+
}
58+
59+
// A staged function generates tasks from file names; it is not a factory.
60+
if (kind !== 'staged' && typeof definition === 'function') {
61+
const factory = definition as (
62+
...args: ConfigArgs<K>
63+
) => ConfigValues[K] | Promise<ConfigValues[K]>;
64+
configs.push(await factory(...args));
65+
} else {
66+
configs.push(definition as ConfigValues[K]);
67+
}
68+
}
69+
70+
return configs;
71+
};
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { ConfigParams } from 'rstack/app';
2+
import { expect, rs, test } from 'rstack/test';
3+
import {
4+
createConfigLayers,
5+
resolveConfigLayers,
6+
type ConfigLayer,
7+
} from '../../src/configLayers.ts';
8+
9+
test('collects inherited layers before the project without resolving definitions', () => {
10+
const factory = rs.fn(() => ({}));
11+
const base = Object.freeze({ app: factory });
12+
const team = Object.freeze({ test: factory });
13+
const project = Object.freeze({ app: factory });
14+
const inherited = Object.freeze([base, team]);
15+
16+
const layers = createConfigLayers(project, inherited);
17+
18+
expect(layers).toEqual([base, team, project]);
19+
expect(layers[0]).toBe(base);
20+
expect(layers[2]).toBe(project);
21+
expect(inherited).toEqual([base, team]);
22+
expect(createConfigLayers(project)).toEqual([project]);
23+
expect(factory).not.toHaveBeenCalled();
24+
});
25+
26+
test.each(['app', 'lib'] as const)(
27+
'passes native parameters unchanged to every %s factory',
28+
async (kind) => {
29+
const params: ConfigParams = {
30+
command: 'build',
31+
env: 'production',
32+
envMode: 'staging',
33+
meta: { caller: 'test' },
34+
};
35+
const baseConfig = { root: 'base' };
36+
const projectConfig = { root: 'project' };
37+
const base = rs.fn((_params: ConfigParams) => baseConfig);
38+
const project = rs.fn((_params: ConfigParams) =>
39+
Promise.resolve(projectConfig),
40+
);
41+
const layers = createConfigLayers({ [kind]: project }, [{ [kind]: base }]);
42+
43+
const configs = await resolveConfigLayers(layers, kind, params);
44+
45+
expect(configs).toEqual([baseConfig, projectConfig]);
46+
expect(configs[0]).toBe(baseConfig);
47+
expect(configs[1]).toBe(projectConfig);
48+
expect(base).toHaveBeenCalledExactlyOnceWith(params);
49+
expect(project).toHaveBeenCalledExactlyOnceWith(params);
50+
expect(base.mock.calls[0][0]).toBe(params);
51+
expect(project.mock.calls[0][0]).toBe(params);
52+
},
53+
);
54+
55+
test('awaits layers sequentially and only resolves the selected tool', async () => {
56+
const started = Promise.withResolvers<void>();
57+
const ready = Promise.withResolvers<void>();
58+
const baseConfig = Object.freeze({ retry: 2 });
59+
const projectConfig = Object.freeze({ retry: 1 });
60+
const unrelated = rs.fn(() => {
61+
throw new Error('Unselected tool must stay lazy');
62+
});
63+
const project = rs.fn(() => projectConfig);
64+
const layers = createConfigLayers({ test: project, app: unrelated }, [
65+
{
66+
async test() {
67+
started.resolve();
68+
await ready.promise;
69+
return baseConfig;
70+
},
71+
fmt: unrelated,
72+
},
73+
]);
74+
75+
const resolving = resolveConfigLayers(layers, 'test');
76+
await started.promise;
77+
try {
78+
expect(project).not.toHaveBeenCalled();
79+
} finally {
80+
ready.resolve();
81+
}
82+
const configs = await resolving;
83+
84+
expect(configs).toEqual([baseConfig, projectConfig]);
85+
expect(configs[0]).toBe(baseConfig);
86+
expect(configs[1]).toBe(projectConfig);
87+
expect(project).toHaveBeenCalledExactlyOnceWith();
88+
expect(unrelated).not.toHaveBeenCalled();
89+
});
90+
91+
test.each(['doc', 'test', 'lint', 'fmt'] as const)(
92+
'resolves %s values and factories without injecting arguments',
93+
async (kind) => {
94+
const values = {
95+
doc: { title: 'Docs' },
96+
test: { retry: 2 },
97+
lint: [],
98+
fmt: { singleQuote: true },
99+
};
100+
const factory = rs.fn(() => Promise.resolve(values[kind]));
101+
const layers = createConfigLayers({ [kind]: factory }, [
102+
{ [kind]: values[kind] },
103+
]);
104+
105+
const configs = await resolveConfigLayers(layers, kind);
106+
107+
expect(configs).toEqual([values[kind], values[kind]]);
108+
expect(configs[0]).toBe(values[kind]);
109+
expect(configs[1]).toBe(values[kind]);
110+
expect(factory).toHaveBeenCalledExactlyOnceWith();
111+
},
112+
);
113+
114+
test('skips missing definitions while preserving explicitly empty configs', async () => {
115+
const emptyConfig = {};
116+
const emptyLint: ConfigLayer = { lint: [] };
117+
const layers = createConfigLayers({ test: emptyConfig }, [{}, emptyLint]);
118+
119+
expect(await resolveConfigLayers(layers, 'test')).toEqual([emptyConfig]);
120+
expect(await resolveConfigLayers(layers, 'lint')).toEqual([[]]);
121+
expect(await resolveConfigLayers(layers, 'fmt')).toEqual([]);
122+
});
123+
124+
test('preserves staged task generators without invoking them', async () => {
125+
const task = rs.fn((_files: readonly string[]) => 'rs lint');
126+
const generator = rs.fn((_files: readonly string[]) =>
127+
Promise.resolve(['rs fmt']),
128+
);
129+
const tasks = { '*.ts': task };
130+
const layers = createConfigLayers({ staged: generator }, [{ staged: tasks }]);
131+
132+
const configs = await resolveConfigLayers(layers, 'staged');
133+
134+
expect(configs[0]).toBe(tasks);
135+
expect(configs[1]).toBe(generator);
136+
expect(task).not.toHaveBeenCalled();
137+
expect(generator).not.toHaveBeenCalled();
138+
});
139+
140+
test('stops at a failed factory and preserves its error', async () => {
141+
const error = new Error('Invalid shared config');
142+
const project = rs.fn(() => ({}));
143+
const layers = createConfigLayers({ fmt: project }, [
144+
{
145+
fmt: () => Promise.reject(error),
146+
},
147+
]);
148+
149+
await expect(resolveConfigLayers(layers, 'fmt')).rejects.toBe(error);
150+
expect(project).not.toHaveBeenCalled();
151+
});
152+
153+
test('keeps simultaneous resolutions with different parameters independent', async () => {
154+
const factory = rs.fn(async ({ env }: ConfigParams) => {
155+
await Promise.resolve();
156+
return { root: env };
157+
});
158+
const layers = createConfigLayers({ app: factory });
159+
160+
const configs = await Promise.all([
161+
resolveConfigLayers(layers, 'app', { command: 'build', env: 'production' }),
162+
resolveConfigLayers(layers, 'app', { command: 'dev', env: 'development' }),
163+
]);
164+
165+
expect(configs).toEqual([
166+
[{ root: 'production' }],
167+
[{ root: 'development' }],
168+
]);
169+
expect(factory).toHaveBeenCalledTimes(2);
170+
});

0 commit comments

Comments
 (0)