diff --git a/.github/workflows/file-doc-tickets.yml b/.github/workflows/file-doc-tickets.yml index 544a395a4c5..b2968d451f7 100644 --- a/.github/workflows/file-doc-tickets.yml +++ b/.github/workflows/file-doc-tickets.yml @@ -76,7 +76,7 @@ jobs: fi - name: File ticket if: ${{ env.FILE_TICKET == '1' }} - uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0 + uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0 with: repository: microsoft/rushstack-websites token: '${{ secrets.RUSHSTACK_WEBSITES_PR_TOKEN }}' diff --git a/common/changes/@microsoft/rush/chore-optimize-named-exports_2026-01-06-12-36.json b/common/changes/@microsoft/rush/chore-optimize-named-exports_2026-01-06-12-36.json new file mode 100644 index 00000000000..2f84e0e56f8 --- /dev/null +++ b/common/changes/@microsoft/rush/chore-optimize-named-exports_2026-01-06-12-36.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add named exports to support named imports to `@rushstack/rush-sdk`.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/libraries/rush-lib/.npmignore b/libraries/rush-lib/.npmignore index 2c1b4d582e5..9194692aaff 100644 --- a/libraries/rush-lib/.npmignore +++ b/libraries/rush-lib/.npmignore @@ -20,6 +20,7 @@ /lib/**/test/ /lib-*/**/test/ *.test.js +*.exports.json # NOTE: These don't need to be specified, because NPM includes them automatically. # diff --git a/libraries/rush-sdk/config/jest.config.json b/libraries/rush-sdk/config/jest.config.json index 62da56b72ce..81cf6b77794 100644 --- a/libraries/rush-sdk/config/jest.config.json +++ b/libraries/rush-sdk/config/jest.config.json @@ -1,9 +1,9 @@ { "extends": "local-node-rig/profiles/default/config/jest.config.json", - "roots": ["/lib-shim"], + "roots": ["/lib-commonjs"], - "testMatch": ["/lib-shim/**/*.test.js"], + "testMatch": ["/lib-commonjs/**/*.test.js"], "collectCoverageFrom": [ "lib-shim/**/*.js", diff --git a/libraries/rush-sdk/src/generate-stubs.ts b/libraries/rush-sdk/src/generate-stubs.ts index c25cce990cc..6baeaa2943f 100644 --- a/libraries/rush-sdk/src/generate-stubs.ts +++ b/libraries/rush-sdk/src/generate-stubs.ts @@ -3,67 +3,133 @@ import * as path from 'node:path'; -import { FileSystem, Import, Path } from '@rushstack/node-core-library'; +import type { IRunScriptOptions } from '@rushstack/heft'; +import { Async, FileSystem, type FolderItem, Import, JsonFile, Path } from '@rushstack/node-core-library'; -function generateLibFilesRecursively(options: { +interface IGenerateOptions { parentSourcePath: string; parentTargetPath: string; parentSrcImportPathWithSlash: string; libShimIndexPath: string; -}): void { - for (const folderItem of FileSystem.readFolderItems(options.parentSourcePath)) { - const sourcePath: string = path.join(options.parentSourcePath, folderItem.name); - const targetPath: string = path.join(options.parentTargetPath, folderItem.name); +} + +interface IFileTask { + type: 'dts' | 'js'; + sourcePath: string; + targetPath: string; + srcImportPath?: string; + shimPathLiteral?: string; +} + +async function* collectFileTasksAsync(options: IGenerateOptions): AsyncGenerator { + const { parentSourcePath, parentTargetPath, parentSrcImportPathWithSlash, libShimIndexPath } = options; + const folderItems: FolderItem[] = await FileSystem.readFolderItemsAsync(options.parentSourcePath); + + for (const folderItem of folderItems) { + const itemName: string = folderItem.name; + const sourcePath: string = `${parentSourcePath}/${itemName}`; + const targetPath: string = `${parentTargetPath}/${itemName}`; if (folderItem.isDirectory()) { - // create destination folder - FileSystem.ensureEmptyFolder(targetPath); - generateLibFilesRecursively({ + // Ensure destination folder exists + await FileSystem.ensureFolderAsync(targetPath); + // Recursively yield tasks from subdirectory + yield* collectFileTasksAsync({ parentSourcePath: sourcePath, parentTargetPath: targetPath, - parentSrcImportPathWithSlash: options.parentSrcImportPathWithSlash + folderItem.name + '/', - libShimIndexPath: options.libShimIndexPath + parentSrcImportPathWithSlash: parentSrcImportPathWithSlash + itemName + '/', + libShimIndexPath }); - } else { - if (folderItem.name.endsWith('.d.ts')) { - FileSystem.copyFile({ - sourcePath: sourcePath, - destinationPath: targetPath - }); - } else if (folderItem.name.endsWith('.js')) { - const srcImportPath: string = options.parentSrcImportPathWithSlash + path.parse(folderItem.name).name; - const shimPath: string = path.relative(options.parentTargetPath, options.libShimIndexPath); - const shimPathLiteral: string = JSON.stringify(Path.convertToSlashes(shimPath)); - const srcImportPathLiteral: string = JSON.stringify(srcImportPath); - - FileSystem.writeFile( - targetPath, - // Example: - // module.exports = require("../../../lib-shim/index")._rushSdk_loadInternalModule("logic/policy/GitEmailPolicy"); - `module.exports = require(${shimPathLiteral})._rushSdk_loadInternalModule(${srcImportPathLiteral});` - ); + } else if (folderItem.name.endsWith('.d.ts')) { + yield { + type: 'dts', + sourcePath, + targetPath + }; + } else if (folderItem.name.endsWith('.js')) { + const srcImportPath: string = parentSrcImportPathWithSlash + path.parse(folderItem.name).name; + const shimPath: string = path.relative(parentTargetPath, libShimIndexPath); + const shimPathLiteral: string = JSON.stringify(Path.convertToSlashes(shimPath)); + + yield { + type: 'js', + sourcePath, + targetPath, + srcImportPath, + shimPathLiteral + }; + } + } +} + +async function processFileTaskAsync(task: IFileTask): Promise { + const { type, sourcePath, targetPath, srcImportPath, shimPathLiteral } = task; + if (type === 'dts') { + await FileSystem.copyFileAsync({ + sourcePath, + destinationPath: targetPath + }); + } else { + const srcImportPathLiteral: string = JSON.stringify(srcImportPath); + + let namedExportsAssignment: string = ''; + try { + // Read the sidecar .exports.json file generated by DeepImportsPlugin to get module exports + const exportsJsonPath: string = sourcePath.slice(0, -'.js'.length) + '.exports.json'; + const { moduleExports }: { moduleExports: string[] } = await JsonFile.loadAsync(exportsJsonPath); + if (moduleExports.length > 0) { + // Assign named exports after module.exports to ensure they're properly exposed for ESM imports + namedExportsAssignment = + '\n' + moduleExports.map((exportName) => `exports.${exportName} = _m.${exportName};`).join('\n'); + } + } catch (e) { + if (!FileSystem.isNotExistError(e)) { + throw e; } } + + await FileSystem.writeFileAsync( + targetPath, + // Example: + // ``` + // const _m = require("../../../lib-shim/index")._rushSdk_loadInternalModule("logic/policy/GitEmailPolicy"); + // module.exports = _m; + // exports.GitEmailPolicy = _m.GitEmailPolicy; + // ``` + `const _m = require(${shimPathLiteral})._rushSdk_loadInternalModule(${srcImportPathLiteral});\nmodule.exports = _m;${namedExportsAssignment}\n` + ); } } // Entry point invoked by "runScript" action from config/heft.json -export async function runAsync(): Promise { +export async function runAsync(options: IRunScriptOptions): Promise { + const { + heftConfiguration: { buildFolderPath }, + heftTaskSession: { + logger: { terminal } + } + } = options; + const rushLibFolder: string = Import.resolvePackage({ baseFolderPath: __dirname, packageName: '@microsoft/rush-lib', useNodeJSResolver: true }); - const stubsTargetPath: string = path.resolve(__dirname, '../lib'); - // eslint-disable-next-line no-console - console.log('generate-stubs: Generating stub files under: ' + stubsTargetPath); - generateLibFilesRecursively({ - parentSourcePath: path.join(rushLibFolder, 'lib'), + const stubsTargetPath: string = `${buildFolderPath}/lib`; + terminal.writeLine('generate-stubs: Generating stub files under: ' + stubsTargetPath); + + // Ensure the target folder exists + await FileSystem.ensureFolderAsync(stubsTargetPath); + + // Collect and process file tasks in parallel with controlled concurrency + const tasks: AsyncGenerator = collectFileTasksAsync({ + parentSourcePath: `${rushLibFolder}/lib`, parentTargetPath: stubsTargetPath, parentSrcImportPathWithSlash: '', - libShimIndexPath: path.join(__dirname, '../lib-shim/index') + libShimIndexPath: `${buildFolderPath}/lib-shim/index.js` }); - // eslint-disable-next-line no-console - console.log('generate-stubs: Completed successfully.'); + await Async.forEachAsync(tasks, processFileTaskAsync, { concurrency: 50 }); + + terminal.writeLine('generate-stubs: Completed successfully.'); } diff --git a/libraries/rush-sdk/src/index.ts b/libraries/rush-sdk/src/index.ts index d42a1398b91..beaa80bb959 100644 --- a/libraries/rush-sdk/src/index.ts +++ b/libraries/rush-sdk/src/index.ts @@ -138,7 +138,7 @@ if (sdkContext.rushLibModule === undefined) { terminal.writeVerboseLine(`Try to load ${RUSH_LIB_NAME} from rush global folder`); const rushGlobalFolder: RushGlobalFolder = new RushGlobalFolder(); // The path needs to keep align with the logic inside RushVersionSelector - const expectedGlobalRushInstalledFolder: string = `${rushGlobalFolder.nodeSpecificPath}/rush-${rushVersion}`; + const expectedGlobalRushInstalledFolder: string = `${rushGlobalFolder.nodeSpecificPath}${path.sep}rush-${rushVersion}`; terminal.writeVerboseLine( `The expected global rush installed folder is "${expectedGlobalRushInstalledFolder}"` ); diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap new file mode 100644 index 00000000000..b497003e629 --- /dev/null +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`@rushstack/rush-sdk Should load via env when Rush has loaded (for child processes): stderr 1`] = `""`; + +exports[`@rushstack/rush-sdk Should load via env when Rush has loaded (for child processes): stdout 1`] = ` +"Try to load @microsoft/rush-lib from process.env._RUSH_LIB_PATH from caller package +Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH +[ + 'ApprovedPackagesConfiguration', + 'ApprovedPackagesItem', + 'ApprovedPackagesPolicy', + 'BuildCacheConfiguration', + 'BumpType', + 'ChangeManager', + 'CobuildConfiguration', + 'CommonVersionsConfiguration', + 'CredentialCache', + 'CustomTipId', + 'CustomTipSeverity', + 'CustomTipType', + 'CustomTipsConfiguration', + 'DependencyType', + 'EnvironmentConfiguration', + 'EnvironmentVariableNames', + 'Event', + 'EventHooks', + 'ExperimentsConfiguration', + 'FileSystemBuildCacheProvider', + 'IndividualVersionPolicy', + 'LockStepVersionPolicy', + 'LookupByPath', + 'NpmOptionsConfiguration', + 'Operation', + 'OperationStatus', + 'PackageJsonDependency', + 'PackageJsonDependencyMeta', + 'PackageJsonEditor', + 'PackageManager', + 'PackageManagerOptionsConfigurationBase', + 'PhasedCommandHooks', + 'PnpmOptionsConfiguration', + 'ProjectChangeAnalyzer', + 'RepoStateFile', + 'Rush', + 'RushCommandLine', + 'RushConfiguration', + 'RushConfigurationProject', + 'RushConstants', + 'RushLifecycleHooks', + 'RushProjectConfiguration', + 'RushSession', + 'RushUserConfiguration', + 'Subspace', + 'SubspacesConfiguration', + 'VersionPolicy', + 'VersionPolicyConfiguration', + 'VersionPolicyDefinitionName', + 'YarnOptionsConfiguration', + '_FlagFile', + '_OperationBuildCache', + '_OperationMetadataManager', + '_OperationStateFile', + '_RushGlobalFolder', + '_RushInternals', + '_rushSdk_loadInternalModule' +]" +`; + +exports[`@rushstack/rush-sdk Should load via global (for plugins): stderr 1`] = `""`; + +exports[`@rushstack/rush-sdk Should load via global (for plugins): stdout 1`] = ` +"[ + '_rushSdk_loadInternalModule', + 'foo' +]" +`; + +exports[`@rushstack/rush-sdk Should load via install-run (for standalone tools): stderr 1`] = `""`; + +exports[`@rushstack/rush-sdk Should load via install-run (for standalone tools): stdout 1`] = ` +"Try to load @microsoft/rush-lib from rush global folder +The expected global rush installed folder is \\"\\" +Failed to load @microsoft/rush-lib from rush global folder: File does not exist: +ENOENT: no such file or directory, lstat '' +Trying to load @microsoft/rush-lib installed by install-run-rush +Loaded @microsoft/rush-lib installed by install-run-rush +[ + '_rushSdk_loadInternalModule', + 'foo' +] +" +`; + +exports[`@rushstack/rush-sdk Should load via process.env._RUSH_LIB_PATH (for child processes): stderr 1`] = `""`; + +exports[`@rushstack/rush-sdk Should load via process.env._RUSH_LIB_PATH (for child processes): stdout 1`] = ` +"Try to load @microsoft/rush-lib from process.env._RUSH_LIB_PATH from caller package +Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH +[ + '_rushSdk_loadInternalModule', + 'foo' +]" +`; diff --git a/libraries/rush-sdk/src/test/build-assets-with-named-exports.test.ts b/libraries/rush-sdk/src/test/build-assets-with-named-exports.test.ts new file mode 100644 index 00000000000..4edcb80d19e --- /dev/null +++ b/libraries/rush-sdk/src/test/build-assets-with-named-exports.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Executable } from '@rushstack/node-core-library'; + +describe('@rushstack/rush-sdk named exports check', () => { + it('Should import named exports correctly (lib-shim)', async () => { + const childProcess = Executable.spawn(process.argv0, [ + '-e', + // Do not use top level await here because it is not supported in Node.js < 20.20 + ` +import('@rushstack/rush-sdk').then(({ RushConfiguration }) => { +console.log(typeof RushConfiguration.loadFromConfigurationFile); + }); +` + ]); + const { stdout, exitCode, signal } = await Executable.waitForExitAsync(childProcess, { + encoding: 'utf8' + }); + + expect(stdout.trim()).toEqual('function'); + expect(exitCode).toBe(0); + expect(signal).toBeNull(); + }); + + it('Should import named exports correctly (lib)', async () => { + const childProcess = Executable.spawn(process.argv0, [ + '-e', + ` +import('@rushstack/rush-sdk/lib/utilities/NullTerminalProvider').then(({ NullTerminalProvider }) => { +console.log(NullTerminalProvider.name); + }); +` + ]); + const { stdout, exitCode, signal } = await Executable.waitForExitAsync(childProcess, { + encoding: 'utf8' + }); + + expect(stdout.trim()).toEqual('NullTerminalProvider'); + expect(exitCode).toBe(0); + expect(signal).toBeNull(); + }); +}); diff --git a/libraries/rush-sdk/src/test/script.test.ts b/libraries/rush-sdk/src/test/script.test.ts index d7c8b8d0de1..cdc34846f2a 100644 --- a/libraries/rush-sdk/src/test/script.test.ts +++ b/libraries/rush-sdk/src/test/script.test.ts @@ -2,11 +2,12 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; -import { Executable } from '@rushstack/node-core-library'; +import { Executable, User } from '@rushstack/node-core-library'; const rushSdkPath: string = path.join(__dirname, '../../lib-shim/index.js'); const sandboxRepoPath: string = `${__dirname}/sandbox`; const mockPackageFolder: string = `${sandboxRepoPath}/mock-package`; +const mockRushJsonPath: string = `${sandboxRepoPath}/rush.json`; const mockRushLibPath: string = `${__dirname}/fixture/mock-rush-lib.js`; const coreLibPath: string = require.resolve('@rushstack/node-core-library'); @@ -101,8 +102,18 @@ ${loadAndPrintRushSdkModule} } } ); + + const nodeVersion = process.version; + const userRushSdkFolder = path.join( + User.getHomeFolder(), + '.rush', + `node-${nodeVersion}`, + 'rush-' + require(mockRushJsonPath).rushVersion + ); expect(result.stderr.trim()).toMatchSnapshot('stderr'); - expect(result.stdout.trim()).toMatchSnapshot('stdout'); + expect( + result.stdout.replace(new RegExp(userRushSdkFolder.replace(/\\/g, '\\\\'), 'g'), '') + ).toMatchSnapshot('stdout'); expect(result.status).toBe(0); }); }); diff --git a/libraries/rush-sdk/webpack.config.js b/libraries/rush-sdk/webpack.config.js index b86d582f7a8..df7ae5c3929 100644 --- a/libraries/rush-sdk/webpack.config.js +++ b/libraries/rush-sdk/webpack.config.js @@ -1,13 +1,26 @@ /* eslint-env es6 */ 'use strict'; -const { PackageJsonLookup } = require('@rushstack/node-core-library'); +const { PackageJsonLookup, Import } = require('@rushstack/node-core-library'); const { PreserveDynamicRequireWebpackPlugin } = require('@rushstack/webpack-preserve-dynamic-require-plugin'); +const {} = require('webpack'); -module.exports = () => { +module.exports = ({ webpack: { BannerPlugin } }) => { const packageJson = PackageJsonLookup.loadOwnPackageJson(__dirname); - const externalDependencyNames = new Set([...Object.keys(packageJson.dependencies || {})]); + const externalDependencyNames = new Set(Object.keys(packageJson.dependencies || {})); + + // Get all export specifiers from the sidecar .exports.json file generated by DeepImportsPlugin + const rushLibFolder = Import.resolvePackage({ + baseFolderPath: __dirname, + packageName: '@microsoft/rush-lib', + useNodeJSResolver: true + }); + const { moduleExports: exportSpecifiers } = require(`${rushLibFolder}/lib/index.exports.json`); + // Assign named exports after the bundle to ensure they're properly exposed for ESM imports + const footerCodeForLibShim = exportSpecifiers + .map((name) => `exports.${name} = module.exports.${name};`) + .join('\n'); // Explicitly exclude @microsoft/rush-lib externalDependencyNames.delete('@microsoft/rush-lib'); @@ -41,7 +54,15 @@ module.exports = () => { innerGraph: true }, target: 'node', - plugins: [new PreserveDynamicRequireWebpackPlugin()], + plugins: [ + new BannerPlugin({ + raw: true, + footer: true, + include: /index\.js$/, + banner: footerCodeForLibShim + }), + new PreserveDynamicRequireWebpackPlugin() + ], externals: [ ({ request }, callback) => { let packageName; diff --git a/webpack/webpack-deep-imports-plugin/src/DeepImportsPlugin.ts b/webpack/webpack-deep-imports-plugin/src/DeepImportsPlugin.ts index 281035faa6f..ff5b2327d8a 100644 --- a/webpack/webpack-deep-imports-plugin/src/DeepImportsPlugin.ts +++ b/webpack/webpack-deep-imports-plugin/src/DeepImportsPlugin.ts @@ -3,13 +3,14 @@ import path from 'node:path'; -import { DllPlugin, type Compiler, WebpackError, type Chunk, type NormalModule } from 'webpack'; +import { DllPlugin, type Compiler, type Chunk, type NormalModule, type ModuleGraph } from 'webpack'; import { Async, FileSystem, LegacyAdapters, Path } from '@rushstack/node-core-library'; const PLUGIN_NAME: 'DeepImportsPlugin' = 'DeepImportsPlugin'; type DllPluginOptions = DllPlugin['options']; +type IExportsInfo = ReturnType; /** * @public @@ -122,6 +123,8 @@ export class DeepImportsPlugin extends DllPlugin { public apply(compiler: Compiler): void { super.apply(compiler); + const { WebpackError } = compiler.webpack; + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { compilation.hooks.processAssets.tapPromise(PLUGIN_NAME, async () => { const runtimeChunks: Chunk[] = []; @@ -147,6 +150,7 @@ export class DeepImportsPlugin extends DllPlugin { libPathWithoutExtension: string; moduleId: string | number | null; secondaryChunkId: string | undefined; + exportsInfo: IExportsInfo; } const pathsToIgnoreWithoutExtension: Set = this._pathsToIgnoreWithoutExtensions; @@ -170,7 +174,8 @@ export class DeepImportsPlugin extends DllPlugin { libModules.push({ libPathWithoutExtension: relativePathWithoutExtension, moduleId: compilation.chunkGraph.getModuleId(runtimeChunkModule), - secondaryChunkId + secondaryChunkId, + exportsInfo: compilation.moduleGraph.getExportsInfo(runtimeChunkModule) // Record exportsInfo to generate named exports placeholder code }); encounteredLibPaths.add(relativePathWithoutExtension); @@ -233,7 +238,7 @@ export class DeepImportsPlugin extends DllPlugin { await Async.forEachAsync( libModules, - async ({ libPathWithoutExtension, moduleId, secondaryChunkId }) => { + async ({ libPathWithoutExtension, moduleId, secondaryChunkId, exportsInfo }) => { const depth: number = countSlashes(libPathWithoutExtension); const requirePath: string = '../'.repeat(depth) + libOutFolderRelativeOutputPath; let moduleText: string; @@ -255,6 +260,15 @@ export class DeepImportsPlugin extends DllPlugin { new compiler.webpack.sources.RawSource(moduleText) ); + const providedExports: null | true | string[] = exportsInfo.getProvidedExports(); + if (Array.isArray(providedExports) && providedExports.length > 0) { + const exportsJson: string = JSON.stringify({ moduleExports: providedExports }, undefined, 2); + compilation.emitAsset( + `${outputPathRelativeLibOutFolder}/${libPathWithoutExtension}.exports.json`, + new compiler.webpack.sources.RawSource(exportsJson) + ); + } + if (resolvedDtsFilesInputFolderName) { const dtsFilePath: string = path.join( resolvedDtsFilesInputFolderName,