import { createHash } from "node:crypto"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { NextConfig } from "next"; import bundleAnalyzer from "@next/bundle-analyzer"; import { version as PKG_VERSION } from "./package.json"; /** * The message catalogs' content hash, the cache key of the boot script * (`/i18n/.js?v=…`, `src/app/layout.tsx`). The service worker serves * that URL cache-first, so a key that does not move with the catalog keeps * an old catalog alive: keyed by the package version, every build of one * release (a beta, a redeploy) shared one URL, and a key added since the * first of them read as its raw name. */ function catalogVersion(): string { const dir = join(process.cwd(), "messages"); const hash = createHash("sha256"); for (const file of readdirSync(dir) .filter((f) => f.endsWith(".json")) .sort()) { hash.update(file).update(readFileSync(join(dir, file))); } return hash.digest("hex").slice(0, 16); } const nextConfig: NextConfig = { output: "standalone", // `next build` type-checks the whole tsconfig program since 16.3, which // reaches the unit tests under `src/**/__tests__` and their fixtures under // `tests/`. The image build context leaves `tests/` and `e2e/` out on // purpose, so the build reads a tsconfig without them; `pnpm typecheck` // still covers the full program. typescript: { tsconfigPath: "tsconfig.build.json" }, poweredByHeader: false, // The image optimizer is off. Nothing renders through `next/image`: logos, // icons and avatars are plain `` elements served from `public/` or // from an authenticated route. With the optimizer on, `/_next/image` still // fetched, decoded and re-encoded whatever local path a request named, // which is surface this app has no use for and where the framework has // shipped critical advisories. Off, Next answers that route with 404 // before it reads a single parameter. `next-image-optimizer-guard.test.ts` // holds this: it fails when `next/image` is imported while the optimizer // is off, and when the optimizer comes back with SVG input allowed. images: { unoptimized: true }, // v1.4.38.4 — expose the package.json version to the client bundle // so the `` can compare the shell-baked version // against the live `/api/version` response and trigger a self-heal // (SW unregister + cache wipe + hard reload) when the server moves // ahead of the running shell after a deploy. Without this the user // had to discover "pull-to-refresh" themselves after every release. env: { NEXT_PUBLIC_APP_VERSION: PKG_VERSION, NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(), }, // v1.4.33 — strip `console.*` calls from the production bundle // (keep `console.error` + `console.warn` so the GlitchTip reporter // and prod-side debug rails still surface). The Lighthouse audit // flagged ~211 KiB of bundled JS as "unminified" — Turbopack already // mangles + minifies the chunks, but the in-tree `console.log` // breadcrumbs from the chart wiring + Coach SSE handlers carried // hundreds of preserved string literals through to the client. The // SWC compiler drops the calls + their literal-only arguments // entirely in production. compiler: { removeConsole: process.env.NODE_ENV === "production" ? { exclude: ["error", "warn"] } : false, }, // v1.4.33 — bfcache hygiene. `Permissions-Policy: unload=()` // tells the browser the page does not need the `unload` event, // which Chromium uses as a hint to admit the page to the // back/forward cache on navigation away. Pair with the absence of // any `beforeunload` / `unload` listener in our own code so the // bfcache restore path stays clear. The other CSP-style security // headers already live on the response via the standalone runtime; // we add only the bfcache hint here. // // v1.4.34 — second rule layers the bfcache-friendly // `Cache-Control` directive onto every authenticated HTML page // response (the source negative-lookahead excludes `/api/*` and // `/_next/*` so static assets keep their immutable caching and API // routes keep their explicit headers). The framework default for // pages that read cookies is `no-store, must-revalidate`, which // Chromium counts as a hard bfcache breaker. `private, max-age=0, // must-revalidate` keeps shared caches out (proxies, CDNs cannot // store personal data), still forces revalidation on every // navigation so session swaps detect on the wire, and is // bfcache-eligible — back/forward navigation restores the page // from memory instead of paying a full reload. See // `src/lib/http/cache-headers.ts` for the typed constant reused by // route handlers that opt into the same posture. // v1.8.0 — the routed Insights sub-pages migrated from German to // English slugs (`/insights/blutdruck` → `/insights/blood-pressure`, // …). Every legacy German URL 301-redirects to its English target so // bookmarks, the PWA's cached navigation, and any external link keep // resolving. The redirect set is exhaustive and matches the rename // table in `docs/adr/0001-insights-naming-convention.md`; the slug // registry itself lives in `src/lib/insights/sub-page-metric.ts`. // `bmi`, `hrv`, and `workouts` were already English and need no entry. async redirects() { const insightsSlugRenames: Array<[string, string]> = [ ["blutdruck", "blood-pressure"], ["puls", "pulse"], ["sauerstoff", "oxygen"], ["koerpertemperatur", "body-temperature"], ["atemfrequenz", "respiratory-rate"], ["gewicht", "weight"], ["koerperwasser", "body-water"], ["knochenmasse", "bone-mass"], ["fettfreie-masse", "fat-free-mass"], ["fettmasse", "fat-mass"], ["muskelmasse", "muscle-mass"], ["viszeralfett", "visceral-fat"], ["magermasse", "lean-body-mass"], ["aktive-energie", "active-energy"], ["stockwerke", "flights-climbed"], ["gehstrecke", "walking-distance"], ["gangstabilitaet", "walking-steadiness"], ["gehpuls", "walking-heart-rate"], ["gangasymmetrie", "walking-asymmetry"], ["doppelstandphase", "double-support-time"], ["schrittlaenge", "step-length"], ["gehgeschwindigkeit", "walking-speed"], ["schlaf", "sleep"], ["ruhepuls", "resting-pulse"], ["pulswellengeschwindigkeit", "pulse-wave-velocity"], ["gefaessalter", "vascular-age"], ["laermbelastung", "environmental-audio"], ["kopfhoererpegel", "headphone-audio"], ["laermereignisse", "audio-events"], ["tageslicht", "daylight"], ["blutzucker", "blood-glucose"], ["hauttemperatur", "skin-temperature"], ["stimmung", "mood"], ["medikamente", "medications"], ]; return [ ...insightsSlugRenames.flatMap(([from, to]) => [ { source: `/insights/${from}`, destination: `/insights/${to}`, permanent: true, }, // Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`). { source: `/insights/${from}/:path*`, destination: `/insights/${to}/:path*`, permanent: true, }, ]), // v1.18.0 — the Coach moved out of the Insights surface to a // standalone top-level page. The legacy `/insights/coach` URL // 301-redirects to `/coach` so bookmarks, the PWA's cached // navigation, and any pre-update push deep-link keep resolving. { source: "/insights/coach", destination: "/coach", permanent: true, }, // v1.18.1 — Sources is a standalone `/settings/sources` route again // (split back out of the Integrations sub-tabs), so the v1.18.0 redirect // to `/settings/integrations` was dropped. // v1.18.0 — the standalone "Erinnerungen" hub at // `/settings/reminders` was a link-only page that duplicated the // canonical editors. Reminder TYPES now live in Notifications, each // gated on its module. 301-redirect so bookmarks, the PWA's cached // navigation, and the old cross-links keep resolving. { source: "/settings/reminders", destination: "/settings/notifications", permanent: true, }, // v1.25.7 — delivery channels live under Settings → Integrationen (they // are delivery providers, the same family as the connected services). // The channels content sits under the `#channels` anchor there, so the // old URL 301-redirects to it. The per-channel anchors (`#telegram`, // `#ntfy`, …) inside the panel keep resolving. { source: "/settings/channels", destination: "/settings/integrations#channels", permanent: true, }, // v1.25.11 (#148) — "Darstellung" (`/settings/layout`) is a HUB that lists // each module's view/sort/order surface; every module now lives on its own // subpage at `/settings/layout/`. The legacy per-module routes // 301-redirect to the matching SUBPAGE (no longer an in-page anchor) so // bookmarks, the PWA's cached navigation, and the per-module page-header // cogs keep resolving. `dashboard` + `insights` join the redirect set so // all seven modules share one canonical URL shape. { source: "/settings/dashboard", destination: "/settings/layout/dashboard", permanent: true, }, { source: "/settings/insights", destination: "/settings/layout/insights", permanent: true, }, { source: "/settings/medications", destination: "/settings/layout/medications", permanent: true, }, // v1.44 — the mood page was tag management, which lives under // Settings > Modules > Mood now; both older addresses go straight there. { source: "/settings/mood", destination: "/settings/modules/mood", permanent: true, }, { source: "/settings/layout/mood", destination: "/settings/modules/mood", permanent: true, }, { source: "/settings/labs", destination: "/settings/layout/labs", permanent: true, }, { source: "/settings/illness", destination: "/settings/layout/illness", permanent: true, }, { source: "/settings/vorsorge", destination: "/settings/layout/vorsorge", permanent: true, }, // v1.25.7 — clinician share links fold into the Gesundheitsakte section // as a labelled "Sharing" group; the old standalone route 301-redirects // to the anchor there. { source: "/settings/sharing", destination: "/settings/gesundheitsakte#sharing", permanent: true, }, // v1.22.0 — the preventive-care surface moved from the German slug // `/vorsorge` to the English `/checkups` (the feature reads "Checkups" // in every non-German locale now). 301-redirect so bookmarks, the PWA's // cached navigation, and any push deep-link keep resolving. The // persisted internal id stays `vorsorge`; only the URL changes. { source: "/vorsorge", destination: "/checkups", permanent: true, }, { source: "/vorsorge/:path*", destination: "/checkups/:path*", permanent: true, }, ]; }, async headers() { return [ { source: "/:path*", headers: [{ key: "Permissions-Policy", value: "unload=()" }], }, { source: "/((?!api|_next).*)", headers: [ { key: "Cache-Control", value: "private, max-age=0, must-revalidate", }, ], }, ]; }, serverExternalPackages: [ "@prisma/client", "pg-boss", "pg", // Document PDF handling MUST run the real, un-bundled modules. When // Turbopack bundles pdfjs-dist into the server chunks, its runtime // `require('@napi-rs/canvas')` and its NodeCanvasFactory render path break // in the standalone image (a scanned PDF fails to rasterize with a bare // `Error`, so the read falls back to "PDF scanning needs a Claude vision // provider"), while the identical un-bundled module rasterizes the same // document fine. Externalise both so the server loads the real files from // node_modules; the Dockerfile hoists them to the top level so the runtime // `import()` resolves in the standalone tree, and outputFileTracingIncludes // still ships pdfjs's wasm decoders. "pdfjs-dist", "@napi-rs/canvas", ], // v1.4.25 — `src/lib/ai/prompts/safety-contracts.ts` reads its // sibling YAML files at runtime via `__dirname + readFileSync`. The // standalone bundler ships the JS but not the YAML, so the build // crashed during page-data collection with ENOENT once the Turbopack // chunking-error layer was cleared. Telling Next to trace the YAML // files explicitly keeps them in the runtime image alongside the // bundled module. outputFileTracingIncludes: { "*": [ "./src/lib/ai/prompts/safety-contracts.*.yaml", // PDFs are read and rendered, and uploaded images decoded into their // thumbnails, in a child process with a memory ceiling and a time limit // (`src/lib/documents/pdf-isolation.ts`). The child is started from // these files by path, which the tracer cannot follow. "./src/lib/documents/pdf-child.mjs", "./src/lib/documents/pdf-engine.mjs", "./src/lib/documents/image-engine.mjs", // The doctor-report PDF embeds its fonts, read from disk by a path the // tracer cannot follow (`src/lib/doctor-report-pdf/fonts.ts`). "./src/lib/doctor-report-pdf/fonts/**", // Document AI local text extraction and rasterization both run the // app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the // text read is the provider-free fallback that keeps content search // working with no AI configured. The standalone tracer can miss // pdfjs-dist's wasm decoders, standard fonts and CMaps (they are read // from disk at runtime), so pin the package into the runtime image // explicitly. pnpm keeps it unhoisted under `.pnpm`. The glob matches // one copy only: `src/__tests__/single-pdfjs-guard.test.ts` fails when // the lockfile resolves a second version (#1277). "./node_modules/.pnpm/pdfjs-dist@*/node_modules/pdfjs-dist/**", // Document AI rasterization (`src/lib/documents/rasterize-pdf.ts`) renders // a scanned/image-only PDF page to a JPEG via `@napi-rs/canvas` so an // image-only-wire provider (codex/OAuth) can read it — the ambient // auto-read path. `@napi-rs/canvas` loads a native `.node` binary that the // Turbopack NFT tracer resolves dynamically and therefore misses, so pin // the JS loader into the standalone image explicitly. The glibc prebuilt // the Debian runtime image loads is placed by the Dockerfile's canvas // hoist step, which `canvas-binary-hoist-guard.test.ts` pins. No system // cairo/pango is needed: the prebuilt `.node` is self-contained. "./node_modules/.pnpm/@napi-rs+canvas@*/node_modules/@napi-rs/canvas/**", ], }, // v1.4.34 — silence the Turbopack NFT trace warnings emitted // during `next build`. The tracer follows the `MAXMIND_LICENSE_KEY` // env access in `src/lib/geo.ts` back into the config file, then // emits "cannot be traced" warnings for paths it tried to walk // (next.config.ts → mood-entries/bulk route, etc.). The standalone // bundle is controlled by `output: "standalone"` above; this exclude // only narrows trace reporting and has no runtime effect. outputFileTracingExcludes: { "*": ["./next.config.ts"], }, // Next reads the body of every non-GET request the proxy (`src/proxy.ts`) // matches into memory, so both the proxy and the route can read it, and it // does so BEFORE the route runs — before any authentication. Everything past // this ceiling is dropped, not refused. It used to be 512 MB so the Apple // Health importer could receive whole archives (GitHub #281), which let any // anonymous POST make the server hold that much. It is 1 MB now: the routes // whose real bodies are larger are left out of the proxy matcher and read // their own body after authenticating (`src/lib/http/proxy-bypass-routes.ts`), // and every other route's own cap is at or below this. Large exports still // want a matching reverse-proxy body limit (docs/self-hosting/reverse-proxy.md). // // `preloadEntriesOnStart` is off. Next turns it on by default and then // requires every route and page module (about 670 here) right after the // server starts. That load runs on the event loop: a stall of several // seconds in the first minute of every container, the first requests // queued behind it, and a heap of about 430 MB before anyone has signed in, // most of it code for routes a given instance rarely or never serves. Off, // each module loads on its route's first request, which costs that one // request a few tens of milliseconds; the same image measured locally // started at 185 MB of heap instead of 428 and without the stall. experimental: { optimizePackageImports: ["recharts", "lucide-react"], proxyClientMaxBodySize: "1mb", preloadEntriesOnStart: false, }, }; /** * v1.4.28 — opt-in bundle analyzer behind `ANALYZE=1`. * * `pnpm analyze` (defined in `package.json`) sets the env var and * runs `next build`; the analyzer writes static HTML reports to * `.next/analyze/*.html`. The wrapper is a no-op when the env var is * unset so the regular `pnpm build` pipeline pays nothing. */ const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "1", }); export default withBundleAnalyzer(nextConfig);