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: {