Service Workers en producción

Captura de pantalla vertical

Resumen

Obtén información sobre cómo usamos las bibliotecas de service worker para que la app web de Google I/O 2015 sea rápida y funcione sin conexión.

Descripción general

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

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

Las bibliotecas se complementan bien entre sí y nos permitieron implementar una estrategia eficaz en la que el “shell” de contenido estático de IOWA siempre se entregaba directamente desde la caché, mientras que los recursos dinámicos o remotos se entregaban desde la red, con resguardos para respuestas estáticas o almacenadas en caché cuando fuera necesario.

Almacenamiento previo en caché con sw-precache

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

Integración en el tiempo de compilación

sw-precache con el proceso de compilación basado en gulp de IOWA. Además, nos basamos en 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 una lista de nombres de archivos en un array y recordar transmitir un número de versión de caché cada vez que alguno de esos cambios en los archivos eran muy propensos a errores, en especial si varios miembros del equipo estaban revisando el código. Nadie desea interrumpir el soporte sin conexión omitiendo un archivo nuevo en un array que se mantiene manualmente. La integración en el tiempo de compilación nos permitió realizar cambios en los archivos existentes y agregar archivos nuevos sin tener que preocuparnos por eso.

Actualiza recursos almacenados en caché

sw-precache genera una secuencia de comandos de service worker base que incluye un hash MD5 único para cada recurso que se almacena en caché previamente. Cada vez que se modifica un recurso existente o se agrega uno nuevo, se vuelve a generar la secuencia de comandos del service worker. Esto activa automáticamente el flujo de actualización del service worker, en el que los recursos nuevos se almacenan en caché y se borran definitivamente los recursos desactualizados. Cualquier recurso existente que tenga hashes MD5 idénticos se mantiene tal como está. Esto significa que los usuarios que visitaron el sitio antes solo descargan el conjunto mínimo de recursos modificados, lo que genera una experiencia mucho más eficiente que si venciera toda la caché en masa.

Cada archivo que coincide con uno de los patrones glob se descarga y se almacena en caché la primera vez que un usuario visita IOWA. Hicimos el esfuerzo de garantizar que solo se almacenaran previamente en caché los recursos críticos necesarios para renderizar la página. El contenido secundario, como el contenido multimedia usado en el experimento audiovisual o las imágenes de perfil de los oradores de las sesiones, no se prealmacenaron en caché deliberadamente. En su lugar, usamos la biblioteca sw-toolbox para manejar las solicitudes sin conexión de esos recursos.

sw-toolbox, para todas nuestras necesidades dinámicas

Como se mencionó, no es posible almacenar previamente en caché cada recurso 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 un servicio o API remotos. Sin embargo, el hecho de que una solicitud no se almacene previamente en caché no significa que tenga que generar una NetworkError. sw-toolbox nos dio la flexibilidad de implementar controladores de solicitudes que controlan el almacenamiento en caché del entorno de ejecución para algunos recursos y resguardos personalizados para otros. También lo usamos para actualizar los recursos previamente almacenados en caché en respuesta a las notificaciones push.

Estos son algunos ejemplos de controladores de solicitudes personalizados que compilamos sobre 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 del alcance del service worker.

Experimento audiovisual

Para el experimento audiovisual, 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 mostraba una respuesta correcta, esa respuesta se almacenaría temporalmente mediante la API de Cache Storage. Si se realiza una solicitud posterior cuando la red no está disponible, se usará la respuesta previamente almacenada en caché.

Debido a que la caché se actualizaba de forma automática cada vez que volvía una respuesta correcta de la red, no tuvimos que crear una versión específica de los recursos ni vencer las entradas.

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

Imágenes del perfil del orador

Para las imágenes de perfil de interlocutor, nuestro objetivo era mostrar una versión previamente almacenada en caché de la imagen de un interlocutor determinada, si estaba disponible, y recurrir a la red para recuperar la imagen si no lo estaba. Si esa solicitud de red falla, como resguardo final, usamos una imagen de marcador de posición genérica que se almacenó previamente en caché (y, por lo tanto, siempre estaría disponible). Esta es una estrategia común para usar cuando se trabaja con imágenes que se podrían reemplazar por un marcador de posición genérico, y fue fácil de implementar mediante la cadena de 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 desde una página de sesión
Imágenes de perfil de una página de sesión.

Actualizaciones de las programaciones de los usuarios

Una de las funciones clave de IOWA era permitir que los usuarios que hubieran accedido crearan y mantuvieran una agenda de sesiones a las que planeaban asistir. Como es de esperar, las actualizaciones de sesión se realizaron a través de solicitudes POST HTTP a un servidor de backend, y dedicamos un tiempo a encontrar la mejor manera de manejar esas solicitudes de modificación de estado cuando el usuario no tenía conexión. Se nos ocurrió una combinación de solicitudes fallidas en cola en IndexedDB, junto con la lógica en la página web principal que verificaba IndexedDB en busca de solicitudes en cola y que reintentó cualquiera que encontró.

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);

Debido a que los reintentos se realizaron desde el contexto de la página principal, pudimos asegurarnos de que incluyeron un conjunto nuevo de credenciales de usuario. Una vez que los reintentos se realizaron de forma correcta, mostramos un mensaje para informarle al usuario que se aplicaron las actualizaciones que antes se encontraban en cola.

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 fallidas de Google Analytics y, luego, intentar reproducirlas más tarde, cuando la red estaba 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 cola, establecido en la cantidad de tiempo que había transcurrido desde que se intentó realizar la solicitud por primera vez, para garantizar que se haya alcanzado el tiempo de atribución de evento adecuado en el backend de Google Analytics. Google Analytics admite oficialmente valores para qt de hasta 4 horas como máximo, por lo que hicimos todo lo posible para volver a reproducir esas solicitudes lo antes posible, cada vez que se iniciaba el service worker.

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 para notificaciones push

