in

Implementación de un service worker para sitios de WordPress de aplicaciones de una sola página

A través de los service worker, todo el código de la aplicación y el marco para generar la vista HTML se pueden almacenar en caché en el navegador, lo que acelera tanto la primera impresión significativa como el tiempo para interactuar. En este artículo, compartiré mi experiencia con la implementación de service worker para PoP, un sitio web de SPA que se ejecuta en WordPress, con el objetivo de acelerar el tiempo de carga y proporcionar capacidades sin conexión primero.

 

Introduce un service worker. A través de los service worker, todo el código de la aplicación y el marco para generar la vista HTML se pueden almacenar en caché en el navegador, lo que acelera tanto la primera impresión significativa como el tiempo para interactuar. En este artículo, compartiré mi experiencia con la implementación de service worker para  PoP, un sitio web de SPA que se ejecuta en WordPress, con el objetivo de acelerar el tiempo de carga y proporcionar capacidades sin conexión primero.

La mayor parte del código de la explicación a continuación también se puede reutilizar para crear una solución para sitios web de WordPress tradicionales (es decir, que no son SPA). ¿Alguien quiere implementar un complemento?

Definición de las características de la aplicación  #

Además de ser adecuada para un sitio web de SPA WordPress, la implementación de los service worker a continuación se ha diseñado para admitir las siguientes características:

  • carga de activos de fuentes externas, como una red de entrega de contenido (CDN);
  • soporte multilingüe (i18n);
  • múltiples vistas;
  • selección en tiempo de ejecución de la estrategia de almacenamiento en caché, URL por URL.

En base a esto, tomaremos las siguientes decisiones de diseño.

Carga un Shell de aplicación (o Appshell) Primero  #

Si la arquitectura SPA lo admite, carga primero una aplicación (es decir, HTML, CSS y JavaScript mínimos para potenciar la interfaz de usuario), debajo https://www.mydomain.com/appshell/. Una vez cargada, la consola de aplicaciones solicitará dinámicamente contenido del servidor a través de una API. Debido a que todos los activos de la carcasa de la aplicación se pueden almacenar en caché utilizando service worker, el marco del sitio web se cargará de inmediato, lo que acelerará la primera impresión significativa. Este escenario utiliza la estrategia de almacenamiento en caché de » caché recurriendo a la red «.

¡Cuidado con los conflictos! Por ejemplo, WordPress genera código que se supone que no debe almacenarse en caché y usarse para siempre, como nonces, que generalmente caduca después de 24 horas. La aplicación, que los service worker almacenarán en caché en el navegador durante más de 24 horas, debes lidiar con los nonces correctamente.

Extrae los recursos de WordPress agregados a través de wp_enqueue_script y wp_enqueue_style  #

Debido a que un sitio web de WordPress carga sus recursos de JavaScript y CSS a través de los ganchos wp_enqueue_script y  wp_enqueue_style, respectivamente, podemos extraer estos recursos de manera conveniente y agregarlos a la lista de precaché.

Si bien seguir esta estrategia reduce el esfuerzo de producir la lista de recursos a precachear (algunos archivos aún deberán agregarse manualmente, como veremos más adelante), implica que el archivo service-workers.js debe generarse dinámicamente, en tiempo de ejecución. Esto se adapta muy bien a la decisión de permitir que los complementos de WordPress se conecten a la generación del archivo service-workers.js, como se explica en el siguiente elemento.

De hecho, yo diría que no hay otra manera que conectarse con estas funciones, porque generar la lista manualmente (es decir, encontrar y enumerar todos los recursos cargados por todos los complementos y el tema) es un proceso demasiado problemático y usar las herramientas para generar archivos JavaScript y CSS, como  Service Worker Precache, en realidad no funcionarán en este contexto, por dos razones principales:

  • Service Worker Precache funciona escaneando archivos en una carpeta específica y filtrándolos usando comodines. Sin embargo, los archivos con los que se envía WordPress son muchos más que los que realmente requiere la aplicación, por lo que es muy probable que estemos almacenando en caché muchos archivos redundantes.
  • WordPress adjunta un número de versión al archivo solicitado, que varía de un archivo a otro, como por ejemplo:
    • https://www.mydomain.com/wp-includes/js/utils.min.js?ver=4.6.1
    • https://www.mydomain.com/wp-includes/js/jquery/jquery-migrate.min.js?ver=1.4.1
    • https://www.mydomain.com/wp-includes/js/jquery/jquery.js?ver=1.12.4

Los service worker interceptan cada solicitud en función de la ruta completa del recurso solicitado, incluidos los parámetros, como el número de versión, en este caso. Debido a que la herramienta Service Worker Precache no conoce el número de versión, no podrás generar la lista requerida correctamente.

Permitir que los complementos se conecten a la generación de service-workers.js  #

Agregar ganchos a nuestra funcionalidad nos permite ampliar la funcionalidad de los service worker. Por ejemplo, los complementos de terceros pueden conectarse a la lista de recursos almacenados en caché para agregar tus propios recursos o especificar qué estrategia de almacenamiento en caché usar según su patrón de URL, entre otros.

Almacenamiento en caché de recursos externos: define una lista de dominios y valida los recursos para que se originen en el caché previo a partir de cualquiera de estos. #

Siempre que el recurso se origina en el dominio del sitio web, siempre se puede manejar con service worker. Siempre que no, se puede recuperar, pero debemos usar el modo de recuperación no-cors. Este tipo de solicitud dará como resultado una respuesta opaca, por lo que no podremos verificar si la solicitud fue exitosa; sin embargo, aún podemos almacenar estos recursos y permitir que el sitio web sea navegable sin conexión.

Soporte para múltiples vistas  #

Supongamos que la URL contiene un parámetro que indica qué vista usar para representar el sitio web, como por ejemplo:

  • Vista predeterminada:  https://www.mydomain.com/(?view=default)
  • Vista integrable: https://www.mydomain.com/?view=embed
  • Vista imprimible: https://www.mydomain.com/?view=print

Se pueden almacenar en caché varias aplicaciones, cada una de las cuales representa una vista:

  • https://www.mydomain.com/appshell/?view=default
  • https://www.mydomain.com/appshell/?view=embed
  • https://www.mydomain.com/appshell/?view=print

Luego, al cargar el sitio web, extraemos el valor del parámetro view de la URL y cargamos la aplicación correspondiente en tiempo de ejecución.

Soporte i18n  #

Suponemos que el código de idioma es parte de la URL, así: https://www.mydomain.com/language-code/path/to/the/page/

Un posible enfoque sería el diseño de la web para dar un diferente archivo service-workers.js para cada idioma, cada uno para ser empleados en su idioma correspondiente: un archivo service-worker-en.js para la posibilidad de Inglés en/, una service-worker-es.js para el es/alcance de la española, y así sucesivamente. Sin embargo, surgen conflictos al acceder a recursos compartidos, como los archivos JavaScript y CSS ubicados en la carpeta wp-content/. Estos recursos son los mismos para todos los idiomas; sus URL no contienen información sobre el idioma. Agregar otro archivo service-workers.js para tratar con todos los ámbitos que no son de lenguaje agregaría una complejidad no deseada.

Un enfoque más sencillo sería emplear la misma técnica anterior para representar múltiples vistas: registra un archivo service-workers.js único que ya contenga toda la información para todos los idiomas y decida en tiempo de ejecución qué idioma usar extrayendo el código de idioma de la URL solicitada.

Para admitir todas las funciones descritas hasta ahora para, digamos, tres vistas (una vista predeterminada, incrustada e impresa) y dos idiomas (inglés y español), necesitaríamos generar las siguientes aplicaciones:

  • https://www.mydomain.com/en/appshell/?view=default
  • https://www.mydomain.com/en/appshell/?view=embed
  • https://www.mydomain.com/en/appshell/?view=print
  • https://www.mydomain.com/es/appshell/?view=default
  • https://www.mydomain.com/es/appshell/?view=embed
  • https://www.mydomain.com/es/appshell/?view=print

Utilice diferentes estrategias de almacenamiento en caché  #

La aplicación debe poder elegir entre varias estrategias de almacenamiento en caché para admitir diferentes comportamientos en diferentes páginas. A continuación, se muestran algunos escenarios posibles:

  • Una página se puede recuperar de la caché la mayor parte del tiempo, pero en ocasiones específicas se debe recuperar de la red (por ejemplo, para ver una publicación después de editarla).
  • Una página puede tener un estado de usuario y, como tal, no se puede almacenar en caché (por ejemplo, «Editar mi cuenta», «Mis publicaciones»).
  • Se puede solicitar una página en segundo plano para traer datos adicionales (por ejemplo, comentarios con carga diferida en una publicación)

