Skip to content

Commit 8915358

Browse files
LPegasusiclantondmichon-msft
authored
[rush-sdk]: add named export support for CommonJS compatibility (microsoft#5539)
* feat(rush-sdk): add named export support for CommonJS compatibility This commit enhances @rushstack/rush-sdk to support named imports when the package is consumed via ESM project. * Update common/changes/@microsoft/rush/chore-optimize-named-exports_2026-01-06-12-36.json Co-authored-by: Ian Clanton-Thuon <iclanton@users.noreply.github.com> * [rush-sdk] Revert test snapshot * feat(webpack-deep-imports-plugin): add named exports code generation logic * refactor(rush-sdk): reuse rush-lib/lib assets exports placehold code * fix(rush-sdk): fix unit test in CI with node <= 20.18 * Update webpack/webpack-deep-imports-plugin/src/DeepImportsPlugin.ts Co-authored-by: David Michon <dmichon@microsoft.com> * Update libraries/rush-sdk/webpack.config.js Co-authored-by: David Michon <dmichon@microsoft.com> * Update libraries/rush-sdk/src/generate-stubs.ts Co-authored-by: David Michon <dmichon@microsoft.com> * refactor(webpack-deep-imports-plugin): Use compilation.webpack.WebpackError * feat: Generate sidecar .exports.json files instead of injecting exports prefix - DeepImportsPlugin now emits a separate <moduleName>.exports.json file containing { moduleExports: [...] } instead of injecting 'exports.X = void 0' prefix into JS files - Updated rush-sdk generate-stubs.ts to read exports from the sidecar JSON files - Updated rush-sdk webpack.config.js to read exports from the sidecar JSON files * fix(rush-sdk): Fix ESM named exports for deep imports Use intermediate variable and explicit exports assignment to ensure Node.js CJS lexer properly detects named exports for ESM interop. Remove unnecessary namedExportsPlaceholder since the explicit exports.X = _m.X assignments are sufficient. * refactor(rush-sdk): Use footer instead of banner for ESM exports hints Replace the exports placeholder banner at the top of the webpack bundle with a footer that explicitly assigns exports.X = module.exports.X. Only apply to index.js bundle, not loader.js. * chore(rush-lib): Exclude .exports.json sidecar files from npm package * refactor(rush-sdk): Use async filesystem operations in generate-stubs Refactor generate-stubs.ts to use async filesystem operations and process file tasks in parallel with controlled concurrency using Async.forEachAsync with an async generator. * refactor(tests): Convert synchronous named exports tests to async --------- Co-authored-by: LPegasus <lpegasus@users.noreply.github.com> Co-authored-by: Ian Clanton-Thuon <iclanton@users.noreply.github.com> Co-authored-by: David Michon <dmichon@microsoft.com>
1 parent 3c130b4 commit 8915358

11 files changed

Lines changed: 320 additions & 51 deletions

File tree

.github/workflows/file-doc-tickets.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ jobs:
7676
fi
7777
- name: File ticket
7878
if: ${{ env.FILE_TICKET == '1' }}
79-
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0
79+
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0
8080
with:
8181
repository: microsoft/rushstack-websites
8282
token: '${{ secrets.RUSHSTACK_WEBSITES_PR_TOKEN }}'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Add named exports to support named imports to `@rushstack/rush-sdk`.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

libraries/rush-lib/.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
/lib/**/test/
2121
/lib-*/**/test/
2222
*.test.js
23+
*.exports.json
2324

2425
# NOTE: These don't need to be specified, because NPM includes them automatically.
2526
#

libraries/rush-sdk/config/jest.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"extends": "local-node-rig/profiles/default/config/jest.config.json",
33

4-
"roots": ["<rootDir>/lib-shim"],
4+
"roots": ["<rootDir>/lib-commonjs"],
55

6-
"testMatch": ["<rootDir>/lib-shim/**/*.test.js"],
6+
"testMatch": ["<rootDir>/lib-commonjs/**/*.test.js"],
77

88
"collectCoverageFrom": [
99
"lib-shim/**/*.js",

libraries/rush-sdk/src/generate-stubs.ts

Lines changed: 104 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,67 +3,133 @@
33

44
import * as path from 'node:path';
55

6-
import { FileSystem, Import, Path } from '@rushstack/node-core-library';
6+
import type { IRunScriptOptions } from '@rushstack/heft';
7+
import { Async, FileSystem, type FolderItem, Import, JsonFile, Path } from '@rushstack/node-core-library';
78

8-
function generateLibFilesRecursively(options: {
9+
interface IGenerateOptions {
910
parentSourcePath: string;
1011
parentTargetPath: string;
1112
parentSrcImportPathWithSlash: string;
1213
libShimIndexPath: string;
13-
}): void {
14-
for (const folderItem of FileSystem.readFolderItems(options.parentSourcePath)) {
15-
const sourcePath: string = path.join(options.parentSourcePath, folderItem.name);
16-
const targetPath: string = path.join(options.parentTargetPath, folderItem.name);
14+
}
15+
16+
interface IFileTask {
17+
type: 'dts' | 'js';
18+
sourcePath: string;
19+
targetPath: string;
20+
srcImportPath?: string;
21+
shimPathLiteral?: string;
22+
}
23+
24+
async function* collectFileTasksAsync(options: IGenerateOptions): AsyncGenerator<IFileTask> {
25+
const { parentSourcePath, parentTargetPath, parentSrcImportPathWithSlash, libShimIndexPath } = options;
26+
const folderItems: FolderItem[] = await FileSystem.readFolderItemsAsync(options.parentSourcePath);
27+
28+
for (const folderItem of folderItems) {
29+
const itemName: string = folderItem.name;
30+
const sourcePath: string = `${parentSourcePath}/${itemName}`;
31+
const targetPath: string = `${parentTargetPath}/${itemName}`;
1732

1833
if (folderItem.isDirectory()) {
19-
// create destination folder
20-
FileSystem.ensureEmptyFolder(targetPath);
21-
generateLibFilesRecursively({
34+
// Ensure destination folder exists
35+
await FileSystem.ensureFolderAsync(targetPath);
36+
// Recursively yield tasks from subdirectory
37+
yield* collectFileTasksAsync({
2238
parentSourcePath: sourcePath,
2339
parentTargetPath: targetPath,
24-
parentSrcImportPathWithSlash: options.parentSrcImportPathWithSlash + folderItem.name + '/',
25-
libShimIndexPath: options.libShimIndexPath
40+
parentSrcImportPathWithSlash: parentSrcImportPathWithSlash + itemName + '/',
41+
libShimIndexPath
2642
});
27-
} else {
28-
if (folderItem.name.endsWith('.d.ts')) {
29-
FileSystem.copyFile({
30-
sourcePath: sourcePath,
31-
destinationPath: targetPath
32-
});
33-
} else if (folderItem.name.endsWith('.js')) {
34-
const srcImportPath: string = options.parentSrcImportPathWithSlash + path.parse(folderItem.name).name;
35-
const shimPath: string = path.relative(options.parentTargetPath, options.libShimIndexPath);
36-
const shimPathLiteral: string = JSON.stringify(Path.convertToSlashes(shimPath));
37-
const srcImportPathLiteral: string = JSON.stringify(srcImportPath);
38-
39-
FileSystem.writeFile(
40-
targetPath,
41-
// Example:
42-
// module.exports = require("../../../lib-shim/index")._rushSdk_loadInternalModule("logic/policy/GitEmailPolicy");
43-
`module.exports = require(${shimPathLiteral})._rushSdk_loadInternalModule(${srcImportPathLiteral});`
44-
);
43+
} else if (folderItem.name.endsWith('.d.ts')) {
44+
yield {
45+
type: 'dts',
46+
sourcePath,
47+
targetPath
48+
};
49+
} else if (folderItem.name.endsWith('.js')) {
50+
const srcImportPath: string = parentSrcImportPathWithSlash + path.parse(folderItem.name).name;
51+
const shimPath: string = path.relative(parentTargetPath, libShimIndexPath);
52+
const shimPathLiteral: string = JSON.stringify(Path.convertToSlashes(shimPath));
53+
54+
yield {
55+
type: 'js',
56+
sourcePath,
57+
targetPath,
58+
srcImportPath,
59+
shimPathLiteral
60+
};
61+
}
62+
}
63+
}
64+
65+
async function processFileTaskAsync(task: IFileTask): Promise<void> {
66+
const { type, sourcePath, targetPath, srcImportPath, shimPathLiteral } = task;
67+
if (type === 'dts') {
68+
await FileSystem.copyFileAsync({
69+
sourcePath,
70+
destinationPath: targetPath
71+
});
72+
} else {
73+
const srcImportPathLiteral: string = JSON.stringify(srcImportPath);
74+
75+
let namedExportsAssignment: string = '';
76+
try {
77+
// Read the sidecar .exports.json file generated by DeepImportsPlugin to get module exports
78+
const exportsJsonPath: string = sourcePath.slice(0, -'.js'.length) + '.exports.json';
79+
const { moduleExports }: { moduleExports: string[] } = await JsonFile.loadAsync(exportsJsonPath);
80+
if (moduleExports.length > 0) {
81+
// Assign named exports after module.exports to ensure they're properly exposed for ESM imports
82+
namedExportsAssignment =
83+
'\n' + moduleExports.map((exportName) => `exports.${exportName} = _m.${exportName};`).join('\n');
84+
}
85+
} catch (e) {
86+
if (!FileSystem.isNotExistError(e)) {
87+
throw e;
4588
}
4689
}
90+
91+
await FileSystem.writeFileAsync(
92+
targetPath,
93+
// Example:
94+
// ```
95+
// const _m = require("../../../lib-shim/index")._rushSdk_loadInternalModule("logic/policy/GitEmailPolicy");
96+
// module.exports = _m;
97+
// exports.GitEmailPolicy = _m.GitEmailPolicy;
98+
// ```
99+
`const _m = require(${shimPathLiteral})._rushSdk_loadInternalModule(${srcImportPathLiteral});\nmodule.exports = _m;${namedExportsAssignment}\n`
100+
);
47101
}
48102
}
49103

50104
// Entry point invoked by "runScript" action from config/heft.json
51-
export async function runAsync(): Promise<void> {
105+
export async function runAsync(options: IRunScriptOptions): Promise<void> {
106+
const {
107+
heftConfiguration: { buildFolderPath },
108+
heftTaskSession: {
109+
logger: { terminal }
110+
}
111+
} = options;
112+
52113
const rushLibFolder: string = Import.resolvePackage({
53114
baseFolderPath: __dirname,
54115
packageName: '@microsoft/rush-lib',
55116
useNodeJSResolver: true
56117
});
57118

58-
const stubsTargetPath: string = path.resolve(__dirname, '../lib');
59-
// eslint-disable-next-line no-console
60-
console.log('generate-stubs: Generating stub files under: ' + stubsTargetPath);
61-
generateLibFilesRecursively({
62-
parentSourcePath: path.join(rushLibFolder, 'lib'),
119+
const stubsTargetPath: string = `${buildFolderPath}/lib`;
120+
terminal.writeLine('generate-stubs: Generating stub files under: ' + stubsTargetPath);
121+
122+
// Ensure the target folder exists
123+
await FileSystem.ensureFolderAsync(stubsTargetPath);
124+
125+
// Collect and process file tasks in parallel with controlled concurrency
126+
const tasks: AsyncGenerator<IFileTask> = collectFileTasksAsync({
127+
parentSourcePath: `${rushLibFolder}/lib`,
63128
parentTargetPath: stubsTargetPath,
64129
parentSrcImportPathWithSlash: '',
65-
libShimIndexPath: path.join(__dirname, '../lib-shim/index')
130+
libShimIndexPath: `${buildFolderPath}/lib-shim/index.js`
66131
});
67-
// eslint-disable-next-line no-console
68-
console.log('generate-stubs: Completed successfully.');
132+
await Async.forEachAsync(tasks, processFileTaskAsync, { concurrency: 50 });
133+
134+
terminal.writeLine('generate-stubs: Completed successfully.');
69135
}

libraries/rush-sdk/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ if (sdkContext.rushLibModule === undefined) {
138138
terminal.writeVerboseLine(`Try to load ${RUSH_LIB_NAME} from rush global folder`);
139139
const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder();
140140
// The path needs to keep align with the logic inside RushVersionSelector
141-
const expectedGlobalRushInstalledFolder: string = `${rushGlobalFolder.nodeSpecificPath}/rush-${rushVersion}`;
141+
const expectedGlobalRushInstalledFolder: string = `${rushGlobalFolder.nodeSpecificPath}${path.sep}rush-${rushVersion}`;
142142
terminal.writeVerboseLine(
143143
`The expected global rush installed folder is "${expectedGlobalRushInstalledFolder}"`
144144
);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`@rushstack/rush-sdk Should load via env when Rush has loaded (for child processes): stderr 1`] = `""`;
4+
5+
exports[`@rushstack/rush-sdk Should load via env when Rush has loaded (for child processes): stdout 1`] = `
6+
"Try to load @microsoft/rush-lib from process.env._RUSH_LIB_PATH from caller package
7+
Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH
8+
[
9+
'ApprovedPackagesConfiguration',
10+
'ApprovedPackagesItem',
11+
'ApprovedPackagesPolicy',
12+
'BuildCacheConfiguration',
13+
'BumpType',
14+
'ChangeManager',
15+
'CobuildConfiguration',
16+
'CommonVersionsConfiguration',
17+
'CredentialCache',
18+
'CustomTipId',
19+
'CustomTipSeverity',
20+
'CustomTipType',
21+
'CustomTipsConfiguration',
22+
'DependencyType',
23+
'EnvironmentConfiguration',
24+
'EnvironmentVariableNames',
25+
'Event',
26+
'EventHooks',
27+
'ExperimentsConfiguration',
28+
'FileSystemBuildCacheProvider',
29+
'IndividualVersionPolicy',
30+
'LockStepVersionPolicy',
31+
'LookupByPath',
32+
'NpmOptionsConfiguration',
33+
'Operation',
34+
'OperationStatus',
35+
'PackageJsonDependency',
36+
'PackageJsonDependencyMeta',
37+
'PackageJsonEditor',
38+
'PackageManager',
39+
'PackageManagerOptionsConfigurationBase',
40+
'PhasedCommandHooks',
41+
'PnpmOptionsConfiguration',
42+
'ProjectChangeAnalyzer',
43+
'RepoStateFile',
44+
'Rush',
45+
'RushCommandLine',
46+
'RushConfiguration',
47+
'RushConfigurationProject',
48+
'RushConstants',
49+
'RushLifecycleHooks',
50+
'RushProjectConfiguration',
51+
'RushSession',
52+
'RushUserConfiguration',
53+
'Subspace',
54+
'SubspacesConfiguration',
55+
'VersionPolicy',
56+
'VersionPolicyConfiguration',
57+
'VersionPolicyDefinitionName',
58+
'YarnOptionsConfiguration',
59+
'_FlagFile',
60+
'_OperationBuildCache',
61+
'_OperationMetadataManager',
62+
'_OperationStateFile',
63+
'_RushGlobalFolder',
64+
'_RushInternals',
65+
'_rushSdk_loadInternalModule'
66+
]"
67+
`;
68+
69+
exports[`@rushstack/rush-sdk Should load via global (for plugins): stderr 1`] = `""`;
70+
71+
exports[`@rushstack/rush-sdk Should load via global (for plugins): stdout 1`] = `
72+
"[
73+
'_rushSdk_loadInternalModule',
74+
'foo'
75+
]"
76+
`;
77+
78+
exports[`@rushstack/rush-sdk Should load via install-run (for standalone tools): stderr 1`] = `""`;
79+
80+
exports[`@rushstack/rush-sdk Should load via install-run (for standalone tools): stdout 1`] = `
81+
"Try to load @microsoft/rush-lib from rush global folder
82+
The expected global rush installed folder is \\"<RUSH_GLOBAL_FOLDER>\\"
83+
Failed to load @microsoft/rush-lib from rush global folder: File does not exist: <RUSH_GLOBAL_FOLDER>
84+
ENOENT: no such file or directory, lstat '<RUSH_GLOBAL_FOLDER>'
85+
Trying to load @microsoft/rush-lib installed by install-run-rush
86+
Loaded @microsoft/rush-lib installed by install-run-rush
87+
[
88+
'_rushSdk_loadInternalModule',
89+
'foo'
90+
]
91+
"
92+
`;
93+
94+
exports[`@rushstack/rush-sdk Should load via process.env._RUSH_LIB_PATH (for child processes): stderr 1`] = `""`;
95+
96+
exports[`@rushstack/rush-sdk Should load via process.env._RUSH_LIB_PATH (for child processes): stdout 1`] = `
97+
"Try to load @microsoft/rush-lib from process.env._RUSH_LIB_PATH from caller package
98+
Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH
99+
[
100+
'_rushSdk_loadInternalModule',
101+
'foo'
102+
]"
103+
`;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { Executable } from '@rushstack/node-core-library';
5+
6+
describe('@rushstack/rush-sdk named exports check', () => {
7+
it('Should import named exports correctly (lib-shim)', async () => {
8+
const childProcess = Executable.spawn(process.argv0, [
9+
'-e',
10+
// Do not use top level await here because it is not supported in Node.js < 20.20
11+
`
12+
import('@rushstack/rush-sdk').then(({ RushConfiguration }) => {
13+
console.log(typeof RushConfiguration.loadFromConfigurationFile);
14+
});
15+
`
16+
]);
17+
const { stdout, exitCode, signal } = await Executable.waitForExitAsync(childProcess, {
18+
encoding: 'utf8'
19+
});
20+
21+
expect(stdout.trim()).toEqual('function');
22+
expect(exitCode).toBe(0);
23+
expect(signal).toBeNull();
24+
});
25+
26+
it('Should import named exports correctly (lib)', async () => {
27+
const childProcess = Executable.spawn(process.argv0, [
28+
'-e',
29+
`
30+
import('@rushstack/rush-sdk/lib/utilities/NullTerminalProvider').then(({ NullTerminalProvider }) => {
31+
console.log(NullTerminalProvider.name);
32+
});
33+
`
34+
]);
35+
const { stdout, exitCode, signal } = await Executable.waitForExitAsync(childProcess, {
36+
encoding: 'utf8'
37+
});
38+
39+
expect(stdout.trim()).toEqual('NullTerminalProvider');
40+
expect(exitCode).toBe(0);
41+
expect(signal).toBeNull();
42+
});
43+
});

0 commit comments

Comments
 (0)