diff --git a/src/sw.ts b/src/sw.ts index 029a48e2..1d4722bc 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -32,6 +32,12 @@ interface GetVerifiedFetchUrlOptions { path: string } +interface StoreReponseInCacheOptions { + response: Response + cacheKey: string + isMutable: boolean +} + /** ****************************************************** * "globals" @@ -40,6 +46,9 @@ interface GetVerifiedFetchUrlOptions { declare let self: ServiceWorkerGlobalScope let verifiedFetch: VerifiedFetch const channel = new HeliaServiceWorkerCommsChannel('SW') +const IMMUTABLE_CACHE = 'IMMUTABLE_CACHE' +const MUTABLE_CACHE = 'MUTABLE_CACHE' +const ONE_HOUR_IN_SECONDS = 3600 const urlInterceptRegex = [new RegExp(`${self.location.origin}/ip(n|f)s/`)] const updateVerifiedFetch = async (): Promise => { verifiedFetch = await getVerifiedFetch() @@ -85,6 +94,7 @@ self.addEventListener('fetch', (event) => { const request = event.request const urlString = request.url const url = new URL(urlString) + log('helia-sw: incoming request url: %s:', event.request.url) if (isConfigPageRequest(url) || isSwAssetRequest(event)) { // get the assets from the server @@ -98,10 +108,9 @@ self.addEventListener('fetch', (event) => { } if (isRootRequestForContent(event)) { - // intercept and do our own stuff... event.respondWith(fetchHandler({ path: url.pathname, request })) } else if (isSubdomainRequest(event)) { - event.respondWith(fetchHandler({ path: url.pathname, request })) + event.respondWith(getResponseFromCacheOrFetch(event)) } }) @@ -177,13 +186,99 @@ function isSwAssetRequest (event: FetchEvent): boolean { return isActualSwAsset } +/** + * Set the expires header on a response object to a timestamp based on the passed ttl interval + * Defaults to + */ +function setExpiresHeader (response: Response, ttlSeconds: number = ONE_HOUR_IN_SECONDS): void { + const expirationTime = new Date(Date.now() + ttlSeconds * 1000) + + response.headers.set('sw-cache-expires', expirationTime.toUTCString()) +} + +/** + * Checks whether a cached response object has expired by looking at the expires header + * Note that this ignores the Cache-Control header since the expires header is set by us + */ +function hasExpired (response: Response): boolean { + const expiresHeader = response.headers.get('sw-cache-expires') + + if (expiresHeader == null) { + return false + } + + const expires = new Date(expiresHeader) + const now = new Date() + + return expires < now +} + +function getCacheKey (event: FetchEvent): string { + return `${event.request.url}-${event.request.headers.get('Accept') ?? ''}` +} + +async function fetchAndUpdateCache (event: FetchEvent, url: URL, cacheKey: string): Promise { + const response = await fetchHandler({ path: url.pathname, request: event.request }) + try { + await storeReponseInCache({ response, isMutable: true, cacheKey }) + trace('helia-ws: updated cache for %s', cacheKey) + } catch (err) { + error('helia-ws: failed updating response in cache for %s', cacheKey, err) + } + return response +} + +async function getResponseFromCacheOrFetch (event: FetchEvent): Promise { + const { protocol } = getSubdomainParts(event.request.url) + const url = new URL(event.request.url) + const isMutable = protocol === 'ipns' + const cacheKey = getCacheKey(event) + trace('helia-sw: cache key: %s', cacheKey) + const cache = await caches.open(isMutable ? MUTABLE_CACHE : IMMUTABLE_CACHE) + const cachedResponse = await cache.match(cacheKey) + const validCacheHit = cachedResponse != null && !hasExpired(cachedResponse) + + if (validCacheHit) { + log('helia-ws: cached response HIT for %s (expires: %s) %o', cacheKey, cachedResponse.headers.get('sw-cache-expires'), cachedResponse) + + if (isMutable) { + // If the response is mutable, update the cache in the background. + void fetchAndUpdateCache(event, url, cacheKey) + } + + return cachedResponse + } + + log('helia-ws: cached response MISS for %s', cacheKey) + + return fetchAndUpdateCache(event, url, cacheKey) +} + +async function storeReponseInCache ({ response, isMutable, cacheKey }: StoreReponseInCacheOptions): Promise { + // 👇 only cache successful responses + if (!response.ok) { + return + } + trace('helia-ws: updating cache for %s in the background', cacheKey) + + const cache = await caches.open(isMutable ? MUTABLE_CACHE : IMMUTABLE_CACHE) + + // Clone the response since streams can only be consumed once. + const respToCache = response.clone() + + if (isMutable) { + trace('helia-ws: setting expires header on response key %s before storing in cache', cacheKey) + // 👇 Set expires header to an hour from now for mutable (ipns://) resources + // Note that this technically breaks HTTP semantics, whereby the cache-control max-age takes precendence + // Setting this header is only used by the service worker using a mechanism similar to stale-while-revalidate + setExpiresHeader(respToCache, ONE_HOUR_IN_SECONDS) + } + + log('helia-ws: storing response for key %s in cache', cacheKey) + await cache.put(cacheKey, respToCache) +} + async function fetchHandler ({ path, request }: FetchHandlerArg): Promise { - /** - * > Any global variables you set will be lost if the service worker shuts down. - * - * @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle - */ - verifiedFetch = verifiedFetch ?? await getVerifiedFetch() // test and enforce origin isolation before anything else is executed const originLocation = await findOriginIsolationRedirect(new URL(request.url)) if (originLocation !== null) { @@ -197,6 +292,13 @@ async function fetchHandler ({ path, request }: FetchHandlerArg): Promise Any global variables you set will be lost if the service worker shuts down. + * + * @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle + */ + verifiedFetch = verifiedFetch ?? await getVerifiedFetch() + /** * Note that there are existing bugs regarding service worker signal handling: * * https://bugs.chromium.org/p/chromium/issues/detail?id=823697