Estas son las estrategias de almacenamiento en caché y cuándo usar cada una:

  • Caché, recurriendo a la red
    • Activos estáticos (archivos JavaScript y CSS, imágenes, etc.)
      El contenido estático nunca se actualizará: los archivos JavaScript y CSS tienen el número de versión, y cargar la misma imagen por segunda vez en el administrador de medios de WordPress cambiará el nombre del archivo de la imagen. Como tal, los activos estáticos almacenados en caché no quedarán obsoletos.
    • Appshell
      Queremos que la appshell se cargue inmediatamente, así que la recuperamos de la caché. Si la aplicación se actualiza y la aplicación cambia, cambiar el número de versión instalará una nueva versión del service worker y descargará la última versión de la aplicación.
  • Caché, luego red
    • Contenido general
      Al obtener el contenido de la caché para mostrarlo de inmediato, también enviamos la solicitud para traer el contenido del servidor. Comparamos las dos versiones usando un  encabezado ETag, y si el contenido ha cambiado, almacenamos en caché la respuesta del servidor (es decir, la más actualizada de las dos) y luego mostramos un mensaje al usuario, «Esta página se ha actualizado, por favor haga clic aquí para actualizarlo «.
  • Solo red
    • Obligar a que el contenido esté actualizado
      El contenido que normalmente usaría la estrategia de «caché y luego red» puede ser forzado a usar una estrategia de «solo red» agregando artificialmente el parámetro sw-strategy=networkfirsto sw-networkfirst=true la URL solicitada. Este parámetro se puede eliminar antes de que la solicitud se envíe al servidor.
    • Contenido con estado de usuario
      No queremos almacenar en caché ningún contenido con estado de usuario, debido a la seguridad. (Podríamos eliminar la caché de estado del usuario cuando el usuario cierre la sesión, pero implementarlo es más complejo).
  • Red, recurriendo al caché
    • Contenido de carga diferida
      El contenido se carga de forma diferida cuando el usuario no lo vea de inmediato, lo que permite que el contenido que el usuario ve de inmediato se cargue más rápido. (Por ejemplo, una publicación se cargaría inmediatamente y sus comentarios se cargarían de forma diferida porque aparecen en la parte inferior de la página). Debido a que no se verá de inmediato, tampoco es necesario obtener este contenido directamente de la caché; en su lugar, intenta siempre obtener la versión más actualizada del servidor.

       

Ignora ciertos datos al generar el encabezado ETag para la estrategia «Caché y luego red»  #

Se puede generar una ETag mediante una función hash; un cambio muy pequeño en la entrada producirá una salida completamente diferente. Como tal, los valores que no se consideran importantes y que pueden volverse obsoletos no deben tenerse en cuenta al generar la ETag. De lo contrario, el usuario puede recibir el mensaje «Esta página ha sido actualizada, haga clic aquí para actualizarla» por cada pequeño cambio, como el contador de comentarios que va de 5 a 6.

Implementación  #

Todo el código a continuación, ligeramente adaptado para este artículo, se puede encontrar en el repositorio de GitHub. Las fuentes de los archivos sw-template.js y sw.php, que se mencionan a continuación, también están disponibles. Además, hay disponible un ejemplo de un  archivo service-workers.js generado.

Generando el archivo service-workers.js en tiempo de ejecución  #

Hemos decidido extraer automáticamente todos los archivos JavaScript y CSS que la aplicación utilizará, agregados a través de las funciones wp_enqueue_script y wp_enqueue_style, para exportar estos recursos a la lista de caché previo de Service Worker. Esto implica que el archivo service-workers.js se generará en tiempo de ejecución.

