La lliçó anterior va deixar una debilitat assenyalada: l'Iván baixa al magatzem de serigrafia, on no arriba el wifi, i Nómada Tasques ni tan sols s'obre. El tauler està desat a localStorage, però tant és, perquè l'index.html, el CSS i els quinze mòduls ES viuen al servidor i sense xarxa el navegador no té res per carregar. Creuar aquella frontera exigeix una cosa que no havies vist: un script que s'executa fora de la pàgina, que continua viu quan la pestanya està tancada, i que se situa entre la teva aplicació i la xarxa per respondre les peticions des d'una memòria cau pròpia. Aquell script és el service worker, i és la peça que converteix un web en una aplicació web progressiva: instal·lable al dispositiu, capaç d'arrencar sense connexió i de comportar-se com una aplicació nativa. En aquesta lliçó entendràs el seu cicle de vida, dominaràs la Cache API i les seves estratègies, faràs que Nómada Tasques funcioni offline amb cua de canvis pendents, escriuràs el manifest, resoldràs el problema de les actualitzacions i aprendràs a depurar-lo sense tornar-te boig.

Contingut

  1. Què és una PWA
  2. Els tres requisits
  3. El service worker: un proxy que viu fora de la pàgina
  4. Registre i àmbit (scope)
  5. El cicle de vida: install, waiting, activate, fetch
  6. Per què no té DOM ni localStorage
  7. La Cache API
  8. Precarregar l'app shell a install
  9. Netejar versions antigues a activate
  10. Les estratègies de xarxa
  11. Interceptar amb fetch: l'encaminador del service worker
  12. Funcionar sense connexió: pàgina de reserva i cua de canvis
  13. El manifest i la instal·lació
  14. Actualitzar sense deixar ningú enrere
  15. Depurar a DevTools
  16. Notificacions push, breument
  17. Nómada Tasques: sw.js complet
  18. Errors Habituals i Consells
  19. Exercicis
  20. Conclusió

  1. Què és una PWA

Una aplicació web progressiva no és una tecnologia: és un conjunt de capacitats que, sumades, fan que un web es comporti com una aplicació instal·lada. Continua sent HTML, CSS i JavaScript servits per una URL.

Web normal PWA App nativa
Instal·lació No , des del navegador Botiga d'aplicacions
Icona a l'escriptori No
Funciona sense connexió No , si la programes
Finestra pròpia, sense barra del navegador No (display: standalone)
Notificacions push Limitat
Actualització Instantània, en recarregar Instantània (controlada per tu) Revisió de la botiga
Distribució Una URL Una URL Botiga, revisió, comissió
Accés al maquinari Limitat Limitat (millorant) Complet
Mida de descàrrega El que pesa el web El que pesa el web Desenes de MB

L'adjectiu progressiva és la clau i explica l'enfocament: l'aplicació funciona en qualsevol navegador, i en els que admeten més capacitats hi afegeix funcions. Ningú no es queda fora. És la mateixa millora progressiva que vas aplicar a 07-01 en degradar a un magatzem en memòria si localStorage fallava.

Per al Taller Nómada la proposta és concreta: la Marta posa Nómada Tasques a la pantalla de la sala com a aplicació instal·lada, i l'Iván l'obre al mòbil al magatzem sense cobertura, veu el tauler, marca una tasca com a feta, i aquell canvi s'envia sol quan torna a pujar.

  1. Els tres requisits

Requisit Què és Per què
HTTPS La pàgina s'ha de servir per connexió segura Un service worker intercepta totes les peticions; sobre HTTP, un atacant a la xarxa en podria injectar un de maliciós i permanent
Manifest Un manifest.json enllaçat des de l'HTML Dona nom, icones i mode de presentació a l'aplicació instal·lada
Service worker Un script registrat amb almenys un gestor fetch És el que permet funcionar sense connexió

Una excepció pràctica: http://localhost està permès per a desenvolupament. És el que fa possible provar tot això a la teva màquina sense certificats.

  1. El service worker: un proxy que viu fora de la pàgina

Aquesta és la idea central, i cal entendre-la abans d'escriure codi. Un service worker no és un script de la teva pàgina. És un worker independent que el navegador executa al seu propi fil, amb el seu propi context global, i que se situa entre totes les pàgines del teu origen i la xarxa.

flowchart LR
    subgraph Abans["Sense service worker"]
        P1["Pàgina"] -->|fetch| R1["Xarxa"]
    end

    subgraph Despres["Amb service worker"]
        P2["Pàgina"] -->|fetch| SW["Service Worker<br/>(fil propi)"]
        SW -->|"és a la memòria cau?"| C[("Cache API")]
        SW -->|"si no, o segons estratègia"| R2["Xarxa"]
        C -.->|"resposta"| SW
        R2 -.->|"resposta"| SW
        SW -.->|"resposta"| P2
    end

Cinc propietats que defineixen què és i què no:

  • Viu fora de la pàgina i sobreviu al tancament de la pestanya. El navegador l'arrenca quan el necessita i l'atura quan no.
  • Intercepta totes les peticions de les pàgines del seu àmbit: HTML, CSS, JS, imatges, crides a l'API. Totes passen pel seu esdeveniment fetch.
  • No té accés al DOM. No existeix document, ni window, ni els teus elements.
  • És asíncron del tot. Res d'APIs síncrones: per això no pot fer servir localStorage.
  • Es pot aturar en qualsevol moment. No desis estat en variables globals del worker: en tornar a arrencar, s'hauran perdut.

La conseqüència de la segona propietat és la que exigeix respecte: un service worker mal escrit pot trencar el teu lloc per a tots els visitants, i com que es desa al dispositiu, continua trencat encara que arreglis el servidor. És una eina potent i persistent. D'aquí l'exigència d'HTTPS.

  1. Registre i àmbit (scope)

El registre es fa des de la pàgina, amb un fetch implícit al fitxer del worker:

// js/app.js — al final, quan l'essencial ja funciona
if ('serviceWorker' in navigator) {                    // ← millora progressiva
  window.addEventListener('load', async () => {        // no competeixis amb la càrrega inicial
    try {
      const registre = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
      console.log('[nomada] Service worker registrat. Àmbit:', registre.scope);
    } catch (error) {
      console.error('[nomada] Fallada en registrar el service worker:', error);
    }
  });
}

L'àmbit decideix quines pàgines controla, i segueix una regla estricta: un service worker només pot controlar URLs que estiguin a la seva carpeta o per sota.

Ubicació del fitxer Àmbit per defecte Controla
/sw.js / Tot el lloc
/js/sw.js /js/ Només /js/… — gairebé mai el que vols
/app/sw.js /app/ Només /app/…

Per això sw.js va a l'arrel del lloc, no a js/ al costat dels altres mòduls. És l'excepció a l'organització de carpetes del projecte, i la causa número u de «l'he registrat i no intercepta res».

nomada-tasques/
  index.html
  manifest.json        ← nou
  sw.js                ← nou, A L'ARREL (àmbit '/')
  offline.html         ← nou, pàgina de reserva
  css/estils.css
  js/app.js
  js/…

