From a86f13202f651e13abcd91929afc1aef4bc91c96 Mon Sep 17 00:00:00 2001 From: Tam Le Date: Tue, 28 Oct 2025 21:05:00 -0400 Subject: [PATCH 1/4] feat: implement PWA installability with service worker and offline support - Add PWA manifest with proper metadata and icons - Implement service worker for caching and offline functionality - Enable app installation on supported browsers - Add offline support for core app functionality - Update HTML meta tags for PWA compliance --- app/index.html | 5 +- app/public/manifest.webmanifest | 17 +++++++ app/public/pwa-icon.svg | 15 ++++++ app/public/sw.js | 68 ++++++++++++++++++++++++++ app/src/main.tsx | 3 ++ app/src/service-worker-registration.ts | 35 +++++++++++++ 6 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 app/public/manifest.webmanifest create mode 100644 app/public/pwa-icon.svg create mode 100644 app/public/sw.js create mode 100644 app/src/service-worker-registration.ts diff --git a/app/index.html b/app/index.html index bded0aa..300b83c 100644 --- a/app/index.html +++ b/app/index.html @@ -7,7 +7,10 @@ - + + + + Echo Sphere diff --git a/app/public/manifest.webmanifest b/app/public/manifest.webmanifest new file mode 100644 index 0000000..ac3abc5 --- /dev/null +++ b/app/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Web Framework Showcase", + "short_name": "WebFramework", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0f172a", + "description": "Explore the Web Framework demo application even when you are offline.", + "icons": [ + { + "src": "/pwa-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/app/public/pwa-icon.svg b/app/public/pwa-icon.svg new file mode 100644 index 0000000..b3a1c9a --- /dev/null +++ b/app/public/pwa-icon.svg @@ -0,0 +1,15 @@ + + Echo Sphere Icon + Monogram of the letters E and S inside a circle. + + + + + + + + + + + + diff --git a/app/public/sw.js b/app/public/sw.js new file mode 100644 index 0000000..160fc52 --- /dev/null +++ b/app/public/sw.js @@ -0,0 +1,68 @@ +const CACHE_NAME = 'webframework-cache-v1'; +const OFFLINE_URL = '/'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' }))) + .catch(() => {}) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + + if (request.method !== 'GET' || request.url.startsWith('chrome-extension')) { + return; + } + + const requestURL = new URL(request.url); + + if (requestURL.origin !== self.location.origin) { + return; + } + + event.respondWith( + caches.open(CACHE_NAME).then(async (cache) => { + try { + const response = await fetch(request); + if ( + response && + response.status === 200 && + response.type === 'basic' && + !request.url.includes('/__vite_ping') + ) { + cache.put(request, response.clone()).catch(() => {}); + } + return response; + } catch (error) { + const cachedResponse = await cache.match(request); + if (cachedResponse) { + return cachedResponse; + } + if (request.mode === 'navigate') { + const offlineResponse = await cache.match(OFFLINE_URL); + if (offlineResponse) { + return offlineResponse; + } + } + throw error; + } + }) + ); +}); diff --git a/app/src/main.tsx b/app/src/main.tsx index 946a569..46ff5e4 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,5 +1,8 @@ import { createRoot } from 'react-dom/client' import App from './App.tsx' +import { registerServiceWorker } from './service-worker-registration.ts' // biome-ignore lint/style/noNonNullAssertion: expect root element to exist createRoot(document.getElementById('root')!).render() + +registerServiceWorker() diff --git a/app/src/service-worker-registration.ts b/app/src/service-worker-registration.ts new file mode 100644 index 0000000..32e6316 --- /dev/null +++ b/app/src/service-worker-registration.ts @@ -0,0 +1,35 @@ +export function registerServiceWorker(): void { + if (!import.meta.env.PROD) { + return + } + + if (!('serviceWorker' in navigator)) { + return + } + + const register = () => { + navigator.serviceWorker + .register('/sw.js') + .catch((error) => { + console.error('Service worker registration failed:', error) + }) + } + + if (document.readyState === 'complete') { + register() + } else { + window.addEventListener('load', register, { once: true }) + } +} + +export function unregisterServiceWorker(): void { + if (!('serviceWorker' in navigator)) { + return + } + + navigator.serviceWorker.ready + .then((registration) => registration.unregister()) + .catch(() => { + // Swallow errors silently to avoid crashing the app when service worker cannot be unregistered. + }) +} From 388a86b53311d8e99abeac17a3861f59e7f575b6 Mon Sep 17 00:00:00 2001 From: Tam Le Date: Tue, 28 Oct 2025 21:19:41 -0400 Subject: [PATCH 2/4] chore: clarify pwa metadata --- app/index.html | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/index.html b/app/index.html index 300b83c..e3ed000 100644 --- a/app/index.html +++ b/app/index.html @@ -10,13 +10,16 @@ - + - Echo Sphere - + Web Framework Showcase + - + From 420d3e3fe346fac51a7e581de57b311b2d10c24c Mon Sep 17 00:00:00 2001 From: Tam Le Date: Tue, 28 Oct 2025 21:19:49 -0400 Subject: [PATCH 3/4] chore: restore echo sphere branding --- app/index.html | 8 ++++---- app/public/manifest.webmanifest | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/index.html b/app/index.html index e3ed000..76cad71 100644 --- a/app/index.html +++ b/app/index.html @@ -10,16 +10,16 @@ - + - Web Framework Showcase + Echo Sphere - + diff --git a/app/public/manifest.webmanifest b/app/public/manifest.webmanifest index ac3abc5..27ca864 100644 --- a/app/public/manifest.webmanifest +++ b/app/public/manifest.webmanifest @@ -1,11 +1,11 @@ { - "name": "Web Framework Showcase", - "short_name": "WebFramework", + "name": "Echo Sphere", + "short_name": "EchoSphere", "start_url": "/", "display": "standalone", "background_color": "#0f172a", "theme_color": "#0f172a", - "description": "Explore the Web Framework demo application even when you are offline.", + "description": "Explore the Echo Sphere demo application even when you are offline.", "icons": [ { "src": "/pwa-icon.svg", From efde5f683a8bc6b132a32358b2adec2dfdb6d64b Mon Sep 17 00:00:00 2001 From: Tam Le Date: Tue, 28 Oct 2025 21:43:14 -0400 Subject: [PATCH 4/4] attempt to fix PWA offline support --- app/public/sw.js | 147 +++++++++++------- app/src/service-worker-registration.ts | 53 +++---- .../apps/website/templates/website/index.html | 9 +- 3 files changed, 122 insertions(+), 87 deletions(-) diff --git a/app/public/sw.js b/app/public/sw.js index 160fc52..75c5b81 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -1,68 +1,99 @@ -const CACHE_NAME = 'webframework-cache-v1'; -const OFFLINE_URL = '/'; +const CACHE_NAME = 'webframework-cache-v1' +const OFFLINE_URL = '/' + +// Assets to cache on install +const STATIC_ASSETS = [ + '/', + '/static/app/index.html', + '/static/app/manifest.webmanifest', + '/static/app/pwa-icon.svg', + '/static/app/vite-logo.svg', +] self.addEventListener('install', (event) => { - event.waitUntil( - caches - .open(CACHE_NAME) - .then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' }))) - .catch(() => {}) - .then(() => self.skipWaiting()) - ); -}); + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => { + // Cache static assets + return cache.addAll(STATIC_ASSETS.map((url) => new Request(url, { cache: 'reload' }))) + }) + .catch(() => {}) + .then(() => self.skipWaiting()) + ) +}) self.addEventListener('activate', (event) => { - event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all( - keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) - ) - ) - .then(() => self.clients.claim()) - ); -}); + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ) + .then(() => self.clients.claim()) + ) +}) self.addEventListener('fetch', (event) => { - const { request } = event; + const { request } = event + + if (request.method !== 'GET' || request.url.startsWith('chrome-extension')) { + return + } + + const requestURL = new URL(request.url) + + if (requestURL.origin !== self.location.origin) { + return + } - if (request.method !== 'GET' || request.url.startsWith('chrome-extension')) { - return; - } + event.respondWith( + caches.open(CACHE_NAME).then(async (cache) => { + try { + const response = await fetch(request) + if ( + response && + response.status === 200 && + response.type === 'basic' && + !request.url.includes('/__vite_ping') && + !request.url.includes('/api/') // Don't cache API responses + ) { + cache.put(request, response.clone()).catch(() => {}) + } + return response + } catch (error) { + // Try to serve from cache + const cachedResponse = await cache.match(request) + if (cachedResponse) { + return cachedResponse + } - const requestURL = new URL(request.url); + // For navigation requests, serve the offline page + if (request.mode === 'navigate') { + const offlineResponse = + (await cache.match('/static/app/index.html')) || (await cache.match(OFFLINE_URL)) + if (offlineResponse) { + return offlineResponse + } + } - if (requestURL.origin !== self.location.origin) { - return; - } + // For API requests, return a proper offline response + if (request.url.includes('/api/')) { + return new Response( + JSON.stringify({ + error: 'Offline', + message: 'This feature is not available offline', + }), + { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'application/json' }, + } + ) + } - event.respondWith( - caches.open(CACHE_NAME).then(async (cache) => { - try { - const response = await fetch(request); - if ( - response && - response.status === 200 && - response.type === 'basic' && - !request.url.includes('/__vite_ping') - ) { - cache.put(request, response.clone()).catch(() => {}); - } - return response; - } catch (error) { - const cachedResponse = await cache.match(request); - if (cachedResponse) { - return cachedResponse; - } - if (request.mode === 'navigate') { - const offlineResponse = await cache.match(OFFLINE_URL); - if (offlineResponse) { - return offlineResponse; - } - } - throw error; - } - }) - ); -}); + throw error + } + }) + ) +}) diff --git a/app/src/service-worker-registration.ts b/app/src/service-worker-registration.ts index 32e6316..f9efb48 100644 --- a/app/src/service-worker-registration.ts +++ b/app/src/service-worker-registration.ts @@ -1,35 +1,36 @@ export function registerServiceWorker(): void { - if (!import.meta.env.PROD) { - return - } + if (!import.meta.env.PROD) { + return + } - if (!('serviceWorker' in navigator)) { - return - } + if (!('serviceWorker' in navigator)) { + return + } - const register = () => { - navigator.serviceWorker - .register('/sw.js') - .catch((error) => { - console.error('Service worker registration failed:', error) - }) - } + const register = () => { + // Use the correct path based on environment + const swPath = import.meta.env.PROD ? '/static/app/sw.js' : '/sw.js' - if (document.readyState === 'complete') { - register() - } else { - window.addEventListener('load', register, { once: true }) - } + navigator.serviceWorker.register(swPath).catch((error) => { + console.error('Service worker registration failed:', error) + }) + } + + if (document.readyState === 'complete') { + register() + } else { + window.addEventListener('load', register, { once: true }) + } } export function unregisterServiceWorker(): void { - if (!('serviceWorker' in navigator)) { - return - } + if (!('serviceWorker' in navigator)) { + return + } - navigator.serviceWorker.ready - .then((registration) => registration.unregister()) - .catch(() => { - // Swallow errors silently to avoid crashing the app when service worker cannot be unregistered. - }) + navigator.serviceWorker.ready + .then((registration) => registration.unregister()) + .catch(() => { + // Swallow errors silently to avoid crashing the app when service worker cannot be unregistered. + }) } diff --git a/server/apps/website/templates/website/index.html b/server/apps/website/templates/website/index.html index 9c23f0a..0dadb01 100644 --- a/server/apps/website/templates/website/index.html +++ b/server/apps/website/templates/website/index.html @@ -10,8 +10,11 @@ {% else %} - - + + + + + {% endif %} Echo Sphere @@ -94,4 +97,4 @@ - \ No newline at end of file +