Service Workers em produção

Captura de tela no modo retrato

Resumo

Saiba como usamos bibliotecas de service workers para tornar o app da Web da Google I/O 2015 rápido e com foco no modo off-line.

Visão geral

O app da Web do Google I/O 2015 deste ano foi escrito pela equipe de relações com desenvolvedores do Google com base em designs dos nossos amigos da Instrument, que criaram a experiência de áudio/visual. A missão da nossa equipe era garantir que o app da Web do I/O (que vamos chamar de IOWA) mostrasse tudo o que a Web moderna poderia fazer. Uma experiência totalmente off-line estava no topo da nossa lista de recursos essenciais.

Se você leu algum dos outros artigos deste site recentemente, provavelmente já encontrou service workers. Não se surpreenda se o suporte off-line do IOWA depender muito deles. Motivados pelas necessidades reais do IOWA, desenvolvemos duas bibliotecas para lidar com dois casos de uso off-line diferentes: sw-precache para automatizar o pré-cache de recursos estáticos e sw-toolbox para lidar com o armazenamento em cache de execução e estratégias de fallback.

As bibliotecas se complementam e nos permitiram implementar uma estratégia eficiente em que o "shell" de conteúdo estático do IOWA era sempre veiculado diretamente do cache, e os recursos dinâmicos ou remotos eram veiculados da rede, com respostas de fallback em cache ou estáticas quando necessário.

Pré-cache com sw-precache

Os recursos estáticos do IOWA (HTML, JavaScript, CSS e imagens) fornecem o shell básico para o aplicativo da Web. Havia dois requisitos específicos que foram importantes quando pensamos em armazenar esses recursos em cache: queríamos garantir que a maioria dos recursos estáticos fosse armazenada em cache e fosse mantida atualizada. O sw-precache foi criado pensando nesses requisitos.

Integração em tempo de build

sw-precache com o processo de build baseado em gulp da IOWA, e contamos com uma série de padrões glob para garantir a geração de uma lista completa de todos os recursos estáticos usados pelo IOWA.

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

Abordagens alternativas, como codificar uma lista de nomes de arquivos em uma matriz e lembrar de colocar um número de versão em cache cada vez que qualquer uma dessas alterações de arquivos for muito propensa a erros, especialmente porque tínhamos vários membros da equipe fazendo check-in do código. Ninguém quer interromper o suporte off-line deixando um novo arquivo em uma matriz mantida manualmente. A integração no build significa que podemos fazer alterações em arquivos existentes e adicionar novos arquivos sem preocupações.

Como atualizar recursos em cache

sw-precache gera um script de service worker base que inclui um hash MD5 exclusivo para cada recurso que é pré-armazenado em cache. Sempre que um recurso é alterado ou um novo recurso é adicionado, o script do service worker é regenerado. Isso aciona automaticamente o fluxo de atualização do service worker, em que os novos recursos são armazenados em cache e os desatualizados são limpos. Todos os recursos existentes com hashes MD5 idênticos são mantidos. Isso significa que os usuários que visitaram o site antes só vão fazer o download do conjunto mínimo de recursos alterados, o que leva a uma experiência muito mais eficiente do que se todo o cache estivesse expirado em massa.

Cada arquivo que corresponde a um dos padrões glob é transferido por download e armazenado em cache na primeira vez que um usuário acessa o IOWA. Fizemos um esforço para garantir que apenas os recursos essenciais necessários para renderizar a página fossem pré-armazenados em cache. O conteúdo secundário, como a mídia usada no experimento audio/visual, ou as imagens de perfil dos palestrantes das sessões, não foram pré-armazenados em cache de propósito. Em vez disso, usamos a biblioteca sw-toolbox para processar solicitações off-line para esses recursos.

sw-toolbox, para todas as nossas necessidades dinâmicas

Como mencionado, armazenar em cache todos os recursos necessários para um site funcionar off-line não é viável. Alguns recursos são muito grandes ou usados com pouca frequência para que isso seja vantajoso, e outros são dinâmicos, como as respostas de uma API remota ou serviço. No entanto, o fato de uma solicitação não estar pré-armazenada em cache não significa que ela precisa resultar em um NetworkError. O sw-toolbox nos deu a flexibilidade para implementar gerenciadores de solicitação que lidam com o armazenamento em cache de execução para alguns recursos e substitutos personalizados para outros. Também usamos essa abordagem para atualizar os recursos armazenados em cache em resposta a notificações push.

Confira alguns exemplos de manipuladores de solicitações personalizados que criamos com base no sw-toolbox. Foi fácil integrá-los ao script do service worker base por meio do importScripts parameter do sw-precache, que extrai arquivos JavaScript independentes para o escopo do service worker.

Experimento audiovisual

