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
- Què és una PWA
- Els tres requisits
- El service worker: un proxy que viu fora de la pàgina
- Registre i àmbit (scope)
- El cicle de vida:
install,waiting,activate,fetch - Per què no té DOM ni
localStorage - La Cache API
- Precarregar l'app shell a
install - Netejar versions antigues a
activate - Les estratègies de xarxa
- Interceptar amb
fetch: l'encaminador del service worker - Funcionar sense connexió: pàgina de reserva i cua de canvis
- El manifest i la instal·lació
- Actualitzar sense deixar ningú enrere
- Depurar a DevTools
- Notificacions push, breument
- Nómada Tasques:
sw.jscomplet - Errors Habituals i Consells
- Exercicis
- Conclusió
- 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 | Sí, des del navegador | Botiga d'aplicacions |
| Icona a l'escriptori | No | Sí | Sí |
| Funciona sense connexió | No | Sí, si la programes | Sí |
| Finestra pròpia, sense barra del navegador | No | Sí (display: standalone) |
Sí |
| Notificacions push | Limitat | Sí | Sí |
| 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.
- 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.
- 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, niwindow, 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.
- 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.
- El cicle de vida:
install, waiting, activate, fetch
install, waiting, activate, fetchEl 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.
- Per què no té DOM ni
localStorage
localStorageDins 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
localStorageperquè é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);
}
- 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ó.putno comprova l'estat. Desarà encantada una resposta 404 o 500. Comprovaresposta.okabans.- 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.
- Precarregar l'app shell a
install
installL'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.
- Netejar versions antigues a
activate
activateCada 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 dellocalStorage.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.
- 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.
- Interceptar amb
fetch: l'encaminador del service worker
fetch: l'encaminador del service workerEl 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 unawaitabans de decidir si respons; primer cridesrespondWithamb 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. UnPOSTa la memòria cau o duplicat crea dades fantasma. I a 07-03 ja vas aprendre com de car surt repetir unPOST. - Retorna sempre alguna cosa. Si la promesa de
respondWithrebutja, el navegador mostra un error de xarxa genèric. Val més respondre amb una pàgina o un JSON de reserva.
- 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.
- 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.
- 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.
- 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 |
| Storage → Clear 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:
- Marca
Update on reloadiDisable cachea DevTools. - Puja la constant
VERSIOasw.jsa cada canvi de recursos. - 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');
}
- 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à.
- Nómada Tasques:
sw.js complet
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:
- Serveix el lloc (
npx servea la carpeta del projecte) i obre'l. - DevTools → Application → Service Workers: hi ha d'aparèixer activated and running.
- Cache Storage: hi ha d'haver
nomada-shell-v3amb tots els recursos. - Network → marca Offline.
- Prem F5. L'aplicació arrenca, amb el tauler de
localStorage(07-01). - Marca una tasca com a feta: s'encua.
- 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.jsajs/. 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.jsa 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 === truesignifica 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 reloadmentre desenvolupes. Estalvia hores. - Consell: puja
VERSIOa 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
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
