Files
civitai__civitai/next.config.mjs
T
manuelurenah f108d51493 fix(prisma): externalize @prisma/client so $queryRaw sees one Sql class
`Prisma` imported from `@prisma/client` was bundled into the app layer while
`dbRead`/`dbWrite` reached a second copy through the transpiled
`@civitai/db-schema`. `$queryRaw` identifies its tagged-template argument with
`instanceof Sql`, so a `Prisma.join()` built by the other copy failed that check
and was bound as a plain value, producing:

    42883 operator does not exist: integer = jsonb

This 500'd every `homeBlock.getHomeBlock` on the homepage feed, and was latent
for the ~20 other server modules that use `Prisma.join`. `require.resolve`
reports a single physical path from every workspace dir, so the duplication is
only visible at the bundler layer.

`@prisma/client` is already in Next's built-in serverExternalPackages defaults;
listing it explicitly is what stops `transpilePackages` from pulling it back in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 13:38:24 -04:00

436 lines
18 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 shouldOptimizeImports = (isDev && analyze) || isProd;
const withBundleAnalyzer = bundlAnalyzer({
enabled: analyze,
});
/**
* 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: [
'superjson',
'@civitai/db-schema',
'@civitai/db',
'@civitai/db-queries',
'@civitai/shared',
'@civitai/buzz',
'@civitai/redis',
'@civitai/clickhouse',
'@civitai/axiom',
'@civitai/telemetry',
'@civitai/auth',
'@civitai/notifications',
],
// 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/**/*'],
'/api/v1/content/[[...slug]]': ['./src/static-content/**/*'],
// /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/**/*',
],
},
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.
//
// Build only. It buys nothing in dev — chunk size is irrelevant to a dev server — and
// the walk over dynamic-import paths is paid on a graph that `_app` already makes large.
turbopackServerSideNestedAsyncChunking: isProd,
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 [
{
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',
})
);