Claude vite optimize ve check new version

This commit is contained in:
Sedat ÖZTÜRK 2026-08-07 14:59:47 +03:00
parent 81960df31b
commit 93e337b9b2
9 changed files with 837 additions and 395 deletions

View file

@ -20586,6 +20586,30 @@
"en": "Update check failed.", "en": "Update check failed.",
"tr": "Güncelleme kontrolü başarısız" "tr": "Güncelleme kontrolü başarısız"
}, },
{
"resourceName": "Platform",
"key": "App.UpdateCheckDisabled",
"en": "Update check is disabled",
"tr": "Güncelleme kontrolü kapalı"
},
{
"resourceName": "Platform",
"key": "App.UpdateCheckDisabledInThisEnvironment",
"en": "The service worker is disabled in this environment, so the version cannot be checked.",
"tr": "Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor."
},
{
"resourceName": "Platform",
"key": "App.UpdateCompleted",
"en": "The application has been updated",
"tr": "Uygulama güncellendi"
},
{
"resourceName": "Platform",
"key": "App.UpdatedToVersion",
"en": "New version",
"tr": "Yeni sürüm"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.StaticLookup.AbpAccount", "key": "App.StaticLookup.AbpAccount",

View file

@ -1,6 +1,28 @@
{ {
"commit": "8146adf", "commit": "81960df",
"releases": [ "releases": [
{
"version": "1.1.10",
"buildDate": "2026-07-31",
"commit": "2eef3ead35848341350659828a14dc4a463e57af",
"changeLog": [
"- Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.",
"- Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.",
"- Scheduler ve TodoBard komponentler geliştirildi.",
"- Lookup editorlerin Static değerlere dil desteği eklendi."
]
},
{
"version": "1.1.09",
"buildDate": "2026-07-29",
"commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e",
"changeLog": [
"- Wizard yetenekleri artırıldı. Menu Order eklendi.",
"- GanttView sample düzenlemeleri",
"- Schedulerview sample düzenlemeleri",
"- CryptographyXml version 10.0.10"
]
},
{ {
"version": "1.1.08", "version": "1.1.08",
"buildDate": "2026-07-16", "buildDate": "2026-07-16",

View file

@ -11,6 +11,7 @@ import { Helmet } from 'react-helmet'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { getSetupStatus } from '@/services/setup.service' import { getSetupStatus } from '@/services/setup.service'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import { useAppVersionNotice } from '@/views/version/useAppVersionNotice'
let didInit = false let didInit = false
@ -62,6 +63,7 @@ const Theme = (props: CommonProps) => {
useDarkMode() useDarkMode()
useTabFocus() useTabFocus()
useNotification() useNotification()
useAppVersionNotice()
type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']> type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>

View file

@ -47,9 +47,6 @@ const validationSchema = Yup.object().shape({
const Login = () => { const Login = () => {
const navigate = useNavigate() const navigate = useNavigate()
const UiVersion = useStoreState((state) => state.locale.currentUiVersion)
const { setUiVersion } = useStoreActions((a) => a.locale)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [error, setError] = useTimeOutMessage(300000) const [error, setError] = useTimeOutMessage(300000)
const [twoFactor, setTwoFactor] = useState(false) const [twoFactor, setTwoFactor] = useState(false)
@ -152,9 +149,6 @@ const Login = () => {
setShowCaptcha(false) setShowCaptcha(false)
setError('') setError('')
setMessage('') setMessage('')
// Versiyon kontrolü
findUiVersion()
} }
if (showCaptcha) { if (showCaptcha) {
@ -165,19 +159,6 @@ const Login = () => {
setSubmitting(false) setSubmitting(false)
} }
const findUiVersion = async () => {
try {
const res = await fetch(`/version.json?ts=${Date.now()}`)
const latest = (await res.json())?.releases?.[0]?.version
if (latest && UiVersion !== latest) {
setUiVersion(latest)
navigate(ROUTES_ENUM.protected.admin.changeLog)
}
} catch (e) {
console.warn('Versiyon okunamadı', e)
}
}
return ( return (
<> <>
<Helmet <Helmet

View file

@ -20,6 +20,13 @@ export const useManualUpdateCheck = () => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
// translate() eksik anahtarda anahtarın kendisini döndürüyor; kullanıcıya
// "::App.X" göstermemek için okunabilir bir karşılığa düş.
const label = (key: string, fallback: string) => {
const value = translate(key)
return !value || value === key || value.includes('::') ? fallback : value
}
const checkForUpdate = async () => { const checkForUpdate = async () => {
if (isCheckingUpdate) return if (isCheckingUpdate) return
setIsCheckingUpdate(true) setIsCheckingUpdate(true)
@ -34,6 +41,19 @@ export const useManualUpdateCheck = () => {
</Notification>, </Notification>,
{ placement: 'bottom-end' }, { placement: 'bottom-end' },
) )
} else if (result === 'disabled') {
toast.push(
<Notification
title={label('::App.UpdateCheckDisabled', 'Güncelleme kontrolü kapalı')}
type="info"
>
{label(
'::App.UpdateCheckDisabledInThisEnvironment',
'Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor.',
)}
</Notification>,
{ placement: 'bottom-end' },
)
} else if (result === 'unsupported') { } else if (result === 'unsupported') {
toast.push( toast.push(
<Notification title={translate('::App.UpdateCouldNotBeChecked')} type="warning"> <Notification title={translate('::App.UpdateCouldNotBeChecked')} type="warning">

View file

@ -1,122 +1,132 @@
import { registerSW } from 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
import { store } from '@/store' import { store } from '@/store'
const ACTIVATION_RETRY_DELAY = 5_000 /**
const ACTIVATION_TIMEOUT = 20_000 * 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.
*/
let registrationStarted = false export type ManualUpdateResult =
let activationInProgress = false | 'up-to-date'
let reloadStarted = false | 'updating'
let currentRegistration: ServiceWorkerRegistration | undefined /** Tarayıcı service worker desteklemiyor. */
let activateUpdateHandler: ((reloadPage?: boolean) => Promise<void>) | undefined | 'unsupported'
let registrationInitialization: Promise<void> | undefined /** Destekleniyor ama bu ortamda kapalı (dev; VITE_PWA_DEV=true ile açılır). */
let activationRetryTimer: number | undefined | 'disabled'
let activationTimeoutTimer: number | undefined
let overlayStoreUnsubscribe: (() => void) | undefined
const watchedInstallingWorkers = new WeakSet<ServiceWorker>()
export type ManualUpdateResult = 'up-to-date' | 'updating' | 'unsupported' 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'
type Release = { type Release = {
version: string version: string
changeLog?: string[] changeLog?: string[]
} }
const getText = (value: unknown) => let started = false
value === undefined || value === null ? undefined : String(value) 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
function updateOverlayApiVersion(overlay: HTMLElement) { const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
const apiConfig = store.getState().abpConfig.config?.extraProperties const watchedWorkers = new WeakSet<ServiceWorker>()
const apiEnvironment = getText(apiConfig?.environment)
const apiVersion = getText(apiConfig?.version)
if (!apiEnvironment && !apiVersion) return false /* ------------------------------------------------------------------ */
/* Sürüm bilgisi */
/* ------------------------------------------------------------------ */
const apiValue = overlay.querySelector<HTMLElement>('[data-sw-api-version]') const loadLatestRelease = () =>
if (apiValue) { (releasePromise ??= fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' })
apiValue.textContent = `${apiEnvironment ?? '-'}:${apiVersion ?? '-'}` .then((response) => {
} if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`)
return response.json() as Promise<{ releases?: Release[] }>
return true
}
function watchOverlayApiVersion(overlay: HTMLElement) {
overlayStoreUnsubscribe?.()
if (updateOverlayApiVersion(overlay)) return
overlayStoreUnsubscribe = store.subscribe(() => {
if (!overlay.isConnected || updateOverlayApiVersion(overlay)) {
overlayStoreUnsubscribe?.()
overlayStoreUnsubscribe = undefined
}
}) })
} .then((data) => {
const release = data.releases?.[0]
async function loadUpdateOverlayDetails(overlay: HTMLElement) { latestVersion = release?.version ?? latestVersion
const state = store.getState() return release
const uiEnvironment = import.meta.env.MODE })
.catch((error: unknown) => {
let uiVersion = state.locale.currentUiVersion console.warn('Sürüm bilgisi okunamadı.', error)
let latestRelease: Release | undefined releasePromise = undefined
return undefined
}))
/** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */
export const consumeAppUpdatedVersion = (): string | undefined => {
try { try {
const response = await fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) const value = sessionStorage.getItem(UPDATED_VERSION_KEY)
if (!response.ok) throw new Error(`Version request failed with ${response.status}`) if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY)
return value ?? undefined
const releases = ((await response.json()) as { releases?: Release[] }).releases } catch {
latestRelease = releases?.[0] return undefined
uiVersion = latestRelease?.version ?? uiVersion }
} catch (error) {
console.warn('Update details could not be loaded.', error)
} }
// The overlay may have been removed while version.json was loading. /* ------------------------------------------------------------------ */
if (!overlay.isConnected) return /* Arayüz */
/* ------------------------------------------------------------------ */
const uiValue = overlay.querySelector<HTMLElement>('[data-sw-ui-version]') const STYLE_ID = 'sw-update-style'
const releaseTitle = overlay.querySelector<HTMLElement>('[data-sw-release-title]') const BANNER_ID = 'sw-update-banner'
const releaseList = overlay.querySelector<HTMLUListElement>('[data-sw-release-list]') const OVERLAY_ID = 'sw-update-overlay'
if (uiValue) uiValue.textContent = `${uiEnvironment}:${uiVersion ?? '-'}` function ensureStyles() {
watchOverlayApiVersion(overlay) if (document.getElementById(STYLE_ID)) return
if (!releaseList || !releaseTitle) return
const changes = latestRelease?.changeLog?.filter(Boolean) ?? []
releaseTitle.textContent = latestRelease?.version
? `What's new in v${latestRelease.version}`
: "What's new"
releaseList.replaceChildren()
if (changes.length === 0) {
const item = document.createElement('li')
item.className = 'sw-update-empty'
item.textContent = 'Release notes are not available.'
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 = '\u2713'
text.textContent = change.replace(/^\s*-\s*/, '')
item.append(icon, text)
releaseList.appendChild(item)
})
}
function showUpdateOverlay() {
if (document.getElementById('sw-update-overlay')) return
const style = document.createElement('style') const style = document.createElement('style')
style.id = 'sw-update-overlay-style' style.id = STYLE_ID
style.textContent = ` style.textContent = `
#sw-update-overlay { #${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; position: fixed;
inset: 0; inset: 0;
z-index: 99999; z-index: 99999;
@ -127,7 +137,7 @@ function showUpdateOverlay() {
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
} }
#sw-update-overlay .sw-update-card { .sw-update-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -141,7 +151,8 @@ function showUpdateOverlay() {
max-height: min(90vh, 720px); max-height: min(90vh, 720px);
width: 90%; width: 90%;
} }
#sw-update-overlay .sw-update-spinner { .sw-update-spinner {
flex: 0 0 auto;
width: 56px; width: 56px;
height: 56px; height: 56px;
border: 5px solid #e5e7eb; border: 5px solid #e5e7eb;
@ -149,21 +160,17 @@ function showUpdateOverlay() {
border-radius: 50%; border-radius: 50%;
animation: sw-spin 0.8s linear infinite; animation: sw-spin 0.8s linear infinite;
} }
@keyframes sw-spin { #${BANNER_ID} .sw-update-spinner {
to { transform: rotate(360deg); } width: 20px;
height: 20px;
border-width: 3px;
border-color: rgba(255,255,255,0.25);
border-top-color: #a5b4fc;
} }
#sw-update-overlay .sw-update-title { @keyframes sw-spin { to { transform: rotate(360deg); } }
font-size: 18px; .sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
font-weight: 700; .sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; }
color: #1f2937; .sw-update-versions {
margin: 0;
}
#sw-update-overlay .sw-update-desc {
font-size: 14px;
color: #6b7280;
margin: 0;
}
#sw-update-overlay .sw-update-versions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -175,13 +182,9 @@ function showUpdateOverlay() {
background: #f3f4f6; background: #f3f4f6;
color: #4b5563; color: #4b5563;
font-size: 13px; font-size: 13px;
text-transform: capitalize;
} }
#sw-update-overlay .sw-update-separator { .sw-update-separator { color: #9ca3af; margin: 0 4px; }
color: #9ca3af; .sw-update-release {
margin: 0 4px;
}
#sw-update-overlay .sw-update-release {
width: 100%; width: 100%;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@ -189,13 +192,8 @@ function showUpdateOverlay() {
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
padding-top: 14px; padding-top: 14px;
} }
#sw-update-overlay .sw-update-release-title { .sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; }
margin: 0 0 10px; .sw-update-list {
color: #374151;
font-size: 14px;
font-weight: 700;
}
#sw-update-overlay .sw-update-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@ -206,12 +204,8 @@ function showUpdateOverlay() {
font-size: 13px; font-size: 13px;
line-height: 1.45; line-height: 1.45;
} }
#sw-update-overlay .sw-update-list li { .sw-update-list li { display: flex; align-items: flex-start; gap: 8px; }
display: flex; .sw-update-check {
align-items: flex-start;
gap: 8px;
}
#sw-update-overlay .sw-update-check {
display: inline-flex; display: inline-flex;
flex: 0 0 16px; flex: 0 0 16px;
align-items: center; align-items: center;
@ -225,51 +219,189 @@ function showUpdateOverlay() {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
} }
#sw-update-overlay .sw-update-empty { .sw-update-empty { display: block; color: #9ca3af; }
display: block; .sw-update-action {
color: #9ca3af; appearance: none;
border: 0;
cursor: pointer;
padding: 10px 20px;
border-radius: 8px;
background: #4f46e5;
color: #fff;
font-size: 14px;
font-weight: 600;
} }
.sw-update-action:hover { background: #4338ca; }
@media (max-width: 640px) { @media (max-width: 640px) {
#sw-update-overlay .sw-update-card { .sw-update-card { padding: 24px 20px; }
padding: 24px 20px; #${BANNER_ID} { left: 16px; right: 16px; max-width: none; }
}
} }
` `
document.head.appendChild(style) 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') const overlay = document.createElement('div')
overlay.id = 'sw-update-overlay' overlay.id = OVERLAY_ID
overlay.setAttribute('role', 'status') overlay.setAttribute('role', 'status')
overlay.setAttribute('aria-live', 'polite') overlay.setAttribute('aria-live', 'polite')
overlay.innerHTML = ` overlay.innerHTML = `
<div class="sw-update-card"> <div class="sw-update-card">
<div class="sw-update-spinner"></div> <div class="sw-update-spinner"></div>
<p class="sw-update-title">System Updating</p> <p class="sw-update-title" data-sw-title>Sistem güncelleniyor</p>
<p class="sw-update-desc">Loading new version, please wait...<br/>The page will reload automatically.</p> <p class="sw-update-desc" data-sw-desc>Yeni sürüm yükleniyor, lütfen bekleyin...<br/>Sayfa otomatik olarak yenilenecek.</p>
<div class="sw-update-versions"> <div class="sw-update-versions">
<span><strong>UI:</strong> <span data-sw-ui-version>Loading...</span></span> <span><strong>UI:</strong> <span data-sw-ui-version>Yükleniyor...</span></span>
<span class="sw-update-separator">|</span> <span class="sw-update-separator">|</span>
<span><strong>API:</strong> <span data-sw-api-version>Loading...</span></span> <span><strong>API:</strong> <span data-sw-api-version>Yükleniyor...</span></span>
</div> </div>
<div class="sw-update-release"> <div class="sw-update-release">
<p class="sw-update-release-title" data-sw-release-title>What's new</p> <p class="sw-update-release-title" data-sw-release-title>Sürüm notları</p>
<ul class="sw-update-list" data-sw-release-list> <ul class="sw-update-list" data-sw-release-list>
<li class="sw-update-empty">Loading release notes...</li> <li class="sw-update-empty">Sürüm notları yükleniyor...</li>
</ul> </ul>
</div> </div>
</div> </div>
` `
document.body.appendChild(overlay) document.body.appendChild(overlay)
void loadUpdateOverlayDetails(overlay) void fillOverlayDetails(overlay)
} }
function hideUpdateOverlay() { /** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */
overlayStoreUnsubscribe?.() function showManualReloadPrompt() {
overlayStoreUnsubscribe = undefined const overlay = document.getElementById(OVERLAY_ID)
document.getElementById('sw-update-overlay')?.remove() if (!overlay) return
document.getElementById('sw-update-overlay-style')?.remove()
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() { function clearActivationTimers() {
window.clearTimeout(activationRetryTimer) window.clearTimeout(activationRetryTimer)
window.clearTimeout(activationTimeoutTimer) window.clearTimeout(activationTimeoutTimer)
@ -278,189 +410,283 @@ function clearActivationTimers() {
} }
function reloadForUpdate() { function reloadForUpdate() {
if (reloadStarted) return if (reloading) return
reloadStarted = true reloading = true
clearActivationTimers() clearActivationTimers()
try {
if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion)
} catch {
/* sessionStorage kullanılamıyorsa bildirim atlanır */
}
window.location.reload() window.location.reload()
} }
function activateWaitingWorker( function postSkipWaiting() {
registration: ServiceWorkerRegistration | undefined, registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
activateUpdate: (reloadPage?: boolean) => Promise<void>, }
showOverlay: boolean,
) { function activateWaitingWorker() {
if (activationInProgress) { if (activating) {
if (showOverlay) showUpdateOverlay() showActivationOverlay()
return return
} }
activationInProgress = true activating = true
showActivationOverlay()
// Sürüm numarasını yenileme öncesinde hazır tut.
void loadLatestRelease()
if (showOverlay) { navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true })
showUpdateOverlay() postSkipWaiting()
}
// Workbox'ın controlling olayı herhangi bir nedenle kaçarsa native olay da // Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor.
// yeni worker kontrolü aldığı anda sayfayı yeniler. activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY)
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, {
once: true,
})
// Workbox callback sırasına bağlı kalmadan waiting worker'ı doğrudan geçir.
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
void activateUpdate(true).catch((error: unknown) => {
console.error('Service worker activation failed.', error)
activationInProgress = false
clearActivationTimers()
hideUpdateOverlay()
})
// Bazı tarayıcılarda Workbox mesajı bekleyen worker'a ulaşmayabiliyor.
// Kısa bir süre sonra native mesajla bir kez daha aktivasyon istenir.
activationRetryTimer = window.setTimeout(() => {
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
}, ACTIVATION_RETRY_DELAY)
// Aktivasyon yine de tamamlanamazsa uygulamayı sonsuza kadar kilitleme.
// Eski worker çalışmaya devam eder; bir sonraki açılışta tekrar denenir.
activationTimeoutTimer = window.setTimeout(() => { activationTimeoutTimer = window.setTimeout(() => {
activationInProgress = false
clearActivationTimers() clearActivationTimers()
activating = false
// Worker waiting durumundan çıktıysa aktivasyon gerçekleşmiş fakat // Worker waiting'den çıktıysa aktivasyon olmuş, sadece controllerchange
// controllerchange olayı kaçmış olabilir. Yeni precache'i almak için yenile. // kaçmış olabilir; yeni precache'i almak için yenile.
if (!registration?.waiting && registration?.active) { if (!registration?.waiting && registration?.active) {
reloadForUpdate() reloadForUpdate()
return return
} }
hideUpdateOverlay() console.warn('Service worker aktivasyonu zaman aşımına uğradı.')
console.warn('Service worker activation timed out; continuing with current version.') showManualReloadPrompt()
}, ACTIVATION_TIMEOUT) }, ACTIVATION_TIMEOUT)
} }
function showUpdateWhileInstalling(registration: ServiceWorkerRegistration) { /* ------------------------------------------------------------------ */
const installingWorker = registration.installing /* Güncelleme takibi */
if ( /* ------------------------------------------------------------------ */
!installingWorker ||
!navigator.serviceWorker.controller || function trackInstallingWorker(reg: ServiceWorkerRegistration) {
watchedInstallingWorkers.has(installingWorker) 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 return
} }
// Precache dosyalarından biri indirilemezse worker redundant olur; eski
watchedInstallingWorkers.add(installingWorker) // sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz.
showUpdateOverlay() if (worker.state === 'redundant' && !activating) {
console.warn('Yeni service worker kurulamadı, mevcut sürümle devam ediliyor.')
installingWorker.addEventListener('statechange', () => { teardownUpdateUi()
// Precache dosyalarından biri indirilemezse worker redundant olur. Eski
// uygulama çalışmaya devam eder ve kullanıcı kilitli ekranda bırakılmaz.
if (installingWorker.state === 'redundant' && !activationInProgress) {
hideUpdateOverlay()
} }
}) })
} }
function watchForUpdate(registration: ServiceWorkerRegistration) { function watchRegistration(reg: ServiceWorkerRegistration) {
registration.addEventListener('updatefound', () => { if (watchedRegistrations.has(reg)) return
// Gerçek worker güncellemesi bulunduğu anda, precache indirmesi başlamadan watchedRegistrations.add(reg)
// Loading'i göster ve aktivasyon/reload tamamlanana kadar açık tut. reg.addEventListener('updatefound', () => trackInstallingWorker(reg))
showUpdateWhileInstalling(registration) trackInstallingWorker(reg)
})
// Listener bağlanmadan hemen önce başlamış bir kurulumu da kaçırma.
showUpdateWhileInstalling(registration)
} }
function checkForUpdate(registration: ServiceWorkerRegistration) { /**
// Bir deploy versiyon numarası/tag değişmeden yapılsa bile sw.js içindeki * Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı
// precache manifest'i değişir. Uygulama açılırken yapılan bu tek kontrol * değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı
// yeni build'i algılar; açık uygulamada periyodik bir arka plan işi çalışmaz. * "unsupported MIME type" SecurityError'ı fırlatır. Bu durumda eski kayıt
void registration.update().catch((error: unknown) => { * kalıcı olarak bozuktur; temizlenmezse her kontrol aynı hatayı verir.
console.warn('Service worker update check failed.', error) */
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 */
}
} }
export const registerServiceWorker = () => { export const registerServiceWorker = () => {
if (registrationStarted || !('serviceWorker' in navigator)) return if (started || !('serviceWorker' in navigator)) return
registrationStarted = true started = true
registrationInitialization = navigator.serviceWorker handleChunkLoadFailures()
.getRegistration()
.catch(() => undefined) if (!isServiceWorkerEnabled) {
.then((existingRegistration) => { // Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i
if (existingRegistration) { // (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her
watchForUpdate(existingRegistration) // güncelleme kontrolü "unsupported MIME type" hatası verir.
initialization = unregisterAll()
return
} }
const activateUpdate = registerSW({ 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, immediate: true,
onRegisteredSW(_swUrl, registration) { onRegisteredSW(_swUrl, reg) {
if (!registration) return if (reg) {
registration = reg
watchRegistration(reg)
startUpdateWatchers()
if (registration !== existingRegistration) { // Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa
watchForUpdate(registration) // doğrudan devreye al.
if (reg.waiting && navigator.serviceWorker.controller) {
activateWaitingWorker()
} else {
void runUpdateCheck(true)
} }
currentRegistration = registration
checkForUpdate(registration)
// waiting yalnızca aktif worker'dan farklı, kurulumu tamamlanmış gerçek
// bir worker olduğunda bulunur. Onu doğrudan etkinleştir.
if (registration.waiting && navigator.serviceWorker.controller) {
activateWaitingWorker(registration, activateUpdate, true)
} }
settle()
}, },
onNeedRefresh() { onNeedRefresh() {
// Bu callback worker'ın bütün yeni JS/CSS dosyalarını başarıyla precache'e // Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır.
// almasından sonra çalışır; aktivasyon ve reload atomik yapılır. activateWaitingWorker()
activateWaitingWorker(currentRegistration, activateUpdate, true)
},
onNeedReload() {
reloadForUpdate()
},
onOfflineReady() {
console.log('📦 App offline ready')
}, },
onRegisterError(error) { onRegisterError(error) {
activationInProgress = false activating = false
clearActivationTimers() clearActivationTimers()
hideUpdateOverlay() teardownUpdateUi()
console.error('Service worker registration failed.', error) console.error('Service worker kaydı başarısız.', error)
settle()
}, },
}) })
activateUpdateHandler = activateUpdate
}) })
} }
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => { export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
if (!('serviceWorker' in navigator)) return 'unsupported' if (!('serviceWorker' in navigator)) return 'unsupported'
if (!isServiceWorkerEnabled) return 'disabled'
// App içindeki normal kayıt işlemi henüz bitmediyse önce onu tamamla. await initialization
await registrationInitialization // `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman
const registration = // resolve etmez; getRegistration() kayıt yoksa undefined döner.
currentRegistration ?? (await navigator.serviceWorker.ready.catch(() => undefined)) const reg =
registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined))
if (!reg) return 'unsupported'
if (!registration) return 'unsupported' registration ??= reg
watchRegistration(reg)
if (reg.waiting && navigator.serviceWorker.controller) {
activateWaitingWorker()
return 'updating'
}
let updateFound = false let updateFound = false
const handleUpdateFound = () => { const handleUpdateFound = () => {
updateFound = true updateFound = true
} }
registration.addEventListener('updatefound', handleUpdateFound)
let staleRegistrationCleared = false
reg.addEventListener('updatefound', handleUpdateFound)
try { try {
await registration.update() 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 { } finally {
registration.removeEventListener('updatefound', handleUpdateFound) reg.removeEventListener('updatefound', handleUpdateFound)
lastCheckAt = Date.now()
} }
const updateAvailable = Boolean(updateFound || registration.installing || registration.waiting) // Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek.
if (!updateAvailable) return 'up-to-date' if (staleRegistrationCleared) return 'up-to-date'
if (registration.waiting && activateUpdateHandler) { if (!updateFound && !reg.installing && !reg.waiting) return 'up-to-date'
activateWaitingWorker(registration, activateUpdateHandler, true)
}
// installing durumundaysa gerçek onNeedRefresh callback'i indirme bittikten if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
// sonra aynı overlay/aktivasyon akışını başlatır. // `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür.
return 'updating' return 'updating'
} }

