PageSourceSearch

https://opportuning.art/sw.js

js opportuning.art collected 2026-10-02 01:16:28 UTC 7,446 bytes, 196 lines download raw bytes

1/**
2 * Opportuning's service worker.
3 *
4 * It does two jobs and refuses a third.
5 *
6 *  1. Keeps the app shell, so an installed app opens offline after one
7 *     successful online visit instead of showing the browser's dinosaur.
8 *  2. Keeps the last few content responses, so that opened app has something
9 *     to show rather than an empty screen.
10 *
11 * The third job — caching anything to do with who you are or what you paid —
12 * it will not do. Auth and payment traffic is matched and passed straight
13 * through, never stored, because a token or an invoice sitting in a disk cache
14 * on a shared device is a worse outcome than any amount of offline polish.
15 *
16 * There is deliberately no build step here and no Workbox: this is the only
17 * service worker in the project, it is small enough to read in one sitting,
18 * and a second one fighting it would be worse than none.
19 */
20
21// Replaced at deploy time with the commit being shipped. It must change on
22// every deploy: `activate` deletes only caches whose names do not match the
23// current ones, so a constant version means the shell cached on a reader's
24// first-ever visit is kept forever, and a publish can leave them on the old
25// app. Left as a literal here so a stamp that never runs still yields a
26// working — if unrotating — cache name.
27const VERSION = "4891a17";
28const SHELL_CACHE = `opportuning-shell-${VERSION}`;
29const CONTENT_CACHE = `opportuning-content-${VERSION}`;
30
31/** Enough to boot the app with no network. Everything else arrives at runtime. */
32const SHELL_ASSETS = ["/", "/index.html", "/favicon.png", "/manifest.webmanifest"];
33
34/** The content cache is a small window on what was read recently, not an archive. */
35const CONTENT_LIMIT = 60;
36/** Older than this and a cached answer is shown but treated as stale. */
37const CONTENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
38
39/**
40 * Never stored, whatever else happens.
41 *
42 * Matched on the path rather than the method, so a token refresh, a checkout
43 * session and a subscription lookup are all excluded even when they arrive as
44 * an innocuous-looking GET.
45 */
46const NEVER_CACHE = [
47  "/auth/v1/",            // Supabase auth: tokens, sessions, recovery
48  "/functions/v1/create-checkout",
49  "/functions/v1/create-portal",
50  "/functions/v1/buy-companion-credits",
51  "/functions/v1/check-subscription",
52  "/functions/v1/cancel-subscription",
53  "/functions/v1/stripe-webhook",
54  "/rest/v1/subscriptions",
55  "/rest/v1/payments",
56  "/rest/v1/invoices",
57  "/rest/v1/user_roles",
58  // Experiences: contribuições próprias, revisão e diretório. As chamadas de
59  // hoje são POST e já passam direto; isto cobre qualquer GET futuro.
60  "/rest/v1/rpc/experiences_",
61];
62
63function isPrivate(url) {
64  return NEVER_CACHE.some((fragment) => url.pathname.includes(fragment) || url.href.includes(fragment));
65}
66
67/** Content worth keeping for an offline read: the listings themselves. */
68function isCacheableContent(url) {
69  if (isPrivate(url)) return false;
70  return (
71    url.pathname.includes("/rest/v1/opportunities") ||
72    url.pathname.includes("/rest/v1/saved_opportunities")
73  );
74}
75
76self.addEventListener("install", (event) => {
77  event.waitUntil(
78    caches.open(SHELL_CACHE)
79      .then((cache) => cache.addAll(SHELL_ASSETS))
80      // A missing asset must not wedge the install; the app still works online.
81      .catch(() => undefined)
82      .then(() => self.skipWaiting()),
83  );
84});
85
86self.addEventListener("activate", (event) => {
87  event.waitUntil(
88    caches.keys()
89      .then((keys) => Promise.all(
90        keys
91          .filter((k) => k.startsWith("opportuning-") && k !== SHELL_CACHE && k !== CONTENT_CACHE)
92          .map((k) => caches.delete(k)),
93      ))
94      .then(() => self.clients.claim()),
95  );
96});
97
98/** Keep the content cache to a window, oldest evicted first. */
99async function trimContentCache() {
100  const cache = await caches.open(CONTENT_CACHE);
101  const keys = await cache.keys();
102  if (keys.length <= CONTENT_LIMIT) return;
103  await Promise.all(keys.slice(0, keys.length - CONTENT_LIMIT).map((k) => cache.delete(k)));
104}
105
106/**
107 * Signing out, or signing in as somebody else.
108 *
109 * The page sends this; the worker drops every cached response. Content is
110 * per-account — a saved list, a matched feed — so leaving one reader's answers
111 * on the device for the next one is not acceptable, and the cheapest correct
112 * answer is to keep none of it.
113 */
114self.addEventListener("message", (event) => {
115  if (event.data?.type === "CLEAR_USER_CACHE") {
116    event.waitUntil(caches.delete(CONTENT_CACHE));
117  }
118});
119
120self.addEventListener("fetch", (event) => {
121  const { request } = event;
122  if (request.method !== "GET") return;
123
124  const url = new URL(request.url);
125
126  // Anything private: straight to the network, never touched.
127  if (isPrivate(url)) return;
128
129  // Navigations: network first so the app is current, cache as the floor so an
130  // installed app opens with no signal at all.
131  if (request.mode === "navigate") {
132    event.respondWith(
133      fetch(request)
134        .then((response) => {
135          const copy = response.clone();
136          caches.open(SHELL_CACHE).then((c) => c.put("/index.html", copy)).catch(() => undefined);
137          return response;
138        })
139        .catch(async () => (await caches.match("/index.html")) ?? Response.error()),
140    );
141    return;
142  }
143
144  // Build output is content-hashed, so a hit is always the right file.
145  if (url.origin === self.location.origin && url.pathname.startsWith("/assets/")) {
146    event.respondWith(
147      caches.match(request).then((hit) =>
148        hit ?? fetch(request).then((response) => {
149          const copy = response.clone();
150          caches.open(SHELL_CACHE).then((c) => c.put(request, copy)).catch(() => undefined);
151          return response;
152        }),
153      ),
154    );
155    return;
156  }
157
158  // Listings: network first, and the cached copy is what makes an offline read
159  // possible. Stamped on the way in so the app can tell how old it is.
160  if (isCacheableContent(url)) {
161    event.respondWith(
162      fetch(request)
163        .then((response) => {
164          if (response.ok) {
165            const copy = response.clone();
166            caches.open(CONTENT_CACHE).then(async (cache) => {
167              const stamped = new Response(await copy.blob(), {
168                status: copy.status,
169                statusText: copy.statusText,
170                headers: (() => {
171                  const h = new Headers(copy.headers);
172                  h.set("x-opportuning-cached-at", String(Date.now()));
173                  return h;
174                })(),
175              });
176              await cache.put(request, stamped);
177              await trimContentCache();
178            }).catch(() => undefined);
179          }
180          return response;
181        })
182        .catch(async () => {
183          const hit = await caches.match(request);
184          if (!hit) return Response.error();
185          const at = Number(hit.headers.get("x-opportuning-cached-at") ?? 0);
186          // Served either way — something to read beats nothing — but the age
187          // is passed on so the app can say the content may be out of date.
188          const stale = Date.now() - at > CONTENT_MAX_AGE_MS;
189          const headers = new Headers(hit.headers);
190          headers.set("x-opportuning-from-cache", "1");
191          headers.set("x-opportuning-stale", stale ? "1" : "0");
192          return new Response(await hit.blob(), { status: 200, headers });
193        }),
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.