mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
The report's built-in Helvetica is WinAnsi-only. These fonts cover what the report can carry: ReportSans (Noto Sans plus the arrows and mathematical operators of Noto Sans Math; Latin incl. Extended-A/B, Greek, Cyrillic) in regular, bold and italic, and ReportSansKR, the same plus Hangul from Noto Sans KR, in regular and bold. scripts/build-pdf-fonts.py rebuilds them from the upstream sources. All are SIL OFL 1.1; the licence sits beside the files. The directory is named in outputFileTracingIncludes because the renderer reads the files by a path the tracer cannot follow.
389 lines
18 KiB
TypeScript
389 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",
|
|
// 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 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);
|