1// Maintenance-page service worker. 2// 3// History: 4// v1-v3: Cache /offline-maintenance.html and serve it as a 503 fallback 5// when the backend is unreachable or returns a non-HTML 5xx. 6// 7// v4 (Luigi 2026-05-14): Self-recovery. After yesterday's deploy outage, 8// some browsers held onto the SW state from when the backend was 9// looping on a failed Alembic migration. The backend recovered 10// but the SW kept presenting the cached maintenance page in those 11// tabs (cached page + stale tab on /maintenance URL = persistent 12// "stuck" feeling even though prod is healthy). Two changes: 13// 1) Bump CACHE_NAME so the activate handler purges every old 14// cache the moment this SW takes over. 15// 2) Whenever we DO serve the cached fallback, fire a background 16// probe to /healthz; on a 200 response, unregister this SW 17// so the next reload bypasses it entirely. Recovery is then 18// at most one extra reload away, with no manual DevTools 19// steps required from the customer. 20var CACHE_NAME = "occ-maintenance-v4"; 21var MAINTENANCE_ASSETS = [ 22 "/offline-maintenance.html", 23 "/logos/logo-wide-light.png", 24 "/images/mascot-thumbsup.webp", 25]; 26 27// Self-recovery probe state. Module-scoped so we don't spam /healthz on 28// every cached response within the same SW lifetime. 29var recoveryProbeInflight = false; 30var recoveryProbeLastAt = 0; 31var RECOVERY_PROBE_MIN_INTERVAL_MS = 15000; 32 33self.addEventListener("install", function (event) { 34 event.waitUntil( 35 caches.open(CACHE_NAME).then(function (cache) { 36 return Promise.allSettled( 37 MAINTENANCE_ASSETS.map(function (url) { 38 return cache.add(url); 39 }) 40 ); 41 }) 42 ); 43 self.skipWaiting(); 44}); 45 46self.addEventListener("activate", function (event) { 47 event.waitUntil( 48 caches.keys().then(function (keys) { 49 return Promise.all( 50 keys 51 .filter(function (k) { 52 return k !== CACHE_NAME; 53 }) 54 .map(function (k) { 55 return caches.delete(k); 56 }) 57 ); 58 }) 59 ); 60 self.clients.claim(); 61}); 62 63// Background self-recovery: when we just served a cached 503, probe the 64// real backend. If it's healthy, unregister so the customer's next 65// navigation bypasses the SW entirely and gets the real page. Bounded
66// by RECOVERY_PROBE_MIN_INTERVAL_MS so a flurry of cached responses 67// only triggers one network probe per window. Returns a promise that 68// the caller MUST attach to event.waitUntil() so the worker is kept 69// alive until recovery completes -- otherwise the browser is free to 70// terminate the SW between serving the cached response and finishing 71// the probe/unregister, which is exactly the "still stuck" scenario 72// we are trying to eliminate. 73function maybeSelfRecover() { 74 var now = Date.now(); 75 if (recoveryProbeInflight) return Promise.resolve(); 76 if (now - recoveryProbeLastAt < RECOVERY_PROBE_MIN_INTERVAL_MS) { 77 return Promise.resolve(); 78 } 79 recoveryProbeInflight = true; 80 recoveryProbeLastAt = now; 81 return fetch("/healthz", { cache: "no-store", credentials: "omit" }) 82 .then(function (res) { 83 if (!res || !res.ok) return null; 84 return self.registration.unregister().then(function () { 85 // Force any open clients to refresh so they pick up the real 86 // page instead of staying on the cached 503. Clients that are 87 // currently on /maintenance are special-cased: reloading them 88 // in place would just land them back on /maintenance (the 89 // backend doesn't redirect away from that route on its own), 90 // so we send them to "/" instead, which the now-healthy 91 // middleware will resolve to the real destination. 92 return self.clients 93 .matchAll({ type: "window" }) 94 .then(function (cs) { 95 return Promise.allSettled( 96 cs.map(function (c) { 97 try { 98 var target = c.url; 99 try { 100 var u = new URL(c.url); 101 if (u.pathname === "/maintenance") { 102 u.pathname = "/"; 103 u.search = ""; 104 target = u.toString(); 105 } 106 } catch (_pe) { 107 // Malformed client URL; fall back to in-place reload. 108 } 109 return c.navigate(target); 110 } catch (_e) { 111 // Cross-origin or detached client; ignore. 112 return Promise.resolve(); 113 } 114 }) 115 ); 116 }); 117 }); 118 }) 119 .catch(function () { 120 // Backend still down or probe blocked -- leave the SW in place. 121 }) 122 .then(function () { 123 recoveryProbeInflight = false; 124 }); 125} 126 127function serve503(cached) { 128 if (!cached) { 129 return new Response( 130 "Service temporarily unavailable. Please try again shortly.", 131 { status: 503, headers: { "Content-Type": "text/plain" } } 132 ); 133 } 134 return new Response(cached.body, { 135 status: 503, 136 statusText: "Service Unavailable", 137 headers: { 138 "Content-Type": "text/html; charset=utf-8", 139 "Retry-After": "30", 140 "Cache-Control": "no-store", 141 }, 142 }); 143} 144 145// Admin/login routes are NEVER intercepted (Luigi 2026-05-14). The 146// Next.js middleware already bypasses /oc-panel-9f4e and /login from 147// auto-maintenance so the operator can always sign in to disable 148// maintenance mode -- the SW must mirror that bypass, otherwise during 149// a full outage (frontend AND backend down at the same time) the cached 150// maintenance page would lock the admin out of the very recovery path. 151function isAdminBypass(pathname) { 152 return ( 153 pathname === "/login" || 154 pathname.indexOf("/oc-panel-9f4e") === 0 155 ); 156} 157 158self.addEventListener("fetch", function (event) { 159 if (event.request.mode === "navigate") { 160 try { 161 var navUrl = new URL(event.request.url); 162 if (isAdminBypass(navUrl.pathname)) { 163 // Hand straight to the network; no cached fallback even if the 164 // backend is unreachable. Better to show the browser's native 165 // "site can't be reached" than to lock the admin out. 166 return; 167 } 168 } catch (_e) { 169 // Malformed URL -- fall through to default handling below.
170 } 171 event.respondWith( 172 fetch(event.request) 173 .then(function (response) { 174 if (response.status >= 500) { 175 var ct = response.headers.get("content-type") || ""; 176 if (!ct.includes("text/html")) { 177 // Served the cached fallback -- kick off self-recovery and 178 // keep the SW alive until it finishes via waitUntil(). 179 try { 180 event.waitUntil(maybeSelfRecover()); 181 } catch (_e) { 182 // Defensive: never let recovery wiring affect the response. 183 } 184 return caches 185 .match("/offline-maintenance.html") 186 .then(serve503); 187 } 188 } 189 return response; 190 }) 191 .catch(function () { 192 try { 193 event.waitUntil(maybeSelfRecover()); 194 } catch (_e) { 195 // Defensive: never let recovery wiring affect the response. 196 } 197 return caches 198 .match("/offline-maintenance.html") 199 .then(serve503); 200 }) 201 ); 202 return; 203 } 204 205 var url = new URL(event.request.url); 206 if (MAINTENANCE_ASSETS.indexOf(url.pathname) !== -1) { 207 event.respondWith( 208 fetch(event.request).catch(function () { 209 return caches.match(event.request); 210 }) 211 ); 212 } 213});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.