mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* feat: encrypt tenant Langfuse secret in admin config Add generic per-field secret encryption to the admin config layer: registered secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a non-secret fingerprint companion is stored. Admin config reads (base + per principal) redact registered secrets so they are never returned; the fingerprint is kept so the UI can show which key is configured. The Langfuse fanout read path decrypts the tenant secret before export. Adds secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact policy. * fix(api): secure admin config secret handling * fix(api): preserve encrypted langfuse config secrets * fix(api): couple config secret fingerprint deletion * fix(api): read langfuse fanout collector url from env * fix(api): display langfuse secret key hint * fix(api): remove langfuse secret fingerprint breadcrumbs * fix(api): use langfuse destination keys for tenant config * fix(api): remove langfuse config compatibility fallbacks * refactor(api): simplify langfuse secret helpers * refactor(api): simplify langfuse config secret handling * feat: in-app Langfuse connection settings panel Add a discoverable, admin-gated Langfuse connection panel inside LibreChat Settings (Dify-style): enable toggle, host, public key, masked write-only secret, configured-key fingerprint, and a test-connection action. Backed by a dedicated /api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns metadata plus fingerprint on read, and validates credentials. Builds on the per-field encryption and fanout decrypt from the langfuse-config-encryption branch. * refactor: align Langfuse secret field to CustomUserVars pattern Use the established SecretInput plus Set/Unset state pill (com_ui_set/com_ui_unset) from the MCP CustomUserVars UI for the saved-secret state, instead of a bespoke masked input. * fix: drop em dash from saved-secret placeholder * feat: show loading state on Langfuse test connection button * feat: gate in-app Langfuse settings on fanout config and admin role * test: align Langfuse connection spec with SecretInput refactor * feat(langfuse): refine tenant connection controls * fix(admin): refine Langfuse connection verification * fix(langfuse): refine tenant connection settings * fix(langfuse): simplify export enablement controls * fix(langfuse): validate tenant export configuration * fix(langfuse): align startup fanout gate * fix(admin): time out Langfuse verification * fix(ui): rename Langfuse connection setting * fix(admin): enforce Langfuse config capability * feat(langfuse): require explicit tenant export activation * feat(langfuse): support single-tenant connection settings * fix(i18n): remove obsolete integrations label * fix(langfuse): authenticate ingestion verification * fix(langfuse): validate public key independently * fix(langfuse): localize connection errors * perf(config): skip Langfuse checks for non-admins * fix(langfuse): preserve trace sampling for feedback * test(langfuse): fix feedback sampling fixture * fix(langfuse): align secret preview field * fix(langfuse): harden connection settings state * fix(langfuse): preserve trace destination state * fix(langfuse): enforce tenant-wide routing invariants * fix(langfuse): preserve verified connection invariants * fix(langfuse): preserve stable project identity * fix(langfuse): warm project identity asynchronously --------- Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com> Co-authored-by: Danny Avila <danny@librechat.ai>
476 lines
17 KiB
JavaScript
476 lines
17 KiB
JavaScript
const telemetry = require('./telemetry');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
|
const cors = require('cors');
|
|
const axios = require('axios');
|
|
const express = require('express');
|
|
const passport = require('passport');
|
|
const compression = require('compression');
|
|
const cookieParser = require('cookie-parser');
|
|
const mongoSanitize = require('express-mongo-sanitize');
|
|
const { logger, runAsSystem } = require('@librechat/data-schemas');
|
|
const {
|
|
isEnabled,
|
|
apiNotFound,
|
|
createMetrics,
|
|
ErrorController,
|
|
memoryDiagnostics,
|
|
performStartupChecks,
|
|
handleJsonParseError,
|
|
GenerationJobManager,
|
|
QUERY_DEVTOOLS_HEADER,
|
|
createStreamServices,
|
|
agentStartupIngressMiddleware,
|
|
agentStartupTelemetryMiddleware,
|
|
initializeFileStorage,
|
|
initializeDeploymentSkills,
|
|
loadToolApprovalHooks,
|
|
maybeInjectQueryDevtoolsBootstrap,
|
|
preAuthTenantMiddleware,
|
|
registerShutdownTask,
|
|
configureServerTimeouts,
|
|
setupGracefulShutdown,
|
|
updateInterfacePermissions,
|
|
} = require('@librechat/api');
|
|
const { connectDb, indexSync } = require('~/db');
|
|
const {
|
|
updateAccessPermissions,
|
|
sweepOrphanedPreviews,
|
|
getRoleByName,
|
|
seedDatabase,
|
|
} = require('~/models');
|
|
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
|
|
const { capabilityContextMiddleware } = require('./middleware/roles/capabilities');
|
|
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
|
const { startExpiredFileSweep } = require('./services/Files/process');
|
|
const { initializeGitHubSkillSync } = require('./services/Skills/sync');
|
|
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
|
const { checkMigrations } = require('./services/start/migration');
|
|
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
|
|
const initializeMCPs = require('./services/initializeMCPs');
|
|
const configureSocialLogins = require('./socialLogins');
|
|
const createSpaFallback = require('./utils/fallback');
|
|
const { getAppConfig } = require('./services/Config');
|
|
const staticCache = require('./utils/staticCache');
|
|
const noIndex = require('./middleware/noIndex');
|
|
const routes = require('./routes');
|
|
|
|
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
|
|
|
// Allow PORT=0 to be used for automatic free port assignment
|
|
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
|
const host = HOST || 'localhost';
|
|
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
|
|
|
const app = express();
|
|
let serverReady = false;
|
|
|
|
const SERVER_NOT_READY_CODE = 'SERVER_NOT_READY';
|
|
const CHAT_START_RETRY_AFTER_SECONDS = '1';
|
|
|
|
const rejectChatStartsUntilReady = (req, res, next) => {
|
|
if (serverReady || req.method !== 'POST' || req.path === '/abort') {
|
|
return next();
|
|
}
|
|
|
|
res.set('Retry-After', CHAT_START_RETRY_AFTER_SECONDS);
|
|
return res.status(503).json({
|
|
code: SERVER_NOT_READY_CODE,
|
|
error: 'Server is still starting. Please retry shortly.',
|
|
});
|
|
};
|
|
|
|
const configureGenerationStreams = () => {
|
|
const streamServices = createStreamServices();
|
|
GenerationJobManager.configure({
|
|
...streamServices,
|
|
cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS),
|
|
});
|
|
GenerationJobManager.initialize();
|
|
// Stop active generations and close their SSE streams while the HTTP server drains.
|
|
registerShutdownTask(
|
|
'generation job manager prepare',
|
|
() => GenerationJobManager.prepareForShutdown(),
|
|
{
|
|
phase: 'pre-drain',
|
|
priority: 100,
|
|
},
|
|
);
|
|
// Tear down stream resources before shared caches and telemetry exporters shut down.
|
|
registerShutdownTask('generation job manager', () => GenerationJobManager.destroy(), {
|
|
priority: 100,
|
|
});
|
|
};
|
|
|
|
const startServer = async () => {
|
|
const { metricsMiddleware, metricsRouter } = createMetrics();
|
|
if (!process.env.METRICS_SECRET) {
|
|
logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests');
|
|
}
|
|
|
|
if (typeof Bun !== 'undefined') {
|
|
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
|
}
|
|
await connectDb();
|
|
|
|
logger.info('Connected to MongoDB');
|
|
indexSync().catch((err) => {
|
|
logger.error('[indexSync] Background sync failed:', err);
|
|
});
|
|
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', trusted_proxy);
|
|
|
|
if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
|
|
logger.warn(
|
|
'[Security] TENANT_ISOLATION_STRICT is active. Ensure your reverse proxy strips or sets ' +
|
|
'the X-Tenant-Id header — untrusted clients must not be able to set it directly.',
|
|
);
|
|
}
|
|
|
|
await runAsSystem(seedDatabase);
|
|
/* Recover stuck `status: 'pending'` records from a crash mid-render.
|
|
* `runAsSystem` is required — `File` is tenant-isolated and strict
|
|
* mode rejects unscoped queries. Lazy sweep in the preview endpoint
|
|
* covers anything younger than the boot cutoff. */
|
|
runAsSystem(sweepOrphanedPreviews).catch((err) => {
|
|
logger.error('[sweepOrphanedPreviews] Background sweep failed:', err);
|
|
});
|
|
const appConfig = await getAppConfig({ baseOnly: true });
|
|
initializeFileStorage(appConfig);
|
|
await initializeDeploymentSkills({ projectRoot: path.resolve(__dirname, '../..') });
|
|
initializeGitHubSkillSync(appConfig);
|
|
startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig });
|
|
// Register any programmatic tool-approval policy hooks declared in
|
|
// `endpoints.agents.toolApproval.hooks`. Honor the `enabled` kill switch: when tool
|
|
// approval is off we pass no hooks, so a disabled endpoint imports/runs nothing (and any
|
|
// previously loaded batch is unregistered). Hooks are read from the BASE config only —
|
|
// they register once, process-wide; per-user/tenant differences belong inside the hook
|
|
// (via its context), not in per-override module lists.
|
|
const toolApproval = appConfig?.endpoints?.agents?.toolApproval;
|
|
await loadToolApprovalHooks(toolApproval?.enabled ? toolApproval.hooks : undefined, {
|
|
basePath: path.resolve(__dirname, '../..'),
|
|
});
|
|
await runAsSystem(async () => {
|
|
await performStartupChecks(appConfig);
|
|
await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions });
|
|
});
|
|
|
|
const indexPath = path.join(appConfig.paths.dist, 'index.html');
|
|
let indexHTML = fs.readFileSync(indexPath, 'utf8');
|
|
|
|
// In order to provide support to serving the application in a sub-directory
|
|
// We need to update the base href if the DOMAIN_CLIENT is specified and not the root path
|
|
if (process.env.DOMAIN_CLIENT) {
|
|
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
|
|
const baseHref = clientUrl.pathname.endsWith('/')
|
|
? clientUrl.pathname
|
|
: `${clientUrl.pathname}/`;
|
|
if (baseHref !== '/') {
|
|
logger.info(`Setting base href to ${baseHref}`);
|
|
indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
|
|
}
|
|
}
|
|
|
|
const sendIndexHtml = (req, res) => {
|
|
res.set({
|
|
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
|
|
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
|
|
Expires: process.env.INDEX_EXPIRES || '0',
|
|
});
|
|
res.vary(QUERY_DEVTOOLS_HEADER);
|
|
|
|
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
|
|
const saneLang = lang.replace(/"/g, '"');
|
|
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
|
|
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
|
|
|
|
res.type('html');
|
|
res.send(updatedIndexHtml);
|
|
};
|
|
|
|
app.get('/health', (_req, res) => res.status(200).send('OK'));
|
|
app.get('/livez', (_req, res) => res.status(200).send('OK'));
|
|
app.get('/readyz', (_req, res) => {
|
|
if (!serverReady) {
|
|
return res.status(503).send('NOT_READY');
|
|
}
|
|
return res.status(200).send('OK');
|
|
});
|
|
|
|
/* Middleware */
|
|
app.use('/api/agents/chat', agentStartupIngressMiddleware);
|
|
app.use(metricsMiddleware);
|
|
app.use(noIndex);
|
|
app.use(express.json({ limit: '3mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
|
app.use(handleJsonParseError);
|
|
|
|
/**
|
|
* Express 5 Compatibility: Make req.query writable for mongoSanitize
|
|
* In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
|
|
*/
|
|
app.use((req, _res, next) => {
|
|
Object.defineProperty(req, 'query', {
|
|
...Object.getOwnPropertyDescriptor(req, 'query'),
|
|
value: req.query,
|
|
writable: true,
|
|
});
|
|
next();
|
|
});
|
|
|
|
app.use(mongoSanitize());
|
|
app.use(cors());
|
|
app.use(cookieParser());
|
|
|
|
if (!isEnabled(DISABLE_COMPRESSION)) {
|
|
app.use(compression());
|
|
} else {
|
|
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
|
}
|
|
|
|
app.get('/index.html', sendIndexHtml);
|
|
app.use(staticCache(appConfig.paths.dist));
|
|
app.use(staticCache(appConfig.paths.fonts));
|
|
app.use(staticCache(appConfig.paths.assets));
|
|
|
|
if (telemetry.enabled) {
|
|
app.use(telemetry.telemetryMiddleware);
|
|
}
|
|
app.use('/api/agents/chat', agentStartupTelemetryMiddleware);
|
|
|
|
if (!ALLOW_SOCIAL_LOGIN) {
|
|
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
|
|
}
|
|
|
|
/* OAUTH */
|
|
app.use(passport.initialize());
|
|
passport.use(jwtLogin());
|
|
passport.use(passportLogin());
|
|
|
|
/* LDAP Auth */
|
|
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
|
|
passport.use(ldapLogin);
|
|
}
|
|
|
|
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
|
await configureSocialLogins(app);
|
|
}
|
|
|
|
/* Per-request capability cache — must be registered before any route that calls hasCapability */
|
|
app.use(capabilityContextMiddleware);
|
|
|
|
/* Pre-auth tenant context for unauthenticated routes that need tenant scoping.
|
|
* The reverse proxy / auth gateway sets `X-Tenant-Id` header for multi-tenant deployments. */
|
|
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
|
|
/* API Endpoints */
|
|
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
|
|
app.use('/api/admin', routes.adminAuth);
|
|
app.use('/api/admin/config', routes.adminConfig);
|
|
app.use('/api/admin/langfuse', routes.adminLangfuse);
|
|
app.use('/api/admin/grants', routes.adminGrants);
|
|
app.use('/api/admin/groups', routes.adminGroups);
|
|
app.use('/api/admin/roles', routes.adminRoles);
|
|
app.use('/api/admin/skills', routes.adminSkills);
|
|
app.use('/api/admin/users', routes.adminUsers);
|
|
app.use('/api/admin/audit-log', routes.adminAuditLog);
|
|
app.use('/api/actions', routes.actions);
|
|
app.use('/api/keys', routes.keys);
|
|
app.use('/api/api-keys', routes.apiKeys);
|
|
app.use('/api/user', routes.user);
|
|
app.use('/api/search', routes.search);
|
|
app.use('/api/messages', routes.messages);
|
|
app.use('/api/convos', routes.convos);
|
|
app.use('/api/presets', routes.presets);
|
|
app.use('/api/projects', routes.projects);
|
|
app.use('/api/prompts', routes.prompts);
|
|
app.use('/api/skills', routes.skills);
|
|
app.use('/api/categories', routes.categories);
|
|
app.use('/api/endpoints', routes.endpoints);
|
|
app.use('/api/balance', routes.balance);
|
|
app.use('/api/models', routes.models);
|
|
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
|
|
app.use('/api/assistants', routes.assistants);
|
|
app.use('/api/files', await routes.files.initialize());
|
|
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
|
app.use('/api/share', preAuthTenantMiddleware, routes.share);
|
|
app.use('/api/roles', routes.roles);
|
|
app.use('/api/agents/chat', rejectChatStartsUntilReady);
|
|
app.use('/api/agents', routes.agents);
|
|
app.use('/api/banner', routes.banner);
|
|
app.use('/api/memories', routes.memories);
|
|
app.use('/api/permissions', routes.accessPermissions);
|
|
|
|
app.use('/api/tags', routes.tags);
|
|
app.use('/api/mcp', routes.mcp);
|
|
app.use('/api/rum', routes.rum);
|
|
|
|
app.use('/metrics', metricsRouter);
|
|
|
|
/** 404 for unmatched API routes */
|
|
app.use('/api', apiNotFound);
|
|
|
|
/** SPA fallback - serve index.html for all unmatched routes */
|
|
app.use(createSpaFallback(sendIndexHtml));
|
|
|
|
/** Record trace errors before the final error controller. */
|
|
if (telemetry.enabled) {
|
|
app.use(telemetry.telemetryErrorMiddleware);
|
|
}
|
|
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
|
|
app.use(ErrorController);
|
|
|
|
configureGenerationStreams();
|
|
|
|
const server = app.listen(port, host, async (err) => {
|
|
if (err) {
|
|
logger.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (host === '0.0.0.0') {
|
|
logger.info(
|
|
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
|
);
|
|
} else {
|
|
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
|
}
|
|
|
|
/**
|
|
* The listen callback is async, so any rejection from these awaits would
|
|
* otherwise be detached from `startServer().catch(...)` (which only
|
|
* catches errors that happen before `app.listen`). Without explicit
|
|
* handling, the global `unhandledRejection` handler would swallow init
|
|
* failures and leave the server listening but only partially
|
|
* initialized — passing liveness checks while serving broken requests.
|
|
*/
|
|
try {
|
|
await runAsSystem(async () => {
|
|
await initializeMCPs();
|
|
await initializeOAuthReconnectManager();
|
|
});
|
|
await checkMigrations();
|
|
|
|
const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect'));
|
|
if (inspectFlags || isEnabled(process.env.MEM_DIAG)) {
|
|
memoryDiagnostics.start();
|
|
}
|
|
serverReady = true;
|
|
logger.info('Server readiness checks passing.');
|
|
} catch (initErr) {
|
|
serverReady = false;
|
|
logger.error('Post-listen initialization failed:', initErr);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
configureServerTimeouts(server);
|
|
logger.info('HTTP server timeout configuration', {
|
|
keepAliveTimeout: server.keepAliveTimeout,
|
|
keepAliveTimeoutBuffer: server.keepAliveTimeoutBuffer,
|
|
headersTimeout: server.headersTimeout,
|
|
requestTimeout: server.requestTimeout,
|
|
});
|
|
|
|
setupGracefulShutdown(server);
|
|
};
|
|
|
|
/**
|
|
* Boot rejections (e.g. `connectDb`, `getAppConfig`, `performStartupChecks`)
|
|
* must remain fail-fast: a half-initialized process with no listening HTTP
|
|
* server should die immediately so the orchestrator restarts it, instead of
|
|
* being kept alive by the `unhandledRejection` handler below until the
|
|
* liveness probe eventually times out. Mirrors the pattern in
|
|
* `experimental.js`.
|
|
*/
|
|
startServer().catch((err) => {
|
|
logger.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
let messageCount = 0;
|
|
process.on('uncaughtException', (err) => {
|
|
if (!err.message.includes('fetch failed')) {
|
|
logger.error('There was an uncaught error:', err);
|
|
}
|
|
|
|
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
|
|
logger.warn('There was an uncatchable abort error.');
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('GoogleGenerativeAI')) {
|
|
logger.warn(
|
|
'\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('fetch failed')) {
|
|
if (messageCount === 0) {
|
|
logger.warn('Meilisearch error, search will be disabled');
|
|
messageCount++;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
|
|
logger.error(
|
|
'\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (err.stack && err.stack.includes('@librechat/agents')) {
|
|
logger.error(
|
|
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
|
|
{
|
|
message: err.message,
|
|
stack: err.stack,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isEnabled(process.env.CONTINUE_ON_UNCAUGHT_EXCEPTION)) {
|
|
logger.error('Unhandled error encountered. The app will continue running.', {
|
|
name: err?.name,
|
|
message: err?.message,
|
|
stack: err?.stack,
|
|
});
|
|
return;
|
|
}
|
|
|
|
process.exit(1);
|
|
});
|
|
|
|
/**
|
|
* Unhandled promise rejection handler.
|
|
*
|
|
* Node 15+ terminates the process by default when a promise rejection is
|
|
* unhandled. MCP OAuth reconnect storms and streamable-HTTP transport resets
|
|
* can produce transient fire-and-forget rejections (ECONNRESET, token refresh
|
|
* races) that are recoverable — the server should log and keep serving other
|
|
* requests rather than silently crash under load.
|
|
*
|
|
* Non-Error reasons are forwarded as-is so structured payloads (e.g.
|
|
* `{ code: "ECONNRESET", errno: -104 }`) survive instead of being collapsed to
|
|
* "[object Object]" by `String()`.
|
|
*/
|
|
process.on('unhandledRejection', (reason) => {
|
|
if (reason instanceof Error) {
|
|
logger.error('Unhandled promise rejection. The app will continue running.', {
|
|
name: reason.name,
|
|
message: reason.message,
|
|
stack: reason.stack,
|
|
cause: reason.cause,
|
|
});
|
|
return;
|
|
}
|
|
logger.error('Unhandled promise rejection. The app will continue running.', { reason });
|
|
});
|
|
|
|
/** Export app for easier testing purposes */
|
|
module.exports = app;
|