mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
⚙️ refactor: lazy-load React Query Devtools (#13639)
* perf(client): lazy-load query devtools * fix: keep query devtools deps lazy * fix: address query devtools review findings * fix: exclude query devtools from pwa precache --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
d91cec2101
commit
865e1da857
12 changed files with 221 additions and 30 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<WakeLockManager />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<QueryDevtoolsGate />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
|
|
|
|||
|
|
@ -658,5 +658,8 @@ export type TThread = { id: string; createdAt: string };
|
|||
declare global {
|
||||
interface Window {
|
||||
google_tag_manager?: unknown;
|
||||
__LIBRECHAT_CONFIG__?: {
|
||||
enableQueryDevtools?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
client/src/components/QueryDevtoolsGate.tsx
Normal file
33
client/src/components/QueryDevtoolsGate.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
|
||||
interface QueryDevtoolsConfig {
|
||||
enableQueryDevtools?: boolean;
|
||||
}
|
||||
|
||||
interface QueryDevtoolsGateProps {
|
||||
config?: QueryDevtoolsConfig;
|
||||
isDevelopment?: boolean;
|
||||
}
|
||||
|
||||
const LazyReactQueryDevtools = lazy(() =>
|
||||
import('@tanstack/react-query-devtools/production').then(({ ReactQueryDevtools }) => ({
|
||||
default: ReactQueryDevtools,
|
||||
})),
|
||||
);
|
||||
|
||||
export const shouldEnableQueryDevtools = ({
|
||||
isDevelopment = import.meta.env.DEV,
|
||||
config = typeof window === 'undefined' ? undefined : window.__LIBRECHAT_CONFIG__,
|
||||
}: QueryDevtoolsGateProps = {}) => isDevelopment || config?.enableQueryDevtools === true;
|
||||
|
||||
export default function QueryDevtoolsGate({ isDevelopment, config }: QueryDevtoolsGateProps = {}) {
|
||||
if (!shouldEnableQueryDevtools({ isDevelopment, config })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LazyReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
29
client/src/components/__tests__/QueryDevtoolsGate.spec.tsx
Normal file
29
client/src/components/__tests__/QueryDevtoolsGate.spec.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import QueryDevtoolsGate, { shouldEnableQueryDevtools } from '../QueryDevtoolsGate';
|
||||
|
||||
jest.mock('@tanstack/react-query-devtools/production', () => ({
|
||||
ReactQueryDevtools: () => <div data-testid="query-devtools" />,
|
||||
}));
|
||||
|
||||
describe('QueryDevtoolsGate', () => {
|
||||
it('keeps query devtools disabled in production by default', () => {
|
||||
expect(shouldEnableQueryDevtools({ isDevelopment: false, config: undefined })).toBe(false);
|
||||
|
||||
const { container } = render(<QueryDevtoolsGate isDevelopment={false} config={undefined} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId('query-devtools')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables query devtools in local development', async () => {
|
||||
render(<QueryDevtoolsGate isDevelopment={true} config={undefined} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('query-devtools')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('enables query devtools in production when the server-injected flag is true', async () => {
|
||||
render(<QueryDevtoolsGate isDevelopment={false} config={{ enableQueryDevtools: true }} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('query-devtools')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,13 @@ const backendURL = process.env.HOST
|
|||
? `http://${process.env.HOST}:${backendPort}`
|
||||
: `http://localhost:${backendPort}`;
|
||||
const buildSourceMap = process.env.NODE_ENV === 'development';
|
||||
const QUERY_DEVTOOLS_CHUNK_MODULES = [
|
||||
'@tanstack/react-query-devtools',
|
||||
'@tanstack/match-sorter-utils',
|
||||
'node_modules/superjson',
|
||||
'node_modules/copy-anything',
|
||||
'node_modules/is-what',
|
||||
];
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: '',
|
||||
|
|
@ -89,6 +96,7 @@ export default defineConfig(({ command }) => ({
|
|||
'index.html',
|
||||
'assets/rum.*.js',
|
||||
'assets/locale-*.js',
|
||||
'assets/query-devtools*.js',
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||
/** LibreChat mutates index.html per request for subpath and language support. */
|
||||
|
|
@ -311,6 +319,13 @@ export default defineConfig(({ command }) => ({
|
|||
if (normalizedId.includes('node_modules/hast-util-raw')) {
|
||||
return 'markdown_large';
|
||||
}
|
||||
if (
|
||||
QUERY_DEVTOOLS_CHUNK_MODULES.some((moduleName) =>
|
||||
normalizedId.includes(moduleName),
|
||||
)
|
||||
) {
|
||||
return 'query-devtools';
|
||||
}
|
||||
if (normalizedId.includes('@tanstack')) {
|
||||
return 'tanstack-vendor';
|
||||
}
|
||||
|
|
|
|||
34
packages/api/src/html/devtools.spec.ts
Normal file
34
packages/api/src/html/devtools.spec.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { QueryDevtoolsRequest } from './devtools';
|
||||
import {
|
||||
QUERY_DEVTOOLS_HEADER,
|
||||
maybeInjectQueryDevtoolsBootstrap,
|
||||
shouldEnableQueryDevtools,
|
||||
} from './devtools';
|
||||
|
||||
const createReq = (value?: string): QueryDevtoolsRequest => ({
|
||||
get: (header) => (header === QUERY_DEVTOOLS_HEADER ? value : undefined),
|
||||
});
|
||||
|
||||
describe('query devtools HTML bootstrap', () => {
|
||||
const html =
|
||||
'<!DOCTYPE html><html lang="en-US"><head><title>LibreChat</title></head><body></body></html>';
|
||||
|
||||
it('uses the documented debug header value as the opt-in signal', () => {
|
||||
expect(shouldEnableQueryDevtools(createReq('1'))).toBe(true);
|
||||
expect(shouldEnableQueryDevtools(createReq('true'))).toBe(false);
|
||||
expect(shouldEnableQueryDevtools(createReq())).toBe(false);
|
||||
});
|
||||
|
||||
it('does not inject the production devtools flag by default', () => {
|
||||
expect(maybeInjectQueryDevtoolsBootstrap(html, createReq())).toBe(html);
|
||||
});
|
||||
|
||||
it('injects a server-to-client flag when the debug header is present', () => {
|
||||
const updatedHtml = maybeInjectQueryDevtoolsBootstrap(html, createReq('1'));
|
||||
|
||||
expect(updatedHtml).toContain('window.__LIBRECHAT_CONFIG__');
|
||||
expect(updatedHtml).toContain('data-librechat-query-devtools="true"');
|
||||
expect(updatedHtml).toContain('"enableQueryDevtools":true');
|
||||
expect(updatedHtml.indexOf('enableQueryDevtools')).toBeLessThan(updatedHtml.indexOf('</head>'));
|
||||
});
|
||||
});
|
||||
34
packages/api/src/html/devtools.ts
Normal file
34
packages/api/src/html/devtools.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export const QUERY_DEVTOOLS_HEADER = 'x-librechat-enable-query-devtools';
|
||||
|
||||
const QUERY_DEVTOOLS_SENTINEL = 'data-librechat-query-devtools="true"';
|
||||
const QUERY_DEVTOOLS_BOOTSTRAP = `<script ${QUERY_DEVTOOLS_SENTINEL}>window.__LIBRECHAT_CONFIG__=Object.assign({},window.__LIBRECHAT_CONFIG__,{"enableQueryDevtools":true});</script>`;
|
||||
|
||||
export interface QueryDevtoolsRequest {
|
||||
get(header: string): string | undefined;
|
||||
}
|
||||
|
||||
export const shouldEnableQueryDevtools = (req: QueryDevtoolsRequest): boolean =>
|
||||
req.get(QUERY_DEVTOOLS_HEADER) === '1';
|
||||
|
||||
const injectQueryDevtoolsBootstrap = (html: string): string => {
|
||||
if (html.includes(QUERY_DEVTOOLS_SENTINEL)) {
|
||||
return html;
|
||||
}
|
||||
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${QUERY_DEVTOOLS_BOOTSTRAP}</head>`);
|
||||
}
|
||||
|
||||
return html.replace(/<body([^>]*)>/i, `<body$1>${QUERY_DEVTOOLS_BOOTSTRAP}`);
|
||||
};
|
||||
|
||||
export const maybeInjectQueryDevtoolsBootstrap = (
|
||||
html: string,
|
||||
req: QueryDevtoolsRequest,
|
||||
): string => {
|
||||
if (!shouldEnableQueryDevtools(req)) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return injectQueryDevtoolsBootstrap(html);
|
||||
};
|
||||
1
packages/api/src/html/index.ts
Normal file
1
packages/api/src/html/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './devtools';
|
||||
|
|
@ -25,6 +25,8 @@ export * from './utils';
|
|||
export { default as Tokenizer, countTokens } from './utils/tokenizer';
|
||||
export type { EncodingName } from './utils/tokenizer';
|
||||
export * from './db/utils';
|
||||
/* HTML */
|
||||
export * from './html';
|
||||
/* OAuth */
|
||||
export * from './oauth';
|
||||
export * from './mcp/oauth/OAuthReconnectionManager';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue