feat: sitemap remediation, PWA activation, search refinements, Steam Deck UX

Sitemap:
- Switch from ISR (revalidate=3600) to force-dynamic for per-request generation
- Flatten into single app/sitemap.ts with inlined static entries and DB queries
- NULL-safe syncStatus filter: ne(games.syncStatus, 'failed') OR isNull(games.syncStatus)
- Structured JSON logging for generated URLs and DB errors
- Delete lib/sitemap/* helpers and app/api/revalidate-sitemap route
- Add basic vitest coverage for sitemap exports

PWA & Offline:
- Add @serwist/next service worker (webpack build) with runtime caching
- Cache strategies: stale-while-revalidate for game pages, network-first for listings/API,
  cache-first for Steam CDN images
- Offline fallback page (public/offline.html)
- Manifest icons: 192px maskable + 512px any
- Viewport meta with viewport-fit=cover, user-scalable=no
- Apple mobile web app meta tags

Search / Filter Refinements:
- Multi-genre OR support in listing API (comma-separated genres)
- Device-scoped FPS filter (min/max FPS constrained to selected device)
- Client-side URL state sync via router.replace for shareable filtered views
- Initialize filter state from URL params on mount
- Auto-collapse filter panel on saved-filter load
- Fix multi-genre saved filter parsing (comma-separated)

Steam Deck / Touch / Gamepad UX:
- WCAG 2.1 AA touch targets (44x44px) on all filter controls
- .gamepad-focus CSS focus ring for controller navigation
- useGamepadNavigation hook: D-pad/left-stick roving tabindex, A/B/X/Y actions
- Integrate gamepad hook into games page (X=search, Y=toggle filters)

Chore:
- Bump version to 2026.0.97
This commit is contained in:
2026-05-05 16:20:28 +08:00
parent 9be64359be
commit dcf6066b2b
25 changed files with 1396 additions and 489 deletions
-54
View File
@@ -1,54 +0,0 @@
import { revalidatePath } from "next/cache"
import { NextRequest, NextResponse } from "next/server"
/**
* On-demand sitemap revalidation webhook.
*
* What this endpoint does:
* Revalidates the `/sitemap.xml` path so Next.js regenerates the sitemap
* at the edge instead of waiting for the next ISR interval.
*
* When to call it:
* - After adding, updating, or removing games
* - After adding, updating, or removing hardware
* - After any bulk import or migration that affects public-facing URLs
*
* How to call it:
* ```bash
* curl -X POST https://<your-domain>/api/revalidate-sitemap \
* -H "Authorization: Bearer $REVALIDATE_SECRET"
* ```
*
* @see https://nextjs.org/docs/app/building-your-application/caching#on-demand-revalidation
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
const authHeader = request.headers.get("authorization")
const secret = process.env.REVALIDATE_SECRET
if (!secret) {
return NextResponse.json(
{ error: "Revalidation not configured. Set REVALIDATE_SECRET env var." },
{ status: 503 },
)
}
const token = authHeader?.replace("Bearer ", "")
if (token !== secret) {
return NextResponse.json({ error: "Invalid secret" }, { status: 401 })
}
try {
revalidatePath("/sitemap.xml")
return NextResponse.json({
revalidated: true,
path: "/sitemap.xml",
now: Date.now(),
})
} catch (error) {
console.error("[Revalidate Sitemap] Failed:", error)
return NextResponse.json(
{ error: "Revalidation failed" },
{ status: 500 },
)
}
}
+91 -20
View File
@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { motion } from "motion/react"
@@ -11,6 +12,7 @@ import {
XIcon,
Loader2Icon,
} from "lucide-react"
import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
@@ -90,9 +92,54 @@ export function GamesPageClient({
const [hasMultiplayer, setHasMultiplayer] = useState<boolean>(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const pageRef = useRef<HTMLElement>(null)
const { isGamepadActive } = useGamepadNavigation(pageRef, {
onXButton: () => {
// Navigate to search page
window.location.href = "/search"
},
onYButton: () => {
// Toggle filter panel
setShowFilters((prev) => !prev)
},
})
const router = useRouter()
const hasMore = games.length < total
// ── Initialize filters from URL params on mount ────────────────
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get("search")) setSearch(params.get("search")!)
if (params.get("device")) setSelectedDevice(params.get("device")!)
if (params.get("genre")) {
const genres = params.get("genre")!.split(",").filter(Boolean)
setSelectedGenres(genres)
}
if (params.get("minFps")) setMinFps(params.get("minFps")!)
if (params.get("maxFps")) setMaxFps(params.get("maxFps")!)
if (params.get("fsrSupport") === "true") setFsrSupport(true)
if (params.get("protonNative") && params.get("protonNative") !== "any")
setProtonNative(params.get("protonNative")!)
if (params.get("antiCheatStatus") && params.get("antiCheatStatus") !== "any")
setAntiCheatStatus(params.get("antiCheatStatus")!)
if (params.get("playabilityStatus")) setPlayabilityStatus(params.get("playabilityStatus")!)
if (params.get("steamReviewScore")) setSteamReviewMin(params.get("steamReviewScore")!)
if (params.get("isFree") === "true") setIsFree(true)
if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true)
if (params.get("sort")) {
const s = params.get("sort")!
if (["recent", "name", "benchmarks", "performance", "popularity", "release_date", "steam_reviews"].includes(s)) {
setSort(s as SortOption)
}
}
if (params.get("order")) setSortDirection(params.get("order") as SortDirection)
}, [])
/* eslint-enable react-hooks/set-state-in-effect */
const buildUrl = useCallback(
(offset: number) => {
const params = new URLSearchParams()
@@ -102,7 +149,7 @@ export function GamesPageClient({
params.set("order", sortDirection)
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length === 1) params.set("genre", selectedGenres[0])
if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
@@ -141,6 +188,29 @@ export function GamesPageClient({
}
}, [loading, hasMore, games.length, buildUrl])
// ── Sync filter state to URL (replace, not push) ───────────────
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
if (sort !== "recent") params.set("sort", sort)
if (sortDirection !== "desc") params.set("order", sortDirection)
const qs = params.toString()
const url = qs ? `/games?${qs}` : "/games"
router.replace(url, { scroll: false })
}, [search, selectedDevice, selectedGenres, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer, sort, sortDirection, router])
// Full reload when filters/sort change
useEffect(() => {
let cancelled = false
@@ -199,7 +269,7 @@ export function GamesPageClient({
}
return (
<section className="w-full flex flex-col gap-8 py-8">
<section ref={pageRef} className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -225,7 +295,7 @@ export function GamesPageClient({
<div className="max-w-7xl mx-auto flex flex-col gap-3">
{/* Search + Sort Row */}
<div className="flex flex-row items-center gap-3">
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text">
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-[44px]">
<SearchIcon className="h-4 w-4 text-text/40 shrink-0" />
<input
type="text"
@@ -256,14 +326,14 @@ export function GamesPageClient({
</select>
<button
onClick={() => setSortDirection(prev => prev === "asc" ? "desc" : "asc")}
className="px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer"
className="px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-[44px]"
title={sortDirection === "asc" ? "Sort ascending" : "Sort descending"}
>
{sortDirection === "asc" ? "↑" : "↓"}
</button>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border ${
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${
showFilters ||
selectedDevice ||
selectedGenres.length > 0 ||
@@ -325,7 +395,7 @@ export function GamesPageClient({
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -339,7 +409,7 @@ export function GamesPageClient({
onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug)
}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -361,7 +431,7 @@ export function GamesPageClient({
<button
key={genre}
onClick={() => toggleGenre(genre)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -382,20 +452,20 @@ export function GamesPageClient({
placeholder="Min FPS"
value={minFps}
onChange={(e) => setMinFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
<input
type="number"
placeholder="Max FPS"
value={maxFps}
onChange={(e) => setMaxFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
</div>
<select
value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="">Any Playability</option>
<option value="great">Plays Great</option>
@@ -411,7 +481,7 @@ export function GamesPageClient({
<select
value={protonNative}
onChange={(e) => setProtonNative(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Runtime</option>
<option value="native">Native</option>
@@ -420,14 +490,14 @@ export function GamesPageClient({
<select
value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Anti-Cheat</option>
<option value="supported">AC Supported</option>
<option value="unsupported">AC Unsupported</option>
<option value="unknown">AC Unknown</option>
</select>
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={fsrSupport}
@@ -441,7 +511,7 @@ export function GamesPageClient({
{/* Other Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Other</h4>
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={isFree}
@@ -450,7 +520,7 @@ export function GamesPageClient({
/>
Free to Play
</label>
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={hasMultiplayer}
@@ -494,11 +564,12 @@ export function GamesPageClient({
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
if (filters.genre) setSelectedGenres([filters.genre as string])
if (filters.genre) setSelectedGenres((filters.genre as string).split(",").filter(Boolean))
else setSelectedGenres([])
if (filters.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption)
setShowFilters(false)
}}
/>
@@ -528,7 +599,7 @@ export function GamesPageClient({
setIsFree(false)
setHasMultiplayer(false)
}}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start"
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-[44px] py-2 flex items-center"
>
Clear all filters
</button>
@@ -544,7 +615,7 @@ export function GamesPageClient({
<p className="text-red-400 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer"
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] py-2 flex items-center"
>
Try again
</button>
@@ -611,7 +682,7 @@ export function GamesPageClient({
setIsFree(false)
setHasMultiplayer(false)
}}
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-[44px]"
>
Clear filters
</button>
+14
View File
@@ -21,4 +21,18 @@
/* Fonts */
--font-sans: var(--font-lexend);
}
/* Touch targets — WCAG 2.1 AA: minimum 44×44px */
.touch-target {
min-height: 44px;
min-width: 44px;
}
/* Gamepad focus ring — visible only when gamepad navigation is active */
.gamepad-focus :focus,
.gamepad-focus:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 4px;
}
+14 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next"
import type { Metadata, Viewport } from "next"
import { Lexend } from "next/font/google"
import "./globals.css"
import Script from "next/script"
@@ -10,6 +10,15 @@ const font = Lexend({
subsets: ["latin"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
maximumScale: 1,
userScalable: false,
themeColor: "#eb3779",
}
export const metadata: Metadata = {
metadataBase: new URL("https://deckyvault.xyz"),
title: {
@@ -82,6 +91,10 @@ export default function RootLayout({
lang='en'
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
>
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body className='min-h-full w-dvw flex flex-col relative'>
<Suspense>
<Navbar />
+12
View File
@@ -11,6 +11,18 @@ export default function manifest(): MetadataRoute.Manifest {
background_color: "#100b14",
theme_color: "#eb3779",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icon.png",
sizes: "any",
+108 -24
View File
@@ -1,30 +1,114 @@
import type { MetadataRoute } from "next"
import { buildStaticEntries } from "@/lib/sitemap/build-static-entries"
import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"
import { or, ne, isNull } from "drizzle-orm"
/**
* ISR-style revalidation window in seconds.
*
* Next.js caches the sitemap and regenerates it at most once per
* revalidation window. Between regenerations, the cached response
* is served instantly from memory (and from Cloudflare's edge via
* the `s-maxage` directive).
*
* On-demand purging is handled by `/api/revalidate-sitemap` which
* calls `revalidatePath("/sitemap.xml")`.
*/
export const revalidate = 3600
export const dynamic = "force-dynamic"
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
function buildStaticEntries(): MetadataRoute.Sitemap {
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 1,
},
{
url: `${BASE_URL}/games`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: 0.8,
},
{
url: `${BASE_URL}/devices`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.6,
},
{
url: `${BASE_URL}/updates`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.5,
},
{
url: `${BASE_URL}/contact`,
lastModified: new Date(),
changeFrequency: "yearly" as const,
priority: 0.3,
},
]
}
/**
* Generates the sitemap for deckyvault.xyz.
*
* Combines static pages with dynamic game and device entries from
* the database. The result is cached by Next.js with ISR semantics
* so crawlers always get a fast response even during cold starts
* (the cache is persisted to disk in self-hosted Docker setups).
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticEntries = buildStaticEntries()
const { gameEntries, deviceEntries } = await fetchDynamicEntries()
return [...staticEntries, ...gameEntries, ...deviceEntries]
try {
const [gameRows, deviceRows] = await Promise.all([
db
.select({
id: games.id,
updatedAt: games.updatedAt,
capsuleImage: games.capsuleImage,
})
.from(games)
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
db
.select({
slug: hardware.slug,
createdAt: hardware.createdAt,
})
.from(hardware),
])
const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => {
const image =
row.capsuleImage &&
typeof row.capsuleImage === "string" &&
row.capsuleImage.trim().startsWith("https://") &&
row.capsuleImage.trim().length <= 2048
? row.capsuleImage.trim()
: undefined
return {
url: `${BASE_URL}/game/${row.id}`,
lastModified: row.updatedAt ?? undefined,
changeFrequency: "weekly" as const,
priority: 0.7,
...(image ? { images: [image] } : {}),
}
})
const deviceEntries: MetadataRoute.Sitemap = deviceRows.map((row) => ({
url: `${BASE_URL}/devices/${row.slug}`,
lastModified: row.createdAt ?? undefined,
changeFrequency: "monthly" as const,
priority: 0.5,
}))
console.info(
JSON.stringify({
event: "sitemap_generated",
gameCount: gameEntries.length,
deviceCount: deviceEntries.length,
staticCount: staticEntries.length,
totalUrls: staticEntries.length + gameEntries.length + deviceEntries.length,
generatedAt: new Date().toISOString(),
}),
)
return [...staticEntries, ...gameEntries, ...deviceEntries]
} catch (err) {
console.error(
JSON.stringify({
event: "sitemap_db_error",
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
generatedAt: new Date().toISOString(),
}),
)
return staticEntries
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { PrecacheEntry } from "@serwist/precaching";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "@serwist/strategies";
import { ExpirationPlugin } from "@serwist/expiration";
import { Serwist } from "serwist";
declare global {
interface Window {
__SW_MANIFEST: (string | PrecacheEntry)[];
}
}
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [
// Game detail pages: stale-while-revalidate, 24h max age
{
matcher: /\/game\/[\w-]+$/,
handler: new StaleWhileRevalidate({
cacheName: "game-pages",
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
},
// Game listing page: network-first (filters matter)
{
matcher: /\/games(\?.*)?$/,
handler: new NetworkFirst({
cacheName: "games-listing",
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 5 * 60 }),
],
}),
},
// Steam CDN images: cache-first, 30 days
{
matcher: /^https:\/\/cdn\.akamai\.steamstatic\.com\//,
handler: new CacheFirst({
cacheName: "steam-images",
plugins: [
new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
}),
},
// SteamGridDB images: cache-first, 30 days
{
matcher: /^https:\/\/cdn\d?\.steamgriddb\.com\//,
handler: new CacheFirst({
cacheName: "steamgrid-images",
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
}),
},
// API responses: network-first (live data critical)
{
matcher: /\/api\//,
handler: new NetworkFirst({
cacheName: "api-responses",
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 5 * 60 }),
],
}),
},
// Navigation fallback: offline.html for uncached pages
{
matcher: ({ request }) => request.mode === "navigate",
handler: new NetworkFirst({
cacheName: "navigation",
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
},
],
});
serwist.addEventListeners();