From 5ca6c520aeaf7c68f6f32ea92c1c835cd28de28b Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:07:00 +0800 Subject: [PATCH] fix(pwa): recover CORS image cache reads (#627) --- src/service-worker.ts | 5 +- .../__tests__/serviceWorkerCache.spec.ts | 53 +++++++++++++++++++ src/utils/serviceWorkerCache.ts | 21 ++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/serviceWorkerCache.spec.ts create mode 100644 src/utils/serviceWorkerCache.ts diff --git a/src/service-worker.ts b/src/service-worker.ts index 743a99ae..42d9ce2c 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -4,6 +4,7 @@ import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategi import { ExpirationPlugin } from 'workbox-expiration' import { CacheableResponsePlugin } from 'workbox-cacheable-response' import * as navigationPreload from 'workbox-navigation-preload' +import { corsSafeCachePlugin } from '@/utils/serviceWorkerCache' // Service Worker 类型声明 declare let self: ServiceWorkerGlobalScope & { @@ -97,6 +98,7 @@ registerRoute( new CacheFirst({ cacheName: `image-cache-${RESOURCE_VERSION}`, plugins: [ + corsSafeCachePlugin, new CacheableResponsePlugin({ statuses: [0, 200], }), @@ -131,6 +133,7 @@ registerRoute( new CacheFirst({ cacheName: `tmdb-image-cache-${RESOURCE_VERSION}`, plugins: [ + corsSafeCachePlugin, new CacheableResponsePlugin({ statuses: [0, 200], }), @@ -157,7 +160,7 @@ registerRoute( !url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口 !url.pathname.includes('/api/v1/auth/') && // 登录认证入口与票据交换 !url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据 - !url.pathname.includes('/api/v1/plugin/')&& // 插件接口 + !url.pathname.includes('/api/v1/plugin/') && // 插件接口 !url.pathname.includes('/api/v1/subscribe/'), // 订阅接口 new NetworkFirst({ cacheName: `api-cache-${CACHE_VERSION}`, diff --git a/src/utils/__tests__/serviceWorkerCache.spec.ts b/src/utils/__tests__/serviceWorkerCache.spec.ts new file mode 100644 index 00000000..07f755ea --- /dev/null +++ b/src/utils/__tests__/serviceWorkerCache.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { corsSafeCachePlugin, selectCorsSafeCachedResponse } from '../serviceWorkerCache' + +function createResponse(type: ResponseType) { + const response = new Response('image') + Object.defineProperty(response, 'type', { value: type }) + return response +} + +describe('Service Worker cache CORS boundary', () => { + it('rejects an opaque cache entry for a CORS image request', () => { + const request = new Request('https://image.example/wallpaper.jpg', { mode: 'cors' }) + const cachedResponse = createResponse('opaque') + + expect(selectCorsSafeCachedResponse(request, cachedResponse)).toBeUndefined() + }) + + it('keeps a CORS-clean cache entry for a CORS image request', () => { + const request = new Request('https://image.example/wallpaper.jpg', { mode: 'cors' }) + const cachedResponse = createResponse('cors') + + expect(selectCorsSafeCachedResponse(request, cachedResponse)).toBe(cachedResponse) + }) + + it('keeps an opaque cache entry for an ordinary no-cors image request', () => { + const request = new Request('https://image.example/poster.jpg', { mode: 'no-cors' }) + const cachedResponse = createResponse('opaque') + + expect(selectCorsSafeCachedResponse(request, cachedResponse)).toBe(cachedResponse) + }) + + it('returns a cache miss unchanged so CacheFirst can use its network and offline policy', () => { + const request = new Request('https://image.example/wallpaper.jpg', { mode: 'cors' }) + + expect(selectCorsSafeCachedResponse(request, undefined)).toBeUndefined() + }) + + it('exposes the boundary through the Workbox cached response lifecycle', async () => { + const request = new Request('https://image.example/wallpaper.jpg', { mode: 'cors' }) + const cachedResponse = createResponse('opaque') + + await expect( + corsSafeCachePlugin.cachedResponseWillBeUsed?.({ + cacheName: 'image-cache', + cachedResponse, + event: new Event('fetch') as ExtendableEvent, + matchOptions: {}, + request, + }), + ).resolves.toBeUndefined() + }) +}) diff --git a/src/utils/serviceWorkerCache.ts b/src/utils/serviceWorkerCache.ts new file mode 100644 index 00000000..54667f9d --- /dev/null +++ b/src/utils/serviceWorkerCache.ts @@ -0,0 +1,21 @@ +import type { WorkboxPlugin } from 'workbox-core' + +/** + * CORS 请求不得采用 opaque 响应,否则浏览器无法读取其内容。 + * 普通 no-cors 请求仍保留 opaque 缓存能力;缓存未命中时继续交给策略决定网络或离线回退。 + */ +export function selectCorsSafeCachedResponse( + request: Request, + cachedResponse: Response | undefined, +): Response | undefined { + if (request.mode === 'cors' && cachedResponse?.type === 'opaque') return undefined + + return cachedResponse +} + +/** 在 Workbox 采用缓存前执行响应的 CORS 可读性边界。 */ +export const corsSafeCachePlugin: WorkboxPlugin = { + async cachedResponseWillBeUsed({ cachedResponse, request }) { + return selectCorsSafeCachedResponse(request, cachedResponse) + }, +}