Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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())
}