mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
pdf.js decodes an image at the size the file declares, whatever size the page is drawn at, and the server opened documents without an image limit. A small upload declaring a very large image made the web process allocate gigabytes until the container was killed, for every user at once, and the thumbnail job ran the same document again after each restart. An image whose soft mask declares the size does the same past pdf.js' own limit, and nothing bounded how long a text read could take. pdf.js is now opened with maxImageSize (20 million pixels, which keeps one image inside the reader's ceiling and admits a 300 dpi A3 scan), and every read and render runs in a child process, one at a time: its V8 heap is capped, its resident memory is sampled and the child killed past 640 MB, on Linux it volunteers to the out-of-memory killer first, a time limit ends a read that runs on, and it starts with an almost empty environment. The loader and the rendering moved into pdf-engine.mjs, which the child runs with plain Node and which is traced into the standalone image. A document that crosses a bound answers resource-limit. The thumbnail job records it as a final failure, so the backfill does not queue it again.
384 lines
18 KiB
TypeScript
384 lines
18 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,
|
|
// The image optimizer is off. Nothing renders through `next/image`: logos,
|
|
// icons and avatars are plain `<img>` 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 `<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,
|
|
},
|
|
// 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 in a child process with a memory ceiling
|
|
// and a time limit (`src/lib/documents/pdf-isolation.ts`). The child is
|
|
// started from these two files by path, which the tracer cannot follow.
|
|
"./src/lib/documents/pdf-child.mjs",
|
|
"./src/lib/documents/pdf-engine.mjs",
|
|
// 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 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);
|