sozsoft-platform/ui/src/sw.ts
2026-10-05 15:58:59 +03:00

781 lines
27 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).
*
* İki kademe vardır:
* 1. KABUK (`__WB_MANIFEST`): index.html + entry JS/CSS + eager vendor
* chunk'ları. Kurulumun tamamıdır; biri inmezse kurulum başarısız olur.
* 2. BİLEŞENLER (`/warmup.json`): kurulumdan bağımsızdır. Uygulama, giriş
* yapmış kullanıcının yetkili olduğu rotaları `ENSURE_WARMUP` ile bildirir;
* yalnızca o rotaların chunk zinciri arka planda indirilir. Ziyaretçi ve giriş
* ekranı mesaj yollamadığı için hiçbir bileşen indirmez. Best-effort: inemeyen
* dosya atlanır, 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
url?: string
focus?: () => Promise<SwClient>
navigate?: (url: string) => Promise<SwClient>
postMessage: (message: unknown) => void
}
/** Masaüstü bildirimine tıklandığında gelen olay; `data` gösterim sırasında yazılan yüktür. */
type SwNotificationEvent = Event & {
waitUntil: (promise: Promise<unknown>) => void
notification: { close: () => void; data?: { url?: string } | null }
}
declare const self: {
__WB_MANIFEST: PrecacheEntry[]
location: Location
navigator?: { connection?: { saveData?: boolean; effectiveType?: string } }
registration: { scope: string; installing?: unknown; waiting?: unknown }
skipWaiting: () => Promise<void>
clients: {
claim: () => Promise<void>
matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise<SwClient[]>
}
addEventListener: (type: string, listener: (event: SwEvent) => void) => void
clients_openWindow?: never
}
declare const selfWithNotifications: {
clients: { openWindow: (url: string) => Promise<SwClient | null> }
addEventListener: (type: 'notificationclick', listener: (event: SwNotificationEvent) => 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 }
type WarmupManifest = {
/** Sürümün bütün JS/CSS dosyaları (boyut ve eski sürüm temizliği için). */
files: WarmupEntry[]
/** View modülü (`views/...`) → açılması için gereken dosyaların `files` sıraları. */
routes: Record<string, 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 fetchWarmupManifest(): Promise<WarmupManifest> {
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 Partial<WarmupManifest> | undefined
return {
files: (data?.files ?? []).map((entry) => ({
url: new URL(entry.url, self.registration.scope).href,
size: Number(entry.size) || 0,
})),
routes: data?.routes ?? {},
}
} catch (error) {
console.warn('[sw] warmup.json okunamadı, bileşen ısıtması atlanıyor.', error)
return { files: [], routes: {} }
}
}
let warmupPromise: Promise<WarmupManifest> | undefined
const loadWarmupManifest = () => (warmupPromise ??= fetchWarmupManifest())
/** Verilen rotaların chunk zinciri; aynı dosya birden çok rotada olsa da bir kez yer alır. */
function warmupFilesFor(manifest: WarmupManifest, routes: string[]) {
const indexes = new Set<number>()
for (const route of routes) manifest.routes[route]?.forEach((index) => indexes.add(index))
return [...indexes].map((index) => manifest.files[index]).filter(Boolean)
}
/**
* Yeni bir sürüm kuruluyor ya da geçişi bekliyor mu?
*
* Bu kontrol ISITMA için: eski aktif worker ısıtmayı sürdürürken yenisi kurulum
* yapıyorsa iki oturum hem bant genişliği için yarışır hem de istemcide iç içe
* geçmiş iki ilerleme akışı üretir. Kurulum önceliklidir. (Kurulum oturumunun
* kendisi bu kontrolü kullanamaz: kuran worker `installing`'in ta kendisidir.)
*/
const updatePending = () => !!(self.registration.installing || self.registration.waiting)
/** Veri tasarrufu açıkken ya da 2G'de ı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 yalnızca kabuk indirilir. 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 { files: warmup } = await loadWarmupManifest()
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)
}
return { jobs, files, reusedCount }
}
/** İ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,
})
}
// Isıtma, araya bir kurulum girdiğinde kalan işleri bırakır (bkz.
// `updatePending`); kurulumun kendisi hiçbir koşulda kesilmez.
const abandoned = () => phase === 'warmup' && updatePending()
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 ya da kurulum başladıysa kalan bileşenler
// runtime cache'e bırakılır.
if (deadline && Date.now() > deadline) return
if (abandoned()) 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 } = await planInstall()
await runSession({ files, jobs, criticalCount: jobs.length, reusedCount, phase: 'install' })
}
/**
* Kullanıcının yetkili olduğu rotaların eksik bileşenlerini indirir.
*
* Kurulumdan ayrıdır: kurulum her ziyaretçide (public sayfa, giriş ekranı) çalışır
* ve bütün chunk'ları oraya bağlamak her açılışta ~20 MB indirmek demekti. Uygulama
* oturum açıldıktan sonra yetkili rotaları `ENSURE_WARMUP` ile bildirir; indirme
* kaldığı yerden sürer. Çalışırken gelen istek (ör. başka kullanıcıyla giriş)
* kaybolmaz, oturum bitince en son liste için yeniden çalışılır.
*/
let warmupRunning = false
let queuedRoutes: string[] | undefined
async function ensureWarmup(routes: string[]) {
if (warmupRunning) {
queuedRoutes = routes
return
}
// Kurulum sürüyorsa sayfa yeni sürüme geçecek; ısıtma yeni worker'da istenir.
if (updatePending() || !isWarmupAllowed()) return
warmupRunning = true
try {
const assetCache = await caches.open(ASSET_CACHE)
const warmup = warmupFilesFor(await loadWarmupManifest(), routes)
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
}
const next = queuedRoutes
queuedRoutes = undefined
if (next) await ensureWarmup(next)
}
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 ve runtime cache her sürümde yeni chunk'lar 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 { files: warmup } = await loadWarmupManifest()
// 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
}
// Oturum açık uygulama yetkili rotalarının bileşenlerini ister.
if (type === 'ENSURE_WARMUP') {
const routes = (event.data as { routes?: unknown }).routes
if (!Array.isArray(routes)) return
event.waitUntil(
ensureWarmup(routes.filter((route): route is string => typeof route === 'string')),
)
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))
}
})
/* ------------------------------------------------------------------ */
/* Bildirim tıklaması */
/* ------------------------------------------------------------------ */
/**
* Masaüstü bildirimine tıklayınca uygulamaya dönülür: açık bir sekme varsa öne alınıp
* hedef adrese götürülür, yoksa yeni sekme açılır. Hedef, bildirimi gösterirken yazılan
* `data.url` değeridir; yoksa etkinlik kaydı ekranı açılır.
*/
const NOTIFICATION_FALLBACK_URL = '/admin/activityLog'
;(self as unknown as typeof selfWithNotifications).addEventListener(
'notificationclick',
(event) => {
event.notification.close()
const target = event.notification.data?.url || NOTIFICATION_FALLBACK_URL
const targetUrl = new URL(target, self.location.origin).href
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
for (const client of clients) {
if (!client.url) continue
if (client.navigate) {
await client.navigate(targetUrl).catch(() => undefined)
}
if (client.focus) {
await client.focus().catch(() => undefined)
}
return
}
await (self as unknown as typeof selfWithNotifications).clients
.openWindow(targetUrl)
.catch(() => undefined)
})(),
)
},
)