Un servidor pot ampliar l'àmbit d'un worker que estigui en un subdirectori mitjançant la capçalera Service-Worker-Allowed, però és una complicació innecessària: posa el fitxer a l'arrel.

I un advertiment de manual: register() retorna una promesa que es resol quan el registre s'ha acceptat, no quan el worker està actiu i controlant la pàgina. La primera visita d'un usuari no està controlada pel service worker tret que ho forcis (apartat 14). Això confon moltíssim en provar.

  1. El cicle de vida: install, waiting, activate, fetch

El cicle de vida és el que més costa dels service workers, i entendre'l evita el 80 % dels problemes.

stateDiagram-v2
    [*] --> Descarregat: register() · el navegador descarrega sw.js
    Descarregat --> Installant: esdeveniment install
    Installant --> Installat: waitUntil() resolt (precàrrega llesta)
    Installant --> Fallit: waitUntil() rebutjat
    Installat --> Esperant: ja hi ha UN ALTRE sw controlant pàgines
    Installat --> Activant: no n'hi ha cap (primera vegada)
    Esperant --> Activant: es tanquen totes les pestanyes<br/>o skipWaiting()
    Activant --> Actiu: esdeveniment activate acabat
    Actiu --> Actiu: esdeveniment fetch (per cada petició)
    Fallit --> [*]
Fase Esdeveniment Què fer-hi
Instal·lació install Precarregar l'app shell a la memòria cau. S'executa una sola vegada per versió del worker
Espera El worker nou espera que el vell deixi de controlar pàgines
Activació activate Netejar memòries cau de versions antigues. Moment segur: ja no hi ha cap altre worker
Funcionament fetch Interceptar i respondre cada petició

La fase d'espera és la que desconcerta. Si hi ha un service worker actiu controlant pestanyes obertes, el nou es queda en waiting i no pren el control, encara que recarreguis amb F5. Només hi entra quan totes les pestanyes del lloc es tanquen. És una protecció deliberada: evita que a mitja sessió canviïn les regles sota els peus de l'usuari, amb una pàgina vella demanant recursos que la memòria cau nova ja ha esborrat.

El navegador decideix que un worker és «nou» comparant el fitxer byte a byte amb el que té desat. Un sol caràcter diferent —típicament el número de versió de la memòria cau— n'hi ha prou per desencadenar el cicle complet.

I una peça essencial: event.waitUntil(). Com que el navegador pot aturar el worker així que el gestor retorna, cal dir-li explícitament que esperi una promesa.

self.addEventListener('install', (esdeveniment) => {
  esdeveniment.waitUntil(                            // ← sense això, el worker pot morir a mitges
    caches.open('nomada-v1').then((cache) => cache.addAll(RECURSOS))
  );
});

Sense waitUntil, la instal·lació es donaria per acabada abans que la memòria cau s'omplís, i tindries un service worker «instal·lat» amb una memòria cau a mitges.

  1. Per què no té DOM ni localStorage

Dins de sw.js, l'objecte global no és window, és self (un ServiceWorkerGlobalScope). El que hi ha i el que no:

Disponible No disponible
fetch, caches, indexedDB document, window, DOM
postMessage, clients localStorage i sessionStorage
setTimeout, Promise, async/await alert, confirm, prompt
importScripts() i mòduls ES (amb type: 'module') Accés directe a la interfície

Les dues absències importants tenen motius diferents:

  • No hi ha DOM perquè el worker no pertany a cap pàgina: hi pot haver zero, una o cinc pestanyes obertes, o cap. Per parlar amb les pàgines es fa servir postMessage, i per canviar la interfície, la pàgina escolta i actua.
  • No hi ha localStorage perquè és síncron, i en un worker que atén peticions de xarxa això seria un desastre de rendiment. És el mateix advertiment de 07-01 portat a la seva conclusió: les alternatives són la Cache API (per a respostes HTTP) i IndexedDB (per a dades).

Això té una conseqüència pràctica per a Nómada Tasques: la còpia del tauler que vas desar a localStorage no és accessible des del service worker. Si vols que el worker gestioni una cua de canvis pendents, aquella cua ha de viure a IndexedDB.

Comunicació entre pàgina i worker, en tots dos sentits:

// Des de la pàgina, cap al worker
navigator.serviceWorker.controller?.postMessage({ tipus: 'netejar-cache-api' });

// Des de la pàgina, escoltant el worker
navigator.serviceWorker.addEventListener('message', (esdeveniment) => {
  if (esdeveniment.data.tipus === 'sincronitzat') {
    vista.avisar(`${esdeveniment.data.quants} canvis enviats al servidor.`);
  }
});
// Dins de sw.js
self.addEventListener('message', (esdeveniment) => {
  if (esdeveniment.data?.tipus === 'saltar-espera') self.skipWaiting();
});

/** Avisa TOTES les pestanyes controlades. */
async function avisarClients(missatge) {
  const clients = await self.clients.matchAll({ includeUncontrolled: true });
  for (const client of clients) client.postMessage(missatge);
}

  1. La Cache API

caches és un magatzem de parells petició/resposta HTTP. No desa dades com localStorage: desa objectes Response complets, amb les seves capçaleres i el seu estat.

// Obrir (o crear) una memòria cau amb nom
const cache = await caches.open('nomada-shell-v1');

// Desar: descarrega i emmagatzema
await cache.add('/css/estils.css');
await cache.addAll(['/index.html', '/js/app.js', '/css/estils.css']);

// Desar una resposta que ja tens
await cache.put('/api/tasques', resposta.clone());     // ← clone(), el cos es llegeix una vegada (07-02)

// Cercar
const desada = await cache.match('/css/estils.css');
const enQualsevol = await caches.match('/css/estils.css');   // cerca a TOTES les memòries cau

// Gestionar
await cache.delete('/js/vell.js');
const noms = await caches.keys();                      // ['nomada-shell-v1', 'nomada-dades-v1']
await caches.delete('nomada-shell-v0');
Mètode Sobre Què fa
caches.open(nom) caches Obre o crea una memòria cau amb nom
caches.match(peticio) caches Cerca a totes les memòries cau
caches.keys() caches Llista els noms
caches.delete(nom) caches Esborra una memòria cau sencera
cache.add(url) una memòria cau Descarrega i desa
cache.addAll([urls]) una memòria cau Igual, en lot. Si una falla, fallen totes
cache.put(peticio, resposta) una memòria cau Desa una resposta que ja tens
cache.match(peticio) una memòria cau Cerca en aquella memòria cau
cache.delete(peticio) una memòria cau Esborra una entrada