¿Cuándo y cómo se debe generar? Activar una acción para crear el archivo admin-ajax.php (por ejemplo, llamar https://www.mydomain.com/wp-admin/admin-ajax.php?action=create-sw) no funcionará, porque cargaría el área de administración de WordPress. En su lugar, necesitamos cargar todos los archivos JavaScript y CSS desde la interfaz, que sin duda será diferente.

Una solución es crear una página privada en el sitio web (oculta a la vista), a la que se accede a través https://www.mydomain.com/create-sw/, que ejecutará la funcionalidad para crear el archivo service-workers.js. La creación del archivo debe tener lugar al final de la ejecución de la solicitud, de modo que todos los archivos JavaScript y CSS se habrán puesto en cola para entonces:

function generate_sw_shortcode($atts) {

    add_action('wp_footer', 'generate_sw_files', PHP_INT_MAX);
}
add_shortcode('generate_sw', 'generate_sw_shortcode');

Archivo: sw.php

Ten en cuenta que esta solución funciona porque el sitio web es un SPA que carga TODOS los archivos por adelantado durante todo el ciclo de vida del uso de la aplicación (el infame archivo empaquetado); solicitar 2 URL diferentes de este sitio web siempre cargará el mismo conjunto de archivos .js y .css. Actualmente estoy desarrollando técnicas de división de código en el marco que, junto con HTTP/2, cargarán solo los recursos JS requeridos y nada más, página por página; debería estar listo en un par de semanas. Con suerte, entonces podré describir cómo Service Workers + SPA + división de código pueden funcionar todos juntos.

El archivo generado podría colocarse en la raíz del sitio web (es decir, al lado wp-config.php) para otorgar alcance. Sin embargo, no siempre es recomendable colocar archivos en la carpeta raíz del sitio web, como por ejemplo por seguridad (la carpeta raíz debe tener permisos de escritura muy restrictivos) y por mantenimiento (si service-workers.js fuera a ser generado por un plugin y este estuviera deshabilitado porque su se cambió el nombre de la carpeta service-workers.js, es posible que el archivo nunca se elimine).

Afortunadamente, existe otra posibilidad. Podemos colocar el archivo service-workers.js en cualquier directorio, como wp-content/sw/, y agregar un archivo .htaccess que otorgue acceso al alcance raíz:

function generate_sw_files() {

    $dir = WP_CONTENT_DIR."/sw/"

    // Create the directory structure
    if (!file_exists($dir)) {
        @mkdir($dir, 0755, true);
    }

    // Generate Service Worker .js file
    save_file($dir.'service-workers.js', get_sw_contents());

    // Generate the file to register the Service Worker
    save_file($dir.'sw-registrar.js', get_sw_registrar_contents());

    // Generate the .htaccess file to allow access to the scope (/)
    save_file($dir.'.htaccess', get_sw_htaccess_contents());
}

function get_sw_registrar_contents() {

    return '
        if ("serviceWorker" in navigator) {
          navigator.serviceWorker.register("/wp-content/sw/service-workers.js", {
            scope: "/"
          });
        }
    ';
}

function get_sw_htaccess_contents() {

    return '
        <FilesMatch "service-workers\.js$">
            Header set Service-Worker-Allowed: /
        </FilesMatch>
    ';
}

function save_file($file, $contents) {

    // Open the file, write content and close it
    $handle = fopen($file, "wb");
    $numbytes = fwrite($handle, $contents);
    fclose($handle);
    return $file;
}

Archivo: sw.php

La generación de estos archivos se puede incluir durante el proceso de implementación del sitio web, para automatizarlo y para que todos los archivos se creen justo antes de que la nueva versión del sitio web esté disponible para los usuarios.

Por razones de seguridad, podemos agregar alguna validación  function generate_sw_files() antes de que se ejecute:

  • para proporcionar una clave de acceso válida como parámetro.
  • para asegurarse de que solo se pueda solicitar desde el mismo servidor.
if ($_REQUEST['accesskey'] != $ACCESS_KEY) {
    die;
}
if (!in_array(getenv('HTTP_CLIENT_IP'), array('localhost', '127.0.0.1', '::1'))) {
    die;
}

Para solicitar https://www.mydomain.com/create-sw/ desde dentro del servidor, y haciéndolo desde un único servidor, ejecutaríamos:

wget -q https://www.mydomain.com/create-sw/?accesskey=…

Desde una matriz de servidores detrás de un equilibrador de carga, o desde una pila de servidores en la nube usando el escalado automático, no podemos ejecutar  wget URL, porque no sabemos qué servidor atenderá la solicitud. En cambio, podemos ejecutar directamente el proceso PHP, usando  php-cgi:

cd /var/www/html/
sudo SCRIPT_FILENAME=index.php SCRIPT_NAME=/index.php REMOTE_ADDR=127.0.0.1 REDIRECT_STATUS=200 SERVER_PROTOCOL=HTTP/1.1 REQUEST_METHOD=GET HTTPS=on SERVER_NAME=www.mydomain.com HTTP_HOST=www.mydomain.com SERVER_PORT=80 REQUEST_URI=/create-sw/ QUERY_STRING="accesskey=…" php-cgi > /dev/null

Si no te sientes cómodo con tener esta página en un servidor de producción, este proceso también podría ejecutarse en un entorno de ensayo, siempre que tenga exactamente la misma configuración que el servidor de producción (es decir, la base de datos debe tener los mismos datos; todas las constantes in wp-config.php debe tener los mismos valores; la URL para acceder al sitio web en el servidor de ensayo debe ser la misma que la del sitio web en sí, etc.). Luego, el archivo service-workers.js recién creado debe copiarse de la etapa de pruebas a los servidores de producción durante la implementación del sitio web.

Contenido de service-workers.js

Generar service-workers.js a partir de una función PHP implica que podemos proporcionar una plantilla de este archivo, que declarará qué variables necesita y la lógica del service worker. Luego, en tiempo de ejecución, las variables se reemplazarán con valores reales. También podemos agregar convenientemente ganchos para permitir que los complementos agreguen sus propios valores requeridos. (Se agregarán más variables de configuración más adelante en este artículo).

function get_sw_contents() {

    // $sw_template has the path to the service-worker template
    $sw_template = dirname(__FILE__).'/assets/sw-template.js';
    $contents = file_get_contents($sw_template);
    foreach (get_sw_configuration() as $key => $replacement) {
        $value = json_encode($replacement);
        $contents = str_replace($key, $value, $contents);
    }
    return $contents;
}

function get_sw_configuration() {

    $configuration = array();
    $configuration['$version'] = get_sw_version();
    …
    return $configuration;
}

Archivo: sw.php

La configuración de la plantilla de service worker se ve así:

var config = {
    version: $version,
    …
};

Archivo: sw-template.js

Tipos de recursos

La introducción de tipos de recursos, que es una forma de dividir los activos en grupos, nos permite implementar diferentes comportamientos en la lógica del service worker. Necesitaremos los siguientes tipos de recursos:

  • HTML
    Producido solo cuando se carga el sitio web por primera vez. Después de eso, todo el contenido se solicita dinámicamente utilizando la API de la aplicación, cuya respuesta está en formato JSON
  • JSON
    La API para obtener y publicar contenido
  • Estático
    Cualquier activo, como JavaScript, CSS, PDF, una imagen, etc.

Los tipos de recursos se pueden usar para almacenar recursos en caché, pero esto es opcional (solo hace que la lógica sea más manejable). Son necesarios para:

  • seleccionar la estrategia de almacenamiento en caché adecuada (para estática, primero en caché y para JSON, primero en la red);
  • definir rutas para no interceptar (cualquier cosa wp-content/por debajo para JSON pero no para estática, o cualquier cosa que termine en .php estática, para imágenes generadas dinámicamente, pero no para JSON).
function get_sw_resourcetypes() {

    return array('static', 'json', 'html');
}

Archivo: sw.php

function getResourceType(request) { var acceptHeader = request.headers.get(‘Accept’); var resourceType = ‘static’; if (acceptHeader.indexOf(‘text/html’) !== -1) { resourceType = ‘html’; } else if (acceptHeader.indexOf(‘application/json’) !== -1) { resourceType = ‘json’; } return resourceType; }

Archivo: sw-template.js

Interceptar solicitudes de service worker

Definiremos para qué patrones de URL no queremos que el service worker intercepte la solicitud. La lista de recursos para excluir está inicialmente vacía, solo contiene un gancho para inyectar todos los valores.

  • $excludedFullPaths
    Rutas completas para excluir.
  • $excludedPartialPaths
    Rutas para excluir, que aparecen después de la URL de inicio (por ejemplo, articles se excluirán https://www.mydomain.com/articles/pero no https://www.mydomain.com/posts/articles/). Las rutas parciales son útiles cuando la URL contiene información sobre el idioma (por ejemplo, https://www.mydomain.com/en/articles/), por lo que una sola ruta excluiría esa página para todos los idiomas (en este caso, la URL de inicio sería https://www.mydomain.com/en/). Más sobre esto más adelante.
function get_sw_configuration() {

    …
    $resourceTypes = get_sw_resourcetypes();
    $configuration['$excludedFullPaths'] = $configuration['$excludedPartialPaths'] = array();
    foreach ($resourceTypes as $resourceType) {

        $configuration['$excludedFullPaths'][$resourceType] = apply_filters('PoP_ServiceWorkers_Job_Fetch:exclude:full', array(), $resourceType);
        $configuration['$excludedPartialPaths'][$resourceType] = apply_filters('PoP_ServiceWorkers_Job_Fetch:exclude:partial', array(), $resourceType);
    }
    …
}

Archivo: sw.php

El valor  opts.locales.domain se calculará en tiempo de ejecución (más sobre esto más adelante).

var config = {
    …
    excludedPaths: {
        full: $excludedFullPaths,
        partial: $excludedPartialPaths
    },
    …
};

self.addEventListener('fetch', event => {

    function shouldHandleFetch(event, opts) {

        var request = event.request;
        var resourceType = getResourceType(request);
        var url = new URL(request.url);

        var fullExcluded = opts.excludedPaths.full[resourceType].some(path => request.url.startsWith(path)),

        var partialExcluded = opts.excludedPaths.partial[resourceType].some(path => request.url.startsWith(opts.locales.domain+path));

        if (fullExcluded || partialExcluded) return false;

        if (resourceType == 'static') {

            // Do not handla dynamic images, eg: the Captcha image, captcha.png.php
            var isDynamic = request.url.endsWith('.php') && request.url.indexOf('.php?') === -1;
            if (isDynamic) return false;
        }

        …
    }

    …
});

Archivo: sw-template.js

Ahora podemos definir los recursos de WordPress que se excluirán. Ten en cuenta que, debido a que depende del tipo de recurso, podemos definir una regla para interceptar cualquier URL que comience con wp-content/, que funciona solo para el tipo de recurso «estático».

class PoP_ServiceWorkers_Hooks_WPExclude {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_Fetch:exclude:full', array($this, 'get_excluded_fullpaths'), 10, 2);
    }

    function get_excluded_fullpaths($excluded, $resourceType) {

        if ($resourceType == 'json' || $resourceType == 'html') {

            // Do not intercept access to the WP Dashboard
            $excluded[] = admin_url();
            $excluded[] = content_url();
            $excluded[] = includes_url();
        }
        elseif ($resourceType == 'static') {

            // Do not cache the service-workers.js file!!!
            $excluded[] = WP_CONTENT_DIR.'/sw/service-workers.js';
        }

        return $excluded;
    }
}
new PoP_ServiceWorkers_Hooks_WPExclude();

Precaching de recursos

Para que el sitio web de WordPress funcione sin conexión, debemos recuperar la lista completa de recursos necesarios y almacenarlos en caché. Queremos poder almacenar en caché tanto recursos locales como externos (por ejemplo, desde una CDN).

  • $origins
    Definir desde qué dominios permitimos que el service worker intercepte la solicitud (por ejemplo, desde nuestro propio dominio más nuestra CDN).
  • $cacheItems
    Lista de recursos para precachear. Inicialmente es una matriz vacía, que proporciona un gancho para inyectar todos los valores.
var config = {
    …
    cacheItems: $cacheItems,
    origins: $origins,
    …
};

Archivo: sw-template.js

function get_sw_configuration() {

    …
    $resourceTypes = get_sw_resourcetypes();
    $configuration['$origins'] = get_sw_allowed_domains();
    $configuration['$cacheItems'] = array();
    foreach ($resourceTypes as $resourceType) {

        $configuration['$cacheItems'][$resourceType] = return apply_filters('PoP_ServiceWorkers_Job_CacheResources:precache', array(), $resourceType);
    }
    …
}

function get_sw_allowed_domains() {

    return array(
        get_site_url(), // 'https://www.mydomain.com',
        'https://cdn.mydomain.com'
    );
}

Archivo: sw.php

Para almacenar en caché los recursos externos, la ejecución cache.addAll no funcionará. En su lugar, necesitamos usar la función fetch, pasando el parámetro {mode: 'no-cors'} para estos.

self.addEventListener('install', event => {
  function onInstall(event, opts) {

    var resourceTypes = ['static', 'json', 'html'];
    return Promise.all(resourceTypes.map(function(resourceType) {
      return caches.open(cacheName(resourceType, opts)).then(function(cache) {
        return Promise.all(opts.cacheItems[resourceType].map(function(url) {
          return fetch(url, (new URL(url)).origin === self.location.origin ? {} : {mode: 'no-cors'}).then(function(response) {
            return cache.put(url, response.clone());
          });
        }))
      })
    }))
  }

  event.waitUntil(
    onInstall(event, config).then( () => self.skipWaiting() )
  );
});

Archivo: sw-template.js

Los recursos que se van a interceptar con el service worker deben provenir de cualquiera de nuestros orígenes o deben haberse definido en la lista de precacheo inicial (para que podamos precachear activos de otros dominios externos, como https://cdnjs.cloudflare.com):

self.addEventListener('fetch', event => {

    function shouldHandleFetch(event, opts) {

        …

        var fromMyOrigins = opts.origins.indexOf(url.origin) > -1;
        var precached = opts.cacheItems[resourceType].indexOf(url) > -1;

        if (!(fromMyOrigins || precached)) return false;

        …
    }

    …
});

Archivo: sw-template.js

Generando la lista de recursos para precachear

Los activos se cargan  wp_enqueue_script y  script_loader_tag se pueden extraer fácilmente. Encontrar otros activos implica un proceso manual, dependiendo de si provienen de archivos centrales de WordPress, del tema o de complementos instalados:

  • imágenes;
  • CSS y JavaScript no cargados a través de wp_enqueue_script y script_loader_tag;
  • Archivos JavaScript cargados condicionalmente (como los que se agregan entre etiquetas html);
  • Recursos solicitados en tiempo de ejecución (por ejemplo, archivos de tema, skin y plugin de TinyMCE);
  • referencias a archivos JavaScript codificados de forma rígida en otro archivo JavaScript;
  • Archivos de fuentes a los que se hace referencia en archivos CSS (TTF, WOFF, etc.);
  • archivos de configuración regional;
  • Archivos i18n.

Para recuperar todos los archivos JavaScript cargados a través de la función wp_enqueue_script, nos conectaríamos a script_loader_tag, y para todos los archivos CSS cargados a través de la función wp_enqueue_style, nos conectaríamos a style_loader_tag:

class PoP_ServiceWorkers_Hooks_WP {

    private $scripts, $styles, $dom;

    function __construct() {

        $this->scripts = $this->styles = array();
        $this->doc = new DOMDocument();

        add_filter('script_loader_tag', array($this, 'script_loader_tag'));
        add_filter('style_loader_tag', array($this, 'style_loader_tag'));

        …
    }

    function script_loader_tag($tag) {

        if (!empty($tag)) {

            $this->doc->loadHTML($tag);
            foreach($this->doc->getElementsByTagName('script') as $script) {
                if($script->hasAttribute('src')) {

                    $this->scripts[] = $script->getAttribute('src');
                }
            }
        }

        return $tag;
    }

    function style_loader_tag($tag) {

        if (!empty($tag)) {

            $this->doc->loadHTML($tag);
            foreach($this->doc->getElementsByTagName('link') as $link) {
                if($link->hasAttribute('href')) {

                    $this->styles[] = $link->getAttribute('href');
                }
            }
        }

        return $tag;
    }

    …
}
new PoP_ServiceWorkers_Hooks_WP();

Luego, simplemente agregamos todos estos recursos a la lista de precacheo:

class PoP_ServiceWorkers_Hooks_WP {

    function __construct() {

        …

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 10, 2);
    }

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'static') {

            $precache = array_merge(
                $precache,
                $this->scripts,
                $this->styles
            );
        }

        return $precache;
    }
}

