-
Notifications
You must be signed in to change notification settings - Fork 452
Expand file tree
/
Copy pathnext.config.mjs
More file actions
350 lines (333 loc) · 12.4 KB
/
Copy pathnext.config.mjs
File metadata and controls
350 lines (333 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
import { createMDX } from 'fumadocs-mdx/next'
import NextBundleAnalyzer from '@next/bundle-analyzer'
import { resolve } from 'path'
import { computeOgVersion } from './lib/og-version.mjs'
const withBundleAnalyzer = NextBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})
/**
* Fumadocs MDX (v15) integration. `createMDX` wires the content/ MDX loaders
* (webpack + turbopack) and generates the `.source/` files from source.config.ts;
* it replaces the removed `start()` API and the manual content/ webpack rule.
*/
const withMDX = createMDX({ configPath: 'source.config.ts' })
/**
* CSP headers
* img-src https to allow loading images from SSO providers
* 'unsafe-inline' is required for inline styles and Next.js script injection
*/
const cspHeader = `
default-src 'self' https: wss:;
script-src 'self' 'unsafe-inline' ${process.env.NODE_ENV === 'development' ? "'unsafe-eval'" : ''} https:;
style-src 'self' 'unsafe-inline' https:;
img-src 'self' https: blob: data:;
media-src 'self' https: blob: data:;
font-src 'self' https:;
frame-src 'self' https:;
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
block-all-mixed-content;
`
const nonPermanentRedirects = [
['/discord', 'https://discord.librechat.ai'],
['/demo', 'https://chat.librechat.ai'],
['/issue', 'https://github.com/danny-avila/LibreChat/issues/new/choose'],
['/new-issue', 'https://github.com/danny-avila/LibreChat/issues/new/choose'],
['/issues', 'https://github.com/danny-avila/LibreChat/issues'],
['/gh-support', 'https://github.com/danny-avila/LibreChat/discussions/categories/support'],
['/gh-discussions', 'https://github.com/danny-avila/LibreChat/discussions'],
['/roadmap', '/blog/2026-02-18_2026_roadmap'],
['/features', '/docs/features'],
['/docs/configuration/azure', '/docs/configuration/librechat_yaml/ai_endpoints/azure'],
['/docs/user_guides/artifacts', '/docs/features/artifacts'],
['/docs/user_guides/fork', '/docs/features/fork'],
['/docs/user_guides/authentication', '/docs/features/authentication'],
['/docs/user_guides/mod_system', '/docs/features/mod_system'],
['/docs/user_guides/search', '/docs/features/search'],
['/docs/user_guides/import_convos', '/docs/features/import_convos'],
['/docs/user_guides/password_reset', '/docs/features/password_reset'],
['/docs/user_guides/rag_api', '/docs/features/rag_api'],
['/docs/user_guides/plugins', '/docs/features/agents'],
['/docs/features/plugins', '/docs/features/agents'],
['/docs/features/speech-to-text', '/docs/configuration/stt_tts'],
['/docs/configuration/librechat_yaml/setup', '/docs/configuration/librechat_yaml'],
['/toolkit/yaml_checker', '/toolkit/yaml-checker'],
['/toolkit/creds_generator', '/toolkit/creds-generator'],
]
/**
* Build-time content fingerprint of the Open Graph cards. Inlined into the
* bundle so lib/og.ts can append it as `?v=` to every social-card URL without
* any runtime filesystem reads. A card change yields a new hash -> a new URL
* -> a cache-miss at every layer (Cloudflare edge + scraper image proxies),
* which is what makes updated cards show up without a manual purge/re-scrape.
*/
const OG_VERSION = computeOgVersion()
/**
* Edge-cache headers for the App Router routes whose HTML we want a shared CDN
* (Cloudflare, in front of the origin) to cache.
*
* The App Router serves two responses at every page URL: the HTML document and
* the RSC flight payload (`text/x-component`), told apart only by the `RSC`
* request header that Next advertises via `Vary: RSC`. Cloudflare ignores
* `Vary: RSC`, so a single `public, s-maxage` rule on the URL lets it cache
* whichever variant it happens to see first and serve that to everyone. When a
* Next prefetch populates the entry with the flight payload, real browser
* navigations then receive raw `text/x-component` data and render it as garbage
* (`:HL[...] 0:{"buildId"...}`) instead of the page.
*
* Split the rule on the `RSC` header: the document response (no header) stays
* shared-cacheable, the flight payload (header present) is marked
* `private, no-store` so the CDN never caches it and therefore can never serve
* one as a document. A cached document occasionally returned to an RSC request
* just makes Next fall back to a full navigation, which is harmless.
*
* The same collision applies to the markdown content negotiation in proxy.ts
* (`isMarkdownPreferred`): a `/docs/*` request with `Accept: text/markdown`
* (LLM/agent tooling) gets rewritten to the raw-markdown response, but that
* response shares its cache key with the HTML document since Cloudflare
* ignores `Vary` here too. Whichever one a given edge PoP sees first for a URL
* gets cached for it and served to everyone else hitting that PoP, including
* browsers, until the entry expires. Mark those responses `private, no-store`
* for the same reason as the RSC flight payload.
*/
const SHARED_CDN_CACHE = 'public, s-maxage=86400, stale-while-revalidate=604800'
/**
* Pages that render live data need an edge TTL matching the ISR window of the
* route that builds them (`revalidate` in app/blog/[slug]/page.tsx). Under the
* shared blog rule the CDN would keep serving a day-old document, so a post
* showing "live" numbers would be up to 24h stale no matter how often the origin
* regenerated it. Keep the two windows in step when changing either.
*/
const LIVE_DATA_CDN_CACHE = 'public, s-maxage=3600, stale-while-revalidate=86400'
const LIVE_DATA_PATHS = ['/blog/2026-07-26_clickhouse-analytics']
const AGENT_DISCOVERY_LINKS = [
'</.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json"',
'</openapi.json>; rel="service-desc"; type="application/vnd.oai.openapi+json;version=3.1"',
'</docs>; rel="service-doc"; type="text/html"',
'</llms.txt>; rel="describedby"; type="text/markdown"',
].join(', ')
/**
* Cache rules for one source: the document response stays shared-cacheable at
* the given TTL, while the two variants that share its cache key — the RSC
* flight payload and the markdown negotiation — are marked uncacheable, for the
* reasons in the note above.
*/
const cdnRulesFor = (source, cache) => [
{
source,
missing: [{ type: 'header', key: 'RSC' }],
headers: [{ key: 'Cache-Control', value: cache }],
},
{
source,
has: [{ type: 'header', key: 'RSC' }],
headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
},
{
source,
has: [{ type: 'header', key: 'accept', value: '.*text/markdown.*' }],
headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
},
]
const MARKDOWN_NEGOTIATED_PATHS = [
'/docs/:path*',
// Localized docs need the same cache partitioning and edge-cache policy as English.
'/(zh|es|fr|de|ja|pt-BR|it|nl|pl|vi|ko|id|tr)/docs/:path*',
]
// Middleware response headers are replaced when the App Router emits its RSC
// Vary value. Configure Accept at the route layer so Next appends its own
// variants instead of dropping the content-negotiation cache key.
const markdownNegotiatedVaryHeaders = MARKDOWN_NEGOTIATED_PATHS.map((source) => ({
source,
headers: [{ key: 'Vary', value: 'Accept' }],
}))
// The narrower live-data rules come last so their Cache-Control overrides the
// shared value on the paths they match.
const cdnCacheHeaders = [
...[
'/docs/:path*',
// Localized docs (/<locale>/docs/...). Without this they match no cache rule,
// so Cloudflare never edge-caches them and every language switch is a full
// origin round-trip — including the 307 that untranslated pages redirect with.
'/(zh|es|fr|de|ja|pt-BR|it|nl|pl|vi|ko|id|tr)/docs/:path*',
'/(blog|changelog|authors|privacy|tos|cookie)(.*)',
].flatMap((source) => cdnRulesFor(source, SHARED_CDN_CACHE)),
...LIVE_DATA_PATHS.flatMap((source) => cdnRulesFor(source, LIVE_DATA_CDN_CACHE)),
]
/** @type {import('next').NextConfig} */
const config = {
poweredByHeader: false,
env: {
OG_VERSION,
},
// The OG renderer (app/api/og/route.tsx) reads the logo + fonts from disk at
// runtime via process.cwd(). Those paths aren't statically analyzable, so
// Next's tracing can miss them and the function 404s/500s on Vercel. Force
// them into the serverless bundle for that route.
outputFileTracingIncludes: {
'/api/og': [
'./lib/fonts/Geist-Regular.ttf',
'./lib/fonts/Geist-SemiBold.ttf',
'./public/librechat.png',
],
'/mcp': ['./content/docs/**/*.mdx'],
},
typescript: {
ignoreBuildErrors: false,
},
// Tree-shake large barrel-file packages so only the icons/animations actually
// used are bundled, instead of the entire module.
experimental: {
optimizePackageImports: ['lucide-react', 'framer-motion'],
},
turbopack: {},
pageExtensions: ['mdx', 'md', 'jsx', 'js', 'tsx', 'ts'],
webpack(webpackConfig, options) {
const componentsDir = resolve(process.cwd(), 'components')
/**
* createMDX (withMDX) already pushed a global `.mdx` rule using
* `fumadocs-mdx/webpack/mdx`. Scope it away from components/ so it doesn't
* double-process the component MDX that the @mdx-js/loader rule below owns
* (chaining the two loaders fails with "only import/exports are supported").
*/
for (const rule of webpackConfig.module.rules) {
const usesFumadocsMdx =
Array.isArray(rule?.use) &&
rule.use.some(
(u) => typeof u === 'object' && u?.loader?.includes('fumadocs-mdx/webpack/mdx'),
)
if (usesFumadocsMdx) {
const existing = Array.isArray(rule.exclude)
? rule.exclude
: rule.exclude
? [rule.exclude]
: []
rule.exclude = [...existing, componentsDir]
}
}
/**
* MDX loader for components/ directory files.
* These are MDX files imported directly as React components
* (e.g. changelog content, repeated sections). The content/ MDX is handled
* by createMDX (withMDX), so only the components/ rule lives here.
*/
webpackConfig.module.rules.push({
test: /\.mdx?$/,
include: [componentsDir],
use: [
options.defaultLoaders.babel,
{
loader: '@mdx-js/loader',
options: {
providerImportSource: resolve(process.cwd(), 'lib/mdx-provider.ts'),
},
},
],
})
return webpackConfig
},
transpilePackages: ['react-tweet', 'geist'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'static.librechat.ai',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'github.com',
port: '',
pathname: '/{user-attachments,danny-avila}/**',
},
{
protocol: 'https',
hostname: 'firebasestorage.googleapis.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'www.librechat.ai',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'librechat.ai',
port: '',
pathname: '/**',
},
],
},
headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'x-frame-options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'autoplay=(self), fullscreen=(self), microphone=()',
},
],
},
{
source: '/:path((?!api).*)*',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replaceAll('\n', ''),
},
],
},
{
source: '/',
headers: [
{
key: 'Link',
value: AGENT_DISCOVERY_LINKS,
},
],
},
...markdownNegotiatedVaryHeaders,
...cdnCacheHeaders,
]
},
async rewrites() {
return [
{
source: '/docs/:path*.md',
destination: '/llms.mdx/docs/:path*',
},
{
source: '/docs/:path*.mdx',
destination: '/llms.mdx/docs/:path*',
},
]
},
redirects: async () => [
...nonPermanentRedirects.map(([source, destination]) => ({
source,
destination,
permanent: false,
})),
],
}
export default withBundleAnalyzer(withMDX(config))