Yeni versiyon güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-12 22:08:14 +03:00
parent f292b6b6a5
commit 534dcce6da
19 changed files with 2288 additions and 777 deletions

View file

@ -684,14 +684,17 @@ konfigürasyonu ile üretilir.
### 18.4. PWA ve sürüm yönetimi
- Precache **yalnızca uygulama kabuğunu** kapsar (index.html + entry JS/CSS ≈ 2.8 MB); DevExtreme
temaları, lazy chunk'lar, font ve görseller runtime cache ile saklanır. Sebebi:
`dist` klasörü ~55 MB olduğundan tam precache, yavaş bağlantılarda service worker kurulumunun
timeout'a düşmesine yol açıyordu.
- `registerType: 'prompt'` — yeni sürüm indirilir, kullanıcı onayıyla devreye alınır
(`views/version/swRegistration.ts`, `useAppVersionNotice.tsx`).
- Kurulum iki kademelidir: **kabuk** (index.html + entry JS/CSS + eager vendor chunk'ları)
workbox precache manifest'inden gelir ve zorunludur; **bileşenler** (tüm lazy chunk'lar,
build'de üretilen `dist/warmup.json`) güncelleme penceresinde aynı ilerleme çubuğunda arka
planda indirilir. İkinci kademe best-effort'tur (inemeyen dosya kurulumu düşürmez, süre
bütçesi aşılırsa kalanlar runtime cache'e kalır); böylece deploy sonrası ilk gezinmede chunk
beklenmez ama yavaş bağlantıda kurulum timeout'a düşmez. DevExtreme temaları (~33 MB), font
ve görseller runtime cache'tedir.
- `registerType: 'prompt'` — yeni sürüm indirilir, indirme bitince otomatik devreye alınır
(`views/version/swRegistration.ts`, `sw.ts`, `AppUpdateDialog.tsx`).
- `version.json` her zaman ağdan tazelenir (NetworkFirst); changelog `/admin/changeLog`
ekranında gösterilir.
ekranında gösterilir, yeni deploy sonrası ilk girişte oraya yönlendirilir.
- `scripts/generate-version.js` build sırasında sürüm bilgisini üretir.
---

View file

@ -114,6 +114,12 @@
}
],
"LanguageTexts": [
{
"resourceName": "Platform",
"key": "App.InstallApp",
"en": "Install app",
"tr": "Uygulamayı yükle"
},
{
"resourceName": "Platform",
"key": "App.Product.ListComponent",

View file

@ -4,12 +4,17 @@ echo "Env değeri: ${ENV}"
cd ~/sozsoft-platform
git checkout main
git fetch && git pull
# --prune-tags: sunucuda elle atılmış, origin'de olmayan yerel tag'leri siler.
# Bunlar temizlenmezse `git tag` listesinde kalır ve deploy yanlış (daha yüksek)
# bir sürüm numarasıyla etiketlenir.
git fetch --prune --prune-tags --tags && git pull
cd ~/sozsoft-platform/ui
# Git tag'den son versiyonu al (örn: v1.0.11 -> 1.0.11), yoksa package.json'daki versiyon
VERSION=$(git tag --sort=-creatordate | head -n1 | sed 's/^v//' || jq -r .version package.json)
# Sürüm, HEAD'den erişilebilen son tag'dir (başka daldaki tag'ler sayılmaz);
# tag yoksa package.json'daki sürüm kullanılır.
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
VERSION=${VERSION:-$(jq -r .version package.json)}
echo "Build versiyonu: ${VERSION}"
# Deploy öncesi git üzerindeki tüm versiyon bilgilerini güncelle

View file

@ -7,7 +7,7 @@
"scripts": {
"start": "vite",
"build": "node scripts/generate-version.js && vite build",
"build:production": "vite build",
"build:production": "node scripts/generate-version.js && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "npm run lint -- --fix",

View file

@ -1,20 +1,17 @@
{
"commit": "95a7b22",
"buildId": "98e9f84.msq55max",
"buildTime": "2026-08-12T13:44:50.404Z",
"buildDate": "2026-08-12",
"commit": "98e9f84",
"packageVersion": "1.0.7",
"version": "1.2.1",
"releases": [
{
"version": "1.1.12",
"buildDate": "2026-08-12",
"commit": "5cfd0105bc7100471edd21f8c1d215fdd6ab4d40",
"changeLog": [
"- Yeni versiyon güncellemesi"
]
},
{
"version": "1.1.11",
"version": "1.2.1",
"buildDate": "2026-08-11",
"commit": "783c1fe1d35f7cb8f23fb63fad26123cbe54aafc",
"changeLog": [
"- Uygulamanın genel optimizasyonu"
"Claude.ai güncellemesi"
]
},
{
@ -22,10 +19,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."
"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."
]
},
{
@ -33,21 +30,21 @@
"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"
"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",
"buildDate": "2026-07-16",
"commit": "e5afb66d89cefc48beff4bbaa00c140bdc58fadc",
"commit": "24e92fcb82eef0ee26ca5d65e08c5404f132a09e",
"changeLog": [
"- CardView komponenti geliştirildi.",
"- Todo / Kanban komponenti geliştirildi.",
"- Licence information",
"- Check New Versiyon komponenti."
"CardView komponenti geliştirildi.",
"Todo / Kanban komponenti geliştirildi.",
"Licence information",
"Check New Versiyon komponenti."
]
},
{
@ -55,11 +52,11 @@
"buildDate": "2026-07-13",
"commit": "50cb3eaf979eb5027172a92a4e941ed64aa3671e",
"changeLog": [
"- localStorage tüm bilgiler easy-peasy içerisinde birleştirildi.",
"- Codex 5.6 sol optimizasyon yapıldı. Bir çok komponent Lazy load ile yüklendi.",
"- Workflow komponentinden birden fazla kişi onay verebilir.",
"- Posta Kodu eklendi.",
"- Sales Order kısmında Yeni ve Eski müşteri sipariş ayrımı yapıldı"
"localStorage tüm bilgiler easy-peasy içerisinde birleştirildi.",
"Codex 5.6 sol optimizasyon yapıldı. Bir çok komponent Lazy load ile yüklendi.",
"Workflow komponentinden birden fazla kişi onay verebilir.",
"Posta Kodu eklendi.",
"Sales Order kısmında Yeni ve Eski müşteri sipariş ayrımı yapıldı"
]
},
{
@ -67,9 +64,9 @@
"buildDate": "2026-07-06",
"commit": "bab639ce6343b451b81d6688e4f7af666f0233da",
"changeLog": [
"- Çekirdek kod için optimizasyon",
"- List Reportlara Gruplu ve Alttoplam eklendi.",
"- Paketlerdeki deprecated uyarları kaldırıldı"
"Çekirdek kod için optimizasyon",
"List Reportlara Gruplu ve Alttoplam eklendi.",
"Paketlerdeki deprecated uyarları kaldırıldı"
]
},
{
@ -77,12 +74,12 @@
"buildDate": "2026-06-27",
"commit": "352cf047b0af1b8aad53b632a41161efcc6cecdf",
"changeLog": [
"- Ai için Ollama kurulumu yapıldı. Ayrıca Gemma LLM ile çalıştırıldı.",
"- DynamicReport eklendi. ReportDesigner ile çalışan bir rapor tasarlandı.",
"- DynamicGridReport eklendi. Grid üzerinde otomatik rapor alınabiliyor.",
"- DynamicTreeReport eklendi. Tree üzerinden otomatik rapor alınabiliyor.",
"- DynamicFormReport eklendi. Single ekranlarda otomatik rapor eklendi.",
"- Demo sayfası menüden ayrıldı."
"Ai için Ollama kurulumu yapıldı. Ayrıca Gemma LLM ile çalıştırıldı.",
"DynamicReport eklendi. ReportDesigner ile çalışan bir rapor tasarlandı.",
"DynamicGridReport eklendi. Grid üzerinde otomatik rapor alınabiliyor.",
"DynamicTreeReport eklendi. Tree üzerinden otomatik rapor alınabiliyor.",
"DynamicFormReport eklendi. Single ekranlarda otomatik rapor eklendi.",
"Demo sayfası menüden ayrıldı."
]
},
{
@ -90,22 +87,22 @@
"buildDate": "2026-06-04",
"commit": "20e7fae481ce69e9a678508ce03b5ed7831aea9f",
"changeLog": [
"- Settingde yapılan ayarlar Auth komponentlerine uygulandı.",
"- Public home ve diğer sayfaların tasarım değişikliği yapıldı.",
"- Route Type Dinamik ve Normal olarak ayrıldı.",
"- Form Devexpress DefaultValue özelliği eklendi.",
"- Devexpress DarkModa uygun şekilde güncellendi.",
"- Grid, Tree ve FormDevexpress setReadonly özelliği eklendi."
"Settingde yapılan ayarlar Auth komponentlerine uygulandı.",
"Public home ve diğer sayfaların tasarım değişikliği yapıldı.",
"Route Type Dinamik ve Normal olarak ayrıldı.",
"Form Devexpress DefaultValue özelliği eklendi.",
"Devexpress DarkModa uygun şekilde güncellendi.",
"Grid, Tree ve FormDevexpress setReadonly özelliği eklendi."
]
},
{
"version": "1.1.03",
"buildDate": "2026-05-30",
"commit": "f9a65a3c8b2920ea40abcba501f0e8673f51f09b",
"commit": "96f7091d46c248ba3c42849fe5d870db0ab96982",
"changeLog": [
"- User Detail komponentinin içerisinde Avatar ekleme",
"- EditForm un içerisinde EditorOptions dinamik oluşturulması",
"- Editform un içerisinde EditorScript dinamik oluşturulması"
"User Detail komponentinin içerisinde Avatar ekleme",
"EditForm un içerisinde EditorOptions dinamik oluşturulması",
"Editform un içerisinde EditorScript dinamik oluşturulması"
]
},
{
@ -113,11 +110,11 @@
"buildDate": "2026-05-27",
"commit": "84b9f6510787bd82f3797728fd675f755b4caa2d",
"changeLog": [
"- .NET 10 yükseltildi.",
"- Abp Framework 10 yükseltildi.",
"- Sql Query Manager problemleri giderildi.",
"- AuditLog problemleri giderildi.",
"- Tenan yapısını uygunluğu için düzenlemeler yapıldı."
".NET 10 yükseltildi.",
"Abp Framework 10 yükseltildi.",
"Sql Query Manager problemleri giderildi.",
"AuditLog problemleri giderildi.",
"Tenan yapısını uygunluğu için düzenlemeler yapıldı."
]
},
{
@ -125,10 +122,10 @@
"buildDate": "2026-05-24",
"commit": "6262baa6f12d695a25d83304af985092715d439a",
"changeLog": [
"- Workflow tanımlaması yapılabilir.",
"- Dark mod için uygulama güncellemesi",
"- Form Field kısmında düzenleme",
"- Hangfire Recurring Job düzenlemesi."
"Workflow tanımlaması yapılabilir.",
"Dark mod için uygulama güncellemesi",
"Form Field kısmında düzenleme",
"Hangfire Recurring Job düzenlemesi."
]
},
{
@ -136,19 +133,19 @@
"buildDate": "2026-05-11",
"commit": "414006204e324018be597a598d3dd102868c5cca",
"changeLog": [
"- Backup dosyalarını son 5 günün kalması",
"- Grid için Fit Columns özelliği eklendi.",
"- Notification Desktop, UiActivity, UiToast özelliği eklendi",
"- Versiyon güncellemeleri için \"System Updating\" mesajı"
"Backup dosyalarını son 5 günün kalması",
"Grid için Fit Columns özelliği eklendi.",
"Notification Desktop, UiActivity, UiToast özelliği eklendi",
"Versiyon güncellemeleri için \"System Updating\" mesajı"
]
},
{
"version": "1.0.9",
"buildDate": "2026-05-09",
"commit": "13e35b961f4a0cb7288ee04e179fa6f61ee996ab",
"commit": "e9d8f5ebac35fc7b21f08139dfe175ed548c14b4",
"changeLog": [
"- Video Rooms düzenlemesi",
"- Genel Liste düzenlemeleri"
"Video Rooms düzenlemesi",
"Genel Liste düzenlemeleri"
]
},
{
@ -156,10 +153,10 @@
"buildDate": "2026-05-07",
"commit": "6fa266f23ed4f67384b7bef0e4030420cb788af5",
"changeLog": [
"- Intranet ve Sosyal Duvar",
"- Listelerde ImageUploadandViewer",
"- Survey Widget",
"- Event, Category ve Type"
"Intranet ve Sosyal Duvar",
"Listelerde ImageUploadandViewer",
"Survey Widget",
"Event, Category ve Type"
]
},
{
@ -167,10 +164,10 @@
"buildDate": "2026-05-04",
"commit": "4444fce93b4f62ad6698099fefe7da1614d2859c",
"changeLog": [
"- Departman tanımlama",
"- Job Position tanımlama",
"- Wizard ve SQl Data güncellemeleri",
"- Organizasyon Şemanı eklendi"
"Departman tanımlama",
"Job Position tanımlama",
"Wizard ve SQl Data güncellemeleri",
"Organizasyon Şemanı eklendi"
]
},
{
@ -178,10 +175,10 @@
"buildDate": "2026-05-03",
"commit": "b2dfb04879a93709b04f8f0fc3bd4f5af1f7c01b",
"changeLog": [
"- SalesOrder ve SalesOrderItem listFormu tanımlandı.",
"- Sql Backup dosyalarını File Manager içerisinde görüntülenebiliyor.",
"- Wizard File Manager komponenti oluşturuldu.",
"- Wizard komponentini Edit özelliği eklendi."
"SalesOrder ve SalesOrderItem listFormu tanımlandı.",
"Sql Backup dosyalarını File Manager içerisinde görüntülenebiliyor.",
"Wizard File Manager komponenti oluşturuldu.",
"Wizard komponentini Edit özelliği eklendi."
]
},
{
@ -189,11 +186,11 @@
"buildDate": "2026-04-28",
"commit": "b9cc68ff41d675fefadab79f5ea2c5e7f376708a",
"changeLog": [
"- AllowAdding özelliği eklendi.",
"- Kulllanıcı bazında Workhour özelliği eklendi.",
"- ConcurrentUser sınırlaması getirildi.",
"- Uygulamadan kullanıcıyı kickleme özelliği getirildi.",
"- Uygulama açıkken DbMigrator çağrısı yapıldı."
"AllowAdding özelliği eklendi.",
"Kulllanıcı bazında Workhour özelliği eklendi.",
"ConcurrentUser sınırlaması getirildi.",
"Uygulamadan kullanıcıyı kickleme özelliği getirildi.",
"Uygulama açıkken DbMigrator çağrısı yapıldı."
]
},
{
@ -201,9 +198,9 @@
"buildDate": "2026-03-30",
"commit": "e9ce256c0706de408e08a462bee9e4a72653f2c7",
"changeLog": [
"- Role Yetkilerini kopyalama",
"- Place Holder güncellemesi",
"- Kayıt kopyalama özelliği"
"Role Yetkilerini kopyalama",
"Place Holder güncellemesi",
"Kayıt kopyalama özelliği"
]
},
{
@ -234,4 +231,4 @@
]
}
]
}
}

