Service Workers: cache offline e background sync

1. Fundamentos do Service Worker no ecossistema JS/Node/React

O Service Worker é um script que o navegador executa em segundo plano, separado da página web, permitindo funcionalidades como cache offline e sincronização em segundo plano. No ecossistema React, o Service Worker atua como uma camada de rede programável entre o frontend e o servidor.

Ciclo de vida

O Service Worker possui três eventos principais:

// service-worker.js
self.addEventListener('install', (event) => {
  console.log('Service Worker instalado');
  self.skipWaiting(); // Ativa imediatamente
});

self.addEventListener('activate', (event) => {
  console.log('Service Worker ativado');
  clients.claim(); // Assume controle das páginas abertas
});

self.addEventListener('fetch', (event) => {
  console.log('Interceptando requisição:', event.request.url);
});

Registro no frontend React

// src/serviceWorkerRegistration.js
export function register() {
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker
        .register('/service-worker.js')
        .then((registration) => {
          console.log('SW registrado:', registration.scope);
        })
        .catch((error) => {
          console.error('Falha no registro:', error);
        });
    });
  }
}

2. Estratégias de Cache Offline para SPAs React

Existem três estratégias principais de cache para aplicações React:

Cache-first (para assets estáticos)

// service-worker.js
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/static/')) {
    event.respondWith(
      caches.match(event.request).then((cachedResponse) => {
        return cachedResponse || fetch(event.request);
      })
    );
  }
});

Network-first (para dados de API)

self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          return caches.open('api-cache').then((cache) => {
            cache.put(event.request, response.clone());
            return response;
          });
        })
        .catch(() => caches.match(event.request))
    );
  }
});

Stale-while-revalidate (para conteúdo dinâmico)

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      const fetchPromise = fetch(event.request).then((response) => {
        caches.open('dynamic-cache').then((cache) => {
          cache.put(event.request, response.clone());
        });
        return response;
      });
      return cachedResponse || fetchPromise;
    })
  );
});

3. Implementando Cache Offline com Workbox + React

O Workbox simplifica a implementação de Service Workers em aplicações React.

Configuração do Workbox webpack plugin

// craco.config.js ou webpack.config.js
const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  webpack: {
    plugins: {
      add: [
        new InjectManifest({
          swSrc: './src/service-worker.js',
          swDest: 'service-worker.js',
          maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
        }),
      ],
    },
  },
};

Service Worker com Workbox

// src/service-worker.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';

precacheAndRoute(self.__WB_MANIFEST);

// Cache de assets estáticos
registerRoute(
  ({ request }) => request.destination === 'script' || 
                    request.destination === 'style',
  new CacheFirst({ cacheName: 'static-assets' })
);

// Cache de requisições de API
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({ cacheName: 'api-responses' })
);

Fallback offline com UI React

// src/components/OfflineFallback.jsx
import React from 'react';

const OfflineFallback = () => {
  const [isOnline, setIsOnline] = React.useState(navigator.onLine);

  React.useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  if (!isOnline) {
    return (
      <div className="offline-banner">
        <p>Você está offline. Exibindo dados em cache.</p>
      </div>
    );
  }

  return null;
};

export default OfflineFallback;

4. Background Sync: Sincronizando Dados em Segundo Plano

O Background Sync permite enfileirar requisições quando o usuário está offline e reenviá-las automaticamente quando a conexão for restabelecida.

Registro de eventos sync no Service Worker

// service-worker.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-posts') {
    event.waitUntil(syncPendingPosts());
  }
});

async function syncPendingPosts() {
  const db = await openIndexedDB();
  const pendingPosts = await db.getAll('pending-posts');

  for (const post of pendingPosts) {
    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post.data)
      });

      if (response.ok) {
        await db.delete('pending-posts', post.id);
      }
    } catch (error) {
      console.error('Falha na sincronização:', error);
    }
  }
}

Enfileiramento de requisições no frontend

// src/hooks/useOfflineSync.js
export async function queuePostForSync(postData) {
  const db = await openIndexedDB();

  await db.add('pending-posts', {
    id: Date.now(),
    data: postData,
    timestamp: new Date().toISOString()
  });

  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-posts');
  }
}

5. Sincronização com Backend Node.js

API REST para receber dados sincronizados

// server/routes/sync.js
const express = require('express');
const router = express.Router();

