PageSourceSearch

https://justherecipe.com/sw.js

js justherecipe.com collected 2026-10-02 01:33:30 UTC 6,910 bytes, 212 lines download raw bytes

1/**
2 * JusTheRecipe service worker.
3 *
4 * Goals:
5 *
6 *  - Make the installed PWA usable when the network is dead. Saved recipes
7 *    live in IndexedDB on the client, so the recipe-shell route also has to
8 *    work offline; we serve a stale-while-revalidate copy of `/r/<slug>`
9 *    pages we've previously seen, plus a hand-built `/offline` fallback for
10 *    anything we haven't.
11 *  - Cache `/_next/static/*` immutably (Next.js fingerprints filenames so
12 *    cache-first is always safe).
13 *  - Network-first navigation requests so we don't wedge users onto an old
14 *    HTML shell when we've shipped a new build.
15 *  - Stay completely off the API surface: `/api/*` never touches a cache,
16 *    full stop.
17 *
18 * Bump VERSION whenever the cache shape or precache list changes; the
19 * activate handler will sweep older versions on next install.
20 */
21
22const VERSION = 'jtr-v3';
23const SHELL_CACHE = `${VERSION}-shell`;
24const RECIPE_CACHE = `${VERSION}-recipes`;
25const STATIC_CACHE = `${VERSION}-static`;
26const IMAGE_CACHE = `${VERSION}-images`;
27
28const OFFLINE_URL = '/offline';
29
30// Pages worth eagerly grabbing on first install. Any route the user is
31// likely to hit on a flaky connection. Keep this list short — it has to
32// fit in the browser's preload budget on `install`.
33const SHELL_URLS = [
34  '/',
35  '/saved',
36  '/list',
37  '/pantry',
38  '/random',
39  '/about',
40  '/manifest.webmanifest',
41  '/favicon.svg',
42  '/icons/icon-192.png',
43  '/icons/icon-512.png',
44  '/icons/icon-maskable-512.png',
45  '/apple-icon.png',
46  OFFLINE_URL,
47];
48
49// Same-origin image hosts we proxy through `/api/og-image`. We deliberately
50// don't cache the proxy URL itself (it's under /api) but the resulting blob
51// is fine — caches are keyed on Request URL so this still works.
52const HERO_IMAGE_PATH = '/api/og-image';
53
54self.addEventListener('install', (event) => {
55  event.waitUntil(
56    (async () => {
57      const cache = await caches.open(SHELL_CACHE);
58      // `addAll` is atomic — one failure aborts the whole install. We want
59      // best-effort behaviour so a missing /random redirect or a 404
60      // doesn't poison the install. Cache them one at a time, swallow
61      // failures.
62      await Promise.all(
63        SHELL_URLS.map(async (url) => {
64          try {
65            const response = await fetch(url, { credentials: 'same-origin' });
66            if (response.ok || response.type === 'opaqueredirect') {
67              await cache.put(url, response);
68            }
69          } catch {
70            // Skip this URL silently; it'll be fetched on demand later.
71          }
72        }),
73      );
74      self.skipWaiting();
75    })(),
76  );
77});
78
79self.addEventListener('activate', (event) => {
80  event.waitUntil(
81    (async () => {
82      // Enable navigation preload — lets the browser kick off the network
83      // fetch in parallel with our worker boot. Material on cold-start.
84      if (self.registration.navigationPreload) {
85        try {
86          await self.registration.navigationPreload.enable();
87        } catch {
88          // Some browsers (older Safari) don't support it. No-op.
89        }
90      }
91      const keys = await caches.keys();
92      await Promise.all(
93        keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k)),
94      );
95      await self.clients.claim();
96    })(),
97  );
98});
99
100self.addEventListener('fetch', (event) => {
101  const req = event.request;
102  if (req.method !== 'GET') return;
103
104  const url = new URL(req.url);
105
106  // Cross-origin: leave alone. We don't proxy third-party assets — the
107  // Next.js Image component already proxies remote hero images through
108  // /_next/image which we DO handle below.
109  if (url.origin !== self.location.origin) return;
110
111  // API routes: pure passthrough. Includes Auth.js endpoints, drive sync,
112  // recipe extraction calls, and the og-image proxy. A stale auth state or
113  // recipe payload is far worse than a network error.
114  if (url.pathname.startsWith('/api/')) return;
115
116  // Hashed Next.js assets: cache-first, immutable.
117  if (
118    url.pathname.startsWith('/_next/static/') ||
119    url.pathname.startsWith('/_next/image')
120  ) {
121    event.respondWith(cacheFirst(req, STATIC_CACHE));
122    return;
123  }
124
125  // Static brand assets we want offline.
126  if (
127    url.pathname === '/favicon.svg' ||
128    url.pathname.startsWith('/icons/') ||
129    url.pathname === '/apple-icon.png' ||
130    url.pathname === '/apple-touch-icon.png' ||
131    url.pathname === '/manifest.webmanifest'
132  ) {
133    event.respondWith(cacheFirst(req, SHELL_CACHE));
134    return;
135  }
136
137  // Recipe pages: stale-while-revalidate so the second view of any recipe
138  // is instant even on the slowest connection, and offline-readable so
139  // long as the user opened it once before.
140  if (url.pathname.startsWith('/r/')) {
141    event.respondWith(staleWhileRevalidate(req, RECIPE_CACHE));
142    return;
143  }
144
145  // Everything HTML-ish: network-first with cached shell + offline fallback.
146  if (req.mode === 'navigate' || req.headers.get('accept')?.includes('text/html')) {
147    event.respondWith(navigationHandler(event));
148    return;
149  }
150});
151
152/**
153 * Listen for an explicit "skip waiting" message from the page so a user can
154 * tap our update toast and immediately get the new bundle without quitting
155 * the PWA. The page sends `{type:'SKIP_WAITING'}` after registration sees
156 * an updatefound + installed-and-waiting worker.
157 */
158self.addEventListener('message', (event) => {
159  if (event.data && event.data.type === 'SKIP_WAITING') {
160    self.skipWaiting();
161  }
162});
163
164async function cacheFirst(req, cacheName) {
165  const cache = await caches.open(cacheName);
166  const cached = await cache.match(req);
167  if (cached) return cached;
168  try {
169    const response = await fetch(req);
170    if (response.ok) cache.put(req, response.clone());
171    return response;
172  } catch (err) {
173    if (cached) return cached;
174    throw err;
175  }
176}
177
178async function staleWhileRevalidate(req, cacheName) {
179  const cache = await caches.open(cacheName);
180  const cached = await cache.match(req);
181  const network = fetch(req)
182    .then((res) => {
183      if (res.ok) cache.put(req, res.clone());
184      return res;
185    })
186    .catch(() => cached);
187  return cached || network;
188}
189
190async function navigationHandler(event) {
191  const req = event.request;
192  try {
193    // Use the navigation preload response if the platform raced a fetch
194    // for us, otherwise hit the network directly.
195    const preload = await event.preloadResponse;
196    const response = preload || (await fetch(req));
197    if (response && response.ok) {
198      const cache = await caches.open(SHELL_CACHE);
199      cache.put(req, response.clone());
200    }
201    return response;
202  } catch {
203    const cached = await caches.match(req);
204    if (cached) return cached;
205    const offline = await caches.match(OFFLINE_URL);
206    if (offline) return offline;
207    return new Response('Offline', {
208      status: 503,
209      headers: { 'content-type': 'text/plain' },
210    });
211  }
212}

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.