Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 186 additions & 28 deletions app/frontend/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,206 @@
const CACHE_NAME = " RustAcademy-v1";
/**
* RustAcademy Service Worker
*
* Caching strategies:
* - Navigation requests → Network-first, fall back to /offline
* - API requests → Stale-while-revalidate (serve cache, then update)
* - Static assets → Cache-first (long-lived hashed files)
*
* A custom header `x-sw-cache-state` is injected into responses so the client
* can distinguish between fresh network responses and cached ones.
*/

const VERSION = "v2";
const PRECACHE = `rustacademy-precache-${VERSION}`;
const RUNTIME = `rustacademy-runtime-${VERSION}`;
const API_CACHE = `rustacademy-api-${VERSION}`;
const OFFLINE_URL = "/offline";

const ASSETS_TO_CACHE = ["/", "/offline", "/icon.png", "/favicon.ico"];
const PRECACHE_ASSETS = [
"/",
"/offline",
"/icon-192.png",
"/icon-512.png",
"/favicon.ico",
"/manifest.webmanifest",
];

// ---------------------------------------------------------------------------
// Lifecycle: install
// ---------------------------------------------------------------------------
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// It's okay if /offline fails during install, but we should try to cache it
return cache
.addAll(ASSETS_TO_CACHE)
.catch((err) => console.warn("Offline cache failed", err));
}),
caches
.open(PRECACHE)
.then((cache) => cache.addAll(PRECACHE_ASSETS))
.catch((err) => console.warn("Precache failed", err)),
);
self.skipWaiting();
});

// ---------------------------------------------------------------------------
// Lifecycle: activate — prune old caches
// ---------------------------------------------------------------------------
self.addEventListener("activate", (event) => {
const ACTIVE_CACHES = new Set([PRECACHE, RUNTIME, API_CACHE]);
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.filter((name) => !ACTIVE_CACHES.has(name))
.map((name) => caches.delete(name)),
);
}),
),
),
);
self.clients.claim();
});

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
* Clone a response and add an x-sw-cache-state header so pages know whether
* they received a fresh or stale response.
*/
function tagResponse(response, cacheState) {
const headers = new Headers(response.headers);
headers.set("x-sw-cache-state", cacheState);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

// Network-first for page navigations: fresh content when online,
// last-seen copy (or /offline) when the network is down.
async function handleNavigation(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
return cached || caches.match(OFFLINE_URL);
}
}

// Cache-first for static assets. Hashed _next/static files are immutable,
// so serving from cache is always safe.
async function handleAsset(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone());
}
return response;
} catch {
// Offline and not cached — return a real Response so the rejection
// doesn't escape the fetch handler.
return new Response("Offline", {
status: 408,
statusText: "Request Timeout",
headers: { "Content-Type": "text/plain" },
});
}
}

// Stale-while-revalidate for backend API calls.
// Serves cached response immediately and refreshes in the background.
async function handleApiRequest(event, request) {
const cache = await caches.open(API_CACHE);
const cached = await cache.match(request);

const networkFetch = fetch(request)
.then((res) => {
if (res.ok) {
cache.put(request, res.clone()).catch(() => {});
}
return tagResponse(res, "fresh");
})
.catch(() => null);

if (cached) {
// Serve stale immediately; revalidation runs in background.
event.waitUntil(networkFetch);
return tagResponse(cached, "stale");
}

const fresh = await networkFetch;
return (
fresh ??
new Response(JSON.stringify({ error: "Offline" }), {
status: 503,
headers: {
"Content-Type": "application/json",
"x-sw-cache-state": "unavailable",
},
})
);
}

// ---------------------------------------------------------------------------
// Fetch handler
// ---------------------------------------------------------------------------
self.addEventListener("fetch", (event) => {
// Only handle GET requests
if (event.request.method !== "GET") return;

if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_URL);
}),
);
} else {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
}),
);
const { request } = event;
if (request.method !== "GET") return;

const url = new URL(request.url);

// Never cache cross-origin requests — payment data must be live.
if (url.origin !== self.location.origin) return;

if (request.mode === "navigate") {
event.respondWith(handleNavigation(request));
return;
}

// Backend API calls: stale-while-revalidate so the UI stays responsive
// offline while still refreshing data in the background.
if (url.pathname.startsWith("/api/")) {
event.respondWith(handleApiRequest(event, request));
return;
}

const isStaticAsset =
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/_next/image") ||
url.pathname === "/manifest.webmanifest" ||
url.pathname === "/favicon.ico" ||
request.destination === "manifest" ||
request.destination === "style" ||
request.destination === "script" ||
request.destination === "image" ||
request.destination === "font";

