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: {