Skip to content

Commit 149c604

Browse files
committed
Add automatic site update check
1 parent fa8a309 commit 149c604

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

src/lib/siteUpdate.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
declare const __SITE_BUILD_ID__: string;
2+
3+
const VERSION_URL = '/site-version.json';
4+
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
5+
const INITIAL_CHECK_DELAY_MS = 15 * 1000;
6+
const UPDATE_STORAGE_KEY = 'mevera:site-update-applied';
7+
const UPDATE_QUERY_KEY = 'siteUpdate';
8+
const RELOAD_DELAY_MS = 750;
9+
10+
type SiteVersion = {
11+
version?: string;
12+
};
13+
14+
async function fetchLatestVersion(): Promise<string | null> {
15+
const response = await fetch(`${VERSION_URL}?t=${Date.now()}`, {
16+
cache: 'no-store',
17+
headers: {
18+
'Cache-Control': 'no-cache',
19+
Pragma: 'no-cache',
20+
},
21+
});
22+
23+
if (!response.ok) {
24+
return null;
25+
}
26+
27+
const data = (await response.json()) as SiteVersion;
28+
return typeof data.version === 'string' && data.version.length > 0 ? data.version : null;
29+
}
30+
31+
function markReloadAttempt(version: string): boolean {
32+
const previousAttempt = sessionStorage.getItem(UPDATE_STORAGE_KEY);
33+
34+
if (previousAttempt === version) {
35+
return false;
36+
}
37+
38+
sessionStorage.setItem(UPDATE_STORAGE_KEY, version);
39+
return true;
40+
}
41+
42+
async function refreshServiceWorkerState(): Promise<void> {
43+
if (!('serviceWorker' in navigator)) {
44+
return;
45+
}
46+
47+
const registrations = await navigator.serviceWorker.getRegistrations();
48+
await Promise.all(registrations.map((registration) => registration.update()));
49+
}
50+
51+
function reloadWithCacheBuster(version: string) {
52+
const nextUrl = new URL(window.location.href);
53+
nextUrl.searchParams.set(UPDATE_QUERY_KEY, version);
54+
window.location.replace(nextUrl);
55+
}
56+
57+
function clearRuntimeCachesInBackground() {
58+
if (!('caches' in window)) {
59+
return;
60+
}
61+
62+
caches.keys()
63+
.then((cacheNames) => Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))))
64+
.catch(() => undefined);
65+
}
66+
67+
function applyUpdate(version: string) {
68+
if (!markReloadAttempt(version)) {
69+
return;
70+
}
71+
72+
clearRuntimeCachesInBackground();
73+
window.setTimeout(() => reloadWithCacheBuster(version), RELOAD_DELAY_MS);
74+
}
75+
76+
export function installSiteUpdateCheck() {
77+
if (!import.meta.env.PROD || typeof window === 'undefined') {
78+
return;
79+
}
80+
81+
const currentVersion = __SITE_BUILD_ID__;
82+
let isChecking = false;
83+
84+
const checkForUpdate = async () => {
85+
if (isChecking || document.visibilityState === 'hidden') {
86+
return;
87+
}
88+
89+
isChecking = true;
90+
91+
try {
92+
const latestVersion = await fetchLatestVersion();
93+
94+
if (latestVersion && latestVersion !== currentVersion) {
95+
applyUpdate(latestVersion);
96+
}
97+
} catch {
98+
// A failed update check should never break the site.
99+
} finally {
100+
isChecking = false;
101+
}
102+
};
103+
104+
window.setTimeout(checkForUpdate, INITIAL_CHECK_DELAY_MS);
105+
window.setInterval(checkForUpdate, CHECK_INTERVAL_MS);
106+
window.addEventListener('focus', checkForUpdate);
107+
document.addEventListener('visibilitychange', checkForUpdate);
108+
109+
if ('serviceWorker' in navigator) {
110+
refreshServiceWorkerState().catch(() => undefined);
111+
}
112+
}
113+
114+
export function scheduleSiteUpdateCheck() {
115+
if (typeof window === 'undefined') {
116+
return;
117+
}
118+
119+
const install = () => installSiteUpdateCheck();
120+
121+
if ('requestIdleCallback' in window) {
122+
window.requestIdleCallback(install, { timeout: 5000 });
123+
return;
124+
}
125+
126+
setTimeout(install, 0);
127+
}

src/main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import './i18n'
66
import App from './App.tsx'
77
import { ThemeProvider } from '@/components/ThemeProvider'
88
import { CodeThemeProvider } from '@/components/CodeThemeProvider'
9+
import { scheduleSiteUpdateCheck } from '@/lib/siteUpdate'
10+
11+
scheduleSiteUpdateCheck()
912

1013
createRoot(document.getElementById('root')!).render(
1114
<StrictMode>

vite.config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,28 @@ import { defineConfig } from "vite"
44
import { inspectAttr } from 'kimi-plugin-inspect-react'
55
import { VitePWA } from 'vite-plugin-pwa'
66

7+
const buildId = process.env.GITHUB_SHA || process.env.VERCEL_GIT_COMMIT_SHA || `${Date.now()}`
8+
const builtAt = new Date().toISOString()
9+
710
// https://vite.dev/config/
811
export default defineConfig({
912
base: '/',
13+
define: {
14+
__SITE_BUILD_ID__: JSON.stringify(buildId),
15+
},
1016
plugins: [
1117
inspectAttr(),
1218
react(),
19+
{
20+
name: 'site-version',
21+
generateBundle() {
22+
this.emitFile({
23+
type: 'asset',
24+
fileName: 'site-version.json',
25+
source: JSON.stringify({ version: buildId, builtAt }),
26+
})
27+
},
28+
},
1329
VitePWA({
1430
registerType: 'autoUpdate',
1531
includeAssets: ['icon-192.png', 'icon-512.png', 'icon-1024.png'],
@@ -43,8 +59,13 @@ export default defineConfig({
4359
},
4460
workbox: {
4561
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'],
62+
globIgnores: ['**/site-version.json'],
4663
maximumFileSizeToCacheInBytes: 4000000, // 4MB to allow React/UI vendor chunks
4764
runtimeCaching: [
65+
{
66+
urlPattern: /\/site-version\.json$/,
67+
handler: 'NetworkOnly',
68+
},
4869
{
4970
urlPattern: /\/docs-nav\.json$/,
5071
handler: 'CacheFirst',

0 commit comments

Comments
 (0)