router.post('/api/posts', async (req, res) => {
  const { title, content, clientTimestamp } = req.body;

  // Validação
  if (!title || !content) {
    return res.status(400).json({ error: 'Campos obrigatórios faltando' });
  }

  // Deduplicação baseada em timestamp
  const existingPost = await Post.findOne({ 
    clientTimestamp, 
    userId: req.user.id 
  });

  if (existingPost) {
    return res.status(200).json({ 
      message: 'Post já sincronizado',
      id: existingPost._id 
    });
  }

  const post = new Post({ title, content, clientTimestamp });
  await post.save();

  res.status(201).json({ id: post._id });
});

module.exports = router;

6. Gerenciamento de Estado e UI no React

Hook personalizado useOnlineStatus

// src/hooks/useOnlineStatus.js
import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [onlineStatus, setOnlineStatus] = useState({
    isOnline: navigator.onLine,
    pendingSync: 0
  });

  useEffect(() => {
    const checkPendingSync = async () => {
      if ('serviceWorker' in navigator) {
        const registration = await navigator.serviceWorker.ready;
        const tags = await registration.sync.getTags();
        setOnlineStatus(prev => ({
          ...prev,
          pendingSync: tags.length
        }));
      }
    };

    const handleOnline = () => {
      setOnlineStatus(prev => ({ ...prev, isOnline: true }));
      checkPendingSync();
    };

    const handleOffline = () => {
      setOnlineStatus(prev => ({ ...prev, isOnline: false }));
    };

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // Verificar sincronizações pendentes a cada 30 segundos
    const interval = setInterval(checkPendingSync, 30000);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
      clearInterval(interval);
    };
  }, []);

  return onlineStatus;
}

Indicador visual de fila de sincronização

// src/components/SyncIndicator.jsx
import React from 'react';
import { useOnlineStatus } from '../hooks/useOnlineStatus';

const SyncIndicator = () => {
  const { isOnline, pendingSync } = useOnlineStatus();

  if (isOnline && pendingSync === 0) return null;

  return (
    <div className={`sync-indicator ${isOnline ? 'online' : 'offline'}`}>
      {isOnline ? (
        <span>Sincronizando {pendingSync} itens...</span>
      ) : (
        <span>Offline - {pendingSync} itens pendentes</span>
      )}
    </div>
  );
};

export default SyncIndicator;

Atualização otimista com rollback

// src/hooks/useOptimisticUpdate.js
export function useOptimisticUpdate(apiEndpoint) {
  const [data, setData] = useState([]);
  const [pendingUpdates, setPendingUpdates] = useState([]);

  const addItem = async (newItem) => {
    // Atualização otimista
    const tempId = `temp-${Date.now()}`;
    setData(prev => [...prev, { ...newItem, id: tempId }]);

    try {
      const response = await fetch(apiEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newItem)
      });

      if (!response.ok) throw new Error('Falha na requisição');

      const savedItem = await response.json();
      // Substituir item temporário pelo real
      setData(prev => prev.map(item => 
        item.id === tempId ? savedItem : item
      ));
    } catch (error) {
      // Rollback
      setData(prev => prev.filter(item => item.id !== tempId));
      // Enfileirar para sincronização posterior
      await queuePostForSync(newItem);
    }
  };

  return { data, addItem, pendingUpdates };
}

7. Testes e Boas Práticas

Simulação de cenários offline com Cypress

// cypress/e2e/offline.cy.js
describe('Testes offline', () => {
  it('deve exibir conteúdo em cache quando offline', () => {
    cy.visit('/');
    cy.contains('Dados carregados').should('be.visible');

    // Simular modo offline
    cy.intercept('GET', '/api/data', {
      forceNetworkError: true
    }).as('offlineRequest');

    cy.reload();
    cy.contains('Exibindo dados em cache').should('be.visible');
  });

  it('deve enfileirar requisições quando offline', () => {
    cy.visit('/');
    cy.contains('Criar Post').click();

    // Desconectar
    cy.window().then((win) => {
      win.dispatchEvent(new Event('offline'));
    });

    cy.get('input[name="title"]').type('Post offline');
    cy.get('button[type="submit"]').click();

    cy.contains('1 itens pendentes').should('be.visible');
  });
});

Versionamento de cache

// service-worker.js
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

self.addEventListener('activate', (event) => {
  const validCaches = [STATIC_CACHE, API_CACHE];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (!validCaches.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Referências