sozsoft-platform/ui/src/views/version/swRegistration.ts

693 lines
22 KiB
TypeScript
Raw Normal View History

2026-02-24 20:44:16 +00:00
import { registerSW } from 'virtual:pwa-register'
2026-07-14 14:16:08 +00:00
import { store } from '@/store'
2026-02-24 20:44:16 +00:00
/**
* Service worker güncelleme akışı
* ------------------------------------------------------------------
* 1. Kayıt + ılışta bir kez `update()` kontrolü.
* 2. Sekme görünürken periyodik, sekmeye dönüldüğünde ve internet geri
* geldiğinde tekrar kontrol -> yeni deploy ık uygulamada da yakalanır.
* 3. Yeni worker kurulmaya başlayınca (`installing`) engellemeyen bir bant
* ile "yeni sürüm indiriliyor" bilgisi gösterilir.
* 4. Kurulum bitince (`installed`) tam ekran overlay + sürüm notları gösterilir,
* SKIP_WAITING gönderilir ve `controllerchange` ile sayfa yenilenir.
* 5. Yenileme sonrası `consumeAppUpdatedVersion()` ile "güncellendi" bildirimi
* ve changelog yönlendirmesi yapılır.
*/
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'
const PERIODIC_CHECK_INTERVAL = 15 * 60 * 1000
const MIN_CHECK_GAP = 60 * 1000
const ACTIVATION_RETRY_DELAY = 8_000
const ACTIVATION_TIMEOUT = 30_000
// `virtual:pwa-register` dev'de (devOptions kapalıyken) no-op'tur ve hiçbir
// callback çalışmaz. Kayıt promise'inin süresiz beklememesi için üst sınır.
const REGISTRATION_TIMEOUT = 10_000
// `registration.update()` yavaş/kopuk ağda süresiz askıda kalabiliyor.
const UPDATE_CALL_TIMEOUT = 20_000
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
Promise.race([
promise,
new Promise<undefined>((resolve) => window.setTimeout(() => resolve(undefined), ms)),
])
const UPDATED_VERSION_KEY = 'sozsoft:app-updated-version'
const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload'
2026-07-14 14:16:08 +00:00
type Release = {
version: string
changeLog?: string[]
}
let started = false
let registration: ServiceWorkerRegistration | undefined
let initialization: Promise<void> | undefined
let activating = false
let reloading = false
let lastCheckAt = 0
let periodicTimer: number | undefined
let activationRetryTimer: number | undefined
let activationTimeoutTimer: number | undefined
let storeUnsubscribe: (() => void) | undefined
let latestVersion: string | undefined
let releasePromise: Promise<Release | undefined> | undefined
const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
const watchedWorkers = new WeakSet<ServiceWorker>()
/* ------------------------------------------------------------------ */
/* Sürüm bilgisi */
/* ------------------------------------------------------------------ */
const loadLatestRelease = () =>
(releasePromise ??= fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' })
.then((response) => {
if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`)
return response.json() as Promise<{ releases?: Release[] }>
})
.then((data) => {
const release = data.releases?.[0]
latestVersion = release?.version ?? latestVersion
return release
})
.catch((error: unknown) => {
console.warn('Sürüm bilgisi okunamadı.', error)
releasePromise = undefined
return undefined
}))
/** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */
export const consumeAppUpdatedVersion = (): string | undefined => {
2026-07-14 14:16:08 +00:00
try {
const value = sessionStorage.getItem(UPDATED_VERSION_KEY)
if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY)
return value ?? undefined
} catch {
return undefined
2026-07-14 14:16:08 +00:00
}
}
2026-07-14 14:16:08 +00:00
/* ------------------------------------------------------------------ */
/* Arayüz */
/* ------------------------------------------------------------------ */
2026-07-14 14:16:08 +00:00
const STYLE_ID = 'sw-update-style'
const BANNER_ID = 'sw-update-banner'
const OVERLAY_ID = 'sw-update-overlay'
2026-07-14 14:16:08 +00:00
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = `
#${BANNER_ID} {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 99998;
display: flex;
align-items: center;
gap: 10px;
max-width: 320px;
padding: 12px 16px;
border-radius: 12px;
background: #111827;
color: #f9fafb;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
#${OVERLAY_ID} {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.sw-update-card {
display: flex;
flex-direction: column;
align-items: center;
2026-07-14 14:16:08 +00:00
gap: 16px;
background: #fff;
border-radius: 16px;
2026-07-14 14:16:08 +00:00
padding: 32px 40px;
box-shadow: 0 8px 40px rgba(0,0,0,0.25);
text-align: center;
2026-07-14 14:16:08 +00:00
max-width: 560px;
max-height: min(90vh, 720px);
width: 90%;
}
.sw-update-spinner {
flex: 0 0 auto;
width: 56px;
height: 56px;
border: 5px solid #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: sw-spin 0.8s linear infinite;
}
#${BANNER_ID} .sw-update-spinner {
width: 20px;
height: 20px;
border-width: 3px;
border-color: rgba(255,255,255,0.25);
border-top-color: #a5b4fc;
}
@keyframes sw-spin { to { transform: rotate(360deg); } }
.sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; }
.sw-update-versions {
2026-07-14 14:16:08 +00:00
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 10px 12px;
border-radius: 8px;
background: #f3f4f6;
color: #4b5563;
font-size: 13px;
}
.sw-update-separator { color: #9ca3af; margin: 0 4px; }
.sw-update-release {
2026-07-14 14:16:08 +00:00
width: 100%;
min-height: 0;
overflow-y: auto;
text-align: left;
border-top: 1px solid #e5e7eb;
padding-top: 14px;
}
.sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; }
.sw-update-list {
2026-07-14 14:16:08 +00:00
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
color: #6b7280;
font-size: 13px;
line-height: 1.45;
}
.sw-update-list li { display: flex; align-items: flex-start; gap: 8px; }
.sw-update-check {
2026-07-14 14:16:08 +00:00
display: inline-flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-top: 1px;
border-radius: 50%;
background: #d1fae5;
color: #059669;
font-size: 11px;
font-weight: 700;
}
.sw-update-empty { display: block; color: #9ca3af; }
.sw-update-action {
appearance: none;
border: 0;
cursor: pointer;
padding: 10px 20px;
border-radius: 8px;
background: #4f46e5;
color: #fff;
font-size: 14px;
font-weight: 600;
2026-07-14 14:16:08 +00:00
}
.sw-update-action:hover { background: #4338ca; }
2026-07-14 14:16:08 +00:00
@media (max-width: 640px) {
.sw-update-card { padding: 24px 20px; }
#${BANNER_ID} { left: 16px; right: 16px; max-width: none; }
2026-07-14 14:16:08 +00:00
}
`
document.head.appendChild(style)
}
function removeStyles() {
document.getElementById(STYLE_ID)?.remove()
}
/** İndirme sürerken kullanıcıyı engellemeyen bilgilendirme. */
function showDownloadBanner() {
if (document.getElementById(BANNER_ID) || document.getElementById(OVERLAY_ID)) return
ensureStyles()
const banner = document.createElement('div')
banner.id = BANNER_ID
banner.setAttribute('role', 'status')
banner.setAttribute('aria-live', 'polite')
const spinner = document.createElement('div')
spinner.className = 'sw-update-spinner'
const text = document.createElement('span')
text.textContent = 'Yeni sürüm indiriliyor, lütfen sekmeyi kapatmayın...'
banner.append(spinner, text)
document.body.appendChild(banner)
}
function removeDownloadBanner() {
document.getElementById(BANNER_ID)?.remove()
}
function getText(value: unknown) {
return value === undefined || value === null ? undefined : String(value)
}
function applyApiVersion(overlay: HTMLElement) {
const apiConfig = store.getState().abpConfig.config?.extraProperties
const environment = getText(apiConfig?.environment)
const version = getText(apiConfig?.version)
if (!environment && !version) return false
const target = overlay.querySelector<HTMLElement>('[data-sw-api-version]')
if (target) target.textContent = `${environment ?? '-'}:${version ?? '-'}`
return true
}
function watchApiVersion(overlay: HTMLElement) {
storeUnsubscribe?.()
storeUnsubscribe = undefined
if (applyApiVersion(overlay)) return
storeUnsubscribe = store.subscribe(() => {
if (!overlay.isConnected || applyApiVersion(overlay)) {
storeUnsubscribe?.()
storeUnsubscribe = undefined
}
})
}
async function fillOverlayDetails(overlay: HTMLElement) {
const release = await loadLatestRelease()
if (!overlay.isConnected) return
const uiVersion = release?.version ?? store.getState().locale.currentUiVersion
const uiValue = overlay.querySelector<HTMLElement>('[data-sw-ui-version]')
if (uiValue) uiValue.textContent = `${import.meta.env.MODE}:${uiVersion ?? '-'}`
watchApiVersion(overlay)
const releaseTitle = overlay.querySelector<HTMLElement>('[data-sw-release-title]')
const releaseList = overlay.querySelector<HTMLUListElement>('[data-sw-release-list]')
if (!releaseTitle || !releaseList) return
releaseTitle.textContent = release?.version
? `v${release.version} ile gelen yenilikler`
: 'Sürüm notları'
releaseList.replaceChildren()
const changes = release?.changeLog?.filter(Boolean) ?? []
if (changes.length === 0) {
const item = document.createElement('li')
item.className = 'sw-update-empty'
item.textContent = 'Bu sürüm için not bulunamadı.'
releaseList.appendChild(item)
return
}
changes.forEach((change) => {
const item = document.createElement('li')
const icon = document.createElement('span')
const text = document.createElement('span')
icon.className = 'sw-update-check'
icon.setAttribute('aria-hidden', 'true')
icon.textContent = '✓'
text.textContent = change.replace(/^\s*-\s*/, '')
item.append(icon, text)
releaseList.appendChild(item)
})
}
function showActivationOverlay() {
removeDownloadBanner()
if (document.getElementById(OVERLAY_ID)) return
ensureStyles()
const overlay = document.createElement('div')
overlay.id = OVERLAY_ID
2026-07-14 14:16:08 +00:00
overlay.setAttribute('role', 'status')
overlay.setAttribute('aria-live', 'polite')
overlay.innerHTML = `
<div class="sw-update-card">
<div class="sw-update-spinner"></div>
<p class="sw-update-title" data-sw-title>Sistem güncelleniyor</p>
<p class="sw-update-desc" data-sw-desc>Yeni sürüm yükleniyor, lütfen bekleyin...<br/>Sayfa otomatik olarak yenilenecek.</p>
2026-07-14 14:16:08 +00:00
<div class="sw-update-versions">
<span><strong>UI:</strong> <span data-sw-ui-version>Yükleniyor...</span></span>
2026-07-14 14:16:08 +00:00
<span class="sw-update-separator">|</span>
<span><strong>API:</strong> <span data-sw-api-version>Yükleniyor...</span></span>
2026-07-14 14:16:08 +00:00
</div>
<div class="sw-update-release">
<p class="sw-update-release-title" data-sw-release-title>Sürüm notları</p>
2026-07-14 14:16:08 +00:00
<ul class="sw-update-list" data-sw-release-list>
<li class="sw-update-empty">Sürüm notları yükleniyor...</li>
2026-07-14 14:16:08 +00:00
</ul>
</div>
</div>
`
document.body.appendChild(overlay)
void fillOverlayDetails(overlay)
}
/** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */
function showManualReloadPrompt() {
const overlay = document.getElementById(OVERLAY_ID)
if (!overlay) return
overlay.querySelector<HTMLElement>('.sw-update-spinner')?.remove()
const title = overlay.querySelector<HTMLElement>('[data-sw-title]')
const desc = overlay.querySelector<HTMLElement>('[data-sw-desc]')
if (title) title.textContent = 'Güncelleme tamamlanamadı'
if (desc) {
desc.textContent = 'Yeni sürüme geçmek için sayfayı yenileyin.'
}
if (overlay.querySelector('.sw-update-action')) return
const button = document.createElement('button')
button.type = 'button'
button.className = 'sw-update-action'
button.textContent = 'Sayfayı yenile'
button.addEventListener('click', () => reloadForUpdate())
overlay.querySelector('.sw-update-card')?.appendChild(button)
}
function teardownUpdateUi() {
storeUnsubscribe?.()
storeUnsubscribe = undefined
removeDownloadBanner()
document.getElementById(OVERLAY_ID)?.remove()
removeStyles()
}
/* ------------------------------------------------------------------ */
/* Aktivasyon */
/* ------------------------------------------------------------------ */
function clearActivationTimers() {
window.clearTimeout(activationRetryTimer)
window.clearTimeout(activationTimeoutTimer)
activationRetryTimer = undefined
activationTimeoutTimer = undefined
}
function reloadForUpdate() {
if (reloading) return
reloading = true
clearActivationTimers()
try {
if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion)
} catch {
/* sessionStorage kullanılamıyorsa bildirim atlanır */
}
window.location.reload()
}
function postSkipWaiting() {
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
}
function activateWaitingWorker() {
if (activating) {
showActivationOverlay()
return
}
activating = true
showActivationOverlay()
// Sürüm numarasını yenileme öncesinde hazır tut.
void loadLatestRelease()
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ı.')
showManualReloadPrompt()
}, ACTIVATION_TIMEOUT)
}
/* ------------------------------------------------------------------ */
/* Güncelleme takibi */
/* ------------------------------------------------------------------ */
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)
showDownloadBanner()
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ı, mevcut sürümle devam ediliyor.')
teardownUpdateUi()
}
})
}
function watchRegistration(reg: ServiceWorkerRegistration) {
if (watchedRegistrations.has(reg)) return
watchedRegistrations.add(reg)
reg.addEventListener('updatefound', () => trackInstallingWorker(reg))
trackInstallingWorker(reg)
}
/**
* 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 durumda eski 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
}
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)
}
}
}
function startUpdateWatchers() {
window.clearInterval(periodicTimer)
periodicTimer = window.setInterval(() => {
if (document.visibilityState === 'visible') void runUpdateCheck()
}, PERIODIC_CHECK_INTERVAL)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void runUpdateCheck()
})
window.addEventListener('online', () => 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
try {
if (sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)) return
sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1')
} catch {
return
}
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(() => {
try {
sessionStorage.removeItem(CHUNK_RELOAD_GUARD_KEY)
} catch {
/* yoksay */
}
}, 10_000)
}
/* ------------------------------------------------------------------ */
/* Genel API */
/* ------------------------------------------------------------------ */
/** 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'
/** 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 */
}
}
2026-02-24 20:44:16 +00:00
export const registerServiceWorker = () => {
if (started || !('serviceWorker' in navigator)) return
started = true
handleChunkLoadFailures()
if (!isServiceWorkerEnabled) {
// Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i
// (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her
// güncelleme kontrolü "unsupported MIME type" hatası verir.
initialization = unregisterAll()
return
}
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)
startUpdateWatchers()
// Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa
// doğrudan devreye al.
if (reg.waiting && navigator.serviceWorker.controller) {
activateWaitingWorker()
} else {
void runUpdateCheck(true)
}
}
settle()
},
onNeedRefresh() {
// Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır.
activateWaitingWorker()
},
onRegisterError(error) {
activating = false
clearActivationTimers()
teardownUpdateUi()
console.error('Service worker kaydı başarısız.', error)
settle()
},
})
})
}
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
if (!('serviceWorker' in navigator)) return 'unsupported'
if (!isServiceWorkerEnabled) return 'disabled'
await initialization
// `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman
// resolve etmez; getRegistration() kayıt yoksa undefined döner.
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) return 'up-to-date'
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
// `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür.
return 'updating'
2026-02-24 20:44:16 +00:00
}