Service Workers en producción

Captura de pantalla vertical

Resumen

Descubre cómo usamos las bibliotecas de service worker para que la app web de Google I/O 2015 fuera rápida y priorizara la conexión sin conexión.

Descripción general

El equipo de Relaciones con Desarrolladores de Google escribió la app web de Google I/O 2015 de este año, en función de los diseños de nuestros amigos de Instrument, quienes escribieron el ingenioso experimento audiovisual. La misión de nuestro equipo era garantizar que la app web de I/O (a la que me referiré por su nombre en clave, IOWA) mostrara todo lo que la Web moderna podía hacer. Una experiencia completa sin conexión estaba en la parte superior de nuestra lista de funciones imprescindibles.

Si leíste alguno de los otros artículos de este sitio recientemente, sin dudas te encontraste con los service workers, y no te sorprenderá saber que la compatibilidad sin conexión de IOWA depende en gran medida de ellos. Motivados por las necesidades reales de IOWA, desarrollamos dos bibliotecas para controlar dos casos de uso sin conexión diferentes: sw-precache para automatizar el almacenamiento en caché previo de recursos estáticos y sw-toolbox para controlar el almacenamiento en caché del tiempo de ejecución y las estrategias de resguardo.

Las bibliotecas se complementan muy bien y nos permitieron implementar una estrategia de alto rendimiento en la que la “cáscara” de contenido estático de IOWA siempre se entregaba directamente desde la caché, y los recursos dinámicos o remotos se entregaban desde la red, con resguardos a respuestas estáticas o almacenadas en caché cuando era necesario.

Almacenamiento en caché previo con sw-precache

Los recursos estáticos de IOWA (HTML, JavaScript, CSS e imágenes) proporcionan la carcasa principal para la aplicación web. Había dos requisitos específicos que eran importantes cuando se pensaba en almacenar en caché estos recursos: queríamos asegurarnos de que la mayoría de los recursos estáticos se almacenaran en caché y se mantuvieran actualizados. sw-precache se creó teniendo en cuenta esos requisitos.

Integración del tiempo de compilación

sw-precache con el proceso de compilación basado en gulp de IOWA, y dependemos de una serie de patrones glob para asegurarnos de generar una lista completa de todos los recursos estáticos que usa IOWA.

staticFileGlobs: [
    rootDir + '/bower_components/**/*.{html,js,css}',
    rootDir + '/elements/**',
    rootDir + '/fonts/**',
    rootDir + '/images/**',
    rootDir + '/scripts/**',
    rootDir + '/styles/**/*.css',
    rootDir + '/data-worker-scripts.js'
]

Los enfoques alternativos, como codificar de forma fija una lista de nombres de archivos en un array y recordar aumentar un número de versión de caché cada vez que cambiaba alguno de esos archivos, eran demasiado propensos a errores, en especial, dado que teníamos varios miembros del equipo que revisaban el código. Nadie quiere dejar de lado la compatibilidad sin conexión por dejar un archivo nuevo en un array mantenido de forma manual. La integración en el tiempo de compilación permitió que pudiéramos hacer cambios en los archivos existentes y agregar archivos nuevos sin tener que preocuparnos por eso.

Actualiza los recursos almacenados en caché

sw-precache genera una secuencia de comandos del trabajador de servicio básica que incluye un hash MD5 único para cada recurso que se almacena en caché de antemano. Cada vez que cambia un recurso existente o se agrega uno nuevo, se vuelve a generar la secuencia de comandos del servicio de trabajo. Esto activa automáticamente el flujo de actualización del service worker, en el que se almacenan en caché los recursos nuevos y se borran los recursos desactualizados. Los recursos existentes que tengan valores hash MD5 idénticos se dejan como están. Eso significa que los usuarios que visitaron el sitio antes solo terminan descargando el conjunto mínimo de recursos modificados, lo que genera una experiencia mucho más eficiente que si toda la caché hubiera caducado en masa.

