Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/devkit/src/utils/package-json.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,15 @@ describe('ensurePackage', () => {

expect(ensurePackage('@nx/devkit', '>=15.0.0')).toEqual(
require('@nx/devkit')
); // return void
);
});

it('should warm the package cache when multiple packages are present', () => {
writeJson(tree, 'package.json', {});

expect(ensurePackage({ '@nx/devkit': '>=15.0.0' })).toBeUndefined();
expect(ensurePackage('@nx/devkit', '>=15.0.0')).toEqual(
require('@nx/devkit')
);
});
});
148 changes: 124 additions & 24 deletions packages/devkit/src/utils/package-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
updateJson,
workspaceRoot,
} from 'nx/src/devkit-exports';
import { installPackageToTmp } from 'nx/src/devkit-internals';
import {
installPackageToTmp,
installPackagesToTmp,
} from 'nx/src/devkit-internals';
import type { PackageToInstall } from 'nx/src/utils/package-json';
import type {
PackageJson,
PackageJsonDependencySection,
Expand Down Expand Up @@ -869,25 +873,67 @@ export function ensurePackage<T extends any = any>(
pkg: string,
version: string
): T;
/**
* Ensure that multiple packages are installed at the required versions in a
* single intermediate installation step. Prefer this form when a generator may
* need several packages in the same run. It avoids spinning up a fresh tmp
* project per package.
*
* For example:
* ```typescript
* ensurePackage({
* '@nx/eslint': nxVersion,
* '@nx/vite': nxVersion,
* '@nx/vitest': nxVersion,
* });
* ```
*
* Individual packages can still be read afterwards with the single-package
* form, which will hit the module cache warmed by this call.
*/
export function ensurePackage(packages: Record<string, string>): void;
export function ensurePackage<T extends any = any>(
pkgOrTree: string | Tree,
requiredVersionOrPackage: string,
pkgOrTreeOrPackages: string | Tree | Record<string, string>,
requiredVersionOrPackage?: string,
maybeRequiredVersion?: string,
_?: never
): T {
): T | void {
if (isPackageMap(pkgOrTreeOrPackages)) {
ensurePackages(pkgOrTreeOrPackages);
return;
}

let pkg: string;
let requiredVersion: string;
if (typeof pkgOrTree === 'string') {
pkg = pkgOrTree;
if (typeof pkgOrTreeOrPackages === 'string') {
pkg = pkgOrTreeOrPackages;
requiredVersion = requiredVersionOrPackage;
} else {
// Old Signature
pkg = requiredVersionOrPackage;
requiredVersion = maybeRequiredVersion;
}

return ensureSinglePackage(pkg, requiredVersion) as T;
}

function isPackageMap(
value: string | Tree | Record<string, string>
): value is Record<string, string> {
if (typeof value !== 'object' || value === null) {
return false;
}
// A Tree has a `root` string and filesystem methods like `read`/`write`.
// A package map is a plain object whose values are all strings.
if (typeof (value as Tree).read === 'function') {
return false;
}
return Object.values(value).every((v) => typeof v === 'string');
}

function ensureSinglePackage(pkg: string, requiredVersion: string): unknown {
if (packageMapCache.has(pkg)) {
return packageMapCache.get(pkg) as T;
return packageMapCache.get(pkg);
}

try {
Expand All @@ -909,31 +955,85 @@ export function ensurePackage<T extends any = any>(
}

const { tempDir } = installPackageToTmp(pkg, requiredVersion);
loadInstalledPackagesFromTmp(tempDir, [pkg]);
return packageMapCache.get(pkg);
}

function ensurePackages(packages: Record<string, string>): void {
const toInstall: PackageToInstall[] = [];
for (const [pkg, requiredVersion] of Object.entries(packages)) {
if (packageMapCache.has(pkg)) {
continue;
}
try {
const mod = require(pkg);
packageMapCache.set(pkg, mod);
continue;
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
// Already installed; consumer must dynamic import it.
packageMapCache.set(pkg, null);
continue;
} else if (e.code !== 'MODULE_NOT_FOUND') {
throw e;
}
}
toInstall.push({ pkg, requiredVersion });
}

if (toInstall.length === 0) {
return;
}

if (process.env.NX_DRY_RUN && process.env.NX_DRY_RUN !== 'false') {
throw new Error(
'NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the "Generate" button.\n'
);
}

// `installPackagesToTmp` was added alongside the batch `ensurePackage`
// overload. Older nx cores (within the +/- 1 major compat window) don't
// export it. Degrade gracefully to sequential single-package installs so
// that the call still succeeds, just without the batching win.
if (typeof installPackagesToTmp === 'function') {
const { tempDir } = installPackagesToTmp(toInstall);
loadInstalledPackagesFromTmp(
tempDir,
toInstall.map(({ pkg }) => pkg)
);
return;
}

for (const { pkg, requiredVersion } of toInstall) {
const { tempDir } = installPackageToTmp(pkg, requiredVersion);
loadInstalledPackagesFromTmp(tempDir, [pkg]);
}
}

function loadInstalledPackagesFromTmp(tempDir: string, pkgs: string[]): void {
addToNodePath(join(workspaceRoot, 'node_modules'));
addToNodePath(join(tempDir, 'node_modules'));

// Re-initialize the added paths into require
(Module as any)._initPaths();

try {
const result = require(
require.resolve(pkg, {
paths: [tempDir],
})
);

packageMapCache.set(pkg, result);

return result;
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
// The package is installed, but is an ESM package.
// The consumer of this function can import it as needed.
packageMapCache.set(pkg, null);
return null;
for (const pkg of pkgs) {
try {
const result = require(
require.resolve(pkg, {
paths: [tempDir],
})
);
packageMapCache.set(pkg, result);
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
// The package is installed, but is an ESM package.
// The consumer of this function can import it as needed.
packageMapCache.set(pkg, null);
continue;
}
throw e;
}
throw e;
}
}

Expand Down
42 changes: 29 additions & 13 deletions packages/expo/src/generators/application/lib/add-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,30 @@ export async function addE2e(
options: NormalizedSchema
): Promise<GeneratorCallback> {
const hasPlugin = hasExpoPlugin(tree);

// Batch the optional @nx/web install with whichever e2e runner was
// chosen so a typical "no expo plugin + cypress" run only provisions one
// tmp project instead of two.
const packagesToEnsure: Record<string, string> = {};
if (!hasPlugin) {
packagesToEnsure['@nx/web'] = nxVersion;
}
switch (options.e2eTestRunner) {
case 'cypress':
packagesToEnsure['@nx/cypress'] = nxVersion;
break;
case 'playwright':
packagesToEnsure['@nx/playwright'] = nxVersion;
break;
case 'detox':
packagesToEnsure['@nx/detox'] = nxVersion;
break;
}
ensurePackage(packagesToEnsure);

if (!hasPlugin) {
const { webStaticServeGenerator } = ensurePackage<typeof import('@nx/web')>(
'@nx/web',
nxVersion
);
const { webStaticServeGenerator } =
require('@nx/web') as typeof import('@nx/web');

await webStaticServeGenerator(tree, {
buildTarget: `${options.projectName}:export`,
Expand All @@ -39,9 +58,8 @@ export async function addE2e(

switch (options.e2eTestRunner) {
case 'cypress': {
const { configurationGenerator } = ensurePackage<
typeof import('@nx/cypress')
>('@nx/cypress', nxVersion);
const { configurationGenerator } =
require('@nx/cypress') as typeof import('@nx/cypress');

const packageJson: PackageJson = {
name: options.e2eProjectName,
Expand Down Expand Up @@ -94,9 +112,8 @@ export async function addE2e(
return e2eTask;
}
case 'playwright': {
const { configurationGenerator } = ensurePackage<
typeof import('@nx/playwright')
>('@nx/playwright', nxVersion);
const { configurationGenerator } =
require('@nx/playwright') as typeof import('@nx/playwright');
const packageJson: PackageJson = {
name: options.e2eProjectName,
version: '0.0.1',
Expand Down Expand Up @@ -143,9 +160,8 @@ export async function addE2e(
return e2eTask;
}
case 'detox':
const { detoxApplicationGenerator } = ensurePackage<
typeof import('@nx/detox')
>('@nx/detox', nxVersion);
const { detoxApplicationGenerator } =
require('@nx/detox') as typeof import('@nx/detox');
return detoxApplicationGenerator(tree, {
...options,
e2eName: options.e2eProjectName,
Expand Down
35 changes: 31 additions & 4 deletions packages/js/src/generators/library/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@ export async function libraryGeneratorInternal(
);
const options = await normalizeOptions(tree, schema);

// Batch every Nx plugin this generator (and its local helpers like
// `addLint` / `addJest`) may need into a single intermediate tmp install.
// Downstream `ensurePackage` calls will hit the module cache for free.
const packagesToEnsure: Record<string, string> = {};
if (options.bundler === 'rollup') {
packagesToEnsure['@nx/rollup'] = nxVersion;
}
if (options.bundler === 'vite') {
packagesToEnsure['@nx/vite'] = nxVersion;
}
if (options.unitTestRunner === 'vitest' && options.bundler !== 'vite') {
packagesToEnsure['@nx/vitest'] = nxVersion;
}
if (options.linter !== 'none') {
packagesToEnsure['@nx/eslint'] = nxVersion;
}
if (options.unitTestRunner === 'jest') {
packagesToEnsure['@nx/jest'] = nxVersion;
}
ensurePackage(packagesToEnsure);

createFiles(tree, options);

await configureProject(tree, options);
Expand All @@ -120,7 +141,11 @@ export async function libraryGeneratorInternal(
}

if (options.bundler === 'rollup') {
const { configurationGenerator } = ensurePackage('@nx/rollup', nxVersion);
// `@nx/rollup` isn't on the type-resolution path for `@nx/js`, so it's
// required untyped here. The preceding batch `ensurePackage` call
// guarantees the module is resolvable at runtime.
// nx-ignore-next-line
const { configurationGenerator } = require('@nx/rollup');
await configurationGenerator(tree, {
project: options.name,
compiler: 'swc',
Expand All @@ -130,8 +155,9 @@ export async function libraryGeneratorInternal(
}

if (options.bundler === 'vite') {
// nx-ignore-next-line
const { viteConfigurationGenerator, createOrEditViteConfig } =
ensurePackage('@nx/vite', nxVersion);
require('@nx/vite') as typeof import('@nx/vite');
const viteTask = await viteConfigurationGenerator(tree, {
project: options.name,
newProject: true,
Expand Down Expand Up @@ -174,7 +200,6 @@ export async function libraryGeneratorInternal(
options.unitTestRunner === 'vitest' &&
options.bundler !== 'vite' // Test would have been set up already
) {
ensurePackage('@nx/vitest', nxVersion);
// nx-ignore-next-line
const { configurationGenerator } = require('@nx/vitest/generators');
const vitestTask = await configurationGenerator(tree, {
Expand Down Expand Up @@ -716,7 +741,9 @@ async function addJest(
tree: Tree,
options: NormalizedLibraryGeneratorOptions
): Promise<GeneratorCallback> {
const { configurationGenerator } = ensurePackage('@nx/jest', nxVersion);
// nx-ignore-next-line
const { configurationGenerator } =
require('@nx/jest') as typeof import('@nx/jest');
return await configurationGenerator(tree, {
...options,
project: options.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,15 @@ export async function cypressComponentConfigurationInternal(
) {
const tasks: GeneratorCallback[] = [];

const { componentConfigurationGenerator: baseCyCtConfig } = ensurePackage<
typeof import('@nx/cypress')
>('@nx/cypress', nxVersion);
// Batch the cypress + webpack tmp installs so both plugins share a single
// intermediate tmp project.
ensurePackage({
'@nx/cypress': nxVersion,
'@nx/webpack': nxVersion,
});

const { componentConfigurationGenerator: baseCyCtConfig } =
require('@nx/cypress') as typeof import('@nx/cypress');
tasks.push(
await baseCyCtConfig(tree, {
project: options.project,
Expand All @@ -46,10 +52,8 @@ export async function cypressComponentConfigurationInternal(
})
);

const { webpackInitGenerator } = ensurePackage<typeof import('@nx/webpack')>(
'@nx/webpack',
nxVersion
);
const { webpackInitGenerator } =
require('@nx/webpack') as typeof import('@nx/webpack');
tasks.push(
await webpackInitGenerator(tree, {
skipFormat: true,
Expand Down
11 changes: 6 additions & 5 deletions packages/nuxt/src/generators/application/lib/add-vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export async function addVitest(tree: Tree, options: NormalizedSchema) {
: p.plugin === '@nx/nuxt/plugin'
);

const { createOrEditViteConfig } = ensurePackage<typeof import('@nx/vite')>(
'@nx/vite',
nxVersion
);
ensurePackage('@nx/vitest', nxVersion);
ensurePackage({
'@nx/vite': nxVersion,
'@nx/vitest': nxVersion,
});
const { createOrEditViteConfig } =
require('@nx/vite') as typeof import('@nx/vite');
const { configurationGenerator } = await import('@nx/vitest/generators');

const vitestTask = await configurationGenerator(
Expand Down
Loading
Loading