import { ACTIVATE_UPDATE_MESSAGE, CACHE_NAME_PREFIX, VERSION_MESSAGE } from './constants' import { toJsLiteral } from './source-literals' /** * What the generated worker is told about the project. `app` is the * installable app (caching, offline page, update flow); `notify` only shows * notifications — it has no fetch handler, so the network is untouched. */ export interface ServiceWorkerConfig { version: string mode: 'app' | 'notify' appName: string /** False for a password-protected site: nothing is cached or precached. */ cacheContent: boolean /** Start URLs precached at install, with the build assets they reference. */ precacheUrls: string[] /** Prefix patterns (see `toNetworkOnlyPattern`) never answered from the cache. */ networkOnly: string[] defaultLocale: string /** Locales served under a `/` prefix; stripped before matching `networkOnly`. */ prefixedLocales: string[] /** The code Next.js also serves the default language under (`/en/checkout`); stripped too. */ defaultLocalePrefix: string /** Shown on a notification that carries no icon of its own. */ notificationIcon: string | null /** Present when the project stores push subscriptions (re-subscription on rotation). */ push: { vapidPublicKey: string; subscriptionsPath: string } | null } /** * The service worker, `public/sw.js`. Authored as plain source text on purpose: * it runs in the visitor's browser exactly as written, and must never pass * through a bundler or a minifier. It contains no backslash and no template * literal, because this module embeds it in one. */ export const buildServiceWorkerSource = ( config: ServiceWorkerConfig, offlinePages: Record ): string => `/* Service worker generated by TeleportHQ. It is rebuilt on every publish. */ 'use strict'; const CONFIG = ${toJsLiteral(config)}; const OFFLINE_PAGES = ${toJsLiteral(offlinePages)}; const CACHE_PREFIX = ${toJsLiteral(CACHE_NAME_PREFIX)}; const VERSION_PREFIX = CACHE_PREFIX + CONFIG.version + '-'; const STATIC_CACHE = VERSION_PREFIX + 'static'; const PAGES_CACHE = VERSION_PREFIX + 'pages'; const ASSETS_CACHE = VERSION_PREFIX + 'assets'; const MAX_PAGES = 50; const MAX_ASSETS = 80; const MAX_PRECACHED_ASSETS = 60; // A page waits this long for the network before a recent cached copy stands in. const SLOW_NETWORK_MS = 4000; // Older copies stand in only offline: they could show prices or stock that changed. const SLOW_NETWORK_MAX_AGE_MS = 60 * 60 * 1000; const ACTIVATE_UPDATE_MESSAGE = ${toJsLiteral(ACTIVATE_UPDATE_MESSAGE)}; const VERSION_MESSAGE = ${toJsLiteral(VERSION_MESSAGE)}; const MATCH_OPTIONS = { ignoreVary: true }; // A page's data comes with its own query (the route's parameters): the cached page is found without it. const PAGE_DATA_MATCH_OPTIONS = { ignoreVary: true, ignoreSearch: true }; function noop() {} self.addEventListener('install', function (event) { event.waitUntil(install()); }); self.addEventListener('activate', function (event) { event.waitUntil(activate()); }); self.addEventListener('message', function (event) { const type = event.data && event.data.type; if (type === ACTIVATE_UPDATE_MESSAGE) { self.skipWaiting(); } else if (type === VERSION_MESSAGE && event.ports && event.ports[0]) { event.ports[0].postMessage({ version: CONFIG.version }); } }); self.addEventListener('push', function (event) { event.waitUntil(showPushNotification(event)); }); self.addEventListener('notificationclick', function (event) { event.notification.close(); const data = event.notification.data || {}; event.waitUntil(focusOrOpen(new URL(safeUrl(data.url, '/'), self.location.origin).href)); }); if (CONFIG.push) { self.addEventListener('pushsubscriptionchange', function (event) { event.waitUntil(renewSubscription(event)); }); } if (CONFIG.mode === 'app') { self.addEventListener('fetch', onFetch); } async function install() { if (CONFIG.mode === 'app' && CONFIG.cacheContent) { await precache().catch(noop); } if (await canActivateImmediately()) { await self.skipWaiting(); } } // A worker whose predecessor answered pages from a cache waits until the // visitor accepts the update, so an open tab never mixes two versions of the // app. With nothing cached before, there is nothing to mix. async function canActivateImmediately() { if (CONFIG.mode !== 'app' || !CONFIG.cacheContent) { return true; } const keys = await caches.keys(); return !keys.some(function (key) { return key.indexOf(CACHE_PREFIX) === 0 && key.indexOf(VERSION_PREFIX) !== 0 && key.slice(-6) === '-pages'; }); } async function deleteCaches(shouldDelete) { const keys = await caches.keys(); await Promise.all( keys .filter(function (key) { return key.indexOf(CACHE_PREFIX) === 0 && shouldDelete(key); }) .map(function (key) { return caches.delete(key); }) ); } async function activate() { await deleteCaches(function (key) { return key.indexOf(VERSION_PREFIX) !== 0; }); // The browser starts loading a page while this worker wakes up, instead of after. // A worker without a fetch handler takes no part in navigations at all. if (self.registration.navigationPreload) { await (CONFIG.mode === 'app' ? self.registration.navigationPreload.enable() : self.registration.navigationPreload.disable() ).catch(noop); } await self.clients.claim(); } async function precache() { const pages = await caches.open(PAGES_CACHE); const assets = []; for (const url of CONFIG.precacheUrls) { try { const response = await fetch(url, { credentials: 'same-origin' }); if (!isCacheablePage(response)) { continue; } await pages.put(url, response.clone()); extractBuildAssets(await response.text()).forEach(function (asset) { if (assets.indexOf(asset) === -1) { assets.push(asset); } }); } catch (error) { // Offline or refused: the page is cached on its first visit instead. } } const cache = await caches.open(STATIC_CACHE); await Promise.all( assets.slice(0, MAX_PRECACHED_ASSETS).map(async function (asset) { try { const response = await fetch(asset, { credentials: 'same-origin' }); if (isCacheableAsset(response)) { await cache.put(asset, response); } } catch (error) { // Fetched again, and cached, the first time a page asks for it. } }) ); } // The hashed build files a page references. A scan rather than a pattern: the // file names are all this needs, and the markup around them does not matter. function extractBuildAssets(html) { const marker = '/_next/static/'; const stop = '<>() ' + '"' + "'"; const found = []; let start = html.indexOf(marker); while (start !== -1) { let end = start; while (end < html.length && html.charCodeAt(end) > 32 && stop.indexOf(html.charAt(end)) === -1) { end++; } const asset = html.slice(start, end); if (found.indexOf(asset) === -1) { found.push(asset); } start = html.indexOf(marker, end); } return found; } function onFetch(event) { const request = event.request; if (request.method !== 'GET') { return; } if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { return; } const url = new URL(request.url); if (url.origin !== self.location.origin) { return; } const dataPage = nextDataPage(url.pathname); if (dataPage !== null) { if (CONFIG.cacheContent && !isNetworkOnly(dataPage)) { event.respondWith(pageData(request, url, dataPage)); } return; } const navigation = request.mode === 'navigate'; if (!CONFIG.cacheContent || isNetworkOnly(url.pathname)) { if (navigation) { event.respondWith(networkOrOffline(event, request, url)); } return; } if (url.pathname.indexOf('/_next/static/') === 0) { event.respondWith(cacheFirst(event, request)); return; } if (navigation) { event.respondWith(networkFirstPage(event, request, url)); return; } if (request.destination === 'image' || request.destination === 'font' || url.pathname.indexOf('/_next/image') === 0) { event.respondWith(staleWhileRevalidate(event, request, ASSETS_CACHE, MAX_ASSETS, isCacheableAsset)); } } // The navigation-preload response the browser started while the worker woke // up, or a request of the worker's own. async function fromNetwork(event, request) { const preloaded = event.preloadResponse ? await event.preloadResponse.catch(noop) : undefined; return preloaded || fetch(request); } async function networkOrOffline(event, request, url) { try { return await fromNetwork(event, request); } catch (error) { return offlineResponse(url); } } // Pages come from the network, so prices, stock and everything else a page // shows are current. The cached copy stands in only offline, when the server // fails, or when the network is slow and the copy is recent. A page is cached // only when it was shown — so the build files it needs were loaded, and cached, // with it. async function networkFirstPage(event, request, url) { const cache = await caches.open(PAGES_CACHE); let servedFromCache = false; const network = fromNetwork(event, request).then(function (response) { if (!servedFromCache) { event.waitUntil(remember(cache, request, response.clone(), isCacheablePage, MAX_PAGES).catch(noop)); } return response; }); const slow = new Promise(function (resolve) { setTimeout(function () { resolve(null); }, SLOW_NETWORK_MS); }); try { const first = await Promise.race([network, slow]); if (first) { return await orLastGoodCopy(cache, request, first); } const recent = await recentCopy(cache, request); if (recent) { servedFromCache = true; event.waitUntil(network.catch(noop)); return recent; } return await orLastGoodCopy(cache, request, await network); } catch (error) { return (await cache.match(request, MATCH_OPTIONS)) || offlineResponse(url); } } // The page a Next.js data request (/_next/data//.json) is for, or // null for any other request. The home page is index.json, or the language // code alone (/en.json) on a site with languages. function nextDataPage(pathname) { const prefix = '/_next/data/'; const suffix = '.json'; if (pathname.indexOf(prefix) !== 0 || pathname.slice(-suffix.length) !== suffix) { return null; } const page = '/' + pathname.slice(prefix.length, -suffix.length).split('/').slice(1).join('/'); return page === '/index' ? '/' : page; } // The props Next.js fetches for a page as it hydrates, and before it shows // one in the browser. From the network, so they are current. Offline, or when // the server fails, they are read from the cached copy of the page itself, // which carries them: a page that opens from the cache then hydrates, where // Next.js would otherwise reload it over and over. async function pageData(request, url, page) { let response = null; let failure = null; try { response = await fetch(request); if (response.status < 500) { return response; } } catch (error) { failure = error; } const cached = await cachedPageData(url, page); if (cached) { return cached; } if (response) { return response; } throw failure; } async function cachedPageData(url, page) { const cache = await caches.open(PAGES_CACHE); // The home page in the default language is cached as / and its data asked for as /en. const unprefixed = splitLocale(page); const paths = unprefixed.locale || unprefixed.path === page ? [page] : [page, unprefixed.path]; for (const path of paths) { const cached = await cache.match(new URL(path, url.origin).href, PAGE_DATA_MATCH_OPTIONS); const props = cached ? nextDataProps(await cached.text()) : null; if (props) { return new Response(props, { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); } } return null; } // What a page's data request returns, as Next.js wrote it into the page: the // "props" of its __NEXT_DATA__ script. function nextDataProps(html) { const start = html.indexOf('id="__NEXT_DATA__"'); const open = start === -1 ? -1 : html.indexOf('>', start); const close = open === -1 ? -1 : html.indexOf('', open); if (close === -1) { return null; } try { const data = JSON.parse(html.slice(open + 1, close)); return data && data.props ? JSON.stringify(data.props) : null; } catch (error) { return null; } } // A server error shows the last copy that worked, when there is one. async function orLastGoodCopy(cache, request, response) { if (response.status < 500) { return response; } return (await cache.match(request, MATCH_OPTIONS)) || response; } async function recentCopy(cache, request) { const cached = await cache.match(request, MATCH_OPTIONS); if (!cached) { return null; } const storedAt = Date.parse(cached.headers.get('Date') || ''); return !isNaN(storedAt) && Date.now() - storedAt < SLOW_NETWORK_MAX_AGE_MS ? cached : null; } async function cacheFirst(event, request) { const cache = await caches.open(STATIC_CACHE); const cached = await cache.match(request, MATCH_OPTIONS); if (cached) { return cached; } const response = await fetch(request); if (isCacheableAsset(response)) { event.waitUntil(cache.put(request, response.clone()).catch(noop)); } return response; } async function staleWhileRevalidate(event, request, cacheName, maxEntries, isCacheable) { const cache = await caches.open(cacheName); const cached = await cache.match(request, MATCH_OPTIONS); const network = fetch(request).then(function (response) { event.waitUntil(remember(cache, request, response.clone(), isCacheable, maxEntries).catch(noop)); return response; }); if (cached) { event.waitUntil(network.catch(noop)); return cached; } return network; } // A fresh copy replaces the cached one. A response that now redirects, answers // 4xx or has turned private loses its cached copy, so it can never be shown // from the cache again; a server error keeps the last good copy. Once the site // asks for a password, nothing it showed before stays on the device. async function remember(cache, request, response, isCacheable, maxEntries) { if (isCacheable(response)) { await cache.put(request, response); await trim(cache, maxEntries); return; } if (response.status === 401) { await deleteCaches(function () { return true; }); return; } const definitive = response.type === 'opaqueredirect' || isPrivate(response) || (response.status >= 300 && response.status < 500); if (definitive) { await cache.delete(request, MATCH_OPTIONS); } } async function trim(cache, maxEntries) { const keys = await cache.keys(); for (let index = 0; index < keys.length - maxEntries; index++) { await cache.delete(keys[index]); } } function isPrivate(response) { const cacheControl = (response.headers.get('Cache-Control') || '').toLowerCase(); return cacheControl.indexOf('no-store') !== -1 || cacheControl.indexOf('private') !== -1; } function isCacheablePage(response) { return ( response.status === 200 && response.type === 'basic' && !response.redirected && !isPrivate(response) && (response.headers.get('Content-Type') || '').indexOf('text/html') !== -1 ); } function isCacheableAsset(response) { return response.status === 200 && response.type === 'basic' && !isPrivate(response); } // The language a path's first segment names, and the path without it. Next.js // also answers the default language under its own code (/en/checkout), so that // prefix is removed too. function splitLocale(pathname) { const segment = (pathname.split('/')[1] || '').toLowerCase(); if (segment && segment === CONFIG.defaultLocalePrefix.toLowerCase()) { return { locale: '', path: pathname.slice(segment.length + 1) || '/' }; } const match = CONFIG.prefixedLocales.filter(function (locale) { return locale.toLowerCase() === segment; })[0]; return match ? { locale: match, path: pathname.slice(match.length + 1) || '/' } : { locale: '', path: pathname }; } function isNetworkOnly(pathname) { let path = pathname; try { path = decodeURIComponent(pathname); } catch (error) { // Malformed escapes: match the path as it came. } path = splitLocale(path.toLowerCase()).path; if (path.length > 1 && path.charAt(path.length - 1) === '/') { path = path.slice(0, -1); } return CONFIG.networkOnly.some(function (pattern) { return pattern === '/' ? path === '/' : path === pattern || path.indexOf(pattern + '/') === 0; }); } function offlineResponse(url) { const locale = splitLocale(url.pathname).locale || CONFIG.defaultLocale; const html = OFFLINE_PAGES[locale] || OFFLINE_PAGES[CONFIG.defaultLocale]; return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, }); } function text(value) { return typeof value === 'string' ? value : ''; } // Same-origin paths and https links only: a notification must never open, or // load an image from, a javascript: or data: URL. function safeUrl(value, fallback) { if (typeof value !== 'string' || !value) { return fallback; } try { const url = new URL(value, self.location.origin); if (url.origin === self.location.origin) { return url.pathname + url.search + url.hash; } if (url.protocol === 'https:') { return url.href; } } catch (error) { // Not a URL at all. } return fallback; } function showPushNotification(event) { let data = {}; if (event.data) { try { data = event.data.json() || {}; } catch (error) { data = { body: event.data.text() }; } } const options = { body: text(data.body), data: { url: safeUrl(data.url, '/') } }; const icon = safeUrl(data.icon, null) || CONFIG.notificationIcon; if (icon) { options.icon = icon; } const image = safeUrl(data.image, null); if (image) { options.image = image; } const tag = text(data.tag); if (tag) { options.tag = tag; options.renotify = true; } if (data.requireInteraction === true) { options.requireInteraction = true; } if (data.silent === true) { options.silent = true; } return self.registration.showNotification(text(data.title) || CONFIG.appName, options); } async function focusOrOpen(target) { const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const open = windows.filter(function (client) { return client.url === target; })[0]; if (open && 'focus' in open) { return open.focus(); } if (self.clients.openWindow) { return self.clients.openWindow(target); } return undefined; } function base64UrlToBytes(value) { const padded = (value + '==='.slice((value.length + 3) % 4)).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(padded); const bytes = new Uint8Array(raw.length); for (let index = 0; index < raw.length; index++) { bytes[index] = raw.charCodeAt(index); } return bytes; } function authSecret(subscription) { const json = subscription ? subscription.toJSON() : null; return (json && json.keys && json.keys.auth) || null; } // The push service replaced or expired the subscription (Firefox does this): // subscribe again with the same key and tell the app which endpoint it // replaces — with that subscription's secret, the proof it was this browser's. async function renewSubscription(event) { try { const subscription = event.newSubscription || (await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: base64UrlToBytes(CONFIG.push.vapidPublicKey), })); await fetch(CONFIG.push.subscriptionsPath, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subscription: subscription.toJSON(), previousEndpoint: event.oldSubscription ? event.oldSubscription.endpoint : null, previousAuth: authSecret(event.oldSubscription), applicationServerKey: CONFIG.push.vapidPublicKey, }), }); } catch (error) { // The page re-syncs the subscription on the visitor's next visit. } } ` /** * What an earlier publish's worker is replaced with once the app is no longer * installable: it takes over at once, deletes every cache the app created and * unregisters itself. It has no fetch handler, so the pages already open keep * working from the network — nobody is reloaded in the middle of a checkout. */ export const buildRetiringWorkerSource = (): string => `/* Generated by TeleportHQ: this site is no longer an installable app. */ 'use strict'; const CACHE_PREFIX = ${toJsLiteral(CACHE_NAME_PREFIX)}; self.addEventListener('install', function () { self.skipWaiting(); }); self.addEventListener('activate', function (event) { event.waitUntil(retire()); }); async function retire() { const keys = await caches.keys(); await Promise.all( keys .filter(function (key) { return key.indexOf(CACHE_PREFIX) === 0; }) .map(function (key) { return caches.delete(key); }) ); await self.registration.unregister(); } `