Galooshi/import-js

View on GitHub
lib/environments/meteorEnvironment.js

Summary

Maintainability
A
2 hrs
Test Coverage
A
95%
// @flow

import fs from 'fs';
import os from 'os';
import path from 'path';

import FileUtils from '../FileUtils';

import findPackageDependencies from '../findPackageDependencies';
import findExports from '../findExports';

const coreModules = [
  'meteor/accounts-base',
  'meteor/blaze',
  'meteor/check',
  'meteor/ddp-client',
  'meteor/ddp-rate-limiter',
  'meteor/ejson',
  'meteor/email',
  'meteor/http',
  'meteor/check',
  'meteor/meteor',
  'meteor/mongo',
  'meteor/random',
  'meteor/reactive-var',
  'meteor/session',
  'meteor/templating',
  'meteor/tracker',
];

const coreNamedExports = {
  'meteor/accounts-base': ['AccountsClient', 'Accounts', 'AccountsServer'],
  'meteor/blaze': ['Blaze'],
  'meteor/check': ['check', 'Match'],
  'meteor/ddp-client': ['DDP'],
  'meteor/ddp-rate-limiter': ['DDPRateLimiter'],
  'meteor/ejson': ['EJSON'],
  'meteor/email': ['Email'],
  'meteor/http': ['HTTP'],
  'meteor/meteor': ['Meteor'],
  'meteor/mongo': ['Mongo'],
  'meteor/random': ['Random'],
  'meteor/reactive-var': ['ReactiveVar'],
  'meteor/session': ['Session'],
  'meteor/templating': ['Template'],
  'meteor/tracker': ['Tracker'],
};

function meteorPackageDependencies({ config }: Object): Array<string> {
  const meteorPackagesPath = path.join(
    config.workingDirectory,
    '.meteor/packages',
  );

  if (!fs.existsSync(meteorPackagesPath)) {
    return [];
  }

  // Meteor is an "app" framework. As such, it has both apps and packages. When
  // working with a module that is part of an app, the list of Meteor packages
  // that a module may import is found in '.meteor/packages'. This file is
  // actually called a ProjectConstraintsFile in the meteor code.
  // The internal meteor routine that parses it may be found at
  // https://github.com/meteor/meteor/blob/f8b1bba6/tools/project-context.js#L841.
  //
  // After reverse engineering ProjectConstraintsFile.prototype._readfile at
  // that location, the following appears to be the pertinent facts for any
  // parser.
  //
  //  - the only true information within the file is a list of constraints
  //  - a constraint may not span a line
  //  - only one constraint may appear on a line
  //  - a constraint consists of a package name and an optional version
  //    constraint separated by the '@' symbol
  //  - white space may not appear within a constraint
  //  - the '#' symbol signifies that the rest of the line is a comment
  //  - all white space is ignored
  //  - package names
  //      - are allowed to contain [a-z0-9:.\-]
  //      - must have at least one lowercase letter
  //      - may not begin or end with a dot or colon
  //      - may not begin with a hyphen
  //      - may not contain two consecutive dots
  //
  // This routine is only interested in extracting the package names and has no
  // concern for precisely validating them. An assumption is made that the file
  // is basically valid. Thus, they may be extracted with a simple global,
  // multiline match of characters allowed to be in a package name that are at
  // the beginning of a line, possibly following white space.
  const coreModulesSet = new Set(coreModules);
  const packages: Array<string> = (
    fs
      .readFileSync(meteorPackagesPath, 'utf8')
      // extract an array of package names (possibly with preceding whitespace)
      // from the packages file
      .match(/^\s*[a-z0-9:.-]+/gm) || []
  )
    // add 'meteor/' to the start of each name per Meteor convention
    .map((pkg: string): string => `meteor/${pkg.trimLeft()}`)
    // eliminate those packages that are considered to be core
    .filter((pkg: string): boolean => !coreModulesSet.has(pkg));
  return packages;
}

