diff --git a/app/index.html b/app/index.html
index bded0aa..76cad71 100644
--- a/app/index.html
+++ b/app/index.html
@@ -7,10 +7,16 @@
-
+
+
+
+
Echo Sphere
-
+
diff --git a/app/public/manifest.webmanifest b/app/public/manifest.webmanifest
new file mode 100644
index 0000000..27ca864
--- /dev/null
+++ b/app/public/manifest.webmanifest
@@ -0,0 +1,17 @@
+{
+ "name": "Echo Sphere",
+ "short_name": "EchoSphere",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0f172a",
+ "theme_color": "#0f172a",
+ "description": "Explore the Echo Sphere 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 @@
+
diff --git a/app/public/sw.js b/app/public/sw.js
new file mode 100644
index 0000000..75c5b81
--- /dev/null
+++ b/app/public/sw.js
@@ -0,0 +1,99 @@
+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 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())
+ )
+})
+
+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') &&
+ !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
+ }
+
+ // 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
+ }
+ }
+
+ // 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' },
+ }
+ )
+ }
+
+ 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..f9efb48
--- /dev/null
+++ b/app/src/service-worker-registration.ts
@@ -0,0 +1,36 @@
+export function registerServiceWorker(): void {
+ if (!import.meta.env.PROD) {
+ return
+ }
+
+ if (!('serviceWorker' in navigator)) {
+ return
+ }
+
+ const register = () => {
+ // Use the correct path based on environment
+ const swPath = import.meta.env.PROD ? '/static/app/sw.js' : '/sw.js'
+
+ 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
+ }
+
+ 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 @@