690 lines
23 KiB
TypeScript
690 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))
|
|||
|
|
}
|
|||
|
|
})
|