mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-10 10:05:18 +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
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* feat: Add private chat projects
* fix: Format project files
* fix: Address project review findings
* fix: Resolve project review follow-ups
* fix: Handle project stats and cache edge cases
* style: align projects UI with sidebar patterns
* fix: resolve projects UI lint issues
* style: Align project menus and composer
* fix: Avoid project placeholder shadowing
* fix: Handle project search and stale ids
* fix: Polish project sidebar behavior
* fix: Preserve new chat stream after creation
* fix: Stabilize project sidebar sections
* fix: Smooth project sidebar organization
* fix: stabilize project chat entry
* fix: keep project workspace outside chat context
* fix: show default model on project workspace
* fix: fallback project workspace model label
* fix: preserve project scope during draft hydration
* fix: include route project in new chat submission
* fix: persist project id in agent chat saves
* fix: refine project sidebar and creation UX
* fix: export chat project method types
* fix: polish project landing context
* fix: refine project navigation affordances
* feat: rework projects UX — coexisting sidebar sections + URL-driven scope
Sidebar
- Replace the chronological/by-project mode toggle with coexisting
Projects + Chats sections (both always visible)
- Remove ProjectConversations (927 lines), the org-mode Header, and types
- Add ProjectsSection: collapsible project rows that unfurl chats inline
(full-size rows), with per-project new chat and an open/rename/delete menu
- Lift the marketplace/favorites shortcuts above the Projects section
Chat scope
- Derive a new chat's project strictly from the URL ?projectId, so the
global New Chat no longer stays stuck in a project after a project chat
Surfaces
- Chat landing: subtle, clickable project chip instead of the floating badge
- Project workspace: modest header, composer-style entry, chats list
- All-projects grid: Claude-style cards with pluralized chat counts
* chore: prune unused i18n keys; fix project chat-count pluralization
* fix: project new-chat keeps model spec; sidebar header + row polish
- newConversation: ignore a chatProjectId-only template when deciding to
apply the default model spec, so starting a chat in a project no longer
strips the conversation `spec`
- useSelectMention: the Model Selector and @ command now retain the active
project across endpoint/spec/preset switches; other new-chat paths still
clear it
- Chats header now matches the Projects header (inline chevron + a new-chat
icon button) and starts a non-project chat
- Project rows: use the new-chat icon for the per-project add button, render
at text-sm to match the chat list, and align the row actions + hover color
with conversation rows
* fix: read project scope from router params; align sidebar header icons
- useSelectMention now reads the active project from React Router's search
params instead of window.location, which can drift out of sync because
new-chat params are written to the URL via raw history.pushState; the
Model Selector and @ command now reliably keep the project on switch
- Move the Chats section header out of the virtualized list so it renders
in the same context as the Projects header and isn't shifted by the
list scrollbar
- Inset header action icons (pr-2) so Projects/Chats header icons line up
with the project-row and conversation-row trailing actions
- Extract getRouteChatProjectId into utils for the submit path
* fix: preserve chatProjectId through the new-chat template reduction
The param-endpoint guard in newConversation reduced a new chat's template to
{ endpoint } only, dropping the chatProjectId injected by the Model Selector /
@ switch — so switching models cleared the project scope. Keep chatProjectId
in the reduced template.
* style: align chat-history panel top padding; improve projects page contrast
- Add pt-2 to the chat-history panel so its top spacing matches the other
side panels (agent builder, skills, files, etc.)
- Projects grid + workspace now use the darkest surface for the page
(surface-primary) with cards, inputs, and the composer one step lighter
(surface-secondary) and tertiary on hover, so cards read as elevated
rather than darker than the background
* feat: interactive project landing chip + gallery icon for all-projects
- All-projects sidebar button uses the gallery-vertical-end icon
- The project landing chip is now interactive: click it to switch projects
via a searchable combobox (ControlCombobox), or the trailing × to drop the
project scope. Both update the draft conversation and the ?projectId search
param in place, so the typed message and selected model are preserved
* test: fix Conversations unit test for refactored sidebar; add projects e2e
- Update Conversations.test.tsx mocks for the inline Chats header
(useNewConvo, useQueryClient, conversation atom, NewChatIcon, TooltipAnchor),
drop the removed chatsHeaderControls prop, and remove the mock for the
deleted ../Header module — fixes the failing frontend Jest job
- Add e2e/specs/mock/projects.spec.ts covering project creation, the
project-scoped new-chat landing + interactive chip (switch/remove), and
listing projects on /projects
- Give the landing chip combobox a stable selectId for reliable targeting
* fix: refresh project stats after project-chat activity; stabilize e2e
- useEventHandlers: when a project chat is created/updated, invalidate the
live [projects] query (gated on chatProjectId) instead of the now-unused
projectConversations key, so the sidebar + all-projects stats refresh
after a streamed reply (addresses a Codex finding)
- projects e2e: assert the reliable project-landing behavior (chip, scoped
composer, accepted send) rather than the /c/:id transition, which the
mock LLM harness doesn't complete
* test: verify a project chat saves and is filed under its project (e2e)
- Switch to a mock endpoint before sending so the message streams without a
real API key (the default model failed with "No key found", so no chat was
saved and the page never left /c/new); this also asserts the project chip
survives the model switch
- Restore the reply + /c/:id transition assertions and add a check that the
chat is listed under the expanded project in the sidebar
- Add data-testid="project-chats-<id>" to the inline project chat list
* fix: address Codex review findings (project scope edge cases)
- useSelectMention: fall back to the conversation's chatProjectId when the
URL has no projectId, so switching model/spec inside an existing project
chat (/c/:id) keeps the project assignment
- Conversations: include chatProjectId in the MemoizedConvo comparator so a
sidebar row's project menu doesn't stay stale after a reassignment
- useDeleteProjectMutation: clear the active conversation's chatProjectId
when its project is deleted (mirrors the assignment mutation); drop the
now-dead projectConversations invalidation
- useQueryParams: carry the project into the new conversation when applying
URL settings, so /c/new?projectId=...&<settings> stays scoped
* fix: project stats pagination + archived-chat edge cases (data-schemas)
- listChatProjects: include the null lastConversationAt bucket in the desc
cursor so empty projects paginate (a $lt:<date> predicate excluded nulls,
hiding chat-less projects from "Load more")
- saveConvo: recompute project stats instead of the incremental fast path
when the saved conversation is itself archived/temporary/expired, so a
project's lastConversationAt/Id no longer points at a hidden chat
* test: cover chat-less project pagination across the dated→null boundary
* fix: validate project ownership in bulkSaveConvos
Bulk paths (import/duplicate/fork) persisted whatever chatProjectId the
payload carried; an id that does not belong to the user created an orphan
assignment hidden from both the project and the unassigned sidebar. Validate
ownership like saveConvo and strip un-owned project ids before persisting,
refreshing stats only for owned projects.
* fix(projects): preserve chatProjectId on continuation, basename-safe delete redirect, project-detail invalidation
* fix(projects): navigate project workspace chats via useNavigateToConvo to avoid stale conversation state
* fix(projects): include projectConversations cache when resolving deleted chat's project for detail invalidation
* fix(projects): refresh both projects when a save or bulk write moves a chat between them
* style(projects): use Folders icon for the sidebar Projects header
* fix(projects): require id on ProjectUser so ProjectRequest extends Express Request cleanly
* style(projects): taller project chip with hover-revealed remove button, upward combobox; sort en translations
* style(projects): show endpoint/agent icon for project workspace chat rows
543 lines
18 KiB
JavaScript
543 lines
18 KiB
JavaScript
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
|
const cluster = require('cluster');
|
|
const Redis = require('ioredis');
|
|
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 { logger, runAsSystem } = require('@librechat/data-schemas');
|
|
const mongoSanitize = require('express-mongo-sanitize');
|
|
const {
|
|
isEnabled,
|
|
apiNotFound,
|
|
ErrorController,
|
|
performStartupChecks,
|
|
handleJsonParseError,
|
|
initializeFileStorage,
|
|
preAuthTenantMiddleware,
|
|
} = require('@librechat/api');
|
|
const { connectDb, indexSync } = require('~/db');
|
|
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
|
|
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
|
const { startExpiredFileSweep } = require('./services/Files/process');
|
|
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
|
const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api');
|
|
const {
|
|
getRoleByName,
|
|
updateAccessPermissions,
|
|
seedDatabase,
|
|
sweepOrphanedPreviews,
|
|
} = require('~/models');
|
|
const { checkMigrations } = require('./services/start/migration');
|
|
const initializeMCPs = require('./services/initializeMCPs');
|
|
const configureSocialLogins = require('./socialLogins');
|
|
const { getAppConfig } = require('./services/Config');
|
|
const staticCache = require('./utils/staticCache');
|
|
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
|
|
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;
|
|
|
|
/** Number of worker processes to spawn (simulating multiple pods) */
|
|
const workers = Number(process.env.CLUSTER_WORKERS) || 4;
|
|
|
|
/** Helper to wrap log messages for better visibility */
|
|
const wrapLogMessage = (msg) => {
|
|
return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`;
|
|
};
|
|
|
|
/**
|
|
* Flushes the Redis cache on startup
|
|
* This ensures a clean state for testing multi-pod MCP connection issues
|
|
*/
|
|
const flushRedisCache = async () => {
|
|
/** Skip cache flush if Redis is not enabled */
|
|
if (!isEnabled(process.env.USE_REDIS)) {
|
|
logger.info('Redis is not enabled, skipping cache flush');
|
|
return;
|
|
}
|
|
|
|
const redisConfig = {
|
|
host: process.env.REDIS_HOST || 'localhost',
|
|
port: process.env.REDIS_PORT || 6379,
|
|
};
|
|
|
|
if (process.env.REDIS_PASSWORD) {
|
|
redisConfig.password = process.env.REDIS_PASSWORD;
|
|
}
|
|
|
|
/** Handle Redis Cluster configuration */
|
|
if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) {
|
|
logger.info('Detected Redis Cluster configuration');
|
|
const uris = process.env.REDIS_URI?.split(',').map((uri) => {
|
|
const url = new URL(uri.trim());
|
|
return {
|
|
host: url.hostname,
|
|
port: parseInt(url.port || '6379', 10),
|
|
};
|
|
});
|
|
const redis = new Redis.Cluster(uris, {
|
|
redisOptions: {
|
|
password: process.env.REDIS_PASSWORD,
|
|
},
|
|
});
|
|
|
|
try {
|
|
logger.info('Attempting to connect to Redis Cluster...');
|
|
await redis.ping();
|
|
logger.info('Connected to Redis Cluster. Executing flushall...');
|
|
const result = await Promise.race([
|
|
redis.flushall(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)),
|
|
]);
|
|
logger.info('Redis Cluster cache flushed successfully', { result });
|
|
} catch (err) {
|
|
logger.error('Error while flushing Redis Cluster cache:', err);
|
|
throw err;
|
|
} finally {
|
|
redis.disconnect();
|
|
}
|
|
return;
|
|
}
|
|
|
|
/** Handle single Redis instance */
|
|
const redis = new Redis(redisConfig);
|
|
|
|
try {
|
|
logger.info('Attempting to connect to Redis...');
|
|
await redis.ping();
|
|
logger.info('Connected to Redis. Executing flushall...');
|
|
const result = await Promise.race([
|
|
redis.flushall(),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)),
|
|
]);
|
|
logger.info('Redis cache flushed successfully', { result });
|
|
} catch (err) {
|
|
logger.error('Error while flushing Redis cache:', err);
|
|
throw err;
|
|
} finally {
|
|
redis.disconnect();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Master process
|
|
* Manages worker processes and handles graceful shutdowns
|
|
*/
|
|
if (cluster.isMaster) {
|
|
logger.info(wrapLogMessage(`Master ${process.pid} is starting...`));
|
|
logger.info(`Spawning ${workers} workers to simulate multi-pod environment`);
|
|
|
|
let activeWorkers = 0;
|
|
const listeningWorkers = new Set();
|
|
let retentionSweepWorkerId = null;
|
|
const startTime = Date.now();
|
|
|
|
const assignRetentionSweepWorker = () => {
|
|
if (retentionSweepWorkerId && cluster.workers[retentionSweepWorkerId]) {
|
|
return;
|
|
}
|
|
|
|
const connectedWorkers = Object.values(cluster.workers).filter(
|
|
(worker) => worker && worker.isConnected(),
|
|
);
|
|
const availableWorkers = connectedWorkers.filter((worker) => listeningWorkers.has(worker.id));
|
|
const workerPool = availableWorkers.length > 0 ? availableWorkers : connectedWorkers;
|
|
const retentionSweepWorker = workerPool[workerPool.length - 1];
|
|
if (!retentionSweepWorker) {
|
|
return;
|
|
}
|
|
|
|
retentionSweepWorkerId = retentionSweepWorker.id;
|
|
logger.info(
|
|
wrapLogMessage(`Worker ${retentionSweepWorker.process.pid} assigned to file-retention sweep`),
|
|
);
|
|
retentionSweepWorker.send({ type: 'file-retention-sweep-worker' });
|
|
};
|
|
|
|
/** Flush Redis cache before starting workers */
|
|
flushRedisCache()
|
|
.then(() => {
|
|
logger.info('Cache flushed, forking workers...');
|
|
for (let i = 0; i < workers; i++) {
|
|
cluster.fork();
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
logger.error('Unable to flush Redis cache, not forking workers:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
/** Track worker lifecycle */
|
|
cluster.on('online', (worker) => {
|
|
activeWorkers++;
|
|
const uptime = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
logger.info(
|
|
`Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`,
|
|
);
|
|
|
|
/** Assign one worker for process-wide background jobs */
|
|
if (activeWorkers === workers) {
|
|
logger.info(wrapLogMessage(`All ${workers} workers are online`));
|
|
}
|
|
});
|
|
|
|
cluster.on('listening', (worker) => {
|
|
listeningWorkers.add(worker.id);
|
|
if (
|
|
listeningWorkers.size === workers ||
|
|
(!retentionSweepWorkerId && activeWorkers >= workers)
|
|
) {
|
|
assignRetentionSweepWorker();
|
|
}
|
|
});
|
|
|
|
cluster.on('exit', (worker, code, signal) => {
|
|
activeWorkers--;
|
|
listeningWorkers.delete(worker.id);
|
|
if (worker.id === retentionSweepWorkerId) {
|
|
retentionSweepWorkerId = null;
|
|
assignRetentionSweepWorker();
|
|
}
|
|
logger.error(
|
|
`Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`,
|
|
);
|
|
logger.info('Starting a new worker to replace it...');
|
|
cluster.fork();
|
|
});
|
|
|
|
/** Graceful shutdown on SIGTERM/SIGINT */
|
|
const shutdown = () => {
|
|
logger.info('Master received shutdown signal, terminating workers...');
|
|
for (const id in cluster.workers) {
|
|
cluster.workers[id].kill();
|
|
}
|
|
setTimeout(() => {
|
|
logger.info('Forcing shutdown after timeout');
|
|
process.exit(0);
|
|
}, 10000);
|
|
};
|
|
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
} else {
|
|
/**
|
|
* Worker process
|
|
* Each worker runs a full Express server instance
|
|
*/
|
|
const app = express();
|
|
/**
|
|
* The master may assign the sweep worker before or after this worker has
|
|
* loaded app config. These flags join the IPC assignment with config
|
|
* availability and ensure the background sweep starts only once.
|
|
*/
|
|
let shouldStartExpiredFileSweep = false;
|
|
let expiredFileSweepOptions = null;
|
|
let expiredFileSweepStarted = false;
|
|
|
|
const startExpiredFileSweepOnce = () => {
|
|
if (!shouldStartExpiredFileSweep || expiredFileSweepStarted || !expiredFileSweepOptions) {
|
|
return;
|
|
}
|
|
|
|
expiredFileSweepStarted = true;
|
|
startExpiredFileSweep(expiredFileSweepOptions);
|
|
};
|
|
|
|
/** Handle inter-process messages from master */
|
|
process.on('message', (msg) => {
|
|
if (msg.type === 'file-retention-sweep-worker') {
|
|
shouldStartExpiredFileSweep = true;
|
|
logger.info(wrapLogMessage(`Worker ${process.pid} is assigned file-retention sweep`));
|
|
startExpiredFileSweepOnce();
|
|
}
|
|
});
|
|
|
|
const startServer = async () => {
|
|
logger.info(`Worker ${process.pid} initializing...`);
|
|
|
|
if (typeof Bun !== 'undefined') {
|
|
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
|
}
|
|
|
|
/** Connect to MongoDB */
|
|
await connectDb();
|
|
logger.info(`Worker ${process.pid}: Connected to MongoDB`);
|
|
|
|
/** Background index sync (non-blocking) */
|
|
indexSync().catch((err) => {
|
|
logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
|
|
});
|
|
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', trusted_proxy);
|
|
|
|
/** Seed database (idempotent) */
|
|
await seedDatabase();
|
|
|
|
/* Mirrors `server/index.js`; `runAsSystem` for tenant-isolated File. */
|
|
runAsSystem(sweepOrphanedPreviews).catch((err) => {
|
|
logger.error('[sweepOrphanedPreviews] Background sweep failed:', err);
|
|
});
|
|
|
|
/** Initialize app configuration */
|
|
const appConfig = await getAppConfig();
|
|
initializeFileStorage(appConfig);
|
|
expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig };
|
|
startExpiredFileSweepOnce();
|
|
await performStartupChecks(appConfig);
|
|
await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions });
|
|
|
|
/** Load index.html for SPA serving */
|
|
const indexPath = path.join(appConfig.paths.dist, 'index.html');
|
|
let indexHTML = fs.readFileSync(indexPath, 'utf8');
|
|
|
|
/** Support serving in subdirectory if DOMAIN_CLIENT is set */
|
|
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}"`);
|
|
}
|
|
}
|
|
|
|
/** Health check endpoint */
|
|
app.get('/health', (_req, res) => res.status(200).send('OK'));
|
|
|
|
/** Middleware */
|
|
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 {
|
|
logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
|
}
|
|
|
|
app.use(staticCache(appConfig.paths.dist));
|
|
app.use(staticCache(appConfig.paths.fonts));
|
|
app.use(staticCache(appConfig.paths.assets));
|
|
|
|
if (!ALLOW_SOCIAL_LOGIN) {
|
|
logger.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);
|
|
}
|
|
|
|
/** Routes */
|
|
app.use('/oauth', routes.oauth);
|
|
app.use('/api/auth', routes.auth);
|
|
app.use('/api/admin', routes.adminAuth);
|
|
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', routes.share);
|
|
app.use('/api/roles', routes.roles);
|
|
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);
|
|
|
|
/** 404 for unmatched API routes */
|
|
app.use('/api', apiNotFound);
|
|
|
|
/** SPA fallback - serve index.html for all unmatched routes */
|
|
app.use((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',
|
|
});
|
|
|
|
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}"`);
|
|
|
|
res.type('html');
|
|
res.send(updatedIndexHtml);
|
|
});
|
|
|
|
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
|
|
app.use(ErrorController);
|
|
|
|
/** Start listening on shared port (cluster will distribute connections) */
|
|
app.listen(port, host, async (err) => {
|
|
if (err) {
|
|
logger.error(`Worker ${process.pid} failed to start server:`, err);
|
|
process.exit(1);
|
|
}
|
|
|
|
logger.info(
|
|
`Worker ${process.pid} started: 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(...)`. Without
|
|
* explicit handling, the global `unhandledRejection` handler would
|
|
* swallow init failures and leave the worker listening but only
|
|
* partially initialized.
|
|
*/
|
|
try {
|
|
/** Initialize MCP servers and OAuth reconnection for this worker */
|
|
await initializeMCPs();
|
|
await initializeOAuthReconnectManager();
|
|
await checkMigrations();
|
|
} catch (initErr) {
|
|
logger.error(`Worker ${process.pid} post-listen initialization failed:`, initErr);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
};
|
|
|
|
startServer().catch((err) => {
|
|
logger.error(`Failed to start worker ${process.pid}:`, err);
|
|
process.exit(1);
|
|
});
|
|
|
|
/** Export app for testing purposes (only available in worker processes) */
|
|
module.exports = app;
|
|
}
|
|
|
|
/**
|
|
* Uncaught exception handler
|
|
* Filters out known non-critical errors
|
|
*/
|
|
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;
|
|
}
|
|
|
|
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 });
|
|
});
|