1/** 2 * Hey Vitae Express's service worker. 3 * 4 * SCOPED TO /express, and that scope is the whole boundary. Express shares 5 * heyvitae.com with the full product, so a worker registered at the root would 6 * sit in front of the tracker's every request. components/express/express- 7 * service-worker.tsx registers it with `scope: "/express"`, which means it can 8 * only ever see Express screens â the tracker's pages never pass through it. 9 * 10 * IT CACHES ONE THING ON PURPOSE: the artifacts somebody needs IN THE ROOM. The 11 * day-of sheet, their answers, the brief. Not the practice workspace (it needs 12 * a live socket), not checkout (it must never be stale), not the interview list 13 * (a cached list of interviews is a list that is wrong). 14 * 15 * WHAT THIS COSTS, stated because it is a real cost: an offline cache is a copy 16 * of somebody's prep left on the device. That is the entire feature â you cannot 17 * read a sheet in a basement without one â but it means a shared phone retains 18 * it until sign-out. `express:clear` below is what sign-out sends, and it is not 19 * optional. 20 */ 21 22const CACHE = "express-v1" 23 24/** 25 * Paths whose GET responses are worth keeping for a room with no signal. 26 * 27 * MATCHED ON THE PATHNAME, not on a list of URLs, because every one of these is 28 * per-job. A prefix list is also what keeps the rule readable: anything not 29 * named here is network-only, which is the safe default for a product where the 30 * wrong answer is a stale one. 31 */ 32const OFFLINE_PATHS = [ 33 /^\/express\/prep\/[^/]+\/sheet$/, 34 /^\/express\/prep\/[^/]+\/kit$/, 35 /^\/express\/prep\/[^/]+$/, 36] 37 38/** Never cached, whatever else matches: money, live sessions, and auth. */ 39const NEVER_CACHE = [ 40 /^\/api\/stripe\//, 41 /^\/api\/express\/claim$/, 42 /^\/express\/practice/, 43 /^\/express\/(login|code)/, 44 /^\/auth\//, 45 /^\/login/, 46] 47 48self.addEventListener("install", (event) => { 49 // No precache list. The app shell is a Next build whose asset names change 50 // every deploy, and a hardcoded list is a cache that serves last week's 51 // JavaScript against this week's HTML. Everything here is cached on first 52 // successful visit instead, which also means we only ever store pages 53 // somebody actually opened. 54 event.waitUntil(self.skipWaiting()) 55}) 56 57self.addEventListener("activate", (event) => { 58 event.waitUntil( 59 (async () => { 60 const names = await caches.keys() 61 await Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))) 62 await self.clients.claim() 63 })(), 64 ) 65}) 66 67self.addEventListener("message", (event) => { 68 // Sign-out sends this. See the header: the cache is somebody's prep and it 69 // must not outlive their session on a shared phone. 70 if (event.data === "express:clear") { 71 event.waitUntil(caches.delete(CACHE)) 72 } 73}) 74 75self.addEventListener("fetch", (event) => { 76 const request = event.request 77 if (request.method !== "GET") return 78 79 const url = new URL(request.url) 80 if (url.origin !== self.location.origin) return 81 if (NEVER_CACHE.some((pattern) => pattern.test(url.pathname))) return 82 if (!OFFLINE_PATHS.some((pattern) => pattern.test(url.pathname))) return 83 84 /** 85 * NETWORK FIRST, cache as the fallback. 86 * 87 * The other way round â cache first â is faster and wrong here: these pages 88 * carry a countdown, a kit that may have just finished building and a 89 * run-through count that changes. Serving yesterday's copy to somebody with 90 * four bars of signal to save 200ms is a bad trade. The cache exists for the 91 * basement, not for speed. 92 */ 93 event.respondWith( 94 (async () => { 95 try { 96 const response = await fetch(request) 97 if (response.ok) { 98 const cache = await caches.open(CACHE) 99 await cache.put(request, response.clone()) 100 } 101 return response 102 } catch (error) { 103 const cached = await caches.match(request) 104 if (cached) return cached 105 throw error 106 } 107 })(), 108 ) 109}) 110 111/** 112 * PUSH. The night-before nudge, the hour-before sheet reminder, and the evening 113 * debrief ask. 114 * 115 * THE PUSH CARRIES NO PAYLOAD. Not an encrypted one â none at all. The wake-up 116 * arrives empty and this handler asks our own origin what to say, which means 117 * nothing about anybody's interview ever passes through Apple's or Google's push 118 * service in any form. See lib/express/push/vapid.ts for the whole argument. 119 * 120 * WHAT COMES BACK IS RESOLVED FRESH, so a nudge queued at 6pm for a 9am 121 * interview that has since moved renders the current truth or nothing. 122 * 123 * A NOTIFICATION IS SHOWN EITHER WAY. A push handler that resolves without 124 * showing one is how a browser decides to stop delivering to a site â and on a 125 * phone with no signal the fetch is exactly what fails. So the fallback is 126 * deliberately vague rather than absent: vague is a bad notification, silent is 127 * a revoked permission. 128 */ 129self.addEventListener("push", (event) => { 130 event.waitUntil( 131 (async () => { 132 let nudge = null
133 try { 134 const response = await fetch("/api/express/push/pending", { 135 credentials: "include", 136 cache: "no-store", 137 }) 138 // A SIGNED-OUT BROWSER GETS THE LOGIN PAGE HERE, not a 401: the session 139 // gate answers 307 and fetch follows it, so `response.ok` is true and 140 // the body is HTML. Checking the content type is what tells the two 141 // apart â without it the only thing standing between us and a thrown 142 // parse is the catch below, which is a worse way to learn the same 143 // thing. Either way the notification falls back to the generic line, 144 // which is the honest outcome: a browser with no session must not be 145 // shown anything about somebody's interview. 146 const isJson = response.headers.get("content-type")?.includes("application/json") 147 if (response.ok && isJson) nudge = (await response.json()).nudge || null 148 } catch { 149 nudge = null 150 } 151 152 // Resolved to nothing, on purpose: the interview moved, or they already 153 // debriefed it. Showing "nothing to tell you" is worse than the generic 154 // line, so this takes the same fallback. 155 const title = nudge?.title || "Hey Vitae" 156 const body = nudge?.body || "Something about your interview is worth a look." 157 158 await self.registration.showNotification(title, { 159 body, 160 icon: "/icons/icon-192.png", 161 badge: "/icons/icon-192.png", 162 // Collapses an older nudge for the same interview rather than stacking 163 // three of them on a lock screen. 164 tag: nudge?.tag || "express", 165 data: { path: nudge?.path || "/express" }, 166 }) 167 })(), 168 ) 169}) 170 171self.addEventListener("notificationclick", (event) => { 172 event.notification.close() 173 const path = event.notification.data?.path || "/express" 174 event.waitUntil( 175 (async () => { 176 const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true }) 177 // Focus a tab that is already open rather than adding a fourth one. 178 for (const client of all) { 179 // AN EXPRESS WINDOW ONLY. Express shares its origin with the full 180 // product, so matchAll returns tracker tabs too â and focusing one of 181 // those and navigating it to an interview would throw away whatever 182 // somebody was doing on their board. 183 const url = new URL(client.url) 184 const isExpressWindow = 185 url.origin === self.location.origin && 186 (url.pathname === "/express" || url.pathname.startsWith("/express/")) 187 if (isExpressWindow) { 188 await client.focus() 189 if ("navigate" in client) await client.navigate(path) 190 return 191 } 192 } 193 await self.clients.openWindow(path) 194 })(), 195 ) 196})
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.