· PWA· Service Worker· Workbox· React· TypeScript· offline· IndexedDB· Tech PWA· installatør
React PWA offline-first — Service Worker, Workbox og IndexedDB sync
React PWA med Workbox: offline-first cache-strategi, IndexedDB til datapersistering, baggrunds-sync, installationsprompt og push-notifikationer til Tech PWA.
Af M-Bus Gateway
M-Bus Gateway Tech PWA fungerer offline på installationsstedet — uden SIM/WiFi. Her er Workbox-konfigurationen og IndexedDB-mønstrene der gør det muligt.
Vite PWA plugin: Basis-konfiguration
// tech-pwa/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
workbox: {
// Cache app-shell (HTML, JS, CSS, billeder):
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
// Runtime caching — API-kald cached offline:
runtimeCaching: [
{
// Cache GET /api/v1/meter-installations/* offline:
urlPattern: /\/api\/v1\/(meter-installations|meters|units)\/.*/,
handler: "StaleWhileRevalidate",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 500,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 dage
},
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Billeder: Cache-first (ingen netværksfejl ved offline fotos):
urlPattern: /\.(png|jpg|jpeg|webp|svg)$/,
handler: "CacheFirst",
options: {
cacheName: "images-cache",
expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 },
},
},
],
},
manifest: {
name: "M-Bus Gateway Tech",
short_name: "MBus Tech",
description: "Tekniker app til installation af wM-Bus målere",
theme_color: "#0f172a",
background_color: "#0f172a",
display: "standalone",
orientation: "portrait",
start_url: "/tech/",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
},
}),
],
});
IndexedDB: Offline datapersistering
// tech-pwa/src/db/offline-db.ts
// Alle actions gem lokalt → sync ved online
interface PendingAction {
id: string;
type: "install" | "replace" | "move" | "remove";
payload: Record<string, unknown>;
createdAt: string;
synced: boolean;
}
class OfflineDB {
private db: IDBDatabase | null = null;
async open(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open("mbus-tech", 1);
req.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result;
// Pending actions (installationer offline):
const actions = db.createObjectStore("pending_actions", { keyPath: "id" });
actions.createIndex("synced", "synced");
// Cached meter data:
db.createObjectStore("meters", { keyPath: "id" });
db.createObjectStore("units", { keyPath: "id" });
};
req.onsuccess = () => { this.db = req.result; resolve(); };
req.onerror = () => reject(req.error);
});
}
async savePendingAction(action: Omit<PendingAction, "id" | "synced">): Promise<string> {
const id = crypto.randomUUID();
const record: PendingAction = { ...action, id, synced: false };
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("pending_actions", "readwrite");
const store = tx.objectStore("pending_actions");
const req = store.put(record);
req.onsuccess = () => resolve(id);
req.onerror = () => reject(req.error);
});
}
async getPendingActions(): Promise<PendingAction[]> {
return new Promise((resolve, reject) => {
const tx = this.db!.transaction("pending_actions", "readonly");
const index = tx.objectStore("pending_actions").index("synced");
const req = index.getAll(IDBKeyRange.only(false));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
}
export const offlineDb = new OfflineDB();
Baggrunds-sync: Upload ved online
// tech-pwa/src/sync/background-sync.ts
export async function syncPendingActions(): Promise<void> {
if (!navigator.onLine) return;
const pending = await offlineDb.getPendingActions();
if (pending.length === 0) return;
const results = await Promise.allSettled(
pending.map(async (action) => {
// Send til server:
const response = await fetch(`/api/v1/meter-installations/${action.type}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(action.payload),
});
if (!response.ok) {
throw new Error(`Sync fejlede: ${response.status}`);
}
// Markér som synced:
await offlineDb.markSynced(action.id);
return action.id;
})
);
const failed = results.filter((r) => r.status === "rejected").length;
const succeeded = results.filter((r) => r.status === "fulfilled").length;
console.log(`Sync: ${succeeded} synced, ${failed} fejlede`);
}
// Lyt efter online-event:
window.addEventListener("online", () => {
syncPendingActions();
});
// Brug også Service Worker Background Sync API (Chromium):
async function registerBackgroundSync(): Promise<void> {
if ("serviceWorker" in navigator && "SyncManager" in window) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-pending-actions");
}
}
Installationsprompt: "Tilføj til hjemmeskærm"
// tech-pwa/src/hooks/usePWAInstall.ts
import { useState, useEffect } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function usePWAInstall() {
const [installPrompt, setInstallPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
const handleInstalled = () => setIsInstalled(true);
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
window.addEventListener("appinstalled", handleInstalled);
// Check om allerede installeret (standalone display mode):
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
}
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
window.removeEventListener("appinstalled", handleInstalled);
};
}, []);
const install = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === "accepted") setInstallPrompt(null);
return outcome === "accepted";
};
return { canInstall: !!installPrompt && !isInstalled, install, isInstalled };
}
Offline-indikator i UI
// tech-pwa/src/components/OfflineBanner.tsx
import { useState, useEffect } from "react";
export function OfflineBanner() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const handleOnline = () => { setIsOnline(true); syncPendingActions(); };
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
if (isOnline && pendingCount === 0) return null;
return (
<div className={`banner ${isOnline ? "syncing" : "offline"}`}>
{isOnline
? `Synkroniserer ${pendingCount} afventende handlinger...`
: `Offline-tilstand — ${pendingCount} handlinger gemmes lokalt`}
</div>
);
}
Konklusion
Tech PWA med Workbox, IndexedDB og baggrunds-sync giver installatøren fuld funktionalitet offline. Actions (installér/udskift/flyt/fjern måler) gemmes i IndexedDB og synkroniseres automatisk ved online. Service Worker cacher app-shell og API-svar. Installationsprompt giver native app-oplevelse på Android.