Quatre detalls que s'aprenen a base d'ensopegar:

  • addAll és atòmic. Si una sola URL dona 404, la promesa rebutja i no se'n desa cap. És útil (una precàrrega a mitges és pitjor que cap) i desconcertant: una ruta mal escrita trenca tota la instal·lació.
  • put no comprova l'estat. Desarà encantada una resposta 404 o 500. Comprova resposta.ok abans.
  • El cos es consumeix en llegir-lo, igual que a 07-02. Si vas a retornar la resposta i desar-la, clona.
  • La memòria cau és de l'origen, amb la mateixa frontera que localStorage, i comparteix quota amb IndexedDB. Guardar-hi vídeos l'omple de pressa.

  1. Precarregar l'app shell a install

L'app shell és el conjunt mínim de recursos que l'aplicació necessita per pintar la seva estructura: l'HTML, el CSS, els mòduls JavaScript, la tipografia, les icones. Les dades no formen part del shell; es demanen a part.

// sw.js
const VERSIO = 'v3';                                    // ← puja-la a cada desplegament
const CACHE_SHELL = `nomada-shell-${VERSIO}`;

const RECURSOS_SHELL = [
  '/',                          // important: l'arrel, a més de l'index.html
  '/index.html',
  '/offline.html',
  '/manifest.json',
  '/css/estils.css',
  '/js/app.js',
  '/js/model/tasca.js',
  '/js/model/tauler.js',
  '/js/model/errors.js',
  '/js/dades/backlog.js',
  '/js/dades/repositori-local.js',
  '/js/dades/api-tasques.js',
  '/js/dades/http.js',
  '/js/dades/temps-real.js',
  '/js/util/dates.js',
  '/js/util/format.js',
  '/js/util/temps.js',
  '/js/vista/dom.js',
  '/js/vista/targeta.js',
  '/js/vista/pintar.js',
  '/js/vista/tauler-vista.js',
  '/js/vista/esdeveniments.js',
  '/js/vista/controlador.js',
  '/js/vista/formulari.js',
  '/icones/icona-192.png',
  '/icones/icona-512.png'
];

self.addEventListener('install', (esdeveniment) => {
  console.log(`[sw] Instal·lant ${VERSIO}`);
  esdeveniment.waitUntil(
    caches.open(CACHE_SHELL)
      .then((cache) => cache.addAll(RECURSOS_SHELL))
      .then(() => console.log('[sw] App shell precarregat'))
  );
});

Aquí apareix el preu dels mòduls ES de 05-04: cada fitxer és una petició, i tots han de ser a la llista. Si te'n descuides un, l'aplicació arrencarà sense connexió fins a l'import que falta i allà morirà. Dos consells: mantén-la ordenada per carpetes per poder-la auditar d'un cop d'ull, i recorda que en un projecte amb empaquetador aquesta llista es genera automàticament (és un dels motius pels quals existeixen, i ho veuràs a 09-05).

Fixa't també que '/' i '/index.html' són dues entrades diferents per a la memòria cau, encara que el servidor retorni el mateix. Si només en guardes una, l'altra fallarà offline.

  1. Netejar versions antigues a activate

Cada versió crea la seva pròpia memòria cau. Sense neteja, el dispositiu acumularia nomada-shell-v1, v2, v3… fins a esgotar la quota. L'activate és el moment segur per esborrar: el worker vell ja no controla res.

self.addEventListener('activate', (esdeveniment) => {
  console.log(`[sw] Activant ${VERSIO}`);
  esdeveniment.waitUntil((async () => {
    const noms = await caches.keys();

    await Promise.all(
      noms
        .filter((nom) => nom.startsWith('nomada-') && !nom.endsWith(VERSIO))
        .map((nom) => {
          console.log('[sw] Esborrant memòria cau antiga:', nom);
          return caches.delete(nom);
        })
    );

    await self.clients.claim();      // pren el control de les pestanyes ja obertes
  })());
});

Dos punts:

  • Filtra pel prefix nomada-. L'origen pot tenir altres memòries cau (d'una altra aplicació del mateix domini, o d'una llibreria). Esborrar tot el que hi hagi seria l'equivalent del localStorage.clear() que desaconsellàvem a 07-01.
  • clients.claim() fa que el worker acabat d'activar prengui el control de les pestanyes que ja estaven obertes sense recarregar-les. Sense ell, continuarien sense controlador fins a la navegació següent.

  1. Les estratègies de xarxa

Aquí hi ha el disseny de debò. Interceptar peticions no serveix de res si no decideixes què fer-ne amb cadascuna, i no tots els recursos mereixen el mateix tracte.

Estratègia Com funciona Avantatge Inconvenient A Nómada Tasques
Cache first Mira la memòria cau; si no hi és, xarxa Instantani, funciona offline Pot servir contingut vell CSS, JS, tipografies, icones, imatges
Network first Prova la xarxa; si falla, memòria cau Sempre el més fresc Lent si la xarxa va malament L'API de tasques
Stale-while-revalidate Retorna la memòria cau ja i actualitza en segon pla Ràpid i es manté fresc La primera vegada mostra el vell Avatars, catàlegs, llistes d'etiquetes
Network only Sempre xarxa, sense memòria cau Mai dades obsoletes No funciona offline POST/PATCH/DELETE, WebSocket
Cache only Només memòria cau Predictible Falla si no està precarregat Recursos del shell versionats

Les tres primeres, en codi:

/** Cache first: per al que no canvia dins d'una mateixa versió. */
async function cacheFirst(peticio) {
  const desada = await caches.match(peticio);
  if (desada) return desada;

  const resposta = await fetch(peticio);
  if (resposta.ok) {
    const cache = await caches.open(CACHE_SHELL);
    cache.put(peticio, resposta.clone());         // clone: l'original es retorna
  }
  return resposta;
}

/** Network first: per a dades, amb la memòria cau com a xarxa de seguretat. */
async function networkFirst(peticio, nomCache = CACHE_DADES) {
  try {
    const resposta = await fetch(peticio);
    if (resposta.ok) {
      const cache = await caches.open(nomCache);
      cache.put(peticio, resposta.clone());
    }
    return resposta;
  } catch {
    const desada = await caches.match(peticio);
    if (desada) {
      // Marquem la resposta perquè la interfície pugui avisar que és antiga
      const capcaleres = new Headers(desada.headers);
      capcaleres.set('X-Des-De-Cache', 'true');
      return new Response(desada.body, { status: 200, headers: capcaleres });
    }
    throw new Error('Sense xarxa i sense còpia a la memòria cau');
  }
}

/** Stale-while-revalidate: el millor dels dos mons per a dades poc crítiques. */
async function staleWhileRevalidate(peticio, nomCache = CACHE_DADES) {
  const cache = await caches.open(nomCache);
  const desada = await cache.match(peticio);

  const actualitzant = fetch(peticio)
    .then((resposta) => {
      if (resposta.ok) cache.put(peticio, resposta.clone());
      return resposta;
    })
    .catch(() => null);                            // sense xarxa: no passa res, ja hem retornat la cau

  return desada ?? await actualitzant;             // ← retorna JA el que hi ha desat, si n'hi ha
}

