diff --git a/public/app.js b/public/app.js index a151310..034a30a 100644 --- a/public/app.js +++ b/public/app.js @@ -216,6 +216,19 @@ async function requestWakeLock() { } } +/* ---------- service worker ---------- */ + +function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('weerscherm: service worker', err)); + let reloading = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (reloading) return; + reloading = true; + location.reload(); + }); +} + /* ---------- start ---------- */ window.addEventListener('resize', fit); @@ -225,6 +238,7 @@ document.addEventListener('visibilitychange', () => { fit(); refresh(); requestWakeLock(); +registerServiceWorker(); } }); @@ -236,3 +250,4 @@ scheduleClock(); scheduleMidnight(); scheduleReload(); requestWakeLock(); +registerServiceWorker(); diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000..b7a8996 Binary files /dev/null and b/public/icons/icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..b17fb96 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..e2025c9 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..aadb695 --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..ec27a07 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Weerscherm", + "short_name": "Weer", + "description": "Netatmo-weerstation en verwachting voor in de woonkamer", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "landscape", + "background_color": "#E8EDF1", + "theme_color": "#E8EDF1", + "lang": "nl", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..952e016 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,82 @@ +/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */ + +const VERSION = 'weerscherm-v1'; +const SHELL = [ + './', + './index.html', + './styles.css', + './app.js', + './format.js', + './icons.js', + './manifest.webmanifest', + './icons/icon.svg', + './icons/icon-180.png', + './icons/icon-192.png', + './icons/icon-512.png', +]; +const API_PATH = '/api/data.php'; +const API_TIMEOUT_MS = 8000; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(VERSION).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.endsWith(API_PATH)) { + event.respondWith(networkFirst(req)); + } else { + event.respondWith(cacheFirst(req)); + } +}); + +async function networkFirst(req) { + const cache = await caches.open(VERSION); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + try { + const res = await fetch(req, { signal: controller.signal, cache: 'no-store' }); + if (res.ok) { + cache.put(req, res.clone()); + } + return res; + } catch (err) { + const cached = await cache.match(req); + if (cached) { + const headers = new Headers(cached.headers); + headers.set('X-From-Cache', '1'); + return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers }); + } + return new Response(JSON.stringify({ errors: ['offline'] }), { + status: 503, + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }, + }); + } finally { + clearTimeout(timer); + } +} + +async function cacheFirst(req) { + const cache = await caches.open(VERSION); + const cached = await cache.match(req, { ignoreSearch: true }); + if (cached) return cached; + const res = await fetch(req); + if (res.ok) { + cache.put(req, res.clone()); + } + return res; +} diff --git a/tests/js/sw.test.js b/tests/js/sw.test.js new file mode 100644 index 0000000..89d4b28 --- /dev/null +++ b/tests/js/sw.test.js @@ -0,0 +1,98 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +/** Laadt sw.js in een minimale nep-serviceworker-omgeving en geeft de geregistreerde handlers terug. */ +function loadServiceWorker({ fetchImpl }) { + const listeners = {}; + const store = new Map(); + const cache = { + async addAll(urls) { urls.forEach((u) => store.set(new URL(u, 'http://x/').href, new Response('shell'))); }, + async put(req, res) { store.set(req.url, res); }, + async match(req) { return store.get(typeof req === 'string' ? req : req.url) || undefined; }, + async keys() { return [...store.keys()].map((u) => new Request(u)); }, + }; + const sandbox = { + self: { + addEventListener: (type, fn) => { listeners[type] = fn; }, + skipWaiting: async () => {}, + clients: { claim: async () => {} }, + location: new URL('http://x/'), + }, + caches: { open: async () => cache, keys: async () => ['weerscherm-v0', 'weerscherm-v1'], delete: async () => true }, + fetch: fetchImpl, + Response, Request, Headers, URL, AbortController, setTimeout, clearTimeout, console, + }; + vm.createContext(sandbox); + vm.runInContext(readFileSync(new URL('../../public/sw.js', import.meta.url), 'utf8'), sandbox); + return { listeners, store }; +} + +function dispatchFetch(listeners, url) { + let promise = null; + listeners.fetch({ + request: new Request(url), + respondWith: (p) => { promise = p; }, + }); + return promise; +} + +test('install caches the app shell', async () => { + const { listeners, store } = loadServiceWorker({ fetchImpl: async () => new Response('x') }); + let done = null; + listeners.install({ waitUntil: (p) => { done = p; } }); + await done; + assert.ok(store.has('http://x/app.js')); + assert.ok(store.has('http://x/index.html')); +}); + +test('API request goes to the network first and is cached', async () => { + const { listeners, store } = loadServiceWorker({ + fetchImpl: async () => new Response('{"station":{}}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + const res = await dispatchFetch(listeners, 'http://x/api/data.php'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('X-From-Cache'), null); + assert.ok(store.has('http://x/api/data.php')); +}); + +test('API request falls back to the cached response with X-From-Cache when offline', async () => { + let online = true; + const { listeners } = loadServiceWorker({ + fetchImpl: async () => { + if (!online) throw new TypeError('Failed to fetch'); + return new Response('{"station":{"stale":false}}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + }); + await dispatchFetch(listeners, 'http://x/api/data.php'); + online = false; + const res = await dispatchFetch(listeners, 'http://x/api/data.php'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('X-From-Cache'), '1'); + assert.equal(await res.text(), '{"station":{"stale":false}}'); +}); + +test('API request without any cache gives a 503 offline JSON', async () => { + const { listeners } = loadServiceWorker({ fetchImpl: async () => { throw new TypeError('offline'); } }); + const res = await dispatchFetch(listeners, 'http://x/api/data.php'); + assert.equal(res.status, 503); + assert.deepEqual(await res.json(), { errors: ['offline'] }); +}); + +test('shell request is served from cache without hitting the network', async () => { + let calls = 0; + const { listeners, store } = loadServiceWorker({ fetchImpl: async () => { calls++; return new Response('net'); } }); + store.set('http://x/styles.css', new Response('cached css')); + const res = await dispatchFetch(listeners, 'http://x/styles.css'); + assert.equal(await res.text(), 'cached css'); + assert.equal(calls, 0); +}); + +test('cross-origin and non-GET requests are left alone', () => { + const { listeners } = loadServiceWorker({ fetchImpl: async () => new Response('x') }); + assert.equal(dispatchFetch(listeners, 'http://elders/api/data.php'), null); + let handled = false; + listeners.fetch({ request: new Request('http://x/api/data.php', { method: 'POST' }), respondWith: () => { handled = true; } }); + assert.equal(handled, false); +});