diff --git a/api/server/experimental.js b/api/server/experimental.js
index c20f6813ca..48666a2f7d 100644
--- a/api/server/experimental.js
+++ b/api/server/experimental.js
@@ -16,9 +16,11 @@ const {
isEnabled,
apiNotFound,
ErrorController,
+ QUERY_DEVTOOLS_HEADER,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
+ maybeInjectQueryDevtoolsBootstrap,
preAuthTenantMiddleware,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
@@ -315,6 +317,23 @@ if (cluster.isMaster) {
}
}
+ 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);
+ };
+
/** Health check endpoint */
app.get('/health', (_req, res) => res.status(200).send('OK'));
@@ -348,6 +367,7 @@ if (cluster.isMaster) {
logger.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));
@@ -406,20 +426,7 @@ if (cluster.isMaster) {
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);
- });
+ app.use(sendIndexHtml);
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
app.use(ErrorController);
diff --git a/api/server/index.js b/api/server/index.js
index 648d099143..19ef7d533c 100644
--- a/api/server/index.js
+++ b/api/server/index.js
@@ -19,9 +19,11 @@ const {
performStartupChecks,
handleJsonParseError,
GenerationJobManager,
+ QUERY_DEVTOOLS_HEADER,
createStreamServices,
initializeFileStorage,
initializeDeploymentSkills,
+ maybeInjectQueryDevtoolsBootstrap,
preAuthTenantMiddleware,
setupGracefulShutdown,
updateInterfacePermissions,
@@ -140,6 +142,23 @@ const startServer = async () => {
}
}
+ 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) => {
@@ -179,6 +198,7 @@ const startServer = async () => {
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));
@@ -256,20 +276,7 @@ const startServer = async () => {
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);
- });
+ app.use(sendIndexHtml);
/** Record trace errors before the final error controller. */
if (telemetry.enabled) {
diff --git a/api/server/index.spec.js b/api/server/index.spec.js
index 20bc5b5a79..3e0fc07127 100644
--- a/api/server/index.spec.js
+++ b/api/server/index.spec.js
@@ -212,6 +212,32 @@ describe('Server Configuration', () => {
expect(response.headers['content-type']).toMatch(/html/);
});
+ it('should gate React Query Devtools config in SPA HTML by debug header', async () => {
+ const defaultResponse = await request(app).get('/this/does/not/exist');
+ const debugResponse = await request(app)
+ .get('/this/does/not/exist')
+ .set('x-librechat-enable-query-devtools', '1');
+ const directIndexResponse = await request(app)
+ .get('/index.html')
+ .set('x-librechat-enable-query-devtools', '1');
+
+ expect(defaultResponse.status).toBe(200);
+ expect(defaultResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
+ expect(defaultResponse.text).not.toContain('enableQueryDevtools');
+
+ expect(debugResponse.status).toBe(200);
+ expect(debugResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
+ expect(debugResponse.text).toContain('window.__LIBRECHAT_CONFIG__');
+ expect(debugResponse.text).toContain('data-librechat-query-devtools="true"');
+ expect(debugResponse.text).toContain('"enableQueryDevtools":true');
+
+ expect(directIndexResponse.status).toBe(200);
+ expect(directIndexResponse.headers.vary).toContain('x-librechat-enable-query-devtools');
+ expect(directIndexResponse.text).toContain('window.__LIBRECHAT_CONFIG__');
+ expect(directIndexResponse.text).toContain('data-librechat-query-devtools="true"');
+ expect(directIndexResponse.text).toContain('"enableQueryDevtools":true');
+ });
+
it('should return 500 for unknown errors via ErrorController', async () => {
// Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 975291526c..78ed8438b0 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -4,11 +4,11 @@ import { DndProvider } from 'react-dnd';
import { RouterProvider } from 'react-router-dom';
import * as RadixToast from '@radix-ui/react-toast';
import { HTML5Backend } from 'react-dnd-html5-backend';
-import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import WakeLockManager from '~/components/System/WakeLockManager';
+import QueryDevtoolsGate from '~/components/QueryDevtoolsGate';
import LanguageSync from '~/components/System/LanguageSync';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
@@ -65,7 +65,7 @@ const App = () => {