sozsoft-platform/ui/src/views/version/swRegistration.ts
2026-08-13 00:26:36 +03:00

922 lines
31 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.

import { registerSW } from 'virtual:pwa-register'
// `beforeinstallprompt` erken tetiklendiği için dinleyiciler modül yüklenirken kurulur.
import './installPrompt'
/**
* Sürüm / güncelleme motoru
* ------------------------------------------------------------------
* Sürüm numarası ve sürüm notları backend'den gelir: application-configuration
* yanıtındaki `extraProperties.changeLogs`. Store, config'i her aldığında
* `setAppVersionInfo()` ile bu modülü besler; ayrı bir sürüm isteği yoktur.
*
* Deploy algılaması service worker'ın kendi güncelleme kontrolüne dayanır:
* herhangi bir chunk değiştiğinde entry chunk'ın hash'i, dolayısıyla index.html
* ve `sw.js` içindeki precache manifest'i değişir; `registration.update()` yeni
* worker'ı görür. Böylece ayrı bir sürüm dosyası yoklamaya gerek kalmaz.
*
* Akış:
* 1. Service worker kaydı + periyodik/olay tabanlı `update()` kontrolü.
* 2. Yeni worker bulunursa kurulum başlar; `src/sw.ts` indirdiği her dosyayı
* isim/boyut/yüzde olarak yayınlar, bu modül state'e çevirir.
* 3. Kurulum bitince SKIP_WAITING → `controllerchange` → reload.
* 4. Backend'den gelen sürüm son görülenden farklıysa changelog bayrağı
* yazılır; login sonrası kullanıcı ChangeLog ekranına yönlendirilir.
* Tetikleyici sürümdür, bundle değil: yalnızca yeni bir sürüm notu (git
* tag) eklendiğinde de kullanıcı değişiklikleri görür.
*
* Bu dosya entry chunk'ındadır: React/UI/store bağımlılığı yoktur, pencere
* yalnızca gerçekten indirme varken lazy yüklenir.
*/
/* ------------------------------------------------------------------ */
/* Tipler */
/* ------------------------------------------------------------------ */
export type ManualUpdateResult =
| 'up-to-date'
| 'updating'
/** Tarayıcı service worker desteklemiyor. */
| 'unsupported'
/** Destekleniyor ama bu ortamda kapalı (dev; VITE_PWA_DEV=true ile açılır). */
| 'disabled'
export type UpdateFileStatus = 'pending' | 'downloading' | 'done' | 'error'
export type UpdateFile = {
/** Service worker'ın gönderdiği liste içindeki sırası (delta birleştirmede anahtar). */
index: number
url: string
name: string
loaded: number
total: number
status: UpdateFileStatus
/** Son hareket sırası; liste en yeni hareketi en üstte gösterir. */
seq?: number
}
export type AppUpdateStatus =
| 'idle'
| 'checking'
| 'downloading'
| 'ready'
/** Tüm bileşenler indi ve geçiş gerekmiyor (ilk kurulum). */
| 'completed'
| 'activating'
| 'error'
export type AppUpdateState = {
status: AppUpdateStatus
/** Pencere açık mı. */
visible: boolean
/** Uygulamanın çalışan sürümü (backend'den gelir). */
currentVersion: string
changeLog: string[]
files: UpdateFile[]
loadedBytes: number
totalBytes: number
percent: number
/** Değişmediği için yeniden indirilmeyen dosya sayısı. */
reusedCount: number
message?: string
/** Otomatik geçiş/yeniden deneme için kalan saniye. */
secondsLeft?: number
/** İndirme hızından tahmin edilen kalan süre (saniye). */
secondsRemaining?: number
/**
* Pencere kullanıcı tarafından kapatılabilir mi? İlk kurulumda indirme
* çalışan sürümü etkilemediği için kullanıcı kapatıp devam edebilir;
* güncellemede ise geçiş bitene kadar açık kalır.
*/
dismissible: boolean
}
/**
* `extraProperties.changeLogs` yükü: yalnızca ÇALIŞAN sürüm. Tam sürüm notu
* listesi ChangeLog ekranının kendi endpoint'inden gelir, her config isteğinde
* taşınmaz.
*/
export type VersionInfo = {
/** Uygulamanın (UI) çalışan sürümü. */
uiVersion: string
buildDate?: string
/** Son sürümün notları. */
tags?: string[]
}
/* ------------------------------------------------------------------ */
/* Sabitler */
/* ------------------------------------------------------------------ */
/** Sekme açık kalırsa periyodik yoklama. */
const VERSION_POLL_INTERVAL = 30 * 60 * 1000
const MIN_CHECK_GAP = 30 * 1000
const ACTIVATION_RETRY_DELAY = 8_000
const ACTIVATION_TIMEOUT = 45_000
const REGISTRATION_TIMEOUT = 10_000
const UPDATE_CALL_TIMEOUT = 20_000
/** Kurulum/aktivasyon başarısız olduğunda otomatik yeniden deneme süresi. */
const RETRY_DELAY_SECONDS = 15
const LAST_VERSION_KEY = 'sozsoft:last-version'
const PENDING_CHANGELOG_KEY = 'sozsoft:pending-changelog'
const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload'
/** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */
const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true'
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
Promise.race([
promise,
new Promise<undefined>((resolve) => window.setTimeout(() => resolve(undefined), ms)),
])
const readStorage = (key: string, session = false) => {
try {
return (session ? sessionStorage : localStorage).getItem(key) ?? undefined
} catch {
return undefined
}
}
const writeStorage = (key: string, value: string | undefined, session = false) => {
try {
const store = session ? sessionStorage : localStorage
if (value === undefined) store.removeItem(key)
else store.setItem(key, value)
} catch {
/* storage kapalıysa yoksay */
}
}
/* ------------------------------------------------------------------ */
/* Durum yönetimi */
/* ------------------------------------------------------------------ */
let state: AppUpdateState = {
status: 'idle',
visible: false,
// Backend'den (`setAppVersionInfo`) gelene kadar boş kalır.
currentVersion: '',
changeLog: [],
files: [],
loadedBytes: 0,
totalBytes: 0,
percent: 0,
reusedCount: 0,
dismissible: false,
}
const listeners = new Set<(value: AppUpdateState) => void>()
export const getAppUpdateState = () => state
export const subscribeAppUpdate = (listener: (value: AppUpdateState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
function setState(patch: Partial<AppUpdateState>) {
state = { ...state, ...patch }
listeners.forEach((listener) => listener(state))
}
/* Kalan süre tahmini: oturum başından beri ölçülen ortalama hız. */
let rateStartedAt = 0
let rateStartBytes = 0
const resetRate = () => {
rateStartedAt = 0
rateStartBytes = 0
}
function estimateRemaining(loadedBytes: number, totalBytes: number) {
const now = Date.now()
if (!rateStartedAt) {
rateStartedAt = now
rateStartBytes = loadedBytes
return undefined
}
const elapsed = now - rateStartedAt
const delta = loadedBytes - rateStartBytes
// İlk saniyelerde hız oturmadığı için tahmin verilmez.
if (elapsed < 2_000 || delta <= 0 || totalBytes <= 0) return state.secondsRemaining
const remaining = Math.max(0, totalBytes - loadedBytes)
return Math.round(remaining / (delta / elapsed) / 1000)
}
/**
* Boyutlar derleme anında bilindiği için toplamlar service worker'dan gelir;
* gelmezse (eski worker) dosya listesinden hesaplanır.
*/
function recalculate(
files: UpdateFile[],
totals?: { loadedBytes?: number; totalBytes?: number },
extra?: Partial<AppUpdateState>,
) {
const loadedBytes = totals?.loadedBytes ?? files.reduce((sum, file) => sum + file.loaded, 0)
const totalBytes =
totals?.totalBytes || files.reduce((sum, file) => sum + (file.total || file.loaded), 0)
// Boyut hiç bilinmiyorsa (warmup.json yoksa) biten dosya sayısına düşülür.
const ratio =
totalBytes > 0
? loadedBytes / totalBytes
: files.filter((file) => file.status === 'done').length / (files.length || 1)
const finished =
state.status === 'ready' || state.status === 'activating' || state.status === 'completed'
setState({
files,
loadedBytes,
totalBytes,
// Yüzde asla geri gitmesin.
percent: finished ? 100 : Math.max(state.percent, Math.min(99, Math.round(ratio * 100))),
secondsRemaining: finished ? undefined : estimateRemaining(loadedBytes, totalBytes),
...extra,
})
}
/* ------------------------------------------------------------------ */
/* Sürüm bilgisi */
/* ------------------------------------------------------------------ */
/**
* Sürüm bilgisini application-configuration yanıtından alır.
*
* Store, config'i her yüklediğinde bunu çağırır; ayrı bir sürüm isteği yoktur.
* Yük şekli: `extraProperties.changeLogs = { uiVersion, buildDate, tags[] }`.
*/
export const setAppVersionInfo = (raw: unknown) => {
const data = raw as Partial<VersionInfo> | undefined
if (!data?.uiVersion) return
setState({
currentVersion: data.uiVersion,
changeLog: Array.isArray(data.tags) ? data.tags.filter(Boolean) : [],
})
markVersionSeen(data.uiVersion)
}
/** Login sonrası changelog yönlendirmesi için bayrak. */
export const hasPendingChangelog = () => !!readStorage(PENDING_CHANGELOG_KEY)
export const clearPendingChangelog = () => writeStorage(PENDING_CHANGELOG_KEY, undefined)
/**
* Sürüm numarası son görülenden farklıysa changelog bayrağını yazar; kullanıcı
* bir sonraki girişinde ChangeLog ekranına yönlendirilir.
*
* Tetikleyici bilerek BUNDLE DEĞİL, backend'den gelen sürümdür: yalnızca yeni
* bir sürüm notu (git tag) eklendiğinde de — hiçbir bileşen değişmemiş olsa
* bile — kullanıcı değişiklikleri görür. İlk açılışta (kayıt yokken) bayrak
* yazılmaz, yalnızca mevcut sürüm not edilir.
*/
function markVersionSeen(version: string) {
const lastSeen = readStorage(LAST_VERSION_KEY)
if (lastSeen === version) return
if (lastSeen) writeStorage(PENDING_CHANGELOG_KEY, version)
writeStorage(LAST_VERSION_KEY, version)
}
/* ------------------------------------------------------------------ */
/* Service worker kaydı ve güncelleme */
/* ------------------------------------------------------------------ */
let started = false
let registration: ServiceWorkerRegistration | undefined
let initialization: Promise<void> | undefined
let activating = false
let reloading = false
let lastCheckAt = 0
let lastVersionCheckAt = 0
let pollTimer: number | undefined
let activationRetryTimer: number | undefined
let activationTimeoutTimer: number | undefined
const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
const watchedWorkers = new WeakSet<ServiceWorker>()
function clearActivationTimers() {
window.clearTimeout(activationRetryTimer)
window.clearTimeout(activationTimeoutTimer)
activationRetryTimer = undefined
activationTimeoutTimer = undefined
}
function reloadForUpdate() {
if (reloading) return
reloading = true
clearActivationTimers()
window.location.reload()
}
/** src/sw.ts içindeki kabuk cache adı ile aynı olmalı. */
const SHELL_CACHE = 'sozsoft-shell-v1'
/**
* Service worker güncellenmediği (ör. yalnızca lazy chunk değişen) deploy'larda
* kabuk cache'i temizlenmeden yenilenirse aynı eski index.html geri gelir ve
* güncelleme penceresi sonsuz döngüye girer.
*/
async function reloadWithFreshShell() {
try {
await caches.delete(SHELL_CACHE)
} catch {
/* cache API kapalıysa normal yenileme yapılır */
}
reloadForUpdate()
}
/* ------------------------------------------------------------------ */
/* Otomatik geçiş */
/* ------------------------------------------------------------------ */
let autoTimer: number | undefined
function clearAutoAction() {
window.clearInterval(autoTimer)
autoTimer = undefined
}
/**
* Güncelleme kullanıcı müdahalesi beklemeden tamamlanır; geri sayım yalnızca
* ne olacağını göstermek içindir. Pencere geçiş bitene kadar kapanmaz.
*/
function scheduleAutoAction(seconds: number, action: () => void) {
clearAutoAction()
pendingAutoAction = action
setState({ secondsLeft: seconds })
autoTimer = window.setInterval(() => {
const left = (state.secondsLeft ?? 0) - 1
if (left > 0) {
setState({ secondsLeft: left })
return
}
clearAutoAction()
setState({ secondsLeft: 0 })
action()
}, 1000)
}
let pendingAutoAction: (() => void) | undefined
/** Yeni sürüme geçişi yeniden dener (kurulum/aktivasyon başarısız olduğunda). */
async function retryUpdate() {
clearAutoAction()
if (!navigator.onLine) {
// Çevrimdışıyken kabuk cache'ini silmek uygulamayı hiç açılamaz hale
// getirir; bağlantı gelince `online` dinleyicisi akışı sürdürür.
setState({
status: 'error',
secondsLeft: undefined,
message: 'Bağlantı bekleniyor. İnternet geldiğinde güncelleme sürdürülecek.',
})
return
}
activating = false
clearActivationTimers()
setState({
status: 'checking',
message: undefined,
files: [],
loadedBytes: 0,
totalBytes: 0,
percent: 0,
secondsLeft: undefined,
})
if (registration) {
await runUpdateCheck(true)
if (state.status === 'checking') void reloadWithFreshShell()
return
}
void reloadWithFreshShell()
}
/** Geri sayımı beklemeden çalıştırır (penceredeki tek buton). */
export const applyAppUpdate = () => {
const action = pendingAutoAction
clearAutoAction()
setState({ secondsLeft: 0 })
if (action) {
action()
return
}
if (registration?.waiting) {
activateWaitingWorker()
return
}
void reloadWithFreshShell()
}
function postSkipWaiting() {
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
}
function activateWaitingWorker() {
if (activating) {
setState({ visible: true })
return
}
activating = true
setState({ status: 'activating', visible: true, percent: 100 })
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true })
postSkipWaiting()
// Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor.
activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY)
activationTimeoutTimer = window.setTimeout(() => {
clearActivationTimers()
activating = false
// Worker waiting'den çıktıysa aktivasyon olmuş, sadece controllerchange
// kaçmış olabilir; yeni precache'i almak için yenile.
if (!registration?.waiting && registration?.active) {
reloadForUpdate()
return
}
console.warn('Service worker aktivasyonu zaman aşımına uğradı.')
setState({
status: 'error',
visible: true,
message: 'Geçiş beklenenden uzun sürdü, yeniden deneniyor.',
})
scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate())
}, ACTIVATION_TIMEOUT)
}
/* ------------------------------------------------------------------ */
/* Service worker mesajları (indirme ilerlemesi) */
/* ------------------------------------------------------------------ */
type SwFile = {
index: number
url: string
name: string
loaded: number
total: number
status: string
}
type SwProgressMessage = {
type?: string
/** Tam liste (START / STATE / DONE). */
files?: SwFile[]
/** Yalnızca değişen dosyalar (PROGRESS). */
updates?: SwFile[]
reusedCount?: number
loadedBytes?: number
totalBytes?: number
/** 'install': yeni sürüm kurulumu, 'warmup': eksik bileşenlerin tamamlanması. */
phase?: 'install' | 'warmup'
}
const toFiles = (files: SwFile[] | undefined): UpdateFile[] =>
(files ?? []).map((file) => ({
index: file.index,
url: file.url,
name: file.name,
loaded: file.loaded ?? 0,
total: file.total ?? 0,
status: (file.status as UpdateFileStatus) ?? 'pending',
}))
let updateSeq = 0
/**
* İlerleme mesajları yalnızca değişen dosyaları taşır (bir sürümde ~500 chunk
* var; tam listeyi saniyede birkaç kez göndermek/kopyalamak pahalı). Her
* güncellenen dosyaya artan bir sıra numarası verilir; pencere listeyi buna
* göre en yeni hareket en üstte olacak şekilde diziyor.
*/
function mergeFiles(updates: UpdateFile[]): UpdateFile[] {
if (updates.length === 0) return state.files
const merged = state.files.slice()
for (const update of updates) {
const item = { ...update, seq: ++updateSeq }
if (update.index >= 0 && update.index < merged.length) merged[update.index] = item
else merged.push(item)
}
return merged
}
/** Tam liste geldiğinde (DONE) mevcut sıralama bilgisi korunur. */
const keepSeq = (files: UpdateFile[]): UpdateFile[] =>
files.map((file, position) => ({ ...file, seq: state.files[position]?.seq }))
/** Tamamlandı/hata mesajının ekranda kaldığı süre (geçiş gerekmeyen kurulumda). */
const NOTICE_VISIBLE_MS = 5_000
function hideDialogLater() {
window.setTimeout(() => {
if (state.status === 'completed' || state.status === 'error') {
setState({ visible: false, status: 'idle' })
}
}, NOTICE_VISIBLE_MS)
}
function handleWorkerMessage(event: MessageEvent) {
const data = event.data as SwProgressMessage | undefined
if (!data?.type) return
/**
* Controller yoksa bu bir güncelleme değil, uygulamanın ilk kez hazırlanmasıdır
* (yeni kullanıcı, temizlenmiş cache, yeni tarayıcı). Bileşenler yine de
* indirilir ve ilerleme gösterilir; ancak çalışan sürüm zaten yeni olduğu için
* sonunda geçiş/yenileme yapılmaz, pencere kapatılabilir.
*/
const controlled = !!navigator.serviceWorker.controller
/**
* Isıtma (eksik bileşenlerin tamamlanması) çalışan sürümü değiştirmez: sonunda
* geçiş/yenileme yapılmaz ve pencere kapatılabilir. Kurulum ise yeni sürüme
* geçişle biter.
*/
const transitions = controlled && data.phase !== 'warmup'
const totals = { loadedBytes: data.loadedBytes, totalBytes: data.totalBytes }
switch (data.type) {
// İndirme sürerken bağlanan istemcinin aldığı anlık durum; START ile aynı
// yükü taşır (bkz. requestPrecacheState).
case 'SW_PRECACHE_STATE':
case 'SW_PRECACHE_START':
// İndirme başladı: bekleyen geri sayım varsa iptal edilir.
clearAutoAction()
void showUpdateDialog('downloading')
resetRate()
setState({ percent: 0 })
recalculate(toFiles(data.files), totals, {
status: 'downloading',
visible: true,
reusedCount: data.reusedCount ?? 0,
secondsLeft: undefined,
dismissible: !transitions,
})
break
case 'SW_PRECACHE_PROGRESS':
// Durum isteği bir yarışa takılıp cevapsız kalırsa pencere burada açılır.
if (!dialogMountRequested) void showUpdateDialog('downloading')
recalculate(mergeFiles(toFiles(data.updates)), totals, {
status: 'downloading',
visible: true,
dismissible: !transitions,
})
break
case 'SW_PRECACHE_DONE':
recalculate(keepSeq(toFiles(data.files)), totals, {
// Yeni sürüme geçilecekse 'ready'; ısıtmada iş bitti.
status: transitions ? 'ready' : 'completed',
visible: true,
percent: 100,
})
if (!transitions) hideDialogLater()
break
case 'SW_PRECACHE_ERROR':
if (!transitions) {
// Çalışan sürüm etkilenmedi; kullanıcıyı yeniden yükleme döngüsüne sokma.
setState({
status: 'error',
visible: true,
dismissible: true,
message: 'Bileşenler tamamen indirilemedi, gerektikçe yüklenecek.',
})
hideDialogLater()
break
}
setState({
status: 'error',
visible: true,
message: 'Yeni sürüm indirilemedi, yeniden deneniyor.',
})
scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate())
break
default:
break
}
}
/** Kapatılabilir bildirimlerde pencereyi gizler (indirme arka planda sürer). */
export const dismissAppUpdateDialog = () => setState({ visible: false })
/* ------------------------------------------------------------------ */
/* Güncelleme penceresi (lazy) */
/* ------------------------------------------------------------------ */
let dialogMountRequested = false
/**
* Pencere bileşeni entry chunk'ında taşınmaz; yalnızca gerçekten güncelleme
* olduğunda indirilir ve body'ye kendi kökünde mount edilir.
*/
async function showUpdateDialog(status: AppUpdateStatus) {
setState({ status, visible: true })
if (dialogMountRequested) return
dialogMountRequested = true
try {
const { mountAppUpdateDialog } = await import('./mountAppUpdateDialog')
await mountAppUpdateDialog()
} catch (error) {
dialogMountRequested = false
console.warn('Güncelleme penceresi yüklenemedi.', error)
}
}
/* ------------------------------------------------------------------ */
/* Sürüm kontrolü */
/* ------------------------------------------------------------------ */
/**
* Deploy'da service worker dosyası mutlaka değişir: herhangi bir chunk'ın
* hash'i değiştiğinde entry chunk, dolayısıyla index.html ve `sw.js` içindeki
* precache manifest'i de değişir. Bu yüzden ayrı bir sürüm dosyası yoklamaya
* gerek yok; `registration.update()` yeni deploy'u tek başına yakalar.
*/
async function runUpdateCheck(force = false) {
if (!registration || activating || reloading) return
if (!force && !navigator.onLine) return
const now = Date.now()
if (!force && now - lastCheckAt < MIN_CHECK_GAP) return
lastCheckAt = now
const reg = registration
try {
await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT)
} catch (error: unknown) {
if (!(await unregisterIfStale(reg, error))) {
console.warn('Service worker güncelleme kontrolü başarısız.', error)
}
}
}
/**
* Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı
* değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı
* "unsupported MIME type" SecurityError'ı fırlatır. Bu kayıt kalıcı olarak
* bozuktur; temizlenmezse her kontrol aynı hatayı verir.
*/
async function unregisterIfStale(reg: ServiceWorkerRegistration, error: unknown) {
const isStaleScript =
error instanceof Error && (error.name === 'SecurityError' || /MIME type/i.test(error.message))
if (!isStaleScript) return false
console.warn('Geçersiz service worker kaydı temizleniyor.', error)
try {
await reg.unregister()
} catch {
/* temizlenemediyse bir sonraki açılışta tekrar denenir */
}
if (registration === reg) registration = undefined
return true
}
/**
* Kurulum çoğu zaman sayfa gezinmesiyle, uygulama mount olmadan başlar; bu
* durumda `SW_PRECACHE_START` kaçar. Kayıt görülür görülmez worker'dan anlık
* ilerleme istenir ki pencere ortadan da açılabilsin.
*/
function requestPrecacheState(reg: ServiceWorkerRegistration) {
const worker = reg.installing ?? reg.waiting ?? reg.active
worker?.postMessage({ type: 'GET_PRECACHE_STATE' })
}
/**
* Eksik bileşenlerin tamamlanmasını ister.
*
* Isıtmayı yalnızca kuruluma 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 indirilmiyor ve kullanıcı ekran açtıkça chunk bekliyordu.
* Worker eksik bir şey yoksa hiçbir mesaj yollamaz, pencere de açılmaz.
*/
function requestWarmup(reg: ServiceWorkerRegistration) {
// Kurulum sürüyorsa zaten indiriliyor; ikinci bir oturum başlatma.
if (reg.installing) return
const worker = reg.active ?? navigator.serviceWorker.controller
worker?.postMessage({ type: 'ENSURE_WARMUP' })
}
/** İlk boyama ve açılış istekleri bitsin diye ısıtma isteği boşta gönderilir. */
function scheduleWarmup(reg: ServiceWorkerRegistration) {
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void })
.requestIdleCallback
const send = () => requestWarmup(reg)
if (idle) idle.call(window, send, { timeout: 5_000 })
else window.setTimeout(send, 3_000)
}
function trackInstallingWorker(reg: ServiceWorkerRegistration) {
const worker = reg.installing
// Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur.
if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return
watchedWorkers.add(worker)
void showUpdateDialog('downloading')
worker.addEventListener('statechange', () => {
if (worker.state === 'installed') {
activateWaitingWorker()
return
}
// Precache dosyalarından biri indirilemezse worker redundant olur; eski
// sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz.
if (worker.state === 'redundant' && !activating) {
console.warn('Yeni service worker kurulamadı, yeniden denenecek.')
setState({
status: 'error',
visible: true,
message: 'Yeni sürüm kurulamadı, yeniden deneniyor.',
})
scheduleAutoAction(RETRY_DELAY_SECONDS, () => void retryUpdate())
}
})
}
function watchRegistration(reg: ServiceWorkerRegistration) {
if (watchedRegistrations.has(reg)) return
watchedRegistrations.add(reg)
reg.addEventListener('updatefound', () => {
trackInstallingWorker(reg)
requestPrecacheState(reg)
})
trackInstallingWorker(reg)
requestPrecacheState(reg)
}
function startWatchers() {
// Yoklama olay tabanlıdır (sekmeye dönüş / bağlantı). Periyodik zamanlayıcı
// yalnızca sekme uzun süre açık ve önde kalırsa devreye girer.
window.clearInterval(pollTimer)
pollTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') void runUpdateCheck()
}, VERSION_POLL_INTERVAL)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return
void runUpdateCheck()
})
window.addEventListener('online', () => {
// Çevrimdışıyken beklemeye alınan güncelleme bağlantı gelince sürer.
if (state.status === 'error') void retryUpdate()
else void runUpdateCheck()
})
}
/**
* Deploy sonrası sunucudan kaldırılmış bir lazy chunk istenirse Vite
* `vite:preloadError` fırlatır. Beyaz ekran yerine bir kez sayfayı yenile.
*/
function handleChunkLoadFailures() {
window.addEventListener('vite:preloadError', (event) => {
if (reloading) return
if (readStorage(CHUNK_RELOAD_GUARD_KEY, true)) return
writeStorage(CHUNK_RELOAD_GUARD_KEY, '1', true)
event.preventDefault()
console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.')
reloading = true
window.location.reload()
})
// Sayfa sorunsuz açıldıysa koruma bayrağını temizle.
window.setTimeout(() => writeStorage(CHUNK_RELOAD_GUARD_KEY, undefined, true), 10_000)
}
/** Kapalıyken önceki oturumlardan kalan kayıtları temizler. */
async function unregisterAll() {
try {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((reg) => reg.unregister().catch(() => false)))
} catch {
/* yoksay */
}
}
/* ------------------------------------------------------------------ */
/* Genel API */
/* ------------------------------------------------------------------ */
export const registerServiceWorker = () => {
// Dev'de HMR modülü yeniden çalıştırdığında modül seviyesindeki bayrak
// sıfırlanıyor ve zamanlayıcı/dinleyiciler üst üste biniyordu.
const scope = window as unknown as { __sozsoftUpdateEngineStarted?: boolean }
if (started || scope.__sozsoftUpdateEngineStarted) return
started = true
scope.__sozsoftUpdateEngineStarted = true
// Açılış performansı: yoklama dinleyicileri ilk boyamayı beklesin.
const idle = (window as unknown as { requestIdleCallback?: (cb: () => void, o?: object) => void })
.requestIdleCallback
if (idle) idle.call(window, startWatchers, { timeout: 3_000 })
else window.setTimeout(startWatchers, 1_500)
if (!('serviceWorker' in navigator)) return
handleChunkLoadFailures()
if (!isServiceWorkerEnabled) {
// Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i
// artık üretilmediği için sunucu index.html döner ve her güncelleme
// kontrolü "unsupported MIME type" hatası verir.
initialization = unregisterAll()
return
}
navigator.serviceWorker.addEventListener('message', handleWorkerMessage)
initialization = new Promise<void>((resolve) => {
// Kayıt callback'i hiç çalışmazsa bile promise'i serbest bırak; aksi halde
// `checkForAppUpdate()` "Kontrol ediliyor..." durumunda takılı kalır.
const registrationTimeout = window.setTimeout(resolve, REGISTRATION_TIMEOUT)
const settle = () => {
window.clearTimeout(registrationTimeout)
resolve()
}
registerSW({
immediate: true,
onRegisteredSW(_swUrl, reg) {
if (reg) {
registration = reg
watchRegistration(reg)
// Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa
// doğrudan devreye al.
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
else scheduleWarmup(reg)
}
settle()
},
onNeedRefresh() {
// Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır.
activateWaitingWorker()
},
onRegisterError(error) {
activating = false
clearActivationTimers()
console.error('Service worker kaydı başarısız.', error)
settle()
},
})
})
}
/** Kullanıcının menüden tetiklediği manuel kontrol. */
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
setState({ status: state.status === 'idle' ? 'checking' : state.status })
if (!('serviceWorker' in navigator)) return 'unsupported'
if (!isServiceWorkerEnabled) return 'disabled'
await initialization
const reg =
registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined))
if (!reg) return 'unsupported'
registration ??= reg
watchRegistration(reg)
if (reg.waiting && navigator.serviceWorker.controller) {
activateWaitingWorker()
return 'updating'
}
let updateFound = false
const handleUpdateFound = () => {
updateFound = true
}
let staleRegistrationCleared = false
reg.addEventListener('updatefound', handleUpdateFound)
try {
await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT)
} catch (error: unknown) {
staleRegistrationCleared = await unregisterIfStale(reg, error)
if (!staleRegistrationCleared) {
console.warn('Manuel güncelleme kontrolü başarısız.', error)
}
} finally {
reg.removeEventListener('updatefound', handleUpdateFound)
lastCheckAt = Date.now()
}
// Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek.
if (staleRegistrationCleared) return 'up-to-date'
if (!updateFound && !reg.installing && !reg.waiting) {
if (state.status === 'checking') setState({ status: 'idle' })
return 'up-to-date'
}
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
// `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür.
return 'updating'
}