From 5eee900fd388e8cc3f223e63b4bb1be7662e4137 Mon Sep 17 00:00:00 2001 From: Simon Larsen Date: Wed, 30 Jul 2025 11:18:55 +0100 Subject: [PATCH] feat: Implement service worker generation script and update build process for Dashboard --- Common/Scripts/generate-service-worker.js | 144 ++++++++ Dashboard/.gitignore | 4 + Dashboard/package.json | 9 +- Dashboard/public/sw.js | 30 +- Dashboard/scripts/generate-sw-dev.sh | 21 ++ Dashboard/scripts/generate-sw.js | 22 ++ Dashboard/sw.js.template | 397 ++++++++++++++++++++++ 7 files changed, 620 insertions(+), 7 deletions(-) create mode 100755 Common/Scripts/generate-service-worker.js create mode 100755 Dashboard/scripts/generate-sw-dev.sh create mode 100755 Dashboard/scripts/generate-sw.js create mode 100644 Dashboard/sw.js.template diff --git a/Common/Scripts/generate-service-worker.js b/Common/Scripts/generate-service-worker.js new file mode 100755 index 0000000000..ba0a6d04ff --- /dev/null +++ b/Common/Scripts/generate-service-worker.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +/** + * Universal Service Worker Generator for OneUptime Services + * + * This script can be used by any OneUptime service to generate + * a service worker from a template with dynamic versioning. + * + * Usage: + * node generate-service-worker.js [template-path] [output-path] + * + * Example: + * node generate-service-worker.js sw.js.template public/sw.js + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// Default values +const DEFAULT_APP_VERSION = '1.0.0'; +const DEFAULT_GIT_SHA = 'local'; + +/** + * Get app version from environment or package.json + */ +function getAppVersion(packageJsonPath) { + // First try environment variable (Docker build) + if (process.env.APP_VERSION) { + return process.env.APP_VERSION; + } + + // Fallback to package.json version + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version || DEFAULT_APP_VERSION; + } catch (error) { + console.warn('Could not read package.json, using default version'); + return DEFAULT_APP_VERSION; + } +} + +/** + * Get git SHA from environment + */ +function getGitSha() { + // Try environment variable first (Docker build) + if (process.env.GIT_SHA) { + return process.env.GIT_SHA.substring(0, 8); // Short SHA + } + + // Try to get from git command if available + try { + const { execSync } = require('child_process'); + const gitSha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); + return gitSha; + } catch (error) { + // Fallback to timestamp-based hash for local development + const timestamp = Date.now().toString(); + const hash = crypto.createHash('md5').update(timestamp).digest('hex'); + return hash.substring(0, 8); + } +} + +/** + * Generate service worker from template + */ +function generateServiceWorker(templatePath, outputPath, serviceName = 'OneUptime') { + // Check if template exists + if (!fs.existsSync(templatePath)) { + console.error('❌ Service worker template not found:', templatePath); + process.exit(1); + } + + // Read template + const template = fs.readFileSync(templatePath, 'utf8'); + + // Get version information + const packageJsonPath = path.join(path.dirname(templatePath), 'package.json'); + const appVersion = getAppVersion(packageJsonPath); + const gitSha = getGitSha(); + const buildTimestamp = new Date().toISOString(); + + console.log(`🔧 Generating service worker for ${serviceName}...`); + console.log(` App Version: ${appVersion}`); + console.log(` Git SHA: ${gitSha}`); + console.log(` Build Time: ${buildTimestamp}`); + + // Replace placeholders + const generatedContent = template + .replace(/\{\{APP_VERSION\}\}/g, appVersion) + .replace(/\{\{GIT_SHA\}\}/g, gitSha) + .replace(/\{\{BUILD_TIMESTAMP\}\}/g, buildTimestamp) + .replace(/\{\{SERVICE_NAME\}\}/g, serviceName); + + // Add generation comment at the top + const header = `/* + * Generated Service Worker for ${serviceName} + * + * Generated at: ${buildTimestamp} + * App Version: ${appVersion} + * Git SHA: ${gitSha} + * + * DO NOT EDIT THIS FILE DIRECTLY + * Edit the template file instead and run the generator script + */ + +`; + + const finalContent = header + generatedContent; + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Write generated service worker + fs.writeFileSync(outputPath, finalContent, 'utf8'); + + console.log('✅ Service worker generated successfully:', outputPath); + console.log(` Cache version: oneuptime-v${appVersion}-${gitSha}`); +} + +// Command line interface +if (require.main === module) { + const args = process.argv.slice(2); + const templatePath = args[0] || 'sw.js.template'; + const outputPath = args[1] || 'public/sw.js'; + const serviceName = args[2] || path.basename(process.cwd()); + + try { + // Resolve paths relative to current working directory + const resolvedTemplatePath = path.resolve(templatePath); + const resolvedOutputPath = path.resolve(outputPath); + + generateServiceWorker(resolvedTemplatePath, resolvedOutputPath, serviceName); + } catch (error) { + console.error('❌ Failed to generate service worker:', error.message); + process.exit(1); + } +} + +module.exports = { generateServiceWorker, getAppVersion, getGitSha }; diff --git a/Dashboard/.gitignore b/Dashboard/.gitignore index 3ff05dd7b8..b93b72dd2a 100644 --- a/Dashboard/.gitignore +++ b/Dashboard/.gitignore @@ -19,6 +19,10 @@ node_modules env.js +# Note: public/sw.js is auto-generated from sw.js.template during build +# but should be committed to ensure it exists for deployments +# The file is regenerated with correct versions during Docker build + npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/Dashboard/package.json b/Dashboard/package.json index ec55d0a3ed..8517607bed 100644 --- a/Dashboard/package.json +++ b/Dashboard/package.json @@ -3,13 +3,14 @@ "version": "0.1.0", "private": false, "scripts": { - "dev-build": "NODE_ENV=development node esbuild.config.js", + "generate-sw": "node scripts/generate-sw.js", + "dev-build": "npm run generate-sw && NODE_ENV=development node esbuild.config.js", "dev": "npx nodemon", - "build": "NODE_ENV=production node esbuild.config.js", - "analyze": "analyze=true NODE_ENV=production node esbuild.config.js", + "build": "npm run generate-sw && NODE_ENV=production node esbuild.config.js", + "analyze": "npm run generate-sw && analyze=true NODE_ENV=production node esbuild.config.js", "test": "react-app-rewired test", "eject": "echo 'esbuild does not require eject'", - "compile": "tsc", + "compile": "npm run generate-sw && tsc", "clear-modules": "rm -rf node_modules && rm package-lock.json && npm install", "start": "node --require ts-node/register Serve.ts", "audit": "npm audit --audit-level=low", diff --git a/Dashboard/public/sw.js b/Dashboard/public/sw.js index 392c984579..56b367128f 100644 --- a/Dashboard/public/sw.js +++ b/Dashboard/public/sw.js @@ -1,3 +1,25 @@ +/* + * Generated Service Worker for OneUptime Dashboard + * + * Generated at: 2025-07-30T10:17:31.747Z + * App Version: 0.1.0 + * Git SHA: 0a6cdd11af + * + * DO NOT EDIT THIS FILE DIRECTLY + * Edit the template file instead and run the generator script + */ + +/* + * Generated Service Worker for OneUptime Dashboard + * + * Generated at: 2025-07-30T10:09:37.995Z + * App Version: 0.1.0 + * Git SHA: 0a6cdd11af + * + * DO NOT EDIT THIS FILE DIRECTLY + * Edit the template file instead and run the generator script + */ + /* eslint-disable no-restricted-globals */ // OneUptime Progressive Web App Service Worker @@ -5,8 +27,10 @@ console.log('[ServiceWorker] OneUptime PWA Service Worker Loaded'); -// Cache configuration -const CACHE_VERSION = 'oneuptime-v1.2.0'; // Update this when deploying new versions +// Cache configuration - Updated dynamically during build +// Version format: oneuptime-v{APP_VERSION}-{GIT_SHA} +// This ensures cache invalidation on every deployment +const CACHE_VERSION = 'oneuptime-v0.1.0-0a6cdd11af'; // Auto-generated version const STATIC_CACHE = `${CACHE_VERSION}-static`; const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`; @@ -363,7 +387,7 @@ self.addEventListener('message', function(event) { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } else if (event.data && event.data.type === 'GET_VERSION') { - event.ports[0].postMessage({ version: 'oneuptime-pwa-no-cache' }); + event.ports[0].postMessage({ version: CACHE_VERSION }); } }); diff --git a/Dashboard/scripts/generate-sw-dev.sh b/Dashboard/scripts/generate-sw-dev.sh new file mode 100755 index 0000000000..59ce8305d5 --- /dev/null +++ b/Dashboard/scripts/generate-sw-dev.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Development Service Worker Generation Script +# +# This script can be used during local development to test +# the service worker generation with sample environment variables + +echo "🔧 Generating service worker for local development..." + +# Set sample environment variables for testing +export APP_VERSION="1.0.0-dev" +export GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "local-dev") + +echo "Using APP_VERSION: $APP_VERSION" +echo "Using GIT_SHA: $GIT_SHA" + +# Generate the service worker +node scripts/generate-sw.js + +echo "✅ Service worker generated for development" +echo "🔍 Check public/sw.js to see the generated file" diff --git a/Dashboard/scripts/generate-sw.js b/Dashboard/scripts/generate-sw.js new file mode 100755 index 0000000000..32f1722d70 --- /dev/null +++ b/Dashboard/scripts/generate-sw.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +/** + * Dashboard Service Worker Generator + * + * This script generates the Dashboard service worker from a template, + * using the universal generator from Common/Scripts. + */ + +const path = require('path'); +const { generateServiceWorker } = require('../../Common/Scripts/generate-service-worker'); + +// Generate Dashboard service worker +const templatePath = path.join(__dirname, '..', 'sw.js.template'); +const outputPath = path.join(__dirname, '..', 'public', 'sw.js'); + +try { + generateServiceWorker(templatePath, outputPath, 'OneUptime Dashboard'); +} catch (error) { + console.error('❌ Failed to generate Dashboard service worker:', error.message); + process.exit(1); +} diff --git a/Dashboard/sw.js.template b/Dashboard/sw.js.template new file mode 100644 index 0000000000..e70a2da8d8 --- /dev/null +++ b/Dashboard/sw.js.template @@ -0,0 +1,397 @@ +/* + * Generated Service Worker for OneUptime Dashboard + * + * Generated at: 2025-07-30T10:09:37.995Z + * App Version: 0.1.0 + * Git SHA: 0a6cdd11af + * + * DO NOT EDIT THIS FILE DIRECTLY + * Edit the template file instead and run the generator script + */ + +/* eslint-disable no-restricted-globals */ + +// OneUptime Progressive Web App Service Worker +// Handles push notifications and caching for PWA functionality + +console.log('[ServiceWorker] OneUptime PWA Service Worker Loaded'); + +// Cache configuration - Updated dynamically during build +// Version format: oneuptime-v{APP_VERSION}-{GIT_SHA} +// This ensures cache invalidation on every deployment +const CACHE_VERSION = 'oneuptime-v0.1.0-0a6cdd11af'; // Auto-generated version +const STATIC_CACHE = `${CACHE_VERSION}-static`; +const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`; + +// Cache duration configuration (in milliseconds) +const CACHE_DURATIONS = { + static: 7 * 24 * 60 * 60 * 1000, // 7 days for static assets + dynamic: 24 * 60 * 60 * 1000, // 1 day for dynamic content +}; + +// Assets to cache immediately during install +const STATIC_ASSETS = [ + '/dashboard/', + '/dashboard/manifest.json', + '/dashboard/offline.html', + '/dashboard/assets/img/favicons/favicon.ico', + '/dashboard/assets/img/favicons/android-chrome-192x192.png', + '/dashboard/assets/img/favicons/android-chrome-512x512.png', + // Add other critical assets as needed +]; + +// Install event - cache static assets +self.addEventListener('install', function(event) { + console.log('[ServiceWorker] Installing...'); + + event.waitUntil( + Promise.all([ + // Cache static assets + caches.open(STATIC_CACHE).then(function(cache) { + console.log('[ServiceWorker] Pre-caching static assets'); + return cache.addAll(STATIC_ASSETS.filter(url => url !== '/dashboard/')); + }), + + // Skip waiting to activate immediately + self.skipWaiting() + ]) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', function(event) { + console.log('[ServiceWorker] Activating...'); + + event.waitUntil( + Promise.all([ + // Clean up old caches + caches.keys().then(function(cacheNames) { + return Promise.all( + cacheNames.map(function(cacheName) { + if (cacheName.startsWith('oneuptime-') && + !cacheName.startsWith(CACHE_VERSION)) { + console.log('[ServiceWorker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }), + + // Claim all clients + self.clients.claim() + ]) + ); +}); + +// Fetch event - implement caching strategies +self.addEventListener('fetch', function(event) { + const request = event.request; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip chrome-extension and other non-http(s) requests + if (!url.protocol.startsWith('http')) { + return; + } + + event.respondWith(handleRequest(request)); +}); + +// Request handling with different caching strategies +async function handleRequest(request) { + const url = new URL(request.url); + const pathname = url.pathname; + + try { + // Strategy 1: Network First for HTML pages (with fallback) + if (pathname.endsWith('/') || pathname.endsWith('.html') || + pathname === '/dashboard' || pathname.startsWith('/dashboard/') && !pathname.includes('.')) { + return await networkFirstWithFallback(request, DYNAMIC_CACHE); + } + + // Strategy 2: Cache First for JavaScript, CSS, and other static assets + if (pathname.includes('/dist/') || pathname.match(/\.(js|css|woff|woff2|ttf|otf|eot)$/)) { + return await cacheFirstWithUpdate(request, STATIC_CACHE); + } + + // Strategy 3: Cache First for images and other media + if (pathname.match(/\.(png|jpe?g|gif|svg|ico|webp|avif)$/)) { + return await cacheFirstWithUpdate(request, STATIC_CACHE); + } + + + // Strategy 5: Network First for everything else + return await networkFirstWithFallback(request, DYNAMIC_CACHE); + + } catch (error) { + console.error('[ServiceWorker] Request handling error:', error); + + // Return offline page for navigation requests + if (request.mode === 'navigate') { + const offlineResponse = await caches.match('/dashboard/offline.html'); + if (offlineResponse) { + return offlineResponse; + } + } + + // Return a basic offline response + return new Response('Offline - Please check your internet connection', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/plain' } + }); + } +} + +// Caching Strategy 1: Network First with Fallback (for HTML) +async function networkFirstWithFallback(request, cacheName) { + try { + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + // Cache successful responses + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + console.log('[ServiceWorker] Network failed, trying cache:', request.url); + + const cachedResponse = await caches.match(request); + if (cachedResponse) { + return cachedResponse; + } + + throw error; + } +} + +// Caching Strategy 2: Cache First with Background Update (for static assets) +async function cacheFirstWithUpdate(request, cacheName) { + const cachedResponse = await caches.match(request); + + if (cachedResponse) { + // Return cached version immediately + + // Background update if cache is old + const cacheDate = new Date(cachedResponse.headers.get('date') || 0); + const now = new Date(); + const age = now.getTime() - cacheDate.getTime(); + + if (age > CACHE_DURATIONS.static) { + // Background update - don't await + updateCacheInBackground(request, cacheName); + } + + return cachedResponse; + } + + // Not in cache, fetch from network + try { + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + } catch (error) { + console.error('[ServiceWorker] Failed to fetch asset:', request.url, error); + throw error; + } +} + +// Background cache update +async function updateCacheInBackground(request, cacheName) { + try { + const networkResponse = await fetch(request); + + if (networkResponse.ok) { + const cache = await caches.open(cacheName); + await cache.put(request, networkResponse); + console.log('[ServiceWorker] Background cache update:', request.url); + } + } catch (error) { + console.log('[ServiceWorker] Background update failed:', request.url, error); + } +} + +// Handle push subscription changes +self.addEventListener('pushsubscriptionchange', function(event) { + console.log('[ServiceWorker] Push subscription changed:', event); + + // Re-subscribe to push notifications + event.waitUntil( + self.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: null // This should be set by your application + }) + .then(function(newSubscription) { + console.log('[ServiceWorker] New push subscription:', newSubscription); + // Send new subscription to your server + return updatePushSubscription(newSubscription); + }) + .catch(function(error) { + console.error('[ServiceWorker] Failed to resubscribe to push:', error); + }) + ); +}); + +// Handle push notifications +self.addEventListener('push', function(event) { + console.log('[ServiceWorker] Push received:', event); + console.log('[ServiceWorker] Event data available:', !!event.data); + + if (event.data) { + try { + const dataText = event.data.text(); + console.log('[ServiceWorker] Raw push data:', dataText); + + const data = event.data.json(); + console.log('[ServiceWorker] Push data (parsed):', data); + + const options = { + body: data.body, + icon: data.icon || '/dashboard/assets/img/favicons/android-chrome-192x192.png', + badge: data.badge || '/dashboard/assets/img/favicons/favicon-32x32.png', + tag: data.tag || 'oneuptime-notification', + requireInteraction: data.requireInteraction || false, + actions: data.actions || [], + data: data.data || {}, + silent: false, + renotify: true, + vibrate: [100, 50, 100], + timestamp: Date.now() + }; + + console.log('[ServiceWorker] Showing notification with options:', options); + + event.waitUntil( + self.registration.showNotification(data.title, options) + .then(() => { + console.log('[ServiceWorker] Notification shown successfully'); + }) + .catch((error) => { + console.error('[ServiceWorker] Error showing notification:', error); + }) + ); + } catch (error) { + console.error('[ServiceWorker] Error parsing push data:', error); + const rawData = event.data ? event.data.text() : 'No data'; + console.log('[ServiceWorker] Raw event data:', rawData); + + // Show fallback notification + event.waitUntil( + self.registration.showNotification('OneUptime Notification', { + body: 'You have a new notification from OneUptime', + icon: '/dashboard/assets/img/favicons/android-chrome-192x192.png', + tag: 'oneuptime-fallback', + data: { url: '/dashboard' } + }) + ); + } + } else { + console.log('[ServiceWorker] Push event received but no data'); + + // Show default notification + event.waitUntil( + self.registration.showNotification('OneUptime', { + body: 'You have a new notification', + icon: '/dashboard/assets/img/favicons/android-chrome-192x192.png', + tag: 'oneuptime-default', + data: { url: '/dashboard' } + }) + ); + } +}); + +// Handle notification clicks +self.addEventListener('notificationclick', function(event) { + console.log('[ServiceWorker] Notification clicked:', event.notification.tag); + event.notification.close(); + + const clickAction = event.action; + const notificationData = event.notification.data || {}; + + let targetUrl = '/dashboard'; + + if (clickAction && notificationData[clickAction]) { + // Handle action button clicks + targetUrl = notificationData[clickAction].url || targetUrl; + } else { + // Handle main notification click + targetUrl = notificationData.url || + notificationData.clickAction || + notificationData.link || + targetUrl; + } + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then(function(clientList) { + // Check if there's already a OneUptime window open + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + const clientUrl = new URL(client.url); + + if (clientUrl.origin === self.location.origin && 'focus' in client) { + // Navigate to the target URL and focus the window + return client.navigate(targetUrl) + .then(() => client.focus()); + } + } + + // If no OneUptime window is open, open a new one + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + .catch(function(error) { + console.error('[ServiceWorker] Error handling notification click:', error); + }) + ); +}); + +// Handle notification close events +self.addEventListener('notificationclose', function(event) { + console.log('[ServiceWorker] Notification closed:', event.notification.tag); +}); + +// Handle background sync - removed offline functionality +self.addEventListener('sync', function(event) { + console.log('[ServiceWorker] Background sync:', event.tag); + // Background sync events can still be handled but no offline caching +}); + +// Handle messages from the main thread +self.addEventListener('message', function(event) { + console.log('[ServiceWorker] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } else if (event.data && event.data.type === 'GET_VERSION') { + event.ports[0].postMessage({ version: CACHE_VERSION }); + } +}); + +// Helper function to update push subscription +function updatePushSubscription(subscription) { + return fetch('/api/push-subscription', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(subscription) + }) + .catch(function(error) { + console.error('[ServiceWorker] Error updating push subscription:', error); + }); +} + +console.log('[ServiceWorker] OneUptime PWA Service Worker Ready');