Please use a larger screen to view this site.
Technical reference for the helldivers.bot infrastructure layer. Audience: project owner and AI assistants.
graph LR
subgraph Startup["Container Startup Order"]
direction TB
M["Dockerfile.migrate"] --> |"1. prisma migrate deploy"| MIG["Run Migrations"]
MIG --> |"2. seed.mjs"| SEED["Seed Historical Data"]
SEED --> |"exit 0"| DONE["Container Exits"]
end
subgraph App["Application Container"]
direction TB
A["Dockerfile"] --> |"npm start"| SERVER["Next.js Server"]
SERVER --> |"instrumentation.js"| WORKER["Worker Thread"]
WORKER --> |"setTimeout loop"| POLL["Poll Official API"]
end
Startup --> |"then"| App
style Startup fill:#1c1917,stroke:#f59e0b,color:#fbbf24
style App fill:#0f1a0f,stroke:#22c55e,color:#4ade80
The project uses two separate Dockerfiles. Migrations and the application server run in separate containers with a defined startup order.
Image: ghcr.io/elfensky/helldiversbot-migrate:staging
Purpose: Runs prisma migrate deploy once and exits. It never stays alive.
Build process:
node:24-alpinetini via apk (init system for zombie process prevention)11.7.0WORKDIR /apppackage.json, package-lock.json, prisma/, and prisma.config.mjspackage.json at build time and install only that version — no full npm cinpx prisma generate to produce the client/sbin/tini --npx prisma migrate deploy && node --experimental-strip-types prisma/seed/seed.mjs — runs migrations first, then seeds historical season data from JSON files in prisma/seed/seasons/. The seed uses upserts and is idempotent (safe to re-run on every deploy). It short-circuits when db.h1_season.count() equals the number of JSON files, so repeat deploys are cheap (set FORCE_SEED=true to override). If no season files exist, the seed exits gracefully.The seasons/*.json files are produced by prisma/seed/fetch-seasons.mjs — a one-shot refresh script developers run on their workstations to pull the latest state from the official HD1 API. Developers then commit the updated files to git. The fetch script never captures the currently-active season (its get_snapshots response is a mid-war partial by definition), so --to defaults to currentSeason - 1 and is clamped to that maximum even if specified higher. Refresh the files periodically via node prisma/seed/fetch-seasons.mjs --force to keep disk data in parity with the API's occasional "closing frame" writes after a season ends. See prisma/seed/readme.md for the full workflow.
The Prisma version extraction uses a shell one-liner:
COPY package.json package-lock.json ./
COPY prisma ./prisma/
COPY prisma.config.mjs ./
RUN PRISMA_VERSION=$(node -p "require('./package.json').devDependencies?.prisma || require('./package.json').dependencies?.prisma") && \
ADAPTER_PG_VERSION=$(node -p "require('./package.json').dependencies?.['@prisma/adapter-pg'] || ''") && \
DOTENV_VERSION=$(node -p "require('./package.json').dependencies?.dotenv || ''") && \
npm install prisma@$PRISMA_VERSION @prisma/client@$PRISMA_VERSION @prisma/adapter-pg@$ADAPTER_PG_VERSION dotenv@$DOTENV_VERSION && \
npx prisma generate
Prisma 7 CLI no longer auto-loads .env files. The prisma.config.mjs file imports dotenv/config to handle local env loading; in Docker, POSTGRES_URL is injected via docker-compose's env_file.
This keeps the migrate image small — it carries only the Prisma CLI, not the entire application dependency tree.
Image: ghcr.io/elfensky/helldiversbot:staging
Purpose: Runs the Next.js standalone server. This container never touches migrations.
Build stages:
| Stage | Base | What it does |
|---|---|---|
base | node:24-alpine | Installs tini and upgrades npm to 11.7.0 |
deps | base | Runs npm ci from lockfile; fails explicitly if lockfile is absent |
builder | base | Copies node_modules from deps, copies source, runs npx prisma generate then npm run build |
runner | base | Copies only .next/standalone, .next/static, and public; runs as non-root user |
Runner stage details:
ARG NODE_ENV=production — overridable at build time; staging CI passes NODE_ENV=stagingnodejs (gid 1001) and user nextjs (uid 1001)--chown=nextjs:nodejsUSER nextjs is set before the entrypointorg.opencontainers.image.source, org.opencontainers.image.licenses, org.opencontainers.image.title, version, description/sbin/tini --node server.js (the Next.js standalone output file)EXPOSE 3000, ENV PORT=3000, ENV HOSTNAME="0.0.0.0"curl -f http://0.0.0.0:3000/api/healthcheck every 30s, timeout 5s, start period 5s, 3 retriesmigrate (helldiversbot-migrate:staging)
env_file: .docker.env
→ runs once and exits
helldiversbot (helldiversbot:staging)
env_file: .docker.env
environment: SKIP_MIGRATIONS=true
ports: 127.0.0.1:58102:3000
restart: unless-stopped
depends_on: migrate (condition: service_completed_successfully)
healthcheck: curl localhost:3000/api/healthcheck every 60s, timeout 10s, 3 retries, 10s start_period
The port binding 127.0.0.1:58102:3000 deliberately limits exposure to the host loopback interface. External traffic must arrive through a reverse proxy (e.g., nginx or Caddy) on the host.
The depends_on: condition: service_completed_successfully ensures the app container does not start until migrations finish and the migrate container exits with code 0.
SKIP_MIGRATIONS=true is passed to the app container environment to signal the initialization code that database setup has already been handled externally.
Running migrations inside the app container creates a race condition when scaling to multiple replicas — each replica would attempt to apply migrations simultaneously. By delegating migrations to a one-shot container that must complete before the app starts, the compose startup sequence is deterministic and safe.
graph LR
subgraph Staging["STAGING PIPELINE"]
PUSH["Push to main"] --> CHANGES["Detect changes"]
CHANGES --> BUILD_APP["Build App Image<br/><small>ghcr.io/.../helldiversbot:staging</small>"]
CHANGES -->|"migration files changed"| BUILD_MIG["Build Migrate Image<br/><small>ghcr.io/.../helldiversbot-migrate:staging</small>"]
BUILD_APP --> CLEANUP["Cleanup untagged images"]
BUILD_MIG --> CLEANUP
end
subgraph Production["PRODUCTION PIPELINE"]
TAG["Push version tag<br/><small>v*.*.* on main</small>"] --> BUILD_PROD["Build App Image<br/><small>:tag, :production, :latest</small>"]
BUILD_PROD --> RELEASE["Create GitHub Release"]
end
style Staging fill:#1c1917,stroke:#f59e0b,color:#fbbf24
style Production fill:#0f1a0f,stroke:#22c55e,color:#4ade80
staging.docker.yml)Trigger: Push to main, or manual workflow_dispatch
Jobs: A changes job detects which files were modified. build-app always runs. build-migrate only runs when migration-related files changed (or on manual workflow_dispatch). A cleanup job runs after both builds complete (or are skipped).
| Job | Dockerfile | Tag pushed | Condition |
|---|---|---|---|
changes | --- | --- | Always runs; outputs migrate boolean via dorny/paths-filter |
build-migrate | Dockerfile.migrate | ghcr.io/elfensky/helldiversbot-migrate:staging | Only when prisma/**, prisma.config.mjs, package.json, package-lock.json, or Dockerfile.migrate changed |
build-app | Dockerfile.app | ghcr.io/elfensky/helldiversbot:staging | Always |
Both build jobs pass NODE_ENV=staging as a build arg.
Registry auth: secrets.GITHUB_TOKEN — the default token with contents: write and packages: write permissions declared at the workflow level.
Cleanup job: Uses snok/container-retention-policy@v3.0.1. Deletes untagged versions of both helldiversbot and helldiversbot-migrate packages that are older than 30 minutes. This keeps GHCR from accumulating dangling layers from every push. Authenticated via secrets.GHCR_CLEANUP_TOKEN — a classic PAT with the delete:packages scope (the action rejects fine-grained tokens, and the default GITHUB_TOKEN can't delete user-account packages).
release.docker.yml)Trigger: Version tags matching the pattern *.*.* (e.g., 1.2.3)
Jobs: Single build job — only the app image is built; no migrate image is produced for production.
Tags pushed:
ghcr.io/elfensky/helldiversbot:{git-tag}
ghcr.io/elfensky/helldiversbot:production
ghcr.io/elfensky/helldiversbot:latest
Version extraction: The workflow reads the version from package.json via jq -r '.version' package.json and injects it into the image via the VERSION ARG (used by the Dockerfile label version="${VERSION}").
Registry auth: secrets.GITHUB_TOKEN — the workflow's permissions: contents: write + packages: write grant it enough access to push images to GHCR and create the GitHub Release, so no personal access token is needed.
GitHub Release: Created by softprops/action-gh-release@v2. The release body is sourced from RELEASE.md at the repository root.
metrics.yml)Trigger: Scheduled (Mondays at 00:00 UTC, Fridays at 06:00 UTC), or manual dispatch.
Job: Generates a PageSpeed Insights badge SVG using lowlighter/metrics@latest targeting https://helldivers.bot. The resulting metrics.plugin.pagespeed.svg is committed back to the repository. Requires secrets.PAGESPEED_TOKEN.
graph TD
REG["register()"] -->|"NEXT_RUNTIME=nodejs"| SENTRY["Sentry.init()"]
REG -->|"NEXT_RUNTIME=nodejs"| INIT["initializeHelldivers1Api()"]
INIT --> ENV["initializeEnvironmentVariables()"]
ENV -->|"POSTGRES_URL missing"| CRASH1["💥 Process crash"]
ENV -->|"All core vars present"| OAS["initializeOpenApiSpec()"]
OAS -->|"Spec invalid"| CRASH2["💥 Process crash"]
OAS -->|"Spec valid"| WORKER["initializeWorker()"]
WORKER -->|"Worker spawned"| POLL["Worker polling loop<br/><small>setTimeout(doWork, interval)</small>"]
WORKER -->|"Spawn failed"| CRASH3["💥 Process crash"]
style REG fill:#1e293b,stroke:#3b82f6,color:#60a5fa
style CRASH1 fill:#2d1b1b,stroke:#ef4444,color:#f87171
style CRASH2 fill:#2d1b1b,stroke:#ef4444,color:#f87171
style CRASH3 fill:#2d1b1b,stroke:#ef4444,color:#f87171
style POLL fill:#0f1a0f,stroke:#22c55e,color:#4ade80
Entry point: src/instrumentation.js — Next.js calls register() automatically on server startup via the instrumentation hook.
register() [src/instrumentation.js]
│
├── NEXT_RUNTIME === 'nodejs'
│ └── import sentry.server.config.js → Sentry.init() for server runtime
│
└── NEXT_RUNTIME === 'nodejs'
└── initializeHelldivers1Api()
│
├── Step 1: initializeEnvironmentVariables() [src/shared/utils/initializeEnv.mjs]
│ ├── checkDatabase() → POSTGRES_URL ← REQUIRED, throws
│ ├── checkUpdates() → UPDATE_KEY, UPDATE_INTERVAL ← REQUIRED, throws (PORT optional)
│ ├── checkAnalytics() → UMAMI_SITE_ID, SENTRY_DSN, SENTRY_AUTH_TOKEN ← OPTIONAL, warns
│ ├── checkAuth() → BETTER_AUTH_SECRET + 5 auth vars ← OPTIONAL, warns (partial = throws)
│ └── Returns { auth: boolean, analytics: boolean }
│
├── Step 2: initializeOpenApiSpec() [src/utils/initialize.openapi.mjs]
│ ├── development: generates public/openapi.json from the OpenAPI registry, validates JSON
│ ├── production: reads existing public/openapi.json, validates it parses as JSON
│ ├── staging: falls through to false (neither branch matches NODE_ENV=staging)
│ └── false → throw (crashes the process)
│
└── Step 3: initializeWorker() [src/shared/utils/initializeWorker.mjs]
├── Resolves worker path:
│ ├── development: path.resolve(process.cwd(), 'public/workers/cron.js')
│ └── production: path.resolve('/app/public/workers/cron.js')
├── new Worker(workerPath)
├── worker.postMessage({ key, interval, port })
├── Attaches message/error/exit handlers
├── Registers SIGINT/SIGTERM handlers that terminate the worker before exit
└── false → throw (crashes the process)
Every initialization step fails hard: any error or falsy return causes a throw new Error(...) inside the register() function. Since Next.js does not catch errors thrown from register(), this crashes the process. There is no graceful degradation. The intent is that Docker's restart: unless-stopped will restart the container, giving the underlying problem (missing env var, bad database, missing OpenAPI spec) a chance to be resolved.
src/instrumentation.js also exports:
export const onRequestError =
process.env.SENTRY_DSN ? Sentry.captureRequestError : () => {};
Next.js calls this hook for errors that occur inside Server Components, middleware, and proxied routes — errors that do not surface through the normal React error boundary. This is the server-side equivalent of global-error.jsx. The DSN-presence gate means localhost reports too when SENTRY_DSN is set in .env.development; environments stay distinguishable via the environment tag.
The project uses the Sentry SDK (@sentry/nextjs) but targets a self-hosted GlitchTip instance rather than Sentry SaaS. GlitchTip is Sentry-protocol-compatible and supports error aggregation and performance traces but not session replay.
| File | Runtime | Role |
|---|---|---|
sentry.server.config.js | Node.js | Sentry.init() called on server startup via instrumentation.js |
src/instrumentation-client.js | Browser | Sentry.init() called when a page loads in the browser |
src/app/api/glitchtip/route.js | Node.js | Client tunnel — proxies Sentry envelopes to GlitchTip, bypassing ad blockers |
{
dsn: process.env.SENTRY_DSN, // server: SENTRY_DSN, client: NEXT_PUBLIC_SENTRY_DSN
environment: // "development" | "staging" | "production"
process.env.NEXT_PUBLIC_DEPLOY_ENV ||
process.env.DEPLOY_ENV ||
process.env.NODE_ENV,
sendDefaultPii: true, // safe on a self-hosted instance
tracesSampleRate: 1.0, // 100% of requests traced
debug: false,
}
Client-only additions: autoSessionTracking: false (GlitchTip doesn't support sessions), tunnel: '/api/glitchtip' (ad blocker bypass).
Server init lives in sentry.server.config.js and is loaded from src/instrumentation.js only when SENTRY_DSN is set — no production gate, so localhost reports too. Client init lives in src/instrumentation-client.js and runs on every page load whenever NEXT_PUBLIC_SENTRY_DSN is set.
The /api/glitchtip route proxies Sentry envelope payloads to GlitchTip's ingest endpoint. This prevents ad blockers from blocking error reports since the SDK sends to the app's own domain. The tunnel extracts the DSN's public key and forwards to https://<host>/api/<project>/envelope/?sentry_key=<key>&sentry_version=7.
Three levels of error isolation:
global-error.jsx — last-resort boundary wrapping the entire React tree. Renders its own <html>/<body> since the root layout is unavailable. Reuses the shared RouteError component.error.jsx — at root (src/app/error.jsx) and archives (src/app/archives/error.jsx). Thin wrappers around RouteError that catch errors within the layout.ComponentErrorBoundary — React class component wrapping Galaxy Map, Regions, Stats, Dashboard, and Timeline. Shows inline "failed to load" + retry button. Layout containers stay outside boundaries to preserve CSS grid/flex structure on error.Each boundary calls the shared reportError(error, { boundary }) helper (src/shared/utils/observability.mjs) so the user-visible failure reaches GlitchTip with the boundary name as context. ComponentErrorBoundary also passes the React componentStack for attribution. The same helper is used at every API-route 5xx path; calling it with a falsy error is a no-op so wiring it next to existing errorResponse(5xx, ...) sites is safe.
The Sentry browser SDK's global window.onerror / unhandledrejection handlers continue to catch uncaught client errors automatically — reportError exists for everything that gets try/catch'd or routed through an error boundary, which the global handler can't see.
src/shared/utils/observability.mjs exposes one function:
export function reportError(error, context = {}) {
if (!error) return;
const { level, ...extra } = context;
Sentry.captureException(error, { level, extra });
}
Call sites pass a route and stage tag for API routes (e.g. { route: '/api/h1/update', stage: 'season' }) or a boundary tag for React error boundaries. The level field maps to Sentry severity — used at update/route.js to mark the closing-pass season-fetch as a warning instead of an error since the code path is explicitly non-fatal.
The tunnel route (/api/glitchtip) is deliberately not wired with reportError; capturing there would self-loop when GlitchTip itself is the failing upstream.
Caught errors flowing through src/shared/utils/tryCatch.mjs — the project's canonical error-handling pattern, used at ~30+ call sites — are also auto-reported with source: 'tryCatch' and level: 'warning'. The explicit reportError(...) calls at 5xx sites still fire at the default error level. Sentry groups by stack-trace fingerprint so duplicate events for the same throw roll up into one issue, while the severity tag keeps caught-and-recovered errors visually distinct from user-visible failures in the inbox.
The service worker at src/sw.js runs in a separate worker context with no DOM, so it can't import the Sentry browser SDK directly. The push and notificationclick handlers are wrapped with try/catch (synchronous) and .catch() (on the async chains inside event.waitUntil); failures postMessage a structured { type: 'sw-error', error: { message, name, stack }, context } envelope to all controlled clients via self.clients.matchAll(). The receiving end lives in src/shared/utils/swErrorBridge.mjs — registered from instrumentation-client.js, it reconstructs the Error and calls reportError(err, { source: 'sw', ...context }). When no clients are open (push received with all tabs closed) the SW falls back to console.error, visible from chrome://serviceworker-internals during triage. Errors are re-thrown after the postMessage so the SW event loop still sees the rejection — original behavior preserved.
public/workers/cronLogic.js POSTs to GLITCHTIP_HEARTBEAT_URL after each successful (response.ok) poll of /api/h1/update. Fire-and-forget — heartbeat fetch failures are swallowed and don't affect the poll loop. GlitchTip's uptime monitor flips red when heartbeats stop, which surfaces: worker thread death, sustained 5xx from the API route, or DB unavailability. The worker reads the URL from process.env (Node worker_threads inherits the parent's env by default), so no message passing is needed.
Umami v3 analytics use a same-origin proxy to bypass ad blockers:
/stats.js → umami.drunik.be/script.js (Next.js rewrite in next.config.mjs)/api/send → /api/umami (Next.js rewrite) → umami.drunik.be/api/send (API route proxy)The proxy forwards X-Forwarded-For so Umami receives the real client IP for its cookieless session hash. No external Umami domain appears in CSP — both script-src and connect-src only reference 'self'.
Env vars: UMAMI_SITE_URL (domain without protocol, e.g., umami.drunik.be), UMAMI_SITE_ID (website UUID).
The withSentryConfig wrapper controls build-time behavior:
export default withSentryConfig(withMDX(nextConfig), {
silent: true,
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
sentryUrl: process.env.SENTRY_URL,
});
Source maps are uploaded to GlitchTip at build time for readable stack traces. The authToken, org, project, and sentryUrl env vars are only used during builds.
Variables are checked at startup by initializeEnvironmentVariables() in src/shared/utils/initializeEnv.mjs. The function uses progressive enhancement: core variables throw on missing (app cannot function), while auth and analytics variables warn and degrade gracefully. Returns { auth: boolean, analytics: boolean } so the startup log shows which features are active.
Validation rules:
BETTER_AUTH_SECRET absent → all auth disabled (warn). BETTER_AUTH_SECRET present but other auth vars missing → throws (partial config is a misconfiguration).SENTRY_DSN set without SENTRY_AUTH_TOKEN → warns about degraded source maps.| Variable | Required | Category | Description |
|---|---|---|---|
POSTGRES_URL | Yes | Database | PostgreSQL connection string |
UPDATE_KEY | Yes | Updates | Bearer token for /api/h1/update — used by the worker to authenticate its polling requests |
UPDATE_INTERVAL | Yes | Updates | Polling interval in seconds (e.g., "20"); passed to the worker thread |
PORT | No | Updates | Server port; defaults to 3000; passed to the worker so it knows which port to poll |
NEXT_PUBLIC_SITE_URL | No | Site | Public site origin (e.g. https://helldivers.bot); inlined at build time so self-hosters can serve under their own domain. Defaults to https://helldivers.bot (src/config/site.mjs). |
UMAMI_SITE_ID | No | Analytics | Umami website tracking ID; if absent, Umami script not loaded and server-side tracking skipped |
UMAMI_SITE_URL | No | Analytics | Umami instance URL; used in server-side fetch calls |
SENTRY_DSN | No | Error tracking | Sentry DSN for server-side error reporting (points to GlitchTip); if absent, error tracking disabled |
NEXT_PUBLIC_SENTRY_DSN | No | Error tracking | Sentry DSN for client-side error reporting (same DSN, exposed to browser) |
SENTRY_AUTH_TOKEN | No | Error tracking | GlitchTip API token for source map uploads; if absent, withSentryConfig build plugin skipped |
SENTRY_URL | No | Error tracking | GlitchTip instance URL; used by withSentryConfig for source map uploads |
SENTRY_ORG | No | Error tracking | GlitchTip organization slug; used for source map uploads |
SENTRY_PROJECT | No | Error tracking | GlitchTip project slug; used for source map uploads |
GLITCHTIP_HEARTBEAT_URL | No | Error tracking | GlitchTip uptime heartbeat endpoint; worker POSTs after each successful update |
NEXT_PUBLIC_DEPLOY_ENV | No | Error tracking | Environment tag attached to Sentry events. Inlined into the client bundle at next build; CI sets staging or production per pipeline. Falls back to DEPLOY_ENV then NODE_ENV if unset. |
DEPLOY_ENV | No | Error tracking | Server-only fallback for the Sentry environment tag when rebuilding to update NEXT_PUBLIC_DEPLOY_ENV isn't an option. |
BETTER_AUTH_SECRET | No (all-or-none) | Auth | BetterAuth session encryption secret; 128+ chars recommended. If absent, all auth features disabled. If present, all other auth vars are required |
BETTER_AUTH_URL | If auth | Auth | Base URL for BetterAuth (e.g., http://localhost:3000; production: https://helldivers.bot) |
AUTH_DISCORD_ID | If auth | Auth | Discord OAuth application client ID |
AUTH_DISCORD_SECRET | If auth | Auth | Discord OAuth application client secret |
AUTH_GITHUB_ID | If auth | Auth | GitHub OAuth application client ID |
AUTH_GITHUB_SECRET | If auth | Auth | GitHub OAuth application client secret |
AUTH_GOOGLE_ID | If auth | Auth | Google OAuth application client ID |
AUTH_GOOGLE_SECRET | If auth | Auth | Google OAuth application client secret |
VAPID_PUBLIC_KEY | No (all-or-none) | Push | Web Push VAPID public key; if absent, push notifications disabled |
VAPID_PRIVATE_KEY | No | Push | Web Push VAPID private key |
VAPID_SUBJECT | No | Push | VAPID contact identifier (mailto: email) |
NEXT_PUBLIC_VAPID_PUBLIC_KEY | No | Push | Client-side VAPID public key (same value as VAPID_PUBLIC_KEY) |
POSTGRES_SSL | No | Database | Set to "false" for local dev without SSL; defaults to SSL enabled |
SKIP_MIGRATIONS | No | Docker | Set to "true" in the app container; has no effect on initialization logic currently but signals intent |
NODE_ENV | No | App | development or production; affects OpenAPI spec behavior and worker path resolution. No longer used as the Sentry environment tag — see NEXT_PUBLIC_DEPLOY_ENV above. |
NEXT_RUNTIME | Internal | Next.js | Set automatically by Next.js; controls which Sentry config and init steps run |
Local development connects directly to the host machine:
postgresql://user:pass@127.0.0.1:5432/dbname
Inside Docker, the app container reaches the host's PostgreSQL via the special Docker DNS name:
postgresql://user:pass@host.docker.internal:5432/dbname
The .docker.env file (not checked into version control) holds the Docker-specific values and is referenced by both services in docker-compose.yml.
The app is an installable Progressive Web App. PWA assets are static files that ship with the build — no build-time generation step is needed.
Source: src/app/site.webmanifest (auto-served by Next.js from the app directory)
Linked in <head> via metadata.manifest in src/app/layout.jsx. Contains start_url, scope, orientation: portrait, and two icons with purpose: "any maskable".
20 portrait PNG images in public/splash/ covering all current iPhone and iPad device sizes. Generated once via npx pwa-asset-generator with solid #282828 background + centered logo. Linked via <link rel="apple-touch-startup-image"> tags with device-specific media queries in layout.jsx.
To regenerate after logo changes:
npx pwa-asset-generator public/images/logo_square.png public/splash \
--splash-only --background "#282828" --padding "30%" --type png
# Then delete landscape images (width > height) since orientation is portrait-only
Static assets served from public/ have cache headers configured in next.config.mjs:
| Asset Type | max-age | immutable |
|---|---|---|
| Favicons | 1 day | Yes |
| Fonts | 1 year | Yes |
| Icons | 7 days | Yes |
| Images | 7 days | Yes |
| SVGs | 7 days | Yes |
| Workers | 1 day | Yes |
The service worker (public/sw.js) has a 1-day max-age, ensuring browsers check for new versions daily. The SW itself handles cache versioning via the CACHE_NAME constant.