chore: enhance CDN response validation and update version to 0.5.25

This commit is contained in:
liquidliang committed 2026-01-20 13:20:52 +08:00
1 parent 8bd7f30c19
commit 155ec00987
5 files changed
+129 -10

No files matched your search

+72
View File
@@ -2795,6 +2795,78 @@ if (cachedResponse) {
- 在返回前验证 `blob.size > 0` 可以自动修复历史问题
- 删除无效缓存后重新获取,确保用户看到正确的内容
### CDN 响应必须多重验证后才能缓存
**场景**: Service Worker 从 CDN 获取静态资源并缓存时
❌ **错误示例**:
```typescript
// 错误:只检查 response.ok,可能缓存 CDN 返回的 HTML 错误页面
const response = await fetch(cdnUrl);
if (response.ok) {
cache.put(request, response.clone());
return response; // 可能是 404 页面被当作 JS 执行!
}
```
✅ **正确示例**:
```typescript
const response = await fetch(cdnUrl);
if (response.ok) {
// 1. Content-Type 验证
const contentType = response.headers.get('Content-Type') || '';
const isValidType = contentType.includes('javascript') ||
contentType.includes('css') ||
contentType.includes('json');
if (!isValidType) continue; // 尝试下一个源
// 2. Content-Length 验证(排除空响应)
const length = parseInt(response.headers.get('Content-Length') || '0', 10);
if (length > 0 && length < 50) continue;
// 3. 内容采样验证(检测 HTML 错误页面)
const sample = await response.clone().text().then(t => t.slice(0, 200));
if (sample.includes('<!DOCTYPE') || sample.includes('Not Found')) {
continue; // CDN 返回了 HTML 错误页面
}
cache.put(request, response.clone());
return response;
}
```
**原因**:
- CDN 可能返回 404 但 HTTP 状态码仍是 200(某些 CDN 的行为)
- npm 包不存在时,CDN 返回 HTML 错误页面
- 错误页面被当作 JS 执行会导致 React 多实例冲突,应用崩溃
- 多重验证确保只缓存真正有效的资源
### CDN 请求应设置短超时快速回退
**场景**: Service Worker 实现 CDN 优先加载策略时
❌ **错误示例**:
```typescript
// 错误:超时太长,CDN 回源慢时用户等待时间过长
const CDN_CONFIG = {
fetchTimeout: 10000, // 10 秒超时
};
```
✅ **正确示例**:
```typescript
// 正确:短超时,CDN 缓存命中很快(<200ms),超时说明在回源
const CDN_CONFIG = {
fetchTimeout: 1500, // 1.5 秒超时,快速回退到服务器
};
```
**原因**:
- CDN 缓存命中通常 < 200ms,1.5s 足够
- CDN 回源(首次请求)可能需要 3-5 秒,等待太久影响用户体验
- 短超时后快速回退到服务器,保证首次加载速度
- 用户请求会触发 CDN 缓存,后续访问自动加速
### 图像处理工具复用规范
**场景**: 需要对图片进行边框检测、去白边、裁剪等处理时
+1 -1
View File
@@ -61,7 +61,7 @@
</script>
<!-- base href 由构建工具处理,CDN 模式下使用相对路径 -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="app-version" content="0.5.24" />
<meta name="app-version" content="0.5.25" />
<link rel="icon" type="image/svg+xml" sizes="16x16" href="./icons/favicon-16x16.svg" />
<link rel="icon" type="image/svg+xml" sizes="32x32" href="./icons/favicon-32x32.svg" />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "0.5.25",
"buildTime": "2026-01-20T05:11:17.776Z",
"buildTime": "2026-01-20T05:11:18.087Z",
"gitCommit": "unknown",
"changelog": []
}
+37 -5
View File
@@ -234,9 +234,13 @@ export async function fetchFromCDNWithFallback(
const response = await fetchWithTimeout(url);
if (response.ok) {
// 验证响应类型,防止把 404 页面当作 JS 执行
// ============================================
// 多重验证:确保 CDN 返回的是有效资源
// ============================================
// 1. Content-Type 验证
const contentType = response.headers.get('Content-Type') || '';
const isValidResponse =
const isValidContentType =
contentType.includes('javascript') ||
contentType.includes('css') ||
contentType.includes('json') ||
@@ -245,10 +249,38 @@ export async function fetchFromCDNWithFallback(
contentType.includes('woff') ||
contentType.includes('application/octet-stream');
if (!isValidResponse) {
console.warn(`[CDN Fallback] ${cdn.name} returned invalid Content-Type: ${contentType}`);
if (!isValidContentType) {
console.warn(`[CDN Fallback] ${cdn.name} invalid Content-Type: ${contentType}`);
markCDNFailure(cdn.name);
continue; // 尝试下一个 CDN
continue;
}
// 2. Content-Length 验证(JS/CSS 文件不应该太小)
const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10);
const isTextResource = contentType.includes('javascript') || contentType.includes('css') || contentType.includes('json');
if (isTextResource && contentLength > 0 && contentLength < 50) {
console.warn(`[CDN Fallback] ${cdn.name} response too small: ${contentLength} bytes`);
markCDNFailure(cdn.name);
continue;
}
// 3. 内容采样验证(检测 HTML 错误页面)
const clonedResponse = response.clone();
try {
const textSample = await clonedResponse.text().then(t => t.slice(0, 200));
const looksLikeHtml = textSample.includes('<!DOCTYPE') ||
textSample.includes('<html') ||
textSample.includes('<HTML') ||
textSample.includes('Not Found') ||
textSample.includes('404');
if (isTextResource && looksLikeHtml) {
console.warn(`[CDN Fallback] ${cdn.name} returned HTML instead of ${contentType}`);
markCDNFailure(cdn.name);
continue;
}
} catch (sampleError) {
// 采样失败不阻止使用(可能是二进制文件)
}
markCDNSuccess(cdn.name);
@@ -16,17 +16,27 @@ export const VersionUpdatePrompt: React.FC = () => {
// Listen for custom event from main.tsx
const handleUpdateAvailable = async (event: Event) => {
const customEvent = event as CustomEvent;
// console.log('[VersionUpdatePrompt] Update available:', customEvent.detail);
const version = customEvent.detail?.version;
const newVersion = customEvent.detail?.version;
// 获取当前运行的版本(从 HTML meta 标签)
const currentVersionMeta = document.querySelector('meta[name="app-version"]');
const currentVersion = currentVersionMeta?.getAttribute('content');
try {
// Fetch detailed version info (changelog)
const res = await fetch(`/version.json?t=${Date.now()}`);
if (res.ok) {
const data = await res.json();
// 如果当前版本已经是最新版本,不显示更新提示
if (currentVersion && data.version === currentVersion) {
// console.log('[VersionUpdatePrompt] Already on latest version, skipping prompt');
return;
}
// Use fetched data if versions match or if event didn't specify version
if (!version || data.version === version) {
if (!newVersion || data.version === newVersion) {
setUpdateAvailable(data);
return;
}
@@ -35,6 +45,11 @@ export const VersionUpdatePrompt: React.FC = () => {
console.warn('Failed to fetch version.json:', error);
}
// 如果无法获取 version.json,但事件带了版本号,检查是否相同
if (currentVersion && newVersion && currentVersion === newVersion) {
return; // 已经是最新版本
}
// Fallback to event detail
setUpdateAvailable(customEvent.detail);
};