import { INestApplication, INestApplicationContext } from '@nestjs/common'; import { NestApplicationContextOptions } from '@nestjs/common/interfaces/nest-application-context-options.interface'; import { NestApplicationOptions } from '@nestjs/common/interfaces/nest-application-options.interface'; import { NestFactory } from '@nestjs/core'; import { getConnectionToken } from '@nestjs/typeorm'; import { DEFAULT_COOKIE_NAME } from '@vendure/common/lib/shared-constants'; import { Type } from '@vendure/common/lib/shared-types'; import cookieSession = require('cookie-session'); import { satisfies } from 'semver'; import { Connection, DataSourceOptions, EntitySubscriberInterface } from 'typeorm'; import { InternalServerError } from './common/error/errors'; import { getConfig, setConfig } from './config/config-helpers'; import { DefaultLogger } from './config/logger/default-logger'; import { Logger } from './config/logger/vendure-logger'; import { RuntimeVendureConfig, VendureConfig } from './config/vendure-config'; import { Administrator } from './entity/administrator/administrator.entity'; import { coreEntitiesMap } from './entity/entities'; import { registerCustomEntityFields } from './entity/register-custom-entity-fields'; import { runEntityMetadataModifiers } from './entity/run-entity-metadata-modifiers'; import { setEntityIdStrategy } from './entity/set-entity-id-strategy'; import { setMoneyStrategy } from './entity/set-money-strategy'; import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config'; import { getCompatibility, getConfigurationFunction, getEntitiesFromPlugins } from './plugin/plugin-metadata'; import { getPluginStartupMessages } from './plugin/plugin-utils'; import { setProcessContext } from './process-context/process-context'; import { VENDURE_VERSION } from './version'; import { VendureWorker } from './worker/vendure-worker'; export type VendureBootstrapFunction = (config: VendureConfig) => Promise; /** * @description * Additional options that can be used to configure the bootstrap process of the * Vendure server. * * @since 2.2.0 * @docsCategory common * @docsPage bootstrap */ export interface BootstrapOptions { /** * @description * These options get passed directly to the `NestFactory.create()` method. */ nestApplicationOptions: NestApplicationOptions; } /** * @description * Additional options that can be used to configure the bootstrap process of the * Vendure worker. * * @since 2.2.0 * @docsCategory worker * @docsPage bootstrapWorker */ export interface BootstrapWorkerOptions { /** * @description * These options get passed directly to the `NestFactory.createApplicationContext` method. */ nestApplicationContextOptions: NestApplicationContextOptions; } /** * @description * Bootstraps the Vendure server. This is the entry point to the application. * * @example * ```ts * import { bootstrap } from '\@vendure/core'; * import { config } from './vendure-config'; * * bootstrap(config).catch(err => { * console.log(err); * process.exit(1); * }); * ``` * * ### Passing additional options * * Since v2.2.0, you can pass additional options to the NestJs application via the `options` parameter. * For example, to integrate with the [Nest Devtools](https://docs.nestjs.com/devtools/overview), you need to * pass the `snapshot` option: * * ```ts * import { bootstrap } from '\@vendure/core'; * import { config } from './vendure-config'; * * bootstrap(config, { * // highlight-start * nestApplicationOptions: { * snapshot: true, * } * // highlight-end * }).catch(err => { * console.log(err); * process.exit(1); * }); * ``` * @docsCategory common * @docsPage bootstrap * @docsWeight 0 * */ export async function bootstrap( userConfig: Partial, options?: BootstrapOptions, ): Promise { const config = await preBootstrapConfig(userConfig); Logger.useLogger(config.logger); Logger.info(`Bootstrapping Vendure Server (pid: ${process.pid})...`); checkPluginCompatibility(config); // The AppModule *must* be loaded only after the entities have been set in the // config, so that they are available when the AppModule decorator is evaluated. // eslint-disable-next-line const appModule = await import('./app.module.js'); setProcessContext('server'); const { hostname, port, cors, middleware } = config.apiOptions; DefaultLogger.hideNestBoostrapLogs(); const app = await NestFactory.create(appModule.AppModule, { cors, logger: new Logger(), ...options?.nestApplicationOptions, }); DefaultLogger.restoreOriginalLogLevel(); app.useLogger(new Logger()); const { tokenMethod } = config.authOptions; const usingCookie = tokenMethod === 'cookie' || (Array.isArray(tokenMethod) && tokenMethod.includes('cookie')); if (usingCookie) { configureSessionCookies(app, config); } const earlyMiddlewares = middleware.filter(mid => mid.beforeListen); earlyMiddlewares.forEach(mid => { app.use(mid.route, mid.handler); }); await app.listen(port, hostname || ''); app.enableShutdownHooks(); logWelcomeMessage(config); return app; } /** * @description * Bootstraps a Vendure worker. Resolves to a {@link VendureWorker} object containing a reference to the underlying * NestJs [standalone application](https://docs.nestjs.com/standalone-applications) as well as convenience * methods for starting the job queue and health check server. * * Read more about the [Vendure Worker](/guides/developer-guide/worker-job-queue/). * * @example * ```ts * import { bootstrapWorker } from '\@vendure/core'; * import { config } from './vendure-config'; * * bootstrapWorker(config) * .then(worker => worker.startJobQueue()) * .then(worker => worker.startHealthCheckServer({ port: 3020 })) * .catch(err => { * console.log(err); * process.exit(1); * }); * ``` * @docsCategory worker * @docsPage bootstrapWorker * @docsWeight 0 * */ export async function bootstrapWorker( userConfig: Partial, options?: BootstrapWorkerOptions, ): Promise { const vendureConfig = await preBootstrapConfig(userConfig); const config = disableSynchronize(vendureConfig); config.logger.setDefaultContext?.('Vendure Worker'); Logger.useLogger(config.logger); Logger.info(`Bootstrapping Vendure Worker (pid: ${process.pid})...`); checkPluginCompatibility(config); setProcessContext('worker'); DefaultLogger.hideNestBoostrapLogs(); const WorkerModule = await import('./worker/worker.module.js').then(m => m.WorkerModule); const workerApp = await NestFactory.createApplicationContext(WorkerModule, { logger: new Logger(), ...options?.nestApplicationContextOptions, }); DefaultLogger.restoreOriginalLogLevel(); workerApp.useLogger(new Logger()); workerApp.enableShutdownHooks(); await validateDbTablesForWorker(workerApp); Logger.info('Vendure Worker is ready'); return new VendureWorker(workerApp); } /** * Setting the global config must be done prior to loading the AppModule. */ export async function preBootstrapConfig( userConfig: Partial, ): Promise> { if (userConfig) { await setConfig(userConfig); } const entities = getAllEntities(userConfig); const { coreSubscribersMap } = await import('./entity/subscribers.js'); await setConfig({ dbConnectionOptions: { entities, subscribers: [ ...((userConfig.dbConnectionOptions?.subscribers ?? []) as Array< Type >), ...(Object.values(coreSubscribersMap) as Array>), ], }, }); let config = getConfig(); // The logger is set here so that we are able to log any messages prior to the final // logger (which may depend on config coming from a plugin) being set. Logger.useLogger(config.logger); config = await runPluginConfigurations(config); const entityIdStrategy = config.entityOptions.entityIdStrategy ?? config.entityIdStrategy; setEntityIdStrategy(entityIdStrategy, entities); const moneyStrategy = config.entityOptions.moneyStrategy; setMoneyStrategy(moneyStrategy, entities); const customFieldValidationResult = validateCustomFieldsConfig(config.customFields, entities); if (!customFieldValidationResult.valid) { process.exitCode = 1; throw new Error('CustomFields config error:\n- ' + customFieldValidationResult.errors.join('\n- ')); } registerCustomEntityFields(config); await runEntityMetadataModifiers(config); setExposedHeaders(config); return config; } function checkPluginCompatibility(config: RuntimeVendureConfig): void { for (const plugin of config.plugins) { const compatibility = getCompatibility(plugin); const pluginName = (plugin as any).name as string; if (!compatibility) { Logger.info( `The plugin "${pluginName}" does not specify a compatibility range, so it is not guaranteed to be compatible with this version of Vendure.`, ); } else { if (!satisfies(VENDURE_VERSION, compatibility, { loose: true, includePrerelease: true })) { Logger.error( `Plugin "${pluginName}" is not compatible with this version of Vendure. ` + `It specifies a semver range of "${compatibility}" but the current version is "${VENDURE_VERSION}".`, ); throw new InternalServerError( `Plugin "${pluginName}" is not compatible with this version of Vendure.`, ); } } } } /** * Initialize any configured plugins. */ async function runPluginConfigurations(config: RuntimeVendureConfig): Promise { for (const plugin of config.plugins) { const configFn = getConfigurationFunction(plugin); if (typeof configFn === 'function') { const result = await configFn(config); Object.assign(config, result); } } return config; } /** * Returns an array of core entities and any additional entities defined in plugins. */ export function getAllEntities(userConfig: Partial): Array> { const coreEntities = Object.values(coreEntitiesMap) as Array>; const pluginEntities = getEntitiesFromPlugins(userConfig.plugins); const allEntities: Array> = coreEntities; // Check to ensure that no plugins are defining entities with names // which conflict with existing entities. for (const pluginEntity of pluginEntities) { if (allEntities.find(e => e.name === pluginEntity.name)) { throw new InternalServerError('error.entity-name-conflict', { entityName: pluginEntity.name }); } else { allEntities.push(pluginEntity); } } return allEntities; } /** * If the 'bearer' tokenMethod is being used, then we automatically expose the authTokenHeaderKey header * in the CORS options, making sure to preserve any user-configured exposedHeaders. */ function setExposedHeaders(config: Readonly) { const { tokenMethod } = config.authOptions; const isUsingBearerToken = tokenMethod === 'bearer' || (Array.isArray(tokenMethod) && tokenMethod.includes('bearer')); if (isUsingBearerToken) { const authTokenHeaderKey = config.authOptions.authTokenHeaderKey; const corsOptions = config.apiOptions.cors; if (typeof corsOptions !== 'boolean') { const { exposedHeaders } = corsOptions; let exposedHeadersWithAuthKey: string[]; if (!exposedHeaders) { exposedHeadersWithAuthKey = [authTokenHeaderKey]; } else if (typeof exposedHeaders === 'string') { exposedHeadersWithAuthKey = exposedHeaders .split(',') .map(x => x.trim()) .concat(authTokenHeaderKey); } else { exposedHeadersWithAuthKey = exposedHeaders.concat(authTokenHeaderKey); } corsOptions.exposedHeaders = exposedHeadersWithAuthKey; } } } function logWelcomeMessage(config: RuntimeVendureConfig) { const { port, shopApiPath, adminApiPath, hostname } = config.apiOptions; const apiCliGreetings: Array = []; const pathToUrl = (path: string) => `http://${hostname || 'localhost'}:${port}/${path}`; apiCliGreetings.push(['Shop API', pathToUrl(shopApiPath)]); apiCliGreetings.push(['Admin API', pathToUrl(adminApiPath)]); apiCliGreetings.push( ...getPluginStartupMessages().map(({ label, path }) => [label, pathToUrl(path)] as const), ); const columnarGreetings = arrangeCliGreetingsInColumns(apiCliGreetings); const title = `Vendure server (v${VENDURE_VERSION}) now running on port ${port}`; const maxLineLength = Math.max(title.length, ...columnarGreetings.map(l => l.length)); const titlePadLength = title.length < maxLineLength ? Math.floor((maxLineLength - title.length) / 2) : 0; Logger.info('='.repeat(maxLineLength)); Logger.info(title.padStart(title.length + titlePadLength)); Logger.info('-'.repeat(maxLineLength).padStart(titlePadLength)); columnarGreetings.forEach(line => Logger.info(line)); Logger.info('='.repeat(maxLineLength)); } function arrangeCliGreetingsInColumns(lines: Array): string[] { const columnWidth = Math.max(...lines.map(l => l[0].length)) + 2; return lines.map(l => `${(l[0] + ':').padEnd(columnWidth)}${l[1]}`); } /** * Fix race condition when modifying DB * See: https://github.com/vendure-ecommerce/vendure/issues/152 */ function disableSynchronize(userConfig: Readonly): Readonly { const config = { ...userConfig, dbConnectionOptions: { ...userConfig.dbConnectionOptions, synchronize: false, } as DataSourceOptions, }; return config; } /** * Check that the Database tables exist. When running Vendure server & worker * concurrently for the first time, the worker will attempt to access the * DB tables before the server has populated them (assuming synchronize = true * in config). This method will use polling to check the existence of a known table * before allowing the rest of the worker bootstrap to continue. * @param worker */ async function validateDbTablesForWorker(worker: INestApplicationContext) { const connection: Connection = worker.get(getConnectionToken()); await new Promise(async (resolve, reject) => { const checkForTables = async (): Promise => { try { const adminCount = await connection.getRepository(Administrator).count(); return 0 < adminCount; } catch (e: any) { return false; } }; const pollIntervalMs = 5000; let attempts = 0; const maxAttempts = 10; let validTableStructure = false; Logger.verbose('Checking for expected DB table structure...'); while (!validTableStructure && attempts < maxAttempts) { attempts++; validTableStructure = await checkForTables(); if (validTableStructure) { Logger.verbose('Table structure verified'); resolve(); return; } Logger.verbose( `Table structure could not be verified, trying again after ${pollIntervalMs}ms (attempt ${attempts} of ${maxAttempts})`, ); await new Promise(resolve1 => setTimeout(resolve1, pollIntervalMs)); } reject('Could not validate DB table structure. Aborting bootstrap.'); }); } export function configureSessionCookies( app: INestApplication, userConfig: Readonly, ): void { const { cookieOptions } = userConfig.authOptions; // Globally set the cookie session middleware const cookieName = typeof cookieOptions?.name !== 'string' ? cookieOptions.name?.shop : cookieOptions.name; app.use( cookieSession({ ...cookieOptions, name: cookieName ?? DEFAULT_COOKIE_NAME, }), ); }