function meteorPackageVersions(projectRootDir: string): ?Map<string, string> {
  // This function processes all of the package versions found in
  // .meteor/versions and returns a map containing them.

  const meteorVersionsPath = path.join(projectRootDir, '.meteor/versions');
  if (!fs.existsSync(meteorVersionsPath)) {
    // If we're even in an application directory, it must be broken. In any
    // case, we can't find the packages without their versions. Return null to
    // indicate that an issue occurred as opposed to processing an empty
    // .meteor/versions.
    return null;
  }

  const pkgVersions = new Map();
  const pkgVersionPairs: Array<string> =
    fs.readFileSync(meteorVersionsPath, 'utf8').match(/^[^@\s]+@[^\s]+$/gm) ||
    [];

  pkgVersionPairs.forEach((pkgVersionPair: string) => {
    const [pkg, version] = pkgVersionPair.split('@');
    pkgVersions.set(pkg, version);
  });

  return pkgVersions;
}

function extractExportsFromMeteorPackage(
  projectRootDir: string,
  pkg: string,
  pkgVersion: string,
): ?Array<string> {
  // This function extracts the named exports from the package specified by pkg
  // and pkgVersion and returns them as an array of strings. If null is
  // returned, a problem was encountered in processing. If an empty array is
  // returned, no named exports were found.

  // Meteor packages are deployed as "isopacks". These can usually be found
  // within the meteor warehouse directory on the system at ~/.meteor/packages.
  // isopacks for local Meteor packages, i.e. those whose source is in the
  // <project-root>/packages directory, can usually be found in at
  // <project-root>/.meteor/local/isopacks.
  //
  // These isopacks do not exactly contain the original package source. In
  // particular, they are always missing a "package.js" file. This is important
  // to us because it is the one that specifies the interface. They do however
  // contain build products that resulted from that specification.
  //
  // Meteor is an isomorphic environment. An isopack contains multiple builds,
  // one for each targeted platform. Typically, this will include at least a
  // client and a server build.  An isopack contains an isopack.json file that
  // details which platforms are represented and leads us to other platform
  // specific isopack.json files that contain the interface details needed by
  // this routine.
  //
  // This routine will find all interfaces from all build platforms and create
  // namedExport entries for them.
  //
  // So, for example, if a project includes version 1.5.3 of package
  // "aldeed:simple-schema", the isopack for that package will usually be
  // located at
  //    ~/.meteor/packages/aldeed_simple-schema/1.5.3/
  // Within that directory, we will find the following important files
  //
  //    isopack.json
  //    os.json
  //    web.browser.json
  //    web.cordova.json
  //
  // isopack.json will point us to the others which all represent specific
  // platforms.
  //
  // There are two ways that meteor packages expose their exported interface,
  // pre-ES6 and post-ES6.
  //
  // The pre-ES6 modules method is to call api.exports within the package.js
  // file for each variable exported. These calls all result in declaredExports
  // entries in the individual platform's json files. Within
  // aldeed:simple-schema's web.browser.json for example, we find
  //
  //  "declaredExports": [
  //      {
  //        "name": "SimpleSchema",
  //        "testOnly": false
  //      },
  //      {
  //        "name": "MongoObject",
  //        "testOnly": false
  //      },
  //      {
  //        "name": "humanize",
  //        "testOnly": true
  //      }
  //    ],
  //
  // From this, we can determine that the namedExports entry should be
  //
  //   'aldeed:simple-schema': ['SimpleSchema', 'MongoObject']
  //
  // "humanize" was left off because it is testOnly. We may try to get smarter
  // and include that for some modules in the future.
  //
  // Post-ES6 modules, things become a bit more difficult. The package.js file
  // in a post-ES6 world specifies a mainModule. Each build will have no more
  // than one mainModule and they may be different. The mainModule's are denoted
  // in the same build json files that we process to find the pre-ES6
  // declaredExports. They are in the resources section which looks like
  //
  //   "resources": [
  //       {
  //         "type": "source",
  //         "extension": "js",
  //         "file": "web.browser/client_main.js",
  //         "length": 752,
  //         "offset": 0,
  //         "path": "client_main.js",
  //         "hash": "cf1eeaf24f21f7755a7cb3fe1e247d56b5e97acf",
  //         "fileOptions": {
  //           "mainModule": true
  //         }
  //       },
  //
  // We are interested only in the one resource that has fileOptions.mainModule
  // = true.
  //
  // The exports are defined by the export statements in the build's mainModule
  // file, "web.browser/client_main.js" in the above example. So, we'll have to
  // parse the file and identify the exports. If the file uses ES6 module export
  // syntax, we should be able to achieve this. If its using CommonJS, we may
  // have to punt and let the user define their own namedExports for that
  // package.

  // The isopack's for 3rd party packages are usually found at
  //   ~/.meteor/packages/<pkg>/<pkgVersion>
  // where pkg is the meteorPkg without the 'meteor/' prefix and colons are
  // replaced with underlines.
  let isopackRoot = path.join(
    os.homedir(),
    '.meteor/packages',
    pkg.replace(':', '_'),
    pkgVersion,
  );
  let isopackPath = path.join(isopackRoot, 'isopack.json');
  let isopack = FileUtils.readJsonFile(isopackPath);

  if (!isopack) {
    // It is possible that this is a local package as opposed to a 3rd party
    // package. If so, it's isopack may be in
    // <project-root>/.meteor/local/isopacks/<pkg>.
    isopackRoot = path.join(
      projectRootDir,
      '.meteor',
      'local',
      'isopacks',
      pkg.replace(':', '_'),
    );
    isopackPath = path.join(isopackRoot, 'isopack.json');
    isopack = FileUtils.readJsonFile(isopackPath);

    if (!isopack) {
      // Can't get anywhere without the main isopack.json.
      return null;
    }
  }

  // isopack.json often contains separate sections for every version of the
  // isopacks that has ever been made available. We're only interested in
  // isopack-2 or isopack-1 at this time. Prefer the newer isopack-2
  // specification if available
  const isopackVer = isopack['isopack-2'] || isopack['isopack-1'];
  if (!isopackVer || !isopackVer.builds) {
    // If we didn't find an isopack version we understand or documented builds
    // within it, we're done.
    return null;
  }

  // We can't guess which build the current module is being included in. So,
  // we'll find all declaredExports from all builds and combine them into one
  // namedExports specification.
  const declaredExports: Set<string> = new Set();

  isopackVer.builds.forEach((build: Object) => {
    const buildIsopackPath = path.join(isopackRoot, build.path);
    const buildIsopack = FileUtils.readJsonFile(buildIsopackPath);

    if (!buildIsopack || !buildIsopack.declaredExports) {
      // This build is missing, corrupted, or has no declaredExports. Try the
      // next one.
      return;
    }

    buildIsopack.declaredExports.forEach((declaredExport: Object) => {
      if (!declaredExport.testOnly) {
        declaredExports.add(declaredExport.name);
      }
    });

    // If the "resources" section of the buildIsopack specifies a
    // mainModule, we need to attempt to scan it to find exports.
    if (buildIsopack.resources) {
      const mainModuleResource = buildIsopack.resources.find(
        (resource: Object): boolean =>
          resource.fileOptions && resource.fileOptions.mainModule === true,
      );

      if (mainModuleResource) {
        const pathToRequiredFile = path.join(
          isopackRoot,
          mainModuleResource.file,
        );
        const requiredFileContent = fs.readFileSync(pathToRequiredFile, 'utf8');
        // returns a set, which we iterate into declaredExports
        const { named } = findExports(requiredFileContent, pathToRequiredFile);
        named.forEach((name: string) => {
          declaredExports.add(name);
        });
      }
    }
  });

  return Array.from(declaredExports);
}

