Jonathan.Brink
Jonathan.Brink

Reputation: 25383

Determine dependency's greatest matching version that exists on an NPM server from a semver version

I'm writing a node script which helps pin dependencies.

How can I determine the greatest realized version of a package existing on an NPM server, from a semver version?

For example, we have a dependency "foo" which is specified in a package.json as ~1.2.3. Out on NPM, there exists published version 1.2.5, which is the latest published version compatible with ~1.2.3.

I need to write a script that would take as input "foo" and ~1.2.3, then after a server query, return 1.2.5. Something like this:

await fetchRealizedVersion('foo', '~1.2.3'); // resolves to 1.2.5

I understand I could do something like yarn upgrade and then parse the lock file, but I am looking for a more direct way of accomplishing this. Hopefully there is a package that boils this down to an API call, but I'm not finding anything after googling around.

Upvotes: 6

Views: 1371

Answers (1)

RobC
RobC

Reputation: 24992

"Hopefully there is a package that boils this down to an API call,"

Short Answer: Unfortunately no, there is not a package that currently exists as far as I know.

Edit: There is the get-latest-version package you may want to try:

Basic usage:

const getLatestVersion = require('get-latest-version')

getLatestVersion('some-other-module', {range: '^1.0.0'})
 .then((version) => console.log(version)) // highest version matching ^1.0.0 range
 .catch((err) => console.error(err))

Alternatively, consider utilizing/writing a custom node.js module to perform the following steps:

  1. Either:

    • Shell out the npm view command to retrieve all versions that are available in the NPM registry for a given package: For instance:

      npm view <pkg> versions --json
      
    • Or, directly make a https request to the public npm registry at https://registry.npmjs.org to retrieve all versions available in for a given package.

  1. Parse the JSON returned and pass it, along with the semver range (e.g. ~1.2.3), to the node-semver package's maxSatisfying() method.

    The maxSatisfying() method is described in the docs as:

    maxSatisfying(versions, range): Return the highest version in the list that satisfies the range, or null if none of them do.


Custom module (A):

The custom example module provided in get-latest-version.js (below) essentially performs the aforementioned steps. In this example we shell out the npm view command.

get-latest-version.js

'use strict';

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const { exec } = require('child_process');
const { maxSatisfying } = require('semver');

//------------------------------------------------------------------------------
// Data
//------------------------------------------------------------------------------

const errorBadge = '\x1b[31;40mERR!\x1b[0m';

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/**
 * Captures the data written to stdout from a given shell command.
 *
 * @param {String} command The shell command to execute.
 * @return {Promise<string>} A Promise object whose fulfillment value holds the
 * data written to stdout. When rejected an error message is returned.
 * @private
 */
function shellExec(command) {
  return new Promise((resolve, reject) => {
    exec(command, (error, stdout, stderr) => {
      if (error) {
        reject(new Error(`Failed executing command: '${command}'`));
        return;
      }
      resolve(stdout.trim());
    });
  });
}

//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------

module.exports = {

  /**
   * Retrieves the latest version that matches the given range for a package.
   *
   * @async
   * @param {String} pkg The package name.
   * @param {String} range The semver range.
   * @returns {Promise<string>} A Promise object that when fulfilled returns the
   * latest version that matches. When rejected an error message is returned.
   */
  async fetchRealizedVersion(pkg, range) {
    try {
      const response = await shellExec(`npm view ${pkg} versions --json`);
      const versions = JSON.parse(response);

      return maxSatisfying(versions, range);

    } catch ({ message: errorMssg }) {
      throw Error([
        `${errorBadge} ${errorMssg}`,
        `${errorBadge} '${pkg}' is probably not in the npm registry.`
      ].join('\n'));
    }
  }

};

Usage:

The following index.js demonstrates using the aforementioned module.

index.js

'use strict';

const { fetchRealizedVersion } = require('./get-latest-version.js');

(async function() {
  try {
    const latest = await fetchRealizedVersion('eslint', '~5.15.0');
    console.log(latest); // --> 5.15.3
  } catch ({ message: errMssg }) {
    console.error(errMssg);
  }
})();

As you can see, in that example we obtain the latest published version for the eslint package that is compatible with the semver tilde range ~5.15.0.

The latest/maximum version that satisfies ~5.15.0 is printed to the console:

$ node ./index.js
5.15.3

Note: You can always double check the results using the online semver calculator which actually utilizes the node-semver package.

Another Usage Example:

The following index.js demonstrates using the aforementioned module to obtain the latest/maximum version for multiple packages and different ranges.

index.js

'use strict';