Para o experimento audiovisual, usamos a estratégia de cache networkFirst de sw-toolbox. Todas as solicitações HTTP que correspondem ao padrão de URL do experimento primeiro são feitas na rede. Se uma resposta bem-sucedida for enviada, ela será armazenada usando a API Cache Storage. Se uma solicitação subsequente for feita quando a rede estiver indisponível, a resposta armazenada em cache anteriormente será usada.

Como o cache era atualizado automaticamente sempre que uma resposta de rede sucesso retornava, não precisávamos especificar recursos de versão ou expirar entradas.

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

Imagens do perfil do palestrante

Para imagens de perfil de locutor, nosso objetivo era exibir uma versão armazenada em cache da imagem de um determinado locutor, se disponível, e usar a rede para recuperar a imagem, caso não estivesse. Se essa solicitação de rede falhasse, como alternativa final, usávamos uma imagem de marcador de posição genérica que era pré-armazenada em cache (e, portanto, sempre estaria disponível). Essa é uma estratégia comum para lidar com imagens que podem ser substituídas por um marcador de posição genérico. Ela foi fácil de implementar vinculando os manipuladores cacheFirst e 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/});
Imagens de perfil de uma página de sessão
Imagens de perfil de uma página de sessão.

Atualizações nas programações dos usuários

Um dos principais recursos do IOWA era permitir que os usuários conectados criassem e mantivessem uma programação de sessões que planejavam participar. Como seria de esperar, atualizações de sessão foram feitas por solicitações HTTP POST para um servidor de back-end, e passamos algum tempo buscando a melhor maneira de lidar com essas solicitações de modificação de estado quando o usuário estava off-line. Criamos uma combinação de uma que enfileirou solicitações com falha no IndexedDB, com a lógica na página da Web principal que verificava o IndexedDB em busca de solicitações enfileiradas e tentava novamente qualquer uma que encontrasse.

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

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

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

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

Como as novas tentativas foram feitas no contexto da página principal, podemos ter certeza de que elas incluíram um novo conjunto de credenciais do usuário. Depois que as tentativas foram bem-sucedidas, mostramos uma mensagem para informar ao usuário que as atualizações anteriores em fila foram aplicadas.

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 off-line

Da mesma forma, implementamos um gerenciador para enfileirar todas as solicitações do Google Analytics com falha e tentar executá-las novamente mais tarde, quando a rede estiver disponível. Com essa abordagem, estar off-line não significa sacrificar os insights que o Google Analytics oferece. Adicionamos o parâmetro qt a cada solicitação em fila, definido como o tempo decorrido desde a primeira tentativa da solicitação, para garantir que um tempo de atribuição de evento adequado fosse enviado ao back-end do Google Analytics. O Google Analytics oferece suporte oficial a valores de qt de até 4 horas. Por isso, fizemos o possível para reproduzir essas solicitações assim que possível, sempre que o worker de serviço fosse iniciado.

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 notificações push

Os service workers não lidaram apenas com a funcionalidade off-line do IOWA, eles também ativaram as notificações push que usamos para notificar os usuários sobre atualizações nas sessões adicionadas aos favoritos. A página de destino associada a essas notificações mostrava os detalhes da sessão atualizada. Essas páginas já estavam sendo armazenadas em cache como parte do site geral, então já funcionavam off-line, mas precisávamos garantir que os detalhes da sessão na página estivessem atualizados, mesmo quando visualizados off-line. Para fazer isso, modificamos os metadados da sessão armazenados em cache com as atualizações que acionaram a notificação push e armazenamos o resultado no cache. Essas informações atualizadas serão usadas na próxima vez que a página de detalhes da sessão for aberta, seja on-line ou off-line.

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 e considerações

É claro que ninguém trabalha em um projeto da escala da IOWA sem enfrentar algumas armadilhas. Confira alguns dos problemas que encontramos e como os resolvemos.

Conteúdo desatualizado

Sempre que você planeja uma estratégia de armazenamento em cache, seja implementada por workers de serviço ou pelo cache padrão do navegador, há uma troca entre entregar recursos o mais rápido possível e entregar os recursos mais recentes. Com sw-precache, implementamos uma estratégia agressiva de priorização de cache para o shell do nosso aplicativo, o que significa que nosso service worker não verificaria se há atualizações na rede antes de retornar HTML, JavaScript e CSS na página.

Felizmente, foi possível aproveitar os eventos de ciclo de vida do service worker para detectar quando o novo conteúdo ficava disponível depois que a página já havia sido carregada. Quando um service worker atualizado é detectado, exibimos uma mensagem de aviso ao usuário informando que ele precisa recarregar a página para ver o conteúdo mais recente.

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);
    }
    };
}
A mensagem pop-up de conteúdo mais recente
O aviso "conteúdo mais recente".