function meteorPackageNamedExports({ config }: Object): Object {
  // This function seeks to extract the named exports from all non-core, 3rd
  // party or local meteor packages being utilized in the application. The
  // meteorPackageDependencies function identifies that list of packages.

  // Retrieve the versions of all Meteor packages. Note that local packages
  // aren't absolutely required to have versions.
  const pkgVersions =
    meteorPackageVersions(config.workingDirectory) || new Map();

  // Try to identify the exports of all packages identified by
  // meteorPackageDependencies
  const namedExports = {};

  meteorPackageDependencies({ config }).forEach((meteorPkg: string) => {
    const pkg = meteorPkg.slice(7);
    const pkgVersion = pkgVersions.get(pkg) || '';
    const extractedExports = extractExportsFromMeteorPackage(
      config.workingDirectory,
      pkg,
      pkgVersion,
    );

    // If we found declared exports, create a namedExports entry for them.
    if (extractedExports && extractedExports.length) {
      namedExports[meteorPkg] = extractedExports;
    }
  });

  return namedExports;
}

export default {
  coreModules,

  moduleNameFormatter({ moduleName, pathToImportedModule }: Object): string {
    // If the module being imported is a Meteor package, it will begin with
    // 'meteor/' and should not be altered.
    if (moduleName.startsWith('meteor/')) {
      return moduleName;
    }
    // If the module being imported is an npm package, the path to the module
    // will start with 'node_modules/' and the moduleName should not be altered.
    // not be altered.
    if (pathToImportedModule.startsWith('node_modules/')) {
      return moduleName;
    }
    // If the moduleName does not start with a '.', then import-js is trying to
    // reference it via an absolute path. In this case, Meteor wants it to start
    // with a '/' and will interpret it as relative to the project directory
    // root.
    if (!moduleName.startsWith('.')) {
      return `/${moduleName}`;
    }
    // Otherwise, return the moduleName unchanged.
    return moduleName;
  },

  moduleSideEffectImports({
    pathToCurrentFile,
    config,
  }: Object): Array<string> {
    let basePath = '';

    if (pathToCurrentFile.endsWith('.js')) {
      basePath = pathToCurrentFile.slice(0, -3);
    } else if (pathToCurrentFile.endsWith('.jsx')) {
      basePath = pathToCurrentFile.slice(0, -4);
    } else {
      return [];
    }

    const moduleSpecifiers = [];

    // DEBUG: should this also include preprocessor files?
    // ie. .jade, .pug, .less, .scss, .sass
    ['.html', '.css'].forEach((ext: string) => {
      const moduleSpecifier = `${basePath}${ext}`;
      if (fs.existsSync(path.join(config.workingDirectory, moduleSpecifier))) {
        if (config.get('useRelativePaths')) {
          moduleSpecifiers.push(`./${path.basename(moduleSpecifier)}`);
        } else {
          // Strip the leading '.' off of the moduleSpecifier to turn it into a
          // Meteor compliant absolute path.
          moduleSpecifiers.push(moduleSpecifier.slice(1));
        }
      }
    });

    return moduleSpecifiers;
  },

  namedExports({ config }: Object): Object {
    const allNamedExports = coreNamedExports;
    // There are no worries about this overwriting the definitions of core
    // namedExports. meteorPackageNamedExports skips core packages. Even if it
    // did not, it would presumably find correct definitions.
    Object.assign(allNamedExports, meteorPackageNamedExports({ config }));
    return allNamedExports;
  },

  packageDependencies({ config }: Object): Set<string> {
    const npmPackages = findPackageDependencies(
      config.workingDirectory,
      config.get('importDevDependencies'),
    );

    return new Set([
      ...meteorPackageDependencies({ config }),
      ...Array.from(npmPackages),
    ]);
  },
};