Ghost/core/boot.js
Hannah Wolfe 409dc3b534
Added frontend key to ghost_head for portal (#14782)
refs: https://github.com/TryGhost/Team/issues/1599
refs: f3d5d9cf6b

- this commit adds the concept of a frontend data service, intended for passing data to the frontend from the server in a clean way. This is the start of a new & improved pattern, to hopefully reduce coupling
- the newly added internal frontend key is then exposed through this pattern so that the frontend can make use of it
- the first use case is so that portal can use it to talk to the content API instead of having weird endpoints for portal
- this key will also be used by other internal scripts in future, it's public and therefore safe to expose, but it's meant for internal use only and therefore is not exposed in a generic way e.g. as a helper
2022-05-11 17:34:31 +01:00

485 lines
17 KiB
JavaScript

// The Ghost Boot Sequence
// -----------------------
// - This is intentionally one big file at the moment, so that we don't have to follow boot logic all over the place
// - This file is FULL of debug statements so we can see timings for the various steps because the boot needs to be as fast as possible
// - As we manage to break the codebase down into distinct components for e.g. the frontend, their boot logic can be offloaded to them
// - app.js is separate as the first example of each component having it's own app.js file colocated with it, instead of inside of server/web
//
// IMPORTANT:
// ----------
// The only global requires here should be overrides + debug so we can monitor timings with DEBUG = ghost: boot * node ghost
require('./server/overrides');
const debug = require('@tryghost/debug')('boot');
// END OF GLOBAL REQUIRES
/**
* Helper class to create consistent log messages
*/
class BootLogger {
constructor(logging, metrics, startTime) {
this.logging = logging;
this.metrics = metrics;
this.startTime = startTime;
}
log(message) {
let {logging, startTime} = this;
logging.info(`Ghost ${message} in ${(Date.now() - startTime) / 1000}s`);
}
/**
* @param {string} name
* @param {number} [initialTime]
*/
metric(name, initialTime) {
let {metrics, startTime} = this;
if (!initialTime) {
initialTime = startTime;
}
metrics.metric(name, Date.now() - initialTime);
}
}
/**
* Helper function to handle sending server ready notifications
* @param {string} [error]
*/
function notifyServerReady(error) {
const notify = require('./server/notify');
if (error) {
debug('Notifying server ready (error)');
notify.notifyServerReady(error);
} else {
debug('Notifying server ready (success)');
notify.notifyServerReady();
}
}
/**
* Get the Database into a ready state
* - DatabaseStateManager handles doing all this for us
*
* @param {object} options
* @param {object} options.config
*/
async function initDatabase({config}) {
const DatabaseStateManager = require('./server/data/db/state-manager');
const dbStateManager = new DatabaseStateManager({knexMigratorFilePath: config.get('paths:appRoot')});
await dbStateManager.makeReady();
const databaseInfo = require('./server/data/db/info');
await databaseInfo.init();
}
/**
* Core is intended to be all the bits of Ghost that are fundamental and we can't do anything without them!
* (There's more to do to make this true)
* @param {object} options
* @param {object} options.ghostServer
* @param {object} options.config
* @param {object} options.bootLogger
* @param {boolean} options.frontend
*/
async function initCore({ghostServer, config, bootLogger, frontend}) {
debug('Begin: initCore');
// URL Utils is a bit slow, put it here so the timing is visible separate from models
debug('Begin: Load urlUtils');
require('./shared/url-utils');
debug('End: Load urlUtils');
// Models are the heart of Ghost - this is a syncronous operation
debug('Begin: models');
const models = require('./server/models');
models.init();
debug('End: models');
// Settings are a core concept we use settings to store key-value pairs used in critical pathways as well as public data like the site title
debug('Begin: settings');
const settings = require('./server/services/settings/settings-service');
await settings.init();
await settings.syncEmailSettings(config.get('hostSettings:emailVerification:verified'));
debug('End: settings');
// The URLService is a core part of Ghost, which depends on models.
debug('Begin: Url Service');
const urlService = require('./server/services/url');
// Note: there is no await here, we do not wait for the url service to finish
// We can return, but the site will remain in maintenance mode until this finishes
// This is managed on request: https://github.com/TryGhost/Ghost/blob/main/core/app.js#L10
urlService.init({
onFinished: () => {
bootLogger.log('URL Service Ready');
},
urlCache: !frontend // hacky parameter to make the cache initialization kick in as we can't initialize labs before the boot
});
debug('End: Url Service');
if (ghostServer) {
// Job Service allows parts of Ghost to run in the background
debug('Begin: Job Service');
const jobService = require('./server/services/jobs');
ghostServer.registerCleanupTask(async () => {
await jobService.shutdown();
});
debug('End: Job Service');
ghostServer.registerCleanupTask(async () => {
await urlService.shutdown();
});
}
debug('End: initCore');
}
/**
* These are services required by Ghost's frontend.
* @param {object} options
* @param {object} options.bootLogger
*/
async function initServicesForFrontend({bootLogger}) {
debug('Begin: initServicesForFrontend');
debug('Begin: Routing Settings');
const routeSettings = require('./server/services/route-settings');
await routeSettings.init();
debug('End: Routing Settings');
debug('Begin: Redirects');
const customRedirects = require('./server/services/redirects');
await customRedirects.init(),
debug('End: Redirects');
debug('Begin: Themes');
// customThemSettingsService.api must be initialized before any theme activation occurs
const customThemeSettingsService = require('./server/services/custom-theme-settings');
customThemeSettingsService.init();
const themeService = require('./server/services/themes');
const themeServiceStart = Date.now();
await themeService.init();
bootLogger.metric('theme-service-init', themeServiceStart);
debug('End: Themes');
debug('Begin: Offers');
const offers = require('./server/services/offers');
await offers.init();
debug('End: Offers');
const frontendDataService = require('./server/services/frontend-data-service');
let dataService = await frontendDataService.init();
debug('End: initServicesForFrontend');
return {dataService};
}
/**
* Frontend is intended to be just Ghost's frontend
*/
async function initFrontend(dataService) {
debug('Begin: initFrontend');
const proxyService = require('./frontend/services/proxy');
proxyService.init({dataService});
const helperService = require('./frontend/services/helpers');
await helperService.init();
debug('End: initFrontend');
}
/**
* At the moment we load our express apps all in one go, they require themselves and are co-located
* What we want is to be able to optionally load various components and mount them
* So eventually this function should go away
* @param {Object} options
* @param {Boolean} options.backend
* @param {Boolean} options.frontend
* @param {Object} options.config
*/
async function initExpressApps({frontend, backend, config}) {
debug('Begin: initExpressApps');
const parentApp = require('./server/web/parent/app')();
const vhost = require('@tryghost/vhost-middleware');
// Mount the express apps on the parentApp
if (backend) {
// ADMIN + API
const backendApp = require('./server/web/parent/backend')();
parentApp.use(vhost(config.getBackendMountPath(), backendApp));
}
if (frontend) {
// SITE + MEMBERS
const urlService = require('./server/services/url');
const frontendApp = require('./server/web/parent/frontend')({urlService});
parentApp.use(vhost(config.getFrontendMountPath(), frontendApp));
}
debug('End: initExpressApps');
return parentApp;
}
/**
* Dynamic routing is generated from the routes.yaml file
* When Ghost's DB and core are loaded, we can access this file and call routing.routingManager.start
* However this _must_ happen after the express Apps are loaded, hence why this is here and not in initFrontend
* Routing is currently tightly coupled between the frontend and backend
*/
async function initDynamicRouting() {
debug('Begin: Dynamic Routing');
const routing = require('./frontend/services/routing');
const routeSettingsService = require('./server/services/route-settings');
const bridge = require('./bridge');
bridge.init();
// We pass the dynamic routes here, so that the frontend services are slightly less tightly-coupled
const routeSettings = await routeSettingsService.loadRouteSettings();
routing.routerManager.start(routeSettings);
const getRoutesHash = () => routeSettingsService.api.getCurrentHash();
const settings = require('./server/services/settings/settings-service');
await settings.syncRoutesHash(getRoutesHash);
debug('End: Dynamic Routing');
}
/**
* Services are components that make up part of Ghost and need initializing on boot
* These services should all be part of core, frontend services should be loaded with the frontend
* We are working towards this being a service loader, with the ability to make certain services optional
*
* @param {object} options
* @param {object} options.config
*/
async function initServices({config}) {
debug('Begin: initServices');
debug('Begin: Services');
const stripe = require('./server/services/stripe');
const members = require('./server/services/members');
const permissions = require('./server/services/permissions');
const xmlrpc = require('./server/services/xmlrpc');
const slack = require('./server/services/slack');
const {mega} = require('./server/services/mega');
const webhooks = require('./server/services/webhooks');
const appService = require('./frontend/services/apps');
const limits = require('./server/services/limits');
const apiVersionCompatibility = require('./server/services/api-version-compatibility');
const scheduling = require('./server/adapters/scheduling');
const urlUtils = require('./shared/url-utils');
// NOTE: limits service has to be initialized first
// in case it limits initialization of any other service (e.g. webhooks)
await limits.init();
// NOTE: Members service depends on these
// so they are initialized before it.
await stripe.init();
await Promise.all([
members.init(),
permissions.init(),
xmlrpc.listen(),
slack.listen(),
mega.listen(),
webhooks.listen(),
appService.init(),
apiVersionCompatibility.init(),
scheduling.init({
apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true)
})
]);
debug('End: Services');
// Initialize analytics events
if (config.get('segment:key')) {
require('./server/analytics-events').init();
}
debug('End: initServices');
}
/**
* Kick off recurring jobs and background services
* These are things that happen on boot, but we don't need to wait for them to finish
* Later, this might be a service hook
* @param {object} options
* @param {object} options.config
*/
async function initBackgroundServices({config}) {
debug('Begin: initBackgroundServices');
// Load all inactive themes
const themeService = require('./server/services/themes');
themeService.loadInactiveThemes();
// we don't want to kick off background services that will interfere with tests
if (process.env.NODE_ENV.startsWith('test')) {
return;
}
// Load email analytics recurring jobs
if (config.get('backgroundJobs:emailAnalytics')) {
const emailAnalyticsJobs = require('./server/services/email-analytics/jobs');
await emailAnalyticsJobs.scheduleRecurringJobs();
}
const updateCheck = require('./server/update-check');
updateCheck.scheduleRecurringJobs();
debug('End: initBackgroundServices');
}
/**
* ----------------------------------
* Boot Ghost - The magic starts here
* ----------------------------------
*
* - This function is written with async/await so you can read, line by line, what happens on boot
* - All the functions above handle init/boot logic for a single component
* @returns {Promise<object>} ghostServer
*/
async function bootGhost({backend = true, frontend = true, server = true} = {}) {
// Metrics
const startTime = Date.now();
debug('Begin Boot');
// We need access to these variables in both the try and catch block
let bootLogger;
let config;
let ghostServer;
let logging;
let metrics;
// These require their own try-catch block and error format, because we can't log an error if logging isn't working
try {
// Step 0 - Load config and logging - fundamental required components
// Version is required by logging, sentry & Migration config & so is fundamental to booting
// However, it involves reading package.json so its slow & it's here for visibility on that slowness
debug('Begin: Load version info');
require('@tryghost/version');
debug('End: Load version info');
// Loading config must be the first thing we do, because it is required for absolutely everything
debug('Begin: Load config');
config = require('./shared/config');
debug('End: Load config');
// Logging is also used absolutely everywhere
debug('Begin: Load logging');
logging = require('@tryghost/logging');
metrics = require('@tryghost/metrics');
bootLogger = new BootLogger(logging, metrics, startTime);
debug('End: Load logging');
// At this point logging is required, so we can handle errors better
// Add a process handler to capture and log unhandled rejections
debug('Begin: Add unhandled rejection handler');
process.on('unhandledRejection', (error) => {
logging.error('Unhandled rejection:', error);
});
debug('End: Add unhandled rejection handler');
} catch (error) {
console.error(error); // eslint-disable-line no-console
process.exit(1);
}
try {
// Step 1 - require more fundamental components
// Sentry must be initialized early, but requires config
debug('Begin: Load sentry');
require('./shared/sentry');
debug('End: Load sentry');
// Step 2 - Start server with minimal app in global maintenance mode
debug('Begin: load server + minimal app');
const rootApp = require('./app')();
if (server) {
const GhostServer = require('./server/ghost-server');
ghostServer = new GhostServer({url: config.getSiteUrl(), env: config.get('env'), serverConfig: config.get('server')});
await ghostServer.start(rootApp);
bootLogger.log('server started');
debug('End: load server + minimal app');
}
// Step 3 - Get the DB ready
debug('Begin: Get DB ready');
await initDatabase({config});
bootLogger.log('database ready');
debug('End: Get DB ready');
// Step 4 - Load Ghost with all its services
debug('Begin: Load Ghost Services & Apps');
await initCore({ghostServer, config, bootLogger, frontend});
const {dataService} = await initServicesForFrontend({bootLogger});
if (frontend) {
await initFrontend(dataService);
}
const ghostApp = await initExpressApps({frontend, backend, config});
if (frontend) {
await initDynamicRouting();
}
await initServices({config});
debug('End: Load Ghost Services & Apps');
// Step 5 - Mount the full Ghost app onto the minimal root app & disable maintenance mode
debug('Begin: mountGhost');
rootApp.disable('maintenance');
rootApp.use(config.getSubdir(), ghostApp);
debug('End: mountGhost');
// Step 6 - We are technically done here - let everyone know!
bootLogger.log('booted');
bootLogger.metric('boot-time');
notifyServerReady();
// Step 7 - Init our background services, we don't wait for this to finish
initBackgroundServices({config});
// We return the server purely for testing purposes
if (server) {
debug('End Boot: Returning Ghost Server');
return ghostServer;
} else {
debug('End boot: Returning Root App');
return rootApp;
}
} catch (error) {
const errors = require('@tryghost/errors');
// Ensure the error we have is an ignition error
let serverStartError = error;
if (!errors.utils.isGhostError(serverStartError)) {
serverStartError = new errors.InternalServerError({message: serverStartError.message, err: serverStartError});
}
logging.error(serverStartError);
// If ghost was started and something else went wrong, we shut it down
if (ghostServer) {
notifyServerReady(serverStartError);
ghostServer.shutdown(2);
} else {
// Ghost server failed to start, set a timeout to give logging a chance to flush
setTimeout(() => {
process.exit(2);
}, 100);
}
}
}
module.exports = bootGhost;