loopback-boot/lib/utils.js

365 lines
11 KiB
JavaScript

// Copyright IBM Corp. 2016,2019. All Rights Reserved.
// Node module: loopback-boot
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
'use strict';
var debug = require('debug')('loopback:boot');
var path = require('path');
var Module = require('module');
var fs = require('fs');
var assert = require('assert');
var _ = require('lodash');
var g = require('./globalize');
exports.arrayToObject = arrayToObject;
exports.tryReadDir = tryReadDir;
exports.resolveRelativePaths = resolveRelativePaths;
exports.assertIsValidConfig = assertIsValidConfig;
exports.fileExistsSync = fileExistsSync;
exports.fixFileExtension = fixFileExtension;
exports.findScripts = findScripts;
exports.resolveAppScriptPath = resolveAppScriptPath;
exports.getExcludedExtensions = getExcludedExtensions;
exports.tryResolveAppPath = tryResolveAppPath;
exports.forEachKeyedObject = forEachKeyedObject;
exports.mergePhaseNameLists = mergePhaseNameLists;
var FILE_EXTENSION_JSON = exports.FILE_EXTENSION_JSON = '.json';
/**
* Find all javascript files (except for those prefixed with _)
* and all directories.
* @param {String} dir Full path of the directory to enumerate.
* @return {Array.<String>} A list of absolute paths to pass to `require()`.
*/
function findScripts(dir, scriptExtensions) {
assert(dir, 'cannot require directory contents without directory name');
var files = tryReadDir(dir);
scriptExtensions = scriptExtensions || require.extensions;
// sort files in lowercase alpha for linux
files.sort(function(a, b) {
a = a.toLowerCase();
b = b.toLowerCase();
if (a < b) {
return -1;
} else if (b < a) {
return 1;
} else {
return 0;
}
});
var results = [];
files.forEach(function(filename) {
// ignore index.js and files prefixed with underscore
if (filename === 'index.js' || filename[0] === '_') {
return;
}
var filepath = path.resolve(path.join(dir, filename));
var stats = fs.statSync(filepath);
// only require files supported by specified extensions
if (stats.isFile()) {
if (scriptExtensions && isPreferredExtension(filename, scriptExtensions))
results.push(filepath);
else
debug('Skipping file %s - unknown extension', filepath);
} else {
debug('Skipping directory %s', filepath);
}
});
return results;
}
function tryReadDir() {
try {
return fs.readdirSync.apply(fs, arguments);
} catch (e) {
return [];
}
}
function resolveRelativePaths(relativePaths, appRootDir) {
var resolveOpts = {strict: false};
relativePaths.forEach(function(relativePath, k) {
var resolvedPath = tryResolveAppPath(appRootDir, relativePath, resolveOpts);
if (resolvedPath !== undefined) {
relativePaths[k] = resolvedPath;
} else {
debug('skipping boot script %s - unknown file', relativePath);
}
});
}
function getExcludedExtensions() {
return {
'.json': '.json',
'.node': 'node',
/**
* This is a temporary workaround for #246
* See discussion here for full description of the underlying issue
* https://github.com/strongloop/loopback-boot/pull/245#issuecomment-311052798
*/
'.map': 'map',
};
}
function arrayToObject(array) {
return array.reduce(function(obj, val) {
obj[val] = val;
return obj;
}, {});
}
function isPreferredExtension(filename, includeExtensions) {
assert(!!includeExtensions, '"includeExtensions" argument is required');
var ext = path.extname(filename);
return (ext in includeExtensions) && !(ext in getExcludedExtensions());
}
function fixFileExtension(filepath, files, scriptExtensions) {
var results = [];
var otherFile;
/* Prefer coffee scripts over json */
if (scriptExtensions && isPreferredExtension(filepath, scriptExtensions)) {
return filepath;
}
var basename = path.basename(filepath, FILE_EXTENSION_JSON);
var sourceDir = path.dirname(filepath);
files.forEach(function(f) {
otherFile = path.resolve(sourceDir, f);
var stats = fs.statSync(otherFile);
if (stats.isFile()) {
var otherFileExtension = path.extname(f);
if (!(otherFileExtension in getExcludedExtensions()) &&
path.basename(f, otherFileExtension) == basename) {
if (!scriptExtensions || otherFileExtension in scriptExtensions) {
results.push(otherFile);
}
}
}
});
return results.length > 0 ? results[0] : undefined;
}
function resolveAppPath(rootDir, relativePath, resolveOptions) {
var resolvedPath = tryResolveAppPath(rootDir, relativePath, resolveOptions);
if (resolvedPath === undefined && !resolveOptions.optional) {
var err = new Error(g.f('Cannot resolve path "%s"', relativePath));
err.code = 'PATH_NOT_FOUND';
throw err;
}
return resolvedPath;
}
function resolveAppScriptPath(rootDir, relativePath, resolveOptions) {
var resolvedPath = resolveAppPath(rootDir, relativePath, resolveOptions);
if (!resolvedPath) {
return false;
}
var sourceDir = path.dirname(resolvedPath);
var files = tryReadDir(sourceDir);
var fixedFile = fixFileExtension(resolvedPath, files);
return (fixedFile === undefined ? resolvedPath : fixedFile);
}
function tryResolveAppPath(rootDir, relativePath, resolveOptions) {
var fullPath;
var start = relativePath.substring(0, 2);
/* In order to retain backward compatibility, we need to support
* two ways how to treat values that are not relative nor absolute
* path (e.g. `relativePath = 'foobar'`)
* - `resolveOptions.strict = true` searches in `node_modules` only
* - `resolveOptions.strict = false` attempts to resolve the value
* as a relative path first before searching `node_modules`
*/
resolveOptions = resolveOptions || {strict: true};
var isModuleRelative = false;
if (relativePath[0] === '/') {
fullPath = relativePath;
} else if (start === './' || start === '..') {
fullPath = path.resolve(rootDir, relativePath);
} else if (!resolveOptions.strict) {
isModuleRelative = true;
fullPath = path.resolve(rootDir, relativePath);
}
if (fullPath) {
// This check is needed to support paths pointing to a directory
if (fileExistsSync(fullPath)) {
return fullPath;
}
try {
fullPath = require.resolve(fullPath);
return fullPath;
} catch (err) {
if (!isModuleRelative) {
debug('Skipping %s - %s', fullPath, err);
return undefined;
}
}
}
// Handle module-relative path, e.g. `loopback/common/models`
// Module.globalPaths is a list of globally configured paths like
// [ env.NODE_PATH values, $HOME/.node_modules, etc. ]
// Module._nodeModulePaths(rootDir) returns a list of paths like
// [ rootDir/node_modules, rootDir/../node_modules, etc. ]
var modulePaths = Module.globalPaths
.concat(Module._nodeModulePaths(rootDir));
fullPath = modulePaths
.map(function(candidateDir) {
var absPath = path.join(candidateDir, relativePath);
try {
// NOTE(bajtos) We need to create a proper String object here,
// otherwise we can't attach additional properties to it
/* jshint -W053 */
var filePath = new String(require.resolve(absPath));
filePath.unresolvedPath = absPath;
return filePath;
} catch (err) {
return absPath;
}
})
.filter(function(candidate) {
return fileExistsSync(candidate.toString());
})
[0];
if (fullPath) {
if (fullPath.unresolvedPath && resolveOptions.fullResolve === false)
return fullPath.unresolvedPath;
// Convert String object back to plain string primitive
return fullPath.toString();
}
debug('Skipping %s - module not found', fullPath);
return undefined;
}
function assertIsValidConfig(name, config) {
if (config) {
assert(typeof config === 'object',
name + ' config must be a valid JSON object');
}
}
function forEachKeyedObject(obj, fn) {
if (typeof obj !== 'object') return;
Object.keys(obj).forEach(function(key) {
fn(key, obj[key]);
});
}
/**
* Extend the list of builtin phases by merging in an array of phases
* requested by a user while preserving the relative order of phases
* as specified by both arrays.
*
* If the first new name does not match any existing phase, it is inserted
* as the first phase in the new list. The same applies for the second phase,
* and so on, until an existing phase is found.
*
* Any new names in the middle of the array are inserted immediatelly after
* the last common phase. For example, extending
* `["initial", "session", "auth"]` with `["initial", "preauth", "auth"]`
* results in `["initial", "preauth", "session", "auth"]`.
*
*
* **Example**
*
* ```js
* var result = mergePhaseNameLists(
* ['initial', 'session', 'auth', 'routes', 'files', 'final'],
* ['initial', 'postinit', 'preauth', 'auth',
* 'routes', 'subapps', 'final', 'last']
* );
*
* // result: [
* // 'initial', 'postinit', 'preauth', 'session', 'auth',
* // 'routes', 'subapps', 'files', 'final', 'last'
* // ]
* ```
*
* @param {Array} currentNames The current list of phase names.
* @param {Array} namesToMerge The items to add (zip merge) into the target
* array.
* @returns {Array} A new array containing combined items from both arrays.
*
* @header mergePhaseNameLists
*/
function mergePhaseNameLists(currentNames, namesToMerge) {
if (!namesToMerge.length) return currentNames.slice();
var targetArray = currentNames.slice();
var targetIx = targetArray.indexOf(namesToMerge[0]);
if (targetIx === -1) {
// the first new item does not match any existing one
// start adding the new items at the start of the list
targetArray.splice(0, 0, namesToMerge[0]);
targetIx = 0;
}
// merge (zip) two arrays
for (var sourceIx = 1; sourceIx < namesToMerge.length; sourceIx++) {
var valueToAdd = namesToMerge[sourceIx];
var previousValue = namesToMerge[sourceIx - 1];
var existingIx = targetArray.indexOf(valueToAdd, targetIx);
if (existingIx === -1) {
// A new phase - try to add it after the last one,
// unless it was already registered
if (targetArray.indexOf(valueToAdd) !== -1) {
var errMsg = g.f('Ordering conflict: cannot add "%s' +
'" after "%s", because the opposite order was ' +
' already specified', valueToAdd, previousValue);
throw new Error(errMsg);
}
var previousIx = targetArray.indexOf(previousValue);
targetArray.splice(previousIx + 1, 0, valueToAdd);
} else {
// An existing phase - move the pointer
targetIx = existingIx;
}
}
return targetArray;
}
/**
* Check synchronously if a filepath points to an existing file.
* Replaces calls to fs.existsSync, which is deprecated (see:
* https://github.com/nodejs/node/pull/166).
*
* @param {String} filepath The absolute path to check
* @returns {Boolean} True if the file exists
*/
function fileExistsSync(filepath) {
try {
fs.statSync(filepath);
return true;
} catch (e) {
return false;
}
}