Los service workers no solo manejaban la funcionalidad sin conexión de IOWA, sino que también impulsaban las notificaciones push que usamos para notificar a los usuarios sobre las actualizaciones de sus sesiones agregadas a favoritos. La página de destino asociada con esas notificaciones mostraba los detalles de sesión actualizados. Esas páginas de destino ya se almacenaban en caché como parte del sitio general, por lo que ya funcionaban sin conexión. Sin embargo, necesitábamos asegurarnos de que los detalles de la sesión en esa página estuvieran actualizados, incluso cuando se vieron sin conexión. Para ello, modificamos los metadatos de la sesión que se almacenaron en caché antes 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 que se realice 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 y consideraciones

Por supuesto, nadie trabaja en un proyecto de la escala de IOWA sin tener que encontrar algunos errores. Estas son algunas de las que encontramos y cómo trabajamos en ellas.

Contenido inactivo

Cuando planificas una estrategia de almacenamiento en caché, ya sea que se implemente a través de service workers o con la caché estándar del navegador, existe una compensación entre entregar los recursos lo más rápido posible y entregar los recursos más recientes. Mediante sw-precache, implementamos una estrategia agresiva en la que se prioriza la caché para el shell de nuestra aplicación, lo que significa que nuestro service worker no comprobaría si hay actualizaciones en la red antes de mostrar el código HTML, JavaScript y CSS en la página.

Afortunadamente, pudimos aprovechar los eventos de 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 service worker actualizado, le mostramos un mensaje de aviso al usuario 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);
    }
    };
}
El aviso de contenido más reciente
El aviso del "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 un elemento se almacene en caché, permanecerá en la caché hasta que se le asigne un hash nuevo en una secuencia de comandos del service worker actualizada.

Tuvimos un problema con este comportamiento durante I/O debido a que nuestro backend necesita actualizar de forma dinámica los IDs de video de YouTube en vivo para cada día de la conferencia. Debido a que el archivo de plantilla subyacente era estático y no cambiaba, el flujo de actualización de nuestro service worker no se activó, y lo que debía ser una respuesta dinámica del servidor con la actualización de 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 manera que la shell siempre sea estática y se pueda almacenar previamente en caché, mientras que cualquier recurso dinámico que modifique la shell se cargue de forma independiente.

Usa el almacenamiento en caché para tus solicitudes de almacenamiento en caché

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

Para garantizar que las respuestas que almacenamos en caché sean directamente desde la red y no desde la caché HTTP del navegador, sw-precache agrega automáticamente un parámetro de consulta de prevención del almacenamiento en caché a cada URL que solicita. Si no usas sw-precache y utilizas 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 limpia para la prevención del almacenamiento en caché sería configurar el modo de almacenamiento en caché de cada Request que se usa para almacenar en caché previamente en reload, lo que garantizará que la respuesta provenga de la red. Sin embargo, al momento de la redacción de este documento, la opción de modo de almacenamiento en caché no es compatible con Chrome.

Asistencia para acceder y salir

IOWA permitió a los usuarios acceder con sus Cuentas de Google y actualizar sus programaciones de eventos personalizadas, pero eso también significaba que los usuarios podían salir más tarde. Almacenar en caché los datos de respuesta personalizados es un tema complicado y no siempre existe un único enfoque correcto.

Dado que ver tu agenda personal, incluso cuando estás sin conexión, era fundamental para la experiencia de IOWA, decidimos que el uso de datos almacenados en caché era apropiado. Cuando un usuario sale de su cuenta, nos aseguramos de borrar los datos de sesión que se almacenaron 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);
            });
          });
        });
      }
    });

¡Presta atención a los parámetros de consulta adicionales!

Cuando un service worker busca una respuesta almacenada en caché, utiliza una URL de solicitud como clave. Según la configuración 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 generó un problema para nosotros durante el desarrollo, cuando comenzamos a usar parámetros de URL para realizar un seguimiento de la procedencia del tráfico. Por ejemplo, agregamos el parámetro utm_source=notification a las URLs que se abrieron cuando se hacía clic en una de nuestras notificaciones y usamos utm_source=web_app_manifest en el start_url de nuestro manifiesto de la app web. Las URLs que antes coincidían con las respuestas almacenadas en caché aparecían como errores cuando se agregaban esos parámetros.

Esto se soluciona 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. Si así fue, se trata de un comportamiento de todo o nada. Lo que necesitábamos era una forma de ignorar algunos parámetros de consulta de URL y tener en cuenta otros que fueran significativos.

Terminamos extendiendo sw-precache para quitar algunos parámetros de consulta antes de buscar una coincidencia de caché y permitir que los desarrolladores personalicen qué parámetros se ignoran mediante 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 usted

Es probable que la integración de service worker en la app web de Google I/O sea el uso más complejo y real que se haya implementado hasta este punto. Esperamos con ansias la comunidad de desarrolladores web que usará las herramientas que creamos sw-precache y sw-toolbox, así como las técnicas que describimos para potenciar tus 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 correcta, la velocidad y los beneficios sin conexión son significativos para los usuarios.