PWA Angular — @angular/service-worker
TL;DR — Une PWA (Progressive Web App) est une application web installable qui fonctionne hors-ligne grâce à un Service Worker (un script JS exécuté par le navigateur en arrière-plan, indépendamment de la page). Angular fournit
@angular/service-worker, configurable viangsw-config.json, qui gère la mise en cache des assets (statiques, versionnés au build) et des data (API, freshness vs performance). On y brancheSwUpdatepour proposer une mise à jour à l'utilisateur,SwPushpour les notifications push, et un manifest pour l'installation home-screen. Les bénéfices : disponibilité offline, accès rapide (cache navigateur), expérience native-like, mesurable via Lighthouse (catégorie PWA). Les pièges principaux : versioning des assets (Angular gère, ne pas désactiver le hash), scope du SW (/par défaut), update flow (toujours prompter, jamais forcer un reload).
🧠 Mental model — ASCII + analogie
L'analogie : le Service Worker est un proxy local entre la page et le réseau. C'est une « gare de triage » : chaque requête HTTP de la page passe d'abord par lui, qui décide de répondre depuis le cache, depuis le réseau, ou les deux.
┌────────────────────────────────────────────────────────────┐
│ BROWSER │
│ ┌─────────────┐ ┌────────────────────────────┐ │
│ │ PAGE │ fetch() │ SERVICE WORKER │ │
│ │ (Angular) │────────►│ ngsw-worker.js │ │
│ │ │ │ │ │
│ └─────────────┘ │ ┌────────┐ ┌──────────┐ │ │
│ │ │ assets │ │ data │ │ │
│ │ │ cache │ │ cache │ │ │
│ │ └────────┘ └──────────┘ │ │
│ │ │ │ │ │
│ └───────┼───────────┼────────┘ │
│ │ │ │
└───────────────────────────────────┼───────────┼────────────┘
│ │
▼ ▼
┌──────────────────────┐
│ NETWORK (origin) │
└──────────────────────┘Les assets (JS, CSS, images, fonts) sont versionnés au build par Angular (hash dans le nom de fichier). Le Service Worker les met en cache immutablement : tant que ngsw.json (manifest généré par le build) n'a pas changé, le SW sert le fichier depuis le cache sans toucher au réseau. Quand un nouveau build est déployé, Angular détecte le nouveau ngsw.json, télécharge les nouveaux assets en arrière-plan, et propose une mise à jour via SwUpdate.
Les data (réponses d'API) suivent une stratégie : freshness (réseau d'abord, fallback cache) ou performance (cache d'abord, refresh en arrière-plan). On choisit selon la tolérance à la fraîcheur.
Le cycle de vie du SW — le modèle mental que la plupart ratent
Un staff engineer ne raisonne pas en « le SW cache des fichiers » mais en machine à états avec une notion de version atomique. Comprendre ces deux invariants évite 90% des bugs PWA :
- Une version = un manifest
ngsw.jsonentier. Le SW ne mélange JAMAIS les fichiers de deux builds. Singsw.jsonchange, c'est une nouvelle « app version » et le SW télécharge l'ensemble des assetsprefetchde cette version avant de la rendre activable. C'est ce qui garantit qu'un utilisateur ne se retrouve jamais avec unmain.jsde la v2 et unvendor.jsde la v1 (le « half-deploy » qui casse les SPA naïves servies par un CDN). - L'activation est paresseuse par onglet. Tant qu'un onglet est ouvert avec la v1, il continue à être servi par la v1, même si la v2 est déjà téléchargée.
activateUpdate()ne fait que basculer l'onglet courant sur la dernière version déjà téléchargée. C'est pourquoireload()suit toujoursactivateUpdate().
Les états du driver (visibles via /ngsw/state) :
| État | Signification | Action |
|---|---|---|
NORMAL | Tout fonctionne, sert depuis la dernière version | Rien |
EXISTING_CLIENTS_ONLY | Le SW ne peut pas se mettre à jour proprement ; sert les onglets existants mais refuse d'initialiser de nouveaux clients | Investiguer le réseau / le manifest |
SAFE_MODE | Le SW a planté ; il passe en pass-through (toutes les requêtes vont au réseau, plus de cache) | Émet unrecoverable ; reload nécessaire |
Mental model : le SW privilégie toujours la disponibilité sur la fraîcheur. En cas de doute (manifest illisible, hash invalide), il dégrade en pass-through plutôt que de servir du contenu corrompu. C'est un choix d'ingénierie délibéré qu'il faut accepter — d'où l'importance d'écouter unrecoverable.
🛠️ Code minimal (ts + html)
Installation
ng add @angular/pwaCela :
- ajoute
@angular/service-workeraux dépendances ; - crée
ngsw-config.jsonà la racine ; - ajoute
serviceWorker: trueau build production ; - crée un
manifest.webmanifest(icônes, theme color, display) ; - enregistre le SW via
provideServiceWorker()ouServiceWorkerModule.register().
Configuration providers (standalone, v17+)
// app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideServiceWorker } from '@angular/service-worker';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), // jamais en dev pour éviter les caches surprises
registrationStrategy: 'registerWhenStable:30000',
// autres : 'registerImmediately', 'registerWithDelay:5000'
}),
],
};ngsw-config.json complet
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"updateMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
],
"dataGroups": [
{
"name": "api-products",
"urls": ["/api/products", "/api/products/**"],
"cacheConfig": {
"strategy": "performance",
"maxSize": 100,
"maxAge": "1h",
"timeout": "5s"
}
},
{
"name": "api-user",
"urls": ["/api/user/**"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 20,
"maxAge": "1d",
"timeout": "3s"
}
}
],
"navigationUrls": [
"/**",
"!/**/*.*",
"!/**/*__*",
"!/**/*__*/**",
"!/api/**"
]
}Précisions sur les champs :
installMode: prefetchtélécharge les ressources lors de l'installation du SW (avant que l'utilisateur les demande).lazyne les télécharge qu'au premier fetch.updateMode: prefetchmet à jour en arrière-plan dès qu'une nouvelle version est détectée.lazyattend la prochaine demande.strategy: performance= cache-first (rapide mais peut servir du stale).freshness= network-first avec fallback cache (frais mais plus lent si réseau lent).timeout= délai max d'attente du réseau avant de basculer sur le cache (freshness uniquement).navigationUrlscontrôle pour quelles URL le SW sertindex.html(SPA fallback). On exclut les URL avec extension et/api/**.
Table de décision — quelle stratégie pour quel endpoint (le réflexe d'architecte) :
| Type de donnée | Stratégie | timeout | maxAge | Pourquoi |
|---|---|---|---|---|
| Catalogue public, contenu éditorial | performance | — | 1h–6h | Volatilité faible, on privilégie la latence ; le stale est acceptable |
Données user-spécifiques (/api/me, panier) | freshness court ou pas de cache | 2s–3s | 0/court | Confidentialité : le SW cache par URL sans notion de session (voir Exercice 4) |
| Données transactionnelles, soldes, prix temps réel | pas de cache (aucun group) | — | — | La fraîcheur prime ; un stale induit en erreur |
| Stream LLM / SSE / WebSocket | bypass total (aucun group) | — | — | Le buffering du SW casse le streaming token-par-token |
| Assets versionnés (JS/CSS hashés) | assetGroup prefetch/lazy | — | immutable | Le hash garantit l'invalidation ; immutable, max-age=1an côté CDN |
Règle de scope : préférez des globs étroits (/api/produits/**) à des globs larges (/api/**). Un glob trop large finit toujours par capturer un endpoint qu'il ne fallait pas cacher (privé) ou pas intercepter (stream).
Flow de mise à jour (SwUpdate)
C'est le code le plus critique d'une PWA. Ne jamais reloader silencieusement : l'utilisateur perdrait son travail en cours.
// update.service.ts
import { Injectable, inject, ApplicationRef } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { MatSnackBar } from '@angular/material/snack-bar';
import { concat, interval } from 'rxjs';
import { first, filter } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UpdateService {
private swUpdate = inject(SwUpdate);
private snack = inject(MatSnackBar);
private appRef = inject(ApplicationRef);
init() {
if (!this.swUpdate.isEnabled) return;
// 1. Écouter les nouvelles versions disponibles
this.swUpdate.versionUpdates
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
.subscribe(() => this.promptUser());
// 2. Vérifier périodiquement (toutes les 6h) une fois l'app stable
const appIsStable = this.appRef.isStable.pipe(first((stable) => stable));
const every6h = interval(6 * 60 * 60 * 1000);
concat(appIsStable, every6h).subscribe(() => {
this.swUpdate.checkForUpdate().catch(console.error);
});
// 3. Gérer les versions cassées (cache corrompu)
this.swUpdate.unrecoverable.subscribe((event) => {
console.error('SW unrecoverable:', event.reason);
// forcer un reload « propre »
document.location.reload();
});
}
private promptUser() {
const ref = this.snack.open(
'Une nouvelle version est disponible.',
'Mettre à jour',
{ duration: 0 },
);
ref.onAction().subscribe(async () => {
await this.swUpdate.activateUpdate();
document.location.reload();
});
}
}Et au bootstrap :
// app.component.ts
import { Component, inject } from '@angular/core';
import { UpdateService } from './update.service';
@Component({ /* ... */ })
export class AppComponent {
constructor() {
inject(UpdateService).init();
}
}Notifications push (SwPush)
import { Injectable, inject } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PushService {
private swPush = inject(SwPush);
private http = inject(HttpClient);
// Clé publique VAPID — générée côté serveur, partagée ici
private readonly VAPID_PUBLIC = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjB...';
async subscribe() {
if (!this.swPush.isEnabled) {
throw new Error('Service Worker indisponible');
}
const sub = await this.swPush.requestSubscription({
serverPublicKey: this.VAPID_PUBLIC,
});
// firstValueFrom : `toPromise()` est supprimé en RxJS 8
await firstValueFrom(this.http.post('/api/push/subscribe', sub));
}
listen() {
this.swPush.messages.subscribe((msg) => {
console.log('Push reçu', msg);
});
this.swPush.notificationClicks.subscribe(({ action, notification }) => {
window.focus();
if (notification.data?.url) {
window.location.href = notification.data.url;
}
});
}
}🎯 Patterns courants
1. Offline-first
Cacher tout l'app shell (header, navigation, layout) en prefetch + assets en lazy/prefetch. L'utilisateur peut lancer l'app depuis l'icône home-screen même sans réseau. Les données dynamiques affichent un placeholder « contenu indisponible hors-ligne », sauf si on les a mises en cache via dataGroups.
2. Stale-while-revalidate (custom)
@angular/service-worker propose performance (cache-first) et freshness (network-first), mais pas directement « stale-while-revalidate » (servir le cache et rafraîchir en arrière-plan, mettre à jour l'UI au retour). Pour cela : combiner performance + écouter manuellement les changements via un long-poll ou WebSocket, ou écrire un SW custom.
3. Background sync
Quand l'utilisateur est offline, mettre en file d'attente les actions (POST, PATCH) dans IndexedDB, puis les rejouer quand la connexion revient. Le package workbox-background-sync (compatible Angular) automatise cela. Sinon, écouter window.addEventListener('online', …) côté app et déclencher un retry.
4. App shell prerendering
Combiner PWA + prerendering Angular : la première visite est instantanée (HTML pré-rendu servi depuis le cache CDN), et les visites suivantes sont offline grâce au SW. C'est la combinaison la plus rapide possible.
5. Manifest et installation
// manifest.webmanifest
{
"name": "Mon App",
"short_name": "MonApp",
"theme_color": "#1976d2",
"background_color": "#fafafa",
"display": "standalone",
"scope": "/",
"start_url": "/?utm_source=pwa",
"icons": [
{ "src": "icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any maskable" },
{ "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"shortcuts": [
{ "name": "Tableau de bord", "url": "/dashboard", "icons": [{ "src": "icons/dashboard.png", "sizes": "96x96" }] }
]
}L'événement beforeinstallprompt permet de proposer l'installation au moment opportun (pas à l'arrivée) :
@Component({
template: `
@if (deferredPrompt()) {
<button (click)="install()">Installer l'app</button>
}
`,
})
export class InstallBannerComponent {
deferredPrompt = signal<any>(null);
constructor() {
window.addEventListener('beforeinstallprompt', (e: any) => {
e.preventDefault();
this.deferredPrompt.set(e);
});
}
async install() {
const e = this.deferredPrompt();
if (!e) return;
await e.prompt();
this.deferredPrompt.set(null);
}
}6. Mesure — Lighthouse PWA
Lancer Lighthouse en mode « Progressive Web App » et viser un score 100. Les critères :
- HTTPS obligatoire ;
- manifest valide ;
- SW enregistré ;
- offline fallback (
navigationUrls) ; - viewport meta, theme color, icônes 192 et 512 ;
- LCP < 2,5 s.
🔄 Versions — Angular 16 → 20
- v16 —
provideServiceWorker()(en plus deServiceWorkerModule.register()). Standalone-ready. - v17 — Build avec esbuild builder ; le SW reste compatible mais le manifest
ngsw.jsonest généré avec un format légèrement différent. VérifierinstallModeaprès migration. - v18 — Améliorations
unrecoverableevent (meilleurs diagnostics). Compatibilité avec event replay SSR. - v19 — Optimisations du cache versioning, support de
cacheQueryOptions(ignore search params). Meilleure interop avec@defer(les chunks lazy sont automatiquement listés dansngsw.json). - v20 — Le package reste stable. Le SW supporte mieux les modules ESM (toujours servi en classic script par défaut, voir pitfalls).
⚠️ Pitfalls — 6-10
- SW activé en dev —
ng servene build pas avec le SW, mais si on fait unng build --watch+ serve statique, le SW peut cacher les anciens assets et rendre le dev infernal. Toujoursenabled: !isDevMode(). - Cache busting cassé — désactiver le hash dans le nom de fichier (
outputHashing: none) brise le SW : il ne sait plus qu'un fichier a changé. Toujours laisseroutputHashing: allen prod. - Scope du SW — par défaut le SW a pour scope
/, donc contrôle toute l'app. Si on sert l'app sous/app/, il faut servir le SW depuis/app/aussi. Le placer dans un sous-dossier réduit son scope. ngsw.jsonnon servi — sur certains hébergeurs (S3 sans index.html fallback),ngsw.jsondoit être accessible à la racine, avecContent-Type: application/jsonet sans cache CDN long (sinon les utilisateurs ne reçoivent jamais les mises à jour).- Reload forcé sans prompt — appeler
activateUpdate()puislocation.reload()sans demander : l'utilisateur perd ses inputs en cours. Toujours prompter, sauf cas critique (faille sécu). - dataGroups trop larges — mettre
urls: ['/api/**']enperformancemet en cache des données privées (panier, profil) qui ne devraient pas l'être. Filtrer par chemin précis ou par méthode. - POST non cachés — par design, le SW Angular ne cache que GET/HEAD. Les POST/PUT/DELETE passent toujours par le réseau (utile pour mutations idempotentes uniquement).
- Notifications push sans VAPID — un push sans clé VAPID est rejeté par les navigateurs modernes. Toujours générer une paire VAPID côté serveur et l'utiliser pour signer les envois.
unrecoverableignoré — si le cache du SW est corrompu (rare, mais possible après un crash navigateur), le SW émetunrecoverableet tout est cassé jusqu'au reload. Toujours écouter cet event et reload.- ESM service worker scope — Angular génère un SW en script classique (pas ESM). Si on veut un SW custom avec imports ESM, il faut le configurer manuellement (
{ type: 'module' }à l'enregistrement) — non supporté sur tous les navigateurs.
🧪 Testing
// Unit test du UpdateService
import { TestBed } from '@angular/core/testing';
import { SwUpdate } from '@angular/service-worker';
import { Subject } from 'rxjs';
describe('UpdateService', () => {
let versionUpdates$: Subject<any>;
beforeEach(() => {
versionUpdates$ = new Subject();
TestBed.configureTestingModule({
providers: [
UpdateService,
{
provide: SwUpdate,
useValue: {
isEnabled: true,
versionUpdates: versionUpdates$.asObservable(),
unrecoverable: new Subject(),
checkForUpdate: jasmine.createSpy().and.resolveTo(true),
activateUpdate: jasmine.createSpy().and.resolveTo(),
},
},
],
});
});
it('prompte sur VERSION_READY', () => {
const svc = TestBed.inject(UpdateService);
svc.init();
versionUpdates$.next({ type: 'VERSION_READY' });
// assert snackbar ouverte (mock MatSnackBar)
});
});E2E (Playwright) :
test('PWA fonctionne offline', async ({ page, context }) => {
await page.goto('/');
await page.waitForFunction(() => navigator.serviceWorker.ready);
await context.setOffline(true);
await page.reload();
await expect(page.locator('h1')).toContainText('Mon App');
});Pour valider le score Lighthouse, intégrer lighthouse-ci dans la pipeline CI/CD.
🎬 Cas d'usage concrets
Scénario 1 — E-commerce mode, PWA avec panier offline
Contexte : le retailer mode constate 18% de paniers abandonnés en mobilité (utilisateurs perdant la connexion dans le métro pendant le checkout). Objectif PWA : permettre la navigation catalogue offline (déjà visité), garder le panier persistant offline, et soumettre la commande dès reconnexion via Background Sync. Approche : @angular/service-worker configuré avec installMode: 'prefetch' sur le shell + assets critiques, dataGroups sur /api/produits/* en stratégie performance (TTL 1h, max 200 entrées), et un Service Worker custom complémentaire (extension du SW Angular) qui hooke Background Sync pour la mise en file des commandes. Le panier est stocké en IndexedDB (pas localStorage, plus robuste), avec sync bi-directionnel signal Angular ↔ IDB. La page checkout détecte navigator.onLine, propose un mode "Commande différée — sera envoyée à la reconnexion" qui inscrit la commande dans une queue Background Sync. Lighthouse PWA à 100, taux de conversion mobile +12%.
Scénario 2 — SaaS RH, managers terrain en PWA
Contexte : le SaaS RH propose un module manager d'agence où les chefs d'équipe (retail, restaurants, BTP) saisissent les feuilles d'heures, valident les demandes de congés, prennent des photos d'incidents — souvent dans des zones de connectivité médiocre (sous-sols magasins, chantiers). Approche : version PWA installable du module manager avec icon, splash, manifest. ngsw-config.json cache l'app shell et le menu navigation, dataGroups sur /api/equipe/me/* en freshness (5s timeout puis cache), /api/feuilles-heures/* cache + sync background. Les photos prises sont stockées en IndexedDB sous forme de Blob avec metadata (date, lieu GPS, employé), uploadées dès que navigator.onLine redevient true. SwUpdate propose la mise à jour en bandeau non-intrusif que le manager applique entre deux saisies. Avec installation home-screen sur Android (PWA stable depuis 2024), l'app remplace efficacement une appli native, économisant le coût de maintenance d'une app iOS/Android.
Scénario 3 — Immobilier, agent itinérant
Contexte : agence immobilière équipe ses agents d'une app de gestion mandats / visites / coordonnées prospects, utilisée en visite à domicile (connectivité mobile variable). Approche : PWA installable, mode offline-first complet. Les fiches biens sont pré-cachées à chaque ouverture matinale de l'app (l'agent voit ses 5 visites du jour, les fiches sont chargées en background avec dataGroups strategy performance). Les notes prises pendant la visite et les coordonnées prospects rentrées dans le formulaire sont stockées en IDB et synchronisées en Background Sync dès le retour de connectivité. SwPush envoie des notifications de nouveaux RDV (intégration calendrier serveur). Le manifest définit display: 'standalone', masquant la barre URL pour une expérience native. Tests sur device réel avec Chrome DevTools > Application > Service Workers > Offline pour vérifier le comportement.
🛠️ Exemple end-to-end
Use case : PWA e-commerce mode avec shell offline, cache produits, panier persistant IndexedDB, et Background Sync commande.
// ngsw-config.json
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app-shell",
"installMode": "prefetch",
"updateMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/manifest.webmanifest", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif)"]
}
}
],
"dataGroups": [
{
"name": "api-produits",
"urls": ["/api/produits/**"],
"cacheConfig": {
"maxSize": 200,
"maxAge": "1h",
"timeout": "3s",
"strategy": "performance"
}
},
{
"name": "api-user",
"urls": ["/api/me", "/api/me/**"],
"cacheConfig": {
"maxSize": 30,
"maxAge": "1d",
"timeout": "2s",
"strategy": "freshness"
}
}
]
}// public/manifest.webmanifest
{
"name": "Shop Mode",
"short_name": "Shop",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#111827",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}// app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' }),
],
};// cart.store.ts
import { Injectable, computed, effect, signal } from '@angular/core';
import { openDB, IDBPDatabase } from 'idb';
interface CartItem { id: string; sku: string; qty: number; prix: number; }
@Injectable({ providedIn: 'root' })
export class CartStore {
private db!: IDBPDatabase;
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly total = computed(() => this._items().reduce((s, i) => s + i.qty * i.prix, 0));
constructor() {
this.init();
effect(() => {
const items = this._items();
this.db?.put('meta', items, 'cart');
});
}
private async init() {
this.db = await openDB('shop', 1, {
upgrade(db) {
db.createObjectStore('meta');
db.createObjectStore('pending-orders', { keyPath: 'id' });
},
});
const saved = (await this.db.get('meta', 'cart')) as CartItem[] | undefined;
if (saved) this._items.set(saved);
}
add(item: CartItem) {
this._items.update((arr) => [...arr, item]);
}
async queueOrder(order: { items: CartItem[]; adresse: string }) {
const id = crypto.randomUUID();
await this.db.put('pending-orders', { id, ...order, createdAt: Date.now() });
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await (reg as any).sync.register(`order-${id}`);
}
}
}// update-prompt.component.ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter } from 'rxjs';
@Component({
selector: 'app-update-prompt',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (available()) {
<div role="alert" class="prompt">
Nouvelle version disponible.
<button (click)="apply()">Actualiser</button>
<button (click)="dismiss()">Plus tard</button>
</div>
}
`,
})
export class UpdatePromptComponent {
private readonly sw = inject(SwUpdate);
protected readonly available = signal(false);
constructor() {
if (this.sw.isEnabled) {
this.sw.versionUpdates
.pipe(filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'))
.subscribe(() => this.available.set(true));
}
}
async apply() {
await this.sw.activateUpdate();
document.location.reload();
}
dismiss() { this.available.set(false); }
}PWA complète : shell pré-caché, API produits cachées 1h, panier persistant IDB, commandes différées via Background Sync, prompt de mise à jour non-intrusif. Lighthouse PWA 100, fonctionne offline.
🔁 Quand utiliser / éviter
Utiliser une PWA quand :
- mobile-first avec audience en zones de mauvaise couverture ;
- besoin d'installation home-screen pour fidélisation ;
- application beaucoup utilisée (retour rapide grâce au cache) ;
- contenu lisible offline (lecture seule des dernières données).
Éviter quand :
- application interne très dynamique (toujours connectée) ;
- contraintes strictes de fraîcheur (trading, monitoring temps réel) ;
- pas de HTTPS (impossible) ;
- équipe qui ne maîtrise pas le flow d'update (risque de blocage utilisateur).
🔬 Debugging du Service Worker
Les pièges sont nombreux et opaques. Chrome DevTools propose un onglet dédié.
Application > Service Workers
- Status :
activated and is runningest l'état attendu. - Update on reload : à cocher en dev, force le SW à se mettre à jour à chaque F5. Sinon on garde l'ancien indéfiniment.
- Bypass for network : désactive le SW pour ce tab, utile pour comparer avec/sans.
- Unregister : repart de zéro.
Application > Cache Storage
Les caches Angular sont préfixés par ngsw: :
ngsw:db:control— état interne (versions, manifest)ngsw:1:assets:app:cache— assets prefetchngsw:1:assets:assets:cache— assets lazyngsw:1:data:api-products:cache— dataGroupngsw:1:data:api-products:age— métadonnées d'âge
Le chiffre (:1:) augmente à chaque nouvelle version du SW. Le SW conserve l'ancienne version le temps que tous les onglets soient fermés, puis purge.
Diagnostic via ngsw/state
L'URL spéciale /ngsw/state retourne du texte décrivant l'état du SW : version active, manifest hash, ressources cachées. Précieux pour diagnostiquer en prod sans DevTools.
curl -s https://monsite.com/ngsw/state
# NGSW Debug Info:
# Driver state: NORMAL
# Latest manifest hash: a1b2c3...
# Last update check: 12:34:56
# ...Désactivation d'urgence (kill switch)
Si une release casse tout (cache pourri, SW infini sur une route morte), il faut un kill switch : un endpoint qui sert un ngsw.json avec {} vide, ce qui force le SW à se désenregistrer.
# Procédure d'urgence (à documenter dans le runbook ops)
1. Déployer un index.html qui contient :
<script>
navigator.serviceWorker.getRegistrations().then(regs =>
regs.forEach(r => r.unregister())
);
</script>
2. Forcer le cache HTML à expirer (no-store).
3. Communication user : « videz votre cache navigateur ».📊 Observabilité en production — voir ce que le SW fait chez l'utilisateur
Le piège mental d'une PWA : le SW tourne sur l'appareil de l'utilisateur, pas sur votre serveur. Vos logs serveur ne voient PAS les requêtes servies depuis le cache. Un utilisateur peut tourner sur une version vieille de 3 semaines sans qu'aucune métrique serveur ne le signale. Un staff engineer instrumente donc le SW côté client et remonte les signaux.
Métriques à remonter (vers un endpoint de télémétrie, sampling inclus pour ne pas se DDoS soi-même) :
// telemetry.service.ts — à brancher dans UpdateService.init()
this.swUpdate.versionUpdates.subscribe((evt) => {
switch (evt.type) {
case 'VERSION_DETECTED': this.track('sw_version_detected', { hash: evt.version.hash }); break;
case 'VERSION_READY': this.track('sw_version_ready'); break; // téléchargée, prête
case 'VERSION_INSTALLATION_FAILED':
this.track('sw_install_failed', { error: evt.error }); break; // download cassé / réseau
}
});
this.swUpdate.unrecoverable.subscribe((e) => this.track('sw_unrecoverable', { reason: e.reason }));Le KPI qui compte vraiment : la distribution des versions actives. Ajoutez le manifest hash (lisible via /ngsw/state ou exposé par votre app) en dimension de chaque event analytics. Vous obtenez une courbe « % d'utilisateurs sur la dernière version » — c'est l'équivalent client de l'adoption de release. Si la courbe stagne après un déploiement, vous avez un problème de cache ngsw.json (voir Exercice 3).
| Signal | Ce qu'il révèle | Alerte si |
|---|---|---|
sw_unrecoverable | Cache corrompu chez des users réels | Pic après un déploiement → rollback |
Taux VERSION_READY / sessions | Vitesse de propagation d'une release | Trop bas 24h après deploy → cache CDN |
Distribution manifest hash | Fragmentation des versions en prod | Queue longue sur vieilles versions |
Erreurs JS dont stack contient ngsw-worker.js | Bug dans le SW lui-même | Tout non-zéro |
Sentry/observabilité : le SW étant un worker, ses erreurs ne remontent pas dans le window.onerror de la page. Capturez-les via les events SwUpdate/unrecoverable ci-dessus, pas en espérant qu'un SDK front les voie automatiquement.
🛡️ Sécurité et permissions
Une PWA accède à des APIs sensibles : push notifications, géolocalisation, background sync, IndexedDB. Quelques règles :
- HTTPS obligatoire — le SW ne s'enregistre pas sur HTTP (sauf
localhost). - Permission prompts au bon moment — ne jamais demander la permission notification au load. Attendre une action explicite (clic sur « Activer les notifs »).
- VAPID — toujours signer les push avec une paire VAPID. Les push sans signature sont bloqués.
- Scope du SW — un SW à
/admin/sw.jsne contrôle que/admin/*. Bien placer le SW. - Cookies HttpOnly — un SW peut intercepter les requêtes mais ne peut pas lire les cookies
HttpOnly(bonne pratique pour les jetons d'auth). - CSP —
worker-src 'self'doit être autorisé dans la CSP pour que le SW se charge.
🌍 Background Fetch et Periodic Sync (APIs avancées)
Au-delà de SwUpdate / SwPush, certains navigateurs (principalement Chrome/Edge) exposent des APIs avancées que @angular/service-worker ne wrap pas, mais qu'on peut utiliser en mode hybride (SW custom complémentaire ou Workbox).
// Background fetch — télécharger un gros fichier en arrière-plan
async function downloadLargeFile() {
const reg = await navigator.serviceWorker.ready;
await (reg as any).backgroundFetch.fetch(
'download-report-2026',
['/api/reports/2026/full.pdf'],
{ title: 'Téléchargement du rapport 2026', icons: [{ src: '/icon.png', sizes: '192x192' }] },
);
}
// Periodic Sync — rafraîchir périodiquement (Chrome only, app installée)
async function registerSync() {
const reg = await navigator.serviceWorker.ready;
const status = await navigator.permissions.query({ name: 'periodic-background-sync' as any });
if (status.state === 'granted') {
await (reg as any).periodicSync.register('refresh-feed', { minInterval: 24 * 60 * 60 * 1000 });
}
}Ces APIs ne fonctionnent que si l'app est installée et que l'utilisateur a accordé la permission. Sur iOS Safari, beaucoup ne sont pas supportées (PWAs sur iOS restent limitées).
🤖 PWA + UI d'agent IA — streaming offline-aware, file de prompts et Stop bout-en-bout
C'est l'angle que la plupart des tutos PWA ratent : un chat IA dans une PWA n'est pas un chat IA dans un onglet. La connexion peut tomber en plein stream, l'utilisateur peut fermer l'app installée, et le SW peut intercepter (et casser) le flux SSE si on ne l'exclut pas. Voici comment un staff engineer raisonne dessus.
1. Le Service Worker NE DOIT PAS toucher au stream LLM
Premier réflexe : un endpoint de streaming (/api/agent/stream, SSE ou fetch chunké) ne doit jamais passer par un dataGroup. Le SW Angular tamponne et cache les réponses GET ; sur un flux text/event-stream cela peut bufferiser tout le corps avant de le rendre (latence catastrophique) ou servir un flux mort depuis le cache. On l'exclut explicitement.
La bonne façon de « bypasser » le SW n'est pas de déclarer un dataGroup avec maxAge: 0 (la grammaire de durée ngsw n'a même pas d'unité u — elle accepte d/h/m/s/u=ms, mais un maxAge nul ne désactive pas l'interception, il force juste un refresh permanent tout en gardant le SW dans le chemin). Le SW Angular n'intercepte un fetch que s'il matche un assetGroup ou un dataGroup ; un endpoint qui ne matche aucun des deux passe directement au réseau. Donc : pour les flux LLM, ne déclarez simplement aucun dataGroup sur /api/agent/** et confirmez qu'ils ne sont pas absorbés par un glob trop large (/api/** en performance).
// ngsw-config.json — l'agentique ne matche AUCUN group → bypass natif du SW
{
"dataGroups": [
{
"name": "api-public",
"urls": ["/api/produits/**"], // scope étroit : on ne capture jamais /api/agent/**
"cacheConfig": { "strategy": "performance", "maxSize": 200, "maxAge": "1h", "timeout": "3s" }
}
// PAS de group sur /api/agent/** ni /api/chat/** : ils vont droit au réseau
],
"navigationUrls": ["/**", "!/**/*.*", "!/api/**"]
}Mental model : assets et data "froides" = job du SW. Stream LLM, WebSocket, SSE = bypass total. Le SW ne touche que ce qui matche explicitement un group — la règle est donc « ne le déclare pas », pas « déclare-le avec un TTL zéro ». Si vous voyez un stream qui n'arrive qu'à la fin d'un bloc, suspectez le SW avant le serveur (testez avec Bypass for network coché dans DevTools).
2. Streaming token sous zoneless — buffer append-only + coalescing rAF
Sous zoneless (Angular 18-20), aucune CD automatique : on écrit dans un signal, et on coalesce les tokens par frame pour ne pas re-rendre à chaque chunk (un LLM émet 50-100 tokens/s — re-rendre 100×/s tue le main thread sur mobile bas de gamme, exactement la cible d'une PWA).
// agent-stream.service.ts
import { Injectable, NgZone, signal, inject, DestroyRef } from '@angular/core';
type Chunk = { type: 'text'; delta: string }
| { type: 'tool'; name: string; status: 'running' | 'done' };
@Injectable({ providedIn: 'root' })
export class AgentStreamService {
private readonly destroyRef = inject(DestroyRef);
readonly draft = signal(''); // buffer append-only de la réponse en cours
readonly streaming = signal(false);
private controller: AbortController | null = null;
private pending = ''; // tampon inter-frame
private rafId = 0;
async send(prompt: string, history: { role: string; content: string }[]) {
this.stop(); // une seule génération à la fois
this.controller = new AbortController();
this.streaming.set(true);
this.draft.set('');
const generationId = crypto.randomUUID(); // idempotency key client → serveur
try {
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': generationId },
body: JSON.stringify({ prompt, history }),
signal: this.controller.signal,
});
if (!res.body) throw new Error('no stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
this.enqueue(decoder.decode(value, { stream: true }));
}
} catch (e) {
if ((e as Error).name !== 'AbortError') throw e; // Stop = comportement normal
} finally {
this.flush();
this.streaming.set(false);
this.controller = null;
}
}
stop() {
this.controller?.abort(); // annule le fetch ET, via req.on('close'), le LLM serveur
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
// Coalescing : on accumule, on applique une fois par frame
private enqueue(delta: string) {
this.pending += delta;
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => { this.flush(); this.rafId = 0; });
}
}
private flush() {
if (!this.pending) return;
const delta = this.pending;
this.pending = '';
this.draft.update((s) => s + delta); // append-only → pas de re-diff du passé
}
}Points seniors : le signal draft est append-only (on ne reconstruit jamais l'historique complet à chaque token), le requestAnimationFrame borne les writes à ~60/s même si le réseau pousse 200 chunks/s, et AbortError est traité comme un flux nominal — c'est la signature d'un Stop propre, pas une erreur à logger.
3. Le Stop doit annuler le LLM serveur (sinon il est cosmétique)
Un AbortController côté client ferme la socket. Côté NestJS, on doit écouter cette fermeture et propager l'abort jusqu'au SDK, sinon la génération continue de tourner et de coûter des tokens même après que l'utilisateur a abandonné.
// nest — agent.controller.ts (DI'd client, pas `new Anthropic()` dans un champ)
import { Controller, Post, Body, Req, Res, Headers, Inject } from '@nestjs/common';
import type { Request, Response } from 'express';
import Anthropic from '@anthropic-ai/sdk';
@Controller('api/agent')
export class AgentController {
constructor(@Inject('ANTHROPIC') private readonly client: Anthropic) {}
@Post('stream')
async stream(
@Body() dto: { prompt: string; history: { role: 'user' | 'assistant'; content: string }[] },
@Headers('idempotency-key') genId: string,
@Req() req: Request,
@Res() res: Response,
) {
const ac = new AbortController();
req.on('close', () => ac.abort()); // déconnexion client → on coupe le LLM
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform'); // no-transform = empêche tout proxy/SW de bufferiser
res.setHeader('X-Accel-Buffering', 'no'); // désactive le buffering nginx
const stream = this.client.messages.stream(
{
model: 'claude-sonnet-4-6', // flagship: claude-opus-4-8 ; éco: claude-haiku-4-5
max_tokens: 1024,
messages: [...dto.history, { role: 'user', content: dto.prompt }],
},
{ signal: ac.signal }, // propagation de l'annulation jusqu'au SDK
);
stream.on('text', (delta) => res.write(delta));
try {
await stream.finalMessage();
} catch (e) {
if ((e as Error).name !== 'AbortError') res.write(`event: error\ndata: ${(e as Error).message}\n\n`);
} finally {
res.end();
}
}
}Le provider DI (à enregistrer via forRootAsync, jamais en dur) :
// anthropic.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
@Module({
providers: [{
provide: 'ANTHROPIC',
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Anthropic({ apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'), maxRetries: 3 }),
}],
exports: ['ANTHROPIC'],
})
export class AnthropicModule {}4. File de prompts offline + rejeu (le pattern qui distingue une vraie PWA)
L'utilisateur tape un prompt dans le métro, sans réseau. Au lieu d'échouer, on met le prompt en file dans IndexedDB (comme les commandes du scénario e-commerce) et on le rejoue dès online — avec l'idempotency-key qui garantit que le serveur ne génère pas deux fois si le rejeu et l'envoi initial se croisent.
// offline-prompt-queue.ts
import { openDB } from 'idb';
const dbP = openDB('agent', 1, { upgrade: (db) => db.createObjectStore('queue', { keyPath: 'id' }) });
export async function enqueuePrompt(prompt: string) {
const id = crypto.randomUUID(); // sert AUSSI d'idempotency-key
await (await dbP).put('queue', { id, prompt, createdAt: Date.now() });
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await (reg as any).sync.register(`prompt-${id}`); // rejeu via Background Sync au retour réseau
}
return id;
}
export async function drainQueue(send: (id: string, prompt: string) => Promise<void>) {
const db = await dbP;
for (const item of await db.getAll('queue')) {
try { await send(item.id, item.prompt); await db.delete('queue', item.id); }
catch { /* on garde en file, Background Sync ré-essaiera */ }
}
}Côté NestJS, l'idempotency-key est keyée en Redis (TTL court) : si une seconde requête arrive avec la même clé, on renvoie le résultat déjà calculé (ou un 409 si encore en cours) au lieu de relancer une génération payante.
5. Pièges spécifiques agent IA + PWA
| Piège | Symptôme | Fix |
|---|---|---|
| Stream mis en cache par le SW | Tokens arrivent tous d'un coup à la fin | Exclure /api/agent/** des dataGroups, no-transform côté serveur |
| Stop client mais pas serveur | Coût tokens continue après abandon | req.on('close') → AbortController.abort() + signal au SDK |
| Re-render par token sous zoneless | Jank/freeze sur mobile bas de gamme | Coalescing requestAnimationFrame, buffer append-only |
| Markdown non sanitizé | XSS via réponse LLM | DomSanitizer + marked ; jamais innerHTML brut sur la sortie modèle |
| Double génération au rejeu offline | Coût ×2, réponses dupliquées | idempotency-key (UUID) keyée Redis côté serveur |
EventSource ne supporte pas POST | Impossible d'envoyer un gros historique | fetch + getReader() (montré ci-dessus), pas EventSource |
Pour le rendu Markdown streamé en sécurité (la sortie LLM est du contenu non fiable) :
import { inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { marked } from 'marked';
const sanitizer = inject(DomSanitizer);
const safeHtml = sanitizer.sanitize(1 /* SecurityContext.HTML */, await marked.parse(draft())) ?? '';🏋️ Exercices
Stack : Angular 18-20, standalone, zoneless,
@angular/service-worker. Chaque exercice se vérifie en build prod (ng buildpuis serve statique) + DevTools > Application + Lighthouse.ng servene build PAS le SW : tout test PWA passe par un build.
Exercice 1 — Prouver l'offline bout-en-bout (implement)
Objectif : une app qui démarre, navigue et affiche des données cachées sans aucun réseau, prouvé par un test Playwright.
Configurez assetGroups (shell en prefetch) + un dataGroup performance sur /api/produits/**, buildez, servez en statique. Écrivez un test Playwright qui : charge la page, attend navigator.serviceWorker.ready, fait context.setOffline(true), reload, et vérifie que le titre ET au moins un produit (depuis le cache data) sont visibles.
Indice/Solution
Le piège classique : le test passe parce que le navigateur a son propre HTTP cache, pas le SW. Pour isoler le SW, videz le HTTP cache (context neuf) et vérifiez dans Cache Storage que ngsw:1:data:api-produits:cache contient bien la réponse. Si le produit n'apparaît pas offline alors que le shell oui : votre dataGroup n'a jamais été peuplé (l'URL n'a pas matché le glob, ou la requête portait un Authorization header que vous filtrez). Vérifiez via curl -s /ngsw/state.
Exercice 2 — Update flow non-destructif avec rollback (production-grade)
Objectif : un prompt de mise à jour qui n'interrompt jamais un formulaire en cours, vérifie périodiquement, et gère le cas unrecoverable.
Implémentez le UpdateService du fichier, mais ajoutez : (a) ne PAS prompter si un <form> est dirty (différer jusqu'au submit/reset), (b) checkForUpdate() toutes les 6h après appRef.isStable, (c) un handler unrecoverable qui log + reload propre. Déployez une v2 et prouvez que l'ancien onglet propose l'update sans casser la saisie.
Indice/Solution
isStable n'émet true qu'une fois les tâches asynchrones drainées — sous zoneless il faut provideZonelessChangeDetection() et isStable reste valide. Pour le "form dirty", exposez un signal global hasUnsavedWork que les composants set ; le service ne prompt que si false. Le test de non-régression décisif : tapez dans un input, déployez la v2, attendez le prompt, vérifiez que l'input garde sa valeur tant que vous n'avez pas cliqué Actualiser (qui, lui, reload()).
Exercice 3 — Casser le cache, puis le kill-switch (break it, then fix it)
Objectif : reproduire un SW "bloqué sur une version morte", puis le désamorcer sans demander à l'utilisateur de vider son cache.
Déployez une v1, laissez le SW s'installer. Déployez une v2 avec ngsw.json servi avec un Cache-Control: max-age=31536000 (l'erreur de prod classique). Constatez que les utilisateurs ne reçoivent jamais la mise à jour. Puis appliquez le kill-switch : servez un index.html qui unregister() tous les SW + ngsw.json en no-store.
Indice/Solution
Cause racine : si le CDN cache ngsw.json longtemps, checkForUpdate() télécharge l'ancien manifest → le SW croit être à jour. Règle d'or : ngsw.json, ngsw-worker.js et index.html doivent être en no-cache/no-store ; seuls les assets hashés sont immutable, max-age=31536000. Le kill-switch (script getRegistrations().then(r => r.forEach(x => x.unregister()))) est le runbook ops à garder sous la main. Vérifiez la purge dans Cache Storage : les buckets ngsw:* disparaissent.
Exercice 4 — dataGroup qui fuite des données privées (break it, then fix it)
Objectif : démontrer une fuite de confidentialité via cache partagé, puis la corriger.
Mettez urls: ['/api/**'] en strategy: performance. Connectez-vous en tant qu'utilisateur A (le panier/profil de A est caché), déconnectez-vous, connectez-vous en tant que B sur le même device. Observez que B voit des données de A servies depuis le cache. Corrigez.
Indice/Solution
Le SW cache par URL, sans notion de session : /api/me est la même clé pour A et B. Fixes : (1) ne JAMAIS cacher les endpoints user-spécifiques en performance — utilisez freshness avec timeout court, ou pas de cache du tout ; (2) purgez les caches ngsw:*:data:* au logout (caches.keys() puis caches.delete()) ; (3) côté serveur, Cache-Control: private, no-store sur les réponses sensibles. Le test : après logout + login B, Cache Storage ne doit contenir aucune donnée de A.
Exercice 5 — Chat IA PWA avec Stop bout-en-bout et file offline (architect)
Objectif : streamer une réponse LLM token-par-token dans la PWA, avec un Stop qui annule le serveur, et une file de prompts qui rejoue à la reconnexion sans double génération.
Branchez AgentStreamService (coalescing rAF, buffer append-only) sur un endpoint NestJS qui propage l'AbortController jusqu'à client.messages.stream(…, { signal }). Excluez /api/agent/** du SW. Ajoutez la file IndexedDB + Background Sync avec idempotency-key. Prouvez les trois propriétés : (a) Stop coupe le LLM serveur (logs), (b) offline → online rejoue le prompt, (c) le rejoue ne génère pas deux fois.
Indice/Solution
Test (a) : appuyez sur Stop à mi-génération, vérifiez dans les logs NestJS que le for await/stream.on lève AbortError et que message_stop n'arrive jamais. Test (b) : context.setOffline(true), envoyez un prompt → il doit aller en IndexedDB (agent/queue), repassez online → Background Sync (ou listener online) draine la file. Test (c) : forcez une race (rejeu + envoi initial) avec la même idempotency-key ; côté serveur, Redis SET key NX doit renvoyer le résultat existant au lieu de relancer. Piège PWA : si vous laissez le stream passer par un dataGroup, le SW le bufferise → les tokens arrivent tous d'un coup, le Stop semble ne rien faire. Excluez-le d'abord.
🎤 En entretien
Q : Pourquoi ne jamais appeler activateUpdate() + reload() automatiquement sur VERSION_READY ? R : Parce que l'utilisateur peut être en train de saisir un formulaire — un reload silencieux détruit son travail. Le contrat PWA correct est prompter, jamais forcer : on propose la mise à jour, et c'est un clic explicite qui déclenche activateUpdate() puis reload(). La seule exception légitime est une faille de sécurité critique, et même là on coupe l'app proprement plutôt que de recharger au milieu d'une frappe.
Q : Un utilisateur ne reçoit jamais les nouvelles versions de la PWA — quels sont les deux suspects principaux ? R : (1) ngsw.json mis en cache long par le CDN/hébergeur → checkForUpdate() relit l'ancien manifest et croit être à jour ; il faut no-cache/no-store sur ngsw.json, ngsw-worker.js et index.html, seuls les assets hashés étant immutable. (2) outputHashing: none désactivé → le SW ne détecte plus les changements de fichiers car les noms ne bougent pas. Diagnostic prod sans DevTools : curl /ngsw/state pour comparer le manifest hash actif au déployé.
Q : performance vs freshness dans un dataGroup — comment choisir, et quel est le risque sécurité ? R : performance = cache-first (rapide, peut servir du stale) pour des données publiques peu volatiles (catalogue, contenu éditorial) ; freshness = network-first avec timeout puis fallback cache pour des données qui doivent être à jour. Le risque : cacher en performance des endpoints user-spécifiques (/api/me, panier) → le SW cache par URL sans notion de session, donc l'utilisateur B peut voir les données de A sur un device partagé. Règle : données privées en freshness court ou non cachées, + purge des caches au logout, + Cache-Control: private côté serveur.
Q : Vous streamez un LLM dans une PWA et les tokens arrivent tous d'un bloc à la fin. Cause ? R : Le Service Worker (ou un proxy nginx) bufferise le flux text/event-stream. Le SW Angular tamponne les réponses qu'il croit pouvoir cacher ; un dataGroup mal scopé sur /api/agent/** capture le stream. Fix : exclure l'agentique du SW, Cache-Control: no-transform + X-Accel-Buffering: no côté serveur. Bonus senior : sous zoneless, re-rendre à chaque token tue le main thread — on coalesce les writes du signal par requestAnimationFrame et on garde un buffer append-only.
🔗 Liens
angular.dev/ecosystem/service-workersweb.dev/learn/pwa- Workbox (Google) — librairie complémentaire pour cas avancés
developer.mozilla.org/en-US/docs/Web/Progressive_web_apps- PWA Builder (Microsoft) — pour packager une PWA en app store
- Article « Angular Service Worker in Depth » — par Maxim Salnikov
web.dev/articles/install-criteria— critères d'installabilité- Compatibilité APIs :
developer.mozilla.org/en-US/docs/Web/API/Background_Fetch_API