diff --git a/client/index.html b/client/index.html
index 41eab608..5ee7fad9 100644
--- a/client/index.html
+++ b/client/index.html
@@ -1,24 +1,39 @@
-
+ content="width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover"
+ />
54Link POS Shell
-
+
+
+
+
-
+
-
+
@@ -27,7 +42,7 @@
+ data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"
+ >
-
diff --git a/client/public/sw.js b/client/public/sw.js
index 1c8aa3df..56532f52 100644
--- a/client/public/sw.js
+++ b/client/public/sw.js
@@ -1,9 +1,10 @@
/**
- * 54Link POS Shell -- Service Worker v4
+ * 54Link POS Shell -- Service Worker v5
* Features: Web Push (failover/fraud/float/settlement), offline shell cache,
- * background sync for offline TX queue, periodic sync for fraud status.
+ * background sync for offline TX queue, periodic sync for fraud status,
+ * version-based cache busting on deployment.
*/
-const CACHE_VERSION = "v5";
+const CACHE_VERSION = "v6";
const SHELL_CACHE = `54link-shell-${CACHE_VERSION}`;
const API_CACHE = `54link-api-${CACHE_VERSION}`;
const DATA_CACHE = `54link-data-${CACHE_VERSION}`;
@@ -31,19 +32,31 @@ self.addEventListener("install", event => {
});
self.addEventListener("activate", event => {
+ const KEEP = new Set([SHELL_CACHE, API_CACHE, DATA_CACHE]);
event.waitUntil(
caches
.keys()
.then(keys =>
Promise.all(
keys
- .filter(
- k =>
- k.startsWith("54link-") && k !== SHELL_CACHE && k !== API_CACHE
- )
- .map(k => caches.delete(k))
+ .filter(k => !KEEP.has(k))
+ .map(k => {
+ console.log(`[SW] Purging stale cache: ${k}`);
+ return caches.delete(k);
+ })
)
)
+ .then(() => {
+ // Notify all open clients to refresh for the new version
+ self.clients.matchAll({ type: "window" }).then(clients => {
+ clients.forEach(client =>
+ client.postMessage({
+ type: "SW_VERSION_CHANGED",
+ version: CACHE_VERSION,
+ })
+ );
+ });
+ })
);
self.clients.claim();
});
diff --git a/client/src/main.tsx b/client/src/main.tsx
index af6bd822..70e466fa 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -59,6 +59,41 @@ if (savedTheme === "light") {
document.documentElement.classList.add("light");
}
+// ── Service Worker: register with cache-bust on version change ─────────────
+if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => {
+ navigator.serviceWorker
+ .register("/sw.js", { scope: "/", updateViaCache: "none" })
+ .then(reg => {
+ // Check for updates every 30 minutes
+ setInterval(() => reg.update(), 30 * 60 * 1000);
+
+ reg.addEventListener("updatefound", () => {
+ const newWorker = reg.installing;
+ if (!newWorker) return;
+ newWorker.addEventListener("statechange", () => {
+ if (
+ newWorker.state === "activated" &&
+ navigator.serviceWorker.controller
+ ) {
+ // New SW activated — prompt user to reload for latest version
+ console.log("[SW] New version available, reloading...");
+ window.location.reload();
+ }
+ });
+ });
+ })
+ .catch(err => console.error("[SW] Registration failed:", err));
+
+ // Listen for version-change messages from the SW
+ navigator.serviceWorker.addEventListener("message", event => {
+ if (event.data?.type === "SW_VERSION_CHANGED") {
+ console.log(`[SW] Updated to ${event.data.version}`);
+ }
+ });
+ });
+}
+
createRoot(document.getElementById("root")!).render(
diff --git a/infra/apisix/routes.yaml b/infra/apisix/routes.yaml
index dc807402..eee2c080 100644
--- a/infra/apisix/routes.yaml
+++ b/infra/apisix/routes.yaml
@@ -3,6 +3,50 @@
# Rate limits: api_general=60/min, api_auth=10/min, api_tx=30/min
routes:
+ # ── Cache-Busting: HTML entry point must never be cached ───────────────────
+ - id: pos-shell-html
+ uri: /
+ upstream_id: pos-shell
+ plugins:
+ response-rewrite:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
+
+ - id: pos-shell-sw
+ uri: /sw.js
+ upstream_id: pos-shell
+ plugins:
+ response-rewrite:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
+
+ - id: pos-shell-manifest
+ uri: /manifest.json
+ upstream_id: pos-shell
+ plugins:
+ response-rewrite:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
+
+ # ── Hashed static assets: long-lived immutable cache ──────────────────────
+ - id: pos-shell-assets
+ uri: /assets/*
+ upstream_id: pos-shell
+ plugins:
+ response-rewrite:
+ headers:
+ set:
+ Cache-Control: "public, max-age=31536000, immutable"
+
# ── Main POS Shell App ─────────────────────────────────────────────────────
- id: pos-shell-api
uri: /api/*
diff --git a/server/_core/vite.ts b/server/_core/vite.ts
index 7c479711..df50ef1e 100644
--- a/server/_core/vite.ts
+++ b/server/_core/vite.ts
@@ -60,10 +60,31 @@ export function serveStatic(app: Express) {
);
}
- app.use(express.static(distPath));
+ // Hashed assets (JS/CSS) get long-lived cache; everything else is short-lived
+ app.use(
+ express.static(distPath, {
+ maxAge: "1y",
+ immutable: true,
+ setHeaders(res, filePath) {
+ // index.html and non-hashed files must never be cached
+ if (
+ filePath.endsWith(".html") ||
+ filePath.endsWith("manifest.json") ||
+ filePath.endsWith("sw.js")
+ ) {
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.setHeader("Pragma", "no-cache");
+ res.setHeader("Expires", "0");
+ }
+ },
+ })
+ );
- // fall through to index.html if the file doesn't exist
+ // fall through to index.html if the file doesn't exist (SPA routing)
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"));
});
}
diff --git a/server/index.ts b/server/index.ts
index 6c0f2a19..692cd71e 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -17,10 +17,30 @@ async function startServer() {
? path.resolve(__dirname, "public")
: path.resolve(__dirname, "..", "dist", "public");
- app.use(express.static(staticPath));
+ // Hashed assets (JS/CSS) get long-lived cache; HTML/SW/manifest must never be cached
+ app.use(
+ express.static(staticPath, {
+ maxAge: "1y",
+ immutable: true,
+ setHeaders(res, filePath) {
+ if (
+ filePath.endsWith(".html") ||
+ filePath.endsWith("manifest.json") ||
+ filePath.endsWith("sw.js")
+ ) {
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.setHeader("Pragma", "no-cache");
+ res.setHeader("Expires", "0");
+ }
+ },
+ })
+ );
// Handle client-side routing - serve index.html for all routes
app.get("*", (_req, res) => {
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.setHeader("Pragma", "no-cache");
+ res.setHeader("Expires", "0");
res.sendFile(path.join(staticPath, "index.html"));
});