Cada archivo que coincida con uno de los patrones glob se descarga y almacena en caché la primera vez que un usuario visita IOWA. Nos esforzamos por garantizar que solo se almacenen en caché previamente los recursos críticos necesarios para renderizar la página. El contenido secundario, como el contenido multimedia que se usa en el experimento audiovisual o las imágenes de perfil de los oradores de las sesiones, no se prealmacenó de forma deliberada y, en su lugar, usamos la biblioteca sw-toolbox para controlar las solicitudes sin conexión para esos recursos.

sw-toolbox, para todas nuestras necesidades dinámicas

Como se mencionó, no es factible almacenar en caché todos los recursos que un sitio necesita para funcionar sin conexión. Algunos recursos son demasiado grandes o se usan con poca frecuencia para que valga la pena, y otros son dinámicos, como las respuestas de una API o un servicio remotos. Sin embargo, el hecho de que una solicitud no esté almacenada en caché previamente no significa que deba generar un NetworkError. sw-toolbox nos brindó la flexibilidad para implementar controladores de solicitudes que controlan la caché del tiempo de ejecución para algunos recursos y los resguardos personalizados para otros. También lo usamos para actualizar nuestros recursos almacenados en caché anteriormente en respuesta a las notificaciones push.

Estos son algunos ejemplos de controladores de solicitudes personalizados que compilamos en sw-toolbox. Fue fácil integrarlos con la secuencia de comandos del service worker base a través de importScripts parameter de sw-precache, que extrae archivos JavaScript independientes en el alcance del service worker.

Experimento audiovisual

Para el experimento de audio y video, usamos la estrategia de caché networkFirst de sw-toolbox. Todas las solicitudes HTTP que coincidan con el patrón de URL del experimento se realizarían primero en la red y, si se devolviera una respuesta correcta, esta se almacenaría con la API de Cache Storage. Si se realiza una solicitud posterior cuando la red no está disponible, se usará la respuesta almacenada en caché anteriormente.

Como la caché se actualizaba automáticamente cada vez que se recibía una respuesta de red correcta, no tuvimos que crear versiones de recursos ni vencer entradas.

toolbox.router.get('/experiment/(.+)', toolbox.networkFirst);

Imágenes de perfil de los oradores

En el caso de las imágenes de perfil de los oradores, nuestro objetivo era mostrar una versión almacenada en caché de la imagen de un orador determinado si estaba disponible y, de lo contrario, recurrir a la red para recuperarla. Si esa solicitud de red fallaba, como resguardo final, usábamos una imagen genérica de marcador de posición que se almacenaba en caché previamente (y, por lo tanto, siempre estaría disponible). Esta es una estrategia común que se usa cuando se trata de imágenes que se pueden reemplazar por un marcador de posición genérico, y fue fácil de implementar encadenando los controladores cacheFirst y cacheOnly de sw-toolbox.

var DEFAULT_PROFILE_IMAGE = 'images/touch/homescreen96.png';

function profileImageRequest(request) {
    return toolbox.cacheFirst(request).catch(function() {
    return toolbox.cacheOnly(new Request(DEFAULT_PROFILE_IMAGE));
    });
}

toolbox.precache([DEFAULT_PROFILE_IMAGE]);
toolbox.router.get('/(.+)/images/speakers/(.*)',
                    profileImageRequest,
                    {origin: /.*\.googleapis\.com/});
Imágenes de perfil de una página de sesión
Imágenes de perfil de una página de sesión.

Actualizaciones de las agendas de los usuarios

Una de las funciones clave de IOWA era permitir que los usuarios que accedían a sus cuentas crearan y mantuvieran un programa de sesiones a las que planeaban asistir. Como era de esperar, las actualizaciones de la sesión se realizaron a través de solicitudes HTTP POST a un servidor de backend, y dedicamos tiempo a encontrar la mejor manera de controlar esas solicitudes que modifican el estado cuando el usuario no tiene conexión. Creamos una combinación de un objeto que puso en cola las solicitudes fallidas en IndexedDB, junto con la lógica en la página web principal que verificaba IndexedDB en busca de solicitudes en cola y las volvía a intentar.

var DB_NAME = 'shed-offline-session-updates';

function queueFailedSessionUpdateRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, request.method);
    });
}

function handleSessionUpdateRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedSessionUpdateRequest(request);
    });
}

