Files
civitai__civitai/next.config.mjs
T
Zachary Lowden d872ab698b feat(apps): migrate /apps/installed to /apps/activity — widen past the slot flag, default to the feed, Build to the end of the sub-nav (#4699)
* feat(apps): migrate /apps/installed to /apps/activity — widen past the slot flag, default to the feed, move Build to the end of the sub-nav

The page stopped being an installs surface. It now opens on the activity feed,
its Installs tab is gated on the model-slot flag alone, and the page itself is
reachable by anyone holding either runtime flag. The route name follows.

1. ROUTE. src/pages/apps/installed.tsx moves to src/pages/apps/activity.tsx and
   /apps/installed gains a 301 in next.config.mjs, on the precedent of the
   /apps/build consolidation (statusCode: 301, not permanent: true — Next maps
   permanent to 308). The page component is MOVED, not stubbed: a stub whose
   only job is to redirect reads as a live route, and the appsPageWidths fs-walk
   would demand a width classification for a page that never renders. Every
   in-repo link is repointed; the prose is swept.

2. GATE. getServerSideProps now calls the shared resolveActivityPageAccess,
   which calls the shared canAccessAppsActivity = appBlocks || appBlocksPages.
   appBlocks is the model-SLOT flag; someone who has only ever run a full-page
   app (/apps/run/<slug>, gated on appBlocksPages) has generations, scope-gated
   API calls and Buzz spends recorded against them and no slot install at all.
   Refusing them a page called "Activity" was the dishonest half of the rename.
   The page body re-checks with the same predicate, so the two cannot drift.

3. INSTALLS TAB. Narrows to features.appBlocks — the tab's content is slot
   subscriptions and per-model installs, which is exactly what that flag
   governs. This is only NON-VACUOUS because of (2): with the page gate
   unchanged the tab check could never be false. The PANEL is gated with the
   tab, since a Tabs.Panel with no tab still mounts its children.

4. TABS ARE URL-BACKED. Controlled value + onChange writing ?tab= through
   router.replace(..., { shallow: true }); the default tab drops the key rather
   than writing ?tab=activity. Resolution rules live in a pure module
   (appsActivityTabs.ts) so they run in the blocking node tier: an unknown,
   repeated, or prototype-key value falls back, and so does ?tab=subscriptions
   for a viewer without the slot flag — handing that to Tabs.value would render
   a bar with nothing active over an empty panel.

5. SUB-NAV ORDER. Marketplace → Activity → Invites → Revenue → Build → Review.
   Build was first on the argument that it is "the front door for someone who
   has not built anything yet"; it is the narrowest-audience row in the table
   (canAccessAppsBuild), so leading with it put the smallest cohort's
   destination in the position that reads as what the bar is for. The stale
   comment is rewritten rather than moved. The chrome's two /apps/installed
   items are repointed and the platform-nav one relabelled to "App activity".

6. HEADER CTA. The AppsPageLayout "Browse marketplace" button is gone —
   Marketplace is a sub-nav tab. The three in-empty-state anchors stay: an empty
   panel with no way forward is a dead end.

7. WHEN COLUMN. Relative, via the existing DaysFromNow, with the absolute stamp
   still in the tooltip (and now also in the <time datetime> attribute).

8. APP NAME LINKS TO THE STORE DETAIL, GATED. getListingDetailHref(slug), gated
   on hasAppsStoreAccess read through useOptionalFeatureFlags so an absent
   provider fails CLOSED — AppNameCrumb's solution copied, not re-derived.
   /apps/store-preview/<slug> getServerSideProps-gates on that predicate and
   answers notFound, and appListings.getAppDetail throws NOT_FOUND at scope
   none, so an ungated link is a 404 affordance: the class #4668 shipped and
   #4685 exists to prevent.

9. COLUMN LEDGER RE-MEASURED. The appsWideLayout EXEMPT entry justified itself
   with a measurement taken while When rendered a fixed-width YYYY-MM-DD HH:mm
   stamp, so it no longer described the table. Redone at 768/1200/1440/2560 on
   the same fixture: When gave up ~10% of its width at every viewport (411.09 to
   372.33 at 2560) and the other four columns absorbed it; ROW HEIGHT is 36.19
   at all four widths, before and after. The no-surplus argument is a claim
   about max-content sum versus container width at 768, and a narrower When
   makes that sum smaller — the exemption is unchanged. The before@2560 row
   reproduces the figure already recorded in that comment, which is the control
   on the re-measurement.

TESTS. Seven new files. resolveActivityPageAccess.test.ts and
appsActivityTabs.test.ts run in the blocking node tier; apps-activity-redirect
invokes next.config.mjs's redirects() rather than scanning the file, and carries
the same positive controls as its /apps/build sibling. Two browser files mount
the real panel and the real page. Every new test was measured RED at
origin/main: 8/8 in AppActivityPanel.storeGate, 9/11 in AppActivityPage (the two
that pass there are the positive-direction cases main already satisfies).

The criterion-8 gate is mutation-checked: deleting "!canSeeStore ||" from
ActivityAppName makes exactly the two gate tests fail, on their own assertion —
"an ineligible viewer must not be given an anchor: expected 'A' not to be 'A'" —
while the three eligible-viewer tests stay green, which is what makes the red
attributable to the gate rather than to the feature.

Also re-cut the blanking-ratio bound in appsStoreAccessCallSites from 0.55 to
0.65. Measured on origin/main it sat at 0.5486 against a 0.55 bound — 0.0014 of
headroom, so any added documentation tripped it — and it now carries a negative
control on the real file (the historical apostrophe desync) proving the bound
can still fire.

* fix(apps): repoint the two label-only references the route sweep could not see

Both were found by the FULL component tier, not by the sweep, and that is the
point worth recording: the sweep keyed on the route string "/apps/installed",
so a reference that names only the LABEL was structurally invisible to it.
Sampling one surface is not enumerating the surface.

- AppBlockChromeMobileShell.browser.test.tsx asserts the platform nav folds into
  the phone-width overflow sheet by looking for its label for this route. That
  label went "Installed apps" to "App activity" with the rename; the test's own
  RED-at-origin note is updated to say the label moved rather than the claim.

- subNavRowsMatchPageGates.test.ts named the summary-driven rows in prose as
  "Installed / Invites / Revenue / Review".

* fix(apps): give the Activity tab to the page-app cohort, and stop offering links into a notFound

Audit follow-ups on #4699. The page gate widened to `appBlocks || appBlocksPages`,
but the sub-nav row pointing at it did not — and two affordances inside it now
lead somewhere the newly-admitted cohort cannot go.

1. `blocks.getNavSummary` gains `hasActivity` — true when the viewer has a row in
   EITHER table the activity feed actually walks (`block_buzz_attribution` as the
   SPENDER, `block_scope_invocations`). The scope probe mirrors the feed's own
   WHERE clause, external-OAuth rows excluded, so the flag cannot light a tab over
   an empty page. Two bounded `findFirst({ select: { id } })` in the existing
   Promise.all; the flag-off shape gains the key too.

2. The Activity row keys on `s.hasInstalls || s.hasActivity`. `hasInstalls` is a
   `block_user_subscriptions` row — a SLOT install — and a full-page app is
   stateless by design, so a viewer who only runs page apps had activity and no
   tab. Both terms are documented at the row so neither gets "simplified" away.

3. The two empty-state `/apps` anchors are gated on `hasAppsStoreAccess`.
   `appBlocksPages` is not one of that predicate's disjuncts, so for the cohort
   this PR admits they were links into a `notFound`. The CTA is omitted rather
   than reworded; `pages/apps/activity.tsx` joins the store-gate call-site ledger.

4. `appSlug` is a listing slug or NULL — never an `AppBlock` primary key. Both
   feeds emitted `r.appBlock?.blockId ?? r.appBlockId`, whose fallback is the FK,
   so an unresolved join produced `/apps/store-preview/<pk>`: a guaranteed 404 on
   exactly the rows where the app is least resolvable. Fixed at the source, not
   with a per-row fetch; `ActivityAppName` renders plain text when the slug is
   absent, and the slug badge is dropped with it.

5. `subNavRowsMatchPageGates` pins the Activity row against `/apps/activity`'s own
   resolver, and its header claim that summary-driven rows have "no second copy to
   disagree with" is corrected — WHICH fields a row ORs together is exactly such a
   copy.

6. Two stale comment claims fixed: `AppsSubNav`'s `getNavSummary` `enabled`
   rationale ("all point at pages that themselves 404 without appBlocks" — Activity
   no longer does), and the test header above.

7. The `When` cell drops its Mantine `Tooltip`: `DaysFromNow` already renders a
   `<time title>`, so one hover raised two tooltips formatting the same instant two
   different ways. `live` is now passed so a long-lived tab does not freeze at
   "20m ago".

8. The run-frame permissions drawer passes `linkable={false}`: the shared panel is
   mounted OVER a running full-page app there, and the new app-name link would
   top-level-navigate the user out of it. Recorded on both components.

Red→green measured per change; the two gates are mutation-checked (details in the
PR body). The double-tooltip guard was rewritten after its first, structural form
survived the mutation — a closed Mantine Tooltip is invisible in the DOM.

* fix(apps): single-source the scope-activity predicate, and correct a residual stated wider than it is

Audit round 2. Two findings actionable; the headline one was DEMONSTRATED rather
than argued, and this fixes it the same way.

F2 (guard) — "the probe MIRRORS the feed" was TWO copies of one literal, and
nothing pinned the relationship. `getNavSummary`'s `hasActivity` probe and
`listMyScopeInvocations` each asserted their OWN `where` against a hand-written
literal in their own suite; neither read the other. The audit tightened the
FEED's clause and updated the FEED's own literal — exactly the edit a developer
making that change would make — and BOTH suites stayed green at 65/65 while the
probe silently over-matched. That is the "docstring names a RELATIONSHIP, body
inspects one SIDE" shape.

  Fixed by construction, not by assertion: `GLOBAL_SCOPE_ACTIVITY_OR` is exported
  once from `user-app-surface.service.ts`; the feed spreads it and
  `blocks.router.ts` imports it. A future edit moves both.

  Proof, same mutation: tightening the predicate at its single source now reds
  THREE tests across ALL THREE files — the feed's, the probe's, and the new
  `scopeActivityPredicate.test.ts` — where it previously reddened none.
  The new seam test deliberately does NOT re-spell the clause as a third
  literal; it asserts the SHAPE and that neither call site carries its own copy.

F5 (comment) — the documented residual said a viewer holding `appBlocksPages`
without `appBlocks` "still gets an all-false summary and no tab", and that
closing it means widening the procedure's gate. As written that invites a future
widening of a `blocks.*` flag gate to close a gap that is effectively EMPTY:
that cohort is refused by `/apps/run` itself (which requires BOTH flags) and
cannot install, so all-false is the CORRECT tab set for them, not a deprivation.
Corrected in place.

F1 (perf) — NOT fixed, because it was MEASURED rather than reasoned about, and
the measurement inverts the analysis. The audit flagged `block_buzz_attribution`
having no index leading on `user_id`, correctly: seven live indexes, none on it.
But on prod that table holds ZERO rows / 72 kB, and the probe plans as a Seq
Scan costing 0.00 at 0.021 ms. Meanwhile the probe the audit CLEARED — scope
invocations, which does have `bsi_user_invoked_idx` — is the slower of the two:
863,305 rows / 314 MB, a Bitmap Heap Scan at 6.0 ms with 48 buffers read.
Neither is a merge blocker on a query cached for 60 s per nav. The index is
worth adding when that table grows; it buys nothing today, and adding an index
here is a manual, human-applied migration (CLAUDE.md rule 8), not a code change.

F3, F4 — left, deliberately. F3 (the store-gate ledger is narrower than its new
header) is bounded by whether the browser tier gates a merge, which it does not.
F4 (`live` gives each feed row its own 15 s interval) is real but harmless at
these volumes and pre-existing in shape.

Verified: unit 7,246 passed across 309 files (routers + blocks services + Apps);
component 78 passed on the three touched specs; prettier clean; typecheck 0
errors under src/. The 3 eslint errors on these files are PRE-EXISTING —
measured at branch HEAD via --stdin-filename, same count, same rules.

* test(apps): make the single-sourcing STRUCTURAL — my own guard was walkable

Audit round 3. Every finding was in MY round-2 fix, not in the product, and the
headline one was WALKED rather than argued.

F-A (guard) — the guard I added asserted (a) the string GLOBAL_SCOPE_ACTIVITY_OR
appears somewhere in blocks.router.ts, satisfied by the COMMENT the same commit
added, and (b) a 29-char literal is absent. Neither establishes that the probe's
`where` derives from the constant. The audit walked it in one edit: swap the
spread for a differently-spelled divergent predicate, leave the symbol alive in
that comment, update the router test's own literal — the edit a developer making
that change makes — and 67/67 stayed green while probe and feed diverged,
silently restoring the no-tab defect this PR exists to fix.

  A guard on WORDS is satisfied by re-wording. Replaced with IDENTITY, in the
  suites that can observe the actual Prisma call:
    blocks.router.getNavSummary.test.ts    expect(where.OR).toBe(GLOBAL_SCOPE_ACTIVITY_OR.OR)
    user-app-surface.orchestration.test.ts same toBe on the feed's where.OR
  `toBe` can only pass if the object handed to Prisma IS the exported one. The
  file-scanning half is deleted rather than tightened — a stricter regex is the
  same class of guard.

  Proof, the audit's exact walk re-run: 1 failed where it was 67/67 green,
  on this assertion's own message ("the probe did not pass the SHARED predicate
  to Prisma — it re-spelled its own copy").

F-D (payload) — my `{ OR: Array<Record<string, { not: null }>> }` annotation,
chosen only to dodge a readonly-tuple error, erased a real compile-time check:
`Record<string, …>` does not constrain key names. Measured by the audit, and
re-measured here: `appBlockId` -> `appBlokId` inside the constant now gives
`TS2561 … 'appBlokId' does not exist in type 'BlockScopeInvocationWhereInput'`
at scope-activity-predicate.ts(41,10), where it previously typechecked clean.
Typing the array as Prisma's own input restores it and compiles — the loose form
bought nothing.

F-B + F-C (payload/scaffolding) — putting the constant in
user-app-surface.service.ts dragged that heavy service into blocks.router.ts's
STATIC import graph, which is exactly what that router's five `await import(…)`
sites exist to avoid, and which four router suites' one-key vi.mock factories
would have tripped over on the next nav-summary case. Both close with one move:
the constant now lives in its own leaf module whose ONLY import is type-only.
Pinned by a test that classifies every import in that file.

Verified: unit 7,246 passed / 309 files; the three predicate suites 67/67;
prettier clean; typecheck 0 errors under src/ AND red on the planted typo. The
2 eslint errors on blocks.router.ts are PRE-EXISTING — same count at HEAD via
--stdin-filename; my two new files are 0/0.
2026-09-08 18:33:12 -05:00

591 lines
28 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 [
// ── The `/apps/build` consolidation ────────────────────────────────────────
// `/apps/get-started` (a static marketing page whose every CTA pointed
// off-platform) and `/apps/mine` (the real author table) merged into ONE
// state-aware `/apps/build`: pitch → first-app → workbench. Both page components
// are DELETED, not emptied — a stub whose only job is to redirect is dead code
// that reads as a live route, and `appsPageWidths`' fs-walk would then demand a
// width classification for a route that never renders.
//
// 🔴 ALL THREE ARE `statusCode: 301`, NOT `permanent: true`. Next maps `permanent`
// to **308**, which preserves the request METHOD — correct in general, but these
// are GET-only pages 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.
//
// 🔴 NO CHAIN. `/apps/my-submissions` used to land on `/apps/mine`; it now lands
// on `/apps/build` DIRECTLY. Repointing only the two new rules and leaving that
// one would have made it a two-hop 301→301 — which costs a round trip, and which
// some link-equity and bookmark-rewriting consumers stop following.
{
source: '/apps/my-submissions',
destination: '/apps/build',
statusCode: 301,
},
{
source: '/apps/mine',
destination: '/apps/build',
statusCode: 301,
},
{
source: '/apps/get-started',
destination: '/apps/build',
statusCode: 301,
},
// ── `/apps/installed` → `/apps/activity` ───────────────────────────────────
// The page was renamed when it stopped being an installs surface: it now opens
// on the activity feed, and its gate widened from `appBlocks` (the model-slot
// flag) to `appBlocks || appBlocksPages`, so a viewer whose only app usage is a
// full-page app reaches it. The page component is MOVED (`installed.tsx` →
// `activity.tsx`), not stubbed — same reason as the three rules above.
//
// 🔴 `statusCode: 301`, matching its neighbours and for the same reason: Next
// maps `permanent: true` to 308, and these are GET-only pages whose inbound
// links are bookmarks and search results. The two keys are mutually exclusive
// in Next's schema.
//
// Next preserves the query string across a redirect, so an inbound
// `/apps/installed?tab=permissions` keeps its `?tab=` — which only became a
// meaningful statement when the tabs went URL-backed in the same change.
{
source: '/apps/installed',
destination: '/apps/activity',
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',
})
);