From 09ff05e458c7d88b4d57c215fff05950236ea938 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Tue, 3 Feb 2026 11:31:06 +0100 Subject: [PATCH 1/6] PB-1878: service worker versioning start --- packages/mapviewer/src/service-workers.ts | 3 +- .../utils/offline/OfflineReadinessStatus.vue | 83 ++++++++++- .../vite-plugin-generate-build-info.js | 9 +- .../vite-plugins/vite-plugin-move-sw.js | 82 +++++++++++ .../vite-plugin-version-sw-path.js | 129 ++++++++++++++++++ packages/mapviewer/vite.config.mts | 106 +++++++------- 6 files changed, 356 insertions(+), 56 deletions(-) create mode 100644 packages/mapviewer/vite-plugins/vite-plugin-move-sw.js create mode 100644 packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js diff --git a/packages/mapviewer/src/service-workers.ts b/packages/mapviewer/src/service-workers.ts index d1344ceb66..7dacf3953b 100644 --- a/packages/mapviewer/src/service-workers.ts +++ b/packages/mapviewer/src/service-workers.ts @@ -18,7 +18,7 @@ declare let self: ServiceWorkerGlobalScope // disabling workbox's console.debug (comment that line if you need them) self.__WB_DISABLE_DEV_LOGS = true - +console.log('Service Worker: workbox dev logs are disabled:', 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 @@ -27,6 +27,7 @@ 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) { + console.log('Service Worker: initializing the service worker...') // self.__WB_MANIFEST is the default injection point precacheAndRoute(self.__WB_MANIFEST) diff --git a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue index 8ade75a030..daf93dce0f 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,9 +19,61 @@ const { withText = false } = defineProps<{ }>() const isServiceWorkerActive = ref(false) +const swValidationFailed = ref(false) const { t } = useI18n() +/** + * Check if service worker versioning/configuration is valid by fetching the validation file + */ +async function checkSwValidation() { + try { + const response = await fetch(`${APP_VERSION}/sw-ready.json`, { + cache: 'no-store', + headers: { + 'cache-control': 'no-cache', + }, + }) + + 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 + } + + 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: ['Failed to check SW validation', error], + }) + swValidationFailed.value = true + } +} + /** * This function will register a periodic sync check every hour and check if there are newer * versions of cached files online @@ -79,7 +132,15 @@ const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW({ }, }) +onMounted(() => { + // Check SW validation status on component mount + checkSwValidation() +}) + const statusIcon = computed(() => { + if (swValidationFailed.value) { + return 'circle-xmark' + } if (isServiceWorkerActive.value) { return 'check' } @@ -89,11 +150,14 @@ const statusIcon = computed(() => { return 'circle-notch' }) const shouldStatusIconSpin = computed( - () => !isServiceWorkerActive.value && !needRefresh.value + () => !swValidationFailed.value && !isServiceWorkerActive.value && !needRefresh.value ) const tooltipContent = computed(() => { const title = t('offline_modal_title') let extraInfoKey = 'wait_data_loading' + if (swValidationFailed.value) { + return `${title}: Service worker configuration error. Offline functionality may not work correctly.` + } if (needRefresh.value) { extraInfoKey = 'offline_cache_obsolete' } @@ -158,16 +222,23 @@ function refreshCache() { - + + Service worker configuration error + + {{ t('wait_data_loading') }} - + {{ t('offline_dl_succeed') }} - + {{ t('offline_cache_obsolete') }} 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..a837c6af39 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 === 'production' + ? `${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..b4c1d1b1e7 --- /dev/null +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -0,0 +1,82 @@ +/** + * 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. Moves service-workers.js to ${appVersion}/service-workers.js + * 3. Moves service-workers.js.map if it exists + * 4. Updates source map references in the moved JS file + * 5. Warns if the expected files are not found + */ +export default function moveServiceWorkerFile(appVersion, staging) { + let outputDir = '' + + return { + name: 'vite-plugin-move-sw', + writeBundle: { + order: 'post', + async handler() { + 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 SW file exists + await fs.access(oldSwPath) + + // Ensure target directory exists + await fs.mkdir(path.dirname(newSwPath), { recursive: true }) + + // Move the service worker file + await fs.rename(oldSwPath, newSwPath) + console.log(`[vite-plugin-move-sw] Moved ${swFileName} to ${appVersion}/`) + + // Handle source map if it exists + try { + await fs.access(oldSwMapPath) + + // Move the source map file + await fs.rename(oldSwMapPath, newSwMapPath) + console.log( + `[vite-plugin-move-sw] Moved ${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=${appVersion}/service-workers.js.map` + ) + + await fs.writeFile(newSwPath, updatedContent, 'utf-8') + console.log('[vite-plugin-move-sw] Updated source map reference') + } catch (mapError) { + // 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) { + this.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..8e63efc118 --- /dev/null +++ b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js @@ -0,0 +1,129 @@ +/** + * Vite plugin that versions the service worker path in generated bundles. + * + * This plugin: + * 1. Finds chunks containing service worker registration code from the virtual:pwa-register module + * 2. Replaces "./service-workers.js" with the versioned path (e.g., "./v1.59.0/service-workers.js") + * 3. Injects scope configuration {scope:"/"} to ensure SW controls the entire origin + * 4. Emits a validation file (sw-ready.json) to indicate successful configuration + * 5. 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) { + 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 the 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 + + // Replace the SW path with versioned path + chunk.code = chunk.code.replace( + /new\s+(\w+)\("\.\/service-workers\.js"/g, + `new $1("./${appVersion}/service-workers.js"` + ) + + // Pattern 2: Also check for scope configuration and inject if needed + // Look for the options object: new Workbox("path", {scope:"./", type:"classic"}) + // We need to ensure scope is set to "/" for root-level control + const scopePattern = /new\s+(\w+)\("\.\/[^"]+",\s*\{([^}]*)\}/g + + chunk.code = chunk.code.replace(scopePattern, (match, constructor, opts) => { + // Check if scope is already defined + if (opts.includes('scope:')) { + // Replace existing scope with "/" + const newOpts = opts.replace(/scope:\s*"[^"]*"/, 'scope:"/"') + return `new ${constructor}("./${appVersion}/service-workers.js",{${newOpts}}` + } else { + // Inject scope if not present + return `new ${constructor}("./${appVersion}/service-workers.js",{scope:"/",${opts}}` + } + }) + } + + // Pattern 3: Look for standalone string references to the SW path + // (in case registration uses a different pattern) + if (chunk.code.includes('"./service-workers.js"')) { + console.log( + `[vite-plugin-version-sw-path] Found SW path reference in ${fileName}` + ) + swPatternFound = true + + chunk.code = chunk.code.replace( + /"\.\/service-workers\.js"/g, + `"./${appVersion}/service-workers.js"` + ) + } + } + } + + 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 ? `${appVersion}/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..d73e3b6ebb 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 @@ -51,6 +53,7 @@ function manualChunks(id) { export default defineConfig(({ mode }) => { // We use "test" only to decide if we want to add Vue dev tools or not (we don't want them when testing). // It otherwise is "development" mode... + console.log('Vite mode:', mode, appVersion) const definitiveMode = mode === 'test' ? 'development' : mode return { base: './', @@ -68,13 +71,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 +90,7 @@ export default defineConfig(({ mode }) => { }, }, }), - generateBuildInfo(stagings[definitiveMode], appVersion), + generateBuildInfo(stagings[definitiveMode], appVersion, definitiveMode), // 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 +117,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 - only in production mode + ...(mode !== 'development' + ? [ + versionServiceWorkerPath(appVersion, stagings[definitiveMode]), + moveServiceWorkerFile(appVersion, stagings[definitiveMode]), + ] + : []), mode === 'development' ? vueDevTools() : {}, ], resolve: { From 789278a580bb0249051dd91a20a371c3aee92c3d Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Tue, 24 Feb 2026 13:09:01 +0100 Subject: [PATCH 2/6] PB-1878: fix lint --- .../src/utils/offline/OfflineReadinessStatus.vue | 2 +- .../mapviewer/vite-plugins/vite-plugin-move-sw.js | 15 ++++++++++++--- .../vite-plugins/vite-plugin-version-sw-path.js | 6 +++++- packages/mapviewer/vite.config.mts | 1 + 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue index daf93dce0f..cb07c37c7f 100644 --- a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue +++ b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue @@ -79,7 +79,7 @@ async function checkSwValidation() { * versions of cached files online */ function registerPeriodicSync(serviceWorkerUrl: string, registration: ServiceWorkerRegistration) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises + setInterval(async () => { if ('onLine' in navigator && !navigator.onLine) { return diff --git a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js index b4c1d1b1e7..a2bed6ff2e 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -2,13 +2,14 @@ * 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. Moves service-workers.js to ${appVersion}/service-workers.js * 3. Moves service-workers.js.map if it exists * 4. Updates source map references in the moved JS file * 5. Warns if the expected files are not found */ -export default function moveServiceWorkerFile(appVersion, staging) { +export default function moveServiceWorkerFile(appVersion) { let outputDir = '' return { @@ -19,6 +20,7 @@ export default function moveServiceWorkerFile(appVersion, staging) { const fs = await import('fs/promises') const path = await import('path') + // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Moving service worker to versioned directory...') const swFileName = 'service-workers.js' @@ -39,6 +41,7 @@ export default function moveServiceWorkerFile(appVersion, staging) { // Move the service worker file await fs.rename(oldSwPath, newSwPath) + // eslint-disable-next-line no-console console.log(`[vite-plugin-move-sw] Moved ${swFileName} to ${appVersion}/`) // Handle source map if it exists @@ -47,6 +50,7 @@ export default function moveServiceWorkerFile(appVersion, staging) { // Move the source map file await fs.rename(oldSwMapPath, newSwMapPath) + // eslint-disable-next-line no-console console.log( `[vite-plugin-move-sw] Moved ${swMapFileName} to ${appVersion}/` ) @@ -59,12 +63,17 @@ export default function moveServiceWorkerFile(appVersion, staging) { ) await fs.writeFile(newSwPath, updatedContent, 'utf-8') + // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Updated source map reference') } catch (mapError) { // Source map doesn't exist, which is fine - console.log('[vite-plugin-move-sw] No source map found (this is OK)') + // eslint-disable-next-line no-console + console.log( + '[vite-plugin-move-sw] No source map found (this is OK)', + mapError + ) } - + // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Service worker relocation complete') } catch (error) { this.warn( diff --git a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js index 8e63efc118..335a0a51a7 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js @@ -21,6 +21,7 @@ export default function versionServiceWorkerPath(appVersion, staging) { }, generateBundle(options, bundle) { + // eslint-disable-next-line no-console console.log('[vite-plugin-version-sw-path] Scanning bundles for SW registration...') for (const fileName in bundle) { @@ -32,6 +33,7 @@ export default function versionServiceWorkerPath(appVersion, staging) { const workboxPattern = /new\s+(\w+)\("\.\/service-workers\.js"/g if (workboxPattern.test(chunk.code)) { + // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Found SW registration in ${fileName}` ) @@ -64,6 +66,7 @@ export default function versionServiceWorkerPath(appVersion, staging) { // Pattern 3: Look for standalone string references to the SW path // (in case registration uses a different pattern) if (chunk.code.includes('"./service-workers.js"')) { + // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Found SW path reference in ${fileName}` ) @@ -109,12 +112,13 @@ export default function versionServiceWorkerPath(appVersion, staging) { JSON.stringify(validationData, null, 2), 'utf-8' ) - + // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Validation file written: ${validationFilePath}` ) if (!swPatternFound) { + // eslint-disable-next-line no-console console.error( '[vite-plugin-version-sw-path] ERROR: SW versioning validation FAILED' ) diff --git a/packages/mapviewer/vite.config.mts b/packages/mapviewer/vite.config.mts index d73e3b6ebb..f992287397 100644 --- a/packages/mapviewer/vite.config.mts +++ b/packages/mapviewer/vite.config.mts @@ -53,6 +53,7 @@ function manualChunks(id) { export default defineConfig(({ mode }) => { // We use "test" only to decide if we want to add Vue dev tools or not (we don't want them when testing). // It otherwise is "development" mode... + // eslint-disable-next-line no-console console.log('Vite mode:', mode, appVersion) const definitiveMode = mode === 'test' ? 'development' : mode return { From ef87457fee44b30c7717fe35450b7d829f622004 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Tue, 24 Feb 2026 13:32:01 +0100 Subject: [PATCH 3/6] PB-1878: fix lint --- packages/mapviewer/src/service-workers.ts | 2 + .../utils/offline/OfflineReadinessStatus.vue | 37 +++++++++++-------- .../vite-plugins/vite-plugin-move-sw.js | 2 +- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/mapviewer/src/service-workers.ts b/packages/mapviewer/src/service-workers.ts index 7dacf3953b..eca57e3091 100644 --- a/packages/mapviewer/src/service-workers.ts +++ b/packages/mapviewer/src/service-workers.ts @@ -18,6 +18,7 @@ declare let self: ServiceWorkerGlobalScope // disabling workbox's console.debug (comment that line if you need them) self.__WB_DISABLE_DEV_LOGS = true +// eslint-disable-next-line no-console console.log('Service Worker: workbox dev logs are disabled:', 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. @@ -27,6 +28,7 @@ console.log('Service Worker: workbox dev logs are disabled:', self.__WB_DISABLE_ // 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) { + // eslint-disable-next-line no-console console.log('Service Worker: initializing the service worker...') // self.__WB_MANIFEST is the default injection point precacheAndRoute(self.__WB_MANIFEST) diff --git a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue index cb07c37c7f..65a4e3f574 100644 --- a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue +++ b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue @@ -79,23 +79,24 @@ async function checkSwValidation() { * versions of cached files online */ function registerPeriodicSync(serviceWorkerUrl: string, registration: ServiceWorkerRegistration) { - - setInterval(async () => { - if ('onLine' in navigator && !navigator.onLine) { - return - } + setInterval(() => { + void (async () => { + if ('onLine' in navigator && !navigator.onLine) { + return + } - const resp = await fetch(serviceWorkerUrl, { - cache: 'no-store', - headers: { + const resp = await fetch(serviceWorkerUrl, { cache: 'no-store', - 'cache-control': 'no-cache', - }, - }) + headers: { + cache: 'no-store', + 'cache-control': 'no-cache', + }, + }) - if (resp?.status === 200) { - await registration.update() - } + if (resp?.status === 200) { + await registration.update() + } + })() }, period) } @@ -134,7 +135,13 @@ const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW({ onMounted(() => { // Check SW validation status on component mount - checkSwValidation() + checkSwValidation().catch((error) => { + log.error({ + title: 'OfflineReadinessStatus', + titleColor: LogPreDefinedColor.Sky, + messages: ['Error during SW validation check', error], + }) + }) }) const statusIcon = computed(() => { diff --git a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js index a2bed6ff2e..f181d8e507 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -52,7 +52,7 @@ export default function moveServiceWorkerFile(appVersion) { await fs.rename(oldSwMapPath, newSwMapPath) // eslint-disable-next-line no-console console.log( - `[vite-plugin-move-sw] Moved ${swMapFileName} to ${appVersion}/` + `[vite-plugin-move-sw] Moved from ${swMapFileName} to ${appVersion}/` ) // Update source map reference in the JS file From da99954a2db2813237b3d7fbb223e8718408aa34 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Tue, 24 Feb 2026 14:33:08 +0100 Subject: [PATCH 4/6] PB-1878: activate service worker on dev --- packages/mapviewer/src/service-workers.ts | 68 +++++----- .../utils/offline/OfflineReadinessStatus.vue | 113 +++++++++++++---- .../cypress/tests-e2e/featureSelection.cy.js | 1 - .../vite-plugin-generate-build-info.js | 2 +- .../vite-plugins/vite-plugin-move-sw.js | 116 +++++++++--------- .../vite-plugin-version-sw-path.js | 44 ++----- packages/mapviewer/vite.config.mts | 4 +- 7 files changed, 194 insertions(+), 154 deletions(-) diff --git a/packages/mapviewer/src/service-workers.ts b/packages/mapviewer/src/service-workers.ts index eca57e3091..cdafa1d840 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,14 +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 -// eslint-disable-next-line no-console -console.log('Service Worker: workbox dev logs are disabled:', self.__WB_DISABLE_DEV_LOGS) +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 @@ -27,42 +33,42 @@ console.log('Service Worker: workbox dev logs are disabled:', self.__WB_DISABLE_ // 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) { - // eslint-disable-next-line no-console - console.log('Service Worker: initializing the service worker...') +if (import.meta.env.MODE !== 'test') { + log.debug({ + title: 'Service Worker: validation passed', + 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 65a4e3f574..ef61cd87c3 100644 --- a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue +++ b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue @@ -20,6 +20,7 @@ const { withText = false } = defineProps<{ const isServiceWorkerActive = ref(false) const swValidationFailed = ref(false) +const swInsecureContext = ref(false) const { t } = useI18n() @@ -27,6 +28,10 @@ const { t } = useI18n() * Check if service worker versioning/configuration is valid by fetching the validation file */ async function checkSwValidation() { + if (import.meta.env.DEV || swInsecureContext.value) { + return + } + try { const response = await fetch(`${APP_VERSION}/sw-ready.json`, { cache: 'no-store', @@ -45,6 +50,19 @@ async function checkSwValidation() { return } + 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) { @@ -100,38 +118,68 @@ function registerPeriodicSync(serviceWorkerUrl: string, registration: ServiceWor }, period) } -const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW({ - immediate: true, - onRegisteredSW(serviceWorkerUrl, registration) { - log.debug({ - title: 'OfflineReadinessStatus', - titleColor: LogPreDefinedColor.Sky, - messages: ['ServiceWorker registration pending', registration], - }) - if (registration?.active?.state === 'activated') { +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], + }) + }, + 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 @@ -145,6 +193,9 @@ onMounted(() => { }) const statusIcon = computed(() => { + if (swInsecureContext.value) { + return 'triangle-exclamation' + } if (swValidationFailed.value) { return 'circle-xmark' } @@ -157,11 +208,18 @@ const statusIcon = computed(() => { return 'circle-notch' }) const shouldStatusIconSpin = computed( - () => !swValidationFailed.value && !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}: Service worker requires a secure context (HTTPS with a trusted certificate).` + } if (swValidationFailed.value) { return `${title}: Service worker configuration error. Offline functionality may not work correctly.` } @@ -231,11 +289,14 @@ function refreshCache() { :spin="shouldStatusIconSpin" :class="{ 'tw:text-lime-500': offlineReady && !swValidationFailed, - 'tw:text-amber-500': needRefresh, + 'tw:text-amber-500': needRefresh || swInsecureContext, 'tw:text-red-600': swValidationFailed, }" size="sm" /> + + Service worker requires HTTPS with a trusted certificate + Service worker configuration error 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 a837c6af39..953523a227 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-generate-build-info.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-generate-build-info.js @@ -91,7 +91,7 @@ export default function generateBuildInfo(staging, version, mode) { }, serviceWorker: { path: - mode === 'production' + mode !== 'test' ? `${version}/service-workers.js` : 'service-workers.js', version: version, diff --git a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js index f181d8e507..77c978b203 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -4,7 +4,8 @@ * This plugin: * * 1. Runs after VitePWA generates the service worker file at the root of dist - * 2. Moves service-workers.js to ${appVersion}/service-workers.js + * 2. Copies service-workers.js to ${appVersion}/service-workers.js + * 3. Rewrites root service-workers.js as a bootstrap loader importing the versioned SW * 3. Moves service-workers.js.map if it exists * 4. Updates source map references in the moved JS file * 5. Warns if the expected files are not found @@ -14,74 +15,77 @@ export default function moveServiceWorkerFile(appVersion) { return { name: 'vite-plugin-move-sw', - writeBundle: { - order: 'post', - async handler() { - const fs = await import('fs/promises') - const path = await import('path') + enforce: 'post', + async closeBundle() { + const fs = await import('fs/promises') + const path = await import('path') - // eslint-disable-next-line no-console - console.log('[vite-plugin-move-sw] Moving service worker to versioned directory...') - - const swFileName = 'service-workers.js' - const swMapFileName = 'service-workers.js.map' + // eslint-disable-next-line no-console + console.log('[vite-plugin-move-sw] Moving service worker to versioned directory...') - const oldSwPath = path.join(outputDir, swFileName) - const newSwPath = path.join(outputDir, appVersion, swFileName) + const swFileName = 'service-workers.js' + const swMapFileName = 'service-workers.js.map' - const oldSwMapPath = path.join(outputDir, swMapFileName) - const newSwMapPath = path.join(outputDir, appVersion, swMapFileName) + const oldSwPath = path.join(outputDir, swFileName) + const newSwPath = path.join(outputDir, appVersion, swFileName) - try { - // Check if SW file exists - await fs.access(oldSwPath) + const oldSwMapPath = path.join(outputDir, swMapFileName) + const newSwMapPath = path.join(outputDir, appVersion, swMapFileName) - // Ensure target directory exists - await fs.mkdir(path.dirname(newSwPath), { recursive: true }) + try { + // Check if the SW file exists + await fs.access(oldSwPath) - // Move the service worker file - await fs.rename(oldSwPath, newSwPath) - // eslint-disable-next-line no-console - console.log(`[vite-plugin-move-sw] Moved ${swFileName} to ${appVersion}/`) + // Ensure target directory exists + await fs.mkdir(path.dirname(newSwPath), { recursive: true }) - // Handle source map if it exists - try { - await fs.access(oldSwMapPath) + // Copy the service worker file to versioned location + await fs.copyFile(oldSwPath, newSwPath) + // eslint-disable-next-line no-console + console.log(`[vite-plugin-move-sw] Copied ${swFileName} to ${appVersion}/`) - // Move the source map file - await fs.rename(oldSwMapPath, newSwMapPath) - // eslint-disable-next-line no-console - console.log( - `[vite-plugin-move-sw] Moved from ${swMapFileName} 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' + ) + // eslint-disable-next-line no-console + console.log('[vite-plugin-move-sw] Created root SW bootstrap loader') - // 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=${appVersion}/service-workers.js.map` - ) + // Handle source map if it exists + try { + await fs.access(oldSwMapPath) - await fs.writeFile(newSwPath, updatedContent, 'utf-8') - // eslint-disable-next-line no-console - console.log('[vite-plugin-move-sw] Updated source map reference') - } catch (mapError) { - // Source map doesn't exist, which is fine - // eslint-disable-next-line no-console - console.log( - '[vite-plugin-move-sw] No source map found (this is OK)', - mapError - ) - } + // Copy the source map file + await fs.copyFile(oldSwMapPath, newSwMapPath) // eslint-disable-next-line no-console - console.log('[vite-plugin-move-sw] Service worker relocation complete') - } catch (error) { - this.warn( - `[vite-plugin-move-sw] WARNING: Failed to move service worker file: ${error.message}. ` + - `Expected file at: ${oldSwPath}. SW versioning may have failed.` + 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') + // eslint-disable-next-line no-console + console.log('[vite-plugin-move-sw] Updated source map reference') + } catch { + // Source map doesn't exist, which is fine + // eslint-disable-next-line no-console + console.log('[vite-plugin-move-sw] No source map found (this is OK)') } - }, + + // eslint-disable-next-line no-console + console.log('[vite-plugin-move-sw] Service worker relocation complete') + } catch (error) { + this.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) { diff --git a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js index 335a0a51a7..301a47e6fa 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js @@ -1,12 +1,11 @@ /** - * Vite plugin that versions the service worker path in generated bundles. + * 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. Replaces "./service-workers.js" with the versioned path (e.g., "./v1.59.0/service-workers.js") - * 3. Injects scope configuration {scope:"/"} to ensure SW controls the entire origin - * 4. Emits a validation file (sw-ready.json) to indicate successful configuration - * 5. Warns if no SW registration pattern is found (validation failure) + * 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 @@ -28,7 +27,7 @@ export default function versionServiceWorkerPath(appVersion, staging) { const chunk = bundle[fileName] if (chunk.type === 'chunk' && chunk.code) { - // Pattern 1: Look for Workbox registration with the SW path + // 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 @@ -38,44 +37,15 @@ export default function versionServiceWorkerPath(appVersion, staging) { `[vite-plugin-version-sw-path] Found SW registration in ${fileName}` ) swPatternFound = true - - // Replace the SW path with versioned path - chunk.code = chunk.code.replace( - /new\s+(\w+)\("\.\/service-workers\.js"/g, - `new $1("./${appVersion}/service-workers.js"` - ) - - // Pattern 2: Also check for scope configuration and inject if needed - // Look for the options object: new Workbox("path", {scope:"./", type:"classic"}) - // We need to ensure scope is set to "/" for root-level control - const scopePattern = /new\s+(\w+)\("\.\/[^"]+",\s*\{([^}]*)\}/g - - chunk.code = chunk.code.replace(scopePattern, (match, constructor, opts) => { - // Check if scope is already defined - if (opts.includes('scope:')) { - // Replace existing scope with "/" - const newOpts = opts.replace(/scope:\s*"[^"]*"/, 'scope:"/"') - return `new ${constructor}("./${appVersion}/service-workers.js",{${newOpts}}` - } else { - // Inject scope if not present - return `new ${constructor}("./${appVersion}/service-workers.js",{scope:"/",${opts}}` - } - }) } - // Pattern 3: Look for standalone string references to the SW path - // (in case registration uses a different pattern) + // Pattern 2: Look for standalone string references to the root SW path if (chunk.code.includes('"./service-workers.js"')) { // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Found SW path reference in ${fileName}` ) swPatternFound = true - - chunk.code = chunk.code.replace( - /"\.\/service-workers\.js"/g, - `"./${appVersion}/service-workers.js"` - ) } } } @@ -95,7 +65,7 @@ export default function versionServiceWorkerPath(appVersion, staging) { // Emit validation file indicating whether SW versioning succeeded const validationData = { swVersioned: swPatternFound, - swPath: swPatternFound ? `${appVersion}/service-workers.js` : null, + swPath: swPatternFound ? 'service-workers.js' : null, timestamp: new Date().toISOString(), staging, version: appVersion, diff --git a/packages/mapviewer/vite.config.mts b/packages/mapviewer/vite.config.mts index f992287397..59b71304d6 100644 --- a/packages/mapviewer/vite.config.mts +++ b/packages/mapviewer/vite.config.mts @@ -161,8 +161,8 @@ export default defineConfig(({ mode }) => { ], }, }), - // Service worker versioning plugins - only in production mode - ...(mode !== 'development' + // Service worker versioning plugins - enabled for all non-test builds + ...(mode !== 'test' ? [ versionServiceWorkerPath(appVersion, stagings[definitiveMode]), moveServiceWorkerFile(appVersion, stagings[definitiveMode]), From 1f0587516fb69e076cda236fc9e92cc093a6a254 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Thu, 26 Feb 2026 10:58:51 +0100 Subject: [PATCH 5/6] PB-1878: clean up workers --- packages/mapviewer/src/service-workers.ts | 2 +- .../src/utils/offline/OfflineReadinessStatus.vue | 3 ++- packages/mapviewer/vite-plugins/vite-plugin-move-sw.js | 6 ++---- .../mapviewer/vite-plugins/vite-plugin-version-sw-path.js | 1 + packages/mapviewer/vite.config.mts | 8 +++----- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/mapviewer/src/service-workers.ts b/packages/mapviewer/src/service-workers.ts index cdafa1d840..e2299644b8 100644 --- a/packages/mapviewer/src/service-workers.ts +++ b/packages/mapviewer/src/service-workers.ts @@ -35,7 +35,7 @@ log.debug({ // are testing things with Cypress if (import.meta.env.MODE !== 'test') { log.debug({ - title: 'Service Worker: validation passed', + title: 'Service Worker: starting initialization', titleColor: LogPreDefinedColor.Sky, messages: ['Service Worker: initializing the service worker...'], }) diff --git a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue index ef61cd87c3..d4ac1e73cd 100644 --- a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue +++ b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue @@ -106,7 +106,6 @@ function registerPeriodicSync(serviceWorkerUrl: string, registration: ServiceWor const resp = await fetch(serviceWorkerUrl, { cache: 'no-store', headers: { - cache: 'no-store', 'cache-control': 'no-cache', }, }) @@ -135,6 +134,8 @@ if (window.isSecureContext) { titleColor: LogPreDefinedColor.Sky, messages: ['ServiceWorker registration failed', error], }) + // mark service worker validation as failed + swValidationFailed.value = true }, onRegisteredSW(serviceWorkerUrl, registration) { log.debug({ diff --git a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js index 77c978b203..fab17f6235 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -5,10 +5,8 @@ * * 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 - * 3. Moves service-workers.js.map if it exists - * 4. Updates source map references in the moved JS file - * 5. Warns if the expected files are not found + * 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 = '' diff --git a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js index 301a47e6fa..2ca8ad5c12 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js @@ -20,6 +20,7 @@ export default function versionServiceWorkerPath(appVersion, staging) { }, generateBundle(options, bundle) { + swPatternFound = false // eslint-disable-next-line no-console console.log('[vite-plugin-version-sw-path] Scanning bundles for SW registration...') diff --git a/packages/mapviewer/vite.config.mts b/packages/mapviewer/vite.config.mts index 59b71304d6..4ea3589e4e 100644 --- a/packages/mapviewer/vite.config.mts +++ b/packages/mapviewer/vite.config.mts @@ -42,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' @@ -53,8 +53,6 @@ function manualChunks(id) { export default defineConfig(({ mode }) => { // We use "test" only to decide if we want to add Vue dev tools or not (we don't want them when testing). // It otherwise is "development" mode... - // eslint-disable-next-line no-console - console.log('Vite mode:', mode, appVersion) const definitiveMode = mode === 'test' ? 'development' : mode return { base: './', @@ -91,7 +89,7 @@ export default defineConfig(({ mode }) => { }, }, }), - generateBuildInfo(stagings[definitiveMode], appVersion, definitiveMode), + 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({ @@ -165,7 +163,7 @@ export default defineConfig(({ mode }) => { ...(mode !== 'test' ? [ versionServiceWorkerPath(appVersion, stagings[definitiveMode]), - moveServiceWorkerFile(appVersion, stagings[definitiveMode]), + moveServiceWorkerFile(appVersion), ] : []), mode === 'development' ? vueDevTools() : {}, From 14225c952aa558fac10975c51e5442c5f5026bb0 Mon Sep 17 00:00:00 2001 From: Felix Sommer Date: Fri, 27 Feb 2026 09:27:31 +0100 Subject: [PATCH 6/6] PB-1878: add translations --- packages/mapviewer/src/modules/i18n/locales/de.json | 4 +++- packages/mapviewer/src/modules/i18n/locales/en.json | 4 +++- packages/mapviewer/src/modules/i18n/locales/fr.json | 4 +++- packages/mapviewer/src/modules/i18n/locales/it.json | 4 +++- packages/mapviewer/src/modules/i18n/locales/rm.json | 4 +++- .../src/utils/offline/OfflineReadinessStatus.vue | 8 ++++---- .../mapviewer/vite-plugins/vite-plugin-move-sw.js | 11 ++--------- .../vite-plugins/vite-plugin-version-sw-path.js | 6 +----- 8 files changed, 22 insertions(+), 23 deletions(-) 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/utils/offline/OfflineReadinessStatus.vue b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue index d4ac1e73cd..41012fab2c 100644 --- a/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue +++ b/packages/mapviewer/src/utils/offline/OfflineReadinessStatus.vue @@ -219,10 +219,10 @@ const tooltipContent = computed(() => { const title = t('offline_modal_title') let extraInfoKey = 'wait_data_loading' if (swInsecureContext.value) { - return `${title}: Service worker requires a secure context (HTTPS with a trusted certificate).` + return `${title}: ${t('offline_sw_secure_context_required')}` } if (swValidationFailed.value) { - return `${title}: Service worker configuration error. Offline functionality may not work correctly.` + return `${title}: ${t('offline_sw_configuration_error')}` } if (needRefresh.value) { extraInfoKey = 'offline_cache_obsolete' @@ -296,10 +296,10 @@ function refreshCache() { size="sm" /> - Service worker requires HTTPS with a trusted certificate + {{ t('offline_sw_secure_context_required') }} - Service worker configuration error + {{ t('offline_sw_configuration_error') }} {{ t('wait_data_loading') }} diff --git a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js index fab17f6235..fb4407c763 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-move-sw.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * Vite plugin that moves the generated service worker file to a versioned directory. * @@ -18,7 +19,6 @@ export default function moveServiceWorkerFile(appVersion) { const fs = await import('fs/promises') const path = await import('path') - // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Moving service worker to versioned directory...') const swFileName = 'service-workers.js' @@ -39,7 +39,6 @@ export default function moveServiceWorkerFile(appVersion) { // Copy the service worker file to versioned location await fs.copyFile(oldSwPath, newSwPath) - // eslint-disable-next-line no-console console.log(`[vite-plugin-move-sw] Copied ${swFileName} to ${appVersion}/`) // Keep a scope-safe bootstrap file at root that imports the versioned SW script @@ -48,7 +47,6 @@ export default function moveServiceWorkerFile(appVersion) { `/* generated by vite-plugin-move-sw */\nimportScripts('./${appVersion}/service-workers.js')\n`, 'utf-8' ) - // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Created root SW bootstrap loader') // Handle source map if it exists @@ -57,7 +55,6 @@ export default function moveServiceWorkerFile(appVersion) { // Copy the source map file await fs.copyFile(oldSwMapPath, newSwMapPath) - // eslint-disable-next-line no-console console.log(`[vite-plugin-move-sw] Copied ${swMapFileName} to ${appVersion}/`) // Update source map reference in the JS file @@ -68,18 +65,14 @@ export default function moveServiceWorkerFile(appVersion) { ) await fs.writeFile(newSwPath, updatedContent, 'utf-8') - // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Updated source map reference') } catch { // Source map doesn't exist, which is fine - // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] No source map found (this is OK)') } - - // eslint-disable-next-line no-console console.log('[vite-plugin-move-sw] Service worker relocation complete') } catch (error) { - this.warn( + console.warn( `[vite-plugin-move-sw] WARNING: Failed to move service worker file: ${error.message}. ` + `Expected file at: ${oldSwPath}. SW versioning may have failed.` ) diff --git a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js index 2ca8ad5c12..2bbcfcb47c 100644 --- a/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js +++ b/packages/mapviewer/vite-plugins/vite-plugin-version-sw-path.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * Vite plugin that validates the service worker registration path and emits a build validation file. * @@ -21,7 +22,6 @@ export default function versionServiceWorkerPath(appVersion, staging) { generateBundle(options, bundle) { swPatternFound = false - // eslint-disable-next-line no-console console.log('[vite-plugin-version-sw-path] Scanning bundles for SW registration...') for (const fileName in bundle) { @@ -33,7 +33,6 @@ export default function versionServiceWorkerPath(appVersion, staging) { const workboxPattern = /new\s+(\w+)\("\.\/service-workers\.js"/g if (workboxPattern.test(chunk.code)) { - // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Found SW registration in ${fileName}` ) @@ -42,7 +41,6 @@ export default function versionServiceWorkerPath(appVersion, staging) { // Pattern 2: Look for standalone string references to the root SW path if (chunk.code.includes('"./service-workers.js"')) { - // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Found SW path reference in ${fileName}` ) @@ -83,13 +81,11 @@ export default function versionServiceWorkerPath(appVersion, staging) { JSON.stringify(validationData, null, 2), 'utf-8' ) - // eslint-disable-next-line no-console console.log( `[vite-plugin-version-sw-path] Validation file written: ${validationFilePath}` ) if (!swPatternFound) { - // eslint-disable-next-line no-console console.error( '[vite-plugin-version-sw-path] ERROR: SW versioning validation FAILED' )