Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions api/server/experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ const {
isEnabled,
apiNotFound,
ErrorController,
QUERY_DEVTOOLS_HEADER,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
maybeInjectQueryDevtoolsBootstrap,
preAuthTenantMiddleware,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
Expand Down Expand Up @@ -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'));

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 21 additions & 14 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ const {
performStartupChecks,
handleJsonParseError,
GenerationJobManager,
QUERY_DEVTOOLS_HEADER,
createStreamServices,
initializeFileStorage,
initializeDeploymentSkills,
maybeInjectQueryDevtoolsBootstrap,
preAuthTenantMiddleware,
setupGracefulShutdown,
updateInterfacePermissions,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions api/server/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
3 changes: 3 additions & 0 deletions client/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,5 +658,8 @@ export type TThread = { id: string; createdAt: string };
declare global {
interface Window {
google_tag_manager?: unknown;
__LIBRECHAT_CONFIG__?: {
enableQueryDevtools?: boolean;
};
}
}
33 changes: 33 additions & 0 deletions client/src/components/QueryDevtoolsGate.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions client/src/components/__tests__/QueryDevtoolsGate.spec.tsx
Original file line number Diff line number Diff line change
@@ -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());
});
});
15 changes: 15 additions & 0 deletions client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the devtools chunk from PWA precache

In production builds where the PWA service worker is registered, this manual chunk is emitted as assets/query-devtools-*.js, but the same client/vite.config.ts Workbox config still precaches every **/*.{js,css,html} file and only ignores index.html, maps, RUM, and locale chunks. That means clients still download/cache the React Query Devtools chunk on every service-worker install/update even when the opt-in header is absent, which defeats the default network-byte savings of lazy-loading; add the chunk to globIgnores or otherwise keep it out of the precache manifest.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ed40410a5 by adding assets/query-devtools*.js to the Workbox globIgnores list so the service worker precache skips the lazy React Query Devtools chunk. Validation: Prettier/import-sort/ESLint on client/vite.config.ts, npm run build:client, and rg "query-devtools" client/dist/sw.js confirmed the generated service worker has no precache reference.

}
if (normalizedId.includes('@tanstack')) {
return 'tanstack-vendor';
}
Expand Down
34 changes: 34 additions & 0 deletions packages/api/src/html/devtools.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 34 additions & 0 deletions packages/api/src/html/devtools.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/api/src/html/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './devtools';
2 changes: 2 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading