Service Worker en TypeScript

Service Worker en TypeScript

Este artículo explica los Service Workers en TypeScript.

Explicaremos los Service Workers en TypeScript, incluyendo ejemplos prácticos.

YouTube Video

Service Worker en TypeScript

Un Service Worker es un “proxy de solicitudes” que se ubica entre el navegador y la red. Permite la interceptación de solicitudes fetch, el control del caché, soporte offline y procesos en segundo plano (sincronización y push). Usar TypeScript ofrece seguridad de tipos y aumenta la mantenibilidad.

Configurando TypeScript

tsconfig.json (Habilitar los tipos de WebWorker)

Veamos un ejemplo de cómo habilitar el tipo WebWorker en tsconfig.json.

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["sw.ts"]
13}
  • Al agregar WebWorker al array lib, puedes usar tipos como ServiceWorkerGlobalScope.
  • DOM y WebWorker tienen diferentes tipos, por lo que es una práctica común separar las configuraciones de tsconfig.json para el navegador (aplicación principal) y el Service Worker.
  • Los archivos de Service Worker se generan finalmente en una ruta que coincide con el alcance (generalmente la raíz del sitio /sw.js).
  • Por razones de seguridad, los Service Workers solo se ejecutan sobre HTTPS (o en localhost).

Código de registro en el lado del navegador

register-sw.ts

1// register-sw.ts
2async function registerServiceWorker() {
3  if (!('serviceWorker' in navigator)) return;
4
5  try {
6    const registration = await navigator.serviceWorker.register(
7      '/sw.js', { scope: '/' }
8    );
  • Este proceso registra un Service Worker. scope se refiere al rango de rutas que el Service Worker puede controlar. Por ejemplo, si colocas /sw.js directamente en la raíz y estableces el scope en el directorio raíz (/), puedes controlar todos los recursos de todo el sitio. Por otro lado, si especificas un directorio concreto como /app/, solo el contenido bajo ese directorio será controlado.
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting indica el estado en el que un nuevo Service Worker ha sido instalado y está esperando ser activado. En esta etapa, las páginas existentes siguen estando controladas por el antiguo Service Worker, así que es común solicitar confirmación al usuario, y tras obtener su aprobación, llamar a skipWaiting() para activar inmediatamente el nuevo Service Worker. Esto te permite aplicar los procesos más recientes sin esperar a que la página se recargue.
 1    // When a new SW is installing, monitor its state changes
 2    registration.addEventListener('updatefound', () => {
 3      const newWorker = registration.installing;
 4      if (!newWorker) return;
 5      newWorker.addEventListener('statechange', () => {
 6        if (newWorker.state === 'installed' &&
 7            navigator.serviceWorker.controller) {
 8          // New content available, prompt the user
 9          promptUserToUpdate(registration);
10        }
11      });
12    });
  • updatefound se activa cuando ha comenzado la instalación de un nuevo Service Worker. Cuando ocurre este evento, un nuevo worker se establece en registration.installing, así que monitoreando su statechange, puedes detectar cuándo se ha completado la instalación (installed). Además, si existe navigator.serviceWorker.controller, significa que un Service Worker antiguo ya está controlando la página, por lo que es una oportunidad para notificar al usuario sobre la existencia de una nueva versión.
1    // When the active worker changes (e.g., after skipWaiting), reload if desired
2    navigator.serviceWorker.addEventListener('controllerchange', () => {
3      // Optionally reload to let the new SW take over
4      window.location.reload();
5    });
6  } catch (err) {
7    console.error('Service Worker registration failed: ', err);
8  }
9}
  • El evento controllerchange se dispara en el momento en que el nuevo Service Worker comienza a controlar la página actual. Recargar en este punto aplicará inmediatamente las nuevas estrategias de caché y procesamiento. Sin embargo, la recarga automática puede degradar la experiencia del usuario, así que es preferible recargar después de obtener el consentimiento del usuario.
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2  // Show UI to user. If user accepts:
3  if (reg.waiting) {
4    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5  }
6}
7
8registerServiceWorker();
  • Haciendo que el Service Worker reciba un postMessage({ type: 'SKIP_WAITING' }) desde el cliente y luego invoque self.skipWaiting(), puedes forzar una actualización.

Declaración del scope en sw.ts

A continuación, veamos un ejemplo típico de Service Worker que implementa el almacenamiento en caché del app shell.

Al usar Service Workers en TypeScript, es útil asignar el tipo correcto a self.

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • En TypeScript, self se trata como any por defecto, así que si no se especifican los tipos, no se obtiene autocompletado ni verificación de tipos para las APIs específicas del Service Worker como skipWaiting() o clients.
  • Especificar ServiceWorkerGlobalScope habilita el autocompletado, previene el mal uso y permite un desarrollo más seguro y separado de los scripts comunes del DOM.