View file

@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useStoreActions } from '@/store'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { consumeAppUpdatedVersion } from './swRegistration'
/**
* ılışta çalışan tek sürüm kaynağı:
* - `version.json`'daki güncel sürümü store'a yazar (footer/changelog bunu okur).
* - Service worker güncellemesi sonrası yenilenen sayfada "güncellendi"
* bildirimini gösterip changelog'a yönlendirme sunar.
*
* Daha önce bu işi Login ekranı yapıyordu; store'daki başlangıç sürümü
* `undefined` olduğu için her girişte changelog'a zorla yönlendiriyordu.
*/
export const useAppVersionNotice = () => {
const navigate = useNavigate()
const { translate } = useLocalization()
const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion)
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const label = (key: string, fallback: string) => {
const value = translate(key)
return !value || value === key || value.includes('::') ? fallback : value
}
const updatedVersion = consumeAppUpdatedVersion()
const controller = new AbortController()
fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store', signal: controller.signal })
.then((response) => (response.ok ? response.json() : undefined))
.then(async (data?: { releases?: { version: string }[] }) => {
const latest = data?.releases?.[0]?.version
if (latest) setUiVersion(latest)
if (!updatedVersion) return
// toast/Notification (ve bağımlısı framer-motion) yalnızca güncelleme
// sonrası bu tek seferlik bildirimde gerekiyor; entry chunk'ta taşınmaz.
const [{ default: toast }, { default: Notification }] = await Promise.all([
import('@/components/ui/toast'),
import('@/components/ui/Notification'),
])
toast.push(
<Notification
closable
duration={10000}
title={label('::App.UpdateCompleted', 'Uygulama güncellendi')}
type="success"
>
{label('::App.UpdatedToVersion', 'Yeni sürüm')} v{updatedVersion} {' '}
<button
type="button"
className="underline"
onClick={() => navigate(ROUTES_ENUM.protected.admin.changeLog)}
>
{label('::App.ChangeLog', 'Değişiklikleri gör')}
</button>
</Notification>,
{ placement: 'bottom-end' },
)
})
.catch(() => {
/* sürüm bilgisi okunamadıysa sessiz geç */
})
return () => controller.abort()
}, [])
}
export default useAppVersionNotice

