mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Three defects in the bounded document reader. Image uploads were decoded into their thumbnails in the server process. A header check is all that stood between a decoder and an allocation the file chooses, and the GIF check showed how that fails. The image is now decoded in the same child process PDFs run in, under its memory ceiling and time limit; the header check stays in front of it. The reader's one slot went to whoever asked first, and a waiting item waited without end, so a few heavy PDFs from one account held everyone's lab scans behind them. The slot now goes to a read a person is waiting on before background work, to accounts in turn, to a few waiting items per account, and an item that waits past its lane's deadline is answered busy, which callers treat as retryable. Any child that ended without an answer counted as a document past its limits, so a kernel kill caused by the container's overall memory marked a good PDF as permanently failed. Only a bound the reader measured (the sampled memory, the time limit, the child's heap cap) is final now; any other end is run once more and then answered interrupted, which the thumbnail job retries within its ordinary attempts.
386 lines
18 KiB
TypeScript
386 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, 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",
|
|
// 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);
|