Skip to content
Merged
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
29 changes: 27 additions & 2 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5, viewport-fit=cover" />
<!-- Cache busting: prevent browsers and proxies from caching the HTML shell -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>NDSEP — National Data Sovereignty Enforcement Platform</title>
<meta name="description" content="National Data Sovereignty Enforcement Platform — real-time compliance monitoring, enforcement, and audit management for financial institutions and regulated entities." />
<meta name="theme-color" content="#f4f2f7" media="(prefers-color-scheme: light)" />
Expand Down Expand Up @@ -49,11 +53,32 @@
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<!-- Service Worker Registration -->
<!-- Service Worker Registration + Cache Bust Listener -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function() {});
navigator.serviceWorker.register('/sw.js').then(function(reg) {
// Check for updates every 60 seconds
setInterval(function() { reg.update(); }, 60000);
// When a new SW is installed, tell it to activate immediately
reg.addEventListener('updatefound', function() {
var newWorker = reg.installing;
if (newWorker) {
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'activated') {
// New version deployed — reload to pick up fresh assets
window.location.reload();
}
});
}
});
}).catch(function() {});
// Listen for CACHE_BUSTED message from SW
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data && event.data.type === 'CACHE_BUSTED') {
window.location.reload();
}
});
});
}
</script>
Expand Down
132 changes: 99 additions & 33 deletions client/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,112 @@
/**
* NDSEP Service Worker — Offline-First PWA
* Stale-while-revalidate for static assets, network-first for API.
* NDSEP Service Worker — Offline-First PWA with Cache Busting
*
* Cache strategy:
* - CACHE_VERSION is bumped on every deploy (build injects a hash via Vite).
* - On activate, ALL caches whose name doesn't match the current version are
* deleted, guaranteeing stale JS/CSS from a prior deploy is never served.
* - Navigation requests (HTML) are always network-first so the latest
* index.html (with fresh <script> hashes) is fetched immediately.
* - Hashed static assets use stale-while-revalidate (the hash in the filename
* already guarantees uniqueness).
* - API responses use network-first with a 5-minute cache fallback.
*/

const CACHE_VERSION = "ndsep-v2";
const CACHE_VERSION = "ndsep-v3";
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;

const STATIC_ASSETS = [
"/",
"/manifest.json",
];
const STATIC_ASSETS = ["/", "/offline.html", "/manifest.json"];

const API_CACHE_MAX_AGE = 5 * 60 * 1000; // 5 minutes

// Install — cache app shell
// ── Install — cache app shell, skip waiting to activate immediately ──────────
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});

// Activate — clean old caches
// ── Activate — purge ALL old caches (cache busting on deploy) ────────────────
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith("ndsep-") && k !== STATIC_CACHE && k !== API_CACHE)
.map((k) => caches.delete(k))
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k !== API_CACHE)
.map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
.then(() =>
// Notify all open tabs to reload if their cache version is stale
self.clients.matchAll({ type: "window" }).then((clients) => {
clients.forEach((client) =>
client.postMessage({
type: "CACHE_BUSTED",
version: CACHE_VERSION,
})
);
})
)
)
);
self.clients.claim();
});

// Fetch handler with smart caching strategies
// ── Fetch handler with smart caching strategies ──────────────────────────────
self.addEventListener("fetch", (event) => {
const { request } = event;

// Skip non-GET requests
if (request.method !== "GET") return;

// Skip chrome-extension and other non-http(s) requests
if (!request.url.startsWith("http")) return;

// API requests: network-first with cache fallback
if (request.url.includes("/api/")) {
event.respondWith(
fetch(request)
.then((response) => {
// Cache successful GET API responses
if (response.ok) {
const clone = response.clone();
caches.open(API_CACHE).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request).then((cached) => cached || offlineResponse()))
.catch(() =>
caches
.match(request)
.then((cached) => cached || offlineResponse())
)
);
return;
}

// Static assets: stale-while-revalidate
if (isStaticAsset(request.url)) {
// Navigation (HTML pages): ALWAYS network-first — never serve stale index.html
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() =>
caches
.match("/")
.then(
(cached) => cached || caches.match("/offline.html")
)
.then((fallback) => fallback || offlineResponse())
)
);
return;
}

// Hashed static assets (/assets/*): stale-while-revalidate
// Vite's content-hash filenames ensure uniqueness; cache hit = correct version
if (isHashedAsset(request.url)) {
event.respondWith(
caches.match(request).then((cached) => {
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(STATIC_CACHE).then((cache) => cache.put(request, clone));
caches
.open(STATIC_CACHE)
.then((cache) => cache.put(request, clone));
}
return response;
})
Expand All @@ -83,17 +118,29 @@ self.addEventListener("fetch", (event) => {
return;
}

// Navigation: network-first, fallback to cached index
if (request.mode === "navigate") {
// Other static files: network-first
if (isStaticAsset(request.url)) {
event.respondWith(
fetch(request).catch(() =>
caches.match("/").then((cached) => cached || offlineResponse())
)
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches
.open(STATIC_CACHE)
.then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request))
);
return;
}
});