Service Worker básico (Install/Activate/Fetch)

Demuestra la gestión simple de versiones de caché, el almacenamiento previo durante la instalación, la eliminación de cachés antiguos al activar y estrategias de caché en fetch (cache-first para archivos estáticos, network-first para APIs).

sw.ts (Configuración mínima + Esqueleto del caché)

 1const CACHE_NAME = 'app-shell-v1';
 2const STATIC_ASSETS = [
 3  '/',
 4  '/index.html',
 5  '/styles.css',
 6  '/main.js',
 7  '/fallback.png'
 8];
 9
10self.addEventListener('install', (event: ExtendableEvent) => {
11  // Pre-cache application shell
12  event.waitUntil(
13    caches.open(CACHE_NAME)
14      .then(cache => cache.addAll(STATIC_ASSETS))
15      // Activate immediately (optional: coordinate with client)
16      .then(() => self.skipWaiting())
17  );
18});
  • Durante el evento install, los recursos estáticos (App Shell) de la aplicación se almacenan previamente en la caché. Al llamar a self.skipWaiting(), el nuevo Service Worker se activa inmediatamente, poniendo la caché más reciente a disposición sin esperar al próximo acceso.
 1self.addEventListener('activate', (event: ExtendableEvent) => {
 2  // Clean up old caches and take control of clients immediately
 3  event.waitUntil(
 4    caches.keys().then(keys =>
 5      Promise.all(keys
 6        .filter(key => key !== CACHE_NAME)
 7        .map(key => caches.delete(key)))
 8    ).then(() => self.clients.claim())
 9  );
10});
  • En el evento activate, se eliminan las versiones antiguas de las cachés y se mantiene actualizado el Service Worker. Además, al llamar a self.clients.claim(), el nuevo Service Worker puede controlar todos los clientes sin esperar a que la página se recargue.
 1self.addEventListener('fetch', (event: FetchEvent) => {
 2  const request = event.request;
 3  const url = new URL(request.url);
 4
 5  // Navigation requests (SPA) -> network-first with fallback to cached index.html
 6  if (request.mode === 'navigate') {
 7    event.respondWith(
 8      fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
 9    );
10    return;
11  }
12
13  // Simple API routing: network-first for /api/
14  if (url.pathname.startsWith('/api/')) {
15    event.respondWith(networkFirst(request));
16    return;
17  }
18
19  // Static assets: cache-first
20  event.respondWith(cacheFirst(request));
21});
  • En fetch, puedes interceptar solicitudes y controlar la respuesta. Puedes implementar estrategias como cache-first o network-first, útiles para soporte offline y mejor rendimiento.
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2  const data = (event as any).data;
3  if (!data) return;
4
5  if (data.type === 'SKIP_WAITING') {
6    // Force the waiting service worker to become active
7    self.skipWaiting();
8  }
9});
  • Si se recibe SKIP_WAITING, al llamar a self.skipWaiting() puedes activar instantáneamente el Service Worker que está en espera. Como resultado, la nueva versión se aplicará desde la siguiente solicitud sin necesidad de recargar la página.

Resumen práctico de estrategias de caché

cache-first

Cache-first revisa primero el caché y, si está disponible, devuelve la respuesta inmediatamente. Si no, la solicita a la red y guarda el resultado en el caché. Esto es adecuado para archivos estáticos.

 1async function cacheFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  const cached = await cache.match(request);
 4  if (cached) {
 5    return cached;
 6  }
 7  const response = await fetch(request);
 8  if (response && response.ok) {
 9    cache.put(request, response.clone());
10  }
11  return response;
12}
  • Este código demuestra una implementación cache-first. Si existe en caché, lo devuelve; si no, lo solicita a la red y lo guarda en el caché. Es adecuado para recursos estáticos que rara vez cambian, como imágenes o CSS.

network-first

Network-first intenta primero con la red y recurre al caché si falla. Esto es adecuado para APIs donde la frescura es importante.

 1async function networkFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  try {
 4    const response = await fetch(request);
 5    if (response && response.ok) {
 6      cache.put(request, response.clone());
 7    }
 8    return response;
 9  } catch (err) {
10    const cached = await cache.match(request);
11    if (cached) return cached;
12    return new Response(JSON.stringify({ error: 'offline' }), {
13      status: 503,
14      headers: { 'Content-Type': 'application/json' }
15    });
16  }
17}
  • Este código demuestra una implementación network-first. Si se recibe una respuesta de la red, la guarda en el caché; si falla, retorna la versión en caché. Es adecuado para recursos que requieren datos actualizados, como artículos de noticias o respuestas de APIs.

stale-while-revalidate