toolbox.router.put('/(.+)api/v1/user/schedule/(.+)',
                    handleSessionUpdateRequest);
toolbox.router.delete('/(.+)api/v1/user/schedule/(.+)',
                        handleSessionUpdateRequest);

Como los reintentos se realizaron desde el contexto de la página principal, pudimos asegurarnos de que incluían un conjunto nuevo de credenciales de usuario. Una vez que los reintentos se completaron correctamente, mostramos un mensaje para informarle al usuario que se habían aplicado las actualizaciones que estaban en cola anteriormente.

simpleDB.open(QUEUED_SESSION_UPDATES_DB_NAME).then(function(db) {
    var replayPromises = [];
    return db.forEach(function(url, method) {
    var promise = IOWA.Request.xhrPromise(method, url, true).then(function() {
        return db.delete(url).then(function() {
        return true;
        });
    });
    replayPromises.push(promise);
    }).then(function() {
    if (replayPromises.length) {
        return Promise.all(replayPromises).then(function() {
        IOWA.Elements.Toast.showMessage(
            'My Schedule was updated with offline changes.');
        });
    }
    });
}).catch(function() {
    IOWA.Elements.Toast.showMessage(
    'Offline changes could not be applied to My Schedule.');
});

Google Analytics sin conexión

De manera similar, implementamos un controlador para poner en cola las solicitudes de Google Analytics que fallaron y tratar de volver a reproducirlas más tarde, cuando la red estuviera disponible. Con este enfoque, estar sin conexión no significa sacrificar las estadísticas que ofrece Google Analytics. Agregamos el parámetro qt a cada solicitud en fila, establecido en la cantidad de tiempo que transcurrió desde que se intentó realizar la solicitud por primera vez, para garantizar que un tiempo de atribución de eventos adecuado llegue al backend de Google Analytics. Google Analytics admite oficialmente valores de qt de hasta 4 horas, por lo que hicimos un esfuerzo máximo para volver a reproducir esas solicitudes lo antes posible, cada vez que se iniciaba el trabajador del servicio.

var DB_NAME = 'offline-analytics';
var EXPIRATION_TIME_DELTA = 86400000;
var ORIGIN = /https?:\/\/((www|ssl)\.)?google-analytics\.com/;

function replayQueuedAnalyticsRequests() {
    simpleDB.open(DB_NAME).then(function(db) {
    db.forEach(function(url, originalTimestamp) {
        var timeDelta = Date.now() - originalTimestamp;
        var replayUrl = url + '&qt=' + timeDelta;
        fetch(replayUrl).then(function(response) {
        if (response.status >= 500) {
            return Response.error();
        }
        db.delete(url);
        }).catch(function(error) {
        if (timeDelta > EXPIRATION_TIME_DELTA) {
            db.delete(url);
        }
        });
    });
    });
}

function queueFailedAnalyticsRequest(request) {
    simpleDB.open(DB_NAME).then(function(db) {
    db.set(request.url, Date.now());
    });
}

function handleAnalyticsCollectionRequest(request) {
    return global.fetch(request).then(function(response) {
    if (response.status >= 500) {
        return Response.error();
    }
    return response;
    }).catch(function() {
    queueFailedAnalyticsRequest(request);
    });
}

toolbox.router.get('/collect',
                    handleAnalyticsCollectionRequest,
                    {origin: ORIGIN});
toolbox.router.get('/analytics.js',
                    toolbox.networkFirst,
                    {origin: ORIGIN});

replayQueuedAnalyticsRequests();

Páginas de destino de notificaciones push

Los trabajadores del servicio no solo controlaron la funcionalidad sin conexión de IOWA, sino que también alimentaron las notificaciones push que usamos para notificar a los usuarios sobre las actualizaciones de sus sesiones guardadas en favoritos. La página de destino asociada con esas notificaciones mostró los detalles actualizados de la sesión. Esas páginas de destino ya se almacenaban en caché como parte del sitio general, por lo que ya funcionaban sin conexión, pero necesitábamos asegurarnos de que los detalles de la sesión en esa página estuvieran actualizados, incluso cuando se vieran sin conexión. Para ello, modificamos los metadatos de la sesión almacenados en caché con las actualizaciones que activaron la notificación push y almacenamos el resultado en la caché. Esta información actualizada se usará la próxima vez que se abra la página de detalles de la sesión, ya sea en línea o sin conexión.