if (isStaticAsset) {
event.respondWith(handleAsset(request));
}
});

// ---------------------------------------------------------------------------
// Message handler — clients can request cache stats or trigger skip-waiting
// ---------------------------------------------------------------------------
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting();
}

if (event.data?.type === "GET_CACHE_STATS") {
caches.open(API_CACHE).then(async (cache) => {
const keys = await cache.keys();
event.source?.postMessage({
type: "CACHE_STATS",
payload: { apiCacheEntries: keys.length },
});
});
}
});
35 changes: 35 additions & 0 deletions app/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,16 @@ function DashboardContent() {
const [userBids, setUserBids] = useState<UserBid[]>([]);
const [userListings, setUserListings] = useState<UserListing[]>([]);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [isStaleData, setIsStaleData] = useState(false);

useEffect(() => {
// Check whether the cached API data is stale
const syncTs = localStorage.getItem("RustAcademy.notification-center.syncTs");
if (syncTs) {
const ageMs = Date.now() - Date.parse(syncTs);
setIsStaleData(ageMs > 5 * 60 * 1000);
}

void callApi(() =>
mockFetch({
items: ACTIVITY_ITEMS,
Expand Down Expand Up @@ -282,6 +290,33 @@ function DashboardContent() {
</header>

<div className="mb-8 space-y-3">
{isStaleData && (
<div
role="status"
aria-live="polite"
className="flex items-center gap-3 rounded-2xl border border-amber-400/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-200"
>
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5 flex-shrink-0 text-amber-300"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
<span>
<strong className="font-semibold">
Showing cached data.
</strong>{" "}
Your dashboard may be outdated. Reconnect to fetch the latest
activity.
</span>
</div>
)}
{spotlightMessage ? (
<p className="rounded-2xl border border-indigo-400/20 bg-indigo-500/10 px-4 py-3 text-sm text-indigo-50">
{spotlightMessage}
Expand Down
37 changes: 26 additions & 11 deletions app/frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,49 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Header } from "@/components/Header";
import { NotificationCenterProvider } from "@/components/NotificationCenterProvider";
import { ErrorReportingShell } from "@/components/ErrorReportingShell";
import { PWAHandler } from "@/components/PWAHandler";
import "./globals.css";

const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
"https:// RustAcademy.to";
"https://RustAcademy.to";

export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: " RustAcademy",
template: "%s | RustAcademy",
default: "RustAcademy",
template: "%s | RustAcademy",
},
description: "Privacy-focused payments on Stellar",
applicationName: " RustAcademy",
applicationName: "RustAcademy",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "RustAcademy",
},
keywords: ["Stellar", "payments", "crypto", "XLM", "USDC", "payment link"],
authors: [{ name: "Pulsefy" }],
creator: "Pulsefy",
openGraph: {
type: "website",
siteName: " RustAcademy",
title: " RustAcademy — Privacy-focused payments on Stellar",
siteName: "RustAcademy",
title: "RustAcademy — Privacy-focused payments on Stellar",
description: "Privacy-focused payments on Stellar",
url: siteUrl,
images: [
{
url: "/api/og",
width: 1200,
height: 630,
alt: " RustAcademy — Privacy-focused payments on Stellar",
alt: "RustAcademy — Privacy-focused payments on Stellar",
},
],
},
twitter: {
card: "summary_large_image",
site: "@ RustAcademy",
title: " RustAcademy — Privacy-focused payments on Stellar",
site: "@RustAcademy",
title: "RustAcademy — Privacy-focused payments on Stellar",
description: "Privacy-focused payments on Stellar",
images: ["/api/og"],
},
Expand All @@ -47,6 +53,12 @@ export const metadata: Metadata = {
},
};

export const viewport: Viewport = {
themeColor: "#0a0a0a",
width: "device-width",
initialScale: 1,
};

export default function RootLayout({
children,
}: {
Expand All @@ -72,7 +84,7 @@ export default function RootLayout({
<p>Copyright 2026 RustAcademy Platform. Built by Pulsefy.</p>
<div className="flex gap-8 underline decoration-white/10 underline-offset-4 hover:decoration-white/20">
<a
href="https://github.com/pulsefy/ RustAcademy"
href="https://github.com/pulsefy/RustAcademy"
target="_blank"
rel="noreferrer"
>
Expand All @@ -83,6 +95,9 @@ export default function RootLayout({
</div>
</div>
</footer>
{/* PWAHandler: registers the service worker, tracks online/offline
transitions, and shows the install prompt banner */}
<PWAHandler />
</NotificationCenterProvider>
</body>
</html>
Expand Down
Loading