Skip to content

Commit 84e60ac

Browse files
committed
fix(config): support custom export conditions from NODE_OPTIONS (#2576)
Node's export conditions passed via `NODE_OPTIONS` (e.g. `--conditions=dev` or `-C custom`) or `process.execArgv` were ignored during `vite.config.ts` resolution. This caused packages whose `exports` map uses a custom condition (e.g. `dev` pointing to `./src/rules.ts` while `default` points to unbuilt `./dist/rules.js`) to fail resolution with `[UNRESOLVED_IMPORT]` and `ERR_MODULE_NOT_FOUND`. - Parse conditions from `process.execArgv` and `process.env.NODE_OPTIONS` in `nodeResolve.ts` - Pass `conditionNames` to Rolldown's `bundleConfigFile` resolver - Ensure TypeScript source files resolved via conditions are bundled rather than externalized - Support conditions in `runnerImport` for SSR - Isolate `resolveCore` to the CLI's own `node_modules` hierarchy Fixes #2576.
1 parent 0931d9b commit 84e60ac

3 files changed

Lines changed: 288 additions & 5 deletions

File tree

packages/cli/src/__tests__/resolve-vite-config.spec.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from 'node:path';
55

66
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
77

8-
import { findViteConfigUp } from '../resolve-vite-config.js';
8+
import { findViteConfigUp, resolveViteConfig } from '../resolve-vite-config.js';
99

1010
describe('findViteConfigUp', () => {
1111
let tempDir: string;
@@ -118,3 +118,129 @@ describe('findViteConfigUp', () => {
118118
expect(result).toBe(path.join(tempDir, 'vite.config.mjs'));
119119
});
120120
});
121+
122+
describe('resolveViteConfig export conditions', () => {
123+
let tempDir: string;
124+
let originalNodeOptions: string | undefined;
125+
126+
beforeEach(() => {
127+
tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-conditions-test-')));
128+
originalNodeOptions = process.env.NODE_OPTIONS;
129+
});
130+
131+
afterEach(() => {
132+
if (originalNodeOptions !== undefined) {
133+
process.env.NODE_OPTIONS = originalNodeOptions;
134+
} else {
135+
delete process.env.NODE_OPTIONS;
136+
}
137+
fs.rmSync(tempDir, { recursive: true, force: true });
138+
});
139+
140+
it('resolves self-referencing package via custom condition from NODE_OPTIONS (--conditions=dev)', async () => {
141+
const pkgJson = {
142+
name: '@test-scope/thing',
143+
type: 'module',
144+
exports: {
145+
'./rules': {
146+
dev: './src/rules.ts',
147+
default: './dist/rules.js',
148+
},
149+
},
150+
};
151+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
152+
153+
const srcDir = path.join(tempDir, 'src');
154+
fs.mkdirSync(srcDir, { recursive: true });
155+
fs.writeFileSync(
156+
path.join(srcDir, 'rules.ts'),
157+
'export const customRule = { name: "custom-rule-from-dev-condition" };\n',
158+
);
159+
160+
const configTs = `
161+
import { customRule } from '@test-scope/thing/rules';
162+
export default {
163+
lint: {
164+
plugins: [customRule],
165+
},
166+
};
167+
`;
168+
fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), configTs);
169+
170+
process.env.NODE_OPTIONS = `${originalNodeOptions || ''} --conditions=dev`.trim();
171+
const config = await resolveViteConfig(tempDir);
172+
expect(config).toBeDefined();
173+
expect(config.lint?.plugins).toEqual([{ name: 'custom-rule-from-dev-condition' }]);
174+
});
175+
176+
it('resolves self-referencing package via -C short flag in NODE_OPTIONS', async () => {
177+
const pkgJson = {
178+
name: '@test-scope/short-flag',
179+
type: 'module',
180+
exports: {
181+
'./plugin': {
182+
custom: './src/plugin.ts',
183+
default: './dist/plugin.js',
184+
},
185+
},
186+
};
187+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
188+
189+
const srcDir = path.join(tempDir, 'src');
190+
fs.mkdirSync(srcDir, { recursive: true });
191+
fs.writeFileSync(
192+
path.join(srcDir, 'plugin.ts'),
193+
'export const flagPlugin = { id: "short-flag-condition" };\n',
194+
);
195+
196+
const configTs = `
197+
import { flagPlugin } from '@test-scope/short-flag/plugin';
198+
export default {
199+
lint: {
200+
plugins: [flagPlugin],
201+
},
202+
};
203+
`;
204+
fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), configTs);
205+
206+
process.env.NODE_OPTIONS = `${originalNodeOptions || ''} -C custom`.trim();
207+
const config = await resolveViteConfig(tempDir);
208+
expect(config).toBeDefined();
209+
expect(config.lint?.plugins).toEqual([{ id: 'short-flag-condition' }]);
210+
});
211+
212+
it('fails to resolve without custom condition when dist does not exist', async () => {
213+
const pkgJson = {
214+
name: '@test-scope/fail-case',
215+
type: 'module',
216+
exports: {
217+
'./rules': {
218+
dev: './src/rules.ts',
219+
default: './dist/rules.js',
220+
},
221+
},
222+
};
223+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
224+
225+
const srcDir = path.join(tempDir, 'src');
226+
fs.mkdirSync(srcDir, { recursive: true });
227+
fs.writeFileSync(
228+
path.join(srcDir, 'rules.ts'),
229+
'export const customRule = { name: "custom-rule" };\n',
230+
);
231+
232+
const configTs = `
233+
import { customRule } from '@test-scope/fail-case/rules';
234+
export default {
235+
lint: {
236+
plugins: [customRule],
237+
},
238+
};
239+
`;
240+
fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), configTs);
241+
242+
// No condition set in NODE_OPTIONS
243+
delete process.env.NODE_OPTIONS;
244+
await expect(resolveViteConfig(tempDir)).rejects.toThrow();
245+
});
246+
});

