← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
starter-fetch/src/client/update-check.ts
167 lines · 5.1 KB · TypeScript/** Visit + About update check. Uses data-running-sha / data-version-path / data-app-label. */
import { apiUrl, readClientConfig } from './config.ts'
const UPDATE_CHECK_MIN_INTERVAL_MS = 3000
function shouldRunUpdateCheck(input) {
if (!input.visible || input.inFlight) return false
if (input.lastStartedAt == null) return true
const gap = input.minIntervalMs ?? UPDATE_CHECK_MIN_INTERVAL_MS
return input.now - input.lastStartedAt >= gap
}
function describeAvailableUpdate(input) {
if (input.hasWaitingWorker) return 'worker'
if (input.runningSha && input.latestSha && input.runningSha !== input.latestSha) {
return 'published'
}
return null
}
export function bootUpdateCheck() {
const config = readClientConfig()
const root = document.documentElement
const pwaState = { registration: null, waitingWorker: null }
const updateCheck = { inFlight: false, lastStartedAt: null }
function showUpdateToast(title, text, action) {
window.pakToast?.show({
title,
description: text,
durationMs: 0,
action: { label: 'Refresh', onClick: action },
})
}
function applyUpdate() {
const worker = pwaState.waitingWorker
if (worker) worker.postMessage('SKIP_WAITING')
else location.reload()
}
async function loadVersion() {
try {
const response = await fetch(apiUrl(config, config.versionPath), { cache: 'no-store' })
if (!response.ok) throw new Error(`version ${response.status}`)
return await response.json()
} catch {
const sha = root.getAttribute('data-running-sha') || ''
return { sha, shortSha: sha.slice(0, 7), message: '', committedAt: '', publishedAt: '' }
}
}
async function ensureServiceWorker() {
if (!('serviceWorker' in navigator)) return null
if (pwaState.registration) return pwaState.registration
try {
if (window.pakRegisterServiceWorker) {
pwaState.registration = await window.pakRegisterServiceWorker()
} else {
const scriptUrl =
config.swUrl.startsWith('http') || config.swUrl.startsWith('/')
? config.swUrl
: apiUrl(config, config.swUrl)
pwaState.registration = await navigator.serviceWorker.register(scriptUrl, {
scope: `${config.appBase}/`,
updateViaCache: 'none',
})
}
const track = (worker) => {
if (!worker) return
worker.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
pwaState.waitingWorker = worker
showUpdateToast(
'Update available',
`A newer ${config.appLabel} is ready.`,
applyUpdate,
)
}
})
}
track(pwaState.registration?.installing)
pwaState.registration?.addEventListener('updatefound', () => {
track(pwaState.registration?.installing)
})
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (pwaState.waitingWorker) location.reload()
})
return pwaState.registration
} catch {
return null
}
}
async function checkForUpdates() {
if (
!shouldRunUpdateCheck({
visible: document.visibilityState === 'visible',
inFlight: updateCheck.inFlight,
lastStartedAt: updateCheck.lastStartedAt,
now: Date.now(),
minIntervalMs: UPDATE_CHECK_MIN_INTERVAL_MS,
})
) {
return
}
updateCheck.inFlight = true
updateCheck.lastStartedAt = Date.now()
try {
const registration = await ensureServiceWorker()
const version = await loadVersion()
if (registration?.update) await registration.update()
const waiting = pwaState.waitingWorker || registration?.waiting || null
if (waiting) pwaState.waitingWorker = waiting
const available = describeAvailableUpdate({
runningSha: root.getAttribute('data-running-sha') || '',
latestSha: version.sha || '',
hasWaitingWorker: Boolean(waiting),
})
if (available === 'worker') {
showUpdateToast(
'Update available',
`A newer ${config.appLabel} is ready. Refresh to apply it.`,
applyUpdate,
)
return
}
if (available === 'published') {
showUpdateToast(
'Update available',
`Latest published commit is ${version.shortSha || String(version.sha || '').slice(0, 7)}. Refresh to download it.`,
() => location.reload(),
)
} else if (
window.pakToast &&
document.querySelector(
'[data-about-check-update]:focus, [data-about-check-update][data-manual]',
)
) {
window.pakToast.show({
title: 'Up to date',
description: 'No newer publish found.',
durationMs: 2400,
})
}
} finally {
updateCheck.inFlight = false
}
}
window.pakCheckForUpdates = checkForUpdates
document.addEventListener('click', (e) => {
let t = e.target
if (t && t.nodeType === 3) t = t.parentElement
const el = t
const btn = el?.closest?.('[data-about-check-update]')
if (!btn) return
btn.setAttribute('data-manual', '1')
void checkForUpdates().finally(() => btn.removeAttribute('data-manual'))
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void checkForUpdates()
})
window.addEventListener('pageshow', () => void checkForUpdates())
window.addEventListener('focus', () => void checkForUpdates())
void ensureServiceWorker().then(() => checkForUpdates())
}