View file

@ -7,6 +7,9 @@ interface ImportMetaEnv {
readonly VITE_REACT_APP_VERSION: string readonly VITE_REACT_APP_VERSION: string
readonly VITE_AI_URL: string readonly VITE_AI_URL: string
readonly VITE_GOOGLE_MAPS_API_KEY: string readonly VITE_GOOGLE_MAPS_API_KEY: string
readonly VITE_USE_POLLING?: string
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
readonly VITE_PWA_DEV?: string
} }
interface ImportMeta { interface ImportMeta {

View file

@ -2,16 +2,47 @@ import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// import { fetchTenantNames } from './src/services/tenants'
export default defineConfig(async ({ mode }) => { /**
* Precache stratejisi
* ------------------------------------------------------------------
* `dist` klasörü ~55 MB (48 adet DevExtreme teması ~33 MB + lazy chunk'lar
* ~20 MB). Bunların tamamını precache etmek her deploy'da service worker'ın
* on-larca MB indirmesine, mobil/yavaş bağlantıda kurulumun timeout'a düşüp
* worker'ın `redundant` olmasına ve güncellemenin hiç tamamlanmamasına yol
* ıyordu.
*
* Bu yüzden precache SADECE uygulama kabuğudur (index.html + entry JS + CSS,
* ~2.8 MB). Geri kalan her şey (lazy chunk, tema CSS'i, görsel, font) runtime
* cache ile ilk kullanımda saklanır. Dosya adları hash'li olduğu için
* CacheFirst güvenlidir; hash'siz public dosyalarda StaleWhileRevalidate
* kullanılır.
*/
const PRECACHE_GLOBS = [
'index.html',
// manifest.webmanifest ve manifest ikonları vite-plugin-pwa tarafından
// otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir.
'favicon.ico',
'assets/js/entry-*.js',
// Sadece entry CSS'i. Rapor tasarımcısı/görüntüleyici CSS'leri (~580 KB)
// lazy yüklendiği için runtime cache'e bırakılır.
'assets/css/index-*.css',
]
// Deploy'da silinen eski build dosyalarını precache manifestine sokmamak için
// runtime cache'e alınacak hash'li uygulama varlıkları.
const HASHED_ASSET_PATTERN = /\/assets\/.+\.(?:js|css|wasm)$/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const apiUrl = env.VITE_API_URL const isProduction = mode === 'production'
const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com'] const usePolling = env.VITE_USE_POLLING === 'true'
const tenantHosts: string[] = [] // Dev'de service worker varsayılan olarak kapalıdır; açık olduğunda HMR
// sonrası eski chunk'lar cache'den servis edilip kafa karıştırıyor.
const enableDevSw = env.VITE_PWA_DEV === 'true'
const allowedHosts = Array.from(new Set(['localhost', ...baseDomains, ...tenantHosts])) const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com']
console.log('> [vite] allowedHosts:', allowedHosts) const allowedHosts = Array.from(new Set(['localhost', ...baseDomains]))
return { return {
plugins: [ plugins: [
@ -20,87 +51,83 @@ export default defineConfig(async ({ mode }) => {
// Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak // Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak
// yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz. // yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz.
registerType: 'prompt', registerType: 'prompt',
// Kayıt ve loading akışı src/views/version/swRegistration.ts tarafından yönetilir. // Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir.
injectRegister: false, injectRegister: false,
// Dev ortamında SW'yi aç, prod'da kapalı tut (build edilmiş SW prod'da zaten aktif olur)
devOptions: { devOptions: {
enabled: mode !== 'production', enabled: enableDevSw,
type: 'module', // Modern module worker kullan type: 'module',
}, },
workbox: workbox: {
mode === 'production'
? {
globDirectory: 'dist', globDirectory: 'dist',
// Eski bir sekme yeni deploy sırasında henüz açmadığı lazy globPatterns: PRECACHE_GLOBS,
// chunk'ları da çalıştırabilsin; tüm uygulama dosyalarını sakla.
globPatterns: ['**/*.{js,css,html,wasm}'],
// Büyük asset'leri de cache'leyebil // Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB).
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
// Yeni worker tüm precache dosyalarını indirdikten sonra uygulama // Yeni worker precache'i bitirdikten sonra uygulama SKIP_WAITING
// SKIP_WAITING mesajını gönderir. clientsClaim açık sekmeleri o anda yeniler. // gönderir; clientsClaim açık sekmelerin kontrolünü o an devralır.
clientsClaim: true, clientsClaim: true,
skipWaiting: false, skipWaiting: false,
// Eski workbox cache'lerini temizle
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
// SPA fallback'i API çağrılarına uygulama navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//], navigateFallbackAllowlist: [/^(?!\/__).*/],
navigateFallbackDenylist: [/^\/api\//, /^\/connect\//, /^\/swagger/, /\/version\.json$/],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /\.(?:js|css|wasm)$/, // Sürüm/changelog bilgisi asla eski kalmamalı.
// Hash'li build dosyaları immutable'dır. Normal kullanımda ağ
// beklenmez; deploy güncellemesini precache manifest'i yönetir.
handler: 'CacheFirst',
options: {
cacheName: 'static-resources-v2',
expiration: {
maxEntries: 200,
maxAgeSeconds: 30 * 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
// Changelog/versiyon bilgisi cache yüzünden eski kalmamalı.
urlPattern: /\/version\.json$/, urlPattern: /\/version\.json$/,
handler: 'NetworkFirst', handler: 'NetworkFirst',
options: { options: {
cacheName: 'app-version-v1', cacheName: 'app-version-v2',
networkTimeoutSeconds: 3, networkTimeoutSeconds: 3,
expiration: { expiration: { maxEntries: 2, maxAgeSeconds: 24 * 60 * 60 },
maxEntries: 5, cacheableResponse: { statuses: [0, 200] },
maxAgeSeconds: 24 * 60 * 60,
},
cacheableResponse: {
statuses: [0, 200],
},
}, },
}, },
{ {
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/, // Hash'li build çıktıları immutable'dır.
urlPattern: HASHED_ASSET_PATTERN,
handler: 'CacheFirst', handler: 'CacheFirst',
options: { options: {
cacheName: 'images', cacheName: 'app-assets-v3',
expiration: { expiration: { maxEntries: 400, maxAgeSeconds: 60 * 24 * 60 * 60 },
maxEntries: 100, cacheableResponse: { statuses: [0, 200] },
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 gün
}, },
}, },
{
// public/css altındaki DevExtreme temaları hash'siz; içerik
// sürümle değişebildiği için arka planda tazelenir.
urlPattern: /\/css\/[\w.-]+\.css$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'dx-themes-v1',
expiration: { maxEntries: 12, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /\.(?:woff2?|ttf|otf|eot)$/,
handler: 'CacheFirst',
options: {
cacheName: 'fonts-v1',
expiration: { maxEntries: 40, maxAgeSeconds: 180 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /\.(?:png|jpe?g|svg|gif|webp|ico)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-v2',
expiration: { maxEntries: 150, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
}, },
], ],
},
// ⭐ YENİ EKLENEN: Additional navigation route for SPA
navigateFallback: '/index.html',
navigateFallbackAllowlist: [/^(?!\/__).*/],
}
: undefined,
manifest: { manifest: {
name: 'Sözsoft Platform', name: 'Sözsoft Platform',
@ -137,10 +164,14 @@ export default defineConfig(async ({ mode }) => {
server: { server: {
open: true, open: true,
port: 3000, port: 3000,
// ⭐ YENİ EKLENEN: Hot reload için polling
watch: { watch: {
usePolling: env.VITE_USE_POLLING === 'true', usePolling,
interval: env.VITE_USE_POLLING === 'true' ? 1000 : undefined, interval: usePolling ? 1000 : undefined,
},
// Sık dokunulan giriş dosyalarını önceden transform ederek ilk
// gezinmedeki dev sunucu gecikmesini azalt.
warmup: {
clientFiles: ['./src/main.tsx', './src/App.tsx', './src/components/layouts/index.ts'],
}, },
}, },
@ -154,24 +185,85 @@ export default defineConfig(async ({ mode }) => {
}, },
}, },
optimizeDeps: {
// Barrel/CJS ağırlıklı paketleri dev cold-start'ta tek seferde
// prebundle et; aksi halde ilk sayfa yüzlerce ayrı istek atıyor.
include: [
'react',
'react-dom',
'react-dom/client',
'react-router-dom',
'@tanstack/react-query',
'@tanstack/react-table',
'axios',
'dayjs',
'formik',
'yup',
'classnames',
'lodash',
'easy-peasy',
'framer-motion',
'react-icons/fa',
// CJS/UMD bundle. `exclude` edilirse interop bozulup
// "Babel.transform is not a function" hatası veriyor. Ayrıca burada
// listelenince dinamik import anındaki "new dependency optimized"
// kaynaklı sayfa yenilemesi de olmuyor.
'@babel/standalone',
],
},
esbuild: {
legalComments: 'none',
drop: isProduction ? ['debugger'] : [],
},
build: { build: {
outDir: 'dist', outDir: 'dist',
// Vite'ın varsayılan 'baseline-widely-available' hedefi bilerek korunuyor:
// es2022'ye sabitlemek esbuild'i downlevel'a zorlayıp entry chunk'ı
// ~200 KB büyütüyor (ölçüldü).
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 2000,
sourcemap: false, sourcemap: false,
emptyOutDir: true, emptyOutDir: true,
cssCodeSplit: true,
// Modern tarayıcı hedefinde polyfill gereksiz; entry'den birkaç KB düşer.
modulePreload: { polyfill: false },
// Build sonunda gzip boyutu hesaplamak bu boyutta bir çıktıda dakikalar sürüyor.
reportCompressedSize: false,
rollupOptions: { rollupOptions: {
output: { output: {
entryFileNames: `assets/js/[name]-[hash].js`, // `entry-` öneki precache glob'unun sadece gerçek entry chunk'ı
chunkFileNames: `assets/js/[name]-[hash].js`, // yakalamasını sağlar (node_modules kaynaklı `index-*.js` chunk'ları
assetFileNames: ({ name }) => { // yanlışlıkla precache'e girmesin).
if (/\.(css)$/.test(name ?? '')) { entryFileNames: 'assets/js/entry-[name]-[hash].js',
return 'assets/css/[name]-[hash][extname]' chunkFileNames: 'assets/js/[name]-[hash].js',
} assetFileNames: ({ name }: { name?: string }) => {
if (/\.css$/.test(name ?? '')) return 'assets/css/[name]-[hash][extname]'
if (/\.(png|jpe?g|svg|gif|ico|webp)$/.test(name ?? '')) { if (/\.(png|jpe?g|svg|gif|ico|webp)$/.test(name ?? '')) {
return 'assets/img/[name]-[hash][extname]' return 'assets/img/[name]-[hash][extname]'
} }
if (/\.(woff2?|ttf|otf|eot)$/.test(name ?? '')) {
return 'assets/fonts/[name]-[hash][extname]'
}
return 'assets/[name]-[hash][extname]' return 'assets/[name]-[hash][extname]'
}, },
// SADECE her açılışta zaten yüklenen (%100 eager) çekirdek vendor'lar
// ayrılır; bunlar sürümler arası değişmediği için tarayıcı cache'inde
// kalır ve deploy sonrası yeniden indirilmez.
//
// Not: framer-motion / lodash / formik gibi paketleri de buraya eklemek
// ölçüldü ve zararlı çıktı — Rollup'ın otomatik chunk'lamasında bu
// paketlerin bir kısmı yalnızca lazy view'lara giriyor; tek chunk'a
// toplandıklarında ilk yükleme ~650 KB büyüyor.
manualChunks(id) {
if (!id.includes('node_modules')) return undefined
const file = id.replace(/\\/g, '/')
if (/\/node_modules\/(react|react-dom|scheduler)\//.test(file)) return 'vendor-react'
if (/\/node_modules\/(react-router|react-router-dom|@remix-run)\//.test(file)) {
return 'vendor-router'
}
return undefined
},
}, },
}, },
}, },
@ -180,16 +272,11 @@ export default defineConfig(async ({ mode }) => {
host: '0.0.0.0', host: '0.0.0.0',
port: 80, port: 80,
open: false, open: false,
// Hesaplanan allowedHosts'u kullan
allowedHosts, allowedHosts,
}, },
define: { define: {
'process.env': {}, 'process.env': {},
// ⭐ YENİ EKLENEN: Version tracking için global değişkenler
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || '1.0.0'),
__BUILD_DATE__: JSON.stringify(new Date().toISOString()),
__APP_MODE__: JSON.stringify(mode),
}, },
} }
}) })