View file

@ -1,67 +1,117 @@
// scripts/write-version.js
// scripts/generate-version.js
//
// public/version.json üretir. Bu dosya uygulamanın tek sürüm kaynağıdır:
// - `buildId` : HER deploy'da değişir (commit + build zamanı). Uygulama açık
// sekmede ve açılışta yeni deploy'u bununla anlar; git tag
// atılmamış olsa bile güncelleme algılanır.
// - `version` : Kullanıcıya gösterilen sürüm (en güncel release).
// - `releases` : Changelog kaynağı. Git tag'lerinden okunur; HEAD tag'li
// değilse son tag'den bu yana atılan commit'ler "yayınlanmamış"
// sürüm olarak en üste eklenir, böylece her deploy'un notu olur.
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
function safeExec(cmd) {
try {
return execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'], shell: true })
.toString()
.trim()
return execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'], shell: true }).toString().trim()
} catch {
return null
}
}
let releases = []
let commit = null
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const uiRoot = path.resolve(scriptDir, '..')
const outputFile = path.join(uiRoot, 'public', 'version.json')
if (fs.existsSync('.git') || fs.existsSync('../.git')) {
// Git varsa: taglerden oku
const rawTags = safeExec('git tag --list --sort=creatordate')
if (rawTags) {
const tags = rawTags.split('\n').filter(Boolean)
releases = tags.map((tag) => {
const pkg = JSON.parse(fs.readFileSync(path.join(uiRoot, 'package.json'), 'utf8'))
const packageVersion = pkg.version || '0.0.0'
const buildTime = new Date().toISOString()
const buildDate = buildTime.slice(0, 10)
const hasGit = !!safeExec('git rev-parse --is-inside-work-tree')
/**
* Docker imajı içinde `.git` yoktur; sürüm bilgisi host'ta (deploy script'i ile)
* üretilip `public/version.json` olarak kopyalanır. Bu durumda dosyayı yeniden
* üretmek gerçek changelog'u ve `buildId`'yi silip yerine "yerel derleme"
* yazardı; bu yüzden mevcut dosya korunur.
*/
if (!hasGit && fs.existsSync(outputFile)) {
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
const version = existing.version ?? existing.releases?.[0]?.version ?? packageVersion
const buildId = existing.buildId ?? existing.commit ?? `${packageVersion}.${Date.now().toString(36)}`
if (!existing.buildId || !existing.version) {
existing.buildId = buildId
existing.version = version
fs.writeFileSync(outputFile, JSON.stringify(existing, null, 2))
}
console.log(`> mevcut version.json korundu: v${version} (${buildId})`)
process.exit(0)
}
const shortCommit = process.env.GIT_COMMIT || (hasGit ? safeExec('git rev-parse --short HEAD') : null)
const commit = shortCommit || 'local'
/** Tag mesajını changelog satırlarına çevirir. */
const toChangeLog = (raw) =>
(raw ?? '')
.split('\n')
.map((line) => line.replace(/^\s*[-*]\s*/, '').trim())
.filter(Boolean)
let releases = []
if (hasGit) {
// Sıralama tag adına göre (creatordate değil): sunucuda sonradan atılmış bir
// tag listeyi başa geçirip yanlış sürüm göstermesin.
const rawTags = safeExec('git tag --list --sort=-v:refname')
const tags = rawTags ? rawTags.split('\n').filter(Boolean) : []
releases = tags
.map((tag) => {
const version = tag.replace(/^v/, '')
const date = safeExec(`git log -1 --format=%ad --date=short ${tag}`)
const commitId = safeExec(`git rev-list -n 1 ${tag}`)
const messageRaw = safeExec(`git tag -l --format="%(contents)" ${tag}`)
const changeLog = messageRaw
? messageRaw
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
: ['No changelog for this tag']
const changeLog = toChangeLog(safeExec(`git tag -l --format="%(contents)" ${tag}`))
return {
version,
buildDate: date,
buildDate: date || buildDate,
commit: commitId,
changeLog,
changeLog: changeLog.length ? changeLog : ['Bu sürüm için not girilmemiş'],
}
})
}
commit = safeExec('git rev-parse --short HEAD')
} else {
// Git yoksa (ör. dev build)
const pkg = JSON.parse(fs.readFileSync('package.json'))
const version = pkg.version || '0.0.0'
commit = process.env.GIT_COMMIT || 'dev-local'
// Changelog yalnızca tag'lerden oluşur. Tag'siz commit'ler için sahte bir
// sürüm üretilmez; sürüm notlarını tag mesajı belirler. Deploy algılaması
// sürüm numarasına değil, her derlemede değişen `buildId`'ye bakar.
}
if (releases.length === 0) {
releases = [
{
version,
buildDate: new Date().toISOString().slice(0, 10),
version: packageVersion,
buildDate,
commit,
changeLog: [
'Local development build',
'Git bilgisi mevcut değil, package.json versiyonu kullanıldı',
],
changeLog: ['Yerel geliştirme derlemesi', 'Git bilgisi bulunamadı, package.json sürümü kullanıldı'],
},
]
}
const versionInfo = { commit, releases: releases.reverse() }
const versionInfo = {
// Her derlemede değişir; uygulamanın deploy algılaması buna bakar.
buildId: `${commit}.${Date.now().toString(36)}`,
buildTime,
buildDate,
commit,
packageVersion,
version: releases[0].version,
releases,
}
fs.writeFileSync('public/version.json', JSON.stringify(versionInfo, null, 2))
//console.log('> version.json güncellendi:', versionInfo)
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
fs.writeFileSync(outputFile, JSON.stringify(versionInfo, null, 2))
console.log(`> version.json: v${versionInfo.version} (${versionInfo.buildId})`)

View file

@ -1,35 +1,50 @@
#!/bin/bash
set -e
# public/version.json üretir (deploy öncesi, HOST üzerinde).
#
# Asıl uygulama scripts/generate-version.js dosyasıdır; node varsa o çalışır.
# Aşağıdaki bash karşılığı yalnızca node bulunmayan sunucular içindir ve
# js sürümüyle aynı çıktıyı üretmelidir.
cd "$(dirname "$0")/.."
echo "> version.json oluşturuluyor..."
# En yeni tag en üstte olsun
VERSIONS=$(git tag --sort=-creatordate)
if command -v node >/dev/null 2>&1; then
node scripts/generate-version.js
exit 0
fi
# origin'de olmayan yerel tag'ler sürüm numarasını bozar; temizle.
git fetch --prune --prune-tags --tags origin >/dev/null 2>&1 || true
# Sürüm sırası tag adına göre (creatordate değil); 1.1.10 > 1.1.09.
VERSIONS=$(git tag --sort=-v:refname)
ROOT_COMMIT=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
BUILD_DATE=$(date -u +%Y-%m-%d)
# Her deploy'da değişir; uygulamanın yeni sürüm algılaması buna bakar.
BUILD_ID="${ROOT_COMMIT}.$(date -u +%s)"
OUTPUT="{\"commit\":\"$ROOT_COMMIT\",\"releases\":["
RELEASES=""
FIRST=true
for TAG in $VERSIONS; do
VER=$(echo $TAG | sed 's/^v//')
DATE=$(git log -1 --format=%ad --date=short $TAG)
COMMIT=$(git rev-list -n 1 $TAG)
VER=$(echo "$TAG" | sed 's/^v//')
DATE=$(git log -1 --format=%ad --date=short "$TAG")
COMMIT=$(git rev-list -n 1 "$TAG")
MESSAGE=$(git tag -l --format="%(contents)" "$TAG" | grep -v '^$' | sed 's/^[[:space:]]*-[[:space:]]*//' | jq -R . | jq -s .)
# 🔑 Boş satırları filtrele
MESSAGE=$(git tag -l --format="%(contents)" $TAG | grep -v '^$' | jq -R . | jq -s .)
if [ "$FIRST" = true ]; then
FIRST=false
else
OUTPUT+=","
fi
OUTPUT+="{\"version\":\"$VER\",\"buildDate\":\"$DATE\",\"commit\":\"$COMMIT\",\"changeLog\":$MESSAGE}"
if [ "$FIRST" = true ]; then FIRST=false; else RELEASES+=","; fi
RELEASES+="{\"version\":\"$VER\",\"buildDate\":\"$DATE\",\"commit\":\"$COMMIT\",\"changeLog\":$MESSAGE}"
done
OUTPUT+="]}"
# Sürüm = en son tag. Tag'siz commit'ler için sahte sürüm üretilmez; deploy
# algılaması sürüm numarasına değil, her derlemede değişen buildId'ye bakar.
LAST_TAG=$(echo "$VERSIONS" | head -n1 | sed 's/^v//')
VERSION=${LAST_TAG:-$(jq -r .version package.json)}
echo "$OUTPUT" | jq . > public/version.json
printf '{"buildId":"%s","buildTime":"%s","buildDate":"%s","commit":"%s","version":"%s","releases":[%s]}' \
"$BUILD_ID" "$BUILD_TIME" "$BUILD_DATE" "$ROOT_COMMIT" "$VERSION" "$RELEASES" | jq . > public/version.json
echo "> public/version.json güncellendi:"
#cat public/version.json
echo "> version.json: v${VERSION} (${BUILD_ID})"

View file

@ -10,7 +10,7 @@ import { Helmet } from 'react-helmet'
import { useNavigate, useLocation } from 'react-router-dom'
import { getSetupStatus } from '@/services/setup.service'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useAppVersionNotice } from '@/views/version/useAppVersionNotice'
import { getRunningVersion } from '@/views/version/swRegistration'
let didInit = false
@ -73,7 +73,13 @@ const Theme = (props: CommonProps) => {
'en'
useDarkMode()
useTabFocus()
useAppVersionNotice()
// Çalışan bundle'ın sürümü footer'da gösterilir; derleme anında gömülüdür,
// ağ isteği gerektirmez.
const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion)
useEffect(() => {
setUiVersion(getRunningVersion())
}, [setUiVersion])
type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>

View file

@ -17,6 +17,7 @@ import {
FcVoicePresentation,
} from 'react-icons/fc'
import { useManualUpdateCheck } from '@/views/version/ChangeLog'
import InstallAppMenuItem from '@/views/version/InstallAppMenuItem'
type DropdownList = {
label: string
@ -122,6 +123,8 @@ const UserDropdownBase = ({ className }: CommonProps) => {
)
})}
<InstallAppMenuItem />
<Dropdown.Item
eventKey="Check New Version"
className="mb-1 px-0"

