mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
fix(pwa): coordinate service worker lifecycle (#569)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user