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"],