apostrophe
Version:
The Apostrophe Content Management System.
627 lines (560 loc) • 22.9 kB
JavaScript
// Use of console permitted here because we sometimes need to
// print something before the utils module exists. -Tom
/* eslint no-console: 0 */
var path = require('path');
var _ = require('@sailshq/lodash');
var argv = require('yargs').argv;
var fs = require('fs');
var async = require('async');
var npmResolve = require('resolve');
var defaults = require('./defaults.js');
var glob = require('glob');
module.exports = function(options) {
traceStartup('begin');
// The core is not a true moog object but it must look enough like one
// to participate as a promise event emitter
var self = {
__meta: {
name: 'apostrophe'
}
};
// The core must have a reference to itself in order to use the
// promise event emitter code
self.apos = self;
require('./lib/modules/apostrophe-module/lib/events.js')(self, options);
try {
// Determine root module and root directory
self.root = options.root || getRoot();
self.rootDir = options.rootDir || path.dirname(self.root.filename);
self.npmRootDir = options.npmRootDir || self.rootDir;
testModule();
self.options = mergeConfiguration(options, defaults);
autodetectBundles();
acceptGlobalOptions();
// Legacy events
self.handlers = {};
// Module-based, promisified events (self.on and self.emit of each module)
self.eventHandlers = {};
traceStartup('defineModules');
defineModules();
} catch (err) {
if (options.initFailed) {
// Report error in an extensible way
return options.initFailed(err);
} else {
throw err;
}
}
// No return statement here because we need to
// return "self" after kicking this process off
async.series([
instantiateModules,
modulesReady,
modulesAfterInit,
lintModules,
migrate,
afterInit
], function(err) {
if (err) {
if (options.initFailed) {
// Report error in an extensible way
return options.initFailed(err);
} else {
throw err;
}
}
traceStartup('startup end');
if (self.argv._.length) {
self.emit('runTask');
} else {
// The apostrophe-express module adds this method
self.listen();
}
});
// EVENT HANDLING (legacy events)
//
// apos.emit(eventName, /* arg1, arg2, arg3... */)
//
// Emit an Apostrophe legacy event. All handlers that have been set
// with apos.on for the same eventName will be invoked. Any additional
// arguments are received by the handler functions as arguments.
//
// See the `self.on` and `self.emit` methods of all modules
// (via the `apostrophe-module`) base class for a better,
// promisified event system.
self.emit = function(eventName /* ,arg1, arg2, arg3... */) {
var handlers = self.handlers[eventName];
if (!handlers) {
return;
}
var args = Array.prototype.slice.call(arguments, 1);
var i;
for (i = 0; (i < handlers.length); i++) {
handlers[i].apply(self, args);
}
};
// Install an Apostrophe legacy event handler. The handler will be called
// when apos.emit is invoked with the same eventName. The handler
// will receive any additional arguments passed to apos.emit.
//
// See the `self.on` and `self.emit` methods of all modules
// (via the `apostrophe-module`) base class for a better,
// promisified event system.
self.on = function(eventName, fn) {
self.handlers[eventName] = (self.handlers[eventName] || []).concat([ fn ]);
};
// Remove an Apostrophe event handler. If fn is not supplied, all
// handlers for the given eventName are removed.
self.off = function(eventName, fn) {
if (!fn) {
delete self.handlers[eventName];
return;
}
self.handlers[eventName] = _.filter(self.handlers[eventName], function(_fn) {
return fn !== _fn;
});
};
// Legacy feature only. New code should call the `emit` method of the
// relevant module to implement a promise event instead. Will be removed
// in 3.x.
//
// For every module, if the method `method` exists,
// invoke it. The method may optionally take a callback.
// The method must take exactly as many additional
// arguments as are passed here between `method`
// and the final `callback`.
self.callAll = function(method, /* argument, ... */ callback) {
var args = Array.prototype.slice.call(arguments);
var extraArgs = args.slice(1, args.length - 1);
callback = args[args.length - 1];
return async.eachSeries(_.keys(self.modules), function(name, callback) {
return invoke(name, method, extraArgs, callback);
}, function(err) {
if (err) {
return callback(err);
}
return callback(null);
});
};
/**
* Allow to bind a callAll method for one module. Legacy feature.
* Use promise events instead.
*/
self.callOne = function(moduleName, method, /* argument, ... */ callback) {
var args = Array.prototype.slice.call(arguments);
var extraArgs = args.slice(2, args.length - 1);
callback = args[args.length - 1];
return invoke(moduleName, method, extraArgs, callback);
};
// Destroys the Apostrophe object, freeing resources such as
// HTTP server ports and database connections. Does **not**
// delete any data; the persistent database and media files
// remain available for the next startup. Invokes
// the `apostropheDestroy` methods of all modules that
// provide one, and also emits the `destroy` promise event on
// the `apostrophe` module; use this mechanism to free your own
// server-side resources that could prevent garbage
// collection by the JavaScript engine, such as timers
// and intervals.
self.destroy = function(callback) {
return self.callAllAndEmit('apostropheDestroy', 'destroy', callback);
};
// Returns true if Apostrophe is running as a command line task
// rather than as a server
self.isTask = function() {
return !!self.argv._.length;
};
// Returns an array of modules that are instances of the given
// module name, i.e. they are of that type or they extend it.
// For instance, `apos.instancesOf('apostrophe-pieces')` returns
// an array of active modules in your project that extend
// pieces, such as `apostrophe-users`, `apostrophe-groups` and
// your own piece types
self.instancesOf = function(name) {
return _.filter(self.modules, function(module) {
return self.synth.instanceOf(module, name);
});
};
// Returns true if the object is an instance of the given
// moog type name or a subclass thereof. A convenience wrapper
// for `apos.synth.instanceOf`
self.instanceOf = function(object, name) {
return self.synth.instanceOf(object, name);
};
// Return self so that app.js can refer to apos
// in inline functions, etc.
return self;
// SUPPORTING FUNCTIONS BEGIN HERE
// Merge configuration from defaults, data/local.js and app.js
function mergeConfiguration(options, defaults) {
var config = {};
var local = {};
var localPath = options.__localPath || '/data/local.js';
var reallyLocalPath = self.rootDir + localPath;
if (fs.existsSync(reallyLocalPath)) {
local = require(reallyLocalPath);
}
// Otherwise making a second apos instance
// uses the same modified defaults object
config = _.cloneDeep(options.__testDefaults || defaults);
_.merge(config, options);
if (typeof (local) === 'function') {
if (local.length === 1) {
_.merge(config, local(self));
} else if (local.length === 2) {
local(self, config);
} else {
throw new Error('data/local.js may export an object, a function that takes apos as an argument and returns an object, OR a function that takes apos and config as objects and directly modifies config');
}
} else {
_.merge(config, local || {});
}
return config;
}
function getRoot() {
var _module = module;
var m = _module;
while (m.parent) {
// The test file is the root as far as we are concerned,
// not mocha itself
if (m.parent.filename.match(/\/node_modules\/mocha\//)) {
return m;
}
m = m.parent;
_module = m;
}
return _module;
}
function nestedModuleSubdirs() {
if (!options.nestedModuleSubdirs) {
return;
}
var configs = glob.sync(self.moogOptions.localModules + '/**/modules.js');
_.each(configs, function(config) {
try {
_.merge(self.options.modules, require(config));
} catch (e) {
console.error('When nestedModuleSubdirs is active, any modules.js file beneath ' + self.moogOptions.localModules + '\nmust export an object containing configuration for Apostrophe modules.\nThe file ' + config + ' did not parse.');
throw e;
}
});
}
function autodetectBundles() {
var modules = _.keys(self.options.modules);
_.each(modules, function(name) {
var path = getNpmPath(name);
if (!path) {
return;
}
var module = require(path);
if (module.moogBundle) {
self.options.bundles = (self.options.bundles || []).concat(name);
_.each(module.moogBundle.modules, function(name) {
if (!_.has(self.options.modules, name)) {
var bundledModule = require(require('path').dirname(path) + '/' + module.moogBundle.directory + '/' + name);
if (bundledModule.improve) {
self.options.modules[name] = {};
}
}
});
}
});
}
function getNpmPath(name) {
var parentPath = path.resolve(self.npmRootDir);
try {
return npmResolve.sync(name, { basedir: parentPath });
} catch (e) {
// Not found via npm. This does not mean it doesn't
// exist as a project-level thing
return null;
}
}
function acceptGlobalOptions() {
// Truly global options not specific to a module
if (options.testModule) {
// Test command lines have arguments not
// intended as command line task arguments
self.argv = {
_: []
};
self.options.shortName = self.options.shortName || 'test';
} else if (options.argv) {
// Allow injection of any set of command line arguments.
// Useful with multiple instances
self.argv = options.argv;
} else {
self.argv = argv;
}
self.shortName = self.options.shortName;
if (!self.shortName) {
throw "Specify the `shortName` option and set it to the name of your project's repository or folder";
}
self.title = self.options.title;
self.baseUrl = self.options.baseUrl;
self.prefix = self.options.prefix || '';
}
// Tweak the Apostrophe environment suitably for
// unit testing a separate npm module that extends
// Apostrophe, like apostrophe-workflow. For instance,
// a node_modules subdirectory with a symlink to the
// module itself is created so that the module can
// be found by Apostrophe during testing. Invoked
// when options.testModule is true. There must be a
// test/ or tests/ subdir of the module containing
// a test.js file that runs under mocha via devDependencies.
function testModule() {
if (!options.testModule) {
return;
}
if (!options.shortName) {
options.shortName = 'test';
}
defaults = _.cloneDeep(defaults);
_.defaults(defaults, {
'apostrophe-express': {}
});
_.defaults(defaults['apostrophe-express'], {
port: 7900,
secret: 'irrelevant'
});
var m = findTestModule();
// Allow tests to be in test/ or in tests/
var testDir = require('path').dirname(m.filename);
var testRegex;
if (process.platform === "win32") {
testRegex = /\\tests?$/;
} else {
testRegex = /\/tests?$/;
}
var moduleDir = testDir.replace(testRegex, '');
if (testDir === moduleDir) {
throw new Error('Test file must be in test/ or tests/ subdirectory of module');
}
var moduleName = require('path').basename(moduleDir);
try {
// Use the given name in the package.json file if it is present
var packageName = JSON.parse(fs.readFileSync(path.resolve(moduleDir, 'package.json'), 'utf8')).name;
if (typeof packageName === 'string') {
moduleName = packageName;
}
} catch (e) {}
var testDependenciesDir = testDir + require("path").normalize('/node_modules/');
if (!fs.existsSync(testDependenciesDir + moduleName)) {
// Ensure dependencies directory exists
if (!fs.existsSync(testDependenciesDir)) {
fs.mkdirSync(testDependenciesDir);
}
// Ensure potential module scope directory exists before the symlink creation
if (moduleName.charAt(0) === '@' && moduleName.includes(require("path").sep)) {
var scope = moduleName.split(require("path").sep)[0];
var scopeDir = testDependenciesDir + scope;
if (!fs.existsSync(scopeDir)) {
fs.mkdirSync(scopeDir);
}
}
// Windows 10 got an issue with permission , known issue at https://github.com/nodejs/node/issues/18518
// Therefore need to have if else statement to determine type of symlinkSync uses.
var type;
if (process.platform === "win32") {
type = "junction";
} else {
type = "dir";
}
fs.symlinkSync(moduleDir, testDependenciesDir + moduleName, type);
}
// Not quite superfluous: it'll return self.root, but
// it also makes sure we encounter mocha along the way
// and throws an exception if we don't
function findTestModule() {
var m = module;
var nodeModuleRegex;
if (process.platform === "win32") {
nodeModuleRegex = /node_modules\\mocha/;
} else {
nodeModuleRegex = /node_modules\/mocha/;
}
while (m) {
if (m.parent && m.parent.filename.match(nodeModuleRegex)) {
return m;
}
m = m.parent;
if (!m) {
throw new Error('mocha does not seem to be running, is this really a test?');
}
}
}
}
function defineModules() {
// Set moog-require up to create our module manager objects
self.moogOptions = {
root: self.root,
bundles: [ 'apostrophe' ].concat(self.options.bundles || []),
localModules: self.options.modulesSubdir || self.options.__testLocalModules || (self.rootDir + '/lib/modules'),
defaultBaseClass: 'apostrophe-module',
nestedModuleSubdirs: self.options.nestedModuleSubdirs
};
var synth = require('moog-require')(self.moogOptions);
self.synth = synth;
// Just like on the browser side, we can
// call apos.define rather than apos.synth.define
self.define = self.synth.define;
self.redefine = self.synth.redefine;
self.create = self.synth.create;
nestedModuleSubdirs();
_.each(self.options.modules, function(options, name) {
synth.define(name, options);
});
return synth;
}
function instantiateModules(callback) {
traceStartup('instantiateModules');
self.modules = {};
return async.eachSeries(_.keys(self.options.modules), function(item, callback) {
traceStartup('Instantiating module ' + item);
var improvement = self.synth.isImprovement(item);
if (self.options.modules[item] && (improvement || self.options.modules[item].instantiate === false)) {
// We don't want an actual instance of this module, we are using it
// as an abstract base class in this particular project (but still
// configuring it, to easily carry those options to subclasses, which
// is how we got here)
return setImmediate(callback);
}
return self.synth.create(item, { apos: self }, function(err, obj) {
if (err) {
return callback(err);
}
return callback(null);
});
}, function(err) {
return setImmediate(function() {
return callback(err);
});
});
}
function modulesReady(callback) {
traceStartup('modulesReady');
return self.callAllAndEmit('modulesReady', 'modulesReady', callback);
}
function modulesAfterInit(callback) {
traceStartup('modulesAfterInit');
return self.callAllAndEmit('afterInit', 'afterInit', callback);
}
function lintModules(callback) {
traceStartup('lintModules');
_.each(self.modules, function(module, name) {
if (module.options.extends && ((typeof module.options.extends) === 'string')) {
lint('The module ' + name + ' contains an "extends" option. This is probably a\nmistake. In Apostrophe "extend" is used to extend other modules.');
}
if (module.options.singletonWarningIfNot && (name !== module.options.singletonWarningIfNot)) {
lint('The module ' + name + ' extends ' + module.options.singletonWarningIfNot + ', which is normally\na singleton (Apostrophe creates only one instance of it). Two competing\ninstances will lead to problems. If you are adding project-level code to it,\njust use lib/modules/' + module.options.singletonWarningIfNot + '/index.js and do not use "extend".\nIf you are improving it via an npm module, use "improve" rather than "extend".\nIf neither situation applies you should probably just make a new module that does\nnot extend anything.\n\nIf you are sure you know what you are doing, you can set the\nsingletonWarningIfNot: false option for this module.');
}
if (name.match(/-widgets$/) && (!extending(module)) && (!module.options.ignoreNoExtendWarning)) {
lint('The module ' + name + ' does not extend anything.\n\nA `-widgets` module usually extends `apostrophe-widgets` or\n`apostrophe-pieces-widgets`. Or possibly you forgot to npm install something.\n\nIf you are sure you are doing the right thing, set the\n`ignoreNoExtendWarning` option to `true` for this module.');
} else if (name.match(/-pages$/) && (name !== 'apostrophe-pages') && (!extending(module)) && (!module.options.ignoreNoExtendWarning)) {
lint('The module ' + name + ' does not extend anything.\n\nA `-pages` module usually extends `apostrophe-custom-pages` or\n`apostrophe-pieces-pages`. Or possibly you forgot to npm install something.\n\nIf you are sure you are doing the right thing, set the\n`ignoreNoExtendWarning` option to `true` for this module.');
} else if ((!extending(module)) && (!hasConstruct(name)) && (!isMoogBundle(name)) && (!module.options.ignoreNoCodeWarning)) {
lint('The module ' + name + ' does not extend anything and does not have a\n`beforeConstruct`, `construct` or `afterConstruct` function. This usually means that you:\n\n1. Forgot to `extend` another module\n2. Configured a module that comes from npm without npm installing it\n3. Simply haven\'t written your `index.js` yet\n\nIf you really want a module with no code, set the `ignoreNoCodeWarning` option\nto `true` for this module.');
}
});
function hasConstruct(name) {
var d = self.synth.definitions[name];
if (d.construct) {
// Module definition at project level has construct
return true;
}
if (self.synth.isMy(d.__meta.name)) {
// None at project level, but maybe at npm level, look there
d = d.extend;
}
// If we got to the base class of all modules, the module
// has no construct of its own
if (d.__meta.name.match(/apostrophe-module$/)) {
return false;
}
return d.beforeConstruct || d.construct || d.afterConstruct;
}
function isMoogBundle(name) {
var d = self.synth.definitions[name];
return d.moogBundle || (d.extend && d.extend.moogBundle);
}
function extending(module) {
// If the module extends no other module, then it will
// have up to four entries in its inheritance chain:
// project level self, npm level self, `apostrophe-modules`
// project-level and `apostrophe-modules` npm level.
return module.__meta.chain.length > 4;
}
return callback(null);
}
function migrate(callback) {
traceStartup('migrate');
if (self.argv._[0] === 'apostrophe-migrations:migrate') {
// Migration task will do this later with custom arguments to
// the event
return callback(null);
}
// Allow the migrate-at-startup behavior to be complete shut off, including
// parked page checks, etc. In this case you are obligated to run the
// apostrophe-migrations:migrate task during deployment before launching
// with new versions of the code
if (process.env.APOS_NO_MIGRATE || (self.options.migrate === false)) {
return callback(null);
}
// Carry out all migrations and consistency checks of the database that are
// still pending before proceeding to listen for connections or run tasks
// that assume a sane environment. If `apostrophe-migrations:migrate` has
// already been run then this will typically find no work to do, although
// the consistency checks can take time on a very large distributed database
// (see the options above).
return self.promiseEmit('migrate', {}).then(function() {
return callback(null);
}).catch(callback);
}
function lint(s) {
self.utils.warnDev('\n⚠️ It looks like you may have made a mistake in your code:\n\n' + s + '\n');
}
function afterInit(callback) {
traceStartup('afterInit');
// Give project-level code a chance to run before we
// listen or run a task
if (!self.options.afterInit) {
return setImmediate(callback);
}
return self.options.afterInit(callback);
}
// Generic helper for call* methods
function invoke(moduleName, method, extraArgs, callback) {
var module = self.modules[moduleName];
var invoke = module[method];
if (invoke) {
if (invoke.length === (1 + extraArgs.length)) {
return invoke.apply(module, extraArgs.concat([callback]));
} else if (invoke.length === extraArgs.length) {
return setImmediate(function () {
try {
invoke.apply(module, extraArgs);
} catch (e) {
return callback(e);
}
return callback(null);
});
} else {
return callback(moduleName + ' module: your ' + method + ' method must take ' + extraArgs.length + ' arguments, plus an optional callback.');
}
} else {
return setImmediate(callback);
}
}
};
var abstractClasses = [ 'apostrophe-module', 'apostrophe-widgets', 'apostrophe-custom-pages', 'apostrophe-pieces', 'apostrophe-pieces-pages', 'apostrophe-pieces-widgets', 'apostrophe-doc-type-manager' ];
module.exports.moogBundle = {
modules: abstractClasses.concat(_.keys(defaults.modules)),
directory: 'lib/modules'
};
function traceStartup(message) {
if (process.env.APOS_TRACE_STARTUP) {
/* eslint-disable-next-line no-console */
console.debug('⌁ startup ' + message);
}
}