stale-while-revalidate devuelve primero la caché y al mismo tiempo la actualiza desde la red en segundo plano. Esto equilibra la velocidad de respuesta y la frescura.

 1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
 2  const cache = await caches.open(cacheName);
 3  const cachedResponse = await cache.match(request);
 4  const networkFetch = fetch(request).then(networkResponse => {
 5    if (networkResponse && networkResponse.ok) {
 6      cache.put(request, networkResponse.clone());
 7    }
 8    return networkResponse;
 9  }).catch(() => undefined);
10
11  // Return cached immediately if exists, otherwise wait network
12  return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
  • Este código devuelve el caché inmediatamente si está disponible, mientras obtiene nuevos datos desde la red en segundo plano para actualizar el caché. Proporciona respuestas rápidas a los usuarios y usa contenido actualizado en el siguiente acceso, lo que lo hace adecuado para la UI o entrega de datos ligeros.

Optimizando el flujo de actualización (notificación de actualización y recarga segura)

Las actualizaciones del Service Worker no son inmediatas; la nueva versión permanecerá en espera hasta que se cierren las pestañas existentes.

Aquí implementamos un sistema para notificar al cliente cuando la nueva versión está lista y recargar la página de manera segura según la acción del usuario.

Notifica al cliente desde el lado del Service Worker cuando la nueva versión esté lista.

 1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
 2async function notifyClientsUpdated() {
 3  const all = await self.clients.matchAll({ type: 'window' });
 4  for (const client of all) {
 5    client.postMessage({ type: 'SW_UPDATED' });
 6  }
 7}
 8
 9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11  event.waitUntil((async () => {
12    if ('navigationPreload' in self.registration) {
13      await self.registration.navigationPreload.enable();
14    }
15    // cache cleanup
16    const cacheNames = await caches.keys();
17    await Promise.all(
18      cacheNames.map((name) => {
19        if (name !== CACHE_NAME) {
20          return caches.delete(name);
21        }
22      })
23    );
24
25    await self.clients.claim();
26    await notifyClientsUpdated();
27  })());
28});
  • En este código, notifyClientsUpdated se llama al final del evento activate para notificar a todos los clientes conectados que la nueva versión está lista. clients.claim() es un método que coloca inmediatamente las páginas abiertas (clientes) bajo el control del Service Worker recién activado. Normalmente, un Service Worker empieza a controlar la página solo en la siguiente carga, pero usando clients.claim(), puedes poner la página bajo control inmediatamente sin recargar.

Muestra una interfaz de actualización en el cliente y recarga con la acción del usuario

1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3  if (e.data?.type === 'SW_UPDATED') {
4    // Show a non-intrusive toast or banner: "New version available"
5    // When user clicks "Reload", call:
6    window.location.reload();
7  }
8});
  • El cliente recibe SW_UPDATED a través del evento message y muestra una notificación de actualización en la interfaz de usuario. Cuando el usuario elige recargar, se ejecuta window.location.reload(), actualizando el HTML, CSS y otros recursos antiguos de la página a la última versión. Esto asegura que la caché y el control por el Service Worker activado con clients.claim() se reflejen en toda la página.

Fallback offline

Prepara /offline.html para la navegación crítica y ofrece una interfaz mínima que transmita significado incluso sin imágenes ni fuentes. Si una llamada a la API falla, muestra el último estado en caché si es posible y reintenta obtener la información en segundo plano para mejorar la experiencia de usuario.

