fix(api): 修复无效 envelope 响应刷屏并加固 SW API 缓存

- 去重缓存仅在 envelope 校验通过后清空,避免坏响应命中时逐条弹窗
- SW API 缓存只写入 JSON 响应,排除 opaque 响应与连接探测心跳
This commit is contained in:
jxxghp
2026-08-18 18:50:49 +08:00
parent 89da6be8bb
commit c98542b9bb
5 changed files with 121 additions and 6 deletions
+56 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { corsSafeCachePlugin, selectCorsSafeCachedResponse } from '../serviceWorkerCache'
import {
corsSafeCachePlugin,
jsonOnlyCachePlugin,
selectCorsSafeCachedResponse,
shouldCacheJsonResponse,
} from '../serviceWorkerCache'
function createResponse(type: ResponseType) {
const response = new Response('image')
@@ -51,3 +56,53 @@ describe('Service Worker cache CORS boundary', () => {
).resolves.toBeUndefined()
})
})
describe('Service Worker API cache JSON boundary', () => {
function createJsonResponse(contentType: string) {
return new Response('{"success":true,"message":"","data":null}', {
headers: { 'content-type': contentType },
})
}
it('accepts a standard JSON API response', () => {
expect(shouldCacheJsonResponse(createJsonResponse('application/json; charset=utf-8'))).toBe(true)
})
it('rejects an HTML response that would break the envelope contract', () => {
const html = new Response('<html><body>offline shell</body></html>', {
headers: { 'content-type': 'text/html; charset=utf-8' },
})
expect(shouldCacheJsonResponse(html)).toBe(false)
})
it('rejects a binary response such as an image or blob', () => {
const image = new Response(new Blob(['\x89PNG']), { headers: { 'content-type': 'image/png' } })
expect(shouldCacheJsonResponse(image)).toBe(false)
})
it('exposes the boundary through the Workbox cache update lifecycle', async () => {
const response = createJsonResponse('application/json')
await expect(
jsonOnlyCachePlugin.cacheWillUpdate?.({
event: new Event('fetch') as ExtendableEvent,
request: new Request('https://moviepilot/api/v1/resource'),
response,
}),
).resolves.toBe(response)
})
it('skips caching a non-JSON response through the Workbox lifecycle', async () => {
const response = new Response('<html></html>', { headers: { 'content-type': 'text/html' } })
await expect(
jsonOnlyCachePlugin.cacheWillUpdate?.({
event: new Event('fetch') as ExtendableEvent,
request: new Request('https://moviepilot/api/v1/resource'),
response,
}),
).resolves.toBeNull()
})
})
+19
View File
@@ -19,3 +19,22 @@ export const corsSafeCachePlugin: WorkboxPlugin = {
return selectCorsSafeCachedResponse(request, cachedResponse)
},
}
/**
* 判定响应是否允许写入 API 运行时缓存。
*
* API 缓存的消费方要求 JSON envelopeSPA 回退页等 HTML 或损坏体一旦被写入,
* NetworkFirst 超时回退时会以 200 返回坏体,前端 envelope 校验失败后逐条弹
* “服务器返回了无效响应”(仅清浏览器缓存可解)。写入前校验 Content-Type
* 可以从源头阻断非 JSON 响应进入缓存。
*/
export function shouldCacheJsonResponse(response: Response): boolean {
return (response.headers.get('content-type') ?? '').includes('json')
}
/** API 缓存专用:只写入声明为 JSON 的响应,HTML、图片等一律不落缓存。 */
export const jsonOnlyCachePlugin: WorkboxPlugin = {
async cacheWillUpdate({ response }) {
return shouldCacheJsonResponse(response) ? response : null
},
}