function isHashedAsset(url) {
return url.includes("/assets/") && /\.[a-zA-Z0-9]{8,}\.(js|css|woff2?)(\?|$)/.test(url);
}

function isStaticAsset(url) {
return /\.(js|css|woff2?|png|jpg|svg|ico|webp|avif)(\?|$)/.test(url);
}
Expand All @@ -105,7 +152,7 @@ function offlineResponse() {
);
}

// Background sync for offline mutations
// ── Background sync for offline mutations ────────────────────────────────────
self.addEventListener("sync", (event) => {
if (event.tag === "ndsep-sync") {
event.waitUntil(syncPendingMutations());
Expand All @@ -116,3 +163,22 @@ async function syncPendingMutations() {
// Placeholder for offline mutation queue sync
// Implemented via client-side IndexedDB queue in production
}

// ── Message handler — allow clients to request cache clear ───────────────────
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
if (event.data && event.data.type === "CLEAR_CACHES") {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => {
if (event.source) {
event.source.postMessage({ type: "CACHES_CLEARED" });
}
})
);
}
});
32 changes: 31 additions & 1 deletion infra/nginx/conf.d/locations.conf
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,46 @@ location /ws {
proxy_send_timeout 3600s;
}

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Hashed assets (Vite content-hash filenames) — immutable, cache forever
location /assets/ {
limit_req zone=global burst=50 nodelay;
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
expires 1y;
add_header Cache-Control "public, immutable";
}

# Other static assets — short cache with revalidation
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
limit_req zone=global burst=50 nodelay;
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
expires 1h;
add_header Cache-Control "public, must-revalidate";
}

# Service worker — never cache
location = /sw.js {
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}

# Manifest — short cache
location = /manifest.json {
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
add_header Cache-Control "no-cache, must-revalidate";
}

# HTML / SPA fallback — never cache (must always fetch fresh index.html)
location / {
limit_req zone=global burst=50 nodelay;
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
}
38 changes: 35 additions & 3 deletions infra/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -157,20 +157,52 @@ http {
proxy_send_timeout 3600s;
}

# ─── Static Assets (long cache) ───────────────────────
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# ─── Hashed Static Assets (immutable, cache forever) ────
# Vite output: /assets/ChunkName-<hash>.js — content-hash
# guarantees uniqueness, so these can be cached indefinitely.
location /assets/ {
limit_req zone=global burst=50 nodelay;
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
expires 1y;
add_header Cache-Control "public, immutable";
}

# ─── All Other Requests ────────────────────────────────
# ─── Other Static Assets (short cache) ───────────────────
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
limit_req zone=global burst=50 nodelay;
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
expires 1h;
add_header Cache-Control "public, must-revalidate";
}

# ─── Service Worker (never cache) ─────────────────────────
location = /sw.js {
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}

# ─── Manifest (short cache) ──────────────────────────────
location = /manifest.json {
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
add_header Cache-Control "no-cache, must-revalidate";
}

# ─── All Other Requests (HTML — never cache) ─────────────
# SPA fallback: index.html must always be fresh so browsers
# fetch the latest <script> tags with updated content hashes.
location / {
limit_req zone=global burst=50 nodelay;
proxy_pass http://ndsep_api;
include /etc/nginx/conf.d/proxy_params.conf;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
}
}
}
33 changes: 31 additions & 2 deletions server/_core/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,39 @@ export function serveStatic(app: Express) {
);
}

app.use(express.static(distPath));
// Hashed assets (JS/CSS/fonts) — immutable, cache forever
app.use(
"/assets",
express.static(path.resolve(distPath, "assets"), {
maxAge: "1y",
immutable: true,
})
);

// fall through to index.html if the file doesn't exist
// All other static files (icons, manifest, etc.) — short cache with revalidation
app.use(
express.static(distPath, {
maxAge: "1h",
setHeaders(res, filePath) {
// Never cache HTML (express.static serves index.html for "/" before SPA fallback)
if (filePath.endsWith(".html")) {
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
// Never cache the service worker — must always be fresh
if (filePath.endsWith("sw.js") || filePath.endsWith("registerSW.js")) {
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
}
},
})
);

// SPA fallback — index.html must never be cached
app.use("*", (_req, res) => {
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.sendFile(path.resolve(distPath, "index.html"));
});
}
Loading