diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 06a55b7..10b28af 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,7 +32,7 @@ jobs: - name: Prepare site run: | mkdir _site - cp index.html app.js styles.css icon.svg manifest.webmanifest _site/ + cp index.html app.js styles.css icon.svg manifest.webmanifest service-worker.js _site/ cp -R assets _site/ - name: Upload artifact diff --git a/README.md b/README.md index bad95d4..9b431dc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ PORT=3000 npm run dev The app also works by opening `index.html` directly in a browser. +## Offline + +When served over HTTPS or localhost, the app registers a service worker that caches the app shell after the first online visit. Online loads check the network first, so customers receive deployed updates before falling back to the cached offline copy. + ## Controls - Type an IPv4 or IPv6 address/prefix to calculate the normalized range. diff --git a/app.js b/app.js index 3c95f5b..1df3506 100644 --- a/app.js +++ b/app.js @@ -1248,3 +1248,27 @@ updateSuggestions(); render(); commitCurrentPrefixHistory(); resizePrefixInput(); +registerServiceWorker(); + +function registerServiceWorker() { + if (!("serviceWorker" in navigator)) return; + + const hadController = Boolean(navigator.serviceWorker.controller); + let refreshing = false; + + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (!hadController || refreshing) return; + + refreshing = true; + window.location.reload(); + }); + + window.addEventListener("load", () => { + navigator.serviceWorker + .register("./service-worker.js") + .then((registration) => registration.update()) + .catch(() => { + // Offline support should never block the calculator itself. + }); + }); +} diff --git a/index.html b/index.html index 6c6f8cc..b2e23d4 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,11 @@ content="A fast, dependency-free IPv4 and IPv6 calculator for prefixes, address ranges, adjacent networks, and alternate notations." /> + + + + diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..d527f5f --- /dev/null +++ b/service-worker.js @@ -0,0 +1,65 @@ +const CACHE_VERSION = "2026-06-02-1"; +const CACHE_PREFIX = "web-ipcalc"; +const CACHE_NAME = `${CACHE_PREFIX}-${CACHE_VERSION}`; +const APP_SHELL = [ + "./", + "./index.html", + "./styles.css", + "./app.js", + "./manifest.webmanifest", + "./icon.svg", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_SHELL)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((names) => Promise.all( + names + .filter((name) => name.startsWith(`${CACHE_PREFIX}-`) && name !== CACHE_NAME) + .map((name) => caches.delete(name)), + )) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + event.respondWith(networkFirst(request)); +}); + +async function networkFirst(request) { + const cache = await caches.open(CACHE_NAME); + + try { + const response = await fetch(request); + if (response.ok) { + const cacheKey = request.mode === "navigate" ? "./index.html" : request; + await cache.put(cacheKey, response.clone()); + } + return response; + } catch (networkError) { + const cached = await cache.match(request); + if (cached) return cached; + + if (request.mode === "navigate") { + return (await cache.match("./index.html")) || cache.match("./"); + } + + throw networkError; + } +}