Service worker para obtener modo sin conexión

Introducción

Los service workers actúan como proxies en segundo plano. Cuando se realiza una petición de red a través de nuestro sitio web, primero pasan por el service worker que está en uso. Gracias a esto y a otros APIs como el de almacenamiento en caché, podemos interceptar una petición de red y devolver inmediatamente una versión cacheada, obteniendo disponibilidad frente a fallos de red (modo offline), a la vez que velocidad al no tener que llevar la petición hasta el servidor.

El problema de esta estrategia conocida como offline-first es que siempre se muestra la versión guardada aunque no sea la más actual. Este problema se puede solventar de muchas maneras como por ejemplo:

  • Mostrar la versión guardada y realizar en segundo plano una nueva petición de red para guardarla en caché. Tras esto se le informa al usuario de que hay nuevos datos para que refresque la página y vea la nueva versión.

  • Mediante técnicas de invalidación de caché para eliminar la versión guardada de las páginas que están obsoletas.

  • Creando una caché nueva en cada despliegue. A esto se le conoce como versionamiento: cache-v1, cache-v2, etc. Cada nuevo despligue invalida y elimina las cachés antiguas.

Estas soluciones añaden bastante complejidad, sobre todo si se realizan desde cero sin utilizar alguna librería como Workbox.

Por estos motivos he cambiado la estrategia de este blog de offline-first a network-first con modo sin conexión. No es perfecto pero es una manera bastante simple de ofrecer modo offline a páginas ya visitadas, a la vez que evitar lidiar con páginas que se quedan obsoletas.

Aquí aparece una curiosidad que admito me costó un rato solucionar. ¿Sabrías decir por qué esto no funciona?

  • JavaScript
event.waitUntil(async () => {
  // ...
})

Las funciones como event.waitUntil() o event.respondWith() no aceptan una función como argumento, si no una promesa. Por lo tanto que hay que llamar a la función asíncrona (por ejemplo, mediante un IIFE) para que devuelva una promesa y se pase como argumento.

  • JavaScript
event.waitUntil((async () => {
  // ...
})())

Service worker - install

El fichero sw.js comienza con el evento de instalación. Éste se ejecuta cuando se instala el service worker por primera vez en el navegador del usuario.

sw.js
  • JavaScript
self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    // Abrimos la caché llamada "cache" (qué originalidad).
    const cache = await caches.open('cache')

    /**
     * Realizamos peticiones de red a tres elementos para guardarlos en caché
     * y que estén siempre disponibles.
     */
    await cache.addAll([
      // La página que verán los usuarios sin conexión.
      '/offline',

      // El fichero CSS principal.
      '/themes/default.css',

      // La tipografía que se utiliza en casi todas partes.
      '/fonts/Nunito.woff2',
    ])

    self.skipWaiting()
  })())
})

Service worker - activate

Posteriormente activamos el service worker:

sw.js
  • JavaScript
self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim())
})

Service worker - fetch

Y ya por último el evento fetch se encarga de interceptar las peticiones de red y gestionar toda la estrategia. Veamos paso por paso a través de los comentarios.

sw.js
  • JavaScript
self.addEventListener('fetch', (event) => {
  /**
   * En el caso de las peticiones que no sean GET, peticiones a
   * otros dominios, o peticiones a páginas como "rss.xml" o
   * "sitemap.xml", no queremos ni cachear el resultado ni servir
   * versiones cacheadas, por lo que respondemos inmediatamente
   * con una petición de red. Si no hubiera red... ¡mala suerte!
   */
  if (
    event.request.method !== 'GET'
    || !event.request.url.includes('felixsanz.dev')
    || event.request.url.endsWith('.xml')
  ) {
    event.respondWith(fetch(event.request))
    return
  }

  // En caso contrario respondemos con el resultado de la función async.
  event.respondWith((async () => {
    // Primero abrimos la caché en este evento.
    const cache = await caches.open('cache')

    // Comprobamos si el elemento ya existe en la caché.
    const cached = await caches.match(event.request, { ignoreSearch: true })

    /**
     * Creamos un controlador para poder cancelar la petición de red
     * cuando tarde demasiado y así proceder a mostrar el modo offline.
     */
    const controller = new AbortController()

    /**
     * Creamos un temporizador para que se cancele la petición de red
     * pasado un tiempo.
     *
     * Si el elemento se encuentra en caché, esperaremos 6 segundos.
     * De no ser así, daremos algo más de margen y esperaremos 12 segundos.
     */
    const timeout = setTimeout(() => controller.abort(), (cached) ? 6000 : 12000)

    try {
      // Realizamos la petición de red.
      const response = await fetch(event.request, { signal: controller.signal })

      // La petición ha sido válida, cancelamos el temporizador.
      clearTimeout(timeout)

      /**
       * Si la respuesta es correcta, la clonamos y la guardamos en caché.
       * 
       * La próxima vez que el usuario sin conexión acceda a este elemento podrá
       * visualizarlo.
       */
      if (response.status === 200) {
        await cache.put(event.request, response.clone())
      }

      /**
       * Respondemos con el resultado del fetch tanto si es 200 como si
       * fuese cualquier otro resultado (30x, 40x...).
       */
      return response
    } catch (error) {
      /**
       * Si la petición de red no ha sido válida acabaremos aquí.
       *
       * Esto puede ser, por ejemplo, porque ha habido un error en
       * el servidor (50x), o simplemente porque pasó el tiempo del
       * temporizador sin obtener una respuesta de red, y el controlador abortó
       * la petición de red para no seguir esperando.
       * 
       * Si el error proviene del servidor, aún tenemos el temporizador activo
       * por lo que debemos cancelarlo.
       */
      if (error.name !== 'AbortError') {
        clearTimeout(timeout)
      }

      // Mostrar la versión cacheada si existiera, o la página de /offline.
      return cached || cache.match('/offline')
    }
  })())
})

Si quieres acceder a la versión del service worker sin comentarios para poder copiar y pegar, recuerda que lo tienes en /sw.js o en el repositorio del blog en GitHub, dentro de snippets/service-worker-to-get-offline-mode/sw.js.

Puedes apoyarme para que pueda dedicar aún más tiempo a escribir artículos y tener recursos para crear nuevos proyectos. ¡Gracias!