packages/cli/src/resolve-core.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync } from 'node:fs';
22
import { createRequire } from 'node:module';
3-
import { join } from 'node:path';
3+
import { dirname, join } from 'node:path';
44

55
import { readNearestPackageJson } from './utils/package.ts';
66

@@ -58,14 +58,28 @@ export function resolveCore(
5858
modulePath = import.meta.url,
5959
): string {
6060
const cliRequire = createRequire(modulePath);
61-
// Read the selected CLI's installed manifest. A static JSON import is inlined
62-
// during the build, before CI and preview packers can stamp a new version.
61+
const cliPackageJsonPath = cliRequire.resolve('vite-plus/package.json');
6362
const { version: expectedVersion } = JSON.parse(
64-
readFileSync(cliRequire.resolve('vite-plus/package.json'), 'utf8'),
63+
readFileSync(cliPackageJsonPath, 'utf8'),
6564
) as { version: string };
65+
const cliDir = dirname(cliPackageJsonPath);
6666
let corePackageJsonPath: string;
6767
try {
6868
corePackageJsonPath = cliRequire.resolve('vite/package.json');
69+
let curr = cliDir;
70+
let isBundled = false;
71+
while (true) {
72+
if (corePackageJsonPath.startsWith(join(curr, 'node_modules') + '/')) {
73+
isBundled = true;
74+
break;
75+
}
76+
const parent = dirname(curr);
77+
if (parent === curr) break;
78+
curr = parent;
79+
}
80+
if (!isBundled) {
81+
throw new Error('Resolved outside CLI directory');
82+
}
6983
} catch (cause) {
7084
throw new Error('Could not resolve the bundled Vite dependency. Run `vp install`.', { cause });
7185
}

packages/tools/src/brand-vite.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,5 +197,148 @@ export function brandVite(rootDir: string = process.cwd()) {
197197
),
198198
);
199199