WordPress cargará algunos archivos que deben agregarse manualmente. Ten en cuenta que la referencia al archivo debe agregarse exactamente como se solicitará, incluidos todos los parámetros. Entonces, este proceso implica mucho copiar y pegar desde el código original:

class PoP_ServiceWorkers_Hooks_WPManual {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 10, 2);
    }

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'static') {

            // File json2.min.js is not added through the $scripts list because it's 'lt IE 8'
            global $wp_scripts;
            $suffix = SCRIPT_DEBUG ? '' : '.min';
            $this->scripts[] = add_query_arg('ver', '2015-05-03', $wp_scripts->base_url."/wp-includes/js/json2$suffix.js");

            // Needed for the thickboxL10n['loadingAnimation'] javascript code produced in the front-end, loaded in wp-includes/script-loader.php
            $precache[] = includes_url('js/thickbox/loadingAnimation.gif');
        }

        return $precache;
    }
}
new PoP_ServiceWorkers_Hooks_WPManual();

TinyMCE presenta un desafío difícil para obtener su lista de recursos, porque los archivos que carga (como complementos, máscaras y archivos de temas) se crean y solicitan realmente en tiempo de ejecución. Además, la ruta completa del recurso no se imprime en el código HTML, sino que se ensambla en una función de JavaScript. Entonces, para obtener la lista de recursos, uno puede inspeccionar el código fuente de TinyMCE y verificar cómo genera los nombres de los archivos, o adivinarlos creando un editor de TinyMCE mientras inspecciona la pestaña «Red» de las herramientas de desarrollo de Chrome y ve qué archivos solicita. Al hacer esto último, pude deducir todos los nombres de archivo (por ejemplo, para los archivos de tema, la ruta es una combinación del dominio, el nombre del tema y el control de versiones como parámetros).

Para obtener la configuración del TinyMCE que se utilizará en tiempo de ejecución, que gancho en  store_tinymce_resources y  teeny_mce_before_init e inspeccionar los valores establecidos en la variable $mceInit:

class PoP_ServiceWorkers_Hooks_TinyMCE {

    private $content_css, $external_plugins, $plugins, $others;

    function __construct() {

        $this->content_css = $this->external_plugins = $this->plugins = $this->others = array();

        // Execute last one
        add_filter('teeny_mce_before_init', array($this, 'store_tinymce_resources'), PHP_INT_MAX, 1);
        add_filter('tiny_mce_before_init', array($this, 'store_tinymce_resources'), PHP_INT_MAX, 1);
    }

    function store_tinymce_resources($mceInit) {

        // Code copied from wp-includes/class-wp-editor.php function editor_js()
        $suffix = SCRIPT_DEBUG ? '' : '.min';
        $baseurl = includes_url( 'js/tinymce' );
        $cache_suffix = $mceInit['cache_suffix'];

        if ($content_css = $mceInit['content_css']) {
            foreach (explode(',', $content_css) as $content_css_item) {

                // The $cache_suffix is added in runtime, it can be safely added already. Eg: wp-includes/css/dashicons.min.css?ver=4.6.1&wp-mce-4401-20160726
                $this->content_css[] = $content_css_item.'&'.$cache_suffix;
            }
        }
        if ($external_plugins = $mceInit['external_plugins']) {

            if ($external_plugins = json_decode($external_plugins, true)) {
                foreach ($external_plugins as $plugin) {
                    $this->external_plugins[] = "{$plugin}?{$cache_suffix}";
                }
            }
        }
        if ($plugins = $mceInit['plugins']) {

            if ($plugins = explode(',', $plugins)) {

                // These URLs are generated on runtime in TinyMCE, without a $version
                foreach ($plugins as $plugin) {
                    $this->plugins[] = "{$baseurl}/plugins/{$plugin}/plugin{$suffix}.js?{$cache_suffix}";
                }

                if (in_array('wpembed', $plugins)) {

                    // Reference to file wp-embed.js, without any parameter, is hardcoded inside file wp-includes/js/tinymce/plugins/wpembed/plugin.min.js!!!
                    $this->others[] = includes_url( 'js' )."/wp-embed.js";
                }
            }
        }
        if ($skin = $mceInit['skin']) {

            // Must produce: wp-includes/js/tinymce/skins/lightgray/content.min.css?wp-mce-4401-20160726
            $this->others[] = "{$baseurl}/skins/{$skin}/content{$suffix}.css?{$cache_suffix}";
            $this->others[] = "{$baseurl}/skins/{$skin}/skin{$suffix}.css?{$cache_suffix}";

            // Must produce: wp-includes/js/tinymce/skins/lightgray/fonts/tinymce.woff
            $this->others[] = "{$baseurl}/skins/{$skin}/fonts/tinymce.woff";
        }
        if ($theme = $mceInit['theme']) {
            // Must produce: wp-includes/js/tinymce/themes/modern/theme.min.js?wp-mce-4401-20160726
            $this->others[] = "{$baseurl}/themes/{$theme}/theme{$suffix}.js?{$cache_suffix}";
        }

        // Files below are always requested. Code copied from wp-includes/class-wp-editor.php function editor_js()
        global $wp_version, $tinymce_version;
        $version = 'ver=' . $tinymce_version;
        $mce_suffix = false !== strpos( $wp_version, '-src' ) ? '' : '.min';

        $this->others[] = "{$baseurl}/tinymce{$mce_suffix}.js?$version";
        $this->others[] = "{$baseurl}/plugins/compat3x/plugin{$suffix}.js?$version";
        $this->others[] = "{$baseurl}/langs/wp-langs-en.js?$version";

        return $mceInit;
    }
}
new PoP_ServiceWorkers_Hooks_TinyMCE();