716
ui/src/sw.ts Normal file
View file

@ -0,0 +1,716 @@
/**
* Sözsoft Platform service worker (injectManifest)
* ------------------------------------------------------------------
* Workbox runtime'ı yerine elle yazıldı; tek sebebi güncelleme sırasında
* indirilen HER dosyanın adını, boyutunu ve toplam yüzdesini uygulamaya
* yayınlayabilmek (güncelleme penceresindeki progress listesi).
*
* Kurulum iki kademelidir:
* 1. KABUK (`__WB_MANIFEST`): index.html + entry JS/CSS + eager vendor
* chunk'ları. Zorunludur; biri inmezse kurulum başarısız olur.
* 2. BİLEŞENLER (`/warmup.json`): tüm lazy chunk'lar. Kullanıcı deploy sonrası
* ekranlara girerken chunk beklemesin diye aynı ilerleme çubuğunda arka
* planda indirilir. Best-effort: inemeyen dosya kurulumu düşürmez, süre
* bütçesi (WARMUP_BUDGET_MS) dolarsa kalanlar runtime cache'e bırakılır.
*
* Boyut/performans notları:
* - Tema CSS'i / görsel / font precache'e girmez, runtime cache'e alınır.
* - Zaten cache'de olan dosya yeniden indirilmez (hash'li ad = aynı içerik).
* - İndirme paralel havuzla yapılır; ilerleme mesajları 150 ms'de bir ve
* yalnızca DEĞİŞEN dosyalar için gönderilir (500 dosyalık listeyi her tick'te
* kopyalamak hem SW hem render tarafında pahalı).
*/
export type PrecacheEntry = { url: string; revision?: string | null }
type SwEvent = Event & {
waitUntil: (promise: Promise<unknown>) => void
respondWith: (response: Response | Promise<Response>) => void
request: Request
data?: { type?: string } | null
source?: { postMessage: (message: unknown) => void } | null
}
type SwClient = { id: string; postMessage: (message: unknown) => void }
declare const self: {
__WB_MANIFEST: PrecacheEntry[]
location: Location
navigator?: { connection?: { saveData?: boolean; effectiveType?: string } }
registration: { scope: string }
skipWaiting: () => Promise<void>
clients: {
claim: () => Promise<void>
matchAll: (options?: { includeUncontrolled?: boolean; type?: string }) => Promise<SwClient[]>
}
addEventListener: (type: string, listener: (event: SwEvent) => void) => void
}
/* ------------------------------------------------------------------ */
/* Sabitler */
/* ------------------------------------------------------------------ */
const SHELL_CACHE = 'sozsoft-shell-v1'
const ASSET_CACHE = 'sozsoft-assets-v1'
const THEME_CACHE = 'sozsoft-themes-v1'
const FONT_CACHE = 'sozsoft-fonts-v1'
const IMAGE_CACHE = 'sozsoft-images-v1'
const VERSION_CACHE = 'sozsoft-version-v1'
const KNOWN_CACHES = [
SHELL_CACHE,
ASSET_CACHE,
THEME_CACHE,
FONT_CACHE,
IMAGE_CACHE,
VERSION_CACHE,
]
/**
* Bir sürümün tüm chunk'ları (~500 dosya) ısıtma ile bu cache'e yazıldığı için
* limit dosya sayısının belirgin üstünde olmalı; aksi halde ısıtılan bileşenler
* daha kurulum biterken birbirini siler.
*/
const ASSET_LIMIT = 1500
const THEME_LIMIT = 12
const FONT_LIMIT = 40
const IMAGE_LIMIT = 150
/** Bileşen ısıtması için üst süre sınırı; aşılırsa kalanlar runtime cache'e kalır. */
const WARMUP_BUDGET_MS = 180_000
const SHELL_CONCURRENCY = 4
const WARMUP_CONCURRENCY = 6
const HASHED_ASSET = /^\/assets\/.+\.(?:js|css|wasm)$/
const THEME_CSS = /^\/css\/[\w.-]+\.css$/
const FONT_FILE = /\.(?:woff2?|ttf|otf|eot)$/
const IMAGE_FILE = /\.(?:png|jpe?g|svg|gif|webp|ico)$/
const NETWORK_ONLY = /^\/(?:api|connect|swagger|signalr|hangfire)\b/
const MANIFEST: PrecacheEntry[] = self.__WB_MANIFEST ?? []
const INDEX_URL = new URL('index.html', self.registration.scope).href
const WARMUP_URL = new URL('warmup.json', self.registration.scope).href
/** Hash'siz dosyalar için revision'ı cache anahtarına ekler. */
const cacheKeyFor = (entry: PrecacheEntry) => {
const url = new URL(entry.url, self.registration.scope)
if (entry.revision) url.searchParams.set('__rev', entry.revision)
return url.href
}
const shellKeys = new Map<string, string>()
MANIFEST.forEach((entry) => {
shellKeys.set(new URL(entry.url, self.registration.scope).href, cacheKeyFor(entry))
})
/* ------------------------------------------------------------------ */
/* Isıtma listesi (build'de üretilen warmup.json) */
/* ------------------------------------------------------------------ */
type WarmupEntry = { url: string; size: number }
/**
* Boyutlar derleme anında yazıldığı için ilerleme yüzdesi ilk saniyeden
* itibaren doğrudur. Dosya yoksa (dev sunucusu ya da eski deploy) ısıtma
* sessizce atlanır; kabuk kurulumu bundan etkilenmez.
*/
async function fetchWarmupList(): Promise<WarmupEntry[]> {
try {
const response = await fetch(
new Request(WARMUP_URL, { cache: 'reload', credentials: 'same-origin' }),
)
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
const data = (await response.json()) as { files?: WarmupEntry[] } | undefined
return (data?.files ?? []).map((entry) => ({
url: new URL(entry.url, self.registration.scope).href,
size: Number(entry.size) || 0,
}))
} catch (error) {
console.warn('[sw] warmup.json okunamadı, bileşen ısıtması atlanıyor.', error)
return []
}
}
let warmupPromise: Promise<WarmupEntry[]> | undefined
const loadWarmupList = () => (warmupPromise ??= fetchWarmupList())
/** Veri tasarrufu açıkken ya da 2G'de ~20 MB'lık ısıtma yapılmaz. */
function isWarmupAllowed() {
const connection = self.navigator?.connection
if (!connection) return true
if (connection.saveData) return false
return !/2g$/.test(connection.effectiveType ?? '')
}
/* ------------------------------------------------------------------ */
/* İstemciye ilerleme yayını */
/* ------------------------------------------------------------------ */
type ProgressFile = {
index: number
url: string
name: string
loaded: number
total: number
status: string
}
async function broadcast(message: Record<string, unknown>) {
// Yeni worker henüz sayfayı kontrol etmiyor; includeUncontrolled şart.
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
clients.forEach((client) => client.postMessage(message))
}
const fileName = (url: string) => {
const pathname = new URL(url).pathname
return pathname.slice(pathname.lastIndexOf('/') + 1) || pathname
}
/* ------------------------------------------------------------------ */
/* Kurulum: ilerleme bildiren precache */
/* ------------------------------------------------------------------ */
async function downloadWithProgress(
url: string,
key: string,
cache: Cache,
file: ProgressFile,
onTick: () => void,
) {
const response = await fetch(new Request(url, { cache: 'reload', credentials: 'same-origin' }))
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
// Content-Length sıkıştırılmış boyuttur, okunan gövde ise açılmış hâldir;
// yalnızca derleme boyutu bilinmiyorsa (kabuk dosyaları) kaba tahmin olarak
// kullanılır.
const declared = Number(response.headers.get('content-length') || 0)
if (declared > 0 && file.total === 0) file.total = declared
// Gövde akış olarak okunamıyorsa (ör. opak/derleyici farkı) progress'siz yaz.
if (!response.body) {
const buffer = await response.clone().arrayBuffer()
file.loaded = buffer.byteLength
file.total = file.total || buffer.byteLength
await cache.put(key, response)
return file.loaded
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let loaded = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
chunks.push(value)
loaded += value.byteLength
file.loaded = loaded
if (file.total < loaded) file.total = loaded
onTick()
}
// Parçaları tek tampona birleştir (Blob kopyasından kaçınmak için).
const body = new Uint8Array(loaded)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
// Gövde ağ katmanında çözüldüğü için sıkıştırma başlıkları taşınmamalı;
// aksi halde cache'ten servis edilen dosya bozuk görünür. `vary` de silinir:
// girdiyi URL anahtarıyla yazıp Request ile okuduğumuz için Vary eşleşmesi
// kaydı görünmez yapabilir.
const headers = new Headers(response.headers)
headers.delete('content-encoding')
headers.delete('content-length')
headers.delete('vary')
await cache.put(key, new Response(body, { status: 200, headers }))
file.loaded = loaded
file.total = file.total || loaded
return loaded
}
type DownloadJob = {
index: number
url: string
/** Cache anahtarı (kabukta revision'lı olabilir). */
key: string
cache: Cache
/** Başarısızlığı kurulumu düşürür mü? */
critical: boolean
}
/** Cache'te zaten bulunan anahtarlar; 500 ayrı `match` çağrısından çok hızlı. */
async function cachedKeys(cache: Cache) {
const requests = await cache.keys()
return new Set(requests.map((request) => request.url))
}
/**
* Kurulumda indirilecek işleri hazırlar: önce zorunlu kabuk, ardından
* (izin veriliyorsa) uygulamanın tüm bileşenleri. Hash'li ad = aynı içerik
* olduğu için cache'te bulunan hiçbir dosya yeniden indirilmez.
*/
async function planInstall() {
const shellCache = await caches.open(SHELL_CACHE)
const assetCache = await caches.open(ASSET_CACHE)
const warmup = await loadWarmupList()
const sizes = new Map(warmup.map((entry) => [entry.url, entry.size]))
const jobs: DownloadJob[] = []
const files: ProgressFile[] = []
let reusedCount = 0
const push = (url: string, key: string, cache: Cache, critical: boolean) => {
const index = files.length
files.push({
index,
url,
name: fileName(url),
loaded: 0,
total: sizes.get(url) ?? 0,
status: 'pending',
})
jobs.push({ index, url, key, cache, critical })
}
const cachedShell = await cachedKeys(shellCache)
for (const entry of MANIFEST) {
const key = cacheKeyFor(entry)
if (cachedShell.has(key)) {
reusedCount++
continue
}
push(new URL(entry.url, self.registration.scope).href, key, shellCache, true)
}
const shellCount = jobs.length
if (isWarmupAllowed()) {
const cachedAssets = await cachedKeys(assetCache)
for (const entry of warmup) {
// Kabuğa giren dosyalar zaten yukarıda ele alındı.
if (shellKeys.has(entry.url)) continue
if (cachedAssets.has(entry.url)) {
reusedCount++
continue
}
push(entry.url, entry.url, assetCache, false)
}
}
return { jobs, files, reusedCount, shellCount }
}
/** İndirme sürerken anlık ilerleme; boştayken `undefined`. */
let snapshot: (() => Record<string, unknown>) | undefined
type SessionOptions = {
files: ProgressFile[]
jobs: DownloadJob[]
/** `jobs` içindeki ilk kaç iş zorunlu (kabuk) — kalanı best-effort ısıtma. */
criticalCount: number
reusedCount: number
/** Güncelleme kurulumu mu, yoksa eksik bileşenlerin tamamlanması mı? */
phase: 'install' | 'warmup'
}
/**
* Ortak indirme oturumu: ilerleme yayınlar, zorunlu işlerde hata fırlatır,
* ısıtma işlerinde hatayı yutar. Hem `install` olayı hem de sonradan çalışan
* `ensureWarmup` bunu kullanır.
*/
async function runSession({ files, jobs, criticalCount, reusedCount, phase }: SessionOptions) {
const totalBytes = files.reduce((sum, file) => sum + file.total, 0)
await broadcast({ type: 'SW_PRECACHE_START', files, reusedCount, totalBytes, phase })
if (jobs.length === 0) {
await broadcast({ type: 'SW_PRECACHE_DONE', files, loadedBytes: 0, totalBytes: 0, phase })
return
}
// İlerleme mesajları yalnızca değişen dosyaları taşır; 500 dosyalık listeyi
// saniyede birkaç kez kopyalamak hem SW'yi hem render'ı gereksiz yorar.
const dirty = new Set<number>()
const inFlight = new Set<number>()
let lastTick = 0
let completedBytes = 0
// Biten dosyalar + hâlâ inen dosyaların o anki baytı. İnen dosya sayısı
// eşzamanlılık kadar (≤6) olduğu için toplam her tick'te ucuza hesaplanır.
const loadedNow = () => {
let total = completedBytes
for (const index of inFlight) total += files[index].loaded
return total
}
// Tarayıcı, worker güncellemesini sayfa gezinmesinde otomatik başlatır; bu
// yüzden kurulum çoğu zaman uygulama mount olup mesaj dinleyicisini eklemeden
// önce başlar ve START mesajı kaçar. Sonradan bağlanan istemci anlık durumu
// buradan sorar (bkz. GET_PRECACHE_STATE).
snapshot = () => ({ files, reusedCount, totalBytes, phase, loadedBytes: loadedNow() })
const flush = (force = false) => {
const now = Date.now()
if (dirty.size === 0) return
if (!force && now - lastTick < 150) return
lastTick = now
const updates = Array.from(dirty, (index) => ({ ...files[index] }))
dirty.clear()
void broadcast({
type: 'SW_PRECACHE_PROGRESS',
updates,
loadedBytes: loadedNow(),
totalBytes,
phase,
})
}
const run = async (pool: DownloadJob[], concurrency: number, deadline?: number) => {
let cursor = 0
const worker = async () => {
for (;;) {
const next = cursor++
if (next >= pool.length) return
// Süre bütçesi dolduysa kalan bileşenler runtime cache'e bırakılır.
if (deadline && Date.now() > deadline) return
const job = pool[next]
const file = files[job.index]
file.status = 'downloading'
inFlight.add(job.index)
dirty.add(job.index)
flush()
try {
await downloadWithProgress(job.url, job.key, job.cache, file, () => {
dirty.add(job.index)
flush()
})
file.status = 'done'
} catch (error) {
file.status = 'error'
console.warn('[sw] Dosya indirilemedi:', job.url, error)
// Kabuk dosyası indirilemezse kurulum başarısız olmalı; yarım bir
// sürümle aktive olmak beyaz ekrana yol açar. Bileşen ısıtmasında ise
// dosya runtime cache'e bırakılır, kurulum devam eder.
if (job.critical) {
inFlight.delete(job.index)
throw error
}
}
inFlight.delete(job.index)
completedBytes += file.loaded
dirty.add(job.index)
flush(true)
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, pool.length) }, worker))
}
try {
await run(jobs.slice(0, criticalCount), SHELL_CONCURRENCY)
} catch (error) {
await broadcast({
type: 'SW_PRECACHE_ERROR',
files,
phase,
message: error instanceof Error ? error.message : String(error),
})
throw error
}
const warmupJobs = jobs.slice(criticalCount)
if (warmupJobs.length > 0) {
await run(warmupJobs, WARMUP_CONCURRENCY, Date.now() + WARMUP_BUDGET_MS)
}
flush(true)
await broadcast({
type: 'SW_PRECACHE_DONE',
files,
loadedBytes: completedBytes,
totalBytes,
phase,
})
}
async function installAssets() {
const { jobs, files, reusedCount, shellCount } = await planInstall()
await runSession({ files, jobs, criticalCount: shellCount, reusedCount, phase: 'install' })
}
/**
* Eksik kalan bileşenleri tamamlar.
*
* Isıtmayı yalnızca `install` olayına bağlamak yetmiyor: süre bütçesi dolduğunda,
* sekme kapandığında ya da bir dosya hata verdiğinde kalan chunk'lar bir sonraki
* deploy'a kadar hiç indirilmiyor ve kullanıcı ekran açtıkça chunk bekliyordu.
* Bu yüzden uygulama ılışta bunu tetikler (ENSURE_WARMUP) ve indirme kaldığı
* yerden sürer.
*/
let warmupRunning = false
async function ensureWarmup() {
if (warmupRunning || !isWarmupAllowed()) return
warmupRunning = true
try {
const assetCache = await caches.open(ASSET_CACHE)
const warmup = await loadWarmupList()
const cached = await cachedKeys(assetCache)
const files: ProgressFile[] = []
const jobs: DownloadJob[] = []
let reusedCount = 0
for (const entry of warmup) {
if (shellKeys.has(entry.url) || cached.has(entry.url)) {
reusedCount++
continue
}
const index = files.length
files.push({
index,
url: entry.url,
name: fileName(entry.url),
loaded: 0,
total: entry.size,
status: 'pending',
})
jobs.push({ index, url: entry.url, key: entry.url, cache: assetCache, critical: false })
}
// Her şey yerinde: istemciyi rahatsız etme.
if (jobs.length === 0) return
await runSession({ files, jobs, criticalCount: 0, reusedCount, phase: 'warmup' })
} finally {
warmupRunning = false
snapshot = undefined
}
}
self.addEventListener('install', (event) => {
event.waitUntil(installAssets().finally(() => (snapshot = undefined)))
})
/* ------------------------------------------------------------------ */
/* Aktivasyon: eski girdileri temizle */
/* ------------------------------------------------------------------ */
async function cleanup() {
const names = await caches.keys()
await Promise.all(
names
.filter((name) => name.startsWith('sozsoft-') && !KNOWN_CACHES.includes(name))
.concat(names.filter((name) => /^(workbox-|app-|dx-|fonts-|images-)/.test(name)))
.map((name) => caches.delete(name)),
)
// Kabuk cache'inde bu sürüme ait olmayan girdileri sil.
const cache = await caches.open(SHELL_CACHE)
const valid = new Set(shellKeys.values())
const requests = await cache.keys()
await Promise.all(requests.filter((r) => !valid.has(r.url)).map((r) => cache.delete(r)))
await pruneAssetCache()
}
/**
* Isıtma her sürümde ~500 chunk yazdığı için eski sürümlerin chunk'ları
* temizlenmezse cache sürekli büyür. Aktivasyon anında yapılır: eski sayfa
* zaten yenilenmek üzeredir, çalışan sürümün chunk'ı elinden alınmaz.
*/
async function pruneAssetCache() {
const warmup = await loadWarmupList()
// Liste okunamadıysa (dev / eski deploy) hiçbir şeyi silme.
if (warmup.length === 0) return
const valid = new Set(warmup.map((entry) => entry.url))
const cache = await caches.open(ASSET_CACHE)
const requests = await cache.keys()
await Promise.all(
requests
.filter((request) => {
const url = new URL(request.url)
if (!/^\/assets\/(?:js|css)\//.test(url.pathname)) return false
const href = url.href.split('?')[0]
return !valid.has(href) && !shellKeys.has(href)
})
.map((request) => cache.delete(request)),
)
}
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
await cleanup()
await self.clients.claim()
await broadcast({ type: 'SW_ACTIVATED' })
})(),
)
})
/* ------------------------------------------------------------------ */
/* Mesajlar */
/* ------------------------------------------------------------------ */
self.addEventListener('message', (event) => {
const type = event.data?.type
if (type === 'SKIP_WAITING') {
void self.skipWaiting()
return
}
// Kurulum sürerken bağlanan istemci ilerlemeyi buradan alır; START mesajını
// kaçırmış olsa bile pencereyi açıp listeyi doldurabilir.
if (type === 'GET_PRECACHE_STATE') {
const current = snapshot?.()
if (current) event.source?.postMessage({ type: 'SW_PRECACHE_STATE', ...current })
return
}
// Uygulama açılışta eksik bileşenlerin tamamlanmasını ister.
if (type === 'ENSURE_WARMUP') {
event.waitUntil(ensureWarmup())
return
}
if (type === 'PING') {
event.source?.postMessage({ type: 'PONG', shellSize: MANIFEST.length })
}
})
/* ------------------------------------------------------------------ */
/* Runtime cache */
/* ------------------------------------------------------------------ */
async function trimCache(cacheName: string, limit: number) {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
if (keys.length <= limit) return
// Cache.keys() ekleme sırasını korur; en eskiler silinir.
await Promise.all(keys.slice(0, keys.length - limit).map((key) => cache.delete(key)))
}
async function cacheFirst(request: Request, cacheName: string, limit: number) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
if (cached) return cached
const response = await fetch(request)
if (response.ok || response.type === 'opaque') {
await cache.put(request, response.clone())
void trimCache(cacheName, limit)
}
return response
}
async function staleWhileRevalidate(request: Request, cacheName: string, limit: number) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
const network = fetch(request)
.then(async (response) => {
if (response.ok) {
await cache.put(request, response.clone())
void trimCache(cacheName, limit)
}
return response
})
.catch(() => undefined)
return cached ?? (await network) ?? Response.error()
}
async function networkFirst(request: Request, cacheName: string, timeoutMs: number) {
const cache = await caches.open(cacheName)
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const response = await fetch(request, { signal: controller.signal })
clearTimeout(timer)
if (response.ok) await cache.put(request, response.clone())
return response
} catch {
const cached = await cache.match(request)
return cached ?? Response.error()
}
}
/** Kabuktaki index.html'i arka planda tazeler (deploy sonrası kendini onarır). */
async function refreshIndex(cache: Cache, key: string, url: string) {
try {
const response = await fetch(new Request(url, { cache: 'reload', credentials: 'same-origin' }))
if (response.ok) await cache.put(key, response)
} catch {
/* çevrimdışıysa mevcut kopya kullanılmaya devam eder */
}
}
/**
* SPA gezinmeleri: precache'teki index.html anında servis edilir (hızlı ılış),
* kopya arka planda tazelenir. Service worker'ın güncellenmediği (yalnızca lazy
* chunk değişen) deploy'larda bir sonraki açılış yeni index'i alır.
*/
async function handleNavigation(request: Request) {
const cache = await caches.open(SHELL_CACHE)
const key = shellKeys.get(INDEX_URL)
const cached = key ? await cache.match(key) : undefined
if (cached && key) {
void refreshIndex(cache, key, INDEX_URL)
return cached
}
try {
return await fetch(request)
} catch {
return Response.error()
}
}
self.addEventListener('fetch', (event) => {
const request = event.request
if (request.method !== 'GET') return
const url = new URL(request.url)
if (url.origin !== self.location.origin) return
if (NETWORK_ONLY.test(url.pathname)) return
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(request))
return
}
// Sürüm bilgisi asla eski kalmamalı.
if (url.pathname.endsWith('/version.json')) {
event.respondWith(networkFirst(request, VERSION_CACHE, 3000))
return
}
const shellKey = shellKeys.get(url.href.split('?')[0])
if (shellKey) {
event.respondWith(
caches
.open(SHELL_CACHE)
.then((cache) => cache.match(shellKey))
.then((cached) => cached ?? fetch(request)),
)
return
}
if (HASHED_ASSET.test(url.pathname)) {
event.respondWith(cacheFirst(request, ASSET_CACHE, ASSET_LIMIT))
return
}
if (THEME_CSS.test(url.pathname)) {
event.respondWith(staleWhileRevalidate(request, THEME_CACHE, THEME_LIMIT))
return
}
if (FONT_FILE.test(url.pathname)) {
event.respondWith(cacheFirst(request, FONT_CACHE, FONT_LIMIT))
return
}
if (IMAGE_FILE.test(url.pathname)) {
event.respondWith(cacheFirst(request, IMAGE_CACHE, IMAGE_LIMIT))
}
})

