Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});
});
}
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
content="A fast, dependency-free IPv4 and IPv6 calculator for prefixes, address ranges, adjacent networks, and alternate notations."
/>
<meta name="application-name" content="Modern IP Calculator" />
<meta name="theme-color" content="#050505" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Modern IP Calculator" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://netsampler.github.io/ip/" />
<meta property="og:title" content="Modern IP Calculator" />
Expand Down
65 changes: 65 additions & 0 deletions service-worker.js
Original file line number Diff line number Diff line change
@@ -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;
}
}