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 creó la app web Google I/O 2015 de este año en función de los diseños de nuestros amigos de Instrument, que escribieron el excelente 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 podía hacer la Web moderna. 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 manejar dos casos de uso sin conexión diferentes: sw-precache para automatizar el almacenamiento previo en caché de recursos estáticos y sw-toolbox para controlar el almacenamiento en caché y las estrategias de resguardo en el entorno de ejecución.

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 previo en caché 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 del tiempo de compilación nos permitió hacer cambios en los archivos existentes y agregar archivos nuevos sin tener que preocuparte.

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 hashes MD5 idénticos se mantienen sin modificaciones. 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 toda la caché caducara 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 usado en el experimento audiovisual o las imágenes de perfil de los oradores de las sesiones, no se almacenaron previamente en caché de forma intencional y, 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 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 una 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 a la secuencia de comandos base del service worker a través de importScripts parameter de sw-precache, que extrae los archivos JavaScript independientes dentro 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 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é previamente de la imagen de un orador determinado si estaba disponible y, de lo contrario, recurrir a la red para recuperar la imagen. Si esa solicitud de red falló, como resguardo final, usamos una imagen genérica de marcador de posición que se almacenó en caché previamente (y, por lo tanto, siempre estaría disponible). Esta es una estrategia común a la hora de trabajar con imágenes que podrían reemplazarse por un marcador de posición genérico y la implementación era sencilla mediante el encadenamiento de 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 una fila 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);

Debido a que los reintentos se realizaron desde el contexto de la página principal, podemos estar seguros de que incluyeron un conjunto nuevo de credenciales de usuario. Una vez que los reintentos se realizaron correctamente, mostramos un mensaje para informarle al usuario que se aplicaron las actualizaciones que estaban 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 de Google Analytics que fallaron y tratar de volver a reproducirlas más tarde, cuando la red estuviera disponible. Con este enfoque, trabajar 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 service workers no solo se encargaban de la funcionalidad sin conexión de IOWA, sino que también potenciaban las notificaciones push que usamos para notificar a los usuarios sobre las actualizaciones de sus sesiones favoritas. 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 de esa página estuvieran actualizados, incluso cuando se visualizaran 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 y consideraciones

Por supuesto, nadie trabaja en un proyecto de la escala de IOWA sin encontrarse con algunos aspectos inesperados. Estos son algunos de los que nos encontramos y cómo los abordamos.

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. Mediante sw-precache, implementamos una estrategia agresiva que prioriza la caché para el shell de nuestra aplicación, lo que significa que nuestro service worker no verificaría la red en busca de actualizaciones antes de mostrar el código 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);
    }
    };
}
El aviso 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 algo se almacene en caché, permanecerá en caché hasta que se le asigne un hash nuevo en una secuencia de comandos actualizada del service worker.

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 las transmisiones 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 se almacenan en caché previamente sean directamente de la red y no de la caché HTTP del navegador, sw-precache agrega automáticamente un parámetro de consulta de invalidación de 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 datos de respuesta personalizados en caché es, por supuesto, un tema complicado, y no siempre hay un solo enfoque adecuado.

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 la 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 la solicitud debe coincidir exactamente con la URL que se usa para almacenar la respuesta almacenada en caché, incluido cualquier parámetro de consulta en la parte de búsqueda de la URL.

Esto nos generó un problema durante el desarrollo, cuando comenzamos a usar parámetros de URL para hacer un seguimiento del origen de 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

La integración del service worker en la app web de Google I/O es probablemente el uso real más complejo que se implementó hasta este punto. 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.