bootstrap.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import { INestApplication, INestMicroservice } from '@nestjs/common';
  2. import { NestFactory } from '@nestjs/core';
  3. import { TcpClientOptions, Transport } from '@nestjs/microservices';
  4. import { Type } from '@vendure/common/lib/shared-types';
  5. import cookieSession = require('cookie-session');
  6. import { ConnectionOptions, EntitySubscriberInterface } from 'typeorm';
  7. import { InternalServerError } from './common/error/errors';
  8. import { getConfig, setConfig } from './config/config-helpers';
  9. import { DefaultLogger } from './config/logger/default-logger';
  10. import { Logger } from './config/logger/vendure-logger';
  11. import { RuntimeVendureConfig, VendureConfig } from './config/vendure-config';
  12. import { coreEntitiesMap } from './entity/entities';
  13. import { registerCustomEntityFields } from './entity/register-custom-entity-fields';
  14. import { setEntityIdStrategy } from './entity/set-entity-id-strategy';
  15. import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config';
  16. import { getConfigurationFunction, getEntitiesFromPlugins } from './plugin/plugin-metadata';
  17. import { getProxyMiddlewareCliGreetings } from './plugin/plugin-utils';
  18. import { BeforeVendureBootstrap, BeforeVendureWorkerBootstrap } from './plugin/vendure-plugin';
  19. export type VendureBootstrapFunction = (config: VendureConfig) => Promise<INestApplication>;
  20. /**
  21. * @description
  22. * Bootstraps the Vendure server. This is the entry point to the application.
  23. *
  24. * @example
  25. * ```TypeScript
  26. * import { bootstrap } from '\@vendure/core';
  27. * import { config } from './vendure-config';
  28. *
  29. * bootstrap(config).catch(err => {
  30. * console.log(err);
  31. * });
  32. * ```
  33. * @docsCategory
  34. * */
  35. export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INestApplication> {
  36. const config = await preBootstrapConfig(userConfig);
  37. Logger.useLogger(config.logger);
  38. Logger.info(`Bootstrapping Vendure Server (pid: ${process.pid})...`);
  39. // The AppModule *must* be loaded only after the entities have been set in the
  40. // config, so that they are available when the AppModule decorator is evaluated.
  41. // tslint:disable-next-line:whitespace
  42. const appModule = await import('./app.module');
  43. const { hostname, port, cors } = config.apiOptions;
  44. DefaultLogger.hideNestBoostrapLogs();
  45. const app = await NestFactory.create(appModule.AppModule, {
  46. cors,
  47. logger: new Logger(),
  48. });
  49. DefaultLogger.restoreOriginalLogLevel();
  50. app.useLogger(new Logger());
  51. await runBeforeBootstrapHooks(config, app);
  52. if (config.authOptions.tokenMethod === 'cookie') {
  53. const cookieHandler = cookieSession({
  54. name: 'session',
  55. secret: config.authOptions.sessionSecret,
  56. httpOnly: true,
  57. });
  58. app.use(cookieHandler);
  59. }
  60. await app.listen(port, hostname || '');
  61. app.enableShutdownHooks();
  62. if (config.workerOptions.runInMainProcess) {
  63. try {
  64. const worker = await bootstrapWorkerInternal(config);
  65. Logger.warn(`Worker is running in main process. This is not recommended for production.`);
  66. Logger.warn(`[VendureConfig.workerOptions.runInMainProcess = true]`);
  67. closeWorkerOnAppClose(app, worker);
  68. } catch (e) {
  69. Logger.error(`Could not start the worker process: ${e.message}`, 'Vendure Worker');
  70. }
  71. }
  72. logWelcomeMessage(config);
  73. return app;
  74. }
  75. /**
  76. * @description
  77. * Bootstraps the Vendure worker. Read more about the [Vendure Worker]({{< relref "vendure-worker" >}}) or see the worker-specific options
  78. * defined in {@link WorkerOptions}.
  79. *
  80. * @example
  81. * ```TypeScript
  82. * import { bootstrapWorker } from '\@vendure/core';
  83. * import { config } from './vendure-config';
  84. *
  85. * bootstrapWorker(config).catch(err => {
  86. * console.log(err);
  87. * });
  88. * ```
  89. * @docsCategory worker
  90. * */
  91. export async function bootstrapWorker(userConfig: Partial<VendureConfig>): Promise<INestMicroservice> {
  92. if (userConfig.workerOptions && userConfig.workerOptions.runInMainProcess === true) {
  93. Logger.useLogger(userConfig.logger || new DefaultLogger());
  94. const errorMessage = `Cannot bootstrap worker when "runInMainProcess" is set to true`;
  95. Logger.error(errorMessage, 'Vendure Worker');
  96. throw new Error(errorMessage);
  97. } else {
  98. try {
  99. const vendureConfig = await preBootstrapConfig(userConfig);
  100. return await bootstrapWorkerInternal(vendureConfig);
  101. } catch (e) {
  102. Logger.error(`Could not start the worker process: ${e.message}`, 'Vendure Worker');
  103. throw e;
  104. }
  105. }
  106. }
  107. async function bootstrapWorkerInternal(
  108. vendureConfig: Readonly<RuntimeVendureConfig>,
  109. ): Promise<INestMicroservice> {
  110. const config = disableSynchronize(vendureConfig);
  111. if (!config.workerOptions.runInMainProcess && (config.logger as any).setDefaultContext) {
  112. (config.logger as any).setDefaultContext('Vendure Worker');
  113. }
  114. Logger.useLogger(config.logger);
  115. Logger.info(`Bootstrapping Vendure Worker (pid: ${process.pid})...`);
  116. const workerModule = await import('./worker/worker.module');
  117. DefaultLogger.hideNestBoostrapLogs();
  118. const workerApp = await NestFactory.createMicroservice(workerModule.WorkerModule, {
  119. transport: config.workerOptions.transport,
  120. logger: new Logger(),
  121. options: config.workerOptions.options,
  122. });
  123. DefaultLogger.restoreOriginalLogLevel();
  124. workerApp.useLogger(new Logger());
  125. workerApp.enableShutdownHooks();
  126. await runBeforeWorkerBootstrapHooks(config, workerApp);
  127. // A work-around to correctly handle errors when attempting to start the
  128. // microservice server listening.
  129. // See https://github.com/nestjs/nest/issues/2777
  130. // TODO: Remove if & when the above issue is resolved.
  131. await new Promise((resolve, reject) => {
  132. const tcpServer = (workerApp as any).server.server;
  133. if (tcpServer) {
  134. tcpServer.on('error', (e: any) => {
  135. reject(e);
  136. });
  137. }
  138. workerApp.listenAsync().then(resolve);
  139. });
  140. workerWelcomeMessage(config);
  141. return workerApp;
  142. }
  143. /**
  144. * Setting the global config must be done prior to loading the AppModule.
  145. */
  146. export async function preBootstrapConfig(
  147. userConfig: Partial<VendureConfig>,
  148. ): Promise<Readonly<RuntimeVendureConfig>> {
  149. if (userConfig) {
  150. checkForDeprecatedOptions(userConfig);
  151. setConfig(userConfig);
  152. }
  153. const entities = await getAllEntities(userConfig);
  154. const { coreSubscribersMap } = await import('./entity/subscribers');
  155. setConfig({
  156. dbConnectionOptions: {
  157. entities,
  158. subscribers: Object.values(coreSubscribersMap) as Array<Type<EntitySubscriberInterface>>,
  159. },
  160. });
  161. let config = getConfig();
  162. setEntityIdStrategy(config.entityIdStrategy, entities);
  163. const customFieldValidationResult = validateCustomFieldsConfig(config.customFields, entities);
  164. if (!customFieldValidationResult.valid) {
  165. process.exitCode = 1;
  166. throw new Error(`CustomFields config error:\n- ` + customFieldValidationResult.errors.join('\n- '));
  167. }
  168. config = await runPluginConfigurations(config);
  169. registerCustomEntityFields(config);
  170. setExposedHeaders(config);
  171. return config;
  172. }
  173. /**
  174. * Initialize any configured plugins.
  175. */
  176. async function runPluginConfigurations(config: RuntimeVendureConfig): Promise<RuntimeVendureConfig> {
  177. for (const plugin of config.plugins) {
  178. const configFn = getConfigurationFunction(plugin);
  179. if (typeof configFn === 'function') {
  180. config = await configFn(config);
  181. }
  182. }
  183. return config;
  184. }
  185. /**
  186. * Returns an array of core entities and any additional entities defined in plugins.
  187. */
  188. export async function getAllEntities(userConfig: Partial<VendureConfig>): Promise<Array<Type<any>>> {
  189. const coreEntities = Object.values(coreEntitiesMap) as Array<Type<any>>;
  190. const pluginEntities = getEntitiesFromPlugins(userConfig.plugins);
  191. const allEntities: Array<Type<any>> = coreEntities;
  192. // Check to ensure that no plugins are defining entities with names
  193. // which conflict with existing entities.
  194. for (const pluginEntity of pluginEntities) {
  195. if (allEntities.find(e => e.name === pluginEntity.name)) {
  196. throw new InternalServerError(`error.entity-name-conflict`, { entityName: pluginEntity.name });
  197. } else {
  198. allEntities.push(pluginEntity);
  199. }
  200. }
  201. return allEntities;
  202. }
  203. /**
  204. * If the 'bearer' tokenMethod is being used, then we automatically expose the authTokenHeaderKey header
  205. * in the CORS options, making sure to preserve any user-configured exposedHeaders.
  206. */
  207. function setExposedHeaders(config: Readonly<RuntimeVendureConfig>) {
  208. if (config.authOptions.tokenMethod === 'bearer') {
  209. const authTokenHeaderKey = config.authOptions.authTokenHeaderKey as string;
  210. const corsOptions = config.apiOptions.cors;
  211. if (typeof corsOptions !== 'boolean') {
  212. const { exposedHeaders } = corsOptions;
  213. let exposedHeadersWithAuthKey: string[];
  214. if (!exposedHeaders) {
  215. exposedHeadersWithAuthKey = [authTokenHeaderKey];
  216. } else if (typeof exposedHeaders === 'string') {
  217. exposedHeadersWithAuthKey = exposedHeaders
  218. .split(',')
  219. .map(x => x.trim())
  220. .concat(authTokenHeaderKey);
  221. } else {
  222. exposedHeadersWithAuthKey = exposedHeaders.concat(authTokenHeaderKey);
  223. }
  224. corsOptions.exposedHeaders = exposedHeadersWithAuthKey;
  225. }
  226. }
  227. }
  228. export async function runBeforeBootstrapHooks(config: Readonly<RuntimeVendureConfig>, app: INestApplication) {
  229. function hasBeforeBootstrapHook(
  230. plugin: any,
  231. ): plugin is { beforeVendureBootstrap: BeforeVendureBootstrap } {
  232. return typeof plugin.beforeVendureBootstrap === 'function';
  233. }
  234. for (const plugin of config.plugins) {
  235. if (hasBeforeBootstrapHook(plugin)) {
  236. await plugin.beforeVendureBootstrap(app);
  237. }
  238. }
  239. }
  240. export async function runBeforeWorkerBootstrapHooks(
  241. config: Readonly<RuntimeVendureConfig>,
  242. worker: INestMicroservice,
  243. ) {
  244. function hasBeforeBootstrapHook(
  245. plugin: any,
  246. ): plugin is { beforeVendureWorkerBootstrap: BeforeVendureWorkerBootstrap } {
  247. return typeof plugin.beforeVendureWorkerBootstrap === 'function';
  248. }
  249. for (const plugin of config.plugins) {
  250. if (hasBeforeBootstrapHook(plugin)) {
  251. await plugin.beforeVendureWorkerBootstrap(worker);
  252. }
  253. }
  254. }
  255. /**
  256. * Monkey-patches the app's .close() method to also close the worker microservice
  257. * instance too.
  258. */
  259. function closeWorkerOnAppClose(app: INestApplication, worker: INestMicroservice) {
  260. // A Nest app is a nested Proxy. By getting the prototype we are
  261. // able to access and override the actual close() method.
  262. const appPrototype = Object.getPrototypeOf(app);
  263. const appClose = appPrototype.close.bind(app);
  264. appPrototype.close = async () => {
  265. return Promise.all([appClose(), worker.close()]);
  266. };
  267. }
  268. function workerWelcomeMessage(config: VendureConfig) {
  269. let transportString = '';
  270. let connectionString = '';
  271. const transport = (config.workerOptions && config.workerOptions.transport) || Transport.TCP;
  272. transportString = ` with ${Transport[transport]} transport`;
  273. const options = (config.workerOptions as TcpClientOptions).options;
  274. if (options) {
  275. const { host, port } = options;
  276. connectionString = ` at ${host || 'localhost'}:${port}`;
  277. }
  278. Logger.info(`Vendure Worker started${transportString}${connectionString}`);
  279. }
  280. function logWelcomeMessage(config: RuntimeVendureConfig) {
  281. let version: string;
  282. try {
  283. version = require('../package.json').version;
  284. } catch (e) {
  285. version = ' unknown';
  286. }
  287. const { port, shopApiPath, adminApiPath } = config.apiOptions;
  288. const apiCliGreetings: Array<[string, string]> = [];
  289. apiCliGreetings.push(['Shop API', `http://localhost:${port}/${shopApiPath}`]);
  290. apiCliGreetings.push(['Admin API', `http://localhost:${port}/${adminApiPath}`]);
  291. apiCliGreetings.push(...getProxyMiddlewareCliGreetings(config));
  292. const columnarGreetings = arrangeCliGreetingsInColumns(apiCliGreetings);
  293. const title = `Vendure server (v${version}) now running on port ${port}`;
  294. const maxLineLength = Math.max(title.length, ...columnarGreetings.map(l => l.length));
  295. const titlePadLength = title.length < maxLineLength ? Math.floor((maxLineLength - title.length) / 2) : 0;
  296. Logger.info(`=`.repeat(maxLineLength));
  297. Logger.info(title.padStart(title.length + titlePadLength));
  298. Logger.info('-'.repeat(maxLineLength).padStart(titlePadLength));
  299. columnarGreetings.forEach(line => Logger.info(line));
  300. Logger.info(`=`.repeat(maxLineLength));
  301. }
  302. function arrangeCliGreetingsInColumns(lines: Array<[string, string]>): string[] {
  303. const columnWidth = Math.max(...lines.map(l => l[0].length)) + 2;
  304. return lines.map(l => `${(l[0] + ':').padEnd(columnWidth)}${l[1]}`);
  305. }
  306. /**
  307. * Fix race condition when modifying DB
  308. * See: https://github.com/vendure-ecommerce/vendure/issues/152
  309. */
  310. function disableSynchronize(userConfig: Readonly<RuntimeVendureConfig>): Readonly<RuntimeVendureConfig> {
  311. const config = { ...userConfig };
  312. config.dbConnectionOptions = {
  313. ...userConfig.dbConnectionOptions,
  314. synchronize: false,
  315. } as ConnectionOptions;
  316. return config;
  317. }
  318. function checkForDeprecatedOptions(config: Partial<VendureConfig>) {
  319. const deprecatedApiOptions = [
  320. 'hostname',
  321. 'port',
  322. 'adminApiPath',
  323. 'shopApiPath',
  324. 'channelTokenKey',
  325. 'cors',
  326. 'middleware',
  327. 'apolloServerPlugins',
  328. ];
  329. const deprecatedOptionsUsed = deprecatedApiOptions.filter(option => config.hasOwnProperty(option));
  330. if (deprecatedOptionsUsed.length) {
  331. throw new Error(
  332. `The following VendureConfig options are deprecated: ${deprecatedOptionsUsed.join(', ')}\n` +
  333. `They have been moved to the "apiOptions" object. Please update your configuration.`,
  334. );
  335. }
  336. }