Finalmente, agregamos los recursos extraídos a la lista de precache:

class PoP_ServiceWorkers_Hooks_TinyMCE {

    function __construct() {

        …

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 1000, 2);
    }

    …

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'static') {

            // In addition, add all the files in the tinymce plugins folder, since these will be needed during runtime when initializing the tinymce textarea
            $precache = array_merge(
                $precache,
                $this->content_css,
                $this->external_plugins,
                $this->plugins,
                $this->others
            );
        }

        return $precache;
    }
}

También debemos almacenar en caché todas las imágenes requeridas por el tema y todos los complementos. En el siguiente código, guardamos previamente todos los archivos del tema en la carpeta img/, asumiendo que estos se solicitan sin agregar parámetros:

class PoPTheme_Wassup_ServiceWorkers_Hooks_ThemeImages {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 10, 2);
    }

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'static') {

            // Add all the images from the active theme
            $theme_dir = get_stylesheet_directory();
            $theme_uri = get_stylesheet_directory_uri();
            foreach (glob($theme_dir."/img/*") as $file) {
                $precache[] = str_replace($theme_dir, $theme_uri, $file);
            }
        }

        return $precache;
    }
}
new PoPTheme_Wassup_ServiceWorkers_Hooks_ThemeImages();

Si usamos Twitter Bootstrap, cargado desde un CDN (por ejemplo,  https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css), entonces debemos precachear los archivos de fuentes de los glifos correspondientes:

class PoPTheme_Wassup_ServiceWorkers_Hooks_Bootstrap {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 10, 2);
    }

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'static') {

            // Add all the fonts needed by Bootstrap inside the bootstrap.min.css file
            $precache[] = 'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot';
            $precache[] = 'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.svg';
            $precache[] = 'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.ttf';
            $precache[] = 'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff';
            $precache[] = 'https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.woff2';
        }

        return $precache;
    }
}
new PoPTheme_Wassup_ServiceWorkers_Hooks_Bootstrap();

Todos los recursos específicos del idioma para todos los idiomas también deben almacenarse en caché, de modo que el sitio web se pueda cargar en cualquier idioma cuando esté fuera de línea. En el código siguiente se asume que el plugin tiene una carpeta js/locales/ con los archivos de traducción locale-en.js, locale-es.js etc:

class PoP_UserAvatar_ServiceWorkers_Hooks_Locales {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 10, 2);
    }

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'static') {

            $dir = dirname(__FILE__));
            $url = plugins_url('', __FILE__));
            foreach (glob($dir."/js/locales/fileupload/*") as $file) {
                $precache[] = str_replace($dir, $url, $file);
            }
        }

        return $precache;
    }
}
new PoP_UserAvatar_ServiceWorkers_Hooks_Locales();

Estrategias sin almacenamiento en caché

A continuación, profundizaremos en las estrategias de almacenamiento en caché y sin almacenamiento en caché. Primero abordemos la estrategia sin almacenamiento en caché:

  • Solo red
    Siempre que las solicitudes JSON tengan un estado de usuario.

Para no almacenar en caché una solicitud, si sabemos qué URL no deben almacenarse en caché de antemano, simplemente podemos agregar sus rutas completas o parciales en la lista de elementos excluidos. Por ejemplo, a continuación, hemos configurado todas las páginas que tienen el estado de usuario (como «Mis publicaciones» y «Editar mi perfil») para que no sean interceptadas por el service worker, porque no queremos almacenar en caché ninguna información personal de el usuario:

function get_page_path($page_id) {

    $page_path = substr(get_permalink($page_id), strlen(home_url()));

    // Remove the first and last '/'
    if ($page_path[0] == '/') $page_path = substr($page_path, 1);
    if ($page_path[strlen($page_path)-1] == '/') $page_path = substr($page_path, 0, strlen($page_path)-1);

    return $page_path;
}

class PoP_ServiceWorkers_Hooks_UserState {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_Fetch:exclude:partial', array($this, 'get_excluded_partialpaths'), 10, 2);
    }

    function get_excluded_partialpaths($excluded, $resourceType) {

        if ($resourceType == 'json') {

            // Variable $USER_STATE_PAGES contains all IDs of pages that have a user state
            foreach ($USER_STATE_PAGES as $page_id) {

                $excluded[] = get_page_path($page_id);
            }
        }

        return $excluded;
    }
}
new PoP_ServiceWorkers_Hooks_UserState();

Si la estrategia sin almacenamiento en caché debe aplicarse en tiempo de ejecución, entonces podemos agregar un parámetro, sw-networkonly=true o sw-strategy=networkonly a la URL solicitada, y descartar su manejo con un service worker en la función shouldHandleFetch:

self.addEventListener('fetch', event => {

    function shouldHandleFetch(event, opts) {

        …

        var params = getParams(url);
        if (params['sw-strategy'] == 'networkonly') return false;

        …
    }

    …
});

Archivo: sw-template.js

Estrategias de almacenamiento en caché

La aplicación utiliza las siguientes estrategias de almacenamiento en caché, según el tipo de recurso y el uso funcional:

  • Caché,  recurriendo a la red para la shell de aplicaciones y los recursos estáticos.
  • Caché, luego red  para solicitudes JSON.
  • Red, recurriendo al caché  para solicitudes JSON que no hacen esperar al usuario, como datos cargados de forma diferida (por ejemplo, los comentarios de una publicación), o que deben estar actualizados (por ejemplo, ver una publicación después de que se haya actualizado).

Tanto los tipos de recursos estáticos como HTML siempre requerirán la misma estrategia. Solo el tipo de recurso JSON se puede cambiar entre estrategias. Establecemos la estrategia de «caché, luego red» como la estrategia predeterminada, y definimos reglas sobre la URL solicitada para cambiar a «red, recurriendo a caché»:

  • startsWith
    La URL comienza con una ruta completa o parcial predefinida.
  • hasParams
    La URL contiene un parámetro predefinido. El parámetro sw-networkfirst ya se ha definido, por lo que la solicitud https://www.mydomain.com/en/?output=json utilizará la estrategia «caché primero», mientras que https://www.mydomain.com/en/?output=json&sw-networkfirst=true cambiará a «red primero».
// Cache falling back to network
const SW_CACHEFIRST = 1;

// Cache then network
const SW_CACHEFIRSTTHENREFRESH = 2;

// Network falling back to cache
const SW_NETWORKFIRST = 3;

var config = {
    …
    strategies: $strategies,
    …
};

Archivo: sw-template.js

function get_sw_configuration() {

    …
    $resourceTypes = get_sw_resourcetypes();
    $configuration['$strategies'] = array();
    foreach ($resourceTypes as $resourceType) {

        $strategies = array();
        if ($resourceType == 'json') {

            $strategies['networkFirst'] = array(
                'startsWith' => array(
                    'full' => apply_filters('PoP_ServiceWorkers_Job_Fetch:strategies:json:networkFirst:startsWith:full', array()),
                    'partial' => apply_filters('PoP_ServiceWorkers_Job_Fetch:strategies:json:networkFirst:startsWith:partial', array()),
                ),
                'hasParams' => apply_filters('PoP_ServiceWorkers_Job_Fetch:strategies:json:networkFirst:hasParams', array('sw-networkfirst')),
            );
        }

        $configuration['$strategies'][$resourceType] = $strategies;
    }
    …
}

Archivo: sw.php

Enganchamos todas las páginas que necesitan utilizar la estrategia de «la red primero». A continuación, agregamos las páginas cargadas de forma diferida:

class PoP_ServiceWorkers_Hooks_LazyLoaded {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_Fetch:strategies:json:networkFirst:startsWith:partial', array($this, 'get_networkfirst_json_partialpaths'));
    }

    function get_networkfirst_json_partialpaths($paths) {

        foreach ($LAZY_LOADED_PAGES as $page_id) {

            $paths[] = get_page_path($page_id);
        }

        return $paths;
    }
}
new PoP_ServiceWorkers_Hooks_LazyLoaded();

Después de la fusión sw-template.js en service-workers.js, que se verá así:

var config = {
    …
    strategies: {
        json: {
            networkFirst: {
                startsWith: {
                    partial: […]
                },
                hasParams: […]
            }
        }
    },
    …
};

Finalmente, llegamos a la lógica del archivo service-workers.js. Tenga en cuenta que para obtener solicitudes JSON, también  necesitaremos agregar  al parámetro de eliminación de caché de URL sw-cachebust, con una marca de tiempo para evitar obtener la respuesta del caché HTTP del navegador.

function getCacheBustRequest(request, opts) {

    var url = new URL(request.url);

    // Put in a cache-busting parameter to ensure we’re caching a fresh response.
    if (url.search) {
      url.search += '&';
    }
    url.search += 'sw-cachebust=' + Date.now();

    return new Request(url.toString());
}
function addToCache(cacheKey, request, response) {

    if (response.ok) {
        var copy = response.clone();
        caches.open(cacheKey).then( cache => {
            cache.put(request, copy);
        });
    }
    return response;
}
self.addEventListener('fetch', event => {

    function getStrategy(request, opts) {

        var strategy = '';
        var resourceType = getResourceType(request);

        // JSON requests have two strategies: cache first + update (the default) or network first
        if (resourceType === 'json') {

            var networkFirst = opts.strategies[resourceType].networkFirst;
            var criteria = {
                startsWith: networkFirst.startsWith.full.some(path => request.url.startsWith(path)),
                // The pages do not included the locale domain, so add it before doing the comparison
                pageStartsWith: networkFirst.startsWith.partial.some(path => request.url.startsWith(opts.locales.domain+path)),
                // Code for function stripIgnoredUrlParameters is in https://github.com/leoloso/PoP/blob/master/wp-content/plugins/pop-serviceworkers/kernel/serviceworkers/assets/js/jobs/lib/utils.js
                hasParams: stripIgnoredUrlParameters(request.url, networkFirst.hasParams) != request.url
            }
            var successCriteria = Object.keys(criteria).filter(criteriaKey => criteria[criteriaKey]);
            if (successCriteria.length) {

                strategy = SW_NETWORKFIRST;
            }
            else {

                strategy = SW_CACHEFIRSTTHENREFRESH;
            }
        }
        else if (resourceType === 'html' || resourceType === 'static') {

            strategy = SW_CACHEFIRST;
        }

        return strategy;
    }

    function onFetch(event, opts) {

        var request = event.request;
        var resourceType = getResourceType(request);
        var cacheKey = cacheName(resourceType, opts);

        var strategy = getStrategy(request, opts);
        var cacheBustRequest = getCacheBustRequest(request, opts);

        if (strategy === SW_CACHEFIRST || strategy === SW_CACHEFIRSTTHENREFRESH) {

            /* Load immediately from the Cache */
            event.respondWith(
                fetchFromCache(request)
                    .catch(() => fetch(request))
                    .then(response => addToCache(cacheKey, request, response))
            );

            /* Bring fresh content from the server, and show a message to the user if the cached content is stale */
            if (strategy === SW_CACHEFIRSTTHENREFRESH) {
                event.waitUntil(
                    fetch(cacheBustRequest)
                        .then(response => addToCache(cacheKey, request, response))
                        .then(response => refresh(request, response))
                );
            }
        }
        else if (strategy === SW_NETWORKFIRST) {

            event.respondWith(
                fetch(cacheBustRequest)
                    .then(response => addToCache(cacheKey, request, response))
                    .catch(() => fetchFromCache(request))
                    .catch(function(err) {/*console.log(err)*/})
            );
        }
    }

    if (shouldHandleFetch(event, config)) {

        onFetch(event, config);
    }
});

Archivo: sw-template.js

La estrategia de «caché y luego red» usa la función refresh para almacenar en caché el contenido más actualizado proveniente del servidor, y si difiere del anterior, luego publica un mensaje en el navegador del cliente para notificar al usuario. No hace la comparación del contenido real, sino de sus encabezados ETag (la generación del encabezado ETag se explicará a continuación). El valor de ETag en caché se almacena usando  localForage, una API simple pero poderosa que envuelve IndexedDB:

function refresh(request, response) {

    var ETag = response.headers.get('ETag');
    if (!ETag) return null;

    var key = 'ETag-'+response.url;
    return localforage.getItem(key).then(function(previousETag) {

        // Compare the ETag of the response with the previous one saved in the IndexedDB
        if (ETag == previousETag) return null;

        // Save the new value
        return localforage.setItem(key, ETag).then(function() {

            // If there was no previous ETag, then send no notification to the user
            if (!previousETag) return null;

            // Send a message to the client
            return self.clients.matchAll().then(function (clients) {
                clients.forEach(function (client) {
                    var message = {
                        type: 'refresh',
                        url: response.url
                    };

                    client.postMessage(JSON.stringify(message));
                });
                return response;
            });
        });
    });
}

Archivo: sw-template.js

Una función de JavaScript captura los mensajes entregados por el service worker e imprime un mensaje solicitando al usuario que actualice la página:

function showRefreshMsgToUser() {

    if ('serviceWorker' in navigator) {

        navigator.serviceWorker.onmessage = function (event) {

            var message = JSON.parse(event.data);
            if (message.type === 'refresh') {

                var msg = 'This page has been updated, <a href="'+message.url+'">click here to refresh it</a>.';
                var alert = '<div class="alert alert-warning alert-dismissible" role="alert"><button type="button" class="close" aria-hidden="true" data-dismiss="alert">×</button>'+msg+'</div>';
                jQuery('body').prepend(alert);
            }
        };
    }
}

Generando el encabezado ETag  #

Un encabezado ETag es un hash que representa el contenido que se sirve; debido a que es un hash, un cambio mínimo en la fuente conducirá a la creación de un ETag completamente diferente. Debemos asegurarnos de que la ETag se genere a partir del contenido real del sitio web e ignorar la información que no es visible para el usuario, como los ID de etiquetas HTML. De lo contrario, considera la siguiente secuencia para la estrategia de «caché y luego red»:

  1. Se genera un ID, que se utiliza  now() para hacerlo único y se imprime en el HTML de la página.
  2. Cuando se accede por primera vez, se crea esta página y se genera su ETag.
  3. Cuando se accede por segunda vez, la página se sirve inmediatamente desde la caché del service worker y se activa una solicitud de red para actualizar el contenido.
  4. Esta solicitud genera la página nuevamente. Incluso si no se ha actualizado, su contenido será diferente, porque  now() producirá un valor diferente y su encabezado ETag será diferente.
  5. El navegador comparará las dos ETag y, debido a que son diferentes, solicitará al usuario que actualice el contenido, incluso si la página no se ha actualizado.

Una solución es eliminar todos los valores generados dinámicamente, como current_time('timestamp') y now(), antes de generar la ETag. Para esto, podemos establecer todos los valores dinámicos en constantes y luego usar estas constantes en toda la aplicación. Finalmente, los eliminaríamos de la entrada a la función de generación de hash:

define('TIMESTAMP', current_time('timestamp'));
define('NOW',  now());

ob_start();
// All the application code in between (using constants TIMESTAMP, NOW if needed)
$content = ob_get_clean();

$dynamic = array(TIMESTAMP, NOW);
$etag_content = str_replace($dynamic, '', $content);

header('ETag: '.wp_hash($etag_content));
echo($content);

Se necesita una estrategia similar para aquellas piezas de información que pueden volverse obsoletas, como el recuento de comentarios de una publicación, mencionado anteriormente. Debido a que este valor no es importante, no queremos que el usuario reciba una notificación para actualizar la página simplemente porque el número de comentarios ha aumentado de 5 a 6.

Appshell con soporte para múltiples modos de presentación multilingües  #

Independientemente de la URL solicitada por el usuario, la aplicación cargará la aplicación, que cargará inmediatamente el contenido de la URL solicitada (aún accesible en  window.location.href) a través de la API, transmitiendo la configuración regional y todos los parámetros necesarios.