200+
// 7. nodeResolve.ts: Support Node export conditions (e.g. NODE_OPTIONS=--conditions=dev)
201+
const nodeResolveFile = join(nodeDir, 'nodeResolve.ts');
202+
const nodeResolveResults = [
203+
replaceInFile(
204+
nodeResolveFile,
205+
`/**
206+
* Resolve like Node.js using Vite's resolution algorithm with preconfigured options.
207+
*/
208+
export function nodeResolveWithVite(
209+
id: string,
210+
importer: string | undefined,
211+
options: NodeResolveWithViteOptions,
212+
): string | undefined {
213+
return tryNodeResolve(id, importer, {
214+
root: options.root,
215+
isBuild: true,
216+
isProduction: true,
217+
preferRelative: false,
218+
tryIndex: true,
219+
mainFields: [],
220+
conditions: [
221+
'node',
222+
...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
223+
],`,
224+
`/**
225+
* Extract Node condition names from process.execArgv and process.env.NODE_OPTIONS.
226+
*/
227+
export function getNodeConditions(): string[] {
228+
const conditions = new Set<string>()
229+
function parseArgs(args: string[]) {
230+
for (let i = 0; i < args.length; i++) {
231+
const arg = args[i]
232+
if (arg === '-C' || arg === '--conditions') {
233+
if (i + 1 < args.length) conditions.add(args[++i])
234+
} else if (arg.startsWith('--conditions=')) {
235+
conditions.add(arg.slice('--conditions='.length))
236+
}
237+
}
238+
}
239+
if (process.execArgv) parseArgs(process.execArgv)
240+
if (process.env.NODE_OPTIONS) {
241+
const parts = process.env.NODE_OPTIONS.match(/(?:[^\\s"']+|"[^"]*"|'[^']*')+/g) || []
242+
parseArgs(
243+
parts.map((p) =>
244+
(p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))
245+
? p.slice(1, -1)
246+
: p,
247+
),
248+
)
249+
}
250+
return Array.from(conditions)
251+
}
252+
253+
/**
254+
* Resolve like Node.js using Vite's resolution algorithm with preconfigured options.
255+
*/
256+
export function nodeResolveWithVite(
257+
id: string,
258+
importer: string | undefined,
259+
options: NodeResolveWithViteOptions,
260+
): string | undefined {
261+
return tryNodeResolve(id, importer, {
262+
root: options.root,
263+
isBuild: true,
264+
isProduction: true,
265+
preferRelative: false,
266+
tryIndex: true,
267+
mainFields: [],
268+
conditions: [
269+
'node',
270+
...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
271+
...getNodeConditions(),
272+
],`,
273+
),
274+
];
275+
logPatch(
276+
'nodeResolve.ts',
277+
'Supported Node conditions in nodeResolveWithVite',
278+
nodeResolveResults.includes('patched') ? 'patched' : 'already',
279+
);
280+
281+
// 8. config.ts: Support Node conditions and bundle TS sources in bundleConfigFile
282+
const configResults = [
283+
replaceInFile(
284+
configFile,
285+
"import { nodeResolveWithVite } from './nodeResolve'",
286+
"import { getNodeConditions, nodeResolveWithVite } from './nodeResolve'",
287+
),
288+
replaceInFile(
289+
configFile,
290+
` resolve: {
291+
mainFields: ['main'],
292+
},`,
293+
` resolve: {
294+
mainFields: ['main'],
295+
conditionNames: getNodeConditions(),
296+
},`,
297+
),
298+
replaceInFile(
299+
configFile,
300+
` // always no-externalize json files as rolldown does not support import attributes
301+
if (idFsPath.endsWith('.json')) {
302+
return idFsPath
303+
}`,
304+
` // always no-externalize json and ts files as rolldown does not support import attributes / node cannot always run ts directly
305+
if (idFsPath.endsWith('.json') || /\\.(?:[cm]?ts|tsx)$/.test(idFsPath)) {
306+
return idFsPath
307+
}`,
308+
),
309+
];
310+
logPatch(
311+
'config.ts',
312+
'Supported Node conditions and TS inlining in bundleConfigFile',
313+
configResults.includes('patched') ? 'patched' : 'already',
314+
);
315+
316+
// 9. ssr/runnerImport.ts: Support Node conditions in runnerImport
317+
const runnerImportFile = join(nodeDir, 'ssr', 'runnerImport.ts');
318+
const runnerImportResults = [
319+
replaceInFile(
320+
runnerImportFile,
321+
"import type { InlineConfig } from '../config'",
322+
"import type { InlineConfig } from '../config'\nimport { getNodeConditions } from '../nodeResolve'",
323+
),
324+
replaceInFile(
325+
runnerImportFile,
326+
` conditions: [
327+
'node',
328+
...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
329+
],`,
330+
` conditions: [
331+
'node',
332+
...(isModuleSyncConditionEnabled ? ['module-sync'] : []),
333+
...getNodeConditions(),
334+
],`,
335+
),
336+
];
337+
logPatch(
338+
'ssr/runnerImport.ts',
339+
'Supported Node conditions in runnerImport',
340+
runnerImportResults.includes('patched') ? 'patched' : 'already',
341+
);
342+
200343
log('Done!');
201344
}

0 commit comments

Comments
 (0)