From 0b2ed6ce089a01a6c0815436d10828d6a27494ff Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 16 Jan 2026 19:09:03 -0500 Subject: [PATCH] feat(pwa): implement dynamic manifest.webmanifest and service worker for multi-tenant support --- src/app.html | 2 +- src/routes/manifest.webmanifest/+server.ts | 75 +++++++++++++++++++++ src/service-worker.js | 77 ++++++++++++++++++++++ 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/routes/manifest.webmanifest/+server.ts create mode 100644 src/service-worker.js diff --git a/src/app.html b/src/app.html index eaf610b4..36ded2e6 100644 --- a/src/app.html +++ b/src/app.html @@ -4,7 +4,7 @@ - + diff --git a/src/routes/manifest.webmanifest/+server.ts b/src/routes/manifest.webmanifest/+server.ts new file mode 100644 index 00000000..f9b4ae4a --- /dev/null +++ b/src/routes/manifest.webmanifest/+server.ts @@ -0,0 +1,75 @@ +import { json } from '@sveltejs/kit'; +import { lookup_site_domain } from '$lib/ae_core/ae_core__site'; +import { + PUBLIC_AE_API_PROTOCOL, + PUBLIC_AE_API_SERVER, + PUBLIC_AE_API_BAK_SERVER, + PUBLIC_AE_API_PORT, + PUBLIC_AE_API_PATH, + PUBLIC_AE_API_SECRET_KEY, + PUBLIC_AE_API_CRUD_SUPER_KEY, + PUBLIC_AE_NO_ACCOUNT_ID +} from '$env/static/public'; + +const api_base_url = `${PUBLIC_AE_API_PROTOCOL}://${PUBLIC_AE_API_SERVER}:${PUBLIC_AE_API_PORT}${PUBLIC_AE_API_PATH}`; +const api_base_url_bak = `${PUBLIC_AE_API_PROTOCOL}://${PUBLIC_AE_API_BAK_SERVER}:${PUBLIC_AE_API_PORT}${PUBLIC_AE_API_PATH}`; + +const api_init = { + base_url: api_base_url, + base_url_bak: api_base_url_bak, + api_secret_key: PUBLIC_AE_API_SECRET_KEY, + api_crud_super_key: PUBLIC_AE_API_CRUD_SUPER_KEY, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + 'x-aether-api-key': PUBLIC_AE_API_SECRET_KEY, + 'x-no-account-id': PUBLIC_AE_NO_ACCOUNT_ID + } +}; + +/** @type {import('./$types').RequestHandler} */ +export async function GET({ url, fetch }) { + const fqdn = url.host; + + // Inject SvelteKit fetch for the lookup + const api_cfg = { ...api_init, fetch }; + + const site_domain = await lookup_site_domain({ + api_cfg, + fqdn, + log_lvl: 0 + }); + + if (!site_domain) { + return json({ error: 'Site not found' }, { status: 404 }); + } + + const site_name = site_domain.site_name || site_domain.account_name || 'Aether PWA'; + const short_name = site_domain.site_code || 'Aether'; + + // Use the site's header image or logo if available, otherwise fallback to default + const icon_src = site_domain.header_image_path || '/favicon.png'; + + const manifest = { + name: `One Sky IT - ${site_name}`, + short_name: short_name, + description: `The Aether Progressive Web App for ${site_name}`, + start_url: '/', + display: 'fullscreen', + background_color: 'hsl(220, 65%, 31%)', + theme_color: 'hsl(220, 65%, 31%)', + icons: [ + { + src: icon_src, + sizes: 'any', + type: 'image/png' + } + ] + }; + + return json(manifest, { + headers: { + 'Content-Type': 'application/manifest+json' + } + }); +} diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 00000000..1da73f2e --- /dev/null +++ b/src/service-worker.js @@ -0,0 +1,77 @@ +/// +import { build, files, version } from '$service-worker'; + +// Create a unique cache name for this deployment +const CACHE = `cache-${version}`; + +const ASSETS = [ + ...build, // the app itself + ...files // everything in `static` +]; + +self.addEventListener('install', (event) => { + // Create a new cache and add all files to it + async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } + + event.waitUntil(addFilesToCache()); +}); + +self.addEventListener('activate', (event) => { + // Delete old caches + async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + } + + event.waitUntil(deleteOldCaches()); +}); + +self.addEventListener('fetch', (event) => { + // ignore POST requests etc + if (event.request.method !== 'GET') return; + + async function respond() { + const url = new URL(event.request.url); + const cache = await caches.open(CACHE); + + // `build`/`files` can always be served from the cache + if (ASSETS.includes(url.pathname)) { + const response = await cache.match(url.pathname); + + if (response) { + return response; + } + } + + // for everything else, try the network first, but fallback to the cache if we're offline + try { + const response = await fetch(event.request); + + // if we're offline, fetch can return a value that is not a response + // instead of throwing - check it is a real response + if (response instanceof Response) { + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + return response; + } + throw new Error('invalid response'); + } catch (err) { + const response = await cache.match(event.request); + + if (response) { + return response; + } + + // if there is no match, the throw will propagate to the browser, which + // will show its default offline page. + throw err; + } + } + + event.respondWith(respond()); +});