diff --git a/packages/mapviewer/src/modules/i18n/locales/de.json b/packages/mapviewer/src/modules/i18n/locales/de.json index d0ab0eb2de..8b030e2b72 100644 --- a/packages/mapviewer/src/modules/i18n/locales/de.json +++ b/packages/mapviewer/src/modules/i18n/locales/de.json @@ -488,6 +488,8 @@ "offline_refresh": "Seite aktualisieren", "offline_save": "Karte Speichern", "offline_save_new_data": "Neue Karte speichern ", + "offline_sw_configuration_error": "Fehler in der Service-Worker-Konfiguration. Die Offline-Funktionalität funktioniert möglicherweise nicht korrekt.", + "offline_sw_secure_context_required": "Der Service Worker erfordert einen sicheren Kontext (HTTPS mit einem vertrauenswürdigen Zertifikat).", "offline_save_warning": "- ~50MB Kartenmaterial wird heruntergeladen werden (bis Massstab 1:25'000) - Bitte diese Seite während dem Speichervorgang nicht verlassen und das Gerät nicht sperren. \\\"Private\\\" mode des Browsers muss deaktiviert sein.", "offline_show": "Zeige Offline Menu", "offline_show_extent": "Ausschnitt anzeigen", @@ -755,4 +757,4 @@ "zoom_in": "Vergrössere Kartenausschnitt", "zoom_out": "Verkleinere Kartenausschnitt", "zooming_mode_warning": "Zum Zoomen der Karte Strg/Cmd gedrückt halten" -} +} \ No newline at end of file diff --git a/packages/mapviewer/src/modules/i18n/locales/en.json b/packages/mapviewer/src/modules/i18n/locales/en.json index 456214aa50..b123c80cf2 100644 --- a/packages/mapviewer/src/modules/i18n/locales/en.json +++ b/packages/mapviewer/src/modules/i18n/locales/en.json @@ -488,6 +488,8 @@ "offline_refresh": "Refresh the page", "offline_save": "Save map", "offline_save_new_data": "Save new map", + "offline_sw_configuration_error": "Service worker configuration error. Offline functionality may not work correctly.", + "offline_sw_secure_context_required": "Service worker requires a secure context (HTTPS with a trusted certificate).", "offline_save_warning": "- ~50MB of maps will be downloaded (until scale 1:25'000) - Don't lock your device or navigate away from this site during the download process. Deactivate \\\"private\\\" mode of your browser.", "offline_show": "Show offline menu", "offline_show_extent": "Show extent", @@ -755,4 +757,4 @@ "zoom_in": "Zoom in", "zoom_out": "Zoom out", "zooming_mode_warning": "Hold Ctrl/Cmd while scrolling to zoom the map" -} +} \ No newline at end of file diff --git a/packages/mapviewer/src/modules/i18n/locales/fr.json b/packages/mapviewer/src/modules/i18n/locales/fr.json index d7ed9f75bb..a72c75af4c 100644 --- a/packages/mapviewer/src/modules/i18n/locales/fr.json +++ b/packages/mapviewer/src/modules/i18n/locales/fr.json @@ -488,6 +488,8 @@ "offline_refresh": "Rafraîchir la page", "offline_save": "Enregistrer carte", "offline_save_new_data": "Sauvegarder une nouvelle carte", + "offline_sw_configuration_error": "Erreur de configuration du service worker. Le mode hors ligne pourrait ne pas fonctionner correctement.", + "offline_sw_secure_context_required": "Le service worker nécessite un contexte sécurisé (HTTPS avec un certificat de confiance).", "offline_save_warning": "- Vous allez télécharger ~50MB de carte (jusqu'à l'échelle 1:25'000) - Ne lockez pas votre appareil et ne quittez pas ce site durant le processus de téléchargement. Désactivez le mode « privé »", "offline_show": "Afficher le menu hors-ligne", "offline_show_extent": "Afficher l'étendue", @@ -755,4 +757,4 @@ "zoom_in": "Zoomer plus", "zoom_out": "Zoomer moins", "zooming_mode_warning": "Maintenir Ctrl/Cmd pour zoomer sur la carte" -} +} \ No newline at end of file diff --git a/packages/mapviewer/src/modules/i18n/locales/it.json b/packages/mapviewer/src/modules/i18n/locales/it.json index 9e95c5750e..76345a03e2 100644 --- a/packages/mapviewer/src/modules/i18n/locales/it.json +++ b/packages/mapviewer/src/modules/i18n/locales/it.json @@ -488,6 +488,8 @@ "offline_refresh": "Ricarica la pagina", "offline_save": "Salvare la mappa", "offline_save_new_data": "Salvare una nuova mappa", + "offline_sw_configuration_error": "Errore di configurazione del service worker. La funzionalità offline potrebbe non funzionare correttamente.", + "offline_sw_secure_context_required": "Il service worker richiede un contesto sicuro (HTTPS con un certificato attendibile).", "offline_save_warning": "- Saranno scaricati ~50MB di carte (fino alla scala 1:25000) - Non bloccare il dispositivo e non abbandonare questa pagina durante il download. Disattivare la modalità \\\"privata\\\"", "offline_show": "Mostra il menu offline", "offline_show_extent": "Mostrare l'estensione", @@ -755,4 +757,4 @@ "zoom_in": "Zoom in avanti", "zoom_out": "Zoom indietro", "zooming_mode_warning": "Mantenere Ctrl/Cmd premuto per zoomare la mappa" -} +} \ No newline at end of file diff --git a/packages/mapviewer/src/modules/i18n/locales/rm.json b/packages/mapviewer/src/modules/i18n/locales/rm.json index 62af07c544..b09271128f 100644 --- a/packages/mapviewer/src/modules/i18n/locales/rm.json +++ b/packages/mapviewer/src/modules/i18n/locales/rm.json @@ -488,6 +488,8 @@ "offline_refresh": "Chargiar giu la pagina", "offline_save": "Arcurnar charta", "offline_save_new_data": "Memorisar la nova charta", + "offline_sw_configuration_error": "Errur da configuraziun dal service worker. La funcziunalitad offline pudess betg funcziunar correctamain.", + "offline_sw_secure_context_required": "Il service worker pretenda in context segir (HTTPS cun in certificat fidà).", "offline_save_warning": "Chargiar e memorisar la charta d'ina grondezza da ~50MB?", "offline_show": "Mussar il menu offline", "offline_show_extent": "Mussar l'extract", @@ -755,4 +757,4 @@ "zoom_in": "Engrondir l'extract da la charta", "zoom_out": "Empitschnir l'extract da la charta", "zooming_mode_warning": "egnair Ctrl/Cmd cuntschaint per zoomar la charta" -} +} \ No newline at end of file diff --git a/packages/mapviewer/src/service-workers.ts b/packages/mapviewer/src/service-workers.ts index d1344ceb66..e2299644b8 100644 --- a/packages/mapviewer/src/service-workers.ts +++ b/packages/mapviewer/src/service-workers.ts @@ -1,5 +1,6 @@ /// +import log, { LogPreDefinedColor } from '@geoadmin/log' import { CacheableResponsePlugin } from 'workbox-cacheable-response' import { clientsClaim } from 'workbox-core' import { ExpirationPlugin } from 'workbox-expiration' @@ -12,13 +13,19 @@ import { NavigationRoute, registerRoute, Route } from 'workbox-routing' import { NetworkFirst } from 'workbox-strategies' import { getWmsBaseUrl, getWmtsBaseUrl } from '@/config/baseUrl.config' -import { IS_TESTING_WITH_CYPRESS } from '@/config/staging.config' declare let self: ServiceWorkerGlobalScope // disabling workbox's console.debug (comment that line if you need them) self.__WB_DISABLE_DEV_LOGS = true - +log.debug({ + title: 'Service Worker: dev logs disabled', + titleColor: LogPreDefinedColor.Sky, + messages: [ + 'Service Worker: workbox dev logs are disabled', + String(self.__WB_DISABLE_DEV_LOGS), + ], +}) // Setting up Service Worker API to have a client-side cache. // This means the app can mostly function in offline mode, so long as the initial load has happened naturally before. // This is the first step to get a proper offline mode, where users will be able to select @@ -26,40 +33,42 @@ self.__WB_DISABLE_DEV_LOGS = true // Cypress doesn't handle well Service Worker API being active, so we skip the setup if we // are testing things with Cypress -if (!IS_TESTING_WITH_CYPRESS) { +if (import.meta.env.MODE !== 'test') { + log.debug({ + title: 'Service Worker: starting initialization', + titleColor: LogPreDefinedColor.Sky, + messages: ['Service Worker: initializing the service worker...'], + }) // self.__WB_MANIFEST is the default injection point precacheAndRoute(self.__WB_MANIFEST) // clean old assets cleanupOutdatedCaches() - let allowlist: RegExp[] | undefined - // in dev mode, we disable precaching to avoid caching issues - if (import.meta.env.DEV) { - allowlist = [/^\/$/] + // in dev mode, we disable navigation fallback registration to avoid runtime errors + // with an empty precache manifest in Vite's dev service worker + if (!import.meta.env.DEV) { + // setting up a cache instance for offline app assets (HTML/JS/CSS) + registerRoute( + new NavigationRoute(createHandlerBoundToURL(`index.html`), { + denylist: [ + // exclude all api calls, as the service worker might interacts with those in a way + // that can shut down the service from the user's perspective + // (injecting the cached index.html file instead of providing the expected output) + /^\/api\//, + // excluding the embed legacy endpoint, as it stops the redirection to the + // `legacyEmbed` route and display map views instead of embed views + /^\/embed/, + // preview sites must be excluded too (we want the latest code, not some cached version) + /^\/preview\//, + ], + }), + new NetworkFirst({ + cacheName: 'geoadmin-app-cache', + }) + ) } - // setting up a cache instance for offline app assets (HTML/JS/CSS) - registerRoute( - new NavigationRoute(createHandlerBoundToURL(`index.html`), { - allowlist, - denylist: [ - // exclude all api calls, as the service worker might interacts with those in a way - // that can shut down the service from the user's perspective - // (injecting the cached index.html file instead of providing the expected output) - /^\/api\//, - // excluding the embed legacy endpoint, as it stops the redirection to the - // `legacyEmbed` route and display map views instead of embed views - /^\/embed/, - // preview sites must be excluded too (we want the latest code, not some cached version) - /^\/preview\//, - ], - }), - new NetworkFirst({ - cacheName: 'geoadmin-app-cache', - }) - ) - // caching essential backend items (layers config, topic list, etc...) const configItemPathNames = [ // layers config diff --git a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue index 8ade75a030..41012fab2c 100644 --- a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue +++ b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue @@ -5,9 +5,10 @@ import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome import log, { LogPreDefinedColor } from '@geoadmin/log' import GeoadminTooltip from '@geoadmin/tooltip' import { useRegisterSW } from 'virtual:pwa-register/vue' -import { computed, ref } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' +import { APP_VERSION } from '@/config/staging.config' import SimpleWindow from '@/utils/components/SimpleWindow.vue' // check for updates every hour @@ -18,68 +19,187 @@ const { withText = false } = defineProps<{ }>() const isServiceWorkerActive = ref(false) +const swValidationFailed = ref(false) +const swInsecureContext = ref(false) const { t } = useI18n() /** - * This function will register a periodic sync check every hour and check if there are newer - * versions of cached files online + * Check if service worker versioning/configuration is valid by fetching the validation file */ -function registerPeriodicSync(serviceWorkerUrl: string, registration: ServiceWorkerRegistration) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setInterval(async () => { - if ('onLine' in navigator && !navigator.onLine) { - return - } +async function checkSwValidation() { + if (import.meta.env.DEV || swInsecureContext.value) { + return + } - const resp = await fetch(serviceWorkerUrl, { + try { + const response = await fetch(`${APP_VERSION}/sw-ready.json`, { cache: 'no-store', headers: { - cache: 'no-store', 'cache-control': 'no-cache', }, }) - if (resp?.status === 200) { - await registration.update() + if (!response.ok) { + log.warn({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: ['SW validation file not found - SW versioning may have failed'], + }) + swValidationFailed.value = true + return } - }, period) -} -const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW({ - immediate: true, - onRegisteredSW(serviceWorkerUrl, registration) { - log.debug({ + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + log.warn({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: [ + 'SW validation response is not JSON - SW versioning may not be active in this mode', + contentType, + ], + }) + return + } + + const validationData = await response.json() + + if (!validationData.swVersioned) { + log.error({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: [ + 'SW validation FAILED - service worker was not properly versioned', + validationData, + ], + }) + swValidationFailed.value = true + } else { + log.debug({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: ['SW validation passed', validationData], + }) + } + } catch (error) { + log.error({ title: 'OfflineReadinessStatus', titleColor: LogPreDefinedColor.Sky, - messages: ['ServiceWorker registration pending', registration], + messages: ['Failed to check SW validation', error], }) - if (registration?.active?.state === 'activated') { + swValidationFailed.value = true + } +} + +/** + * This function will register a periodic sync check every hour and check if there are newer + * versions of cached files online + */ +function registerPeriodicSync(serviceWorkerUrl: string, registration: ServiceWorkerRegistration) { + setInterval(() => { + void (async () => { + if ('onLine' in navigator && !navigator.onLine) { + return + } + + const resp = await fetch(serviceWorkerUrl, { + cache: 'no-store', + headers: { + 'cache-control': 'no-cache', + }, + }) + + if (resp?.status === 200) { + await registration.update() + } + })() + }, period) +} + +type BooleanRefLike = { value: boolean } + +let offlineReadySource: BooleanRefLike = { value: false } +let needRefreshSource: BooleanRefLike = { value: false } +const offlineReady = computed(() => offlineReadySource.value) +const needRefresh = computed(() => needRefreshSource.value) +let updateServiceWorker: (_reloadPage?: boolean) => Promise = () => Promise.resolve() + +if (window.isSecureContext) { + const registerSw = useRegisterSW({ + immediate: true, + onRegisterError(error) { + log.error({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: ['ServiceWorker registration failed', error], + }) + // mark service worker validation as failed + swValidationFailed.value = true + }, + onRegisteredSW(serviceWorkerUrl, registration) { log.debug({ title: 'OfflineReadinessStatus', titleColor: LogPreDefinedColor.Sky, - messages: ['ServiceWorker activated', registration], + messages: ['ServiceWorker registration pending', registration], }) - isServiceWorkerActive.value = true - registerPeriodicSync(serviceWorkerUrl, registration) - } else if (registration?.installing) { - registration.installing.addEventListener('statechange', (e) => { - const sw = e.target as ServiceWorker + if (registration?.active?.state === 'activated') { log.debug({ title: 'OfflineReadinessStatus', titleColor: LogPreDefinedColor.Sky, - messages: ['ServiceWorker state change', sw.state], + messages: ['ServiceWorker activated', registration], }) - isServiceWorkerActive.value = sw.state === 'activated' - if (isServiceWorkerActive.value) { - registerPeriodicSync(serviceWorkerUrl, registration) - } - }) - } - }, + isServiceWorkerActive.value = true + registerPeriodicSync(serviceWorkerUrl, registration) + } else if (registration?.installing) { + registration.installing.addEventListener('statechange', (e) => { + const sw = e.target as ServiceWorker + log.debug({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: ['ServiceWorker state change', sw.state], + }) + isServiceWorkerActive.value = sw.state === 'activated' + if (isServiceWorkerActive.value) { + registerPeriodicSync(serviceWorkerUrl, registration) + } + }) + } + }, + }) + + updateServiceWorker = registerSw.updateServiceWorker + offlineReadySource = registerSw.offlineReady + needRefreshSource = registerSw.needRefresh +} else { + swInsecureContext.value = true + log.warn({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: [ + 'ServiceWorker registration skipped: insecure context (HTTPS with trusted certificate required).', + ], + }) +} + +onMounted(() => { + // Check SW validation status on component mount + checkSwValidation().catch((error) => { + log.error({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: ['Error during SW validation check', error], + }) + }) }) const statusIcon = computed(() => { + if (swInsecureContext.value) { + return 'triangle-exclamation' + } + if (swValidationFailed.value) { + return 'circle-xmark' + } if (isServiceWorkerActive.value) { return 'check' } @@ -89,11 +209,21 @@ const statusIcon = computed(() => { return 'circle-notch' }) const shouldStatusIconSpin = computed( - () => !isServiceWorkerActive.value && !needRefresh.value + () => + !swInsecureContext.value && + !swValidationFailed.value && + !isServiceWorkerActive.value && + !needRefresh.value ) const tooltipContent = computed(() => { const title = t('offline_modal_title') let extraInfoKey = 'wait_data_loading' + if (swInsecureContext.value) { + return `${title}: ${t('offline_sw_secure_context_required')}` + } + if (swValidationFailed.value) { + return `${title}: ${t('offline_sw_configuration_error')}` + } if (needRefresh.value) { extraInfoKey = 'offline_cache_obsolete' } @@ -158,16 +288,26 @@ function refreshCache() { - + + {{ t('offline_sw_secure_context_required') }} + + + {{ t('offline_sw_configuration_error') }} + + {{ t('wait_data_loading') }} - + {{ t('offline_dl_succeed') }} - + {{ t('offline_cache_obsolete') }} diff --git a/packages/mapviewer/tests/cypress/tests-e2e/featureSelection.cy.js b/packages/mapviewer/tests/cypress/tests-e2e/featureSelection.cy.js index 69c311f439..b2ea30a587 100644 --- a/packages/mapviewer/tests/cypress/tests-e2e/featureSelection.cy.js +++ b/packages/mapviewer/tests/cypress/tests-e2e/featureSelection.cy.js @@ -8,7 +8,6 @@ import { FeatureInfoPositions } from '@/store/modules/ui.store' import { addFeatureIdentificationIntercepts } from '../support/intercepts' registerProj4(proj4) - describe('Testing the feature selection', () => { context('Feature pre-selection in the URL', () => { const timeLayer = 'test.timeenabled.wmts.layer' diff --git a/packages/mapviewer/vite-plugins/vite-plugin-generate-build-info.js b/packages/mapviewer/vite-plugins/vite-plugin-generate-build-info.js index d632ac37b2..953523a227 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-generate-build-info.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-generate-build-info.js @@ -40,7 +40,7 @@ async function getGitUserEmail() { * * The app version is received as parameter of the plugin (when added to vite plugin array) */ -export default function generateBuildInfo(staging, version) { +export default function generateBuildInfo(staging, version, mode) { return { name: 'vite-plugin-generate-build-info', buildEnd: { @@ -89,6 +89,13 @@ export default function generateBuildInfo(staging, version) { localChanges: localChanges, prNumber: process.env.PULL_REQUEST_ID, }, + serviceWorker: { + path: + mode !== 'test' + ? `${version}/service-workers.js` + : 'service-workers.js', + version: version, + }, }, null, 2 diff --git a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js new file mode 100644 index 0000000000..fb4407c763 --- /dev/null +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -0,0 +1,86 @@ +/* eslint-disable no-console */ +/** + * Vite plugin that moves the generated service worker file to a versioned directory. + * + * This plugin: + * + * 1. Runs after VitePWA generates the service worker file at the root of dist + * 2. Copies service-workers.js to ${appVersion}/service-workers.js + * 3. Rewrites root service-workers.js as a bootstrap loader importing the versioned SW and moves service-workers.js.map if it exists + * 4. Warns if the expected files are not found + */ +export default function moveServiceWorkerFile(appVersion) { + let outputDir = '' + + return { + name: 'vite-plugin-move-sw', + enforce: 'post', + async closeBundle() { + const fs = await import('fs/promises') + const path = await import('path') + + console.log('[vite-plugin-move-sw] Moving service worker to versioned directory...') + + const swFileName = 'service-workers.js' + const swMapFileName = 'service-workers.js.map' + + const oldSwPath = path.join(outputDir, swFileName) + const newSwPath = path.join(outputDir, appVersion, swFileName) + + const oldSwMapPath = path.join(outputDir, swMapFileName) + const newSwMapPath = path.join(outputDir, appVersion, swMapFileName) + + try { + // Check if the SW file exists + await fs.access(oldSwPath) + + // Ensure target directory exists + await fs.mkdir(path.dirname(newSwPath), { recursive: true }) + + // Copy the service worker file to versioned location + await fs.copyFile(oldSwPath, newSwPath) + console.log(`[vite-plugin-move-sw] Copied ${swFileName} to ${appVersion}/`) + + // Keep a scope-safe bootstrap file at root that imports the versioned SW script + await fs.writeFile( + oldSwPath, + `/* generated by vite-plugin-move-sw */\nimportScripts('./${appVersion}/service-workers.js')\n`, + 'utf-8' + ) + console.log('[vite-plugin-move-sw] Created root SW bootstrap loader') + + // Handle source map if it exists + try { + await fs.access(oldSwMapPath) + + // Copy the source map file + await fs.copyFile(oldSwMapPath, newSwMapPath) + console.log(`[vite-plugin-move-sw] Copied ${swMapFileName} to ${appVersion}/`) + + // Update source map reference in the JS file + const swContent = await fs.readFile(newSwPath, 'utf-8') + const updatedContent = swContent.replace( + /\/\/# sourceMappingURL=service-workers\.js\.map/g, + '//# sourceMappingURL=service-workers.js.map' + ) + + await fs.writeFile(newSwPath, updatedContent, 'utf-8') + console.log('[vite-plugin-move-sw] Updated source map reference') + } catch { + // Source map doesn't exist, which is fine + console.log('[vite-plugin-move-sw] No source map found (this is OK)') + } + console.log('[vite-plugin-move-sw] Service worker relocation complete') + } catch (error) { + console.warn( + `[vite-plugin-move-sw] WARNING: Failed to move service worker file: ${error.message}. ` + + `Expected file at: ${oldSwPath}. SW versioning may have failed.` + ) + } + }, + + configResolved(config) { + outputDir = config.build.outDir + }, + } +} diff --git a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js new file mode 100644 index 0000000000..2bbcfcb47c --- /dev/null +++ b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js @@ -0,0 +1,100 @@ +/* eslint-disable no-console */ +/** + * Vite plugin that validates the service worker registration path and emits a build validation file. + * + * This plugin: + * 1. Finds chunks containing service worker registration code from the virtual:pwa-register module + * 2. Validates that the registration path remains "./service-workers.js" (root scope-safe path) + * 3. Emits a validation file (sw-ready.json) to indicate successful configuration + * 4. Warns if no SW registration pattern is found (validation failure) + */ +export default function versionServiceWorkerPath(appVersion, staging) { + let swPatternFound = false + let outputDir = '' + + return { + name: 'vite-plugin-version-sw-path', + enforce: 'post', + + configResolved(config) { + outputDir = config.build.outDir + }, + + generateBundle(options, bundle) { + swPatternFound = false + console.log('[vite-plugin-version-sw-path] Scanning bundles for SW registration...') + + for (const fileName in bundle) { + const chunk = bundle[fileName] + + if (chunk.type === 'chunk' && chunk.code) { + // Pattern 1: Look for Workbox registration with root SW path + // Example: new Workbox("./service-workers.js", {...}) + const workboxPattern = /new\s+(\w+)\("\.\/service-workers\.js"/g + + if (workboxPattern.test(chunk.code)) { + console.log( + `[vite-plugin-version-sw-path] Found SW registration in ${fileName}` + ) + swPatternFound = true + } + + // Pattern 2: Look for standalone string references to the root SW path + if (chunk.code.includes('"./service-workers.js"')) { + console.log( + `[vite-plugin-version-sw-path] Found SW path reference in ${fileName}` + ) + swPatternFound = true + } + } + } + + if (!swPatternFound) { + this.warn( + '[vite-plugin-version-sw-path] WARNING: No service worker registration pattern found in bundle! ' + + 'SW versioning may have failed. Offline functionality may not work correctly.' + ) + } + }, + + async writeBundle() { + const fs = await import('fs/promises') + const path = await import('path') + + // Emit validation file indicating whether SW versioning succeeded + const validationData = { + swVersioned: swPatternFound, + swPath: swPatternFound ? 'service-workers.js' : null, + timestamp: new Date().toISOString(), + staging, + version: appVersion, + } + + const validationFilePath = path.join(outputDir, appVersion, 'sw-ready.json') + + try { + // Ensure directory exists + await fs.mkdir(path.dirname(validationFilePath), { recursive: true }) + + await fs.writeFile( + validationFilePath, + JSON.stringify(validationData, null, 2), + 'utf-8' + ) + console.log( + `[vite-plugin-version-sw-path] Validation file written: ${validationFilePath}` + ) + + if (!swPatternFound) { + console.error( + '[vite-plugin-version-sw-path] ERROR: SW versioning validation FAILED' + ) + } + } catch (error) { + this.error( + `[vite-plugin-version-sw-path] Failed to write validation file: ${error.message}` + ) + } + }, + } +} diff --git a/packages/mapviewer/vite.config.mts b/packages/mapviewer/vite.config.mts index 8f9213913b..4ea3589e4e 100644 --- a/packages/mapviewer/vite.config.mts +++ b/packages/mapviewer/vite.config.mts @@ -11,6 +11,8 @@ import { viteStaticCopy } from 'vite-plugin-static-copy' import vueDevTools from 'vite-plugin-vue-devtools' import generateBuildInfo from './vite-plugins/vite-plugin-generate-build-info' +import moveServiceWorkerFile from './vite-plugins/vite-plugin-move-sw' +import versionServiceWorkerPath from './vite-plugins/vite-plugin-version-sw-path' // We take the version from APP_VERSION but if not set, then take // it from git describe command @@ -40,7 +42,7 @@ const stagings = { * @param id * @returns */ -function manualChunks(id) { +function manualChunks(id: string) { // Put all files from the src/utils into the chunk named utils.js if (id.includes('/src/utils/')) { return 'utils' @@ -68,13 +70,13 @@ export default defineConfig(({ mode }) => { { ...(process.env.USE_HTTPS ? basicSsl({ - /** Name of certification */ - name: 'localhost', - /** Custom trust domains */ - domains: ['localhost', '192.168.*.*'], - /** Custom certification directory */ - certDir: './devServer/cert', - }) + /** Name of certification */ + name: 'localhost', + /** Custom trust domains */ + domains: ['localhost', '192.168.*.*'], + /** Custom certification directory */ + certDir: './devServer/cert', + }) : {}), apply: 'serve', }, @@ -87,7 +89,7 @@ export default defineConfig(({ mode }) => { }, }, }), - generateBuildInfo(stagings[definitiveMode], appVersion), + generateBuildInfo(stagings[definitiveMode], appVersion, mode), // CesiumJS requires static files from the following 4 folders to be included in the build // https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/#install-with-npm viteStaticCopy({ @@ -114,49 +116,56 @@ export default defineConfig(({ mode }) => { mode === 'test' ? {} : VitePWA({ - devOptions: { - enabled: true, - type: 'module', - }, + devOptions: { + enabled: true, + type: 'module', + }, - strategies: 'injectManifest', - srcDir: 'src', - filename: 'service-workers.ts', - registerType: 'autoUpdate', - includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'icon.svg'], - injectRegister: false, - injectManifest: { - // 5MB max (default is 2MB, some of our chunks and Cesium files are larger than that) - maximumFileSizeToCacheInBytes: 5 * 1000 * 1000, - }, + strategies: 'injectManifest', + srcDir: 'src', + filename: 'service-workers.ts', + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'icon.svg'], + injectRegister: false, + injectManifest: { + // 5MB max (default is 2MB, some of our chunks and Cesium files are larger than that) + maximumFileSizeToCacheInBytes: 5 * 1000 * 1000, + }, - pwaAssets: { - disabled: false, - config: true, - }, + pwaAssets: { + disabled: false, + config: true, + }, - manifest: { - name: 'map.geo.admin.ch', - short_name: 'geoadmin', - description: - 'Maps of Switzerland - Swiss Confederation - map.geo.admin.ch', - theme_color: '#ffffff', - icons: [ - { src: '/icon-192.png', type: 'image/png', sizes: '192x192' }, - { src: '/icon-512.png', type: 'image/png', sizes: '512x512' }, - ], - related_applications: [ - { - platform: 'play', - url: 'https://play.google.com/store/apps/details?id=ch.admin.swisstopo', - }, - { - platform: 'itunes', - url: 'https://apps.apple.com/us/app/swisstopo/id1505986543', - }, - ], - }, - }), + manifest: { + name: 'map.geo.admin.ch', + short_name: 'geoadmin', + description: + 'Maps of Switzerland - Swiss Confederation - map.geo.admin.ch', + theme_color: '#ffffff', + icons: [ + { src: '/icon-192.png', type: 'image/png', sizes: '192x192' }, + { src: '/icon-512.png', type: 'image/png', sizes: '512x512' }, + ], + related_applications: [ + { + platform: 'play', + url: 'https://play.google.com/store/apps/details?id=ch.admin.swisstopo', + }, + { + platform: 'itunes', + url: 'https://apps.apple.com/us/app/swisstopo/id1505986543', + }, + ], + }, + }), + // Service worker versioning plugins - enabled for all non-test builds + ...(mode !== 'test' + ? [ + versionServiceWorkerPath(appVersion, stagings[definitiveMode]), + moveServiceWorkerFile(appVersion), + ] + : []), mode === 'development' ? vueDevTools() : {}, ], resolve: {