Files
civitai__civitai/next.config.mjs
T
briant 8a39b7c876 build: consume form-graph from npm instead of the local link
Swaps the pnpm link: override for the published package (^0.3.0, currently
0.3.1) and removes the two accommodations the link needed: the turbopack.root
widening (Turbopack refused files outside the repo) and the tsconfig
react/react-dom paths pin (the linked checkout's own @types/react could
split the program).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 01:08:00 -06:00

552 lines
26 KiB
JavaScript

// @ts-check
import { withAxiom } from '@civitai/next-axiom';
import bundlAnalyzer from '@next/bundle-analyzer';
import CircularDependencyPlugin from 'circular-dependency-plugin';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const packageJson = require('./package.json');
const isProd = process.env.NODE_ENV === 'production';
const isDev = process.env.NODE_ENV === 'development';
const analyze = process.env.ANALYZE === 'true';
const includeCircularDependencyPlugin = process.env.CIRCULAR_DEPENDENCY_PLUGIN === 'true';
const withBundleAnalyzer = bundlAnalyzer({
enabled: analyze,
});
/**
* Runtime files of every installed `@swc/helpers`, force-included into the standalone output.
*
* WHY. `output: 'standalone'` ships the subset @vercel/nft traced, not `node_modules`. nft
* resolves a bare specifier under the `require`/`default` conditions; Node (>= 22.10)
* additionally honours `module-sync` for a CJS `require`. When a package's `exports` map points
* those two at different files, the build traces one and the running process asks for the other.
*
* Next's own `dist/shared/lib/constants.js` does
* `require('@swc/helpers/_/_interop_require_default')`, reached from the generated `server.js`
* via `next` -> `config.js` -> `constants.js` — i.e. before any application code. On
* @swc/helpers 0.5.15 (next 16.3.0) that subpath exported only `{ import, default }` and both
* resolvers landed on `cjs/_interop_require_default.cjs`. 0.5.17+ added `module-sync` ->
* `esm/_interop_require_default.js`, and next 16.3.1 bumped its dependency to 0.5.23 — so the
* image shipped `cjs/` only and every pod crash-looped on
* `MODULE_NOT_FOUND .../@swc/helpers/esm/_interop_require_default.js` (civitai#4075) with the
* build, the unit suite, typecheck, ESLint and the compiled-branch gate all green.
*
* BOTH condition branches of EVERY copy, not the one file missing today: which helper Next
* requires, and which branch each resolver picks, are upstream details that move. ~950 KB per
* copy (426 files across the two copies installed today). Version- and hash-agnostic globs —
* `@swc+helpers@*` covers whatever the next bump resolves to, and the flat form covers a hoisted
* (non-pnpm) layout. A non-matching glob is a silent no-op, which is exactly why this is NOT the
* guard: the guard is
* `scripts/ci/assert-standalone-boot-graph.mjs`, run against the runtime filesystem in the
* Dockerfile's runner stage, which fails the build if this ever stops landing the files.
*
* ATTACHED TO EXISTING ROUTE KEYS ON PURPOSE. This is a process-wide boot dependency, not a
* route's, and `copyTracedFiles` unions every entry's traced set into the single
* `.next/standalone` node_modules — so any one entry carrying it is enough. A `'**'` key does
* match every route (keys are picomatch'd with `contains: true`), but it would make all 572
* entries read/parse/rewrite their `.nft.json` concurrently — 826 MB of JSON in one
* `Promise.all` — on a build already tuned against OOM. These keys are API routes: always
* present, never statically prerendered (an entry in `staticPages` has its includes skipped),
* and already include-keyed, so they cost no additional entry. Three of them for redundancy: if
* one route is ever renamed or removed the files still ship, and if all three go the boot gate
* turns the build red rather than letting a broken image out.
*/
const swcHelpersRuntimeFiles = [
'./node_modules/@swc/helpers/esm/**/*',
'./node_modules/@swc/helpers/cjs/**/*',
'./node_modules/.pnpm/@swc+helpers@*/node_modules/@swc/helpers/esm/**/*',
'./node_modules/.pnpm/@swc+helpers@*/node_modules/@swc/helpers/cjs/**/*',
];
/**
* Don't be scared of the generics here.
* All they do is to give us autocompletion when using this.
*
* @template {import('next').NextConfig} T
* @param {T} config - A generic parameter that flows through to the return type
* @constraint {{import('next').NextConfig}}
*/
function defineNextConfig(config) {
return withBundleAnalyzer(config);
}
export default defineNextConfig(
withAxiom({
env: {
version: packageJson.version,
// The client login helpers need the hub origin. Reuse AUTH_JWT_ISSUER (the server's single hub-URL source)
// by exposing it to the client bundle as NEXT_PUBLIC_AUTH_HUB_URL — so there's no separate var to set. An
// explicit NEXT_PUBLIC_AUTH_HUB_URL still wins if provided. (AUTH_JWT_ISSUER is public: the JWT `iss` /
// JWKS origin.)
NEXT_PUBLIC_AUTH_HUB_URL: process.env.NEXT_PUBLIC_AUTH_HUB_URL ?? process.env.AUTH_JWT_ISSUER,
},
// webpack: (config, options) => {
// if (isDev && !options.isServer) {
// config.plugins.push(
// new CircularDependencyPlugin({
// exclude: /node_modules|\.d\.ts/, // Ignore types and external modules
// failOnError: true, // Fail build on cycle
// allowAsyncCycles: false, // Disallow lazy cycles (recommended)
// cwd: process.cwd(), // Base path for clearer output
// // `onStart` is called before the cycle detection starts
// // onStart({ compilation }) {
// // console.log('start detecting webpack modules cycles');
// // },
// // `onDetected` is called for each module that is cyclical
// onDetected({ module: webpackModuleRecord, paths, compilation }) {
// // `paths` will be an Array of the relative module paths that make up the cycle
// // `module` will be the module record generated by webpack that caused the cycle
// compilation.errors.push(new Error(paths.join(' -> ')));
// },
// // `onEnd` is called before the cycle detection ends
// // onEnd({ compilation }) {
// // console.log('end detecting webpack modules cycles');
// // },
// })
// );
// }
// return config;
// },
// Turbopack is the default bundler as of Next 16. The OpenTelemetry packages
// that produced the `require-in-the-middle` webpack warnings are listed in
// `serverExternalPackages` below, so Turbopack externalizes them and never
// emits those warnings — an empty config just acknowledges we're on Turbopack
// and silences Next's "webpack config with no turbopack config" build error.
turbopack: {},
// Per-branch build dir. Turbopack's dev filesystem cache (~8GB) is invalidated
// wholesale by an in-place branch switch, so the dev daemon points each branch at
// its own dir and keeps them warm instead of purging. Unset -> stock `.next`.
distDir: process.env.NEXT_DIST_DIR || '.next',
allowedDevOrigins: ['civitai-dev.green', 'civitai-dev.blue', 'civitai-dev.red'],
// Retained for the `next build --webpack` fallback path; ignored under Turbopack.
webpack: (config) => {
config.ignoreWarnings = [
{ module: /require-in-the-middle/ },
{ module: /@opentelemetry\/instrumentation/ },
];
return config;
},
reactStrictMode: true,
// Source maps for prod CPU-profile de-minification.
//
// Under Turbopack (our prod bundler, Next 16), the ONLY source-map lever is the
// experimental `turbopackSourceMaps` flag, whose build-time default IS
// `productionBrowserSourceMaps`. So setting this `true` turns on map emission for
// BOTH client (`.next/static/**/*.js.map`) and server (`.next/server/**/*.js.map`)
// chunks. Turbopack ignores `experimental.serverSourceMaps` (webpack-only) — that
// flag below only matters for the `next build --webpack` fallback path.
//
// Maps are inert at runtime: the Node server never loads a `.js.map` unless an
// inspector / error-stack resolver reads it, so there is NO
// request-path perf cost.
// They are NOT served to browsers for server chunks (those live in `.next/server`,
// which is not a static-served directory). Cost is build time + image size only.
//
// IMPORTANT: `output:'standalone'` traces required files via @vercel/nft, which
// follows `import`/`require`/`fs` — it does NOT trace sibling `.js.map` files, so
// the server maps are emitted to `.next/server` but DROPPED from `.next/standalone`.
// The runtime image does NOT ship these maps (the RUNNER stage copies only
// standalone + static). The build instead publishes them as a separate
// `civitai-web-maps:<tag>` artifact (Dockerfile `maps` target + the pipeline),
// fetched on demand by `scripts/resolve-cpuprofile.mjs --image <tag>` to
// de-minify a captured profile — keeping the runtime image lean.
productionBrowserSourceMaps: true,
// Next.js i18n docs: https://nextjs.org/docs/advanced-features/i18n-routing
i18n: {
locales: ['en'],
defaultLocale: 'en',
},
generateEtags: false,
compress: false,
images: {
remotePatterns: [
{ hostname: 's3.us-west-1.wasabisys.com' },
{ hostname: 'model-share.s3.us-west-1.wasabisys.com' },
{ hostname: 'civitai-prod.s3.us-west-1.wasabisys.com' },
{ hostname: 'civitai-dev.s3.us-west-1.wasabisys.com' },
{ hostname: 'image.civitai.com' },
],
// domains: [
// 's3.us-west-1.wasabisys.com',
// 'model-share.s3.us-west-1.wasabisys.com',
// 'civitai-prod.s3.us-west-1.wasabisys.com',
// 'civitai-dev.s3.us-west-1.wasabisys.com',
// 'image.civitai.com',
// ],
},
compiler:
process.env.NODE_ENV === 'production'
? {
reactRemoveProperties: { properties: ['^data-testid$'] },
// removeConsole: true,
}
: {},
transpilePackages: [
// pnpm link: to the local checkout during the data-graph port — Turbopack
// won't resolve the out-of-root symlink without transpiling it
'form-graph',
'superjson',
'@civitai/db-schema',
'@civitai/db',
'@civitai/db-queries',
'@civitai/shared',
'@civitai/buzz',
'@civitai/redis',
'@civitai/clickhouse',
'@civitai/axiom',
'@civitai/flipt',
'@civitai/telemetry',
'@civitai/auth',
'@civitai/notifications',
'@civitai/moderation',
],
// Renamed from experimental.serverComponentsExternalPackages → top-level serverExternalPackages in Next 15
serverExternalPackages: [
'redis',
'@redis/client',
'@redis/bloom',
'@redis/json',
'@redis/search',
'@redis/time-series',
'@opentelemetry/sdk-node',
'@opentelemetry/instrumentation',
'@opentelemetry/instrumentation-http',
'@opentelemetry/instrumentation-redis',
'@prisma/instrumentation',
// Bundling this gives the app layer its own copy of the Prisma runtime while
// `dbRead`/`dbWrite` (reached through the transpiled `@civitai/db-schema`) hold a
// second one. `$queryRaw` identifies its template argument with `instanceof Sql`,
// so a `Prisma.join()` built by the other copy fails that check and is bound as a
// plain value -> `operator does not exist: integer = jsonb`.
'@prisma/client',
// NOTE: the logs-pipeline packages (@opentelemetry/api, /api-logs, /sdk-logs,
// /exporter-logs-otlp-proto) are deliberately NOT externalized here. Adding them
// fails the image build, so it needs to be its own change with a full build as its
// gate. The logs bridge does not depend on it: it binds to the Logs API lazily, so
// a second bundled module copy is survivable, and `no_provider` on its skip counter
// is the runtime signal if one ever appears.
'@pyroscope/nodejs',
'@datadog/pprof',
],
// Several entry points read markdown from src/static-content at runtime via fs
// (dynamic string paths that @vercel/nft can't trace). With output:'standalone'
// the build only ships traced files, so without these explicit includes the
// markdown is missing in the deployed image and every read hits ENOENT ->
// 500/404 (works locally because the full source tree is present). Top-level as
// of Next 15 (lived under `experimental` on Next 14). Keyed by each read site.
outputFileTracingIncludes: {
'/safety': ['./src/static-content/**/*'],
'/region-blocked': ['./src/static-content/**/*'],
'/content/[[...slug]]': ['./src/static-content/**/*'],
'/api/trpc/[trpc]': ['./src/static-content/**/*', ...swcHelpersRuntimeFiles],
'/api/v1/content/[[...slug]]': ['./src/static-content/**/*', ...swcHelpersRuntimeFiles],
// /api/og uses next/og's `ImageResponse`, which on the nodejs runtime
// lazily require()s `next/dist/compiled/@vercel/og/index.node.js` (plus its
// resvg/yoga WASM + fonts). @vercel/nft cannot follow that dynamic require,
// so with output:'standalone' the file is DROPPED from the image and every
// origin (cache-miss) /api/og render throws `Cannot find module ...
// index.node.js` -> 500. This became the dominant app-500 source (~1.9/s)
// after the Next 16.2.7 upgrade; Cloudflare edge-caching of OG images masks
// it for popular entities. Force-include the whole compiled @vercel/og dir
// (entry + WASM + fonts) for this route. Two version-agnostic globs (no
// hardcoded next@<hash>): the symlinked path, plus the real pnpm path with a
// `next@*` wildcard in case globby doesn't follow the node_modules/next
// symlink. Whichever matches copies the files; a non-matching glob is a no-op.
'/api/og': [
'./node_modules/next/dist/compiled/@vercel/og/**/*',
'./node_modules/.pnpm/next@*/node_modules/next/dist/compiled/@vercel/og/**/*',
...swcHelpersRuntimeFiles,
],
},
experimental: {
// scrollRestoration: true,
cpus: 8,
serverSourceMaps: true,
// instrumentationHook removed in Next 15 — instrumentation.ts is enabled by default now
largePageDataBytes: 512 * 100000,
// Nested async chunking for the SERVER build. Next's own defaults table
// (node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md) has
// `turbopackClientSideNestedAsyncChunking` defaulting to TRUE in build mode but
// `turbopackServerSideNestedAsyncChunking` defaulting to FALSE in *both* dev and
// build — so the server build never got the async-chunk dedup the client build has,
// and every async chunk group re-emits its whole module graph.
//
// Trade: ~72% fewer emitted server chunks and roughly half the server chunk bytes,
// in exchange for ~+8% CI build time and ~+33% peak builder RSS. Measurements live
// in the PR rather than here, so they don't rot when Next's chunker changes.
//
// Off because the builder's memory ceiling is now enforced and that ~+33% peak RSS is
// what puts the release build over it. Re-enable only with a measured peak-RSS margin.
//
// SECOND REASON THIS FLAG MATTERS, and the reason to revisit it: the emitted server
// chunk COUNT is what drives the intermittent `Two or more assets with different
// content were emitted to the same output path` build failure. Turbopack names a
// server chunk `<namespace>_<7-char-hash>._.js`, and that hash's first character is
// bounded in practice to {0,1,2} — so the usable space is ~2 x 38^6, not 38^7, and
// the failure is an ordinary birthday collision between two UNRELATED chunks. It is
// deterministic for a given module graph (so a rebuild of the same commit fails
// again), and it moves to a different pair whenever the graph changes at all — which
// is why bisecting finds a commit but never a responsible file.
//
// Turning this flag ON is the only lever here that attacks the mechanism, because
// P(collision) grows with the SQUARE of the chunk count. Measured on one tree:
// 24,552 server chunks with the flag off vs 7,122 with it on (-71%).
// 🔴 DO NOT FLIP IT ANYWAY — measured on 16.3.1, it does not fit the builder's
// memory ceiling. Two blockers were on record here. The first cleared: the flag is
// BROKEN on Next 16.3.0 (19 `__turbopack_context__.a is not a function` PostCSS
// errors) and compiles from 16.3.1 onward, which the repo is now on. The second
// closed the option: a same-commit A/B on 16.3.1 measured +43.0% peak `next-build`
// RSS / +30.3% build-container peak — LARGER than the ~+33% quoted above, not
// smaller. Projected onto the worst observed production build that lands at
// 37-39 GiB against the enforced 40 GiB limit, which is the exact band where the
// release build OOMKilled three times when this flag was last on (#3807).
// Dropping source maps to pay for it is also closed: it works, but server `.js.map`
// has three consumers including the hard `scripts/assert-compiled-branches.mjs`
// gate, and `turbopackSourceMaps` cannot be split client/server.
// Full evidence: claudedocs/turbopack-chunk-hash-collision-2026-08-18.md
// (§Option 1 is closed). The live fix is upstream, not this flag.
turbopackServerSideNestedAsyncChunking: false,
// Not the same as omitting it: Next 16.3.0 defaults this to true, and turbopack-build
// derives `dependencyTracking` from it, so the flag governs what turbo-tasks retains in
// memory and not just what lands on disk.
turbopackFileSystemCacheForBuild: false,
// NB: `lodash-es`, `@tabler/icons-react` and `@headlessui/react` are already in Next's
// built-in default list (config.js merges ours into it) — kept here only as intent.
//
// 🔴 Do NOT add a package that creates React context — `@mantine/core`, `@mantine/modals`,
// `@mantine/notifications`. This rewrites barrel imports into deep per-component imports,
// which can put the provider and its consumers on DIFFERENT module instances: the provider
// is in the tree, but consumers read a context object created by another copy. Adding
// `@mantine/core` here 500'd every Mantine-heavy route in preview with "MantineProvider was
// not found in component tree" (PR #3802). Nothing local catches it — typecheck, lint and
// both vitest projects stayed green; only a real build renders the provider.
optimizePackageImports: [
'@civitai/client',
'./src/libs/form',
'lodash-es',
'@tabler/icons-react',
'@headlessui/react',
],
},
headers: async () => {
// Add X-Robots-Tag header to all pages matching /sitemap.xml and /sitemap-models.xml /sitemap-articles.xml, etc
const headers = [
{
source: '/sitemap(-\\w+)?.xml',
headers: [
{ key: 'X-Robots-Tag', value: 'noindex' },
{ key: 'Content-Type', value: 'application/xml' },
{ key: 'Cache-Control', value: 'public, max-age=86400, must-revalidate' },
],
},
];
if (process.env.NODE_ENV !== 'production') {
headers.push({
source: '/:path*',
headers: [
{
key: 'X-Robots-Tag',
value: 'noindex',
},
],
});
}
// Allow Kinguin checkout iframe on gift cards page - NO X-Frame-Options header
// Minimal CSP that only restricts frame-src to allow Kinguin
headers.push({
source: '/gift-cards',
headers: [
{
key: 'Content-Security-Policy',
value:
"frame-src 'self' https://www.kinguin.net https://sandbox.kinguin.net https://gateway.kinguin.net https://*.kinguin.net;",
},
// NOTE: Intentionally NO X-Frame-Options header as per Kinguin's documentation
// NOTE: Only setting frame-src, letting other resources use browser defaults
],
});
// Apply X-Frame-Options to all pages EXCEPT gift-cards
headers.push({
source: '/((?!gift-cards).*)',
headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
});
return headers;
},
poweredByHeader: false,
redirects: async () => {
// Note: the .red-host support-portal bounce is implemented as a
// Cloudflare Redirect Rule on the civitai.red zone. Config lives at
// ops/cloudflare/civitai-red-redirects.json. A host-conditional rule
// here would be pruned to nothing at build time anyway since
// SERVER_DOMAIN_* env vars aren't exposed as Docker build ARGs.
return [
{
// `/apps/my-submissions` merged into `/apps/mine` — one author table over every
// app you own or hold a seat on, with each app's submission history nested in
// its row. The page component is DELETED, not emptied: a stub whose only job is
// to redirect is dead code that reads as a live route.
//
// 🔴 `statusCode: 301`, not `permanent: true`. Next maps `permanent` to **308**,
// which preserves the request METHOD — correct in general, but this is a GET-only
// author page whose inbound links are bookmarks, notification URLs and search
// results, and 301 is the status those consumers cache and rewrite on. The two
// options are mutually exclusive in Next's schema, so this is `statusCode` alone.
source: '/apps/my-submissions',
destination: '/apps/mine',
statusCode: 301,
},
{
source: '/api/download/training-data/:modelVersionId',
destination: '/api/download/models/:modelVersionId?type=Training%20Data',
permanent: true,
},
{
source: '/github/:path*',
destination: 'https://github.com/civitai/civitai/:path*',
permanent: true,
},
{
source: '/discord',
destination: 'https://discord.gg/civitai',
permanent: true,
},
{
source: '/twitter',
destination: 'https://twitter.com/HelloCivitai',
permanent: true,
},
{
source: '/reddit',
destination: 'https://reddit.com/r/civitai',
permanent: true,
},
{
source: '/instagram',
destination: 'https://www.instagram.com/hellocivitai/',
permanent: true,
},
{
source: '/tiktok',
destination: 'https://www.tiktok.com/@hellocivitai',
permanent: true,
},
{
source: '/youtube',
destination: 'https://www.youtube.com/@civitai',
permanent: true,
},
{
source: '/twitch',
destination: 'https://www.twitch.tv/civitai',
permanent: true,
},
{
source: '/ideas',
destination: 'https://github.com/civitai/civitai/discussions/categories/ideas',
permanent: true,
},
{
source: '/v/civitai-link-intro',
destination: 'https://youtu.be/EHUjiDgh-MI',
permanent: false,
},
{
source: '/v/civitai-link-installation',
destination: 'https://youtu.be/fs-Zs-fvxb0',
permanent: false,
},
{
source: '/v/ally-parting-message',
destination: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
permanent: false,
},
{
source: '/gallery/:path*',
destination: '/images/:path*',
permanent: true,
},
{
source: '/canny/bugs',
destination:
'https://civitai-team.myfreshworks.com/login/auth/civitai?client_id=451979510707337272&redirect_uri=https%3A%2F%2Fcivitai.freshdesk.com%2Ffreshid%2Fcustomer_authorize_callback%3Fhd%3Dsupport.civitai.com',
permanent: true,
},
{
source: '/bugs',
destination:
'https://civitai-team.myfreshworks.com/login/auth/civitai?client_id=451979510707337272&redirect_uri=https%3A%2F%2Fcivitai.freshdesk.com%2Ffreshid%2Fcustomer_authorize_callback%3Fhd%3Dsupport.civitai.com',
permanent: true,
},
{
source: '/support-portal',
destination:
'https://civitai-team.myfreshworks.com/login/auth/civitai?client_id=451979510707337272&redirect_uri=https%3A%2F%2Fcivitai.freshdesk.com%2Ffreshid%2Fcustomer_authorize_callback%3Fhd%3Dsupport.civitai.com',
permanent: true,
},
{
source: '/leaderboard',
destination: '/leaderboard/overall',
permanent: true,
},
{
source: '/forms/bounty-refund',
destination: 'https://forms.clickup.com/8459928/f/825mr-8331/R30FGV9JFHLF527GGN',
permanent: true,
},
{
source: '/air/confirm',
destination: '/studio/confirm',
permanent: true,
},
{
source: '/education',
destination: 'https://education.civitai.com',
permanent: true,
},
{
source: '/cosmetic-shop',
destination: '/shop',
permanent: true,
},
{
source: '/shop/cosmetic-shop',
destination: '/shop',
permanent: true,
},
{
source: '/projectodyssey_season2',
destination: '/collections/6503138',
permanent: true,
},
{
source: '/creators-program',
destination: '/creator-program',
permanent: true,
},
{
source: '/research/rater',
destination: '/games/knights-of-new-order',
permanent: true,
},
{
// Reserved-name redirect: the legacy 'civitai' user account moved to
// 'CivitaiOfficial'. Handled here (not in middleware) so it runs at
// framework/edge level before any middleware, with no JS execution
// per request.
source: '/user/civitai',
destination: '/user/CivitaiOfficial',
permanent: true,
},
];
},
output: 'standalone',
})
);