const { fetchRealizedVersion } = require('./get-latest-version.js');

const criteria = [
  {
    pkg: 'eslint',
    range: '^4.9.0'
  },
  {
    pkg: 'eslint',
    range: '~5.0.0'
  },
  {
    pkg: 'lighthouse',
    range: '~1.0.0'
  },
  {
    pkg: 'lighthouse',
    range: '^1.0.4'
  },
  {
    pkg: 'yarn',
    range: '~1.3.0'
  },
  {
    pkg: 'yarn',
    range: '^1.3.0'
  },
  {
    pkg: 'yarn',
    range: '^20.3.0'
  },
  {
    pkg: 'quuxbarfoo',
    range: '~1.3.0'
  }
];


(async function () {

  // Each request is sent and read in parallel.
  const promises = criteria.map(async ({ pkg, range }) => {
    try {
      return await fetchRealizedVersion(pkg, range);
    } catch ({ message: errMssg }) {
      return errMssg;
    }
  });

  // Log each 'latest' semver in sequence.
  for (const latest of promises) {
    console.log(await latest);
  }
})();

The result for that last example is as follows:

$ node ./index.js
4.19.1
5.0.1
1.0.6
1.6.5
1.3.2
1.22.4
null
ERR! Failed executing command: 'npm view quuxbarfoo versions --json'
ERR! 'quuxbarfoo' is probably not in the npm registry.

Additional Note: The shellExec helper function in get-latest-version.js currently promisifies the child_process module's exec() method to shell out the npm view command. However, since node.js version 12 the built-in util.promisify provides another way to promisify the exec() method (as shown in the docs for exec), so you may prefer to do it that way instead.


Custom module (B):

If you wanted to avoid shelling out the npm view command you could consider making a request directly to the https://registry.npmjs.org endpoint instead (which is the same endpoint that the npm view command sends a https GET request to).

The modified version of get-latest-version.js (below) essentially utilizes a promisified version of the builtin https.get.

Usage is the same as demonstrated previously in the "Usage" section.

get-latest-version.js

'use strict';

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const https = require('https');
const { maxSatisfying } = require('semver');

//------------------------------------------------------------------------------
// Data
//------------------------------------------------------------------------------

const endPoint = 'https://registry.npmjs.org';
const errorBadge = '\x1b[31;40mERR!\x1b[0m';

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/**
 * Requests JSON for a given package from the npm registry.
 *
 * @param {String} pkg The package name.
 * @return {Promise<json>} A Promise object that when fulfilled returns the JSON
 * metadata for the specific package. When rejected an error message is returned.
 * @private
 */
function fetchPackageInfo(pkg) {

  return new Promise((resolve, reject) => {

    https.get(`${endPoint}/${pkg}/`, response => {

      const { statusCode, headers: { 'content-type': contentType } } = response;

      if (statusCode !== 200) {
        reject(new Error(`Request to ${endPoint} failed. ${statusCode}`));
        return;
      }

      if (!/^application\/json/.test(contentType)) {
        reject(new Error(`Expected application/json but received ${contentType}`));
        return;
      }

      let data = '';

      response.on('data', chunk => {
        data += chunk;
      });

      response.on('end', () => {
        resolve(data);
      });

    }).on('error', error => {
      reject(new Error(`Cannot find ${endPoint}`));
    });
  });
}

//------------------------------------------------------------------------------
// Public Interface
//------------------------------------------------------------------------------

module.exports = {

  /**
   * Retrieves the latest version that matches the given range for a package.
   *
   * @async
   * @param {String} pkg The package name.
   * @param {String} range The semver range.
   * @returns {Promise<string>} A Promise object that when fulfilled returns the
   * latest version that matches. When rejected an error message is returned.
   */
  async fetchRealizedVersion(pkg, range) {
    try {
      const response = await fetchPackageInfo(pkg);
      const { versions: allVersionInfo } = JSON.parse(response);

      // The response includes all metadata for all versions of a package.
      // Let's create an Array holding just the `version` info.
      const versions = [];
      Object.keys(allVersionInfo).forEach(key => {
        versions.push(allVersionInfo[key].version)
      });

     return maxSatisfying(versions, range);

    } catch ({ message: errorMssg }) {
      throw Error([
        `${errorBadge} ${errorMssg}`,
        `${errorBadge} '${pkg}' is probably not in the npm registry.`
      ].join('\n'));
    }
  }

};

Note The version of node-semver used in the example custom modules (A & B) IS NOT the current latest version (i.e. 7.3.2). Version ^5.7.1 was used instead - which is the same version used by the npm cli tool.


Upvotes: 4

Related Questions