From 8d6e2e00b87f499c1cecbab4135b3d65395f6071 Mon Sep 17 00:00:00 2001 From: shumengya Date: Sat, 6 Jun 2026 14:14:43 +0800 Subject: [PATCH] fix: reduce upload failures on Cloudflare Workers Optimize zip validation, lower max upload to 25MB, raise CPU limit, and show clear errors when Cloudflare returns HTML error pages. Co-authored-by: Cursor --- .env.example | 2 +- frontend/src/api/client.ts | 55 ++++++++++++-- frontend/src/lib/upload-limits.ts | 7 ++ frontend/src/pages/Upload.tsx | 13 +++- frontend/tsconfig.app.tsbuildinfo | 2 +- worker/src/index.ts | 40 ++++++---- worker/src/routes/builds.ts | 24 +++--- worker/src/services/zip.ts | 119 +++++++++++++++++++++++++----- wrangler.toml | 6 +- 9 files changed, 212 insertions(+), 56 deletions(-) create mode 100644 frontend/src/lib/upload-limits.ts diff --git a/.env.example b/.env.example index 58797ff..ec8e77d 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,4 @@ GITHUB_TOKEN=ghp_your_personal_access_token GITHUB_OWNER=shumengya GITHUB_REPO=Web2App DEFAULT_BRANCH=main -MAX_UPLOAD_MB=50 +MAX_UPLOAD_MB=25 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2528380..36f4667 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { MAX_UPLOAD_BYTES, MAX_UPLOAD_MB } from "../lib/upload-limits"; + const API_BASE = import.meta.env.VITE_API_BASE ?? ""; export type BuildStatus = @@ -36,6 +38,12 @@ function apiUrl(path: string): string { return `${API_BASE}${path}`; } +export function assertUploadSize(file: File): void { + if (file.size > MAX_UPLOAD_BYTES) { + throw new Error(`zip 不能超过 ${MAX_UPLOAD_MB}MB,请压缩后重试`); + } +} + export async function createBuild(formData: FormData): Promise<{ id: string }> { let response: Response; try { @@ -49,7 +57,7 @@ export async function createBuild(formData: FormData): Promise<{ id: string }> { ); } - const data = (await readJson(response)) as CreateBuildResponse; + const data = (await readResponseBody(response)) as CreateBuildResponse; if (!response.ok) { throw new Error(data.error ?? "上传失败"); } @@ -58,7 +66,7 @@ export async function createBuild(formData: FormData): Promise<{ id: string }> { export async function fetchBuild(id: string): Promise { const response = await fetch(apiUrl(`/api/builds/${id}`)); - const data = (await readJson(response)) as Build; + const data = (await readResponseBody(response)) as Build; if (!response.ok) { const errorData = data as Build & ApiErrorResponse; throw new Error(errorData.error ?? "Failed to load build"); @@ -68,21 +76,56 @@ export async function fetchBuild(id: string): Promise { export async function fetchBuilds(): Promise { const response = await fetch(apiUrl("/api/builds")); - const data = (await readJson(response)) as { builds: Build[] }; + const data = (await readResponseBody(response)) as { builds: Build[] }; if (!response.ok) { throw new Error("Failed to load builds"); } return data.builds; } -async function readJson(response: Response): Promise { +function parseCloudflareHtmlError(status: number, text: string): string { + const codeMatch = text.match(/cloudflare[^0-9]*(\d{4})/i); + const cfCode = codeMatch?.[1]; + + if (status === 413) { + return `上传体积过大(HTTP 413),请压缩 zip 到 ${MAX_UPLOAD_MB}MB 以内`; + } + if (status === 502 || status === 503 || status === 524) { + return `服务器处理超时或暂时不可用(HTTP ${status})。请缩小 zip 体积后重试,或稍后再试`; + } + if (cfCode === "1102" || text.includes("1102")) { + return "Worker CPU 时间超限(Cloudflare 1102)。请缩小 zip 或升级 Workers 付费套餐"; + } + if (cfCode === "1101" || text.includes("1101")) { + return "Worker 运行时错误(Cloudflare 1101)。请检查 zip 是否损坏,或查看部署日志"; + } + + return `服务器返回了错误页面(HTTP ${status}),通常因 zip 过大或 Worker 超时。请压缩到 ${MAX_UPLOAD_MB}MB 以内后重试`; +} + +async function readResponseBody(response: Response): Promise { const text = await response.text(); if (!text) return {}; + const trimmed = text.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed) as unknown; + } catch { + /* fall through */ + } + } + + if (trimmed.startsWith("

使用说明

- 压缩包需包含 index.html(根目录或单层文件夹内),默认不超过 50MB。 + 压缩包需包含 index.html(根目录或单层文件夹内),默认不超过{" "} + {formatMaxUploadLabel()}。 可单独上传应用图标(PNG / JPG / ICO,优先于 zip 内图标),版本号默认取当天日期(如 2026.5.29)。中文名用于展示,英文名用于安装包与 Bundle ID。

diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 354c8c5..ae5f7f1 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/lib/version.ts","./src/pages/buildlist.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/lib/duration.ts","./src/lib/upload-limits.ts","./src/lib/version.ts","./src/pages/buildlist.tsx","./src/pages/jobstatus.tsx","./src/pages/upload.tsx"],"version":"5.6.3"} \ No newline at end of file diff --git a/worker/src/index.ts b/worker/src/index.ts index 6a97e05..e7ade57 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -7,20 +7,34 @@ import { export default { async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); + try { + const url = new URL(request.url); - if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) { - return corsPreflightResponse(request); + if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) { + return corsPreflightResponse(request); + } + + if (url.pathname === "/api/health") { + return jsonResponseWithCors(request, { ok: true }); + } + + if (url.pathname.startsWith("/api/builds")) { + return await handleBuildsRequest(request, env, url); + } + + return env.ASSETS.fetch(request); + } catch (error) { + console.error("Unhandled worker error:", error); + return jsonResponseWithCors( + request, + { + error: + error instanceof Error + ? error.message + : "Internal server error", + }, + 500, + ); } - - if (url.pathname === "/api/health") { - return jsonResponseWithCors(request, { ok: true }); - } - - if (url.pathname.startsWith("/api/builds")) { - return handleBuildsRequest(request, env, url); - } - - return env.ASSETS.fetch(request); }, }; diff --git a/worker/src/routes/builds.ts b/worker/src/routes/builds.ts index e071892..de228e3 100644 --- a/worker/src/routes/builds.ts +++ b/worker/src/routes/builds.ts @@ -9,18 +9,6 @@ import { type BuildRecord, } from "../db/builds"; import { jsonResponseWithCors } from "../lib/response"; - -function apiJson( - request: Request, - data: unknown, - status = 200, -): Response { - return jsonResponseWithCors(request, data, status); -} - -function apiError(request: Request, message: string, status: number): Response { - return jsonResponseWithCors(request, { error: message }, status); -} import { getActionsRunUrl, getReleaseAssets, @@ -43,6 +31,18 @@ import { ZipValidationError, } from "../services/zip"; +function apiJson( + request: Request, + data: unknown, + status = 200, +): Response { + return jsonResponseWithCors(request, data, status); +} + +function apiError(request: Request, message: string, status: number): Response { + return jsonResponseWithCors(request, { error: message }, status); +} + function maxUploadBytes(env: Env): number { const mb = Number(env.MAX_UPLOAD_MB ?? "50"); return mb * 1024 * 1024; diff --git a/worker/src/services/zip.ts b/worker/src/services/zip.ts index 3085335..37e7174 100644 --- a/worker/src/services/zip.ts +++ b/worker/src/services/zip.ts @@ -7,6 +7,99 @@ export class ZipValidationError extends Error { } } +const CDH_SIG = 0x02014b50; + +/** 只读中央目录,不解压文件体,降低 Worker CPU 占用 */ +function listZipEntryNames(buffer: Uint8Array): string[] { + const eocdOffset = findEndOfCentralDirectory(buffer); + if (eocdOffset < 0) { + throw new ZipValidationError("Invalid zip archive"); + } + + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); + const cdSize = view.getUint32(eocdOffset + 12, true); + const cdOffset = view.getUint32(eocdOffset + 16, true); + const names: string[] = []; + + let ptr = cdOffset; + const end = cdOffset + cdSize; + while (ptr + 46 <= end) { + if (view.getUint32(ptr, true) !== CDH_SIG) break; + + const nameLen = view.getUint16(ptr + 28, true); + const extraLen = view.getUint16(ptr + 30, true); + const commentLen = view.getUint16(ptr + 32, true); + const nameStart = ptr + 46; + + if (nameStart + nameLen > buffer.length) break; + + const raw = new TextDecoder().decode( + buffer.subarray(nameStart, nameStart + nameLen), + ); + const name = raw.replace(/\\/g, "/"); + if (!name.endsWith("/")) { + names.push(name); + } + + ptr = nameStart + nameLen + extraLen + commentLen; + } + + if (names.length === 0) { + throw new ZipValidationError("Zip archive is empty"); + } + + return names; +} + +function findEndOfCentralDirectory(buffer: Uint8Array): number { + const minEocd = 22; + const maxComment = 0xffff; + const start = Math.max(0, buffer.length - minEocd - maxComment); + + for (let i = buffer.length - minEocd; i >= start; i--) { + if ( + buffer[i] === 0x50 && + buffer[i + 1] === 0x4b && + buffer[i + 2] === 0x05 && + buffer[i + 3] === 0x06 + ) { + return i; + } + } + + return -1; +} + +function needsFlatten(entries: string[]): boolean { + if (entries.includes("index.html")) return false; + + const indexEntry = entries.find((entry) => { + const parts = entry.split("/"); + return parts.length === 2 && parts[1] === "index.html"; + }); + + if (!indexEntry) { + throw new ZipValidationError( + "Zip must contain index.html at root or in a single top-level folder", + ); + } + + const folder = indexEntry.split("/")[0]; + const topDirs = new Set(entries.map((k) => k.split("/")[0]).filter(Boolean)); + + if (topDirs.size !== 1 || !topDirs.has(folder)) { + throw new ZipValidationError( + "index.html must be at zip root or inside exactly one folder", + ); + } + + return true; +} + export function validateZipBuffer( buffer: Uint8Array, maxBytes: number, @@ -17,6 +110,12 @@ export function validateZipBuffer( ); } + const entries = listZipEntryNames(buffer); + + if (!needsFlatten(entries)) { + return { normalizedBuffer: buffer }; + } + let files: Record; try { files = unzipSync(buffer); @@ -24,26 +123,6 @@ export function validateZipBuffer( throw new ZipValidationError("Invalid zip archive"); } - const entries = Object.keys(files).filter( - (key) => !key.endsWith("/") && files[key].length > 0, - ); - if (entries.length === 0) { - throw new ZipValidationError("Zip archive is empty"); - } - - const indexEntry = entries.find((entry) => { - const normalized = entry.replace(/\\/g, "/"); - if (normalized === "index.html") return true; - const parts = normalized.split("/"); - return parts.length === 2 && parts[1] === "index.html"; - }); - - if (!indexEntry) { - throw new ZipValidationError( - "Zip must contain index.html at root or in a single top-level folder", - ); - } - const normalized = normalizeZipFiles(files); return { normalizedBuffer: zipSync(normalized) }; } diff --git a/wrangler.toml b/wrangler.toml index 241a97f..fdf7060 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,6 +2,10 @@ name = "web2app" main = "worker/src/index.ts" compatibility_date = "2024-11-01" +# 付费 Workers 可提高 CPU 上限,避免大 zip 上传时触发 Cloudflare 1102 +[limits] +cpu_ms = 300000 + [assets] directory = "./frontend/dist" not_found_handling = "single-page-application" @@ -15,6 +19,6 @@ migrations_dir = "worker/migrations" [vars] DEFAULT_BRANCH = "main" -MAX_UPLOAD_MB = "50" +MAX_UPLOAD_MB = "25" GITHUB_OWNER = "shumengya" GITHUB_REPO = "Web2App" \ No newline at end of file