View file

@ -13,6 +13,8 @@ import { isLoginSuccess } from '../../proxy/account/models'
import { REDIRECT_URL_KEY } from '../../constants/app.constant'
import appConfig from '../../proxy/configs/app.config'
import { register } from '../../services/account.service'
import { hasPendingChangelog } from '@/views/version/swRegistration'
import { ROUTES_ENUM } from '@/routes/route.constant'
type Status = 'success' | 'failed' | 'error'
@ -68,7 +70,12 @@ function useAuth() {
handleToken({ token, refreshToken, expiresIn })
const redirectUrl = query.get(REDIRECT_URL_KEY)
navigate(redirectUrl ?? appConfig.authenticatedEntryPath)
// Uygulama yeni bir sürümle/deploy ile açıldıysa kullanıcı önce
// değişiklikleri görsün. Bayrak ChangeLog ekranında tüketilir.
const target = hasPendingChangelog()
? ROUTES_ENUM.protected.admin.changeLog
: (redirectUrl ?? appConfig.authenticatedEntryPath)
navigate(target)
return {
status: 'success',

View file

@ -0,0 +1,262 @@
import { memo, useSyncExternalStore } from 'react'
import {
applyAppUpdate,
dismissAppUpdateDialog,
getAppUpdateState,
subscribeAppUpdate,
type AppUpdateState,
} from './swRegistration'
/**
* Sürüm indirme penceresi.
*
* Güncellemede kapatılamaz: geçiş kullanıcı onayı beklemeden yapılır ve pencere
* yeni sürüme geçilene (sayfa yenilenene) kadar ık kalır. İlk kurulumda ise
* (`dismissible`) çalışan sürüm etkilenmediği için kapatılıp arka planda devam
* edilebilir.
*
* Bilerek platform Dialog'u (react-modal + framer-motion) kullanılmadı: bu
* bileşen güncelleme anında lazy yüklenir ve tek başına ~2 KB kalmalı.
*/
const formatBytes = (bytes: number) => {
if (!bytes) return '0 KB'
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const formatDuration = (seconds: number) => {
if (seconds < 60) return `${Math.max(1, seconds)} sn`
const minutes = Math.round(seconds / 60)
return `${minutes} dk`
}
const titleFor = (state: AppUpdateState) => {
switch (state.status) {
case 'downloading':
return state.dismissible ? 'Bileşenler indiriliyor' : 'Yeni sürüm indiriliyor'
case 'completed':
return 'Güncelleme tamamlandı'
case 'ready':
return 'Yeni sürüm hazırlanıyor'
case 'activating':
return 'Uygulama yeniden başlatılıyor'
case 'reload-required':
return 'Yeni sürüm yayınlandı'
case 'error':
return 'Güncelleme tamamlanamadı'
default:
return 'Güncelleme kontrol ediliyor'
}
}
const countdownText = (state: AppUpdateState) =>
state.secondsLeft && state.secondsLeft > 0 ? ` (${state.secondsLeft} sn)` : ''
const descriptionFor = (state: AppUpdateState) => {
switch (state.status) {
case 'downloading':
return state.dismissible
? 'Tüm bileşenler indiriliyor; bittiğinde ekranlar anında açılacak. Bu pencereyi kapatıp devam edebilirsiniz.'
: 'Bileşenler indiriliyor, sekmeyi kapatmayın. İndirme bitince yeni sürüme otomatik geçilecek.'
case 'completed':
return 'Tüm bileşenler indirildi, uygulama kullanıma hazır.'
case 'ready':
return 'İndirme tamamlandı, yeni sürüme geçiliyor.'
case 'activating':
return 'Sayfa otomatik olarak yenilenecek.'
case 'reload-required':
return `Yeni sürüme otomatik geçiliyor${countdownText(state)}.`
case 'error':
return `${state.message ?? 'Geçiş tamamlanamadı.'}${countdownText(state)}`
default:
return 'Sunucudaki sürüm bilgisi okunuyor...'
}
}
/**
* Bir sürümde ~500 bileşen listelenir ve ilerleme mesajları 150 ms'de bir gelir.
* `memo` şart: service worker yalnızca değişen dosyaları yolladığı için
* değişmeyen satırların nesne kimliği korunur ve yeniden çizilmezler.
* `content-visibility` ise görünür alan dışındaki satırların düzen/boyama
* maliyetini sıfırlar.
*/
const FileRow = memo(({ file }: { file: AppUpdateState['files'][number] }) => {
const percent = file.total > 0 ? Math.min(100, Math.round((file.loaded / file.total) * 100)) : 0
const color =
file.status === 'error'
? 'bg-red-500'
: file.status === 'done'
? 'bg-emerald-500'
: 'bg-indigo-500'
return (
<li className="py-1.5" style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 38px' }}>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="truncate font-medium text-gray-700 dark:text-gray-200" title={file.url}>
{file.name}
</span>
<span className="shrink-0 tabular-nums text-gray-500 dark:text-gray-400">
{file.status === 'done'
? formatBytes(file.total || file.loaded)
: `${formatBytes(file.loaded)} / ${file.total ? formatBytes(file.total) : '—'}`}
</span>
</div>
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-gray-200 dark:bg-gray-600">
<div
className={`h-full rounded transition-[width] duration-200 ${color}`}
style={{ width: `${file.status === 'done' ? 100 : percent}%` }}
/>
</div>
</li>
)
})
FileRow.displayName = 'FileRow'
/**
* Liste sırası: inen dosyalar en üstte, altında en son bitenler, en altta henüz
* sıraya gelmemişler. `seq` yalnızca dosya durum/ilerleme aldığında değiştiği
* için sıralama her bayt tick'inde bozulmaz.
*/
const ACTIVITY_RANK: Record<string, number> = { downloading: 0, error: 1, done: 2, pending: 3 }
const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files'][number]) =>
ACTIVITY_RANK[a.status] - ACTIVITY_RANK[b.status] ||
(b.seq ?? 0) - (a.seq ?? 0) ||
a.index - b.index
const AppUpdateDialog = () => {
const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState)
if (!state.visible) return null
const busy = state.status === 'downloading' || state.status === 'ready'
const doneCount = state.files.filter((file) => file.status === 'done').length
const upgrading = !!state.version && state.version !== state.currentVersion
const ordered = state.files.slice().sort(byActivity)
return (
<div
className="fixed inset-0 z-[99999] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
role="status"
aria-live="polite"
>
<div className="flex max-h-[88vh] w-full max-w-[640px] flex-col overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-gray-800">
<div className="flex items-start gap-3 px-6 pt-6">
{busy && (
<span className="mt-1 h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-500 dark:border-gray-600 dark:border-t-indigo-400" />
)}
<div className="min-w-0">
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
{titleFor(state)}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{descriptionFor(state)}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 px-6 pt-4 text-xs">
<span className="rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{upgrading ? 'Mevcut' : 'Sürüm'}: v{state.currentVersion}
</span>
{/* İlk kurulumda geçilecek bir sürüm yok; aynı numarayı iki kez göstermeyelim. */}
{upgrading && (
<>
<span className="text-gray-400"></span>
<span className="rounded-full bg-indigo-50 px-2.5 py-1 font-semibold text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300">
Yeni: v{state.version}
</span>
</>
)}
</div>
{(busy || state.files.length > 0) && (
<div className="px-6 pt-4">
<div className="flex flex-wrap items-center justify-between gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span>
{doneCount}/{state.files.length} bileşen
{state.reusedCount > 0 && ` · ${state.reusedCount} bileşen önbellekten`}
{state.status === 'downloading' && state.secondsRemaining !== undefined && (
<> · kalan ~{formatDuration(state.secondsRemaining)}</>
)}
</span>
<span className="tabular-nums">
{formatBytes(state.loadedBytes)}
{state.totalBytes > 0 && ` / ${formatBytes(state.totalBytes)}`} · %{state.percent}
</span>
</div>
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-600">
<div
className="h-full rounded-full bg-indigo-500 transition-[width] duration-200"
style={{ width: `${state.percent}%` }}
/>
</div>
</div>
)}
<div className="mt-4 min-h-0 flex-1 overflow-y-auto px-6">
{state.files.length > 0 && (
<>
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
İndirilen bileşenler
</p>
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{ordered.map((file) => (
<FileRow key={file.url} file={file} />
))}
</ul>
</>
)}
{state.changeLog.length > 0 && (
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
v{state.version} ile gelen yenilikler
</p>
<ul className="mt-2 flex flex-col gap-1.5 text-sm text-gray-600 dark:text-gray-300">
{state.changeLog.map((line, index) => (
<li key={index} className="flex items-start gap-2">
<span className="mt-0.5 text-emerald-500"></span>
<span>{line}</span>
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex items-center justify-between gap-3 px-6 py-4">
<span className="text-xs text-gray-400">
{state.dismissible
? 'Pencereyi kapatsanız da indirme arka planda sürer.'
: 'Güncelleme tamamlanana kadar bu pencere kapanmaz.'}
</span>
{state.dismissible ? (
<button
type="button"
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={dismissAppUpdateDialog}
>
{state.status === 'completed' ? 'Tamam' : 'Arka planda devam et'}
</button>
) : (
(state.status === 'reload-required' || state.status === 'error') && (
<button
type="button"
className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
onClick={applyAppUpdate}
>
{state.status === 'error' ? 'Şimdi tekrar dene' : 'Şimdi geç'}
{countdownText(state)}
</button>
)
)}
</div>
</div>
</div>
)
}
export default AppUpdateDialog

View file

@ -6,7 +6,7 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import React, { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet'
import { FaRegCheckCircle, FaSyncAlt, FaTag } from 'react-icons/fa'
import { checkForAppUpdate } from './swRegistration'
import { checkForAppUpdate, clearPendingChangelog, fetchVersionInfo } from './swRegistration'
type Release = {
version: string
@ -115,14 +115,12 @@ const Changelog = () => {
const { isCheckingUpdate, checkForUpdate } = useManualUpdateCheck()
useEffect(() => {
fetch('/version.json?ts=' + Date.now())
.then((res) => res.json())
.then((data) => {
if (data?.releases) {
setReleases(data.releases)
}
})
.catch(() => setReleases([]))
// Bekleyen changelog bayrağı burada tüketilir; kullanıcı değişiklikleri
// gördüğü için sonraki girişlerde tekrar yönlendirilmez.
clearPendingChangelog()
// Açılışta zaten alınan sürüm bilgisi paylaşılır (ek istek atılmaz).
void fetchVersionInfo().then((info) => setReleases((info?.releases as Release[]) ?? []))
}, [])
return (

View file

@ -0,0 +1,37 @@
import { useSyncExternalStore } from 'react'
import { FcDownload } from 'react-icons/fc'
import Dropdown from '@/components/ui/Dropdown'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { getInstallState, promptAppInstall, subscribeInstallState } from './installPrompt'
/**
* Kullanıcı menüsündeki "uygulamayı yükle" satırı. Tarayıcı kurulum teklifi
* vermediyse (zaten kurulu, kriterler sağlanmıyor ya da desteklenmiyor) hiç
* render edilmez.
*/
const InstallAppMenuItem = () => {
const { translate } = useLocalization()
const state = useSyncExternalStore(subscribeInstallState, getInstallState, getInstallState)
if (!state.canInstall || state.installed) return null
const value = translate('::App.InstallApp')
const label = !value || value === '::App.InstallApp' ? 'Uygulamayı yükle' : value
return (
<Dropdown.Item
eventKey="Install App"
className="mb-1 px-0"
onClick={() => void promptAppInstall()}
>
<span className="flex h-full w-full items-center gap-2 px-2">
<span className="text-xl opacity-50">
<FcDownload />
</span>
<span>{label}</span>
</span>
</Dropdown.Item>
)
}
export default InstallAppMenuItem

View file

@ -0,0 +1,87 @@
/**
* Tarayıcının "uygulamayı yükle" akışı (PWA install).
*
* `beforeinstallprompt` sayfa yüklenirken erken tetiklenir; React ağacı mount
* olmadan yakalanmazsa olay kaybolur ve buton hiç görünmez. Bu yüzden modül
* import edilir edilmez dinleyiciler kurulur (dosya ~1 KB, entry'de kalması
* sorun değil).
*/
type InstallPromptEvent = Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export type InstallState = {
/** Tarayıcı kurulum teklifi verdi ve uygulama henüz kurulu değil. */
canInstall: boolean
/** Uygulama standalone modda çalışıyor (yani kurulu). */
installed: boolean
}
let deferredPrompt: InstallPromptEvent | undefined
const isStandalone = () => {
try {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: minimal-ui)').matches ||
(navigator as unknown as { standalone?: boolean }).standalone === true
)
} catch {
return false
}
}
let state: InstallState = { canInstall: false, installed: isStandalone() }
const listeners = new Set<(value: InstallState) => void>()
export const getInstallState = () => state
export const subscribeInstallState = (listener: (value: InstallState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
function setState(patch: Partial<InstallState>) {
state = { ...state, ...patch }
listeners.forEach((listener) => listener(state))
}
export const promptAppInstall = async (): Promise<'accepted' | 'dismissed' | 'unavailable'> => {
const event = deferredPrompt
if (!event) return 'unavailable'
// Yakalanan olay tek kullanımlıktır.
deferredPrompt = undefined
setState({ canInstall: false })
try {
await event.prompt()
const { outcome } = await event.userChoice
if (outcome === 'accepted') setState({ installed: true })
return outcome
} catch (error) {
console.warn('Uygulama kurulumu başlatılamadı.', error)
return 'unavailable'
}
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeinstallprompt', (event) => {
// Varsayılan mini bar yerine uygulama içindeki butonu kullanıyoruz.
event.preventDefault()
deferredPrompt = event as InstallPromptEvent
setState({ canInstall: !isStandalone(), installed: isStandalone() })
})
window.addEventListener('appinstalled', () => {
deferredPrompt = undefined
setState({ canInstall: false, installed: true })
})
window
.matchMedia('(display-mode: standalone)')
.addEventListener?.('change', (event) =>
setState({ installed: event.matches, canInstall: event.matches ? false : state.canInstall }),
)
}

View file

@ -0,0 +1,21 @@
import { createRoot, type Root } from 'react-dom/client'
import AppUpdateDialog from './AppUpdateDialog'
/**
* Güncelleme penceresi uygulama ağacından bağımsız, kendi kökünde mount edilir:
* - Entry chunk'ta taşınmaz (yalnızca güncelleme varken indirilir).
* - Router/Store yeniden render'larından etkilenmez, güncelleme sırasında
* uygulama yeniden yüklense bile pencere ayakta kalır.
*/
let root: Root | undefined
export const mountAppUpdateDialog = async () => {
if (root) return
const host = document.createElement('div')
host.id = 'app-update-root'
document.body.appendChild(host)
root = createRoot(host)
root.render(<AppUpdateDialog />)
}
export default mountAppUpdateDialog

File diff suppressed because it is too large Load diff

View file

@ -1,77 +0,0 @@
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

@ -1,22 +1,32 @@
import { defineConfig, loadEnv } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import fs from 'fs'
import path from 'path'
import { VitePWA } from 'vite-plugin-pwa'
/**
* 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.
* İki kademe vardır:
*
* 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.
* 1. KABUK (workbox precache manifest'i, aşağıdaki `PRECACHE_GLOBS`)
* index.html + entry JS/CSS + her ılışta yüklenen vendor chunk'ları.
* Kurulumda ZORUNLUDUR; biri inmezse service worker kurulumu başarısız olur
* (yarım sürümle aktive olmak beyaz ekran demek).
*
* 2. BİLEŞENLER (`warmup.json`, bu dosyadaki plugin üretir)
* Tüm lazy chunk'lar ve CSS'leri. Güncelleme penceresi ıkken aynı ilerleme
* çubuğunda arka planda indirilir; böylece kullanıcı deploy'dan sonra ilk kez
* bir ekrana girdiğinde chunk beklemez. Best-effort'tur: inemeyen dosya
* kurulumu düşürmez, süre bütçesi ılırsa kalanlar runtime cache'e kalır.
*
* `dist/css` altındaki 48 DevExtreme teması (~33 MB) bilerek dışarıdadır;
* kullanıcı başına yalnızca bir tema gerekir, runtime cache yeterlidir.
*
* Service worker `src/sw.ts` içinde elle yazıldı (strategies: injectManifest);
* çünkü güncelleme penceresinde indirilen her dosyanın adı/boyutu ve toplam
* yüzdesi gösterilebilsin diye indirme progress yayınlıyor.
*/
const PRECACHE_GLOBS = [
'index.html',
@ -24,17 +34,75 @@ const PRECACHE_GLOBS = [
// otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir.
'favicon.ico',
'assets/js/entry-*.js',
'assets/js/vendor-react-*.js',
'assets/js/vendor-router-*.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)$/
/** Arka planda ısıtılacak bileşenler: uygulamanın tüm JS/CSS chunk'ları. */
const WARMUP_FILE = /^assets\/(?:js|css)\/.+\.(?:js|css)$/
/**
* Build çıktısındaki chunk adlarını ve (sıkıştırılmamış) boyutlarını
* `dist/warmup.json` olarak yazar. Boyutun derleme anında bilinmesi, service
* worker'ın ilerleme yüzdesini ilk saniyeden itibaren doğru göstermesini sağlar
* (Content-Length gzip'li boyut olduğu için yüzde zıplıyordu).
*/
function warmupManifest(): Plugin {
return {
name: 'sozsoft-warmup-manifest',
apply: 'build',
enforce: 'post',
generateBundle(_options, bundle) {
const files = Object.values(bundle)
.filter((output) => WARMUP_FILE.test(output.fileName))
.map((output) => {
const source = output.type === 'chunk' ? output.code : output.source
return {
url: `/${output.fileName}`,
size:
typeof source === 'string'
? new TextEncoder().encode(source).byteLength
: source.byteLength,
}
})
// vite-plugin-pwa service worker'ı ayrı bir build ile üretir; o build'de
// eşleşen dosya olmaz. Boş liste yazmak gerçek manifest'i ezerdi.
if (files.length === 0) return
this.emitFile({
type: 'asset',
fileName: 'warmup.json',
source: JSON.stringify({ files }),
})
},
}
}
/**
* `scripts/generate-version.js` build'den önce çalışır. Buradaki değerleri
* bundle'a gömüyoruz ki uygulama, çalışan kodun build kimliğini sunucudaki
* `version.json` ile birebir karşılaştırıp yeni deploy'u kesin olarak anlasın.
*/
function readBuildInfo() {
try {
const file = path.join(import.meta.dirname, 'public', 'version.json')
const data = JSON.parse(fs.readFileSync(file, 'utf8'))
return {
buildId: String(data.buildId ?? data.commit ?? 'dev'),
version: String(data.version ?? data.releases?.[0]?.version ?? '0.0.0'),
}
} catch {
return { buildId: 'dev', version: '0.0.0' }
}
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const buildInfo = readBuildInfo()
const usePolling = env.VITE_USE_POLLING === 'true'
// 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.
@ -46,86 +114,29 @@ export default defineConfig(({ mode }) => {
return {
plugins: [
react(),
warmupManifest(),
VitePWA({
// 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.
registerType: 'prompt',
// Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir.
injectRegister: false,
// Precache indirmesinin ilerlemesini kullanıcıya gösterebilmek için
// service worker elle yazıldı.
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
enabled: enableDevSw,
type: 'module',
navigateFallback: 'index.html',
},
workbox: {
injectManifest: {
globDirectory: 'dist',
globPatterns: PRECACHE_GLOBS,
// Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB).
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
// Yeni worker precache'i bitirdikten sonra uygulama SKIP_WAITING
// gönderir; clientsClaim açık sekmelerin kontrolünü o an devralır.
clientsClaim: true,
skipWaiting: false,
cleanupOutdatedCaches: true,
navigateFallback: '/index.html',
navigateFallbackAllowlist: [/^(?!\/__).*/],
navigateFallbackDenylist: [/^\/api\//, /^\/connect\//, /^\/swagger/, /\/version\.json$/],
runtimeCaching: [
{
// Sürüm/changelog bilgisi asla eski kalmamalı.
urlPattern: /\/version\.json$/,
handler: 'NetworkFirst',
options: {
cacheName: 'app-version-v2',
networkTimeoutSeconds: 3,
expiration: { maxEntries: 2, maxAgeSeconds: 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Hash'li build çıktıları immutable'dır.
urlPattern: HASHED_ASSET_PATTERN,
handler: 'CacheFirst',
options: {
cacheName: 'app-assets-v3',
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// 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] },
},
},
],
},
manifest: {
@ -133,7 +144,11 @@ export default defineConfig(({ mode }) => {
short_name: 'Sözsoft Platform',
theme_color: '#FF99C8',
background_color: '#f0e7db',
display: 'browser',
// Tarayıcının "uygulamayı yükle" akışı (beforeinstallprompt) yalnızca
// standalone/minimal-ui görüntü modlarında tetiklenir.
display: 'standalone',
start_url: '/',
scope: '/',
icons: [
{
src: '/img/logo/logo-400.png',
@ -277,6 +292,10 @@ export default defineConfig(({ mode }) => {
define: {
'process.env': {},
// Çalışan bundle'ın kimliği. İstemci bunu sunucudaki version.json ile
// karşılaştırarak yeni deploy'u kesin olarak (tag'e ihtiyaç duymadan) anlar.
__APP_BUILD_ID__: JSON.stringify(buildInfo.buildId),
__APP_VERSION__: JSON.stringify(buildInfo.version),
},
}
})