689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
/**
|
||
* 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<unknown>) => void
|
||
respondWith: (response: Response | Promise<Response>) => 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<void>
|
||
clients: {
|
||
claim: () => Promise<void>
|
||
matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise<SwClient[]>
|
||
}
|
||
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<string, string>()
|
||
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<WarmupEntry[]> {
|
||
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<WarmupEntry[]> | 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<string, unknown>) {
|
||
// 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<string, unknown>) | 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<number>()
|
||
const inFlight = new Set<number>()
|
||
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))
|
||
}
|
||
})
|