fix(pwa): isolate service worker development mode (#568)

This commit is contained in:
InfinityPacer
2026-07-21 16:41:55 +08:00
committed by GitHub
parent 42ba550bb4
commit 8dc36a2007
4 changed files with 455 additions and 2 deletions
+1
View File
@@ -6,6 +6,7 @@
"bin": "dist/service.js", "bin": "dist/service.js",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"dev:pwa": "vite --host --port 5174",
"prebuild": "npm run build:icons", "prebuild": "npm run build:icons",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 5050", "preview": "vite preview --port 5050",
+275
View File
@@ -0,0 +1,275 @@
import type { Plugin } from 'vite'
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
const devEntryScriptUrl = '/src/main.ts'
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
const moviePilotIdentityTimeoutMs = 1500
const moviePilotIdentityAttempts = 2
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
/** 普通开发服务器才执行历史 Service Worker 清理,production preview 保持构建产物行为。 */
export const shouldEnableDevServiceWorkerCleanup = (
command: 'build' | 'serve',
mode: string,
isPreview: boolean | undefined,
lifecycleEvent?: string,
) =>
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
export function resolveDevAppScope(pageUrl: string): URL {
return new URL('./', new URL(pageUrl))
}
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
export function isManagedServiceWorkerRegistration(
scriptUrl: string,
registrationScope: string,
pageUrl: string,
): boolean {
const appScope = resolveDevAppScope(pageUrl)
const scope = new URL(registrationScope)
const script = new URL(scriptUrl)
return (
scope.href === appScope.href &&
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
)
}
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
return typeof (value as { count?: unknown }).count === 'number'
}
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
export async function retryMoviePilotIdentityVerification(
verifyOnce: () => Promise<boolean>,
attempts: number,
): Promise<boolean> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (await verifyOnce()) return true
}
return false
}
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
if (!requested) return new URL('/', origin)
const target = new URL(requested, origin)
return target.origin === origin ? target : new URL('/', origin)
}
/**
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
*/
export function createDevServiceWorkerCleanupPlugin(): Plugin {
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
const redirectScript = `
(() => {
const entryScriptUrl = ${entryScriptUrl}
let appStarted = false
const startApp = () => {
if (appStarted) return
appStarted = true
const entry = document.createElement('script')
entry.type = 'module'
entry.src = entryScriptUrl
document.head.appendChild(entry)
}
if (!('serviceWorker' in navigator)) {
startApp()
return
}
const workerScripts = ${workerScripts}
const identityMessage = ${identityMessage}
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const cleanupPath = ${cleanupPath}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
const getCandidateWorker = registration => {
if (new URL(registration.scope).href !== appScope.href) return null
return [registration.active, registration.waiting, registration.installing].find(worker => {
if (!worker) return false
const scriptUrl = new URL(worker.scriptURL).href
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
}) || null
}
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
const channel = new MessageChannel()
const finish = result => {
window.clearTimeout(timeout)
channel.port1.close()
resolve(result)
}
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
try {
worker.postMessage({ type: identityMessage }, [channel.port2])
} catch {
finish(false)
}
})
const verifyMoviePilotWorker = worker =>
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
const hasVerifiedRegistration = async () => {
const registrations = await navigator.serviceWorker.getRegistrations()
for (const registration of registrations) {
const worker = getCandidateWorker(registration)
if (worker && await verifyMoviePilotWorker(worker)) return true
}
return false
}
const redirectToCleanup = () => {
sessionStorage.setItem(cleanupAttemptKey, 'pending')
const target = new URL(cleanupPath.slice(1), appScope)
target.searchParams.set('return', location.href)
location.replace(target.href)
}
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
if (cleanupState === 'complete') {
sessionStorage.removeItem(cleanupAttemptKey)
location.reload()
return
}
void hasVerifiedRegistration().then(hasRegistration => {
if (hasRegistration) {
redirectToCleanup()
return
}
sessionStorage.removeItem(cleanupAttemptKey)
startApp()
}).catch(error => {
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
startApp()
})
})()
`
const cleanupDocument = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
<body>
<script>
(() => {
const workerScripts = ${workerScripts}
const identityMessage = ${identityMessage}
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const resolveReturnUrl = () => {
const requested = new URLSearchParams(location.search).get('return')
if (!requested) return appScope
const target = new URL(requested, appScope)
return target.href.startsWith(appScope.href) ? target : appScope
}
const returnUrl = resolveReturnUrl()
const getCandidateWorker = registration => {
if (new URL(registration.scope).href !== appScope.href) return null
return [registration.active, registration.waiting, registration.installing].find(worker => {
if (!worker) return false
const scriptUrl = new URL(worker.scriptURL).href
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
}) || null
}
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
const channel = new MessageChannel()
const finish = result => {
window.clearTimeout(timeout)
channel.port1.close()
resolve(result)
}
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
try {
worker.postMessage({ type: identityMessage }, [channel.port2])
} catch {
finish(false)
}
})
const verifyMoviePilotWorker = worker =>
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
const cleanup = async () => {
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
throw new Error('Missing Service Worker cleanup context for current application scope')
}
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
const managedRegistrations = []
for (const registration of registrations) {
const worker = getCandidateWorker(registration)
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
}
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
sessionStorage.setItem(cleanupAttemptKey, 'complete')
location.replace(returnUrl.href)
}
void cleanup().catch(error => {
console.error('[PWA] Failed to clean stale development Service Worker state', error)
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
})
})()
</script>
</body>
</html>`
return {
name: 'moviepilot:dev-service-worker-cleanup',
apply: 'serve',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = new URL(request.url || '/', 'http://localhost').pathname
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
next()
return
}
response.statusCode = 200
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.setHeader('Cache-Control', 'no-store')
response.end(cleanupDocument)
})
},
transformIndexHtml: {
order: 'pre',
handler(html) {
if (!html.includes(devEntryScriptTag)) {
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
}
return {
html: html.replace(devEntryScriptTag, ''),
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
}
},
},
}
}
+170
View File
@@ -0,0 +1,170 @@
import type { IndexHtmlTransformContext, IndexHtmlTransformResult, ViteDevServer } from 'vite'
import { describe, expect, it, vi } from 'vitest'
import {
createDevServiceWorkerCleanupPlugin,
DEV_SW_CLEANUP_PATH,
isManagedServiceWorkerRegistration,
isMoviePilotServiceWorkerIdentityResponse,
isPwaDevelopmentEnabled,
retryMoviePilotIdentityVerification,
resolveDevAppScope,
resolveDevCleanupReturnUrl,
shouldEnableDevServiceWorkerCleanup,
} from '../../scripts/pwa-development'
describe('PWA 开发模式', () => {
it('仅显式 dev:pwa 脚本启用开发 Service Worker', () => {
expect(isPwaDevelopmentEnabled('development', 'dev')).toBe(false)
expect(isPwaDevelopmentEnabled('development', 'dev:pwa')).toBe(true)
expect(isPwaDevelopmentEnabled('production', 'dev:pwa')).toBe(false)
})
it('仅普通 development server 启用历史 Service Worker 清理', () => {
expect(shouldEnableDevServiceWorkerCleanup('serve', 'development', false, 'dev')).toBe(true)
expect(shouldEnableDevServiceWorkerCleanup('serve', 'development', false, 'dev:pwa')).toBe(false)
expect(shouldEnableDevServiceWorkerCleanup('serve', 'production', true, 'preview')).toBe(false)
expect(shouldEnableDevServiceWorkerCleanup('build', 'production', false, 'build')).toBe(false)
})
it('只匹配当前应用 scope 下精确的 MoviePilot Worker URL', () => {
const pageUrl = 'http://localhost:5173/moviepilot/#/dashboard'
const scope = 'http://localhost:5173/moviepilot/'
expect(resolveDevAppScope(pageUrl).href).toBe(scope)
expect(isManagedServiceWorkerRegistration(`${scope}dev-sw.js?dev-sw`, scope, pageUrl)).toBe(true)
expect(isManagedServiceWorkerRegistration(`${scope}service-worker.js`, scope, pageUrl)).toBe(true)
expect(isManagedServiceWorkerRegistration(`${scope}unrelated-sw.js`, scope, pageUrl)).toBe(false)
expect(
isManagedServiceWorkerRegistration(
'http://localhost:5173/another-app/service-worker.js',
'http://localhost:5173/another-app/',
pageUrl,
),
).toBe(false)
expect(
isManagedServiceWorkerRegistration(
'http://localhost:5174/moviepilot/dev-sw.js?dev-sw',
'http://localhost:5174/moviepilot/',
pageUrl,
),
).toBe(false)
})
it('只有现有 MoviePilot 消息协议响应才能通过身份确认', () => {
expect(isMoviePilotServiceWorkerIdentityResponse({ count: 0 })).toBe(true)
expect(isMoviePilotServiceWorkerIdentityResponse({ count: 3 })).toBe(true)
expect(isMoviePilotServiceWorkerIdentityResponse({ success: true })).toBe(false)
expect(isMoviePilotServiceWorkerIdentityResponse(null)).toBe(false)
})
it('身份确认首次失败后重试,持续失败时保持 fail-closed', async () => {
const succeedsOnRetry = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const alwaysFails = vi.fn().mockResolvedValue(false)
await expect(retryMoviePilotIdentityVerification(succeedsOnRetry, 2)).resolves.toBe(true)
expect(succeedsOnRetry).toHaveBeenCalledTimes(2)
await expect(retryMoviePilotIdentityVerification(alwaysFails, 2)).resolves.toBe(false)
expect(alwaysFails).toHaveBeenCalledTimes(2)
})
it('清理完成后只返回当前 origin', () => {
const origin = 'http://localhost:5173'
expect(resolveDevCleanupReturnUrl('/#/dashboard', origin).href).toBe(`${origin}/#/dashboard`)
expect(resolveDevCleanupReturnUrl('https://example.com/path', origin).href).toBe(`${origin}/`)
expect(resolveDevCleanupReturnUrl(null, origin).href).toBe(`${origin}/`)
})
it('在应用入口前检查历史 MoviePilot Service Worker', async () => {
const plugin = createDevServiceWorkerCleanupPlugin()
const transform = plugin.transformIndexHtml
expect(transform).toBeTypeOf('object')
const entryTag = '<script type="module" src="/src/main.ts"></script>'
const result = await (
transform as {
handler: (html: string, context: IndexHtmlTransformContext) => IndexHtmlTransformResult
}
).handler(`<main>loading</main>${entryTag}`, {} as IndexHtmlTransformContext)
if (!result || Array.isArray(result) || typeof result === 'string') {
throw new Error('Expected transformIndexHtml to gate the development entry')
}
const [script] = result.tags ?? []
expect(result.html).toBe('<main>loading</main>')
expect(script.injectTo).toBe('head-prepend')
if (typeof script.children !== 'string') throw new TypeError('Expected an inline cleanup script')
const scriptContent = script.children
expect(scriptContent).toContain('cleanupPath.slice(1)')
expect(scriptContent).not.toContain("replace(/^//, '')")
expect(scriptContent).toContain('dev-sw.js?dev-sw')
expect(scriptContent).toContain('service-worker.js')
expect(scriptContent).toContain(DEV_SW_CLEANUP_PATH)
expect(scriptContent).toContain('GET_UNREAD_COUNT')
expect(scriptContent).toContain('const identityTimeoutMs = 1500')
expect(scriptContent).toContain('const identityAttempts = 2')
expect(scriptContent).toContain('retryMoviePilotIdentityVerification')
expect(scriptContent).toContain('entry.src = entryScriptUrl')
expect(scriptContent).toContain('startApp()')
expect(scriptContent).toContain('verifyMoviePilotWorkerOnce')
expect(scriptContent).toContain('verifyMoviePilotWorker')
expect(scriptContent).toContain("cleanupState === 'complete'")
expect(scriptContent).toContain("sessionStorage.setItem(cleanupAttemptKey, 'pending')")
expect(scriptContent).toContain('encodeURIComponent(appScope.pathname)')
expect(scriptContent).toContain('location.reload()')
expect(scriptContent).not.toContain('caches.delete')
})
it('开发入口标签缺失时立即失败,避免普通 dev 静默白屏', () => {
const transform = createDevServiceWorkerCleanupPlugin().transformIndexHtml
expect(() =>
(
transform as {
handler: (html: string, context: IndexHtmlTransformContext) => IndexHtmlTransformResult
}
).handler('<main>missing entry</main>', {} as IndexHtmlTransformContext),
).toThrow('Expected development entry tag')
})
it('提供不缓存的清理页面并只清理 MoviePilot 管理的浏览器状态', () => {
const plugin = createDevServiceWorkerCleanupPlugin()
let middleware: ((request: { url?: string }, response: ResponseStub, next: () => void) => void) | undefined
const use = vi.fn(handler => {
middleware = handler
})
const response: ResponseStub = {
statusCode: 0,
setHeader: vi.fn(),
end: vi.fn(),
}
const configureServer = plugin.configureServer as (server: Pick<ViteDevServer, 'middlewares'>) => void
configureServer({ middlewares: { use } } as unknown as Pick<ViteDevServer, 'middlewares'>)
middleware?.({ url: DEV_SW_CLEANUP_PATH }, response, vi.fn())
expect(response.statusCode).toBe(200)
expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store')
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('registration.unregister()'))
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('verifyMoviePilotWorker'))
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('const identityTimeoutMs = 1500'))
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('const identityAttempts = 2'))
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining("sessionStorage.getItem(cleanupAttemptKey) !== 'pending'"),
)
expect(response.end).toHaveBeenCalledWith(expect.stringContaining("const appScope = new URL('./', location.href)"))
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining("new URL('./', returnUrl)"))
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining("sessionStorage.setItem(cleanupAttemptKey, 'complete')"),
)
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('caches.delete'))
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('localStorage.clear'))
})
})
interface ResponseStub {
statusCode: number
setHeader: (name: string, value: string) => void
end: (body: string) => void
}
+9 -2
View File
@@ -14,6 +14,11 @@ import federation from '@originjs/vite-plugin-federation'
import topLevelAwait from 'vite-plugin-top-level-await' import topLevelAwait from 'vite-plugin-top-level-await'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames' import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames'
import {
createDevServiceWorkerCleanupPlugin,
isPwaDevelopmentEnabled,
shouldEnableDevServiceWorkerCleanup,
} from './scripts/pwa-development'
// 读取 package.json 获取版本号 // 读取 package.json 获取版本号
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')) const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -21,9 +26,11 @@ const buildTime = new Date().getTime().toString()
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true' const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({ export default defineConfig(({ command, mode, isPreview }) => ({
base: './', base: './',
plugins: [ plugins: [
shouldEnableDevServiceWorkerCleanup(command, mode, isPreview, process.env.npm_lifecycle_event) &&
createDevServiceWorkerCleanupPlugin(),
vue(), vue(),
vueJsx(), vueJsx(),
vuetify({ vuetify({
@@ -74,7 +81,7 @@ export default defineConfig(({ mode }) => ({
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
}, },
devOptions: { devOptions: {
enabled: true, enabled: isPwaDevelopmentEnabled(mode, process.env.npm_lifecycle_event),
type: 'module', type: 'module',
}, },
manifest: { manifest: {