92 lines
3.0 KiB
TypeScript
92 lines
3.0 KiB
TypeScript
import type { Router } from 'vue-router'
|
|||
|
|
|
||
|
|
const RECOVERY_KEY = 'client-static-asset-recovery'
|
||
|
|
const RECOVERY_COOLDOWN_MS = 60_000
|
||
|
|
const RECOVERY_STABLE_MS = 15_000
|
||
|
|
|
||
|
|
interface RecoveryRecord {
|
||
|
|
href: string
|
||
|
|
refreshedAt: number
|
||
|
|
}
|
||
|
|
|
||
|
|
function errorMessage(error: unknown) {
|
||
|
|
if (error instanceof Error) return `${error.name}: ${error.message}`
|
||
|
|
return typeof error === 'string' ? error : ''
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isStaticAssetLoadError(error: unknown) {
|
||
|
|
return /ChunkLoadError|Loading chunk [\w-]+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(errorMessage(error))
|
||
|
|
}
|
||
|
|
|
||
|
|
function readRecoveryRecord(): RecoveryRecord | null {
|
||
|
|
try {
|
||
|
|
const raw = window.sessionStorage.getItem(RECOVERY_KEY)
|
||
|
|
if (!raw) return null
|
||
|
|
const value = JSON.parse(raw) as Partial<RecoveryRecord>
|
||
|
|
return typeof value.href === 'string' && typeof value.refreshedAt === 'number'
|
||
|
|
? { href: value.href, refreshedAt: value.refreshedAt }
|
||
|
|
: null
|
||
|
|
} catch {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeRecoveryRecord(record: RecoveryRecord) {
|
||
|
|
try {
|
||
|
|
window.sessionStorage.setItem(RECOVERY_KEY, JSON.stringify(record))
|
||
|
|
return true
|
||
|
|
} catch {
|
||
|
|
// Without a persistent guard, reloading could loop while the deployment is still unavailable.
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearRecoveryRecord() {
|
||
|
|
try {
|
||
|
|
window.sessionStorage.removeItem(RECOVERY_KEY)
|
||
|
|
} catch {
|
||
|
|
// Storage availability must not affect application startup.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function recoverFromStaticAssetFailure(error: unknown, confirmedAssetFailure = false) {
|
||
|
|
if (!confirmedAssetFailure && !isStaticAssetLoadError(error)) return false
|
||
|
|
const now = Date.now()
|
||
|
|
const previous = readRecoveryRecord()
|
||
|
|
if (previous?.href === window.location.href && now - previous.refreshedAt < RECOVERY_COOLDOWN_MS) return false
|
||
|
|
if (!writeRecoveryRecord({ href: window.location.href, refreshedAt: now })) return false
|
||
|
|
window.location.reload()
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
function failedApplicationAsset(event: Event) {
|
||
|
|
const target = event.target
|
||
|
|
if (target instanceof HTMLScriptElement) return target.src
|
||
|
|
if (target instanceof HTMLLinkElement && ['stylesheet', 'modulepreload', 'preload'].includes(target.rel)) return target.href
|
||
|
|
return ''
|
||
|
|
}
|
||
|
|
|
||
|
|
export function installStaticAssetRecovery(router: Router) {
|
||
|
|
window.addEventListener('vite:preloadError', (event) => {
|
||
|
|
const preloadEvent = event as Event & { payload?: unknown }
|
||
|
|
if (recoverFromStaticAssetFailure(preloadEvent.payload, true)) preloadEvent.preventDefault()
|
||
|
|
})
|
||
|
|
|
||
|
|
window.addEventListener('error', (event) => {
|
||
|
|
const assetUrl = failedApplicationAsset(event)
|
||
|
|
if (!assetUrl) return
|
||
|
|
try {
|
||
|
|
if (new URL(assetUrl, window.location.href).origin !== window.location.origin) return
|
||
|
|
} catch {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
recoverFromStaticAssetFailure(assetUrl, true)
|
||
|
|
}, true)
|
||
|
|
|
||
|
|
router.onError((error) => {
|
||
|
|
recoverFromStaticAssetFailure(error)
|
||
|
|
})
|
||
|
|
|
||
|
|
window.setTimeout(clearRecoveryRecord, RECOVERY_STABLE_MS)
|
||
|
|
}
|