/** * Sözsoft Platform service worker (injectManifest) * ------------------------------------------------------------------ * Workbox runtime'ı yerine elle yazıldı; tek sebebi güncelleme sırasında * indirilen HER dosyanın adını, boyutunu ve toplam yüzdesini uygulamaya * yayınlayabilmek (güncelleme penceresindeki progress listesi). * * Kurulum iki kademelidir: * 1. KABUK (`__WB_MANIFEST`): index.html + entry JS/CSS + eager vendor * chunk'ları. Zorunludur; biri inmezse kurulum başarısız olur. * 2. BİLEŞENLER (`/warmup.json`): tüm lazy chunk'lar. Kullanıcı deploy sonrası * ekranlara girerken chunk beklemesin diye aynı ilerleme çubuğunda arka * planda indirilir. Best-effort: inemeyen dosya kurulumu düşürmez, süre * bütçesi (WARMUP_BUDGET_MS) dolarsa kalanlar runtime cache'e bırakılır. * * Boyut/performans notları: * - Tema CSS'i / görsel / font precache'e girmez, runtime cache'e alınır. * - Zaten cache'de olan dosya yeniden indirilmez (hash'li ad = aynı içerik). * - İndirme paralel havuzla yapılır; ilerleme mesajları 150 ms'de bir ve * yalnızca DEĞİŞEN dosyalar için gönderilir (500 dosyalık listeyi her tick'te * kopyalamak hem SW hem render tarafında pahalı). */ export type PrecacheEntry = { url: string; revision?: string | null } type SwEvent = Event & { waitUntil: (promise: Promise) => void respondWith: (response: Response | Promise) => void request: Request data?: { type?: string } | null source?: { postMessage: (message: unknown) => void } | null } type SwClient = { id: string; postMessage: (message: unknown) => void } declare const self: { __WB_MANIFEST: PrecacheEntry[] location: Location navigator?: { connection?: { saveData?: boolean; effectiveType?: string } } registration: { scope: string } skipWaiting: () => Promise clients: { claim: () => Promise matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise } addEventListener: (type: string, listener: (event: SwEvent) => void) => void } /* ------------------------------------------------------------------ */ /* Sabitler */ /* ------------------------------------------------------------------ */ const SHELL_CACHE = 'sozsoft-shell-v1' const ASSET_CACHE = 'sozsoft-assets-v1' const THEME_CACHE = 'sozsoft-themes-v1' const FONT_CACHE = 'sozsoft-fonts-v1' const IMAGE_CACHE = 'sozsoft-images-v1' const KNOWN_CACHES = [ SHELL_CACHE, ASSET_CACHE, THEME_CACHE, FONT_CACHE, IMAGE_CACHE, ] /** * Bir sürümün tüm chunk'ları (~500 dosya) ısıtma ile bu cache'e yazıldığı için * limit dosya sayısının belirgin üstünde olmalı; aksi halde ısıtılan bileşenler * daha kurulum biterken birbirini siler. */ const ASSET_LIMIT = 1500 const THEME_LIMIT = 12 const FONT_LIMIT = 40 const IMAGE_LIMIT = 150 /** Bileşen ısıtması için üst süre sınırı; aşılırsa kalanlar runtime cache'e kalır. */ const WARMUP_BUDGET_MS = 180_000 const SHELL_CONCURRENCY = 4 const WARMUP_CONCURRENCY = 6 const HASHED_ASSET = /^\/assets\/.+\.(?:js|css|wasm)$/ const THEME_CSS = /^\/css\/[\w.-]+\.css$/ const FONT_FILE = /\.(?:woff2?|ttf|otf|eot)$/ const IMAGE_FILE = /\.(?:png|jpe?g|svg|gif|webp|ico)$/ const NETWORK_ONLY = /^\/(?:api|connect|swagger|signalr|hangfire)\b/ const MANIFEST: PrecacheEntry[] = self.__WB_MANIFEST ?? [] const INDEX_URL = new URL('index.html', self.registration.scope).href const WARMUP_URL = new URL('warmup.json', self.registration.scope).href /** Hash'siz dosyalar için revision'ı cache anahtarına ekler. */ const cacheKeyFor = (entry: PrecacheEntry) => { const url = new URL(entry.url, self.registration.scope) if (entry.revision) url.searchParams.set('__rev', entry.revision) return url.href } const shellKeys = new Map() MANIFEST.forEach((entry) => { shellKeys.set(new URL(entry.url, self.registration.scope).href, cacheKeyFor(entry)) }) /* ------------------------------------------------------------------ */ /* Isıtma listesi (build'de üretilen warmup.json) */ /* ------------------------------------------------------------------ */ type WarmupEntry = { url: string; size: number } /** * Boyutlar derleme anında yazıldığı için ilerleme yüzdesi ilk saniyeden * itibaren doğrudur. Dosya yoksa (dev sunucusu ya da eski deploy) ısıtma * sessizce atlanır; kabuk kurulumu bundan etkilenmez. */ async function fetchWarmupList(): Promise { try { const response = await fetch( new Request(WARMUP_URL, { cache: 'reload', credentials: 'same-origin' }), ) if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) const data = (await response.json()) as { files?: WarmupEntry[] } | undefined return (data?.files ?? []).map((entry) => ({ url: new URL(entry.url, self.registration.scope).href, size: Number(entry.size) || 0, })) } catch (error) { console.warn('[sw] warmup.json okunamadı, bileşen ısıtması atlanıyor.', error) return [] } } let warmupPromise: Promise | undefined const loadWarmupList = () => (warmupPromise ??= fetchWarmupList()) /** Veri tasarrufu açıkken ya da 2G'de ~20 MB'lık ısıtma yapılmaz. */ function isWarmupAllowed() { const connection = self.navigator?.connection if (!connection) return true if (connection.saveData) return false return !/2g$/.test(connection.effectiveType ?? '') } /* ------------------------------------------------------------------ */ /* İstemciye ilerleme yayını */ /* ------------------------------------------------------------------ */ type ProgressFile = { index: number url: string name: string loaded: number total: number status: string } async function broadcast(message: Record) { // Yeni worker henüz sayfayı kontrol etmiyor; includeUncontrolled şart. const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' }) clients.forEach((client) => client.postMessage(message)) } const fileName = (url: string) => { const pathname = new URL(url).pathname return pathname.slice(pathname.lastIndexOf('/') + 1) || pathname } /* ------------------------------------------------------------------ */ /* Kurulum: ilerleme bildiren precache */ /* ------------------------------------------------------------------ */ async function downloadWithProgress( url: string, key: string, cache: Cache, file: ProgressFile, onTick: () => void, ) { const response = await fetch(new Request(url, { cache: 'reload', credentials: 'same-origin' })) if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) // Content-Length sıkıştırılmış boyuttur, okunan gövde ise açılmış hâldir; // yalnızca derleme boyutu bilinmiyorsa (kabuk dosyaları) kaba tahmin olarak // kullanılır. const declared = Number(response.headers.get('content-length') || 0) if (declared > 0 && file.total === 0) file.total = declared // Gövde akış olarak okunamıyorsa (ör. opak/derleyici farkı) progress'siz yaz. if (!response.body) { const buffer = await response.clone().arrayBuffer() file.loaded = buffer.byteLength file.total = file.total || buffer.byteLength await cache.put(key, response) return file.loaded } const reader = response.body.getReader() const chunks: Uint8Array[] = [] let loaded = 0 for (;;) { const { done, value } = await reader.read() if (done) break if (!value) continue chunks.push(value) loaded += value.byteLength file.loaded = loaded if (file.total < loaded) file.total = loaded onTick() } // Parçaları tek tampona birleştir (Blob kopyasından kaçınmak için). const body = new Uint8Array(loaded) let offset = 0 for (const chunk of chunks) { body.set(chunk, offset) offset += chunk.byteLength } // Gövde ağ katmanında çözüldüğü için sıkıştırma başlıkları taşınmamalı; // aksi halde cache'ten servis edilen dosya bozuk görünür. `vary` de silinir: // girdiyi URL anahtarıyla yazıp Request ile okuduğumuz için Vary eşleşmesi // kaydı görünmez yapabilir. const headers = new Headers(response.headers) headers.delete('content-encoding') headers.delete('content-length') headers.delete('vary') await cache.put(key, new Response(body, { status: 200, headers })) file.loaded = loaded file.total = file.total || loaded return loaded } type DownloadJob = { index: number url: string /** Cache anahtarı (kabukta revision'lı olabilir). */ key: string cache: Cache /** Başarısızlığı kurulumu düşürür mü? */ critical: boolean } /** Cache'te zaten bulunan anahtarlar; 500 ayrı `match` çağrısından çok hızlı. */ async function cachedKeys(cache: Cache) { const requests = await cache.keys() return new Set(requests.map((request) => request.url)) } /** * Kurulumda indirilecek işleri hazırlar: önce zorunlu kabuk, ardından * (izin veriliyorsa) uygulamanın tüm bileşenleri. Hash'li ad = aynı içerik * olduğu için cache'te bulunan hiçbir dosya yeniden indirilmez. */ async function planInstall() { const shellCache = await caches.open(SHELL_CACHE) const assetCache = await caches.open(ASSET_CACHE) const warmup = await loadWarmupList() const sizes = new Map(warmup.map((entry) => [entry.url, entry.size])) const jobs: DownloadJob[] = [] const files: ProgressFile[] = [] let reusedCount = 0 const push = (url: string, key: string, cache: Cache, critical: boolean) => { const index = files.length files.push({ index, url, name: fileName(url), loaded: 0, total: sizes.get(url) ?? 0, status: 'pending', }) jobs.push({ index, url, key, cache, critical }) } const cachedShell = await cachedKeys(shellCache) for (const entry of MANIFEST) { const key = cacheKeyFor(entry) if (cachedShell.has(key)) { reusedCount++ continue } push(new URL(entry.url, self.registration.scope).href, key, shellCache, true) } const shellCount = jobs.length if (isWarmupAllowed()) { const cachedAssets = await cachedKeys(assetCache) for (const entry of warmup) { // Kabuğa giren dosyalar zaten yukarıda ele alındı. if (shellKeys.has(entry.url)) continue if (cachedAssets.has(entry.url)) { reusedCount++ continue } push(entry.url, entry.url, assetCache, false) } } return { jobs, files, reusedCount, shellCount } } /** İndirme sürerken anlık ilerleme; boştayken `undefined`. */ let snapshot: (() => Record) | undefined type SessionOptions = { files: ProgressFile[] jobs: DownloadJob[] /** `jobs` içindeki ilk kaç iş zorunlu (kabuk) — kalanı best-effort ısıtma. */ criticalCount: number reusedCount: number /** Güncelleme kurulumu mu, yoksa eksik bileşenlerin tamamlanması mı? */ phase: 'install' | 'warmup' } /** * Ortak indirme oturumu: ilerleme yayınlar, zorunlu işlerde hata fırlatır, * ısıtma işlerinde hatayı yutar. Hem `install` olayı hem de sonradan çalışan * `ensureWarmup` bunu kullanır. */ async function runSession({ files, jobs, criticalCount, reusedCount, phase }: SessionOptions) { const totalBytes = files.reduce((sum, file) => sum + file.total, 0) await broadcast({ type: 'SW_PRECACHE_START', files, reusedCount, totalBytes, phase }) if (jobs.length === 0) { await broadcast({ type: 'SW_PRECACHE_DONE', files, loadedBytes: 0, totalBytes: 0, phase }) return } // İlerleme mesajları yalnızca değişen dosyaları taşır; 500 dosyalık listeyi // saniyede birkaç kez kopyalamak hem SW'yi hem render'ı gereksiz yorar. const dirty = new Set() const inFlight = new Set() let lastTick = 0 let completedBytes = 0 // Biten dosyalar + hâlâ inen dosyaların o anki baytı. İnen dosya sayısı // eşzamanlılık kadar (≤6) olduğu için toplam her tick'te ucuza hesaplanır. const loadedNow = () => { let total = completedBytes for (const index of inFlight) total += files[index].loaded return total } // Tarayıcı, worker güncellemesini sayfa gezinmesinde otomatik başlatır; bu // yüzden kurulum çoğu zaman uygulama mount olup mesaj dinleyicisini eklemeden // önce başlar ve START mesajı kaçar. Sonradan bağlanan istemci anlık durumu // buradan sorar (bkz. GET_PRECACHE_STATE). snapshot = () => ({ files, reusedCount, totalBytes, phase, loadedBytes: loadedNow() }) const flush = (force = false) => { const now = Date.now() if (dirty.size === 0) return if (!force && now - lastTick < 150) return lastTick = now const updates = Array.from(dirty, (index) => ({ ...files[index] })) dirty.clear() void broadcast({ type: 'SW_PRECACHE_PROGRESS', updates, loadedBytes: loadedNow(), totalBytes, phase, }) } const run = async (pool: DownloadJob[], concurrency: number, deadline?: number) => { let cursor = 0 const worker = async () => { for (;;) { const next = cursor++ if (next >= pool.length) return // Süre bütçesi dolduysa kalan bileşenler runtime cache'e bırakılır. if (deadline && Date.now() > deadline) return const job = pool[next] const file = files[job.index] file.status = 'downloading' inFlight.add(job.index) dirty.add(job.index) flush() try { await downloadWithProgress(job.url, job.key, job.cache, file, () => { dirty.add(job.index) flush() }) file.status = 'done' } catch (error) { file.status = 'error' console.warn('[sw] Dosya indirilemedi:', job.url, error) // Kabuk dosyası indirilemezse kurulum başarısız olmalı; yarım bir // sürümle aktive olmak beyaz ekrana yol açar. Bileşen ısıtmasında ise // dosya runtime cache'e bırakılır, kurulum devam eder. if (job.critical) { inFlight.delete(job.index) throw error } } inFlight.delete(job.index) completedBytes += file.loaded dirty.add(job.index) flush(true) } } await Promise.all(Array.from({ length: Math.min(concurrency, pool.length) }, worker)) } try { await run(jobs.slice(0, criticalCount), SHELL_CONCURRENCY) } catch (error) { await broadcast({ type: 'SW_PRECACHE_ERROR', files, phase, message: error instanceof Error ? error.message : String(error), }) throw error } const warmupJobs = jobs.slice(criticalCount) if (warmupJobs.length > 0) { await run(warmupJobs, WARMUP_CONCURRENCY, Date.now() + WARMUP_BUDGET_MS) } flush(true) await broadcast({ type: 'SW_PRECACHE_DONE', files, loadedBytes: completedBytes, totalBytes, phase, }) } async function installAssets() { const { jobs, files, reusedCount, shellCount } = await planInstall() await runSession({ files, jobs, criticalCount: shellCount, reusedCount, phase: 'install' }) } /** * Eksik kalan bileşenleri tamamlar. * * Isıtmayı yalnızca `install` olayına bağlamak yetmiyor: süre bütçesi dolduğunda, * sekme kapandığında ya da bir dosya hata verdiğinde kalan chunk'lar bir sonraki * deploy'a kadar hiç indirilmiyor ve kullanıcı ekran açtıkça chunk bekliyordu. * Bu yüzden uygulama açılışta bunu tetikler (ENSURE_WARMUP) ve indirme kaldığı * yerden sürer. */ let warmupRunning = false async function ensureWarmup() { if (warmupRunning || !isWarmupAllowed()) return warmupRunning = true try { const assetCache = await caches.open(ASSET_CACHE) const warmup = await loadWarmupList() const cached = await cachedKeys(assetCache) const files: ProgressFile[] = [] const jobs: DownloadJob[] = [] let reusedCount = 0 for (const entry of warmup) { if (shellKeys.has(entry.url) || cached.has(entry.url)) { reusedCount++ continue } const index = files.length files.push({ index, url: entry.url, name: fileName(entry.url), loaded: 0, total: entry.size, status: 'pending', }) jobs.push({ index, url: entry.url, key: entry.url, cache: assetCache, critical: false }) } // Her şey yerinde: istemciyi rahatsız etme. if (jobs.length === 0) return await runSession({ files, jobs, criticalCount: 0, reusedCount, phase: 'warmup' }) } finally { warmupRunning = false snapshot = undefined } } self.addEventListener('install', (event) => { event.waitUntil(installAssets().finally(() => (snapshot = undefined))) }) /* ------------------------------------------------------------------ */ /* Aktivasyon: eski girdileri temizle */ /* ------------------------------------------------------------------ */ async function cleanup() { const names = await caches.keys() await Promise.all( names .filter((name) => name.startsWith('sozsoft-') && !KNOWN_CACHES.includes(name)) .concat(names.filter((name) => /^(workbox-|app-|dx-|fonts-|images-)/.test(name))) .map((name) => caches.delete(name)), ) // Kabuk cache'inde bu sürüme ait olmayan girdileri sil. const cache = await caches.open(SHELL_CACHE) const valid = new Set(shellKeys.values()) const requests = await cache.keys() await Promise.all(requests.filter((r) => !valid.has(r.url)).map((r) => cache.delete(r))) await pruneAssetCache() } /** * Isıtma her sürümde ~500 chunk yazdığı için eski sürümlerin chunk'ları * temizlenmezse cache sürekli büyür. Aktivasyon anında yapılır: eski sayfa * zaten yenilenmek üzeredir, çalışan sürümün chunk'ı elinden alınmaz. */ async function pruneAssetCache() { const warmup = await loadWarmupList() // Liste okunamadıysa (dev / eski deploy) hiçbir şeyi silme. if (warmup.length === 0) return const valid = new Set(warmup.map((entry) => entry.url)) const cache = await caches.open(ASSET_CACHE) const requests = await cache.keys() await Promise.all( requests .filter((request) => { const url = new URL(request.url) if (!/^\/assets\/(?:js|css)\//.test(url.pathname)) return false const href = url.href.split('?')[0] return !valid.has(href) && !shellKeys.has(href) }) .map((request) => cache.delete(request)), ) } self.addEventListener('activate', (event) => { event.waitUntil( (async () => { await cleanup() await self.clients.claim() })(), ) }) /* ------------------------------------------------------------------ */ /* Mesajlar */ /* ------------------------------------------------------------------ */ self.addEventListener('message', (event) => { const type = event.data?.type if (type === 'SKIP_WAITING') { void self.skipWaiting() return } // Kurulum sürerken bağlanan istemci ilerlemeyi buradan alır; START mesajını // kaçırmış olsa bile pencereyi açıp listeyi doldurabilir. if (type === 'GET_PRECACHE_STATE') { const current = snapshot?.() if (current) event.source?.postMessage({ type: 'SW_PRECACHE_STATE', ...current }) return } // Uygulama açılışta eksik bileşenlerin tamamlanmasını ister. if (type === 'ENSURE_WARMUP') { event.waitUntil(ensureWarmup()) return } }) /* ------------------------------------------------------------------ */ /* Runtime cache */ /* ------------------------------------------------------------------ */ async function trimCache(cacheName: string, limit: number) { const cache = await caches.open(cacheName) const keys = await cache.keys() if (keys.length <= limit) return // Cache.keys() ekleme sırasını korur; en eskiler silinir. await Promise.all(keys.slice(0, keys.length - limit).map((key) => cache.delete(key))) } async function cacheFirst(request: Request, cacheName: string, limit: number) { const cache = await caches.open(cacheName) const cached = await cache.match(request) if (cached) return cached const response = await fetch(request) if (response.ok || response.type === 'opaque') { await cache.put(request, response.clone()) void trimCache(cacheName, limit) } return response } async function staleWhileRevalidate(request: Request, cacheName: string, limit: number) { const cache = await caches.open(cacheName) const cached = await cache.match(request) const network = fetch(request) .then(async (response) => { if (response.ok) { await cache.put(request, response.clone()) void trimCache(cacheName, limit) } return response }) .catch(() => undefined) return cached ?? (await network) ?? Response.error() } /** Kabuktaki index.html'i arka planda tazeler (deploy sonrası kendini onarır). */ async function refreshIndex(cache: Cache, key: string, url: string) { try { const response = await fetch(new Request(url, { cache: 'reload', credentials: 'same-origin' })) if (response.ok) await cache.put(key, response) } catch { /* çevrimdışıysa mevcut kopya kullanılmaya devam eder */ } } /** * SPA gezinmeleri: precache'teki index.html anında servis edilir (hızlı açılış), * kopya arka planda tazelenir. Service worker'ın güncellenmediği (yalnızca lazy * chunk değişen) deploy'larda bir sonraki açılış yeni index'i alır. */ async function handleNavigation(request: Request) { const cache = await caches.open(SHELL_CACHE) const key = shellKeys.get(INDEX_URL) const cached = key ? await cache.match(key) : undefined if (cached && key) { void refreshIndex(cache, key, INDEX_URL) return cached } try { return await fetch(request) } catch { return Response.error() } } self.addEventListener('fetch', (event) => { const request = event.request if (request.method !== 'GET') return const url = new URL(request.url) if (url.origin !== self.location.origin) return if (NETWORK_ONLY.test(url.pathname)) return if (request.mode === 'navigate') { event.respondWith(handleNavigation(request)) return } const shellKey = shellKeys.get(url.href.split('?')[0]) if (shellKey) { event.respondWith( caches .open(SHELL_CACHE) .then((cache) => cache.match(shellKey)) .then((cached) => cached ?? fetch(request)), ) return } if (HASHED_ASSET.test(url.pathname)) { event.respondWith(cacheFirst(request, ASSET_CACHE, ASSET_LIMIT)) return } if (THEME_CSS.test(url.pathname)) { event.respondWith(staleWhileRevalidate(request, THEME_CACHE, THEME_LIMIT)) return } if (FONT_FILE.test(url.pathname)) { event.respondWith(cacheFirst(request, FONT_CACHE, FONT_LIMIT)) return } if (IMAGE_FILE.test(url.pathname)) { event.respondWith(cacheFirst(request, IMAGE_CACHE, IMAGE_LIMIT)) } })