diff --git a/infrastructure/apisix-resources/routes/admin-web-app.yaml b/infrastructure/apisix-resources/routes/admin-web-app.yaml
index 16c0fb531..ddfd11a43 100644
--- a/infrastructure/apisix-resources/routes/admin-web-app.yaml
+++ b/infrastructure/apisix-resources/routes/admin-web-app.yaml
@@ -7,7 +7,55 @@ metadata:
spec:
ingressClassName: apisix
http:
- - name: rule-1
+ # Static hashed assets — immutable cache (1 year)
+ - name: hashed-assets
+ priority: 20
+ match:
+ hosts:
+ - admin.54link-dev.upi.dev
+ paths:
+ - /assets/*
+ backends:
+ - serviceName: admin-web-app
+ servicePort: 80
+ plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "public, max-age=31536000, immutable"
+ - name: cors
+ enable: true
+ config:
+ allow_origins: "*"
+ allow_methods: "*"
+ allow_headers: "*"
+ expose_headers: "*"
+
+ # Service worker — never cache
+ - name: service-worker
+ priority: 15
+ match:
+ hosts:
+ - admin.54link-dev.upi.dev
+ paths:
+ - /sw.js
+ backends:
+ - serviceName: admin-web-app
+ servicePort: 80
+ plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
+
+ # HTML pages and fallback — no cache
+ - name: html-no-cache
priority: 10
match:
hosts:
@@ -18,6 +66,14 @@ spec:
- serviceName: admin-web-app
servicePort: 80
plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
- name: cors
enable: true
config:
diff --git a/infrastructure/apisix-resources/routes/client-web-app.yaml b/infrastructure/apisix-resources/routes/client-web-app.yaml
index 72973dc15..0fcde9ced 100644
--- a/infrastructure/apisix-resources/routes/client-web-app.yaml
+++ b/infrastructure/apisix-resources/routes/client-web-app.yaml
@@ -7,7 +7,55 @@ metadata:
spec:
ingressClassName: apisix
http:
- - name: rule-1
+ # Static hashed assets — immutable cache (1 year)
+ - name: hashed-assets
+ priority: 20
+ match:
+ hosts:
+ - app.54link-dev.upi.dev
+ paths:
+ - /assets/*
+ backends:
+ - serviceName: client-web-app
+ servicePort: 80
+ plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "public, max-age=31536000, immutable"
+ - name: cors
+ enable: true
+ config:
+ allow_origins: "*"
+ allow_methods: "*"
+ allow_headers: "*"
+ expose_headers: "*"
+
+ # Service worker — never cache
+ - name: service-worker
+ priority: 15
+ match:
+ hosts:
+ - app.54link-dev.upi.dev
+ paths:
+ - /sw.js
+ backends:
+ - serviceName: client-web-app
+ servicePort: 80
+ plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
+
+ # HTML pages and fallback — no cache (forces fresh index.html on every deploy)
+ - name: html-no-cache
priority: 10
match:
hosts:
@@ -19,6 +67,14 @@ spec:
servicePort: 80
websocket: true
plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
- name: cors
enable: true
config:
diff --git a/infrastructure/apisix-resources/routes/pup-web-app.yaml b/infrastructure/apisix-resources/routes/pup-web-app.yaml
index 56ad61734..ea816432c 100644
--- a/infrastructure/apisix-resources/routes/pup-web-app.yaml
+++ b/infrastructure/apisix-resources/routes/pup-web-app.yaml
@@ -1,13 +1,61 @@
-# tenant-web-app
+# tenant-web-app (PUP)
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: 54link-tenant-web-app-route
namespace: 54link-dev
spec:
- ingressClassName: apisix
+ ingressClassName: apisix
http:
- - name: rule-1
+ # Static hashed assets — immutable cache (1 year)
+ - name: hashed-assets
+ priority: 20
+ match:
+ hosts:
+ - pup.54link-dev.upi.dev
+ paths:
+ - /assets/*
+ backends:
+ - serviceName: pup-web-app
+ servicePort: 80
+ plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "public, max-age=31536000, immutable"
+ - name: cors
+ enable: true
+ config:
+ allow_origins: "*"
+ allow_methods: "*"
+ allow_headers: "*"
+ expose_headers: "*"
+
+ # Service worker — never cache
+ - name: service-worker
+ priority: 15
+ match:
+ hosts:
+ - pup.54link-dev.upi.dev
+ paths:
+ - /sw.js
+ backends:
+ - serviceName: pup-web-app
+ servicePort: 80
+ plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
+
+ # HTML pages and fallback — no cache
+ - name: html-no-cache
priority: 10
match:
hosts:
@@ -18,10 +66,18 @@ spec:
- serviceName: pup-web-app
servicePort: 80
plugins:
+ - name: response-rewrite
+ enable: true
+ config:
+ headers:
+ set:
+ Cache-Control: "no-cache, no-store, must-revalidate"
+ Pragma: "no-cache"
+ Expires: "0"
- name: cors
enable: true
config:
allow_origins: "*"
allow_methods: "*"
allow_headers: "*"
- expose_headers: "*"
\ No newline at end of file
+ expose_headers: "*"
diff --git a/uis/admin/54link_admin/index.html b/uis/admin/54link_admin/index.html
index 7390baf41..2a2d5394e 100644
--- a/uis/admin/54link_admin/index.html
+++ b/uis/admin/54link_admin/index.html
@@ -4,6 +4,9 @@
+
+
+
admin-portal-ui
diff --git a/uis/admin/54link_admin/nginx.conf b/uis/admin/54link_admin/nginx.conf
index efa20f67c..889fbf8f6 100644
--- a/uis/admin/54link_admin/nginx.conf
+++ b/uis/admin/54link_admin/nginx.conf
@@ -4,17 +4,46 @@ server {
root /usr/share/nginx/html;
index index.html;
- # React SPA — all unknown paths fall back to index.html
+ # HTML entry point — never cache (forces browser to fetch latest on deploy)
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ try_files $uri =404;
+ }
+
+ # React SPA — all unknown paths fall back to index.html (with no-cache)
location / {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
try_files $uri $uri/ /index.html;
}
- # Cache static assets
- location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
+ # Vite hashed assets (e.g. assets/index-a1b2c3d4.js) — cache forever
+ location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
+ # Other static assets (fonts, images, icons) — cache with revalidation
+ location ~* \.(woff2?|png|jpg|jpeg|gif|svg|ico|webp)$ {
+ expires 30d;
+ add_header Cache-Control "public, must-revalidate";
+ }
+
+ # manifest.json — short cache for PWA updates
+ location = /manifest.json {
+ add_header Cache-Control "no-cache, must-revalidate";
+ }
+
+ # Service worker — never cache (must always be fresh for update detection)
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ }
+
gzip on;
- gzip_types text/plain text/css application/javascript application/json;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
}
diff --git a/uis/admin/tenant_admin/Dockerfile b/uis/admin/tenant_admin/Dockerfile
index 9468b930e..3644ac160 100644
--- a/uis/admin/tenant_admin/Dockerfile
+++ b/uis/admin/tenant_admin/Dockerfile
@@ -1,13 +1,12 @@
-FROM node:20
-
+FROM node:20-alpine AS builder
WORKDIR /app
-
COPY package.json package-lock.json* .npmrc* ./
-
-RUN npm install
-
+RUN npm ci
COPY . .
+RUN npm run build
-EXPOSE 5173
-
-CMD ["npm", "run", "dev"]
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/uis/admin/tenant_admin/index.html b/uis/admin/tenant_admin/index.html
index 7390baf41..2a2d5394e 100644
--- a/uis/admin/tenant_admin/index.html
+++ b/uis/admin/tenant_admin/index.html
@@ -4,6 +4,9 @@
+
+
+
admin-portal-ui
diff --git a/uis/admin/tenant_admin/nginx.conf b/uis/admin/tenant_admin/nginx.conf
new file mode 100644
index 000000000..889fbf8f6
--- /dev/null
+++ b/uis/admin/tenant_admin/nginx.conf
@@ -0,0 +1,49 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # HTML entry point — never cache (forces browser to fetch latest on deploy)
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ try_files $uri =404;
+ }
+
+ # React SPA — all unknown paths fall back to index.html (with no-cache)
+ location / {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Vite hashed assets (e.g. assets/index-a1b2c3d4.js) — cache forever
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # Other static assets (fonts, images, icons) — cache with revalidation
+ location ~* \.(woff2?|png|jpg|jpeg|gif|svg|ico|webp)$ {
+ expires 30d;
+ add_header Cache-Control "public, must-revalidate";
+ }
+
+ # manifest.json — short cache for PWA updates
+ location = /manifest.json {
+ add_header Cache-Control "no-cache, must-revalidate";
+ }
+
+ # Service worker — never cache (must always be fresh for update detection)
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+}
diff --git a/uis/client/web2/Dockerfile b/uis/client/web2/Dockerfile
index 1a56fd9cd..3644ac160 100644
--- a/uis/client/web2/Dockerfile
+++ b/uis/client/web2/Dockerfile
@@ -1,14 +1,12 @@
-FROM node:20
-
+FROM node:20-alpine AS builder
WORKDIR /app
-
-COPY package.json package-lock.json* ./
-
-RUN npm install
-
+COPY package.json package-lock.json* .npmrc* ./
+RUN npm ci
COPY . .
+RUN npm run build
-EXPOSE 5173
-
-
-CMD ["npm", "run", "dev"]
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/uis/client/web2/index.html b/uis/client/web2/index.html
index f07b9dcb7..07cb0553f 100644
--- a/uis/client/web2/index.html
+++ b/uis/client/web2/index.html
@@ -5,6 +5,9 @@
+
+
+
diff --git a/uis/client/web2/nginx.conf b/uis/client/web2/nginx.conf
new file mode 100644
index 000000000..889fbf8f6
--- /dev/null
+++ b/uis/client/web2/nginx.conf
@@ -0,0 +1,49 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # HTML entry point — never cache (forces browser to fetch latest on deploy)
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ try_files $uri =404;
+ }
+
+ # React SPA — all unknown paths fall back to index.html (with no-cache)
+ location / {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Vite hashed assets (e.g. assets/index-a1b2c3d4.js) — cache forever
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # Other static assets (fonts, images, icons) — cache with revalidation
+ location ~* \.(woff2?|png|jpg|jpeg|gif|svg|ico|webp)$ {
+ expires 30d;
+ add_header Cache-Control "public, must-revalidate";
+ }
+
+ # manifest.json — short cache for PWA updates
+ location = /manifest.json {
+ add_header Cache-Control "no-cache, must-revalidate";
+ }
+
+ # Service worker — never cache (must always be fresh for update detection)
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires "0";
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json image/svg+xml;
+}
diff --git a/uis/client/web2/public/sw.js b/uis/client/web2/public/sw.js
index 8d7664d65..1e5f69519 100644
--- a/uis/client/web2/public/sw.js
+++ b/uis/client/web2/public/sw.js
@@ -1,165 +1,132 @@
/* eslint-disable no-restricted-globals */
/**
* Service Worker for PWA Offline Functionality
- * Handles caching, background sync, and offline support
+ * Handles caching, background sync, and offline support.
+ *
+ * CACHE BUSTING: The CACHE_VERSION below is bumped by the build pipeline
+ * (vite-plugin-version-inject or CI sed). When it changes, the activate
+ * handler deletes every old cache, guaranteeing users get fresh assets.
*/
-const CACHE_NAME = '54link-pwa-cache-v1';
+const CACHE_VERSION = '__BUILD_HASH__';
+const CACHE_NAME = `54link-pwa-cache-${CACHE_VERSION}`;
const OFFLINE_PAGE = '/offline';
-// Assets to cache on install
-// Don't cache the root HTML page - it's dynamically generated with tenant colors
const STATIC_ASSETS = [
'/offline',
'/manifest.json',
];
-// Install event - cache static assets
+// Install — cache static assets and activate immediately
self.addEventListener('install', (event) => {
- console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
- .then((cache) => {
- console.log('[Service Worker] Caching static assets');
- return cache.addAll(STATIC_ASSETS);
- })
+ .then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
-// Activate event - clean up old caches
+// Activate — delete ALL caches that don't match the current version
self.addEventListener('activate', (event) => {
- console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
- cacheNames.map((cacheName) => {
- if (cacheName !== CACHE_NAME) {
- console.log('[Service Worker] Deleting old cache:', cacheName);
- return caches.delete(cacheName);
- }
- })
+ cacheNames
+ .filter((name) => name !== CACHE_NAME)
+ .map((name) => {
+ console.log('[SW] Purging stale cache:', name);
+ return caches.delete(name);
+ })
);
- }).then(() => self.clients.claim())
+ })
+ .then(() => self.clients.claim())
+ .then(() => {
+ // Notify all open tabs that a new version is active
+ return self.clients.matchAll({ type: 'window' }).then((clients) => {
+ clients.forEach((client) => {
+ client.postMessage({ type: 'SW_UPDATED', version: CACHE_VERSION });
+ });
+ });
+ })
);
});
-// Fetch event - serve from cache when offline
+// Fetch — network-first for HTML, cache-first for hashed assets
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
- // Skip non-GET requests and external URLs
- if (request.method !== 'GET' || url.origin !== location.origin) {
- return;
- }
+ if (request.method !== 'GET' || url.origin !== location.origin) return;
+ if (url.pathname.startsWith('/api/') || url.pathname.includes('/api/')) return;
- // Skip API calls - let them fail for offline handling
- if (url.pathname.startsWith('/api/') || url.pathname.includes('/api/')) {
+ // HTML navigation — always network-first (never serve stale HTML)
+ if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
+ event.respondWith(
+ fetch(request)
+ .catch(() => caches.match(OFFLINE_PAGE) || new Response('Offline', { status: 503 }))
+ );
return;
}
- // For HTML pages (navigation requests), use network-first strategy
- // This ensures tenant colors are always fresh and not cached
- if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
+ // Hashed assets under /assets/ — cache-first (immutable filenames)
+ if (url.pathname.startsWith('/assets/')) {
event.respondWith(
- fetch(request)
- .then((response) => {
- // Don't cache HTML pages - they contain dynamic tenant colors
+ caches.match(request).then((cached) => {
+ if (cached) return cached;
+ return fetch(request).then((response) => {
+ if (response.status === 200) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
+ }
return response;
- })
- .catch(() => {
- // If offline, show offline page
- return caches.match(OFFLINE_PAGE) || new Response('Offline', { status: 503 });
- })
+ });
+ })
);
return;
}
- // For other assets (JS, CSS, images), use cache-first strategy
+ // Other assets — stale-while-revalidate
event.respondWith(
- caches.match(request)
- .then((cachedResponse) => {
- // Return cached version if available
- if (cachedResponse) {
- return cachedResponse;
+ caches.match(request).then((cached) => {
+ const networkFetch = fetch(request).then((response) => {
+ if (response.status === 200) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
+ return response;
+ }).catch(() => cached || new Response('Offline', { status: 503 }));
- // Try network, fallback to offline page if offline
- return fetch(request)
- .then((response) => {
- // Cache successful responses (but not HTML pages)
- if (response.status === 200 && !response.headers.get('content-type')?.includes('text/html')) {
- const responseToCache = response.clone();
- caches.open(CACHE_NAME).then((cache) => {
- cache.put(request, responseToCache);
- });
- }
- return response;
- })
- .catch(() => {
- // If offline and no cache, return error
- return new Response('Offline', { status: 503 });
- });
- })
+ return cached || networkFetch;
+ })
);
});
// Background sync for queued operations
self.addEventListener('sync', (event) => {
- console.log('[Service Worker] Background sync:', event.tag);
-
if (event.tag === 'sync-pending-transfers') {
- event.waitUntil(syncPendingTransfers());
+ event.waitUntil(notifyClients('SYNC_PENDING_TRANSFERS'));
}
-
if (event.tag === 'sync-scheduled-transfers') {
- event.waitUntil(syncScheduledTransfers());
+ event.waitUntil(notifyClients('SYNC_SCHEDULED_TRANSFERS'));
}
});
-// Sync pending transfers
-async function syncPendingTransfers() {
- try {
- // This will be handled by the sync service in the app
- // The service worker just triggers the sync
- const clients = await self.clients.matchAll();
- clients.forEach((client) => {
- client.postMessage({
- type: 'SYNC_PENDING_TRANSFERS',
- });
- });
- } catch (error) {
- console.error('[Service Worker] Sync error:', error);
- }
-}
-
-// Sync scheduled transfers
-async function syncScheduledTransfers() {
- try {
- const clients = await self.clients.matchAll();
- clients.forEach((client) => {
- client.postMessage({
- type: 'SYNC_SCHEDULED_TRANSFERS',
- });
- });
- } catch (error) {
- console.error('[Service Worker] Sync error:', error);
- }
+async function notifyClients(type) {
+ const clients = await self.clients.matchAll();
+ clients.forEach((client) => client.postMessage({ type }));
}
-// Message handler for communication with app
+// Message handler
self.addEventListener('message', (event) => {
- if (event.data && event.data.type === 'SKIP_WAITING') {
+ if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
-
- if (event.data && event.data.type === 'CACHE_URLS') {
+ if (event.data?.type === 'CACHE_URLS') {
event.waitUntil(
- caches.open(CACHE_NAME).then((cache) => {
- return cache.addAll(event.data.urls);
- })
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(event.data.urls))
);
}
+ if (event.data?.type === 'GET_VERSION') {
+ event.source.postMessage({ type: 'SW_VERSION', version: CACHE_VERSION });
+ }
});
-
diff --git a/uis/client/web2/src/utils/serviceWorkerRegistration.ts b/uis/client/web2/src/utils/serviceWorkerRegistration.ts
index e9380ff00..2fbf87306 100644
--- a/uis/client/web2/src/utils/serviceWorkerRegistration.ts
+++ b/uis/client/web2/src/utils/serviceWorkerRegistration.ts
@@ -1,60 +1,129 @@
/**
- * Service Worker Registration
+ * Service Worker Registration with cache-busting on deploy.
+ *
+ * On every page load the browser re-fetches /sw.js (served with
+ * Cache-Control: no-cache). If the file changed (new build hash),
+ * the browser installs the new SW, which purges all old caches.
+ * This module also listens for the SW_UPDATED message and shows
+ * a non-intrusive banner prompting the user to reload.
*/
+let refreshing = false;
+
export function registerServiceWorker(): void {
- if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- navigator.serviceWorker
- .register('/sw.js')
- .then((registration) => {
- console.log('[Service Worker] Registered successfully:', registration.scope);
-
- // Check for updates
- registration.addEventListener('updatefound', () => {
- const newWorker = registration.installing;
- if (newWorker) {
- newWorker.addEventListener('statechange', () => {
- if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
- // New service worker available
- console.log('[Service Worker] New version available');
- // Optionally show update notification to user
- }
- });
- }
- });
- })
- .catch((error) => {
- console.error('[Service Worker] Registration failed:', error);
- });
+ if (!('serviceWorker' in navigator)) return;
- // Listen for messages from service worker
- navigator.serviceWorker.addEventListener('message', (event) => {
- if (event.data && event.data.type === 'SYNC_PENDING_TRANSFERS') {
- // Trigger sync in the app
- import('../services/sync_service').then(({ syncService }) => {
- syncService.syncPendingTransfers();
- });
- }
- if (event.data && event.data.type === 'SYNC_SCHEDULED_TRANSFERS') {
- import('../services/sync_service').then(({ syncService }) => {
- syncService.syncScheduledTransfers();
- });
- }
+ window.addEventListener('load', async () => {
+ try {
+ const registration = await navigator.serviceWorker.register('/sw.js', {
+ updateViaCache: 'none', // force browser to bypass HTTP cache for sw.js
});
+
+ // Periodic update check (every 60 s while tab is active)
+ setInterval(() => registration.update(), 60_000);
+
+ registration.addEventListener('updatefound', () => {
+ const newWorker = registration.installing;
+ if (!newWorker) return;
+
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ showUpdateBanner(newWorker);
+ }
+ });
+ });
+ } catch (error) {
+ console.error('[SW] Registration failed:', error);
+ }
+
+ // Listen for messages from service worker
+ navigator.serviceWorker.addEventListener('message', (event) => {
+ const { type } = event.data ?? {};
+
+ if (type === 'SW_UPDATED') {
+ showUpdateBanner();
+ }
+ if (type === 'SYNC_PENDING_TRANSFERS') {
+ import('../services/sync_service').then(({ syncService }) => {
+ syncService.syncPendingTransfers();
+ });
+ }
+ if (type === 'SYNC_SCHEDULED_TRANSFERS') {
+ import('../services/sync_service').then(({ syncService }) => {
+ syncService.syncScheduledTransfers();
+ });
+ }
});
- }
+
+ // Reload once when the new SW takes over
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ if (!refreshing) {
+ refreshing = true;
+ window.location.reload();
+ }
+ });
+ });
+}
+
+/**
+ * Show a non-intrusive banner at the top of the page prompting
+ * the user to reload for the latest version.
+ */
+function showUpdateBanner(waitingWorker?: ServiceWorker): void {
+ if (document.getElementById('sw-update-banner')) return;
+
+ const banner = document.createElement('div');
+ banner.id = 'sw-update-banner';
+ banner.setAttribute('role', 'alert');
+ Object.assign(banner.style, {
+ position: 'fixed',
+ top: '0',
+ left: '0',
+ right: '0',
+ zIndex: '99999',
+ background: '#1a56db',
+ color: '#fff',
+ padding: '12px 16px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '12px',
+ fontFamily: 'Inter, system-ui, sans-serif',
+ fontSize: '14px',
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
+ });
+
+ banner.innerHTML = `
+ A new version is available.
+
+
+ `;
+
+ document.body.prepend(banner);
+
+ document.getElementById('sw-update-btn')?.addEventListener('click', () => {
+ if (waitingWorker) {
+ waitingWorker.postMessage({ type: 'SKIP_WAITING' });
+ } else {
+ window.location.reload();
+ }
+ });
+
+ document.getElementById('sw-dismiss-btn')?.addEventListener('click', () => {
+ banner.remove();
+ });
}
export function unregisterServiceWorker(): void {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
- .then((registration) => {
- registration.unregister();
- })
- .catch((error) => {
- console.error('[Service Worker] Unregistration failed:', error);
- });
+ .then((registration) => registration.unregister())
+ .catch((error) => console.error('[SW] Unregistration failed:', error));
}
}
-
diff --git a/uis/client/web2/vite.config.ts b/uis/client/web2/vite.config.ts
index 760647a5e..2fd4255b5 100644
--- a/uis/client/web2/vite.config.ts
+++ b/uis/client/web2/vite.config.ts
@@ -1,10 +1,48 @@
-import { defineConfig } from "vite";
+import { defineConfig, Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
+import { readFileSync, writeFileSync } from "fs";
+import { resolve } from "path";
+import { createHash } from "crypto";
+
+/**
+ * Vite plugin: stamps a unique build hash into sw.js at build time.
+ * This ensures the service worker cache name changes on every deploy,
+ * triggering the activate handler to purge stale caches.
+ */
+function swVersionStamp(): Plugin {
+ return {
+ name: "sw-version-stamp",
+ closeBundle() {
+ const swPath = resolve(__dirname, "dist/sw.js");
+ try {
+ let sw = readFileSync(swPath, "utf-8");
+ const hash = createHash("md5")
+ .update(Date.now().toString())
+ .digest("hex")
+ .slice(0, 8);
+ sw = sw.replace("__BUILD_HASH__", hash);
+ writeFileSync(swPath, sw);
+ } catch {
+ // sw.js may not exist if public/ wasn't copied — safe to skip
+ }
+ },
+ };
+}
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ plugins: [react(), tailwindcss(), swVersionStamp()],
+ build: {
+ // Content-hash filenames for all JS/CSS chunks (Vite default, made explicit)
+ rollupOptions: {
+ output: {
+ entryFileNames: "assets/[name]-[hash].js",
+ chunkFileNames: "assets/[name]-[hash].js",
+ assetFileNames: "assets/[name]-[hash].[ext]",
+ },
+ },
+ },
server: {
host: true,
allowedHosts: ["app.54link-dev.upi.dev"],