Verifique se o conteúdo estático é estático

O sw-precache usa um hash MD5 do conteúdo dos arquivos locais e só busca os recursos cujo hash mudou. Isso significa que os recursos ficam disponíveis na página quase imediatamente, mas também significa que, depois que algo é armazenado em cache, ele permanece em cache até que um novo hash seja atribuído a um script atualizado do service worker.

Encontramos um problema com esse comportamento durante a I/O porque nosso back-end precisava atualizar dinamicamente os IDs dos vídeos do YouTube da transmissão ao vivo para cada dia da conferência. Como o arquivo de modelo era estático e não mudou, o fluxo de atualização do worker de serviço não foi acionado, e o que deveria ser uma resposta dinâmica do servidor com a atualização de vídeos do YouTube acabou sendo a resposta em cache para vários usuários.

Para evitar esse tipo de problema, confira se o aplicativo da Web está estruturado de modo que o shell esteja sempre estático e possa ser pré-armazenado em cache com segurança, enquanto os recursos dinâmicos que modificam o shell são carregados de maneira independente.

Quebre o cache das suas solicitações de pré-cache

Quando sw-precache faz solicitações de recursos para pré-cache, ele usa essas respostas indefinidamente, desde que ele ache que o hash MD5 do arquivo não mudou. Isso significa que é particularmente importante garantir que a resposta à solicitação de pré-armazenamento em cache seja nova e não retornada do cache HTTP do navegador. Sim, as solicitações fetch() feitas em um worker de serviço podem responder com dados do cache HTTP do navegador.

Para garantir que as respostas que armazenamos em cache sejam diretamente da rede, e não do cache HTTP do navegador, o sw-precache automaticamente anexa um parâmetro de consulta de quebra de cache a cada URL solicitado. Se você não estiver usando sw-precache e estiver usando uma estratégia de resposta de cache-first, faça algo semelhante no seu próprio código.

Uma solução mais limpa para o impedimento de cache seria definir o modo de cache de cada Request usado no pré-armazenamento em cache como reload, o que garantirá que a resposta venha da rede. No entanto, no momento, a opção de modo de cache não é compatível com o Chrome.

Suporte para login e logout

A IOWA permitia que os usuários fizessem login usando as Contas do Google e atualizassem as programações de eventos personalizadas, mas isso também significava que os usuários podiam sair mais tarde. Armazenar em cache dados de respostas personalizadas é obviamente um assunto complicado, e não há sempre uma única abordagem correta.

Como a visualização da sua programação pessoal, mesmo off-line, era essencial para a experiência da IOWA, decidimos que o uso de dados em cache era adequado. Quando um usuário sai, nós limpamos os dados da sessão armazenados em cache 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);
            });
          });
        });
      }
    });

Cuidado com os parâmetros de consulta extras.

Quando um worker de serviço verifica uma resposta em cache, ele usa um URL de solicitação como chave. Por padrão, o URL da solicitação precisa corresponder exatamente ao URL usado para armazenar a resposta em cache, incluindo todos os parâmetros de consulta na parte search do URL.

Isso acabou causando um problema durante o desenvolvimento, quando começamos a usar parâmetros de URL para acompanhar de onde vinha nosso tráfego. Por exemplo, adicionamos o parâmetro utm_source=notification aos URLs que foram abertos ao clicar em uma das notificações e usamos utm_source=web_app_manifest no start_url para o manifesto do app da Web. Os URLs que correspondiam às respostas armazenadas em cache apareciam como falhas quando esses parâmetros eram anexados.

Isso é parcialmente abordado pela opção ignoreSearch, que pode ser usada ao chamar Cache.match(). Infelizmente, o Chrome ainda não tem suporte a ignoreSearch. Mesmo que tenha, é um comportamento de tudo ou nada. O que precisávamos era uma maneira de ignorar alguns parâmetros de consulta de URL, mas considerar outros que fossem importantes.

Ampliamos sw-precache para remover alguns parâmetros de consulta antes de verificar uma correspondência de cache e permitir que os desenvolvedores personalizem quais parâmetros são ignorados pela opção ignoreUrlParametersMatching. Confira a implementação:

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

O que isso significa para você

A integração do worker de serviço no app da Web do Google I/O é provavelmente o uso mais complexo e real que foi implantado até o momento. Estamos ansiosos para que a comunidade de desenvolvedores da Web use as ferramentas que criamos sw-precache e sw-toolbox, além das técnicas que estamos descrevendo para impulsionar seus próprios aplicativos da Web. Os service workers são um aprimoramento progressivo que você pode começar a usar hoje mesmo e, quando usados como parte de um app da Web estruturado corretamente, a velocidade e os benefícios off-line são significativos para os usuários.