Search code examples
node.jsnpmsemantic-versioning

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.


Solution

  • "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.