mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
fix(pwa): coordinate service worker lifecycle (#569)
This commit is contained in:
@@ -62,6 +62,12 @@ export async function retryMoviePilotIdentityVerification(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除当前 origin 的全部 Cache Storage;调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
|
||||||
|
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
|
||||||
|
const cacheNames = await cacheStorage.keys()
|
||||||
|
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
|
||||||
|
}
|
||||||
|
|
||||||
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
||||||
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
||||||
if (!requested) return new URL('/', origin)
|
if (!requested) return new URL('/', origin)
|
||||||
@@ -78,6 +84,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
|||||||
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
||||||
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
||||||
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
||||||
|
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
|
||||||
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
||||||
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
||||||
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
||||||
@@ -85,6 +92,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
|||||||
const redirectScript = `
|
const redirectScript = `
|
||||||
(() => {
|
(() => {
|
||||||
const entryScriptUrl = ${entryScriptUrl}
|
const entryScriptUrl = ${entryScriptUrl}
|
||||||
|
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||||
let appStarted = false
|
let appStarted = false
|
||||||
const startApp = () => {
|
const startApp = () => {
|
||||||
if (appStarted) return
|
if (appStarted) return
|
||||||
@@ -150,8 +158,15 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
|||||||
|
|
||||||
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
||||||
if (cleanupState === 'complete') {
|
if (cleanupState === 'complete') {
|
||||||
|
void (async () => {
|
||||||
|
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
|
||||||
|
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||||
sessionStorage.removeItem(cleanupAttemptKey)
|
sessionStorage.removeItem(cleanupAttemptKey)
|
||||||
location.reload()
|
location.reload()
|
||||||
|
})().catch(error => {
|
||||||
|
console.error('[PWA] Failed to finish stale development cache cleanup', error)
|
||||||
|
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +195,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
|||||||
const identityTimeoutMs = ${identityTimeoutMs}
|
const identityTimeoutMs = ${identityTimeoutMs}
|
||||||
const identityAttempts = ${identityAttempts}
|
const identityAttempts = ${identityAttempts}
|
||||||
const retryIdentityVerification = ${retryIdentityVerification}
|
const retryIdentityVerification = ${retryIdentityVerification}
|
||||||
|
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||||
const appScope = new URL('./', location.href)
|
const appScope = new URL('./', location.href)
|
||||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||||
const resolveReturnUrl = () => {
|
const resolveReturnUrl = () => {
|
||||||
@@ -227,6 +243,9 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
|||||||
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
||||||
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
||||||
|
|
||||||
|
// Vite dev server 使用独立 origin;仅在确认 MoviePilot Worker 后清除其遗留模块响应。
|
||||||
|
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||||
|
|
||||||
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
||||||
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
||||||
location.replace(returnUrl.href)
|
location.replace(returnUrl.href)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useVersionChecker } from '@/composables/useVersionChecker'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
toastInfo: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ info: mocks.toastInfo }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useVersionChecker', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.toastInfo.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有可用 Service Worker 时保留版本不一致的清缓存兜底', async () => {
|
||||||
|
const { checkVersion } = useVersionChecker()
|
||||||
|
|
||||||
|
await checkVersion('version-that-never-matches-the-build')
|
||||||
|
|
||||||
|
expect(mocks.toastInfo).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.toastInfo).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
props: expect.objectContaining({
|
||||||
|
message: expect.any(String),
|
||||||
|
onRefresh: expect.any(Function),
|
||||||
|
refreshText: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
draggable: false,
|
||||||
|
timeout: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,11 +3,57 @@ import { useToast } from 'vue-toastification'
|
|||||||
import { Workbox } from 'workbox-window'
|
import { Workbox } from 'workbox-window'
|
||||||
import i18n from '@/plugins/i18n'
|
import i18n from '@/plugins/i18n'
|
||||||
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
|
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
|
||||||
|
import {
|
||||||
|
createServiceWorkerCoordinator,
|
||||||
|
resolveServiceWorkerRegistration,
|
||||||
|
type ServiceWorkerRegistrationConfig,
|
||||||
|
} from '@/utils/serviceWorkerCoordinator'
|
||||||
|
|
||||||
// 全局状态
|
// 全局状态
|
||||||
const currentVersion = ref(__APP_VERSION__)
|
const currentVersion = ref(__APP_VERSION__)
|
||||||
let isUpdateToastShown = false
|
let isUpdateToastShown = false
|
||||||
let wb: Workbox | null = null
|
|
||||||
|
const serviceWorkerRegistration =
|
||||||
|
'serviceWorker' in navigator
|
||||||
|
? resolveServiceWorkerRegistration(
|
||||||
|
import.meta.env.BASE_URL,
|
||||||
|
document.baseURI,
|
||||||
|
import.meta.env.DEV,
|
||||||
|
__PWA_DEVELOPMENT__,
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
|
/** 显示全局唯一的版本更新通知。 */
|
||||||
|
function showUpdateNotification(message: string, refreshText?: string, onRefresh?: () => void): void {
|
||||||
|
if (isUpdateToastShown) return
|
||||||
|
isUpdateToastShown = true
|
||||||
|
const component = h(VersionUpdateToast, {
|
||||||
|
message,
|
||||||
|
refreshText,
|
||||||
|
onRefresh,
|
||||||
|
})
|
||||||
|
|
||||||
|
useToast().info(component, {
|
||||||
|
timeout: false,
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
draggable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceWorkerCoordinator = createServiceWorkerCoordinator({
|
||||||
|
registration: serviceWorkerRegistration,
|
||||||
|
createClient: (registration: ServiceWorkerRegistrationConfig) =>
|
||||||
|
new Workbox(registration.scriptUrl, {
|
||||||
|
scope: registration.scope,
|
||||||
|
type: registration.type,
|
||||||
|
}),
|
||||||
|
onUpdateActivated: () => {
|
||||||
|
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
|
||||||
|
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
|
||||||
|
},
|
||||||
|
onError: error => console.error('[VersionChecker] Service Worker 注册失败:', error),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 普通刷新页面
|
* 普通刷新页面
|
||||||
@@ -62,16 +108,20 @@ export const clearCacheAndReload = async (): Promise<void> => {
|
|||||||
const reloadTimer = window.setTimeout(reload, 3000)
|
const reloadTimer = window.setTimeout(reload, 3000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([clearCachesAndServiceWorker(), new Promise(resolve => window.setTimeout(resolve, 2500))])
|
||||||
clearCachesAndServiceWorker(),
|
|
||||||
new Promise(resolve => window.setTimeout(resolve, 2500)),
|
|
||||||
])
|
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(reloadTimer)
|
window.clearTimeout(reloadTimer)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化应用唯一的 Service Worker coordinator。
|
||||||
|
* 普通 dev 不注册;production 与显式 dev:pwa 复用同一个注册 Promise。
|
||||||
|
*/
|
||||||
|
export const initializeServiceWorker = (): Promise<ServiceWorkerRegistration | undefined> =>
|
||||||
|
serviceWorkerCoordinator.initialize()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 版本检查 Composable
|
* 版本检查 Composable
|
||||||
*
|
*
|
||||||
@@ -81,49 +131,6 @@ export const clearCacheAndReload = async (): Promise<void> => {
|
|||||||
* - 显示持久化更新通知
|
* - 显示持久化更新通知
|
||||||
*/
|
*/
|
||||||
export function useVersionChecker() {
|
export function useVersionChecker() {
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示版本更新通知
|
|
||||||
* @param message 通知消息文本
|
|
||||||
* @param refreshText 按钮文本,不传则不显示按钮
|
|
||||||
* @param onRefresh 按钮点击事件
|
|
||||||
*/
|
|
||||||
const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {
|
|
||||||
if (isUpdateToastShown) return
|
|
||||||
isUpdateToastShown = true
|
|
||||||
const component = h(VersionUpdateToast, {
|
|
||||||
message,
|
|
||||||
refreshText,
|
|
||||||
onRefresh,
|
|
||||||
})
|
|
||||||
|
|
||||||
toast.info(component, {
|
|
||||||
timeout: false, // 不自动消失
|
|
||||||
closeButton: false,
|
|
||||||
closeOnClick: false,
|
|
||||||
draggable: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 Workbox
|
|
||||||
if (!wb && 'serviceWorker' in navigator) {
|
|
||||||
wb = new Workbox('/service-worker.js')
|
|
||||||
|
|
||||||
// Service Worker 激活事件 (install -> activate)
|
|
||||||
wb.addEventListener('activated', event => {
|
|
||||||
// 只有在更新时才显示通知
|
|
||||||
if (event.isUpdate) {
|
|
||||||
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
|
|
||||||
|
|
||||||
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 注册 Service Worker
|
|
||||||
wb.register()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查版本并在需要时显示更新通知
|
* 检查版本并在需要时显示更新通知
|
||||||
* @param latestVersion 服务端返回的最新版本号
|
* @param latestVersion 服务端返回的最新版本号
|
||||||
@@ -141,33 +148,26 @@ export function useVersionChecker() {
|
|||||||
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
|
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
|
||||||
|
|
||||||
// 尝试触发 Service Worker 更新检查
|
// 尝试触发 Service Worker 更新检查
|
||||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
const registration = await initializeServiceWorker()
|
||||||
|
if (registration && navigator.serviceWorker.controller) {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (registration) {
|
|
||||||
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
|
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
|
||||||
|
|
||||||
// 标记是否发现更新
|
|
||||||
let updateFound = false
|
let updateFound = false
|
||||||
const onUpdateFound = () => {
|
const onUpdateFound = () => {
|
||||||
updateFound = true
|
updateFound = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 updatefound 事件
|
|
||||||
registration.addEventListener('updatefound', onUpdateFound, { once: true })
|
registration.addEventListener('updatefound', onUpdateFound, { once: true })
|
||||||
|
|
||||||
// 等待检查完成
|
await serviceWorkerCoordinator.update()
|
||||||
await registration.update()
|
|
||||||
|
|
||||||
// 检查是否有更新正在进行
|
// 更新生命周期由同一个 Workbox 实例继续监听,避免检查和提示使用不同 owner。
|
||||||
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
|
|
||||||
if (updateFound || registration.installing || registration.waiting) {
|
if (updateFound || registration.installing || registration.waiting) {
|
||||||
console.log('[VersionChecker] Service Worker 更新中...')
|
console.log('[VersionChecker] Service Worker 更新中...')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
|
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
|
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
|
||||||
// 失败继续向下执行,显示通知
|
// 失败继续向下执行,显示通知
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
emitAgentAssistantToastBubble,
|
emitAgentAssistantToastBubble,
|
||||||
type AgentAssistantBubbleVariant,
|
type AgentAssistantBubbleVariant,
|
||||||
} from '@/utils/agentAssistantBubble'
|
} from '@/utils/agentAssistantBubble'
|
||||||
|
import { initializeServiceWorker } from '@/composables/useVersionChecker'
|
||||||
|
|
||||||
// 5. 注册自定义组件
|
// 5. 注册自定义组件
|
||||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
@@ -178,6 +179,9 @@ app
|
|||||||
.use(ConfirmDialog)
|
.use(ConfirmDialog)
|
||||||
.use(i18n)
|
.use(i18n)
|
||||||
|
|
||||||
|
// UI 通知依赖安装完成后立即绑定更新监听并启动唯一的 Service Worker 注册。
|
||||||
|
void initializeServiceWorker()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
// 图标全集很大,延后到首屏挂载后的空闲时间加载,避免阻塞登录页首次渲染。
|
// 图标全集很大,延后到首屏挂载后的空闲时间加载,避免阻塞登录页首次渲染。
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
declare global {
|
declare global {
|
||||||
const __APP_VERSION__: string
|
const __APP_VERSION__: string
|
||||||
const __BUILD_TIME__: string
|
const __BUILD_TIME__: string
|
||||||
|
const __PWA_DEVELOPMENT__: boolean
|
||||||
|
|
||||||
interface Navigator {
|
interface Navigator {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
createServiceWorkerCoordinator,
|
||||||
|
resolveServiceWorkerRegistration,
|
||||||
|
type ServiceWorkerClient,
|
||||||
|
} from '@/utils/serviceWorkerCoordinator'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
describe('Service Worker coordinator', () => {
|
||||||
|
it('普通 dev 不注册,dev:pwa 使用模块 Worker', () => {
|
||||||
|
const documentBaseUrl = 'http://localhost:5174/moviepilot/#/dashboard'
|
||||||
|
|
||||||
|
expect(resolveServiceWorkerRegistration('./', documentBaseUrl, true, false)).toBeNull()
|
||||||
|
expect(resolveServiceWorkerRegistration('./', documentBaseUrl, true, true)).toEqual({
|
||||||
|
scriptUrl: 'http://localhost:5174/moviepilot/dev-sw.js?dev-sw',
|
||||||
|
scope: '/moviepilot/',
|
||||||
|
type: 'module',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('production 按应用 base 注册 classic Worker', () => {
|
||||||
|
expect(
|
||||||
|
resolveServiceWorkerRegistration('/moviepilot/', 'https://example.com/ignored/#/dashboard', false, false),
|
||||||
|
).toEqual({
|
||||||
|
scriptUrl: 'https://example.com/moviepilot/service-worker.js',
|
||||||
|
scope: '/moviepilot/',
|
||||||
|
type: 'classic',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('监听先于注册,重复初始化和更新复用同一个客户端', async () => {
|
||||||
|
const events: string[] = []
|
||||||
|
let activatedListener: ((event: { isExternal?: boolean; isUpdate?: boolean }) => void) | undefined
|
||||||
|
const registration = {} as ServiceWorkerRegistration
|
||||||
|
const client: ServiceWorkerClient = {
|
||||||
|
addEventListener: vi.fn((_type, listener) => {
|
||||||
|
events.push('listen')
|
||||||
|
activatedListener = listener
|
||||||
|
}),
|
||||||
|
register: vi.fn(async () => {
|
||||||
|
events.push('register')
|
||||||
|
return registration
|
||||||
|
}),
|
||||||
|
update: vi.fn(async () => {
|
||||||
|
events.push('update')
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const createClient = vi.fn(() => client)
|
||||||
|
const onUpdateActivated = vi.fn()
|
||||||
|
const coordinator = createServiceWorkerCoordinator({
|
||||||
|
registration: {
|
||||||
|
scriptUrl: 'https://example.com/moviepilot/service-worker.js',
|
||||||
|
scope: '/moviepilot/',
|
||||||
|
type: 'classic',
|
||||||
|
},
|
||||||
|
createClient,
|
||||||
|
onUpdateActivated,
|
||||||
|
getController: () => null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstInitialization = coordinator.initialize()
|
||||||
|
const secondInitialization = coordinator.initialize()
|
||||||
|
|
||||||
|
expect(firstInitialization).toBe(secondInitialization)
|
||||||
|
await expect(firstInitialization).resolves.toBe(registration)
|
||||||
|
expect(createClient).toHaveBeenCalledOnce()
|
||||||
|
expect(client.register).toHaveBeenCalledOnce()
|
||||||
|
expect(events).toEqual(['listen', 'register'])
|
||||||
|
|
||||||
|
activatedListener?.({ isUpdate: false })
|
||||||
|
expect(onUpdateActivated).not.toHaveBeenCalled()
|
||||||
|
activatedListener?.({ isExternal: true })
|
||||||
|
expect(onUpdateActivated).toHaveBeenCalledOnce()
|
||||||
|
activatedListener?.({ isUpdate: true })
|
||||||
|
expect(onUpdateActivated).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
await coordinator.update()
|
||||||
|
expect(createClient).toHaveBeenCalledOnce()
|
||||||
|
expect(client.register).toHaveBeenCalledOnce()
|
||||||
|
expect(client.update).toHaveBeenCalledOnce()
|
||||||
|
expect(events).toEqual(['listen', 'register', 'update'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('注册期间快速激活时通过 controller 变化补发一次更新通知', async () => {
|
||||||
|
const previousController = {} as ServiceWorker
|
||||||
|
const updatedWorker = {} as ServiceWorker
|
||||||
|
let activatedListener: ((event: { isExternal?: boolean; isUpdate?: boolean }) => void) | undefined
|
||||||
|
const client: ServiceWorkerClient = {
|
||||||
|
addEventListener: vi.fn((_type, listener) => {
|
||||||
|
activatedListener = listener
|
||||||
|
}),
|
||||||
|
register: vi.fn(async () => ({ active: updatedWorker }) as ServiceWorkerRegistration),
|
||||||
|
update: vi.fn(),
|
||||||
|
}
|
||||||
|
const onUpdateActivated = vi.fn()
|
||||||
|
const coordinator = createServiceWorkerCoordinator({
|
||||||
|
registration: {
|
||||||
|
scriptUrl: 'https://example.com/service-worker.js',
|
||||||
|
scope: '/',
|
||||||
|
type: 'classic',
|
||||||
|
},
|
||||||
|
createClient: () => client,
|
||||||
|
onUpdateActivated,
|
||||||
|
getController: () => previousController,
|
||||||
|
})
|
||||||
|
|
||||||
|
await coordinator.initialize()
|
||||||
|
activatedListener?.({ isUpdate: true })
|
||||||
|
|
||||||
|
expect(onUpdateActivated).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('注册失败时只记录一次且不阻断应用启动', async () => {
|
||||||
|
const error = new Error('register failed')
|
||||||
|
const onError = vi.fn()
|
||||||
|
const client: ServiceWorkerClient = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
register: vi.fn().mockRejectedValue(error),
|
||||||
|
update: vi.fn(),
|
||||||
|
}
|
||||||
|
const coordinator = createServiceWorkerCoordinator({
|
||||||
|
registration: {
|
||||||
|
scriptUrl: 'https://example.com/service-worker.js',
|
||||||
|
scope: '/',
|
||||||
|
type: 'classic',
|
||||||
|
},
|
||||||
|
createClient: () => client,
|
||||||
|
onUpdateActivated: vi.fn(),
|
||||||
|
getController: () => null,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.initialize()).resolves.toBeUndefined()
|
||||||
|
await expect(coordinator.initialize()).resolves.toBeUndefined()
|
||||||
|
expect(client.register).toHaveBeenCalledOnce()
|
||||||
|
expect(onError).toHaveBeenCalledOnce()
|
||||||
|
expect(onError).toHaveBeenCalledWith(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/** Service Worker 在当前运行模式下的注册参数。 */
|
||||||
|
export interface ServiceWorkerRegistrationConfig {
|
||||||
|
/** Worker 脚本的同源绝对地址。 */
|
||||||
|
scriptUrl: string
|
||||||
|
/** Worker 允许控制的应用路径。 */
|
||||||
|
scope: string
|
||||||
|
/** development Worker 使用 ESM,production Worker 保持 classic。 */
|
||||||
|
type: WorkerType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Workbox coordinator 所需的最小客户端契约。 */
|
||||||
|
export interface ServiceWorkerClient {
|
||||||
|
addEventListener(type: 'activated', listener: (event: { isExternal?: boolean; isUpdate?: boolean }) => void): void
|
||||||
|
register(): Promise<ServiceWorkerRegistration | undefined>
|
||||||
|
update(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceWorkerCoordinatorOptions {
|
||||||
|
/** 普通 dev 返回 null;production 与显式 dev:pwa 返回实际注册参数。 */
|
||||||
|
registration: ServiceWorkerRegistrationConfig | null
|
||||||
|
/** 创建 Workbox 客户端,由 coordinator 保证全生命周期只调用一次。 */
|
||||||
|
createClient: (registration: ServiceWorkerRegistrationConfig) => ServiceWorkerClient
|
||||||
|
/** 已有 Worker 完成升级激活时通知 UI。 */
|
||||||
|
onUpdateActivated: () => void
|
||||||
|
/** 返回当前页面 controller,用于补偿注册期间完成的快速激活。 */
|
||||||
|
getController?: () => ServiceWorker | null
|
||||||
|
/** 注册失败不阻断应用启动,但必须保留诊断信息。 */
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 Vite base 和运行模式解析唯一的 Worker URL、scope 与脚本类型。
|
||||||
|
*/
|
||||||
|
export function resolveServiceWorkerRegistration(
|
||||||
|
baseUrl: string,
|
||||||
|
documentBaseUrl: string,
|
||||||
|
isDevelopment: boolean,
|
||||||
|
pwaDevelopmentEnabled: boolean,
|
||||||
|
): ServiceWorkerRegistrationConfig | null {
|
||||||
|
if (isDevelopment && !pwaDevelopmentEnabled) return null
|
||||||
|
|
||||||
|
const appBaseUrl = new URL(baseUrl, documentBaseUrl)
|
||||||
|
const workerScript = isDevelopment ? 'dev-sw.js?dev-sw' : 'service-worker.js'
|
||||||
|
|
||||||
|
return {
|
||||||
|
scriptUrl: new URL(workerScript, appBaseUrl).href,
|
||||||
|
scope: appBaseUrl.pathname,
|
||||||
|
type: isDevelopment ? 'module' : 'classic',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建单 owner 的注册协调器;监听器先绑定,随后只执行一次 register。
|
||||||
|
*/
|
||||||
|
export function createServiceWorkerCoordinator(options: ServiceWorkerCoordinatorOptions) {
|
||||||
|
let client: ServiceWorkerClient | null = null
|
||||||
|
let registrationPromise: Promise<ServiceWorkerRegistration | undefined> | null = null
|
||||||
|
let updateNotified = false
|
||||||
|
|
||||||
|
const notifyUpdateActivated = (): void => {
|
||||||
|
if (updateNotified) return
|
||||||
|
updateNotified = true
|
||||||
|
options.onUpdateActivated()
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialize = (): Promise<ServiceWorkerRegistration | undefined> => {
|
||||||
|
if (!options.registration) return Promise.resolve(undefined)
|
||||||
|
if (registrationPromise) return registrationPromise
|
||||||
|
|
||||||
|
const controllerBeforeRegistration = options.getController
|
||||||
|
? options.getController()
|
||||||
|
: navigator.serviceWorker.controller
|
||||||
|
client = options.createClient(options.registration)
|
||||||
|
client.addEventListener('activated', event => {
|
||||||
|
// 其他页签或注册 60 秒后发现的更新会被 Workbox 标为 external,仍代表当前页面需要刷新。
|
||||||
|
if (event.isUpdate || event.isExternal) notifyUpdateActivated()
|
||||||
|
})
|
||||||
|
registrationPromise = client
|
||||||
|
.register()
|
||||||
|
.then(registration => {
|
||||||
|
// skipWaiting + clients.claim 可能在 Workbox 接上底层监听前完成;active 已变化时仍需通知用户刷新。
|
||||||
|
if (controllerBeforeRegistration && registration?.active !== controllerBeforeRegistration) {
|
||||||
|
notifyUpdateActivated()
|
||||||
|
}
|
||||||
|
return registration
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
options.onError?.(error)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return registrationPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = async (): Promise<void> => {
|
||||||
|
await initialize()
|
||||||
|
await client?.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialize,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { IndexHtmlTransformContext, IndexHtmlTransformResult, ViteDevServer
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
createDevServiceWorkerCleanupPlugin,
|
createDevServiceWorkerCleanupPlugin,
|
||||||
|
deleteCurrentOriginCaches,
|
||||||
DEV_SW_CLEANUP_PATH,
|
DEV_SW_CLEANUP_PATH,
|
||||||
isManagedServiceWorkerRegistration,
|
isManagedServiceWorkerRegistration,
|
||||||
isMoviePilotServiceWorkerIdentityResponse,
|
isMoviePilotServiceWorkerIdentityResponse,
|
||||||
@@ -67,6 +68,19 @@ describe('PWA 开发模式', () => {
|
|||||||
expect(alwaysFails).toHaveBeenCalledTimes(2)
|
expect(alwaysFails).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('删除当前 dev origin 的全部 Cache Storage', async () => {
|
||||||
|
const keys = vi.fn().mockResolvedValue(['precache', 'static-resources', 'api-cache'])
|
||||||
|
const deleteCache = vi.fn().mockResolvedValue(true)
|
||||||
|
|
||||||
|
await deleteCurrentOriginCaches({ keys, delete: deleteCache })
|
||||||
|
|
||||||
|
expect(keys).toHaveBeenCalledOnce()
|
||||||
|
expect(deleteCache).toHaveBeenCalledTimes(3)
|
||||||
|
expect(deleteCache).toHaveBeenNthCalledWith(1, 'precache')
|
||||||
|
expect(deleteCache).toHaveBeenNthCalledWith(2, 'static-resources')
|
||||||
|
expect(deleteCache).toHaveBeenNthCalledWith(3, 'api-cache')
|
||||||
|
})
|
||||||
|
|
||||||
it('清理完成后只返回当前 origin', () => {
|
it('清理完成后只返回当前 origin', () => {
|
||||||
const origin = 'http://localhost:5173'
|
const origin = 'http://localhost:5173'
|
||||||
|
|
||||||
@@ -112,8 +126,11 @@ describe('PWA 开发模式', () => {
|
|||||||
expect(scriptContent).toContain("cleanupState === 'complete'")
|
expect(scriptContent).toContain("cleanupState === 'complete'")
|
||||||
expect(scriptContent).toContain("sessionStorage.setItem(cleanupAttemptKey, 'pending')")
|
expect(scriptContent).toContain("sessionStorage.setItem(cleanupAttemptKey, 'pending')")
|
||||||
expect(scriptContent).toContain('encodeURIComponent(appScope.pathname)')
|
expect(scriptContent).toContain('encodeURIComponent(appScope.pathname)')
|
||||||
|
expect(scriptContent).toContain('deleteCurrentOriginCaches(caches)')
|
||||||
expect(scriptContent).toContain('location.reload()')
|
expect(scriptContent).toContain('location.reload()')
|
||||||
expect(scriptContent).not.toContain('caches.delete')
|
expect(scriptContent.indexOf('deleteCurrentOriginCaches(caches)')).toBeLessThan(
|
||||||
|
scriptContent.indexOf('sessionStorage.removeItem(cleanupAttemptKey)'),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('开发入口标签缺失时立即失败,避免普通 dev 静默白屏', () => {
|
it('开发入口标签缺失时立即失败,避免普通 dev 静默白屏', () => {
|
||||||
@@ -158,8 +175,17 @@ describe('PWA 开发模式', () => {
|
|||||||
expect(response.end).toHaveBeenCalledWith(
|
expect(response.end).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("sessionStorage.setItem(cleanupAttemptKey, 'complete')"),
|
expect.stringContaining("sessionStorage.setItem(cleanupAttemptKey, 'complete')"),
|
||||||
)
|
)
|
||||||
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('caches.delete'))
|
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('deleteCurrentOriginCaches(caches)'))
|
||||||
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('localStorage.clear'))
|
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('localStorage.clear'))
|
||||||
|
|
||||||
|
const cleanupDocument = vi.mocked(response.end).mock.calls[0]?.[0]
|
||||||
|
if (typeof cleanupDocument !== 'string') throw new TypeError('Expected cleanup document')
|
||||||
|
expect(cleanupDocument.indexOf("sessionStorage.getItem(cleanupAttemptKey) !== 'pending'")).toBeLessThan(
|
||||||
|
cleanupDocument.indexOf('deleteCurrentOriginCaches(caches)'),
|
||||||
|
)
|
||||||
|
expect(cleanupDocument.indexOf('if (!managedRegistrations.length) throw new Error')).toBeLessThan(
|
||||||
|
cleanupDocument.indexOf('deleteCurrentOriginCaches(caches)'),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -70,8 +70,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
}),
|
}),
|
||||||
!isTestMode(mode) &&
|
!isTestMode(mode) &&
|
||||||
VitePWA({
|
VitePWA({
|
||||||
injectRegister: 'script',
|
injectRegister: false,
|
||||||
registerType: 'autoUpdate',
|
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
filename: 'service-worker.ts',
|
filename: 'service-worker.ts',
|
||||||
@@ -211,6 +210,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'process.env': {},
|
'process.env': {},
|
||||||
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
|
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
|
||||||
'__BUILD_TIME__': JSON.stringify(buildTime),
|
'__BUILD_TIME__': JSON.stringify(buildTime),
|
||||||
|
'__PWA_DEVELOPMENT__': JSON.stringify(isPwaDevelopmentEnabled(mode, process.env.npm_lifecycle_event)),
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user