La aplicación tiene diferentes vistas e idiomas, y queremos que estas diferentes aplicaciones se almacenen en caché y luego carguen la apropiada en tiempo de ejecución, obteniendo la información de la URL solicitada: https://www.mydomain.com/language-code/path/to/page/?view=….

Como se mencionó anteriormente, dados dos idiomas (inglés y español) y tres vistas (por defecto, incrustar e imprimir), necesitaremos almacenar previamente las siguientes aplicaciones:

  • https://www.mydomain.com/en/appshell/?view=default
  • https://www.mydomain.com/en/appshell/?view=embed
  • https://www.mydomain.com/en/appshell/?view=print
  • https://www.mydomain.com/es/appshell/?view=default
  • https://www.mydomain.com/es/appshell/?view=embed
  • https://www.mydomain.com/es/appshell/?view=print

Además del idioma y la vista, la aplicación puede tener otros parámetros (digamos «estilo» y «formato»). Sin embargo, agregarlos haría que las combinaciones de URL para precachear crezcan enormemente. Por lo tanto, debemos establecer una compensación, decidiendo qué parámetros precargar (los más utilizados) y cuáles no. Para los últimos, se puede acceder a su URL correspondiente sin conexión solo a partir del segundo acceso.

URL solicitadaAppshellPrecached
https://www.mydomain.com/en/https://www.mydomain.com/en/appshell/?view=default
https://www.mydomain.com/en/?view=printhttps://www.mydomain.com/en/appshell/?view=print
https://www.mydomain.com/en/?view=print&style=classichttps://www.mydomain.com/en/appshell/?view=print&style=classicNo

Al agregar ganchos a la configuración, permitimos que los complementos multilingües, como  qTranslate X, modifiquen las configuraciones regionales, los idiomas y las URL en consecuencia.

var config = {
    …
    appshell: {
        pages: $appshellPages,
        params: $appshellParams
    },
    …
};

Archivo: sw-template.js

function get_sw_configuration() {

    …

    $configuration['$appshellPages'] = get_sw_appshell_pages();
    $configuration['$appshellParams'] = apply_filters('PoP_ServiceWorkers_Job_Fetch:appshell_params', array("themestyle", "settingsformat", "mangled"));
    …
}

function get_sw_appshell_pages() {

    // Locales: can be hooked into by qTranslate to input the language codes
    $locales = apply_filters('PoP_ServiceWorkers_Job_Fetch:locales', array(get_locale()));
    $views = array("default", "embed", "print");

    $appshellPages = array();
    foreach ($locales as $locale) {
        foreach ($views as $view) {

            // By adding a hook to the URL, we can allow plugins to modify the URL
            $appshellPages[$locale][$view] = apply_filters('PoP_ServiceWorkers_Job_Fetch:appshell_url', add_query_arg('view', $view, get_permalink($APPSHELL_PAGE_ID), $locale);
        }
    }

    return apply_filters('PoP_ServiceWorkers_Job_Fetch:appshell_pages', $appshellPages);
}

Archivo: sw.php

class PoP_ServiceWorkers_QtransX_Job_Fetch_Hooks {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_Fetch:locales', array($this, 'get_locales'));
        add_filter('PoP_ServiceWorkers_Job_Fetch:appshell_url', array($this, 'get_appshell_url'), 10, 2);
        …
    }

    function get_locales($locales) {

        global $q_config;
        if ($languages = $q_config['enabled_languages']) {

            return $languages;
        }

        return $locales;
    }

    function get_appshell_url($url, $lang) {

        return qtranxf_convertURL($url, $lang);
    }
}
new PoP_ServiceWorkers_QtransX_Job_Fetch_Hooks();

Después de la fusión sw-template.js en service-workers.js, se verá así:

var config = {
    appshell: {
        pages: {
            es: {
                default: "https://www.mydomain.com/es/appshell/?view=default",
                embed: "https://www.mydomain.com/es/appshell/?view=embed",
                print: "https://www.mydomain.com/es/appshell/?view=print"
            },
            en :{
                default: "https://www.mydomain.com/en/appshell/?view=default",
                embed: "https://www.mydomain.com/en/appshell/?view=embed",
                print: "https://www.mydomain.com/en/appshell/?view=print"
            }
        },
        params: ["style", "format"]
    },
};

La solicitud se intercepta con el método onFetch y, si es del tipo de recurso HTML, se reemplazará por la URL de la aplicación, justo después de decidir qué estrategia se utilizará. (A continuación, veremos cómo obtener la configuración regional actual, configurada  opts.locales.current).

function onFetch(event, opts) {

    var request = event.request;
    …

    var strategy = getStrategy(request, opts);

    // Allow to modify the request, fetching content from a different URL
    request = getRequest(request, opts);

    …
}

function getRequest(request, opts) {

    var resourceType = getResourceType(request);
    if (resourceType === 'html') {

      // The different appshells are a combination of locale and view.
      var params = getParams(request.url);
      var view = params.view || 'default';

      // The initial appshell URL has the params that we have precached.
      var url = opts.appshell.pages[opts.locales.current][view];

      // In addition, there are other params that, if provided by the user, must be added to the URL. These params are not originally precached in any appshell URL, so such a page will have to be retrieved from the server.
      opts.appshell.params.forEach(function(param) {

        // If the param was passed in the URL, then add it along.
        if (params[param]) {
          url += '&'+param+'='+params[param];
        }
      });
      request = new Request(url);
    }

    return request;
}

Por último, procedemos a precachear las aplicaciones:

class PoP_ServiceWorkers_Hooks_AppShell {

    function __construct() {

        add_filter('PoP_ServiceWorkers_Job_CacheResources:precache', array($this, 'get_precache_list'), 10, 2);
    }

    function get_precache_list($precache, $resourceType) {

        if ($resourceType == 'html') {
            foreach (get_sw_appshell_pages() as $locale => $views) {
                foreach ($views as $view => $url) {

                    $precache[] = $url;
                }
            }
        }

        return $precache;
    }
}
new PoP_ServiceWorkers_Hooks_AppShell();

Obtener la configuración regional

Appshell tiene soporte multilingüe, por lo que necesitamos extraer la información del idioma de la URL solicitada.

var config = {
    …
    locales: {
        all: $localesByURL,
        default: $defaultLocale,
        current: null,
        domain: null
    },
    …
};

Archivo: sw-template.js

De forma predeterminada, simplemente configuramos la configuración regional  get_locale() y permitimos que los complementos enganchen sus valores en:

function get_sw_configuration() {

    …
    $configuration['$localesByURL'] = apply_filters('PoP_ServiceWorkers_Job_Fetch:locales_byurl', array(site_url() => get_locale()));
    $configuration['$defaultLocale'] = apply_filters('PoP_ServiceWorkers_Job_Fetch:default_locale', get_locale());
    …
}

Archivo: sw.php

Teniendo un complemento multilingüe habilitado, como qTranslate X, podemos enganchar en los idiomas:

class PoP_ServiceWorkers_QtransX_Job_Fetch_Hooks {

    function __construct() {

        …
        add_filter('PoP_ServiceWorkers_Job_Fetch:locales_byurl', array($this, 'get_locales_byurl'));
        add_filter('PoP_ServiceWorkers_Job_Fetch:default_locale', array($this, 'get_default_locale'));
    }

    function get_locales_byurl($locales) {

        global $q_config;
        if ($languages = $q_config['enabled_languages']) {

            $locales = array();
            $url = trailingslashit(home_url());
            foreach ($languages as $lang) {

                $locales[qtranxf_convertURL($url, $lang)] = $lang;
            }
        }

        return $locales;
    }

    function get_default_locale($default) {

        if ($lang = qtranxf_getLanguage()) {

            return $lang;
        }

        return $default;
    }
}

Después de la fusión sw-template.js en service-workers.js, se verá así:

