sozsoft-platform/ui/src/sw.ts

690 lines
23 KiB
TypeScript
Raw Normal View History

2026-08-12 19:08:14 +00:00
/**
* 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 ı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ı ı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))
}
})