Skip to content

@aeppic/service-worker

The service worker project compiles into a javascript file which can be used as a service worker for aeppic installations.

Usage

The default behavior in aeppic is to load a service-worker Layout when rendering ae-root which triggers the loading of the service worker from a Preference document.

Caching Strategy

The service worker caches all files returned by /api/v4/statics/_list.

All files listed in there with http:// or https:// are seens as external and cached with no-cors mode.

This actual implementation is here:

ts
async function cacheStaticFiles(cacheName: string, endpointUrl: string): Promise<string | undefined> {
  const lastStaticFileListCachedEtag = await get(cacheName, 'lastStaticFileListCached')

  try {
    const cache = await caches.open(cacheName)
    const response = await fetch(endpointUrl) 

    if (!response.ok) {
      fail({ status: response.status }, 'Failed to fetch static files list:', response.statusText)
      return null
    }

    const eTag = response.headers.get('ETag')

    // We do not want to cache the static files list if it has not changed.
    if (lastStaticFileListCachedEtag === eTag) {
      info({ status: response.status }, 'Static files list is up to date')
      return lastStaticFileListCachedEtag
    }

    info('Caching files (etag:%s)', eTag)

    const staticFiles: StaticFile[] = await response.json() as StaticFile[]
    let errorsDuringCaching = 0

    staticFiles.push({ url: DEFAULT_SPA_URL, path: '*', hash: '', size: 0 })

    // We limit the number of concurrent requests to avoid
    // overloading the server with requests or the browser to
    // make too many calls at once.
    const semaphore = new Semaphore(MAX_NUMBER_OF_CONCURRENT_REQUESTS)

    await Promise.all(staticFiles.map(async file => {
      await semaphore.acquire()

      try {
        const keyForFileHash = `static:${file.url}`
        const hash = await get(cacheName, keyForFileHash)

        // Skip files that might already be cached because they have the same hashs
        if (hash === file.hash) {
          const alreadyPresent = await cache.match(file.url)
          
          if (alreadyPresent) {
            return
          }
        }
      
        const isExternalResource = file.url.startsWith('http://') || file.url.startsWith('https://')
        const fileResponse = await fetch(file.url, { mode: isExternalResource ? 'no-cors' : 'cors' })
        
        if (fileResponse.ok) {
          await cache.put(file.url, fileResponse)
          await set(cacheName, keyForFileHash, file.hash)
        } else if (fileResponse.status === 0) {
          if (isExternalResource) {
            await cache.put(file.url, fileResponse)
            // We cannot store the hash of the file because the response
            // is opaque so it might be invalid and it definitely should
            // be recached on the next request
          }
        } else {
          errorsDuringCaching++
          warn('Could not cache file at %s [%d: %s]', file.url, fileResponse.status, fileResponse.statusText)
        }
      } catch (error) {
        errorsDuringCaching++
        fail(`Could not cache file '${file.url}':`, error)
      } finally {
        semaphore.release()
      }
    }))

    // The cache is complete, so we can update the ETag and remember
    // to not cache the static files list again until it has changed.
    if (errorsDuringCaching === 0) {
      await set(cacheName, 'lastStaticFileListCached', response.headers.get('ETag') || '')
    }
 
    // Read total size of all files by reading from X-Total-Static-Content-Size header
    const totalSizeOfAllFiles = Number.parseInt(response.headers.get('X-Total-Static-Content-Size') || '0', 10)
    const note = errorsDuringCaching > 0 ? ` But ${errorsDuringCaching} errors occured.` : ''
    info(`Cached ${staticFiles.length} (#${totalSizeOfAllFiles} bytes) static files.` + note)
  } catch (error) {
    warn('Error fetching static files list. Could be offline.', error)
    return null
  }

  return lastStaticFileListCachedEtag
}

All files returned during normal fetches by the clients are also added to the static cache if any of the following conditions are true:

  1. They are marked as immutable in the Cache-Control reponse header
  2. They are cacheable with max-age for at least a day

This actual implementation is here (also checks if response is status ok etc):

ts
function canCache(response: Response) {
  if (!response.ok) {
    return false
  }

  // We can cache all requests that are marked as immutable in the Cache-Control header.
  // The normal web-cache can be cleared by the users to easily and we know these
  // files to not change.
  //
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 
  //
  // This specifically is done by the /api/packages api for example when a versioned content
  // call is performed
  const cacheControlHeader = response.headers.get('Cache-Control')
  const cacheControl = cacheControlHeader ? cacheControlHeader.split(',') : []

  if (cacheControl.includes('immutable')) {
    return true
  }

  // We also cache all requests that are marked as with a max-age of at least 1 day.
  const maxAge = cacheControl.find(value => value.startsWith('max-age='))
  
  if (maxAge) {
    const maxAgeInSeconds = Number.parseInt(maxAge.slice(8), 10)
    const maxAgeInDays = maxAgeInSeconds / 60 / 60 / 24

    if (maxAgeInDays >= 1) {
      return true
    }
  }

  return false
}