Ejemplo de implementación

 1// sw.ts
 2const CACHE_NAME = 'app-cache-v1';
 3
 4// Cache offline.html during install
 5self.addEventListener('install', (event) => {
 6  event.waitUntil((async () => {
 7    const cache = await caches.open(CACHE_NAME);
 8    await cache.addAll(['/offline.html']);
 9  })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14  const request = event.request;
15
16  // Navigation requests (e.g., page transitions)
17  if (request.mode === 'navigate') {
18    event.respondWith((async () => {
19      try {
20        // Try to fetch from the network as usual
21        return await fetch(request);
22      } catch (err) {
23        // On failure, return offline fallback page
24        const cache = await caches.open(CACHE_NAME);
25        return await cache.match('/offline.html') as Response;
26      }
27    })());
28  }
29});
  • Precacha /offline.html durante el evento install para poder mostrar al menos una página mínima cuando la red no esté disponible.
  • En el evento fetch, puedes monitorear las solicitudes de navegación con request.mode === 'navigate' y así enfocar específicamente las transiciones de página.
  • Utiliza /offline.html como fallback cuando la red falle, asegurando que se muestre incluso cuando se esté offline.

Mensajería entre el cliente y el Service Worker.

Debido a que el Service Worker opera de forma independiente del ciclo de vida de la página, la mensajería bidireccional es importante para notificar estados y ejecutar comandos. Especificar tipos para los mensajes ayuda a prevenir el envío incorrecto de mensajes, permite el autocompletado del código y hace más robusta tu implementación.

Ejemplo de código

  1. Definición de tipos de mensaje
1type SwToClient =
2  | { type: 'SW_READY' }
3  | { type: 'SW_UPDATED' }
4  | { type: 'CACHE_CLEARED' }
5  | { type: 'PING'; ts: number };
6
7type ClientToSw =
8  | { type: 'CLEAR_CACHE' }
9  | { type: 'PING'; ts: number };
  • SwToClient es el tipo de mensaje enviado del Service Worker al cliente.
  • ClientToSw es el tipo de mensaje enviado del cliente al Service Worker.
  • Esto te permite aclarar los tipos de eventos que pueden intercambiarse mediante comunicación bidireccional.
  1. Procesamiento en el Service Worker
 1self.addEventListener('message', (event) => {
 2  const data = event.data as ClientToSw;
 3  if (data?.type === 'CLEAR_CACHE') {
 4    event.waitUntil((async () => {
 5      const keys = await caches.keys();
 6      await Promise.all(keys.map((k) => caches.delete(k)));
 7      await broadcast({ type: 'CACHE_CLEARED' });
 8    })());
 9  } else if (data?.type === 'PING') {
10    event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11  }
12});
  • El Service Worker recibe mensajes del cliente y ramifica el procesamiento según el tipo.
  • Para CLEAR_CACHE, elimina el caché y luego notifica a todos los clientes con CACHE_CLEARED.
  • Para PING, responde al cliente original con un mensaje PING que incluye una marca de tiempo.
  1. Notificar a todos los clientes desde el Service Worker
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • Utiliza clients.matchAll para obtener todas las pestañas de ventana.
  • Enviando postMessage a cada una, puedes difundir mensajes.
  • Esto puede usarse para notificaciones de actualización (como SW_UPDATED) y notificaciones de error.
  1. Procesamiento en el lado del cliente
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • Al enviar un PING desde el cliente y recibir una respuesta del Service Worker, puedes verificar que la comunicación bidireccional funciona correctamente. Esto facilita probar los estados de conexión y la gestión de mensajes.
 1navigator.serviceWorker.addEventListener('message', (e) => {
 2  const msg = e.data as SwToClient;
 3  switch (msg.type) {
 4    case 'SW_READY':
 5      console.log('Service Worker is ready');
 6      // Example: hide loading spinner or enable offline UI
 7      break;
 8    case 'SW_UPDATED':
 9      console.log('A new version of the Service Worker is available');
10      // Example: show update notification or reload prompt
11      const shouldReload = confirm('A new version is available. Reload now?');
12      if (shouldReload) {
13        window.location.reload();
14      }
15      break;
16    case 'CACHE_CLEARED':
17      console.log('Cache cleared');
18      // Example: show confirmation message to user
19      alert('Cache has been successfully cleared.');
20      break;
21    case 'PING':
22      console.log(`Received PING response, ts=${msg.ts}`);
23      break;
24  }
25});
  • {^ i18n_speak クライアント側では Service Worker から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY は初期化完了、SW_UPDATED は新バージョン検出、CACHE_CLEARED はキャッシュ削除完了、PING は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}

Beneficios de la mensajería tipada

  • Utilizar mensajes tipados deja claros los mensajes que pueden enviarse y recibirse, y el autocompletado y la comprobación de tipos mejoran la seguridad.
  • postMessage permite comunicación uno a uno y broadcast permite comunicación uno a muchos.
  • Puedes implementar fácilmente características esenciales como notificaciones de actualización (SW_UPDATED), gestión de caché (CACHE_CLEARED) y comprobaciones de estado (PING).

Resumen

  • Usar TypeScript añade seguridad de tipos a las llamadas a la API del Service Worker y a la mensajería, mejorando mucho la eficiencia y la mantenibilidad del desarrollo.
  • Comprender los eventos del ciclo de vida install, activate y fetch, y elegir la estrategia de caché adecuada (como cache-first o network-first) para cada situación conduce a una mejor experiencia de usuario.
  • Para las operaciones, es esencial comprender la gestión de versiones de caché y los flujos de actualización (updatefound, waiting, SKIP_WAITING, etc.).
  • Adoptando mensajería tipada para la comunicación entre el cliente y el Service Worker, puedes prevenir implementaciones erróneas y establecer un sistema fácil de ampliar y mantener a largo plazo.

Puedes seguir el artículo anterior utilizando Visual Studio Code en nuestro canal de YouTube. Por favor, también revisa nuestro canal de YouTube.

YouTube Video