PageSourceSearch

https://heyvitae.com/express-sw.js

js heyvitae.com collected 2026-09-24 14:14:44 UTC 7,782 bytes, 196 lines download raw bytes

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.