Aquell X-Des-De-Cache mereix un comentari: és una capçalera inventada per nosaltres que permet a l'aplicació distingir una dada fresca d'una recuperada del passat. Sense ella, l'usuari veuria el tauler d'ahir creient que és el d'avui, i això és exactament el tipus de mentida que cal evitar.

  1. Interceptar amb fetch: l'encaminador del service worker

El gestor fetch rep totes les peticions. Aplicar una sola estratègia a totes seria un error; el que s'escriu és un encaminador.

self.addEventListener('fetch', (esdeveniment) => {
  const peticio = esdeveniment.request;
  const url = new URL(peticio.url);

  // 1 · Només GET: no interceptis mai escriptures
  if (peticio.method !== 'GET') return;                     // sense respondWith: va a la xarxa normal

  // 2 · Només el nostre origen (i el que decidim permetre)
  if (url.origin !== self.location.origin && !esApiPermesa(url)) return;

  // 3 · Navegacions (l'usuari obre o recarrega la pàgina)
  if (peticio.mode === 'navigate') {
    esdeveniment.respondWith(gestionarNavegacio(peticio));
    return;
  }

  // 4 · Crides a l'API: network first
  if (url.pathname.startsWith('/api/') || esApiPermesa(url)) {
    esdeveniment.respondWith(networkFirst(peticio));
    return;
  }

  // 5 · Tota la resta (CSS, JS, imatges): cache first
  esdeveniment.respondWith(cacheFirst(peticio));
});

Tres regles d'or del gestor fetch:

  • esdeveniment.respondWith() s'ha de cridar de manera síncrona. No pots fer un await abans de decidir si respons; primer crides respondWith amb una promesa, i aquella promesa fa la feina. Si el gestor acaba sense cridar-lo, la petició segueix el seu curs normal, que és exactament el que vols per al que no gestiones.
  • No interceptis mai peticions que no siguin GET. Un POST a la memòria cau o duplicat crea dades fantasma. I a 07-03 ja vas aprendre com de car surt repetir un POST.
  • Retorna sempre alguna cosa. Si la promesa de respondWith rebutja, el navegador mostra un error de xarxa genèric. Val més respondre amb una pàgina o un JSON de reserva.

  1. Funcionar sense connexió: pàgina de reserva i cua de canvis

Amb l'anterior, l'aplicació arrenca sense connexió. Falta tancar dos buits.

Pàgina de reserva per a les navegacions a rutes que no són a la memòria cau:

async function gestionarNavegacio(peticio) {
  try {
    return await fetch(peticio);                     // network first: sempre l'HTML més fresc
  } catch {
    const desada = await caches.match('/index.html');
    return desada ?? await caches.match('/offline.html');
  }
}
<!-- offline.html — autònom: no pot dependre de res que no estigui a la memòria cau -->
<main class="offline">
  <h1>Sense connexió</h1>
  <p>Nómada Tasques no pot contactar amb el servidor ara mateix.</p>
  <p>Els teus canvis s'estan desant al dispositiu i s'enviaran així que torni la connexió.</p>
  <button type="button" onclick="location.reload()">Reintentar</button>
</main>

Detectar l'estat de la connexió des de la pàgina:

// js/app.js
function actualitzarConnectivitat() {
  const enLinia = navigator.onLine;
  $('#estat-xarxa').textContent = enLinia ? '' : 'Sense connexió — treballant en local';
  $('#estat-xarxa').hidden = enLinia;
  document.body.classList.toggle('sense-connexio', !enLinia);
}

window.addEventListener('online',  () => { actualitzarConnectivitat(); sincronitzarPendents(); });
window.addEventListener('offline', actualitzarConnectivitat);
actualitzarConnectivitat();

Un advertiment important sobre navigator.onLine: només diu si hi ha una interfície de xarxa activa, no si hi ha Internet de debò. Un wifi connectat a un router sense sortida dona true. És una pista útil, mai una garantia; l'única prova real és intentar la petició, amb la gestió d'errors de 07-03.

Cua de canvis pendents. Com que el service worker no pot fer servir localStorage, la cua viu a IndexedDB, que sí que comparteix amb la pàgina:

// js/dades/cua-pendents.js (a la PÀGINA, no al worker)
const BD = 'nomada-pendents';
const MAGATZEM = 'canvis';

function obrir() {
  return new Promise((resoldre, rebutjar) => {
    const sollicitud = indexedDB.open(BD, 1);
    sollicitud.onupgradeneeded = () => {
      sollicitud.result.createObjectStore(MAGATZEM, { keyPath: 'id', autoIncrement: true });
    };
    sollicitud.onsuccess = () => resoldre(sollicitud.result);
    sollicitud.onerror = () => rebutjar(sollicitud.error);
  });
}

export async function encuar(canvi) {
  const bd = await obrir();
  const tx = bd.transaction(MAGATZEM, 'readwrite');
  tx.objectStore(MAGATZEM).add({ ...canvi, ts: Date.now() });
  return new Promise((r) => { tx.oncomplete = r; });
}

export async function llegirTots() {
  const bd = await obrir();
  return new Promise((resoldre) => {
    const sollicitud = bd.transaction(MAGATZEM).objectStore(MAGATZEM).getAll();
    sollicitud.onsuccess = () => resoldre(sollicitud.result);
  });
}

export async function eliminar(id) {
  const bd = await obrir();
  bd.transaction(MAGATZEM, 'readwrite').objectStore(MAGATZEM).delete(id);
}
// js/app.js — envia el que s'ha acumulat quan torna la xarxa
export async function sincronitzarPendents() {
  const pendents = await llegirTots();
  if (pendents.length === 0) return;

  vista.avisar(`Enviant ${pendents.length} canvis pendents…`);

  for (const canvi of pendents) {
    try {
      await api.actualitzarTasca(canvi.id, canvi.dades);   // amb reintents de 07-03
      await eliminar(canvi.clauCua);
    } catch (error) {
      if (!error.reintentable) await eliminar(canvi.clauCua);   // no insisteixis amb un 400
      break;                                                    // la resta, al pròxim intent
    }
  }
  vista.avisar('Sincronització completada.');
}

Existeix a més la Background Sync API, que permet registrar una sincronització que el navegador executarà quan hi hagi connexió, encara que la pestanya estigui tancada:

// Des de la pàgina
const registre = await navigator.serviceWorker.ready;
if ('sync' in registre) await registre.sync.register('enviar-canvis');
// Dins de sw.js
self.addEventListener('sync', (esdeveniment) => {
  if (esdeveniment.tag === 'enviar-canvis') esdeveniment.waitUntil(enviarCanvisPendents());
});

És elegant, però el seu suport no és universal, així que tracta-la com una millora sobre la sincronització amb l'esdeveniment online, mai com el mecanisme principal.

  1. El manifest i la instal·lació

El manifest és un JSON que descriu l'aplicació instal·lada:

