fix(pwa): coordinate service worker lifecycle (#569)

This commit is contained in:
InfinityPacer
2026-07-21 17:30:08 +08:00
committed by GitHub
parent 8dc36a2007
commit 65d93b70e4
9 changed files with 409 additions and 78 deletions
+19
View File
@@ -62,6 +62,12 @@ export async function retryMoviePilotIdentityVerification(
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,避免开发中间页形成开放重定向。 */
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
if (!requested) return new URL('/', origin)
@@ -78,6 +84,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
@@ -85,6 +92,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
const redirectScript = `
(() => {
const entryScriptUrl = ${entryScriptUrl}
const deleteCurrentOriginCaches = ${deleteOriginCaches}
let appStarted = false
const startApp = () => {
if (appStarted) return
@@ -150,8 +158,15 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
if (cleanupState === 'complete') {
void (async () => {
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
if ('caches' in window) await deleteCurrentOriginCaches(caches)
sessionStorage.removeItem(cleanupAttemptKey)
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
}
@@ -180,6 +195,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const deleteCurrentOriginCaches = ${deleteOriginCaches}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const resolveReturnUrl = () => {
@@ -227,6 +243,9 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
// Vite dev server 使用独立 origin;仅在确认 MoviePilot Worker 后清除其遗留模块响应。
if ('caches' in window) await deleteCurrentOriginCaches(caches)
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
sessionStorage.setItem(cleanupAttemptKey, 'complete')
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,
}),
)
})
})
+59 -59
View File
@@ -3,11 +3,57 @@ import { useToast } from 'vue-toastification'
import { Workbox } from 'workbox-window'
import i18n from '@/plugins/i18n'
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
import {
createServiceWorkerCoordinator,
resolveServiceWorkerRegistration,
type ServiceWorkerRegistrationConfig,
} from '@/utils/serviceWorkerCoordinator'
// 全局状态
const currentVersion = ref(__APP_VERSION__)
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)
try {
await Promise.race([
clearCachesAndServiceWorker(),
new Promise(resolve => window.setTimeout(resolve, 2500)),
])
await Promise.race([clearCachesAndServiceWorker(), new Promise(resolve => window.setTimeout(resolve, 2500))])
} finally {
window.clearTimeout(reloadTimer)
reload()
}
}
/**
* 初始化应用唯一的 Service Worker coordinator。
* 普通 dev 不注册;production 与显式 dev:pwa 复用同一个注册 Promise。
*/
export const initializeServiceWorker = (): Promise<ServiceWorkerRegistration | undefined> =>
serviceWorkerCoordinator.initialize()
/**
* 版本检查 Composable
*
@@ -81,49 +131,6 @@ export const clearCacheAndReload = async (): Promise<void> => {
* - 显示持久化更新通知
*/
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 服务端返回的最新版本号
@@ -141,33 +148,26 @@ export function useVersionChecker() {
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
// 尝试触发 Service Worker 更新检查
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
const registration = await initializeServiceWorker()
if (registration && navigator.serviceWorker.controller) {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
// 标记是否发现更新
let updateFound = false
const onUpdateFound = () => {
updateFound = true
}
// 监听 updatefound 事件
registration.addEventListener('updatefound', onUpdateFound, { once: true })
// 等待检查完成
await registration.update()
await serviceWorkerCoordinator.update()
// 检查是否有更新正在进行
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
// 更新生命周期由同一个 Workbox 实例继续监听,避免检查和提示使用不同 owner。
if (updateFound || registration.installing || registration.waiting) {
console.log('[VersionChecker] Service Worker 更新中...')
return
}
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
}
} catch (error) {
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
// 失败继续向下执行,显示通知
+4
View File
@@ -22,6 +22,7 @@ import {
emitAgentAssistantToastBubble,
type AgentAssistantBubbleVariant,
} from '@/utils/agentAssistantBubble'
import { initializeServiceWorker } from '@/composables/useVersionChecker'
// 5. 注册自定义组件
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
@@ -178,6 +179,9 @@ app
.use(ConfirmDialog)
.use(i18n)
// UI 通知依赖安装完成后立即绑定更新监听并启动唯一的 Service Worker 注册。
void initializeServiceWorker()
app.mount('#app')
// 图标全集很大,延后到首屏挂载后的空闲时间加载,避免阻塞登录页首次渲染。
+1
View File
@@ -2,6 +2,7 @@
declare global {
const __APP_VERSION__: string
const __BUILD_TIME__: string
const __PWA_DEVELOPMENT__: boolean
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)
})
})
+104
View File
@@ -0,0 +1,104 @@
/** Service Worker 在当前运行模式下的注册参数。 */
export interface ServiceWorkerRegistrationConfig {
/** Worker 脚本的同源绝对地址。 */
scriptUrl: string
/** Worker 允许控制的应用路径。 */
scope: string
/** development Worker 使用 ESMproduction 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 返回 nullproduction 与显式 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,
}
}
+28 -2
View File
@@ -2,6 +2,7 @@ import type { IndexHtmlTransformContext, IndexHtmlTransformResult, ViteDevServer
import { describe, expect, it, vi } from 'vitest'
import {
createDevServiceWorkerCleanupPlugin,
deleteCurrentOriginCaches,
DEV_SW_CLEANUP_PATH,
isManagedServiceWorkerRegistration,
isMoviePilotServiceWorkerIdentityResponse,
@@ -67,6 +68,19 @@ describe('PWA 开发模式', () => {
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', () => {
const origin = 'http://localhost:5173'
@@ -112,8 +126,11 @@ describe('PWA 开发模式', () => {
expect(scriptContent).toContain("cleanupState === 'complete'")
expect(scriptContent).toContain("sessionStorage.setItem(cleanupAttemptKey, 'pending')")
expect(scriptContent).toContain('encodeURIComponent(appScope.pathname)')
expect(scriptContent).toContain('deleteCurrentOriginCaches(caches)')
expect(scriptContent).toContain('location.reload()')
expect(scriptContent).not.toContain('caches.delete')
expect(scriptContent.indexOf('deleteCurrentOriginCaches(caches)')).toBeLessThan(
scriptContent.indexOf('sessionStorage.removeItem(cleanupAttemptKey)'),
)
})
it('开发入口标签缺失时立即失败,避免普通 dev 静默白屏', () => {
@@ -158,8 +175,17 @@ describe('PWA 开发模式', () => {
expect(response.end).toHaveBeenCalledWith(
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'))
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
View File
@@ -70,8 +70,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
}),
!isTestMode(mode) &&
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',
injectRegister: false,
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
@@ -211,6 +210,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'process.env': {},
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
'__BUILD_TIME__': JSON.stringify(buildTime),
'__PWA_DEVELOPMENT__': JSON.stringify(isPwaDevelopmentEnabled(mode, process.env.npm_lifecycle_event)),
},
resolve: {
alias: {