UNPKG

22.9 kBJavaScriptView Raw
1// Use of console permitted here because we sometimes need to
2// print something before the utils module exists. -Tom
3
4/* eslint no-console: 0 */
5
6var path = require('path');
7var _ = require('@sailshq/lodash');
8var argv = require('yargs').argv;
9var fs = require('fs');
10var async = require('async');
11var npmResolve = require('resolve');
12var defaults = require('./defaults.js');
13var glob = require('glob');
14
15module.exports = function(options) {
16
17 traceStartup('begin');
18
19 // The core is not a true moog object but it must look enough like one
20 // to participate as a promise event emitter
21 var self = {
22 __meta: {
23 name: 'apostrophe'
24 }
25 };
26
27 // The core must have a reference to itself in order to use the
28 // promise event emitter code
29 self.apos = self;
30
31 require('./lib/modules/apostrophe-module/lib/events.js')(self, options);
32
33 try {
34 // Determine root module and root directory
35 self.root = options.root || getRoot();
36 self.rootDir = options.rootDir || path.dirname(self.root.filename);
37 self.npmRootDir = options.npmRootDir || self.rootDir;
38
39 testModule();
40
41 self.options = mergeConfiguration(options, defaults);
42 autodetectBundles();
43 acceptGlobalOptions();
44
45 // Legacy events
46 self.handlers = {};
47
48 // Module-based, promisified events (self.on and self.emit of each module)
49 self.eventHandlers = {};
50
51 traceStartup('defineModules');
52 defineModules();
53 } catch (err) {
54 if (options.initFailed) {
55 // Report error in an extensible way
56 return options.initFailed(err);
57 } else {
58 throw err;
59 }
60 }
61
62 // No return statement here because we need to
63 // return "self" after kicking this process off
64
65 async.series([
66 instantiateModules,
67 modulesReady,
68 modulesAfterInit,
69 lintModules,
70 migrate,
71 afterInit
72 ], function(err) {
73 if (err) {
74 if (options.initFailed) {
75 // Report error in an extensible way
76 return options.initFailed(err);
77 } else {
78 throw err;
79 }
80 }
81 traceStartup('startup end');
82 if (self.argv._.length) {
83 self.emit('runTask');
84 } else {
85 // The apostrophe-express module adds this method
86 self.listen();
87 }
88 });
89
90 // EVENT HANDLING (legacy events)
91 //
92 // apos.emit(eventName, /* arg1, arg2, arg3... */)
93 //
94 // Emit an Apostrophe legacy event. All handlers that have been set
95 // with apos.on for the same eventName will be invoked. Any additional
96 // arguments are received by the handler functions as arguments.
97 //
98 // See the `self.on` and `self.emit` methods of all modules
99 // (via the `apostrophe-module`) base class for a better,
100 // promisified event system.
101
102 self.emit = function(eventName /* ,arg1, arg2, arg3... */) {
103 var handlers = self.handlers[eventName];
104 if (!handlers) {
105 return;
106 }
107 var args = Array.prototype.slice.call(arguments, 1);
108 var i;
109 for (i = 0; (i < handlers.length); i++) {
110 handlers[i].apply(self, args);
111 }
112 };
113
114 // Install an Apostrophe legacy event handler. The handler will be called
115 // when apos.emit is invoked with the same eventName. The handler
116 // will receive any additional arguments passed to apos.emit.
117 //
118 // See the `self.on` and `self.emit` methods of all modules
119 // (via the `apostrophe-module`) base class for a better,
120 // promisified event system.
121
122 self.on = function(eventName, fn) {
123 self.handlers[eventName] = (self.handlers[eventName] || []).concat([ fn ]);
124 };
125
126 // Remove an Apostrophe event handler. If fn is not supplied, all
127 // handlers for the given eventName are removed.
128 self.off = function(eventName, fn) {
129 if (!fn) {
130 delete self.handlers[eventName];
131 return;
132 }
133 self.handlers[eventName] = _.filter(self.handlers[eventName], function(_fn) {
134 return fn !== _fn;
135 });
136 };
137
138 // Legacy feature only. New code should call the `emit` method of the
139 // relevant module to implement a promise event instead. Will be removed
140 // in 3.x.
141 //
142 // For every module, if the method `method` exists,
143 // invoke it. The method may optionally take a callback.
144 // The method must take exactly as many additional
145 // arguments as are passed here between `method`
146 // and the final `callback`.
147
148 self.callAll = function(method, /* argument, ... */ callback) {
149 var args = Array.prototype.slice.call(arguments);
150 var extraArgs = args.slice(1, args.length - 1);
151 callback = args[args.length - 1];
152 return async.eachSeries(_.keys(self.modules), function(name, callback) {
153 return invoke(name, method, extraArgs, callback);
154 }, function(err) {
155 if (err) {
156 return callback(err);
157 }
158 return callback(null);
159 });
160 };
161
162 /**
163 * Allow to bind a callAll method for one module. Legacy feature.
164 * Use promise events instead.
165 */
166 self.callOne = function(moduleName, method, /* argument, ... */ callback) {
167 var args = Array.prototype.slice.call(arguments);
168 var extraArgs = args.slice(2, args.length - 1);
169 callback = args[args.length - 1];
170 return invoke(moduleName, method, extraArgs, callback);
171 };
172
173 // Destroys the Apostrophe object, freeing resources such as
174 // HTTP server ports and database connections. Does **not**
175 // delete any data; the persistent database and media files
176 // remain available for the next startup. Invokes
177 // the `apostropheDestroy` methods of all modules that
178 // provide one, and also emits the `destroy` promise event on
179 // the `apostrophe` module; use this mechanism to free your own
180 // server-side resources that could prevent garbage
181 // collection by the JavaScript engine, such as timers
182 // and intervals.
183 self.destroy = function(callback) {
184 return self.callAllAndEmit('apostropheDestroy', 'destroy', callback);
185 };
186
187 // Returns true if Apostrophe is running as a command line task
188 // rather than as a server
189 self.isTask = function() {
190 return !!self.argv._.length;
191 };
192
193 // Returns an array of modules that are instances of the given
194 // module name, i.e. they are of that type or they extend it.
195 // For instance, `apos.instancesOf('apostrophe-pieces')` returns
196 // an array of active modules in your project that extend
197 // pieces, such as `apostrophe-users`, `apostrophe-groups` and
198 // your own piece types
199
200 self.instancesOf = function(name) {
201 return _.filter(self.modules, function(module) {
202 return self.synth.instanceOf(module, name);
203 });
204 };
205
206 // Returns true if the object is an instance of the given
207 // moog type name or a subclass thereof. A convenience wrapper
208 // for `apos.synth.instanceOf`
209
210 self.instanceOf = function(object, name) {
211 return self.synth.instanceOf(object, name);
212 };
213
214 // Return self so that app.js can refer to apos
215 // in inline functions, etc.
216 return self;
217
218 // SUPPORTING FUNCTIONS BEGIN HERE
219
220 // Merge configuration from defaults, data/local.js and app.js
221 function mergeConfiguration(options, defaults) {
222 var config = {};
223 var local = {};
224 var localPath = options.__localPath || '/data/local.js';
225 var reallyLocalPath = self.rootDir + localPath;
226
227 if (fs.existsSync(reallyLocalPath)) {
228 local = require(reallyLocalPath);
229 }
230
231 // Otherwise making a second apos instance
232 // uses the same modified defaults object
233
234 config = _.cloneDeep(options.__testDefaults || defaults);
235
236 _.merge(config, options);
237
238 if (typeof (local) === 'function') {
239 if (local.length === 1) {
240 _.merge(config, local(self));
241 } else if (local.length === 2) {
242 local(self, config);
243 } else {
244 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');
245 }
246 } else {
247 _.merge(config, local || {});
248 }
249
250 return config;
251 }
252
253 function getRoot() {
254 var _module = module;
255 var m = _module;
256 while (m.parent) {
257 // The test file is the root as far as we are concerned,
258 // not mocha itself
259 if (m.parent.filename.match(/\/node_modules\/mocha\//)) {
260 return m;
261 }
262 m = m.parent;
263 _module = m;
264 }
265 return _module;
266 }
267
268 function nestedModuleSubdirs() {
269 if (!options.nestedModuleSubdirs) {
270 return;
271 }
272 var configs = glob.sync(self.moogOptions.localModules + '/**/modules.js');
273 _.each(configs, function(config) {
274 try {
275 _.merge(self.options.modules, require(config));
276 } catch (e) {
277 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.');
278 throw e;
279 }
280 });
281 }
282
283 function autodetectBundles() {
284 var modules = _.keys(self.options.modules);
285 _.each(modules, function(name) {
286 var path = getNpmPath(name);
287 if (!path) {
288 return;
289 }
290 var module = require(path);
291 if (module.moogBundle) {
292 self.options.bundles = (self.options.bundles || []).concat(name);
293 _.each(module.moogBundle.modules, function(name) {
294 if (!_.has(self.options.modules, name)) {
295 var bundledModule = require(require('path').dirname(path) + '/' + module.moogBundle.directory + '/' + name);
296 if (bundledModule.improve) {
297 self.options.modules[name] = {};
298 }
299 }
300 });
301 }
302 });
303 }
304
305 function getNpmPath(name) {
306 var parentPath = path.resolve(self.npmRootDir);
307 try {
308 return npmResolve.sync(name, { basedir: parentPath });
309 } catch (e) {
310 // Not found via npm. This does not mean it doesn't
311 // exist as a project-level thing
312 return null;
313 }
314 }
315
316 function acceptGlobalOptions() {
317 // Truly global options not specific to a module
318 if (options.testModule) {
319 // Test command lines have arguments not
320 // intended as command line task arguments
321 self.argv = {
322 _: []
323 };
324 self.options.shortName = self.options.shortName || 'test';
325 } else if (options.argv) {
326 // Allow injection of any set of command line arguments.
327 // Useful with multiple instances
328 self.argv = options.argv;
329 } else {
330 self.argv = argv;
331 }
332
333 self.shortName = self.options.shortName;
334 if (!self.shortName) {
335 throw "Specify the `shortName` option and set it to the name of your project's repository or folder";
336 }
337 self.title = self.options.title;
338 self.baseUrl = self.options.baseUrl;
339 self.prefix = self.options.prefix || '';
340 }
341
342 // Tweak the Apostrophe environment suitably for
343 // unit testing a separate npm module that extends
344 // Apostrophe, like apostrophe-workflow. For instance,
345 // a node_modules subdirectory with a symlink to the
346 // module itself is created so that the module can
347 // be found by Apostrophe during testing. Invoked
348 // when options.testModule is true. There must be a
349 // test/ or tests/ subdir of the module containing
350 // a test.js file that runs under mocha via devDependencies.
351
352 function testModule() {
353 if (!options.testModule) {
354 return;
355 }
356 if (!options.shortName) {
357 options.shortName = 'test';
358 }
359 defaults = _.cloneDeep(defaults);
360 _.defaults(defaults, {
361 'apostrophe-express': {}
362 });
363 _.defaults(defaults['apostrophe-express'], {
364 port: 7900,
365 secret: 'irrelevant'
366 });
367 var m = findTestModule();
368 // Allow tests to be in test/ or in tests/
369 var testDir = require('path').dirname(m.filename);
370 var testRegex;
371 if (process.platform === "win32") {
372 testRegex = /\\tests?$/;
373 } else {
374 testRegex = /\/tests?$/;
375 }
376 var moduleDir = testDir.replace(testRegex, '');
377 if (testDir === moduleDir) {
378 throw new Error('Test file must be in test/ or tests/ subdirectory of module');
379 }
380 var moduleName = require('path').basename(moduleDir);
381 try {
382 // Use the given name in the package.json file if it is present
383 var packageName = JSON.parse(fs.readFileSync(path.resolve(moduleDir, 'package.json'), 'utf8')).name;
384 if (typeof packageName === 'string') {
385 moduleName = packageName;
386 }
387 } catch (e) {}
388 var testDependenciesDir = testDir + require("path").normalize('/node_modules/');
389 if (!fs.existsSync(testDependenciesDir + moduleName)) {
390 // Ensure dependencies directory exists
391 if (!fs.existsSync(testDependenciesDir)) {
392 fs.mkdirSync(testDependenciesDir);
393 }
394 // Ensure potential module scope directory exists before the symlink creation
395 if (moduleName.charAt(0) === '@' && moduleName.includes(require("path").sep)) {
396 var scope = moduleName.split(require("path").sep)[0];
397 var scopeDir = testDependenciesDir + scope;
398 if (!fs.existsSync(scopeDir)) {
399 fs.mkdirSync(scopeDir);
400 }
401 }
402 // Windows 10 got an issue with permission , known issue at https://github.com/nodejs/node/issues/18518
403 // Therefore need to have if else statement to determine type of symlinkSync uses.
404 var type;
405 if (process.platform === "win32") {
406 type = "junction";
407 } else {
408 type = "dir";
409 }
410 fs.symlinkSync(moduleDir, testDependenciesDir + moduleName, type);
411 }
412
413 // Not quite superfluous: it'll return self.root, but
414 // it also makes sure we encounter mocha along the way
415 // and throws an exception if we don't
416 function findTestModule() {
417 var m = module;
418 var nodeModuleRegex;
419 if (process.platform === "win32") {
420 nodeModuleRegex = /node_modules\\mocha/;
421 } else {
422 nodeModuleRegex = /node_modules\/mocha/;
423 }
424 while (m) {
425 if (m.parent && m.parent.filename.match(nodeModuleRegex)) {
426 return m;
427 }
428 m = m.parent;
429 if (!m) {
430 throw new Error('mocha does not seem to be running, is this really a test?');
431 }
432 }
433 }
434 }
435
436 function defineModules() {
437 // Set moog-require up to create our module manager objects
438
439 self.moogOptions = {
440 root: self.root,
441 bundles: [ 'apostrophe' ].concat(self.options.bundles || []),
442 localModules: self.options.modulesSubdir || self.options.__testLocalModules || (self.rootDir + '/lib/modules'),
443 defaultBaseClass: 'apostrophe-module',
444 nestedModuleSubdirs: self.options.nestedModuleSubdirs
445 };
446 var synth = require('moog-require')(self.moogOptions);
447
448 self.synth = synth;
449
450 // Just like on the browser side, we can
451 // call apos.define rather than apos.synth.define
452 self.define = self.synth.define;
453 self.redefine = self.synth.redefine;
454 self.create = self.synth.create;
455
456 nestedModuleSubdirs();
457
458 _.each(self.options.modules, function(options, name) {
459 synth.define(name, options);
460 });
461
462 return synth;
463 }
464
465 function instantiateModules(callback) {
466 traceStartup('instantiateModules');
467 self.modules = {};
468 return async.eachSeries(_.keys(self.options.modules), function(item, callback) {
469 traceStartup('Instantiating module ' + item);
470 var improvement = self.synth.isImprovement(item);
471 if (self.options.modules[item] && (improvement || self.options.modules[item].instantiate === false)) {
472 // We don't want an actual instance of this module, we are using it
473 // as an abstract base class in this particular project (but still
474 // configuring it, to easily carry those options to subclasses, which
475 // is how we got here)
476 return setImmediate(callback);
477 }
478 return self.synth.create(item, { apos: self }, function(err, obj) {
479 if (err) {
480 return callback(err);
481 }
482 return callback(null);
483 });
484 }, function(err) {
485 return setImmediate(function() {
486 return callback(err);
487 });
488 });
489 }
490
491 function modulesReady(callback) {
492 traceStartup('modulesReady');
493 return self.callAllAndEmit('modulesReady', 'modulesReady', callback);
494 }
495
496 function modulesAfterInit(callback) {
497 traceStartup('modulesAfterInit');
498 return self.callAllAndEmit('afterInit', 'afterInit', callback);
499 }
500
501 function lintModules(callback) {
502 traceStartup('lintModules');
503 _.each(self.modules, function(module, name) {
504 if (module.options.extends && ((typeof module.options.extends) === 'string')) {
505 lint('The module ' + name + ' contains an "extends" option. This is probably a\nmistake. In Apostrophe "extend" is used to extend other modules.');
506 }
507 if (module.options.singletonWarningIfNot && (name !== module.options.singletonWarningIfNot)) {
508 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.');
509 }
510 if (name.match(/-widgets$/) && (!extending(module)) && (!module.options.ignoreNoExtendWarning)) {
511 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.');
512 } else if (name.match(/-pages$/) && (name !== 'apostrophe-pages') && (!extending(module)) && (!module.options.ignoreNoExtendWarning)) {
513 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.');
514 } else if ((!extending(module)) && (!hasConstruct(name)) && (!isMoogBundle(name)) && (!module.options.ignoreNoCodeWarning)) {
515 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.');
516 }
517 });
518 function hasConstruct(name) {
519 var d = self.synth.definitions[name];
520 if (d.construct) {
521 // Module definition at project level has construct
522 return true;
523 }
524 if (self.synth.isMy(d.__meta.name)) {
525 // None at project level, but maybe at npm level, look there
526 d = d.extend;
527 }
528 // If we got to the base class of all modules, the module
529 // has no construct of its own
530 if (d.__meta.name.match(/apostrophe-module$/)) {
531 return false;
532 }
533 return d.beforeConstruct || d.construct || d.afterConstruct;
534 }
535 function isMoogBundle(name) {
536 var d = self.synth.definitions[name];
537 return d.moogBundle || (d.extend && d.extend.moogBundle);
538 }
539 function extending(module) {
540 // If the module extends no other module, then it will
541 // have up to four entries in its inheritance chain:
542 // project level self, npm level self, `apostrophe-modules`
543 // project-level and `apostrophe-modules` npm level.
544 return module.__meta.chain.length > 4;
545 }
546 return callback(null);
547 }
548
549 function migrate(callback) {
550 traceStartup('migrate');
551 if (self.argv._[0] === 'apostrophe-migrations:migrate') {
552 // Migration task will do this later with custom arguments to
553 // the event
554 return callback(null);
555 }
556 // Allow the migrate-at-startup behavior to be complete shut off, including
557 // parked page checks, etc. In this case you are obligated to run the
558 // apostrophe-migrations:migrate task during deployment before launching
559 // with new versions of the code
560 if (process.env.APOS_NO_MIGRATE || (self.options.migrate === false)) {
561 return callback(null);
562 }
563 // Carry out all migrations and consistency checks of the database that are
564 // still pending before proceeding to listen for connections or run tasks
565 // that assume a sane environment. If `apostrophe-migrations:migrate` has
566 // already been run then this will typically find no work to do, although
567 // the consistency checks can take time on a very large distributed database
568 // (see the options above).
569 return self.promiseEmit('migrate', {}).then(function() {
570 return callback(null);
571 }).catch(callback);
572 }
573
574 function lint(s) {
575 self.utils.warnDev('\n⚠️ It looks like you may have made a mistake in your code:\n\n' + s + '\n');
576 }
577
578 function afterInit(callback) {
579 traceStartup('afterInit');
580 // Give project-level code a chance to run before we
581 // listen or run a task
582 if (!self.options.afterInit) {
583 return setImmediate(callback);
584 }
585 return self.options.afterInit(callback);
586 }
587
588 // Generic helper for call* methods
589 function invoke(moduleName, method, extraArgs, callback) {
590 var module = self.modules[moduleName];
591 var invoke = module[method];
592 if (invoke) {
593 if (invoke.length === (1 + extraArgs.length)) {
594 return invoke.apply(module, extraArgs.concat([callback]));
595 } else if (invoke.length === extraArgs.length) {
596 return setImmediate(function () {
597 try {
598 invoke.apply(module, extraArgs);
599 } catch (e) {
600 return callback(e);
601 }
602 return callback(null);
603 });
604 } else {
605 return callback(moduleName + ' module: your ' + method + ' method must take ' + extraArgs.length + ' arguments, plus an optional callback.');
606 }
607 } else {
608 return setImmediate(callback);
609 }
610 }
611
612};
613
614var abstractClasses = [ 'apostrophe-module', 'apostrophe-widgets', 'apostrophe-custom-pages', 'apostrophe-pieces', 'apostrophe-pieces-pages', 'apostrophe-pieces-widgets', 'apostrophe-doc-type-manager' ];
615
616module.exports.moogBundle = {
617 modules: abstractClasses.concat(_.keys(defaults.modules)),
618 directory: 'lib/modules'
619};
620
621function traceStartup(message) {
622 if (process.env.APOS_TRACE_STARTUP) {
623 /* eslint-disable-next-line no-console */
624 console.debug('⌁ startup ' + message);
625 }
626}