{
  "name": "Nómada Tasques — Taller Nómada",
  "short_name": "Nómada",
  "description": "Gestió de tasques del taller i el coworking",
  "start_url": "/?origen=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#faf7f2",
  "theme_color": "#2b6b5b",
  "lang": "ca",
  "dir": "ltr",
  "icons": [
    { "src": "/icones/icona-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icones/icona-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icones/icona-mascara.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "shortcuts": [
    { "name": "Nova tasca", "url": "/?accio=nova", "description": "Crear una tasca" }
  ]
}
<!-- index.html, dins de <head> -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2b6b5b">
<link rel="apple-touch-icon" href="/icones/icona-192.png">
Camp Per a què Nota
name Nom complet, a la pantalla d'instal·lació
short_name Sota la icona Màxim ~12 caràcters o es talla
start_url Què s'obre en prémer la icona El paràmetre permet mesurar quanta gent la fa servir instal·lada
scope Quines URLs pertanyen a l'aplicació Fora de l'scope s'obre el navegador
display standalone, fullscreen, minimal-ui, browser standalone és l'habitual: sense barra d'adreces
theme_color Color de la barra del sistema
background_color Fons de la pantalla d'arrencada Posa'l igual que el fons real, o es veurà un espurneig
icons Icones 192 i 512 px com a mínim; afegeix-ne una de maskable

La icona maskable és un detall que es nota: Android retalla les icones en formes diferents (cercle, quadrat arrodonit), i sense ella el teu logotip pot quedar decapitat. Una de maskable deixa marge de seguretat al voltant.

L'avís d'instal·lació. Quan es compleixen els requisits, el navegador dispara beforeinstallprompt, i pots controlar el moment:

let promesaInstalacio = null;

window.addEventListener('beforeinstallprompt', (esdeveniment) => {
  esdeveniment.preventDefault();                 // ← evita l'avís automàtic del navegador
  promesaInstalacio = esdeveniment;
  $('#instalar').hidden = false;                 // mostra EL TEU botó, en EL TEU moment
});

$('#instalar').addEventListener('click', async () => {
  if (promesaInstalacio === null) return;

  promesaInstalacio.prompt();
  const { outcome } = await promesaInstalacio.userChoice;
  console.log('[nomada] Instal·lació:', outcome);      // 'accepted' | 'dismissed'

  promesaInstalacio = null;                      // l'esdeveniment és d'un sol ús
  $('#instalar').hidden = true;
});

window.addEventListener('appinstalled', () => {
  $('#instalar').hidden = true;
  console.log('[nomada] Instal·lada');
});

Dues normes de bona educació, germanes de les que veuràs a 07-06 amb els permisos: no demanis instal·lar res just en entrar —l'usuari encara no sap si la teva aplicació li interessa—, i ofereix-ho quan hagi demostrat interès, per exemple després de crear la tercera tasca. I si ho rebutja, no tornis a preguntar en setmanes.

  1. Actualitzar sense deixar ningú enrere

Aquest és el problema pràctic més molest de les PWA: l'usuari es pot quedar amb una versió antiga indefinidament. Amb un web normal, un F5 porta l'últim; amb una PWA mal configurada, el service worker serveix la memòria cau vella per sempre.

El navegador comprova si sw.js ha canviat en navegar (i com a molt cada 24 h). Si ha canviat, instal·la el nou… que es queda esperant. Hi ha tres estratègies:

Estratègia Com Avantatge Inconvenient
Esperar (per defecte) El nou hi entra quan es tanquen totes les pestanyes Mai trenca una sessió en curs L'usuari que no tanca mai la pestanya no actualitza mai
skipWaiting() immediat El nou pren el control ja Actualització garantida Perillós: la pàgina en curs pot demanar recursos ja esborrats
Avisar i deixar decidir «Hi ha una versió nova. Actualitzar» Segur i transparent Una mica més de codi

La tercera és la correcta, i s'implementa així:

// sw.js
self.addEventListener('install', (esdeveniment) => {
  esdeveniment.waitUntil(caches.open(CACHE_SHELL).then((c) => c.addAll(RECURSOS_SHELL)));
  // ← NO cridem skipWaiting() aquí: esperem que l'usuari accepti
});

self.addEventListener('message', (esdeveniment) => {
  if (esdeveniment.data?.tipus === 'saltar-espera') self.skipWaiting();
});
// js/app.js
const registre = await navigator.serviceWorker.register('/sw.js');

// 1 · Detectar que hi ha un worker nou esperant
registre.addEventListener('updatefound', () => {
  const nou = registre.installing;

  nou.addEventListener('statechange', () => {
    // 'installed' + hi ha un controlador = és una ACTUALITZACIÓ, no la primera instal·lació
    if (nou.state === 'installed' && navigator.serviceWorker.controller) {
      mostrarAvisActualitzacio(nou);
    }
  });
});

function mostrarAvisActualitzacio(treballadorNou) {
  $('#avis-versio').hidden = false;
  $('#avis-versio-actualitzar').addEventListener('click', () => {
    treballadorNou.postMessage({ tipus: 'saltar-espera' });    // 2 · l'usuari accepta
  }, { once: true });
}

// 3 · Quan el nou pren el control, recarregar UNA vegada
let recarregant = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (recarregant) return;                                      // ← guarda contra bucles
  recarregant = true;
  window.location.reload();
});

// Comprovar si hi ha actualitzacions en tornar a la pestanya
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') registre.update();
});

Aquella bandera recarregant no és opcional: sense ella, controllerchange es pot disparar més d'una vegada i provocar un bucle infinit de recàrregues, una de les fallades més desagradables que pot patir un usuari.

I una regla de desplegament que evita el pitjor escenari: el fitxer sw.js no s'ha de posar mai a la memòria cau del servidor. Configura'l amb Cache-Control: no-cache. Si un CDN serveix un sw.js vell durant hores, els teus usuaris es queden congelats en una versió antiga i no hi ha res que puguis fer des del client.

  1. Depurar a DevTools

A DevTools → Application tens el panell de control complet:

Secció Per a què
Service Workers Veure l'estat (installing / waiting / activated), forçar skipWaiting, Unregister, veure els registres
Manifest Comprovar que el manifest es llegeix bé i veure les icones detectades
Cache Storage Inspeccionar cada memòria cau entrada per entrada, i esborrar-les
StorageClear site data El botó nuclear: esborra tot i desregistra el worker

Tres caselles que et salvaran la vida durant el desenvolupament:

  • Update on reload: força que el service worker nou s'instal·li i s'activi a cada recàrrega, saltant-se l'espera. Activa-la mentre desenvolupes. És la diferència entre iterar en segons o barallar-te amb memòries cau.
  • Bypass for network: ignora el service worker completament, com si no existís.
  • Offline (a Network): l'única manera de provar de debò que la teva aplicació funciona sense connexió.

El parany de la memòria cau durant el desenvolupament mereix un paràgraf propi perquè li passa a tothom: canvies el CSS, recarregues i no veus el canvi. La causa és la teva pròpia estratègia cache-first servint el fitxer vell. Els remeis, per ordre:

  1. Marca Update on reload i Disable cache a DevTools.
  2. Puja la constant VERSIO a sw.js a cada canvi de recursos.
  3. Com a últim recurs, Clear site data i recarrega.

