Ctrl K
ComunidadGuías…

next-app/next.config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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/<current-season>/<current-year> 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 <img> 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 <img>.
    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,
  },
}));