caches.open(toolbox.options.cacheName).then(function(cache) {
    cache.match('api/v1/schedule').then(function(response) {
    if (response) {
        parseResponseJSON(response).then(function(schedule) {
        sessions.forEach(function(session) {
            schedule.sessions[session.id] = session;
        });
        cache.put('api/v1/schedule',
                    new Response(JSON.stringify(schedule)));
        });
    } else {
        toolbox.cache('api/v1/schedule');
    }
    });
});

Problemas potenciales y consideraciones

Por supuesto, nadie trabaja en un proyecto de la escala de IOWA sin encontrarse con algunos aspectos inesperados. Estas son algunas de las que encontramos y cómo las solucionamos.

Contenido inactivo

Cada vez que planificas una estrategia de almacenamiento en caché, ya sea que se implemente a través de trabajadores de servicio o con la caché estándar del navegador, existe un equilibrio entre entregar los recursos lo más rápido posible y entregar los recursos más recientes. A través de sw-precache, implementamos una estrategia agresiva de almacenamiento en caché para la shell de nuestra aplicación, lo que significa que nuestro trabajador de servicio no verificaría la red en busca de actualizaciones antes de mostrar el HTML, JavaScript y CSS en la página.

Por fortuna, pudimos aprovechar los eventos del ciclo de vida del service worker para detectar cuándo había contenido nuevo disponible después de que la página ya se había cargado. Cuando se detecta un servicio de trabajo actualizado, le mostramos al usuario un mensaje emergente para informarle que debe volver a cargar la página para ver el contenido más reciente.

if (navigator.serviceWorker && navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.onstatechange = function(e) {
    if (e.target.state === 'redundant') {
        var tapHandler = function() {
        window.location.reload();
        };
        IOWA.Elements.Toast.showMessage(
        'Tap here or refresh the page for the latest content.',
        tapHandler);
    }
    };
}
La notificación de contenido más reciente
El aviso "Contenido más reciente".

Asegúrate de que el contenido estático sea estático

sw-precache usa un hash MD5 del contenido de los archivos locales y solo recupera los recursos cuyo hash cambió. Esto significa que los recursos están disponibles en la página casi de inmediato, pero también significa que, una vez que se almacena algo en caché, permanecerá allí hasta que se le asigne un hash nuevo en una secuencia de comandos del service worker actualizada.

Tuvimos un problema con este comportamiento durante la E/S debido a que nuestro backend necesitaba actualizar de forma dinámica los IDs de los videos de YouTube de la transmisión en vivo para cada día de la conferencia. Debido a que el archivo de plantilla subyacente era estático y no cambiaba, no se activó nuestro flujo de actualización del trabajador del servicio, y lo que debía ser una respuesta dinámica del servidor con la actualización de los videos de YouTube terminó siendo la respuesta almacenada en caché para varios usuarios.

Para evitar este tipo de problema, asegúrate de que tu aplicación web esté estructurada de modo que el shell siempre sea estático y se pueda almacenar en caché de forma segura, mientras que los recursos dinámicos que modifican el shell se carguen de forma independiente.

Evita el almacenamiento en caché de tus solicitudes de almacenamiento en caché previo

Cuando sw-precache realiza solicitudes de recursos para la caché previa, usa esas respuestas de forma indefinida, siempre y cuando considere que el hash MD5 del archivo no cambió. Esto significa que es muy importante asegurarse de que la respuesta a la solicitud de almacenamiento en caché previo sea nueva y no se muestre desde la caché HTTP del navegador. (Sí, las solicitudes fetch() que se realizan en un servicio trabajador pueden responder con datos de la caché HTTP del navegador).

Para garantizar que las respuestas que almacenamos en caché sean directamente de la red y no de la caché HTTP del navegador, sw-precache agrega automáticamente un parámetro de consulta que evita la caché a cada URL que solicita. Si no usas sw-precache y usas una estrategia de respuesta en la que se prioriza la caché, asegúrate de hacer algo similar en tu propio código.

