var assert = require('assert'); var cloneDeep = require('lodash').cloneDeep; var fs = require('fs'); var path = require('path'); var toposort = require('toposort'); var ConfigLoader = require('./config-loader'); var debug = require('debug')('loopback:boot:compiler'); var Module = require('module'); var _ = require('lodash'); var FILE_EXTENSION_JSON = '.json'; /** * Gather all bootstrap-related configuration data and compile it into * a single object containing instruction for `boot.execute`. * * @options {String|Object} options Boot options; If String, this is * the application root directory; if object, has the properties * described in `bootLoopBackApp` options above. * @return {Object} * * @header boot.compile(options) */ module.exports = function compile(options) { options = options || {}; if (typeof options === 'string') { options = { appRootDir: options }; } var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); var env = options.env || process.env.NODE_ENV || 'development'; var appConfigRootDir = options.appConfigRootDir || appRootDir; var appConfig = options.config || ConfigLoader.loadAppConfig(appConfigRootDir, env); assertIsValidConfig('app', appConfig); var modelsRootDir = options.modelsRootDir || appRootDir; var modelsConfig = options.models || ConfigLoader.loadModels(modelsRootDir, env); assertIsValidModelConfig(modelsConfig); var dsRootDir = options.dsRootDir || appRootDir; var dataSourcesConfig = options.dataSources || ConfigLoader.loadDataSources(dsRootDir, env); assertIsValidConfig('data source', dataSourcesConfig); // not configurable yet var middlewareRootDir = appRootDir; var middlewareConfig = options.middleware || ConfigLoader.loadMiddleware(middlewareRootDir, env); var middlewareInstructions = buildMiddlewareInstructions(middlewareRootDir, middlewareConfig); var componentRootDir = appRootDir; // not configurable yet var componentConfig = options.components || ConfigLoader.loadComponents(componentRootDir, env); var componentInstructions = buildComponentInstructions(componentRootDir, componentConfig); // require directories var bootDirs = options.bootDirs || []; // precedence bootDirs = bootDirs.concat(path.join(appRootDir, 'boot')); resolveRelativePaths(bootDirs, appRootDir); var bootScripts = options.bootScripts || []; resolveRelativePaths(bootScripts, appRootDir); bootDirs.forEach(function(dir) { bootScripts = bootScripts.concat(findScripts(dir)); }); // de-dedup boot scripts -ERS // https://github.com/strongloop/loopback-boot/issues/64 bootScripts = _.uniq(bootScripts); var modelsMeta = modelsConfig._meta || {}; delete modelsConfig._meta; var modelSources = options.modelSources || modelsMeta.sources || ['./models']; var modelInstructions = buildAllModelInstructions( modelsRootDir, modelsConfig, modelSources); // When executor passes the instruction to loopback methods, // loopback modifies the data. Since we are loading the data using `require`, // such change affects also code that calls `require` for the same file. var instructions = { config: appConfig, dataSources: dataSourcesConfig, models: modelInstructions, middleware: middlewareInstructions, components: componentInstructions, files: { boot: bootScripts } }; if (options.appId) instructions.appId = options.appId; return cloneDeep(instructions); }; function assertIsValidConfig(name, config) { if (config) { assert(typeof config === 'object', name + ' config must be a valid JSON object'); } } function assertIsValidModelConfig(config) { assertIsValidConfig('model', config); for (var name in config) { var entry = config[name]; var options = entry.options || {}; var unsupported = entry.properties || entry.base || options.base || entry.plural || options.plural; if (unsupported) { throw new Error( 'The data in model-config.json is in the unsupported 1.x format.'); } } } /** * Find all javascript files (except for those prefixed with _) * and all directories. * @param {String} dir Full path of the directory to enumerate. * @return {Array.} A list of absolute paths to pass to `require()`. * @private */ function findScripts(dir) { assert(dir, 'cannot require directory contents without directory name'); var files = tryReadDir(dir); // 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 require.extensions (.txt .md etc.) if (stats.isFile()) { if (isPreferredExtension(filename)) 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 buildAllModelInstructions(rootDir, modelsConfig, sources) { var registry = findModelDefinitions(rootDir, sources); var modelNamesToBuild = addAllBaseModels(registry, Object.keys(modelsConfig)); var instructions = modelNamesToBuild .map(function createModelInstructions(name) { var config = modelsConfig[name]; var definition = registry[name] || {}; debug('Using model "%s"\nConfiguration: %j\nDefinition %j', name, config, definition.definition); return { name: name, config: config, definition: definition.definition, sourceFile: definition.sourceFile }; }); return sortByInheritance(instructions); } function addAllBaseModels(registry, modelNames) { var result = []; var visited = {}; while (modelNames.length) { var name = modelNames.shift(); if (visited[name]) continue; visited[name] = true; result.push(name); var definition = registry[name] && registry[name].definition; if (!definition) continue; var base = getBaseModelName(definition); // ignore built-in models like User if (!registry[base]) continue; modelNames.push(base); } return result; } function getBaseModelName(modelDefinition) { if (!modelDefinition) return undefined; return modelDefinition.base || modelDefinition.options && modelDefinition.options.base; } function sortByInheritance(instructions) { // create edges Base name -> Model name var edges = instructions .map(function(inst) { return [getBaseModelName(inst.definition), inst.name]; }); var sortedNames = toposort(edges); var instructionsByModelName = {}; instructions.forEach(function(inst) { instructionsByModelName[inst.name] = inst; }); return sortedNames // convert to instructions .map(function(name) { return instructionsByModelName[name]; }) // remove built-in models .filter(function(inst) { return !!inst; }); } function findModelDefinitions(rootDir, sources) { var registry = {}; sources.forEach(function(src) { var srcDir = tryResolveAppPath(rootDir, src); if (!srcDir) { debug('Skipping unknown module source dir %j', src); return; } var files = tryReadDir(srcDir); files .filter(function(f) { return f[0] !== '_' && path.extname(f) === '.json'; }) .forEach(function(f) { var fullPath = path.resolve(srcDir, f); var entry = loadModelDefinition(rootDir, fullPath, files); var modelName = entry.definition.name; if (!modelName) { debug('Skipping model definition without Model name: %s', path.relative(srcDir, fullPath)); return; } registry[modelName] = entry; }); }); return registry; } function resolveAppPath(rootDir, relativePath, resolveOptions) { var resolvedPath = tryResolveAppPath(rootDir, relativePath, resolveOptions); if (resolvedPath === undefined) { var err = new Error('Cannot resolve path "' + relativePath + '"'); err.code = 'PATH_NOT_FOUND'; throw err; } return resolvedPath; } function tryResolveAppPath(rootDir, relativePath, resolveOptions) { var fullPath; var start = relativePath.substring(0, 2); /* In order to retain backward compatibility, while resolving * component path, `resolveOptions` parameter is added where * `resolveOptions.strict` = true, * means retain backward compatibility when resolving the path and * `resolveOptions.strict` = false, * does not enforce any such restriction when resolving the path */ resolveOptions = resolveOptions || { strict: false }; if (relativePath[0] === '/') { fullPath = relativePath; } else if (start === './' || start === '..' || !resolveOptions.strict) { fullPath = path.resolve(rootDir, relativePath); } if (fullPath && fs.existsSync(fullPath)) return fullPath; if (start !== './' && start !== '..') { // 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) { try { var filePath = path.join(candidateDir, relativePath); filePath = require.resolve(filePath); return filePath; } catch (err) { return filePath; } }) .filter(function(candidate) { return fs.existsSync(candidate); }) [0]; if (fullPath) return fullPath; } else { // Handle relative path, e.g. `./common/models` try { fullPath = require.resolve(fullPath); return fullPath; } catch (err) { debug ('Skipping %s - %s', fullPath, err); } } return undefined; } function loadModelDefinition(rootDir, jsonFile, allFiles) { var definition = require(jsonFile); // find a matching file with a supported extension like `.js` or `.coffee` var sourceFile = fixFileExtension(jsonFile, allFiles, true); if (sourceFile === undefined) { debug('Model source code not found: %s', sourceFile); } debug('Found model "%s" - %s %s', definition.name, path.relative(rootDir, jsonFile), sourceFile ? path.relative(rootDir, sourceFile) : '(no source file)'); return { definition: definition, sourceFile: sourceFile }; } function buildMiddlewareInstructions(rootDir, config) { var phasesNames = Object.keys(config); var middlewareList = []; phasesNames.forEach(function(phase) { var phaseConfig = config[phase]; Object.keys(phaseConfig).forEach(function(middleware) { var allConfigs = phaseConfig[middleware]; if (!Array.isArray(allConfigs)) allConfigs = [allConfigs]; allConfigs.forEach(function(config) { var resolved = resolveMiddlewarePath(rootDir, middleware); var middlewareConfig = cloneDeep(config); middlewareConfig.phase = phase; if (middlewareConfig.params) { middlewareConfig.params = resolveMiddlewareParams( rootDir, middlewareConfig.params); } var item = { sourceFile: resolved.sourceFile, config: middlewareConfig }; if (resolved.fragment) { item.fragment = resolved.fragment; } middlewareList.push(item); }); }); }); var flattenedPhaseNames = phasesNames .map(function getBaseName(name) { return name.replace(/:[^:]+$/, ''); }) .filter(function differsFromPreviousItem(value, ix, source) { // Skip duplicate entries. That happens when // `name:before` and `name:after` are both translated to `name` return ix === 0 || value !== source[ix - 1]; }); return { phases: flattenedPhaseNames, middleware: middlewareList }; } function resolveMiddlewarePath(rootDir, middleware) { var resolved = {}; var segments = middleware.split('#'); var pathName = segments[0]; var fragment = segments[1]; var middlewarePath = pathName; if (fragment) { resolved.fragment = fragment; } if (pathName.indexOf('./') === 0 || pathName.indexOf('../') === 0) { // Relative path pathName = path.resolve(rootDir, pathName); } if (!fragment) { resolved.sourceFile = resolveAppScriptPath(rootDir, middlewarePath); return resolved; } var err; // Try to require the module and check if . is a valid // function var m = require(pathName); if (typeof m[fragment] === 'function') { resolved.sourceFile = resolveAppScriptPath(rootDir, middlewarePath); return resolved; } /* * module/server/middleware/fragment * module/middleware/fragment */ var candidates = [ pathName + '/server/middleware/' + fragment, pathName + '/middleware/' + fragment, // TODO: [rfeng] Should we support the following flavors? // pathName + '/lib/' + fragment, // pathName + '/' + fragment ]; for (var ix in candidates) { try { resolved.sourceFile = resolveAppScriptPath(rootDir, candidates[ix]); delete resolved.fragment; return resolved; } catch (e) { // Report the error for the first candidate when no candidate matches if (!err) err = e; } } throw err; } // Match values starting with `$!./` or `$!../` var MIDDLEWARE_PATH_PARAM_REGEX = /^\$!(\.\/|\.\.\/)/; function resolveMiddlewareParams(rootDir, params) { return cloneDeep(params, function resolvePathParam(value) { if (typeof value === 'string' && MIDDLEWARE_PATH_PARAM_REGEX.test(value)) { return path.resolve(rootDir, value.slice(2)); } else { return undefined; // no change } }); } function buildComponentInstructions(rootDir, componentConfig) { return Object.keys(componentConfig) .filter(function(name) { return !!componentConfig[name]; }) .map(function(name) { return { sourceFile: resolveAppScriptPath(rootDir, name, {'strict': true}), config: componentConfig[name] }; }); } function resolveRelativePaths(relativePaths, appRootDir) { relativePaths.forEach(function(relativePath, k) { var resolvedPath = tryResolveAppPath(appRootDir, relativePath); if (resolvedPath !== undefined) { relativePaths[k] = resolvedPath; } else { debug ('skipping boot script %s - unknown file', relativePath); } }); } function getExcludedExtensions() { return { '.json': '.json', '.node': 'node' }; } function isPreferredExtension (filename) { var includeExtensions = require.extensions; var ext = path.extname(filename); return (ext in includeExtensions) && !(ext in getExcludedExtensions()); } function fixFileExtension (filepath, files, onlyScriptsExportingFunction) { var results = []; var otherFile; /* Prefer coffee scripts over json */ if (isPreferredExtension(filepath)) 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 (!onlyScriptsExportingFunction) results.push(otherFile); else if (onlyScriptsExportingFunction && (typeof require.extensions[otherFileExtension]) === 'function') { results.push(otherFile); } } } }); return (results.length > 0 ? results[0] : undefined); } function resolveAppScriptPath (rootDir, relativePath, resolveOptions) { var resolvedPath = resolveAppPath (rootDir, relativePath, resolveOptions); var sourceDir = path.dirname(resolvedPath); var files = tryReadDir(sourceDir); var fixedFile = fixFileExtension (resolvedPath, files, false); return (fixedFile === undefined ? resolvedPath : fixedFile); }