import type { NextConfig } from "next"; import { writeFileSync } from "fs"; import { resolve } from "path"; // Stable in dev to avoid cache churn on hot-reload; unique per production build. const BUILD_TIME = process.env.NODE_ENV === "development" ? "dev" : String(Date.now()); writeFileSync(resolve(process.cwd(), "public/sw.js"), buildSwContent(BUILD_TIME)); function buildSwContent(version: string): string { return `// famapp service worker — v${version} // Generated at build time. Do not edit directly. const CACHE_VERSION = "${version}"; const SHELL = "famapp-shell-" + CACHE_VERSION; const API = "famapp-api-" + CACHE_VERSION; const OFFLINE = "/offline.html"; // --- install: precache the offline fallback --- self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.add(OFFLINE))); self.skipWaiting(); }); // --- activate: evict old-version caches, claim clients --- self.addEventListener("activate", (e) => { e.waitUntil( caches .keys() .then((keys) => Promise.all( keys .filter( (k) => k.startsWith("famapp-") && !k.endsWith("-" + CACHE_VERSION) ) .map((k) => caches.delete(k)) ) ) .then(() => self.clients.claim()) ); }); // --- fetch: strategy dispatch --- self.addEventListener("fetch", (e) => { const { request } = e; const url = new URL(request.url); // Mutations — network-only; notify clients if the network is unreachable. if (request.method !== "GET") { e.respondWith( fetch(request).catch(() => { self.clients .matchAll({ includeUncontrolled: true }) .then((cs) => cs.forEach((c) => c.postMessage({ type: "OFFLINE_MUTATION" })) ); return new Response(JSON.stringify({ error: "offline" }), { status: 503, headers: { "Content-Type": "application/json" }, }); }) ); return; } // Auth endpoints — always network-only. if (url.pathname.startsWith("/api/auth/")) return; // Next.js immutable static chunks — stale-while-revalidate. if (url.pathname.startsWith("/_next/static/")) { e.respondWith(staleWhileRevalidate(request, SHELL)); return; } // API GETs — network-first with 2-second timeout, fall back to cache. if (url.pathname.startsWith("/api/")) { e.respondWith(networkFirstWithTimeout(request, API, 2000)); return; } // Navigation — stale-while-revalidate; fall back to offline page. if (request.mode === "navigate") { e.respondWith(navigateWithFallback(request)); return; } }); // --- push notifications --- self.addEventListener("push", (e) => { if (!e.data) return; const data = e.data.json(); e.waitUntil( self.registration.showNotification(data.title || "famapp", { body: data.body || "", data: { url: data.url || "/" }, icon: "/icon-192.png", badge: "/icon-192.png", }) ); }); self.addEventListener("notificationclick", (e) => { e.notification.close(); const url = e.notification.data?.url || "/"; e.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) .then((cs) => { const match = cs.find((c) => c.url.includes(self.location.origin)); if (match) { match.focus(); return match.navigate(url); } return self.clients.openWindow(url); }) ); }); // --- strategy helpers --- async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); const update = fetch(request) .then((res) => { if (res.ok) cache.put(request, res.clone()); return res; }) .catch(() => null); // Serve cached immediately; let update happen in the background. return cached ?? (await update); } async function networkFirstWithTimeout(request, cacheName, ms) { const cache = await caches.open(cacheName); const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), ms); try { const res = await fetch(request, { signal: ac.signal }); clearTimeout(timer); if (res.ok) cache.put(request, res.clone()); return res; } catch { clearTimeout(timer); return (await cache.match(request)) ?? new Response(null, { status: 503 }); } } async function navigateWithFallback(request) { const cache = await caches.open(SHELL); const cached = await cache.match(request); const networkFetch = fetch(request) .then((res) => { if (res.ok) cache.put(request, res.clone()); return res; }) .catch(() => null); if (cached) { // Return cached immediately; revalidate in the background. networkFetch; return cached; } const net = await networkFetch; if (net) return net; return ( (await cache.match(OFFLINE)) ?? new Response("Offline", { status: 503 }) ); } `; } const nextConfig: NextConfig = { reactStrictMode: true, output: "standalone", // Keep pino and pino-pretty as native Node.js requires so their worker-thread // transport and stream internals work correctly inside the standalone bundle. serverExternalPackages: [ "pino", "pino-pretty", "drizzle-orm", "postgres", "isomorphic-dompurify", "jsdom", ], }; export default nextConfig;