Una solución más sencilla para evitar el almacenamiento en caché sería configurar el modo de almacenamiento en caché de cada Request que se usa para el almacenamiento en caché previo en reload, lo que garantizará que la respuesta provenga de la red. Sin embargo, en el momento de escribir este artículo, la opción de modo de caché no es compatible con Chrome.

Compatibilidad para acceder y salir

IOWA permitía que los usuarios accedieran con sus Cuentas de Google y actualizaran sus agendas de eventos personalizadas, pero eso también significaba que los usuarios podrían salir más tarde. Almacenar en caché los datos de respuesta personalizados es, sin duda, un tema complicado, y no siempre hay un solo enfoque correcto.

Dado que ver tu agenda personal, incluso sin conexión, era fundamental para la experiencia de IOWA, decidimos que era apropiado usar datos almacenados en caché. Cuando un usuario sale de su cuenta, nos aseguramos de borrar los datos de sesión almacenados en caché anteriormente.

    self.addEventListener('message', function(event) {
      if (event.data === 'clear-cached-user-data') {
        caches.open(toolbox.options.cacheName).then(function(cache) {
          cache.keys().then(function(requests) {
            return requests.filter(function(request) {
              return request.url.indexOf('api/v1/user/') !== -1;
            });
          }).then(function(userDataRequests) {
            userDataRequests.forEach(function(userDataRequest) {
              cache.delete(userDataRequest);
            });
          });
        });
      }
    });

¡Ten cuidado con los parámetros de consulta adicionales!

Cuando un trabajador de servicio busca una respuesta almacenada en caché, usa una URL de solicitud como clave. De forma predeterminada, la URL de solicitud debe coincidir exactamente con la URL que se usa para almacenar la respuesta almacenada en caché, incluidos los parámetros de consulta en la parte de búsqueda de la URL.

Esto nos causó un problema durante el desarrollo, cuando comenzamos a usar parámetros de URL para hacer un seguimiento de dónde provenía nuestro tráfico. Por ejemplo, agregamos el parámetro utm_source=notification a las URLs que se abrieron cuando se hizo clic en una de nuestras notificaciones y usamos utm_source=web_app_manifest en start_url para nuestro manifiesto de app web. Las URLs que antes coincidían con las respuestas almacenadas en caché aparecían como errores cuando se adjuntaban esos parámetros.

Esto se aborda parcialmente con la opción ignoreSearch, que se puede usar cuando se llama a Cache.match(). Lamentablemente, Chrome aún no es compatible con ignoreSearch y, aunque lo fuera, su comportamiento sería de todo o nada. Lo que necesitábamos era una manera de ignorar algunos parámetros de consulta de URL y, al mismo tiempo, tener en cuenta otros que eran significativos.

Terminamos extendiendo sw-precache para quitar algunos parámetros de consulta antes de verificar si hay una coincidencia en la caché y permitir que los desarrolladores personalicen qué parámetros se ignoran a través de la opción ignoreUrlParametersMatching. Esta es la implementación subyacente:

function stripIgnoredUrlParameters(originalUrl, ignoredRegexes) {
    var url = new URL(originalUrl);

    url.search = url.search.slice(1)
    .split('&')
    .map(function(kv) {
        return kv.split('=');
    })
    .filter(function(kv) {
        return ignoredRegexes.every(function(ignoredRegex) {
        return !ignoredRegex.test(kv[0]);
        });
    })
    .map(function(kv) {
        return kv.join('=');
    })
    .join('&');

    return url.toString();
}

Qué significa esto para ti

Es probable que la integración del trabajador de servicio en la app web de Google I/O sea el uso más complejo y real que se haya implementado hasta el momento. Esperamos que la comunidad de desarrolladores web use las herramientas que creamos sw-precache y sw-toolbox, así como las técnicas que describimos para potenciar sus propias aplicaciones web. Los service workers son una mejora progresiva que puedes comenzar a usar hoy mismo. Cuando se usan como parte de una app web estructurada correctamente, la velocidad y los beneficios sin conexión son significativos para los usuarios.