From 4377f76c837e39de26348bd63e1d61da71ad2468 Mon Sep 17 00:00:00 2001 From: lspgn Date: Tue, 2 Jun 2026 15:03:48 -0700 Subject: [PATCH] feat(pwa): add offline service worker --- .github/workflows/pages.yml | 2 +- README.md | 4 +++ app.js | 24 ++++++++++++++ index.html | 4 +++ service-worker.js | 65 +++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 service-worker.js 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; + } +}