Angular Service Worker ile Offline Çalışan Uygulama: ngsw-config, dataGroups, SwUpdate ve Güvenli Deploy
Angular Service Worker uygulama dosyalarını assetGroups ile, API cevaplarını dataGroups ile cache'ler ve sürümü build'in ürettiği hash'li ngsw.json'dan anlar; yeni sürümü SwUpdate.versionUpdates ile haber verip kullanıcıya tam yenileme yaptırırsın. Production build'de offline senaryoyu, VERSION_READY'yi, eski dosya silinince gelen unrecoverable durumunu, deploy cache header'larını ve safety-worker.js ile worker'ı geri çekmeyi Chromium ve WebKit'te gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), @angular/service-worker 22.2.6, @angular/pwa 22.2.7, Node.js 24 ve Playwright 1.65 ile denenmiştir; tarayıcı çıktıları Playwright'ın Chromium 158 ve WebKit 27.2 build'lerinden, production build üzerinde alınmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisine service worker eklendi ve ngsw-config.json'da katalog API'si bir dataGroups girdisine kondu:
{
"name": "catalog",
"urls": ["/api/books", "/api/books/*"],
"cacheConfig": {
"strategy": "freshness",
"maxSize": 100,
"maxAge": "1d",
"timeout": "3s"
}
}
Kullanıcı uygulamayı online açıyor, kataloğa ve Dune'un sayfasına bakıyor, "My orders" sayfasını açıyor. Sonra sunucu kapanıyor. Kullanıcı sırayla kataloğu, Dune'u, hiç açmadığı Emma'yı ve "My orders"ı açıyor. Hangileri çalışır?
Bir önceki yazıda service worker'ı elle yazmış, sürüm sabitini, cache adlarını ve stratejileri kendimiz yönetmiştik. Angular Service Worker, yani NGSW (Angular'ın hazır service worker'ı ve @angular/service-worker paketinin kısaltması), aynı işleri bir build adımı ve bir configuration dosyasıyla yapıyor. Bu yazıda ng add @angular/pwa'nın ne eklediğini, assetGroups ve dataGroups'u, hash tabanlı ngsw.json'u, SwUpdate ile kullanıcıya yeni sürümü haber vermeyi, kurtarılamayan (unrecoverable) durumu ve safety-worker.js ile worker'ı geri çekmeyi gerçek bir production build üzerinde göreceğiz.
ng add @angular/pwa Ne Ekliyor?
npx ng add @angular/pwa --skip-confirmation
Komut @angular/service-worker'ı bağımlılıklara ekledi, ngsw-config.json, public/manifest.webmanifest ve sekiz icon dosyası oluşturdu, index.html'e manifest linkini koydu ve angular.json'ın build seçeneklerine "serviceWorker": "ngsw-config.json" yazdı. Uygulama tarafındaki değişiklik app.config.ts'te:
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};
İki ayrıntı önemli. enabled: !isDevMode() yüzünden worker ng serve'de hiç kaydedilmiyor; denemek için production build alıp gerçek bir HTTP sunucusundan sunman gerekiyor. Bookstore'un sahte API'si bunu --app parametresiyle yapıyor (npm run serve:app), böylece build ve /api aynı origin'de. registerWhenStable:30000 ise kaydı uygulama stable olana kadar, en fazla 30 saniye erteliyor: worker'ın bütün dosyaları indirmesi ilk açılışın ağ trafiğiyle yarışmasın diye. Manifest ve icon'lar kurulabilir PWA'nın (Progressive Web App; tarayıcıdan kurulabilen web uygulaması) konusu; bir sonraki yazıda onlara döneceğiz.
assetGroups ve Hash Tabanlı ngsw.json
ng add'in ürettiği assetGroups uygulamanın kendi dosyalarını iki gruba ayırıyor: app grubu (index.html, bütün .js ve .css) installMode: "prefetch" ile worker kurulurken baştan indiriliyor; assets grubu (görseller, font'lar) lazy, yani yalnız istendiğinde cache'e giriyor. ng build bu configuration'ı okuyup dist içine ngsw.json adında bir manifest yazıyor. Kısaltılmış hâli:
{
"configVersion": 1,
"timestamp": 1794684569894,
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"urls": [
"/chunk-5Bq5Z2tF.js",
"/chunk-C872WQBo.js",
"/chunk-C9P7jqt2.js",
"..."
]
}
],
"dataGroups": [
{
"name": "catalog",
"patterns": [
"\\/api\\/books",
"\\/api\\/books\\/[^/]*"
],
"strategy": "freshness",
"maxSize": 100,
"maxAge": 86400000,
"timeoutMs": 3000,
"cacheQueryOptions": {
"ignoreVary": true
},
"version": 1
}
],
"hashTable": {
"/chunk-5Bq5Z2tF.js": "f1f1fff3cf912f59e2bed0fc6cf56851328eec67",
"/chunk-C872WQBo.js": "9b8e0ebfb2989071c5bf15e8587b29001e20b348",
"/chunk-C9P7jqt2.js": "cab04d1624d28d99cee6fcd718e983b1452b26d8",
"...": "..."
}
}
Bir önceki yazıdaki VERSION sabitinin yerini bu dosya alıyor. Her dosyanın içerik hash'i (SHA-1) hashTable'da; worker sunucudaki ngsw.json'u kendi bildiğiyle karşılaştırıyor, fark varsa yeni bir sürüm olarak indiriyor ve indirdiği her dosyanın hash'ini doğruluyor. Glob'lar build sırasında gerçek dosya adlarına, dataGroups'taki URL'ler regex'lere çevrilmiş. Dikkat çeken bir varsayılan var: Angular cacheQueryOptions'a kendiliğinden ignoreVary: true ekliyor. Bir önceki yazıda Vary: Authorization'ın kişiye özel cevapları ayırdığını görmüştük; NGSW'nin cache'inde bu header dikkate alınmıyor. Kişiye özel endpoint'ler bu yüzden hiçbir dataGroups girdisine girmemeli.
dataGroups: freshness ve performance
dataGroups API cevapları için. Yalnız GET ve HEAD istekleri cache'leniyor; iki strateji var. performance cache'te cevap varsa ağa hiç gitmiyor (bayatlığı maxAge sınırlıyor) ve nadiren değişen veriye uyuyor. freshness önce ağa gidiyor, timeout içinde cevap gelmezse ya da ağ yoksa cache'tekini veriyor; fiyat ve stok gibi değişen veri için doğru seçim. Angular dokümanına göre freshness ile timeout: "0u" stale-while-revalidate'e yakın bir davranış veriyor. maxSize girdi sayısını sınırlıyor; sınırsız bir cache kotayı doldurup bütün origin'in silinmesine yol açabilir. Önce Tahmin Et senaryosunu Playwright'la oynattım; ilk ziyaret ve NGSW'nin /ngsw/state debug sayfası:
--- 1. first visit (v1)
heading "Catalog", controlled by /ngsw-worker.js
orders online: Orders: 1051
NGSW Debug Info:
Driver version: 22.2.6
Driver state: NORMAL ((nominal))
Latest manifest hash: e4bedf302455fd5e2ba00d18eeab034eec99f1d8
Last update check: 123u
=== Version e4bedf302455fd5e2ba00d18eeab034eec99f1d8 ===
Clients: 864390d5-ffeb-4505-80c6-233de942c580, 67ba2ab3-eda9-4567-90d6-ca0c0983fe65, 413c9794-7513-4920-ac1a-e347fb48e970
=== Idle Task Queue ===
Sunucu kapandıktan sonra:
--- 2. server stopped
/: Catalog | Dune: TRY450.00
/books/9780441172719: Dune | Frank Herbert, TRY450.00
/books/9780141439587: This book could not be loaded. | Reading list
/orders: My orders | Your orders need a connection. Please try again when you are online.
Katalog ve Dune çalıştı: uygulama dosyaları app grubundan, API cevapları catalog data group'unun cache'inden geldi. Emma çalışmadı, çünkü dataGroups yalnız gerçekten yapılmış isteklerin cevabını saklıyor; açılmamış bir kitabın detayı cache'te yok. "My orders" bilerek çalışmadı: /api/me/orders hiçbir data group'ta değil, istek worker'dan doğrudan ağa gidiyor ve ağ yokken sayfa kendi hata mesajını gösteriyor. Sipariş ve ödeme verisinin bayat bir kopyasını göstermek, hiç göstermemekten daha kötü. Offline'da gösterilmesini istediğin detay sayfaları varsa onları önceden istemen gerekir; örneğin kullanıcının okuma listesindeki kitapları uygulama açılışında bir kez yükleyerek.
Data group isteklerinin tarayıcının HTTP cache'inden geçtiğini de unutma. NGSW'nin kaynağında data group isteği, sayfanın isteğiyle aynı cache ayarlarıyla gönderiliyor; API max-age veriyorsa freshness'ın "ağdan" aldığı cevap da bir önceki yazıda gördüğümüz gibi HTTP cache'ten gelebilir.
navigationUrls: Hangi İstek index.html Alır?
Angular bir SPA (single-page application; sayfa geçişlerini tarayıcıda yapan tek sayfalık uygulama) olduğu için /books/9780441172719 gibi bir adres sunucuda bir dosya değil; NGSW navigasyon isteklerine cache'teki index.html'i veriyor. Hangi URL'lerin navigasyon sayılacağını navigationUrls belirliyor. Varsayılanı build çıktısında: bütün URL'ler dahil, son parçasında nokta olan (dosya adı gibi görünen) ve __ içerenler hariç. Sunucunun kendisinin üretmesi gereken sayfalar varsa (ör. /admin altında server-side bir ekran ya da bir ödeme sağlayıcısının dönüş adresi) onları "!/admin/**" gibi bir kuralla dışarıda bırakmalısın; yoksa worker o adrese de Angular'ın index.html'ini döner.
navigationRequestStrategy varsayılan olarak performance: navigasyon cache'teki index.html ile cevaplanıyor ve sunucuya gitmiyor. Sunucu navigasyonları bir giriş sayfasına yönlendiriyorsa ya da uygulama SSR (sunucu tarafında render) kullanıyorsa freshness önce ağa gidiyor. SSR'lı bir uygulamada bunu bil: worker kurulduktan sonra navigasyonları cache'teki kabukla cevaplayan bir NGSW, sunucunun ürettiği HTML'i kullanıcıya hiç ulaştırmaz ve sayfa istemci tarafında render edilir. ng add'in listeye /index.csr.html'i eklemesi de bu yüzden; SSR'lı projelerde istemci tarafı kabuk o dosya.
SwUpdate ile "Yeni Sürüm Hazır" Demek
NGSW de bir önceki yazıdaki yaşam döngüsüne uyuyor ama sürümleri kendi içinde yönetiyor: yeni bir ngsw.json bulunca yeni sürümü arka planda indiriyor ve açık sekmeler eski sürümle çalışmaya devam ediyor. Sekmeye haber vermek SwUpdate'in işi:
import { ApplicationRef, inject, Service, signal } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
@Service()
export class AppUpdates {
private readonly updates = inject(SwUpdate);
readonly ready = signal(false);
readonly broken = signal('');
constructor() {
if (!this.updates.isEnabled) return; // ng serve ya da service worker'sız bir tarayıcı
this.updates.versionUpdates.subscribe((event) => {
if (event.type === 'VERSION_DETECTED') {
console.log(`update: VERSION_DETECTED ${event.version.hash.slice(0, 8)}`);
} else if (event.type === 'VERSION_READY') {
console.log(
`update: VERSION_READY ${event.currentVersion.hash.slice(0, 8)} -> ${event.latestVersion.hash.slice(0, 8)}`,
);
this.ready.set(true);
} else if (event.type === 'VERSION_INSTALLATION_FAILED') {
console.log(`update: VERSION_INSTALLATION_FAILED ${event.error}`);
} else {
console.log(`update: ${event.type}`);
}
});
// bu sekmeye hizmet eden sürüm gereken bir dosyayı kaybetti; yalnız tam yenileme çözer
this.updates.unrecoverable.subscribe((event) => {
console.log(`unrecoverable: ${event.reason}`);
this.broken.set(event.reason);
});
// service worker'ın her navigasyondaki kontrolüne ek olarak 6 saatte bir kontrol et
void inject(ApplicationRef)
.whenStable()
.then(() => setInterval(() => void this.check(), 6 * 60 * 60 * 1000));
}
async check() {
try {
console.log(`checkForUpdate: ${await this.updates.checkForUpdate()}`);
} catch (err) {
console.log(`checkForUpdate failed: ${err}`);
}
}
reload() {
document.location.reload();
}
}
Uygulamanın kabuğu ready() true olunca "A new version of Bookstore is ready" banner'ını ve bir Reload butonu gösteriyor. Heading'i değiştirip yeni bir build aldım (Angular build'i dist'i temizlediği için eski hash'li dosyalar sunucudan silindi) ve açık sayfayı yeniden açtım:
--- 3. new version
--- deployed v2: ng build, old hashed files deleted
update: VERSION_DETECTED d598b917
update: VERSION_READY e4bedf30 -> d598b917
heading "Catalog", banner: A new version of Bookstore is ready. Reload
after Reload: heading "Catalog (v2)"
Navigasyon sırasında worker yeni ngsw.json'u gördü (VERSION_DETECTED), dosyaları indirip hash'lerini doğruladı (VERSION_READY), ama sayfa hâlâ eski heading'i gösteriyor: bu sekme eski sürüme bağlı. Reload'dan sonra yeni sürüm geldi. NGSW bu kontrolü kendiliğinden her navigasyonda yapıyor; checkForUpdate() ise sayfayı günlerce açık bırakan kullanıcılar için. Angular'ın service worker rehberindeki örnek de bu kontrolü uygulama stable olduktan sonra, altı saatte bir başlatıyor. Sebebi zone.js'li uygulamalar: açılışta başlayan bir setInterval böyle bir uygulamanın hiç stable olmamasına ve registerWhenStable yüzünden worker'ın 30 saniye dolana kadar kaydedilmemesine yol açar. Bookstore zoneless (Angular 21'den beri varsayılan) olduğu için bu risk yok, ama whenStable()'ı beklemek iki durumda da doğru.
activateUpdate()'i sayfayı yenilemeden kullanma. Angular'ın kendi dokümantasyonu da uyarıyor: yeni sürümü yenilemeden etkinleştirmek, eski main.js'in yeni sürümün lazy chunk'ını yüklemesine yol açar; bir önceki yazıdaki "page v2 + chunk v3" karışımının aynısı. Kullanıcıya bir buton göster ve yenilemeyi o yapsın.
unrecoverable: Sürümün Bir Dosyası Kaybolunca
Bir sekme hâlâ eski sürümle çalışırken o sürümün bir dosyası hem worker'ın cache'inde hem sunucuda yoksa ne olur? Tarayıcı disk baskısında cache'i kısmen silebilir, yeni deploy da eski hash'li dosyaları kaldırmıştır. Bunu taklit etmek için v3'ü deploy ettim, sonra açık sekmenin kullandığı sürümün cache'inden lazy chunk'ları sildim ve Chromium'un HTTP cache'ini temizledim. Ardından henüz yüklenmemiş "My orders" sayfasına geçtim:
--- 4. a file of the running version disappears
--- deployed v3: ng build, old hashed files deleted
deleted 8 cached chunks of the running version (as if the browser had evicted them)
unrecoverable: Failed to retrieve hashed resource from the server. (AssetGroup: app | URL: /chunk-9r4Qn76r.js)
banner: Bookstore must reload to keep working. Reload
after reload: heading "Catalog (v3)"
Worker chunk'ı sunucudan istedi, 404 aldı ve sayfaya unrecoverable event'ini gönderdi; kabuk "Bookstore must reload to keep working" banner'ını gösterdi. Bu durumda tek çare tam bir yenileme; yenilemeden sonra sayfa v3 ile açıldı. Kurtarılamayan durumun en sık sebebi deploy sırasında eski dosyaların hemen silinmesi. Eski sürümün hash'li dosyalarını sunucuda birkaç sürüm boyunca tutmak bu riski neredeyse ortadan kaldırıyor; isimlerinde hash olduğu için çakışmıyorlar. Aynı senaryo WebKit'te de aynı mesajla sonuçlandı.
Deploy: Cache Header'ları ve safety-worker.js
NGSW'nin doğru çalışması sunucunun header'larına bağlı. ngsw.json ve index.html HTTP cache'te bayat kalırsa worker yeni sürümü göremez; hash'li bundle'lar ise hiç değişmediği için sonsuza kadar cache'lenebilir. Sahte API'nin --app modunun verdiği header'lar:
/index.html Cache-Control: no-cache
/books/9780441172719 Cache-Control: no-cache
/ngsw.json Cache-Control: no-cache
/ngsw-worker.js Cache-Control: no-cache
/main-UW6NLQP5.js Cache-Control: public, max-age=31536000, immutable
/chunk-5Bq5Z2tF.js Cache-Control: public, max-age=31536000, immutable
Kuralın kod karşılığı küçük:
// 05: --app <klasör> (ya da APP_DIST) web/ yerine Angular production build'ini, deploy'un gerektirdiği cache header'larıyla sunar
const appArg = process.argv.indexOf("--app");
const appFolder = appArg > 0 ? process.argv[appArg + 1] : process.env.APP_DIST;
const appDist = appFolder ? `${resolve(appFolder)}/` : null;
function cacheControlFor(pathname: string) {
if (appDist && /-[\w-]{8}\.(js|css)$/.test(pathname)) return "public, max-age=31536000, immutable"; // hash'li bundle'lar hiç değişmez
// 04: service worker script'i uzun bir max-age alıyor, dikkatsiz bir "bütün .js'leri bir saat cache'le" kuralı gibi
if (pathname === "/sw/sw.js") return "max-age=3600";
return "no-cache"; // index.html, ngsw.json, ngsw-worker.js, manifest: her zaman sunucuya sor
}
Nginx ve IIS'te (Microsoft'un Windows'taki web sunucusu) aynı kuralları yazmak Web Server'lar ve Hosting serisinin "Nginx ile Angular Yayınlamak: SPA Fallback, Cache Header'ları, Compression ve /api Proxy" ve "IIS'te Production: Deploy, app_offline.htm, Kilitli Dosyalar, Application Initialization ve Angular" yazılarında. Deploy sonrası kullanıcının eski bundle'ı görmesinin teşhisi ise Browser DevTools serisinin "Application Paneli: Storage, Cookie Flag'leri, Cache Storage ve Angular Service Worker" yazısında; orada /ngsw/state sayfasının çıktısını adım adım okuyoruz.
Worker'ın kendisi bir soruna yol açarsa Angular hazır bir kill switch veriyor: dist'teki safety-worker.js'i (aynı içerik worker-basic.min.js adıyla da var) ngsw-worker.js'in yerine koymak. Bu worker install'da skipWaiting, activate'te clients.claim çağırıyor, kaydını kaldırıyor ve ngsw: ile başlayan bütün cache'leri siliyor:
--- 5. safety worker deployed as ngsw-worker.js
1 s after the reload: registered
7 s after the reload: unregistered
first reload: controller yes, registrations 0, ngsw caches 0
second reload: controller yes, registrations 0, ngsw caches 0
İki gözlem var. Birincisi, safety worker hemen devralmadı: Chromium'da 7, WebKit'te 6 saniye sonra kayıt kalktı. NGSW'nin arka plan işleri (idle task queue'su; kaynakta 5 saniye boşta bekleme, en fazla 30 saniye) sürerken eski worker'ın aktivasyon için bırakılması gerekiyor. İkincisi, kayıt ve NGSW cache'leri gitti ama sayfa hâlâ bir controller'a sahip: uygulama her açılışta provideServiceWorker ile ngsw-worker.js'i yeniden kaydediyor, sunucuda o adreste artık safety worker olduğu için o kuruluyor, sayfayı alıyor ve kendini tekrar kaldırıyor. Safety worker'ın fetch handler'ı olmadığı için istekler doğrudan ağa gidiyor, yani uygulama worker'sız gibi çalışıyor. Bu döngüyü bitirmek için bir sonraki deploy'da provideServiceWorker'ı enabled: false yap ya da kaldır, safety worker'ı ise eski kullanıcılar uğrayana kadar (birkaç hafta) sunucuda tut.
Üretimde Dikkat
Deploy'u atomik düşün: önce hash'li yeni dosyalar, sonra index.html, en son ngsw.json. ngsw.json yeni dosyalardan önce yayınlanırsa worker henüz sunucuda olmayan dosyaları indirmeye çalışır, hash doğrulaması başarısız olur ve sayfaya VERSION_INSTALLATION_FAILED gider. Eski hash'li dosyaları silme işini ayrı ve gecikmeli bir adım yap.
/ngsw/state sahadaki sorunların en hızlı teşhis aracı: driver state'i NORMAL değilse (örneğin EXISTING_CLIENTS_ONLY) worker bir hata nedeniyle yalnız mevcut sekmelere hizmet ediyor demektir. Destek ekibinin kullanıcıdan bu sayfanın çıktısını isteyebileceği bir yol bırak.
applicationMaxAge ve dataGroups'un maxAge'i farklı şeyler. İlki build zamanından itibaren sayılıyor ve aşıldığında, yeni bir sürüm olmasa bile bütün istekleri ağa gönderiyor; offline desteği o süre sonunda kapanıyor. Uzun süre deploy almayan bir uygulamada bunu vermeden önce iki kez düşün.
Sık Düşülen Tuzaklar
- Service worker'ı
ng serve'de test etmeye çalışmak.enabled: !isDevMode()yüzünden kaydedilmez; production build'i bir HTTP sunucusundan sun. - Kişiye özel ya da çabuk değişen endpoint'leri
dataGroups'a koymak. NGSWVary'i yok sayar; siparişler, sepet ve profil network-only kalmalı. ngsw.jsonya daindex.html'e uzunmax-agevermek. Worker yeni sürümü görmez; ikisi deno-cacheolmalı.- Deploy'da eski hash'li dosyaları hemen silmek. Eski sürümle çalışan sekmeler lazy chunk'ları bulamaz ve
unrecoverabledurumuna düşer. VERSION_READY'de kullanıcıya sormadanactivateUpdate()çağırmak. Eski kabuk yeni chunk'larla karışır; banner ve tam yenileme kullan.- Safety worker'ı deploy edip
provideServiceWorker'ı uygulamada bırakmak. Uygulama onu her açılışta yeniden kaydeder; zararsız ama gereksiz bir döngü.
Küçük Bir Deneme
★☆☆ catalog data group'unu performance stratejisine ve maxAge: "1m"'ye çevir. Kataloğu açtıktan sonra sahte API'deki Dune fiyatını değiştirip bir dakika içinde ve bir dakika sonra sayfayı yenile; hangi fiyatı gördüğünü ve sunucu log'unda kaç istek olduğunu karşılaştır.
★★☆ AppUpdates'e, sekme tekrar görünür olduğunda (visibilitychange) checkForUpdate() çağıran ama son kontrolün üzerinden 10 dakika geçmediyse çağırmayan bir mantık ekle. Playwright'ta sayfayı gizli/görünür yapıp kaç kontrol yapıldığını say.
★★★ Deploy sürecini taklit eden bir script yaz: yeni build'i ayrı bir klasöre al, hash'li dosyaları mevcut dist'e ekle (eskileri silmeden), sonra index.html'i ve en son ngsw.json'u kopyala. Bu yazıdaki 4. adımı tekrarla ve unrecoverable event'inin artık gelmediğini doğrula.
Bookstore artık offline açılıyor ve yeni sürümleri kullanıcıya haber veriyor, ama hâlâ bir tarayıcı sekmesi. Bir sonraki yazıda onu kurulabilir bir uygulamaya çeviriyoruz: ng add'in ürettiği manifest'in alanları, maskable icon'lar, Chromium'da beforeinstallprompt ve yeni navigator.install(), iOS'ta "Ana Ekrana Ekle" ve okunmamış siparişler için App Badging.