Skip to content
File

Blob: src/client/lib/pwa.ts

typescript88 lines
1import { useSyncExternalStore } from "react";
2import { Serwist } from "@serwist/window";
3import { reloadPwa } from "@/client/lib/pwa-reload";
4 
5// Shell delivery uses `NetworkOnly` + precache fallback (see `vite.config.ts`),
6// so there is no runtime shell cache to evict. Only user-content caches need
7// to be cleared on logout / owner switch; app-code precaches are
8// user-agnostic and stay.
9const RUNTIME_CACHE_NAMES = ["bland-uploads-v1"];
10 
11type UpdateState = {
12 waiting: boolean;
13 apply: (() => void) | null;
14};
15 
16const listeners = new Set<() => void>();
17let state: UpdateState = { waiting: false, apply: null };
18let sw: Serwist | null = null;
19 
20function emit(next: UpdateState) {
21 state = next;
22 for (const l of listeners) l();
23}
24 
25function subscribe(listener: () => void): () => void {
26 listeners.add(listener);
27 return () => {
28 listeners.delete(listener);
29 };
30}
31 
32function getSnapshot(): UpdateState {
33 return state;
34}
35 
36export function registerServiceWorker(): void {
37 if (sw) return;
38 if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
39 // The production build emits `/sw.js`; skip registration in dev so missing
40 // or experimental service workers do not trip the global rejection handler.
41 if (!import.meta.env.PROD) return;
42 
43 const instance = new Serwist("/sw.js", { scope: "/", type: "classic" });
44 
45 const apply = () => {
46 instance.addEventListener("controlling", () => {
47 reloadPwa();
48 });
49 instance.messageSkipWaiting();
50 };
51 
52 instance.addEventListener("waiting", () => {
53 emit({ waiting: true, apply });
54 });
55 
56 sw = instance;
57 void instance.register().catch(() => {
58 // Registration can fail on stale browser engines or storage-blocked
59 // profiles. Swallow so we do not surface a global unhandledrejection.
60 });
61 
62 // Long-lived editor sessions otherwise would not notice a new deploy until
63 // the browser's default 24h heuristic fires. Recheck on tab focus.
64 if (typeof document !== "undefined") {
65 document.addEventListener("visibilitychange", () => {
66 if (document.visibilityState === "visible") {
67 void instance.update().catch(() => {
68 // Transient update failures are non-fatal; the next focus re-checks.
69 });
70 }
71 });
72 }
73}
74 
75export function usePwaUpdate(): UpdateState {
76 return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
77}
78 
79export async function clearPwaRuntimeCaches(): Promise<void> {
80 if (typeof caches === "undefined") return;
81 try {
82 await Promise.all(RUNTIME_CACHE_NAMES.map((name) => caches.delete(name)));
83 } catch {
84 // Cache Storage may be unavailable (private mode, storage pressure);
85 // treat as best-effort so auth/local-owner resets stay robust.
86 }
87}