I el consell més valuós: registra el service worker només en producció mentre desenvolupes l'aplicació, o darrere d'una bandera. Depurar una aplicació amb un proxy de memòria cau pel mig multiplica el temps de cada iteració.

const ENTORN_LOCAL = ['localhost', '127.0.0.1'].includes(location.hostname);
if ('serviceWorker' in navigator && (!ENTORN_LOCAL || location.search.includes('sw=1'))) {
  navigator.serviceWorker.register('/sw.js');
}

  1. Notificacions push, breument

Un service worker pot rebre missatges del servidor encara que l'aplicació estigui tancada, i mostrar una notificació del sistema. El mecanisme, en tres passos:

// 1 · La pàgina demana permís i se subscriu (amb la clau pública VAPID del servidor)
const registre = await navigator.serviceWorker.ready;
const subscripcio = await registre.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: CLAU_PUBLICA_VAPID
});
await api.desarSubscripcio(subscripcio);          // el servidor la necessita per enviar-te coses
// 2 · A sw.js: arriba el push
self.addEventListener('push', (esdeveniment) => {
  const dades = esdeveniment.data?.json() ?? {};
  esdeveniment.waitUntil(self.registration.showNotification('Nómada Tasques', {
    body: dades.missatge ?? 'Hi ha novetats al tauler',
    icon: '/icones/icona-192.png',
    data: { url: dades.url ?? '/' }
  }));
});

// 3 · L'usuari prem la notificació
self.addEventListener('notificationclick', (esdeveniment) => {
  esdeveniment.notification.close();
  esdeveniment.waitUntil(self.clients.openWindow(esdeveniment.notification.data.url));
});

Tres coses que cal saber abans de plantejar-ho:

  • Requereix un servidor que gestioni les subscripcions i signi els enviaments amb claus VAPID. No és només client.
  • userVisibleOnly: true és obligatori a la pràctica: no pots fer servir push per fer feina silenciosa.
  • Els permisos s'estudien a 07-06, juntament amb la regla d'or: no demanis permís de notificacions just en entrar. És la manera més ràpida que te'l deneguin per sempre.

Per a Nómada Tasques és probablement innecessari: amb el WebSocket de 07-04 el tauler ja s'actualitza en directe mentre està obert, i les notificacions aporten quan l'aplicació no ho està.

  1. Nómada Tasques: sw.js complet

// sw.js — a l'ARREL del lloc, perquè l'àmbit sigui '/'
const VERSIO = 'v3';
const CACHE_SHELL = `nomada-shell-${VERSIO}`;
const CACHE_DADES = `nomada-dades-${VERSIO}`;
const API = 'http://localhost:3000';                     // en producció, la URL real de l'API

const RECURSOS_SHELL = [
  '/', '/index.html', '/offline.html', '/manifest.json',
  '/css/estils.css',
  '/js/app.js',
  '/js/model/tasca.js', '/js/model/tauler.js', '/js/model/errors.js',
  '/js/dades/backlog.js', '/js/dades/repositori-local.js',
  '/js/dades/api-tasques.js', '/js/dades/http.js', '/js/dades/temps-real.js',
  '/js/util/dates.js', '/js/util/format.js', '/js/util/temps.js',
  '/js/vista/dom.js', '/js/vista/targeta.js', '/js/vista/pintar.js',
  '/js/vista/tauler-vista.js', '/js/vista/esdeveniments.js',
  '/js/vista/controlador.js', '/js/vista/formulari.js',
  '/icones/icona-192.png', '/icones/icona-512.png'
];

// ══════════════════ INSTAL·LACIÓ ══════════════════
self.addEventListener('install', (esdeveniment) => {
  console.log(`[sw] install ${VERSIO}`);
  esdeveniment.waitUntil(
    caches.open(CACHE_SHELL).then((cache) => cache.addAll(RECURSOS_SHELL))
  );
  // Sense skipWaiting(): l'actualització la decideix l'usuari (apartat 14)
});

// ══════════════════ ACTIVACIÓ ══════════════════
self.addEventListener('activate', (esdeveniment) => {
  console.log(`[sw] activate ${VERSIO}`);
  esdeveniment.waitUntil((async () => {
    const noms = await caches.keys();
    await Promise.all(
      noms
        .filter((n) => n.startsWith('nomada-') && !n.endsWith(VERSIO))
        .map((n) => caches.delete(n))
    );
    await self.clients.claim();
  })());
});

// ══════════════════ MISSATGES DES DE LA PÀGINA ══════════════════
self.addEventListener('message', (esdeveniment) => {
  if (esdeveniment.data?.tipus === 'saltar-espera') self.skipWaiting();
});

// ══════════════════ ESTRATÈGIES ══════════════════
async function cacheFirst(peticio) {
  const desada = await caches.match(peticio);
  if (desada) return desada;
  try {
    const resposta = await fetch(peticio);
    if (resposta.ok) (await caches.open(CACHE_SHELL)).put(peticio, resposta.clone());
    return resposta;
  } catch (error) {
    if (peticio.destination === 'image') return caches.match('/icones/icona-192.png');
    throw error;
  }
}

