Files
HealthLog/next.config.ts
T
Marc-André Bombeck 63a3e354c8 perf(boot): load route modules on first request, not all at start
Next preloads every route and page module right after the server
starts, about 670 here. The load runs on the event loop, so every
container start showed one stall of several seconds, the first
requests waited behind it, and the heap stood at about 430 MB before
the first sign-in, most of it code for routes an instance rarely
serves. With preloadEntriesOnStart off, each module loads on its
route's first request. The same image started locally at 185 MB of
heap instead of 428, with no stall, and a route's first request paid
30 to 60 ms once.
2026-10-20 20:46:34 +02:00

362 lines
16 KiB
TypeScript

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/<locale>.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,
// v1.4.38.4 — expose the package.json version to the client bundle
// so the `<VersionPoller>` 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/<module>`. 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,
},
{
source: "/settings/mood",
destination: "/settings/layout/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",
// Document AI local text extraction runs `pdf-parse` (→ pdfjs-dist)
// server-side in the content-index job — the provider-free fallback that
// keeps content search working with no AI configured. Both are pure JS +
// wasm; there is NO native `.node` on the text-layer path (`@napi-rs/canvas`
// is only pulled for image rendering, which `getText()` never invokes).
// The standalone tracer can miss pdfjs-dist's wasm decoders (they resolve
// via `new URL(..., import.meta.url)`), so pin both packages into the
// runtime image explicitly. pnpm keeps pdfjs-dist unhoisted under `.pnpm`.
"./node_modules/.pnpm/pdf-parse@*/node_modules/pdf-parse/**",
"./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 AND the two musl prebuilts (the Alpine runtime image is
// amd64 + arm64) into the standalone image explicitly. pnpm keeps them
// unhoisted under `.pnpm`. No cairo/pango apk is needed — the prebuilt
// `.node` is self-contained.
"./node_modules/.pnpm/@napi-rs+canvas@*/node_modules/@napi-rs/canvas/**",
"./node_modules/.pnpm/@napi-rs+canvas-linux-x64-musl@*/node_modules/@napi-rs/canvas-linux-x64-musl/**",
"./node_modules/.pnpm/@napi-rs+canvas-linux-arm64-musl@*/node_modules/@napi-rs/canvas-linux-arm64-musl/**",
],
},
// 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);