mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
* 🧬 feat: Scaffold Skills CRUD with ACL Sharing and File Schema Adds Skills as a new first-class resource modeled on Anthropic's Agent Skills, reusing the existing Prompt ACL stack for sharing. Lays the groundwork for multi-file skills (SkillFile schema + metadata routes) without wiring upload processing — single-file skills (inline SKILL.md body) work end-to-end, multi-file uploads are stubbed for phase 2. * 🔬 fix: Wire Skill Cleanup, AccessRole Enum, and Express 5 Path Params CI surfaced four follow-ups from the initial Skills scaffolding commit that local builds missed: - AccessRole's resourceType field had a hardcoded enum that didn't include `'skill'`, blocking SKILL_OWNER/EDITOR/VIEWER role creation in every test that hit the AccessRole model. - The seedDefaultRoles assertion in accessRole.spec.ts hard-listed the expected role IDs and needed the new SKILL_* entries. - deleteUserController had no cleanup for skills, and the deleteUserResourceCoverage guard test enforces every ResourceType has a documented handler — wired in db.deleteUserSkills(user._id) and added the entry to HANDLED_RESOURCE_TYPES. - Express 5's path-to-regexp v6 rejects the legacy `(*)` named-group glob syntax. The two skill file routes now use a plain `:relativePath` param; the client already encodeURIComponents the path, so a single param is sufficient and decoded server-side. * 🪡 fix: Make Skill Name Uniqueness Application-Level Resolve three more CI failures from the Skills scaffolding PR: - Mongoose creates indexes asynchronously and mongodb-memory-server tests can race ahead of the unique (name, author, tenantId) index being built, so the duplicate-name uniqueness test was flaky. Added an explicit findOne pre-check inside createSkill that throws with code 11000 (mimicking the index violation), giving deterministic behavior. The unique index stays as the persistent guarantee. - The deleteUser.spec.js and UserController.spec.js suites mock the ~/models module directly and were missing deleteUserSkills, causing deleteUserController to throw and return 500 instead of 200. - Removed two doc-comment claims that the SKILL_NAME_MAX_LENGTH and SKILL_DESCRIPTION_MAX_LENGTH constants "match Anthropic's API". The values themselves are reasonable but the comments were misleading about who enforces them. * 🪢 fix: Address Code Review Findings on Skills Scaffolding Resolve all 15 findings from the comprehensive PR review: Critical: - Rollback the created skill when grantPermission throws so a transient ACL failure cannot leave an orphaned, inaccessible skill in the DB. - Fix infinite query cache corruption in useUpdateSkillMutation helpers. setQueriesData([QueryKeys.skills]) matches useSkillsInfiniteQuery's InfiniteData cache entries, which have { pages, pageParams } shape — spreading data.skills on those would throw. Added an isInfiniteSkillData guard and per-page transform so both flat and infinite caches update correctly. Major: - Fix TUpdateSkillContext type: the public type declared previousListData but onMutate actually returns previousListSnapshots (a [key, value] tuple array). Updated the type + added TSkillCacheEntry as a shared export from data-provider. - Add cancelQueries calls before optimistic update in onMutate so in-flight refetches cannot clobber the optimistic state. - Parallelize deleteUserSkills ACL removal via Promise.allSettled instead of a sequential await loop — O(1) round-trip vs O(n). - Stub mockDeleteUserSkills in stubDeletionMocks() and assert it's called with user.id in the deleteUser.spec.js happy-path test. - Add idResolver: getSkillById to the SKILL branch in accessPermissions.js so GET /api/permissions/skill/<missing-id> returns 404 instead of 403. Minor: - Reuse resolved skill from req.resourceAccess.resourceInfo in getHandler to eliminate a redundant getSkillById call per GET /api/skills/:id. - Reject PATCH /api/skills/:id requests whose body contains only expectedVersion — previously they silently bumped version with no changes, triggering spurious 409s for collaborators. - Make TSkill.frontmatter optional (wire type) and add serializeFrontmatter / serializeSourceMetadata helpers that return undefined for empty objects instead of casting incomplete data to SkillFrontmatter. - Standardize deleteUserSkills to accept string | ObjectId and convert internally, matching deleteUserPrompts's signature; UserController now passes user.id consistently. - Replace bumpSkillVersionAndRecount (read-then-write, racy) with bumpSkillVersionAndAdjustFileCount using atomic $inc. upsertSkillFile pre-checks existence to distinguish insert (+1) from replace (0). - Add DELETE /api/skills/:id/files/:relativePath integration tests covering success, 404, and 403 paths. Nits: - Drop trivial resolveSkillId wrapper — pass getSkillById directly. - Remove dead staleTime: 1000 * 10 from useListSkillsQuery since all refetch triggers are already disabled. * 🧭 fix: Resolve Second Skills Review Pass — Cache, Gate, TOCTOU Address 13 of 14 findings from the second code review; reject #13 as misread of the AGENTS.md import-order rule (package types correctly precede local types regardless of length). Major: - Fix addSkillToCachedLists closure bug: a hoisted `prepended` flag was shared across every cache entry matched by setQueriesData, so concurrent flat + infinite caches would silently drop the prepend on whichever was processed second. Replaced the shared helper with three per-entry inline updaters that handle InfiniteData at the page level (page 0 only for prepend, all pages for replace/remove). - Tighten patchHandler's expectedVersion validation: NaN passes `typeof === 'number'` and would previously leak current skill state via a misleading 409. Now requires finite positive integer and returns 400 otherwise. - Guard decodeURIComponent in deleteFileHandler with try/catch — malformed percent encoding now returns 400 instead of 500. - Add PermissionTypes.SKILLS + skillPermissionsSchema + TSkillPermissions in data-provider; seed default SKILLS permissions for ADMIN (all true) and USER (use + create only); wire checkSkillAccess / checkSkillCreate via generateCheckAccess onto the skills router mirroring the prompts pattern. Skills route now enforces role-based capability gates alongside per-resource ACLs. Test suite adds a mocked getRoleByName returning permissive SKILLS. - Fix upsertSkillFile TOCTOU: replaced the pre-check + upsert pair with a single `findOneAndUpdate({ new: false, upsert: true })` call that atomically returns the pre-update doc (null ⇒ insert) so fileCount delta can't double-count on concurrent same-path uploads. Minor: - Add `sourceMetadata` to listSkillsByAccess .select() so summaries no longer silently drop the field for GitHub/Notion-synced skills. - Include `cursor` in useListSkillsQuery's query key so manual pagination doesn't alias across pages. - Clean up TSkillSummary to `Omit<TSkill, 'body' | 'frontmatter'>` matching what serializeSkillSummary actually emits; drop the Omit-then-re-add noise. - Skip getPublicSkillIdSet in createHandler; a newly-created skill cannot have a PUBLIC ACL entry, so pass an empty set directly instead of paying a DB round-trip. - Trim SkillMethods public surface: drop internal helpers countSkillFiles / deleteSkillFilesBySkillId / getSkillFile from the return object; inline the file cascade into deleteSkill. - Use TSkillConflictResponse at the PATCH 409 call site instead of an inline ad-hoc object literal. - Drop the now-unused EXPECTED_VERSION_ERROR module constant. * 🧩 fix: Extend Role Schema + Types with SKILLS PermissionType CI type-check and unit test failures from the PermissionTypes.SKILLS addition surfaced three unrelated places that all hardcode the permission-type set: - IRole.permissions in data-schemas/types/role.ts enumerates every PermissionTypes key as an optional field. Adding SKILLS to the enum without updating the interface caused TS7053 'expression of type PermissionTypes can't be used to index type' errors in role.methods.spec.ts (lines 407-408, 477-478) because Object.values(PermissionTypes) now yielded a value the interface didn't cover. - schema/role.ts rolePermissionsSchema mirrors the interface at the Mongoose layer; also needed SKILLS added so the persisted role document can actually store skill permissions. - data-provider/roles.spec.ts has a guard test that every permission type carrying CREATE/SHARE/SHARE_PUBLIC must be explicitly "tracked" either in RESOURCE_PERMISSION_TYPES or in the PROMPTS/AGENTS/MEMORIES exemption list. Added SKILLS to the exemption list since skills follow the same default model as prompts/agents (USE + CREATE on for USER, SHARE / SHARE_PUBLIC off). All three are additive pass-throughs with no behavior change. * 🏷️ refactor: Introduce ISkillSummary for Narrow List Projection Follow-up NITs from the second review pass on the Skills PR: - Define ISkillSummary = Omit<ISkill, 'body' | 'frontmatter'> and use it as the element type in ListSkillsByAccessResult. The list query's .select() intentionally omits body and frontmatter for payload size, but the previous type claimed both fields were present — a type lie that would mislead future readers even though serializeSkillSummary never touches those fields at runtime. handlers.ts's signature for serializeSkillSummary now accepts ISkillSummary too. - Document the intentional second-round-trip `findOne` in upsertSkillFile. Switching to `findOneAndUpdate({ new: false })` was required for TOCTOU-safe insert-vs-replace detection, which means the handler needs a follow-up query to return the post-upsert document. A comment now explains the tradeoff so future readers don't silently "optimize" it away. No behavior change. * 🌐 fix: Wire SKILL into SHARE_PUBLIC Resource Maps Address codex comment #1 — making a skill public was blocked on two hardcoded resource→permission-type maps that didn't know about SKILL: - api/server/middleware/checkSharePublicAccess.js's resourceToPermissionType map was missing ResourceType.SKILL, so PUT /api/permissions/skill/:id with { public: true } would fall through to the 400 "Unsupported resource type for public sharing" path even though PermissionTypes.SKILLS exists and ADMIN has SHARE_PUBLIC configured. Added the mapping. - client/src/hooks/Sharing/useCanSharePublic.ts has an identical client-side map used to gate the "Make Public" UI toggle. Without the SKILL mapping the hook returned false for everyone, so the toggle wouldn't render for skills once the sharing UI lands in phase 2. Added the mapping. Codex comment #2 (create/update cache writes inject skills into unrelated filtered lists) is invalid — it flags a pattern that mirrors useUpdatePromptGroup (which the PR description explicitly cites as the model) and is a deliberate optimistic-update tradeoff. Trying to match each cache key's embedded filter would couple the mutation callback to query-key internals, which is exactly what setQueriesData is designed to avoid. No change there. * 🧪 feat: Frontmatter Validation, Reserved-Name Fixes, Coaching Warnings Address the follow-up review notes on the Skills PR. This commit closes the gap between the wire-type promise and what the backend actually enforces, tightens the reserved-name rules, and adds a non-blocking coaching tier for validators. Frontmatter validation (new): - Add `validateSkillFrontmatter` in data-schemas/methods/skill.ts with strict mode — unknown keys are rejected so expanding the allowed set is an intentional code change. Known keys are type-checked against a `FrontmatterKind` table derived from Anthropic's Agent Skills spec (name, description, when-to-use, allowed-tools, arguments, argument-hint, user-invocable, disable-model-invocation, model, effort, context, agent, paths, shell, hooks, version, metadata). - `hooks` and `metadata` get a shallow JSON-safety check (max depth 4, max string 2000, max array 100) instead of a full schema, since their full shapes live outside this module. - Wired into BOTH createSkill AND updateSkill so the PATCH path can't smuggle invalid frontmatter past the validator. Validation warning tier (new): - Add optional `severity: 'error' | 'warning'` to `ValidationIssue` (defaults to error). `partitionIssues` splits an issue list into blocking errors and non-blocking warnings. - `createSkill` / `updateSkill` filter on errors for the throw check and return warnings in a new `warnings: ValidationIssue[]` field on their result objects (`CreateSkillResult` / `UpdateSkillResult`). - `validateSkillDescription` now emits a `TOO_SHORT` warning for descriptions under 20 chars — the primary triggering field, so a little coaching goes a long way. - `createHandler` / `patchHandler` in packages/api surface the warnings via a new `attachWarnings` helper that decorates the serialized response with a `warnings?: TSkillWarning[]` field. - `TSkill` gains an optional `warnings?: TSkillWarning[]` field documented as "present on POST/PATCH, never on GET". Reserved-name filter (tightened): - Replace the substring match (`.includes('anthropic')`) with prefix matching on `anthropic-` and `claude-` plus exact-match rejection of CLI slash-command collisions (help, clear, compact, model, exit, quit, settings, plus the bare `anthropic` / `claude` words). Both the pure validator (`methods/skill.ts`) and the Mongoose schema validator (`schema/skill.ts`) updated in lockstep; comments on each reference the other to prevent drift. - `research-anthropic-helper` and `about-claude` are now allowed; `anthropic-helper`, `claude-bot`, and `settings` are still rejected. Documentation: - Add docstrings on `ISkill`, `schema/skill.ts`, and `TSkill` explaining the semantics of `name` (Claude-visible identifier, kebab-case, stable), `displayTitle` (UI-only cosmetic label, NOT sent to Claude), `description` (highest-leverage trigger field), and `source` / `sourceMetadata` (reserved for phase 2+ external sync). - Add a detailed consistency comment on `bumpSkillVersionAndAdjustFileCount` explaining that it runs as a separate MongoDB operation from upsertSkillFile/deleteSkillFile, so `fileCount` can drift if the second op fails — options listed, tradeoff documented, phase 1 risk window noted as closed because upload is still stubbed. Tests: - data-schemas skill.spec.ts: destructure `{ skill, warnings }` from createSkill at every call site; add a TOO_SHORT warning test, a frontmatter strict-mode test, reserved-prefix tests (including positive cases for substring names that should pass), CLI reserved word tests, and a full `validateSkillFrontmatter` describe block covering unknown keys, type mismatches, and deep-nesting rejection. - api/server/routes/skills.test.js: bump default test description above the 20-char threshold, add a warning-emission test, add reserved-prefix + reserved-CLI-word tests, add an unknown-frontmatter- key test asserting the 400 response carries `issues` with `UNKNOWN_KEY`. * 📦 fix: Export CreateSkillResult from data-schemas Methods Index `CreateSkillResult` was defined in `methods/skill.ts` and consumed by `packages/api/src/skills/handlers.ts` but never re-exported from the methods barrel, so the type-check job failed with TS2724 "'@librechat/data-schemas' has no exported member named 'CreateSkillResult'". Rollup's bundle-mode build picked up the type via its internal resolver, but the standalone `tsc --noEmit` type-check ran against the package's public entrypoint and couldn't see it. Added the type import + export alongside the existing `UpdateSkillResult` export, which fixes the CI type-check without any runtime change.
482 lines
16 KiB
JavaScript
482 lines
16 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 } = 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 { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
|
const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api');
|
|
const { getRoleByName, updateAccessPermissions, seedDatabase } = 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 startTime = Date.now();
|
|
|
|
/** 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`,
|
|
);
|
|
|
|
/** Notify the last worker to perform one-time initialization tasks */
|
|
if (activeWorkers === workers) {
|
|
const allWorkers = Object.values(cluster.workers);
|
|
const lastWorker = allWorkers[allWorkers.length - 1];
|
|
if (lastWorker) {
|
|
logger.info(wrapLogMessage(`All ${workers} workers are online`));
|
|
lastWorker.send({ type: 'last-worker' });
|
|
}
|
|
}
|
|
});
|
|
|
|
cluster.on('exit', (worker, code, signal) => {
|
|
activeWorkers--;
|
|
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();
|
|
|
|
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();
|
|
|
|
/** Initialize app configuration */
|
|
const appConfig = await getAppConfig();
|
|
initializeFileStorage(appConfig);
|
|
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/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);
|
|
}
|
|
});
|
|
|
|
/** Handle inter-process messages from master */
|
|
process.on('message', async (msg) => {
|
|
if (msg.type === 'last-worker') {
|
|
logger.info(
|
|
wrapLogMessage(
|
|
`Worker ${process.pid} is the last worker and can perform special initialization tasks`,
|
|
),
|
|
);
|
|
/** Add any one-time initialization tasks here */
|
|
/** For example: scheduled jobs, cleanup tasks, etc. */
|
|
}
|
|
});
|
|
};
|
|
|
|
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 });
|
|
});
|