async function networkFirst(peticio) {
  try {
    const resposta = await fetch(peticio);
    if (resposta.ok) (await caches.open(CACHE_DADES)).put(peticio, resposta.clone());
    return resposta;
  } catch {
    const desada = await caches.match(peticio);
    if (desada) {
      const capcaleres = new Headers(desada.headers);
      capcaleres.set('X-Des-De-Cache', 'true');            // la interfície avisarà que és antic
      return new Response(desada.body, { status: 200, headers: capcaleres });
    }
    // Resposta JSON de reserva: millor que un error de xarxa opac
    return new Response(
      JSON.stringify({ error: 'sense-connexio', missatge: 'Sense connexió i sense còpia local.' }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

async function gestionarNavegacio(peticio) {
  try {
    return await fetch(peticio);
  } catch {
    return (await caches.match('/index.html')) ?? (await caches.match('/offline.html'));
  }
}

// ══════════════════ INTERCEPCIÓ ══════════════════
self.addEventListener('fetch', (esdeveniment) => {
  const peticio = esdeveniment.request;
  const url = new URL(peticio.url);

  if (peticio.method !== 'GET') return;                  // mai POST/PATCH/DELETE
  if (url.protocol.startsWith('ws')) return;             // el WebSocket de 07-04 va a part

  if (peticio.mode === 'navigate') {
    esdeveniment.respondWith(gestionarNavegacio(peticio));
    return;
  }
  if (url.origin === API) {
    esdeveniment.respondWith(networkFirst(peticio));
    return;
  }
  if (url.origin === self.location.origin) {
    esdeveniment.respondWith(cacheFirst(peticio));
  }
  // Qualsevol altre origen: sense respondWith, va a la xarxa tal qual
});

Comprova que funciona amb aquest guió, que és també el que hauries de repetir a cada desplegament:

  1. Serveix el lloc (npx serve a la carpeta del projecte) i obre'l.
  2. DevTools → Application → Service Workers: hi ha d'aparèixer activated and running.
  3. Cache Storage: hi ha d'haver nomada-shell-v3 amb tots els recursos.
  4. Network → marca Offline.
  5. Prem F5. L'aplicació arrenca, amb el tauler de localStorage (07-01).
  6. Marca una tasca com a feta: s'encua.
  7. Desmarca Offline: el canvi s'envia i el WebSocket es reconnecta amb el retrocés de 07-04.

Aquell punt 5 és el moment en què Nómada Tasques deixa de ser un web i passa a ser una aplicació.

Errors Habituals i Consells

  • Posar sw.js a js/. El seu àmbit seria /js/ i no controlaria res. Va a l'arrel.
  • Oblidar event.waitUntil(). El navegador pot aturar el worker abans que la precàrrega acabi, deixant-la a mitges.
  • Un 404 a RECURSOS_SHELL. addAll és atòmic: una URL mal escrita impedeix que es desi res.
  • Interceptar peticions que no són GET. Dades duplicades o perdudes.
  • Cridar skipWaiting() sempre. La pàgina en curs es pot quedar demanant recursos que la memòria cau nova ja ha esborrat. Avisa i deixa decidir.
  • No posar la guarda a controllerchange. Bucle infinit de recàrregues.
  • Posar sw.js a la memòria cau del servidor. Els teus usuaris es congelen en una versió vella. Cache-Control: no-cache.
  • Oblidar esborrar memòries cau velles. S'acumulen versions fins a esgotar la quota.
  • Desar respostes sense comprovar resposta.ok. Un 500 a la memòria cau se serveix com si fos bo.
  • Oblidar clone(). TypeError: body stream already read, el mateix de 07-02.
  • Creure que navigator.onLine === true significa que hi ha Internet. Només diu que hi ha una interfície de xarxa.
  • Confondre la primera visita amb estar controlat. El worker no controla la pàgina on es va registrar tret que facis servir clients.claim().
  • Provar sense activar Offline. Un mode offline que mai s'ha provat no funciona; és una llei.
  • Consell: Update on reload mentre desenvolupes. Estalvia hores.
  • Consell: puja VERSIO a cada desplegament. És el que dispara el cicle d'instal·lació i la neteja.
  • Consell: registra el worker només en producció mentre construeixes l'aplicació.
  • Consell: passa Lighthouse (DevTools → Lighthouse → Progressive Web App). Et diu exactament què falta.
  • Consell: no demanis instal·lar ni notificar res just en entrar. Espera que l'usuari demostri interès.

Exercicis

Exercici 1 — Memòria cau de dades amb caducitat. L'estratègia networkFirst desa respostes de l'API sense límit d'antiguitat, i un tauler de fa tres dies és pitjor que cap. Escriu cacheAmbCaducitat(peticio, maxEdatMs) que, en desar, afegeixi una capçalera X-Cachejat-En amb Date.now(), i en recuperar de la memòria cau comprovi aquella marca: si la resposta és més vella que maxEdatMs, l'esborri i llanci en lloc de retornar-la. Afegeix una funció netejarCaducats(nomCache, maxEdatMs) que s'executi a activate.

Exercici 2 — Avís de versió nova accessible. Escriu vigilarActualitzacions(registre, { enHaverVersio }) que detecti un service worker nou en estat installed (distingint actualització de primera instal·lació), cridi enHaverVersio amb una funció aplicar(), comprovi actualitzacions en tornar a la pestanya visible i cada 60 minuts, i gestioni controllerchange amb la guarda contra bucles. L'avís a l'HTML ha de fer servir role="status" i aria-live="polite", oferir «Actualitzar ara» i «Més tard», i recordar el rebuig durant la sessió amb sessionStorage.

Exercici 3 — Auditar la precàrrega. La llista RECURSOS_SHELL es desincronitza així que algú afegeix un mòdul. Escriu un script de Node auditar-shell.js que llegeixi tots els fitxers .js sota js/, n'extregui les rutes, les compari amb la llista declarada a sw.js i avisi dels que falten i dels que sobren, acabant amb codi de sortida 1 si hi ha diferències. No facis servir llibreries externes: node:fs i expressions regulars són suficients.

Solucions

Solució 1

const CAPCALERA_DATA = 'X-Cachejat-En';

async function desarAmbMarca(nomCache, peticio, resposta) {
  const capcaleres = new Headers(resposta.headers);
  capcaleres.set(CAPCALERA_DATA, String(Date.now()));

  // Cal reconstruir la Response: les seves capçaleres són immutables
  const ambMarca = new Response(await resposta.clone().blob(), {
    status: resposta.status,
    statusText: resposta.statusText,
    headers: capcaleres
  });

  const cache = await caches.open(nomCache);
  await cache.put(peticio, ambMarca);
}

function estaCaducada(resposta, maxEdatMs) {
  const marca = Number(resposta.headers.get(CAPCALERA_DATA));
  if (!Number.isFinite(marca)) return true;          // sense marca: la tractem com a caducada
  return Date.now() - marca > maxEdatMs;
}

async function cacheAmbCaducitat(peticio, maxEdatMs = 3600000) {
  try {
    const resposta = await fetch(peticio);
    if (resposta.ok) await desarAmbMarca(CACHE_DADES, peticio, resposta);
    return resposta;
  } catch (error) {
    const cache = await caches.open(CACHE_DADES);
    const desada = await cache.match(peticio);

    if (!desada) throw error;
    if (estaCaducada(desada, maxEdatMs)) {
      await cache.delete(peticio);
      throw new Error('Còpia a la memòria cau massa antiga');
    }
    return desada;
  }
}

async function netejarCaducats(nomCache, maxEdatMs) {
  const cache = await caches.open(nomCache);
  const peticions = await cache.keys();

  await Promise.all(peticions.map(async (peticio) => {
    const resposta = await cache.match(peticio);
    if (resposta && estaCaducada(resposta, maxEdatMs)) await cache.delete(peticio);
  }));
}

El detall que cal descobrir a la força: les capçaleres d'una Response són immutables. No pots fer resposta.headers.set(...); cal construir una Response nova amb les capçaleres modificades, i per a això primer es llegeix el cos del clon.

Solució 2

const CLAU_REBUIG = 'nomada:actualitzacio-rebutjada';

export function vigilarActualitzacions(registre, { enHaverVersio }) {
  let recarregant = false;

  function avaluar(treballador) {
    if (treballador.state !== 'installed') return;
    if (!navigator.serviceWorker.controller) return;        // primera instal·lació, no actualització
    if (sessionStorage.getItem(CLAU_REBUIG) === 'si') return;

    enHaverVersio(() => treballador.postMessage({ tipus: 'saltar-espera' }));
  }

  // Un worker que ja estava esperant quan hem carregat
  if (registre.waiting && navigator.serviceWorker.controller) avaluar(registre.waiting);

  registre.addEventListener('updatefound', () => {
    const nou = registre.installing;
    nou?.addEventListener('statechange', () => avaluar(nou));
  });

  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (recarregant) return;                                // ← guarda contra el bucle infinit
    recarregant = true;
    window.location.reload();
  });

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') registre.update().catch(() => {});
  });
  setInterval(() => registre.update().catch(() => {}), 3600000);
}
<div id="avis-versio" role="status" aria-live="polite" hidden>
  <p>Hi ha una versió nova de Nómada Tasques.</p>
  <button type="button" id="versio-actualitzar">Actualitzar ara</button>
  <button type="button" id="versio-despres">Més tard</button>
