import type { NextConfig } from "next"; import { withSentryConfig } from "@sentry/nextjs"; import bundleAnalyzer from "@next/bundle-analyzer"; import path from "node:path"; import { IMAGE_REMOTE_PATTERNS } from "./src/lib/images/remotePatterns"; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); // An identifier for this build, inlined into both the client bundle and the // server bundle by the `env` option below. // // The comparison it enables is between two builds, not two processes: a tab // opened before a deploy holds the old value in its own JavaScript, asks // /version.json (served by the new deployment) for the current one, and finds // them different. See components/layout/StaleTabNotice.tsx. // // Next inlines `env` values at BUILD time on both sides, which is the whole // reason this works — if the server read it at runtime instead, `next start` // would re-evaluate this file, produce a third value, and every visitor would // be told to refresh forever. There is a test that pins that behaviour // (lib/buildId.test.ts) because getting it wrong is invisible in development, // where client and server are the same process. // // GIT_SHA when the deploy provides one, so two rebuilds of the same commit are // the same build; the timestamp otherwise, so a local build still differs from // the last one. const BUILD_ID = process.env.GIT_SHA?.trim() || `t${Date.now()}`; const nextConfig: NextConfig = { env: { NEXT_PUBLIC_BUILD_ID: BUILD_ID, }, // Standalone output bundles a minimal Node server + only the deps the // tree actually uses. Reduces the Docker image from ~600MB (full // node_modules) to ~120MB. Required for the multi-stage Dockerfile. output: "standalone", // Pages generated at runtime are cached in memory only, never on disk. // By default Next writes every ISR page it renders (HTML, RSC payload and // segment data) under .next/server/app, and its fetch cache under // .next/cache, in the container's writable layer, with no limit and // nothing ever deleted until the container is recreated. With every title, // its tabs, every character and every person page crawled in three // languages, that grew without bound and would fill the server's disk -- // the disk the database is on -- in well under a day. // // The disk copy only ever bought serving a stale page instead of rendering // one: these routes revalidate every 60 seconds, and the CDN caches them. A // page evicted from memory now renders on its next request. Pages // prerendered at build time are still read from the image (the flag only // stops writes, and the fetch cache's reads). 128 MB of memory cache, from // the default 50: next-app itself uses a small part of its 1 GB limit. cacheMaxMemorySize: 128 * 1024 * 1024, experimental: { isrFlushToDisk: false, }, turbopack: { root: path.resolve(__dirname), }, // Dev-only rewrite to the local go-api. In docker-compose // (local and prod) RSC reads GO_API_INTERNAL_URL directly via the // Docker network, and browser requests for /api/* hit nginx, which // routes to legacy Express:5001. Phase 8.5 will switch nginx /api/ // upstream from `app` to `go_api`. // // The destination is GO_API_INTERNAL_URL when set — the variable // lib/api.ts already reads for server-side fetches — so the browser's // /api calls and the server's reach the same go-api. Unset, it is // :8080 as before, which is what CI's sandbox job publishes. async rewrites() { if (process.env.NODE_ENV === "production") return []; const goApi = (process.env.GO_API_INTERNAL_URL || "http://localhost:8080").replace(/\/+$/, ""); return [ { source: "/api/:path*", destination: `${goApi}/api/:path*`, }, ]; }, // /seasonal -> /seasonal// at the HTTP // layer (308 Permanent Redirect). Computed at build time, so the // destination freezes to whatever season the Docker image was built // in — same lifecycle as sitemap.ts's CURRENT_SEASON_URL, which is // also build-time-pinned. A rebuild is required at each season // boundary; this is fine because every Phase release rebuilds anyway. // // Without this redirect, bare /seasonal triggered an infinite loop: // nginx's `^~ /seasonal/` location emits an implicit add-slash 301 // for the bare form, and next-app's trailingSlash=false 308'd // /seasonal/ back to /seasonal. async redirects() { const now = new Date(); const m = now.getMonth() + 1; const season = m <= 3 ? "winter" : m <= 6 ? "spring" : m <= 9 ? "summer" : "fall"; const year = now.getFullYear(); return [ { source: "/seasonal", destination: `/seasonal/${season}/${year}`, permanent: true, }, { // P11: legacy /season (old SPA path, retired with the Express SPA) // -> the live seasonal grid. Keeps old bookmarks / search-index // URLs out of the dead legacy route. source: "/season", destination: `/seasonal/${season}/${year}`, permanent: true, }, ]; }, // Image Optimization. Until this commit there was no `images` key at all, so // the whole section ran on Next 16 defaults -- which meant `remotePatterns: // []`, i.e. every AniList URL fed to next/image answered 400, which is why // the codebase had exactly one next/image call site and it was a local file. // // ## Why AVIF and not just WebP // // Measured on this repo's own material (flat cel-shaded covers), at equal // byte size, AVIF's banding is 1/3.8 of WebP's and 1/9.4 of JPEG's. The // cause is chroma subsampling, not the codec's entropy coder: sharp emits // AVIF at 4:4:4 by default while WebP and JPEG default to 4:2:0, halving // chroma resolution -- exactly what blurs the coloured line art anime is // made of. WebP's apparent size advantage in a naive comparison is bought // with that chroma. Order matters here: the first entry the browser's Accept // header matches wins, so AVIF leads and WebP is the fallback. // // ## qualities: why 85 and not the default 75 // // THIS IS THE ONE THAT FAILS SILENTLY. Next does not pass `quality` through // to the AVIF encoder; it passes `quality - 20`: // // image-optimizer.js: transformer.avif({ quality: Math.max(quality - 20, 1), effort: 3 }) // // So the default quality={75} produces AVIF q55, which on this material is // below the knee -- gradients (sky, skin) start to band. quality={85} lands // on AVIF q65, which measures SSIM 0.986-0.988 against the source. Since // Next 16 `qualities` is an allowlist, 85 has to be named here or the call // sites asking for it get silently clamped. It is the only entry: a call // site that passes nothing asks for the default 75, and the component moves // it to 85. // // ## The optimizer runs in a small container of its own // // In production every /_next/image request goes to `next-image` // (docker-compose.yml), a copy of this build with its own memory limit and // CPU ceiling, so encoding can never take the CPU that page renders need. // Both of the next two keys exist because that container is small, and // neither default is safe there: // // - maximumResponseBody defaults to 50 MB, and the source image is read // fully into memory before sharp touches it. The largest thing we // actually reference is a ~708 KB cover, so 8 MB is already generous; // 50 MB x a few concurrent requests is how this container gets OOM-killed. // - maximumDiskCacheSize defaults to "50% of free disk measured at startup", // which inside a container reads the HOST disk. This cache sits on a // volume behind nginx's own, larger one, so it only has to cover what // nginx evicts. // // ## Edge caching is NOT free here -- see docs/ section 12.6 // // The optimizer sets `Vary: Accept` unconditionally on every /_next/image // response, and Cloudflare's default for a Vary'd response is to skip the // cache and report BYPASS. A plain "cache /_next/image*" rule therefore does // nothing. It needs the Cache Rules `vary` setting (Cloudflare shipped it // 2026-07-02, API-only, works on the free plan) with `accept` normalized // over image/avif + image/webp. Without that rule every variant of every // image is an origin request. images: { // Where the optimizer may fetch from: AniList's media CDN, our own mirror // of the AniList originals (https://animegoclub.com/img/anilist/), and // YouTube trailer stills. Every entry has `search: ""`, which forbids a // query string -- omitting it implies `**`, which would let a crafted // query turn the optimizer into a proxy for arbitrary upstream responses. // // The list lives in src/lib/images/remotePatterns.ts rather than here, // because FadeImage reads it too: before handing a src to next/image it // asks whether this list covers it, and renders a plain when not. // The two must agree exactly (that file says what disagreeing costs), and // one array read by both is how they do. remotePatterns.test.ts fails if // this key stops being exactly that array. // // `dangerouslyAllowLocalIP` stays at its default, false: the mirror is // reached through the public domain precisely so that the optimizer's // refusal to fetch from private addresses never has to be switched off. // // Deliberately NOT listed: user avatars. They are served from go-api's // volume at /api/avatars/*, and Next resolves a same-origin path through // `fetchInternalImage`, a mocked request handled inside the next-app // process -- which has no /api route in production (rewrites() returns [] // there; nginx is what routes /api to go-api). Optimizing an avatar would // 404 into FallbackImg's onError and silently show the default card. They // stay on plain . remotePatterns: [...IMAGE_REMOTE_PATTERNS], formats: ["image/avif", "image/webp"], // The optimizer answers only these widths and qualities, and each width // is its own encode and its own cache entry for the same source. Next's // defaults are 15 widths; with two qualities and three formats that let // anyone ask for 90 encodes of every image the site shows, and requests // shaped like that are what a scraper sends. These lists are what the // site's own pages ask for, read from the access log: // - 3840 and 2048 are gone because no source is that wide. AniList // banners are 1900px and Next never enlarges, so they re-encoded the // 1920 image under a key of their own. // - 32, 48 and 750 were a few dozen requests a day. The next width up // serves them. // - 85 is the only quality any page uses (FadeImage's default). A call // site that asks for 75 is moved to 85 by the component. deviceSizes: [640, 828, 1080, 1200, 1920], imageSizes: [64, 96, 128, 256, 384], qualities: [85], // AniList serves its own covers with a ~31 day max-age; matching it means // a variant is re-encoded about as often as the upstream file changes. // The effective TTL is max(this, upstream Cache-Control) either way. minimumCacheTTL: 2678400, // 31 days maximumResponseBody: 8_000_000, maximumDiskCacheSize: 500_000_000, }, // Cache the jassub subtitle-engine static binaries (WASM / worker / font) hard. // They live in public/jassub/ (built by `build:jassub` at prebuild) at stable, // non-content-hashed paths, so Next serves them with the public/ default // `Cache-Control: public, max-age=0`. That means the ~2 MB worker WASM is // re-fetched from the origin on every player open; on a slow load it blows // jassub's 10 s init budget and subtitles silently fail to appear (Cf-Cache- // Status was DYNAMIC because CF doesn't edge-cache .wasm by extension either). // A long max-age lets the browser AND the CF edge cache them. No `immutable`: // the paths are not content-hashed, so a 30-day window self-heals after a // jassub upgrade instead of pinning a stale worker for a year. Verified vs the // Next 16 headers() docs — headers are checked before /public, and non-hashed // public assets CAN have Cache-Control overridden (only SHA-hashed // /_next/static immutable assets cannot). async headers() { return [ { source: "/jassub/:path*", headers: [ { key: "Cache-Control", value: "public, max-age=2592000" }, // 30 days ], }, // The self-hosted webfonts (scripts/fonts/vendor-google-fonts.mjs). // Unlike jassub these names carry a content hash, so they can be // cached for good: a changed font is a new file name. { source: "/fonts/:path*", headers: [ { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, ]; }, }; // Sentry wraps the Next config so the webpack plugin can (a) inject the // SDK at build time and (b) upload source maps to Sentry when an auth // token is provided. The plugin is safe to invoke unconditionally: with // no DSN at runtime the SDK no-ops, and with no auth token at build time // the source-map upload step is skipped (see `sourcemaps.disable` below). // // CSP note: we deliberately do NOT set `tunnelRoute`. Sentry events go // direct to ingest.sentry.io, which keeps nginx's strict CSP block in // `nginx/default.conf` untouched. If ad-blocker bypass becomes a need, // add the tunnel route here AND amend the CSP `connect-src` -- not one // without the other. export default withBundleAnalyzer(withSentryConfig(nextConfig, { // Suppress the SDK's verbose build logs locally; CI still sees them so // source-map upload failures stay visible in build logs. silent: !process.env.CI, // Don't widen client source maps to include Next internals + deps -- // keeps source-map upload (and the resulting bundle) smaller. Trade-off // is unreadable stack frames inside node_modules, which is fine. widenClientFileUpload: false, bundleSizeOptimizations: { excludeDebugStatements: true, excludeReplayShadowDom: true, excludeReplayIframe: true, excludeReplayWorker: true, }, webpack: { treeshake: { removeDebugLogging: true, }, automaticVercelMonitors: false, }, // Source-map upload is gated on SENTRY_AUTH_TOKEN. Without the token // the plugin skips upload entirely so dev / unauthenticated CI builds // don't fail. With the token set (prod CI), maps upload normally. sourcemaps: { disable: !process.env.SENTRY_AUTH_TOKEN, }, }));