sozsoft-platform/ui/src/sw.ts
2026-08-13 00:52:49 +03:00

689 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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ıı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))
}
})