</div>
vigilarActualitzacions(registre, {
  enHaverVersio(aplicar) {
    const avis = document.querySelector('#avis-versio');
    avis.hidden = false;
    document.querySelector('#versio-actualitzar')
      .addEventListener('click', aplicar, { once: true });
    document.querySelector('#versio-despres').addEventListener('click', () => {
      sessionStorage.setItem(CLAU_REBUIG, 'si');            // no insistir en aquesta sessió
      avis.hidden = true;
    }, { once: true });
  }
});

La comprovació registre.waiting inicial és la que s'oblida sempre: si l'usuari obre l'aplicació i ja hi havia un worker esperant d'una visita anterior, l'esdeveniment updatefound no es tornarà a disparar i l'avís no apareixeria mai.

Solució 3

// auditar-shell.js — executar amb: node auditar-shell.js
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const ARREL = process.cwd();

/** Recorre un directori recursivament retornant les rutes dels .js */
function llistarJs(directori) {
  const sortida = [];
  for (const entrada of readdirSync(directori)) {
    const ruta = join(directori, entrada);
    if (statSync(ruta).isDirectory()) sortida.push(...llistarJs(ruta));
    else if (entrada.endsWith('.js')) sortida.push('/' + relative(ARREL, ruta).replaceAll('\\', '/'));
  }
  return sortida;
}

const enDisc = new Set(llistarJs(join(ARREL, 'js')));

// Extreu les cadenes entre cometes de la llista RECURSOS_SHELL
const sw = readFileSync(join(ARREL, 'sw.js'), 'utf8');
const bloc = sw.match(/const RECURSOS_SHELL\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
const declarats = new Set([...bloc.matchAll(/'([^']+)'/g)].map((m) => m[1]));

const falten = [...enDisc].filter((r) => !declarats.has(r));
const sobren = [...declarats].filter((r) => r.startsWith('/js/') && !enDisc.has(r));

if (falten.length) console.error('❌ Falten a RECURSOS_SHELL:\n  ' + falten.join('\n  '));
if (sobren.length) console.error('❌ Declarats però inexistents:\n  ' + sobren.join('\n  '));

if (falten.length === 0 && sobren.length === 0) {
  console.log(`✅ La precàrrega està al dia (${enDisc.size} mòduls).`);
  process.exit(0);
}
process.exit(1);

El codi de sortida 1 és el que converteix aquest script en una cosa útil de debò: es pot enganxar a un npm run build o a la integració contínua, i el desplegament falla si algú ha afegit un mòdul i s'ha descuidat la precàrrega. És també una bona il·lustració de per què existeixen els empaquetadors: generen aquesta llista sols, i ho veuràs a 09-05.

Conclusió

Nómada Tasques ja funciona sense connexió i es pot instal·lar al dispositiu. Saps què és una PWA —no una tecnologia, sinó un conjunt de capacitats sobre HTML, CSS i JavaScript— i els seus tres requisits: HTTPS (amb localhost com a excepció per a desenvolupament), manifest i service worker. I entens la idea que ho sosté tot: el service worker és un proxy que viu fora de la pàgina, al seu propi fil, que sobreviu al tancament de la pestanya, intercepta totes les peticions del seu àmbit i es pot aturar en qualsevol moment —d'aquí que no es desi estat a les seves variables globals—.

Domines el seu cicle de vida: install per precarregar l'app shell, la fase d'espera que protegeix les pestanyes obertes i que explica per què el teu worker nou «no hi entra», activate per netejar versions antigues, i fetch per atendre cada petició; amb event.waitUntil() com a peça obligatòria perquè el navegador no aturi el worker a mitges. Saps que l'àmbit el determina la ubicació del fitxer, i que per això sw.js va a l'arrel. Saps per què no té DOM (no pertany a cap pàgina) ni localStorage (és síncron), i que les alternatives són la Cache API i IndexedDB, amb postMessage i clients per parlar amb les pestanyes.

Manegues la Cache API amb els seus paranys —addAll atòmic, put que no mira l'estat, el clone() obligatori— i, sobretot, saps triar estratègia per tipus de recurs: cache-first per al shell, network-first per a l'API amb la memòria cau com a xarxa de seguretat, stale-while-revalidate per al que és poc crític, network-only per a les escriptures. El gestor fetch que has escrit és un encaminador, no una regla única, i respecta les tres normes: respondWith síncron, no interceptar mai el que no sigui GET, i retornar sempre alguna cosa. Amb això, més la pàgina de reserva, la cua de canvis a IndexedDB i els esdeveniments online/offline —recordant que navigator.onLine només indica que hi ha interfície de xarxa, no Internet—, Nómada Tasques arrenca al magatzem de serigrafia i envia sola el que està pendent quan torna la cobertura.

I tens resolts els dos problemes operatius que enfonsen les PWA mal fetes: el manifest amb les seves icones de 192 i 512 píxels, la maskable, el short_name curt i el beforeinstallprompt controlat per no demanar instal·lació just en entrar; i l'actualització, amb el patró d'avisar i deixar decidir, la comprovació de registre.waiting que gairebé tothom oblida, la guarda contra el bucle de recàrregues a controllerchange, i la regla de desplegament que evita el desastre: sw.js no es posa mai a la memòria cau del servidor. Sumat a Update on reload, Bypass for network i Clear site data, ja saps sortir del parany de la memòria cau durant el desenvolupament en lloc de patir-lo.

Amb això, l'aplicació té les seves grans capacitats cobertes: recorda (07-01), parla amb un servidor (07-02), aguanta les fallades de la xarxa (07-03), se sincronitza en directe (07-04) i funciona sense connexió i instal·lada (07-05). El que queda són les peces mitjanes que separen una aplicació correcta d'una que dona gust fer servir: carregar targetes només quan apareixen a la pantalla, reaccionar a canvis de mida, copiar el resum del tauler al porta-retalls amb un clic, fer que els filtres siguin enllaçables i sobrevisquin a la recàrrega, respectar que la Marta prefereixi el mode fosc o que la Lucía hagi demanat menys animació, animar sense estrebades, i formatar dates i hores en català de debò en lloc de a mà —aquella funció dataLlegible amb el seu array de mesos escrit a mà demana a crits una reescriptura—. Tot això són APIs del navegador que ja hi són, esperant: APIs del Navegador Essencials.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats