From 8dc36a2007c70de994d466c20e1b547d93e1a819 Mon Sep 17 00:00:00 2001
From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
Date: Tue, 21 Jul 2026 16:41:55 +0800
Subject: [PATCH] fix(pwa): isolate service worker development mode (#568)
---
package.json | 1 +
scripts/pwa-development.ts | 275 +++++++++++++++++++++++++++
tests/config/pwa-development.spec.ts | 170 +++++++++++++++++
vite.config.ts | 11 +-
4 files changed, 455 insertions(+), 2 deletions(-)
create mode 100644 scripts/pwa-development.ts
create mode 100644 tests/config/pwa-development.spec.ts
diff --git a/package.json b/package.json
index 857eb966..138dfdff 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
+ "dev:pwa": "vite --host --port 5174",
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
diff --git a/scripts/pwa-development.ts b/scripts/pwa-development.ts
new file mode 100644
index 00000000..c308dfc0
--- /dev/null
+++ b/scripts/pwa-development.ts
@@ -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 = ''
+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,
+ attempts: number,
+): Promise {
+ 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 = `
+
+ MoviePilot Dev Cleanup
+
+
+
+`
+
+ 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' }],
+ }
+ },
+ },
+ }
+}
diff --git a/tests/config/pwa-development.spec.ts b/tests/config/pwa-development.spec.ts
new file mode 100644
index 00000000..8ae4de49
--- /dev/null
+++ b/tests/config/pwa-development.spec.ts
@@ -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 = ''
+ const result = await (
+ transform as {
+ handler: (html: string, context: IndexHtmlTransformContext) => IndexHtmlTransformResult
+ }
+ ).handler(`loading${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('loading')
+ 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('missing entry', {} 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) => void
+ configureServer({ middlewares: { use } } as unknown as Pick)
+ 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
+}
diff --git a/vite.config.ts b/vite.config.ts
index ee35cdec..ce7f1b7b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,6 +14,11 @@ import federation from '@originjs/vite-plugin-federation'
import topLevelAwait from 'vite-plugin-top-level-await'
import { readFileSync } from 'node:fs'
import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames'
+import {
+ createDevServiceWorkerCleanupPlugin,
+ isPwaDevelopmentEnabled,
+ shouldEnableDevServiceWorkerCleanup,
+} from './scripts/pwa-development'
// 读取 package.json 获取版本号
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'
// https://vitejs.dev/config/
-export default defineConfig(({ mode }) => ({
+export default defineConfig(({ command, mode, isPreview }) => ({
base: './',
plugins: [
+ shouldEnableDevServiceWorkerCleanup(command, mode, isPreview, process.env.npm_lifecycle_event) &&
+ createDevServiceWorkerCleanupPlugin(),
vue(),
vueJsx(),
vuetify({
@@ -74,7 +81,7 @@ export default defineConfig(({ mode }) => ({
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
},
devOptions: {
- enabled: true,
+ enabled: isPwaDevelopmentEnabled(mode, process.env.npm_lifecycle_event),
type: 'module',
},
manifest: {