var config = { locales: { all: { «https://www.mydomain.com/es/»:»en», «https://www.mydomain.com/en/»:»es» }, default: «en», current: null, domain: null }, };

Finalmente, obtenemos el código de idioma de la URL solicitada e inicializamos los valores de configuración regional current y local domain al comienzo del evento fetch:

self.addEventListener('fetch', event => {

  config = initOpts(config, event);
  if (shouldHandleFetch(event, config)) {

    …
  }

});

function initOpts(opts, event) {

    // Find the current locale and set it on the configuration object
    opts.locales.current = getLocale(event, opts);
    opts.locales.domain = getLocaleDomain(event, opts);
    return opts;
}

function getLocale(event, opts) {

    var currentDomain = getCurrentDomain(event, opts);
    if (currentDomain.length) {
        return opts.locales.all[currentDomain];
    }
    return opts.locales.default;
}

function getLocaleDomain(event, opts) {

    var currentDomain = getCurrentDomain(event, opts);
    if (currentDomain.length) {
        return currentDomain[0];
    }

    // Return the default domain
    return Object.keys(opts.locales.all).filter(function(key) {return opts.locales.all[key] === opts.locales.default})[0];
}

function getCurrentDomain(event, opts) {

    return Object.keys(opts.locales.all).filter(path => event.request.url.startsWith(path));
}

Tratar con Nonces  #

Un nonce (o un «número usado una vez») es un hash criptográfico que se usa para verificar la autenticidad de una persona o cliente. WordPress utiliza nonces como tokens de seguridad para proteger las URL y los formularios de ataques maliciosos. A pesar de su nombre, WordPress usa un nonce más de una vez, lo que le da una vida útil limitada, después de la cual caduca. Aunque no son la medida de seguridad definitiva, los nonces son un buen primer filtro para prevenir ataques de piratas informáticos.

El código HTML impreso en cualquier página de WordPress contendrá nonces, como el nonce para cargar imágenes al administrador de medios, guardado en el objeto JavaScript _wpPluploadSettings.defaults.multipart_params._wpnonce. La vida útil de un nonce está, de forma predeterminada, establecida en 24 horas (configurada en el enlace nonce_life). Sin embargo, este valor es más corto que la duración esperada de la aplicación en la caché del service worker. Esto es un problema: después de solo 24 horas, la carcasa de la aplicación contendrá nonces no válidos, lo que hará que la aplicación no funcione correctamente, como mostrar mensajes de error cuando el usuario intenta cargar imágenes.

Hay algunas soluciones para superar este problema:

  • Inmediatamente después de cargar la aplicación, cargue otra página en segundo plano, usando la estrategia «solo red», para actualizar el valor del nonce en el objeto JavaScript original:

        
        _wpPluploadSettings.defaults.multipart_params._wpnonce = "<?php echo $VALID_NONCE ?>";
        
      
  • Implementa un período nonce_life más largo, como tres meses, y luego asegúrate de implementar una nueva versión del service worker dentro de esta vida útil:

        add_filter('nonce_life', 'sw_nonce_life');
        function sw_nonce_life($nonce_life) {

            return 90*DAY_IN_SECONDS;
        }
Debido a que esta solución debilita la seguridad de los nonces, también se deben implementar medidas de seguridad más estrictas en toda la aplicación, como asegurarse de que el usuario pueda editar una publicación:
if (!current_user_can('edit_post', $post_id))
    wp_die( __( 'Sorry, you are not allowed to edit this item.'));

Consideraciones adicionales  #

El hecho de que se pueda hacer algo no significa que deba hacerse. El desarrollador debe evaluar si cada función debe agregarse de acuerdo con los requisitos de la aplicación, no solo porque la tecnología lo permite.

A continuación, mostraré algunos ejemplos de funcionalidades que se pueden implementar perfectamente con otras soluciones o que necesitan alguna solución para que funcionen con los service worker.

Mostrando un simple mensaje «Estás sin conexión»

Los service worker se pueden utilizar para mostrar una   página de respaldo sin conexión siempre que el usuario no tenga conexión a Internet. En su implementación más básica de simplemente mostrar un mensaje «Estás desconectado», creo que no estamos obteniendo el máximo valor de esta página de respaldo. Más bien, podría hacer cosas más interesantes:

  • Proporciona información adicional, como mostrar una lista de todos los recursos ya almacenados en caché, que el usuario aún puede navegar sin conexión (verifica cómo la demostración de Wikipedia sin conexión de Jake Archibald enumera todos los recursos ya almacenados en caché en su página de inicio).
  • Deja que el usuario juegue un juego mientras espera que vuelva la conexión (como lo hizo The Guardian).

Con un SPA, podemos ofrecer un enfoque diferente: podemos interceptar el estado fuera de línea y mostrar un pequeño mensaje «Estás fuera de línea» en la parte superior de la página que el usuario está navegando actualmente. Esto evita la redirección del usuario a otra página, lo que podría afectar el flujo de la aplicación.

HTML:

<div id="error-msg"></div>

CSS:

#error-msg {
    display: none;
    position: fixed;
    top: 0;
    left: 50%;
    width: 300px;
    margin-left: -150px;
    color: #8a6d3b;
    background-color: #fcf8e3;
    border-color: #faebcc;
    text-align: center;
}

JavaScript:

function intercept_click() {

    $(document).on('click', 'a[href^="'+WEBSITE_DOMAIN+'"]', function(e) {

        var anchor = $(this);
        var url = anchor.attr('href');

        $.ajax({
            url: url,
            error: function(jqXHR) {

                var msg = 'Oops, there was an error';
                if (jqXHR.status === 0) { // status = 0 => user is offline

                    msg = 'You are offline!';
                }
                else if (jqXHR.status === 404) {

                    msg = 'That page doesn\'t exist';
                }

                $('#error-msg').text(msg).css('display', 'block');
            }
        });
    });
}

Uso de localStorage para almacenar datos en caché

Los service worker no son la única solución que ofrecen los navegadores para almacenar en caché los datos de la respuesta. Una tecnología más antigua, con un soporte aún más amplio (Internet Explorer y Safari la admiten), es localStorage. Ofrece un buen rendimiento para almacenar en caché piezas de información de tamaño pequeño a mediano (normalmente puede almacenar en caché hasta 5 MB de datos).

/* Using Modernizr library */
function intercept_click() {

    $(document).on('click', 'a[href^="'+WEBSITE_DOMAIN+'"]', function(e) {

        var anchor = $(this);
        var url = anchor.attr('href');

        var stored = '';
        if (Modernizr.localstorage) {

            stored = localStorage[url];
        }
        if (stored) {

            // We already have the data!
            process(stored);
        }
        else {

            $.ajax({
                url: url,
                success: function(response){

                    // Save the data in the localStorage
                    if (Modernizr.localstorage) {
                        localStorage[url] = response;
                    }

                    process(response);
                }
            });
        }
    });
}

Haciendo las cosas más bonitas

Para obligar al service worker a emplear la estrategia de «red primero», podemos agregar un parámetro adicional  sw-networkfirst=true, a la URL solicitada. Sin embargo, agregar este parámetro en el enlace real se vería desagradable (los detalles de la implementación técnica deben ocultarse al usuario, tanto como sea posible).

En su lugar, data-sw-networkfirst se podría agregar un atributo de datos, en el ancla. Luego, en tiempo de ejecución, el clic del usuario sería interceptado para ser manejado por una llamada AJAX, verificando si el enlace en el que se hizo clic tiene este atributo de datos; si lo hace, solo entonces se agregará el parámetro sw-networkfirst=true a la URL para obtener:

function intercept_click() {

    $(document).on('click', 'a[href^="'+WEBSITE_DOMAIN+'"]', function(e) {

        var anchor = $(this);
        var url = anchor.attr('href');

        if (anchor.data('sw-networkfirst')) {

            url = add_query_arg('sw-networkfirst', 'true', url);
        }

        $.ajax({
            url: url,
            …
        });
    });
}

Planificación de cosas que no funcionan

No todo funcionará sin conexión. Por ejemplo, un CAPTCHA no funcionará porque necesita sincronizar su valor con el servidor. Si un formulario tiene un CAPTCHA, entonces intentar enviar el formulario sin conexión no debería guardar el valor localmente, para ser enviado una vez que se restablezca la conexión a Internet. En cambio, podría completar el formulario una vez más con todos los valores previamente llenados por el usuario, solicitar al usuario que ingrese el CAPTCHA y solo luego enviar el formulario.

Conclusión  #

Hemos visto cómo se pueden implementar los service worker para un sitio web de WordPress con una arquitectura SPA. Los SPA mejoran en gran medida a los service worker, ya que te permiten elegir entre diferentes aplicaciones para cargar durante el tiempo de ejecución. La integración con WordPress no es tan fácil, al menos para que el sitio web se desconecte primero, porque necesitamos encontrar todos los recursos del tema y todos los complementos para agregar a la lista de precacheo. Sin embargo, por muy larga que sea, vale la pena realizar la integración: el sitio web se cargará más rápido y funcionará sin conexión.

¿Qué opinas?

Escrito por Wombat

Deja una respuesta

Tu dirección de correo electrónico no será publicada.

Imágenes receptivas de WordPress con Art Direction

Cómo agregar cursos a WordPress con LearnPress