M-Bus Gateway
← Tilbage til blog
· 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.

Se TypeScript React API guide eller TanStack Query guide.