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 <cursoragent@cursor.com>
This commit is contained in:
@@ -8,4 +8,4 @@ GITHUB_TOKEN=ghp_your_personal_access_token
|
|||||||
GITHUB_OWNER=shumengya
|
GITHUB_OWNER=shumengya
|
||||||
GITHUB_REPO=Web2App
|
GITHUB_REPO=Web2App
|
||||||
DEFAULT_BRANCH=main
|
DEFAULT_BRANCH=main
|
||||||
MAX_UPLOAD_MB=50
|
MAX_UPLOAD_MB=25
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { MAX_UPLOAD_BYTES, MAX_UPLOAD_MB } from "../lib/upload-limits";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE ?? "";
|
||||||
|
|
||||||
export type BuildStatus =
|
export type BuildStatus =
|
||||||
@@ -36,6 +38,12 @@ function apiUrl(path: string): string {
|
|||||||
return `${API_BASE}${path}`;
|
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 }> {
|
export async function createBuild(formData: FormData): Promise<{ id: string }> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error ?? "上传失败");
|
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<Build> {
|
export async function fetchBuild(id: string): Promise<Build> {
|
||||||
const response = await fetch(apiUrl(`/api/builds/${id}`));
|
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) {
|
if (!response.ok) {
|
||||||
const errorData = data as Build & ApiErrorResponse;
|
const errorData = data as Build & ApiErrorResponse;
|
||||||
throw new Error(errorData.error ?? "Failed to load build");
|
throw new Error(errorData.error ?? "Failed to load build");
|
||||||
@@ -68,21 +76,56 @@ export async function fetchBuild(id: string): Promise<Build> {
|
|||||||
|
|
||||||
export async function fetchBuilds(): Promise<Build[]> {
|
export async function fetchBuilds(): Promise<Build[]> {
|
||||||
const response = await fetch(apiUrl("/api/builds"));
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to load builds");
|
throw new Error("Failed to load builds");
|
||||||
}
|
}
|
||||||
return data.builds;
|
return data.builds;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readJson(response: Response): Promise<unknown> {
|
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<unknown> {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) return {};
|
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("<!DOCTYPE") || trimmed.startsWith("<html")) {
|
||||||
|
throw new Error(parseCloudflareHtmlError(response.status, text));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text) as unknown;
|
return JSON.parse(trimmed) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Unexpected response from server: ${text.slice(0, 200)}`);
|
throw new Error(
|
||||||
|
`服务器返回了无法解析的响应(HTTP ${response.status}):${text.slice(0, 120)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
frontend/src/lib/upload-limits.ts
Normal file
7
frontend/src/lib/upload-limits.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** 与 wrangler.toml MAX_UPLOAD_MB 保持一致 */
|
||||||
|
export const MAX_UPLOAD_MB = 25;
|
||||||
|
export const MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
export function formatMaxUploadLabel(): string {
|
||||||
|
return `${MAX_UPLOAD_MB}MB`;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createBuild } from "../api/client";
|
import { assertUploadSize, createBuild } from "../api/client";
|
||||||
import { getDefaultAppVersion } from "../lib/version";
|
import { getDefaultAppVersion } from "../lib/version";
|
||||||
|
import { formatMaxUploadLabel } from "../lib/upload-limits";
|
||||||
|
|
||||||
export default function Upload() {
|
export default function Upload() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -32,6 +33,13 @@ export default function Upload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertUploadSize(file);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "文件过大");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -67,7 +75,8 @@ export default function Upload() {
|
|||||||
<section className="doc-section">
|
<section className="doc-section">
|
||||||
<h2>使用说明</h2>
|
<h2>使用说明</h2>
|
||||||
<p className="prose">
|
<p className="prose">
|
||||||
压缩包需包含 index.html(根目录或单层文件夹内),默认不超过 50MB。
|
压缩包需包含 index.html(根目录或单层文件夹内),默认不超过{" "}
|
||||||
|
{formatMaxUploadLabel()}。
|
||||||
可单独上传应用图标(PNG / JPG / ICO,优先于 zip 内图标),版本号默认取当天日期(如
|
可单独上传应用图标(PNG / JPG / ICO,优先于 zip 内图标),版本号默认取当天日期(如
|
||||||
2026.5.29)。中文名用于展示,英文名用于安装包与 Bundle ID。
|
2026.5.29)。中文名用于展示,英文名用于安装包与 Bundle ID。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
@@ -7,20 +7,34 @@ import {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
if (request.method === "OPTIONS" && url.pathname.startsWith("/api/")) {
|
||||||
return corsPreflightResponse(request);
|
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);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ import {
|
|||||||
type BuildRecord,
|
type BuildRecord,
|
||||||
} from "../db/builds";
|
} from "../db/builds";
|
||||||
import { jsonResponseWithCors } from "../lib/response";
|
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 {
|
import {
|
||||||
getActionsRunUrl,
|
getActionsRunUrl,
|
||||||
getReleaseAssets,
|
getReleaseAssets,
|
||||||
@@ -43,6 +31,18 @@ import {
|
|||||||
ZipValidationError,
|
ZipValidationError,
|
||||||
} from "../services/zip";
|
} 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 {
|
function maxUploadBytes(env: Env): number {
|
||||||
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
|
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
|
||||||
return mb * 1024 * 1024;
|
return mb * 1024 * 1024;
|
||||||
|
|||||||
@@ -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(
|
export function validateZipBuffer(
|
||||||
buffer: Uint8Array,
|
buffer: Uint8Array,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
@@ -17,6 +110,12 @@ export function validateZipBuffer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entries = listZipEntryNames(buffer);
|
||||||
|
|
||||||
|
if (!needsFlatten(entries)) {
|
||||||
|
return { normalizedBuffer: buffer };
|
||||||
|
}
|
||||||
|
|
||||||
let files: Record<string, Uint8Array>;
|
let files: Record<string, Uint8Array>;
|
||||||
try {
|
try {
|
||||||
files = unzipSync(buffer);
|
files = unzipSync(buffer);
|
||||||
@@ -24,26 +123,6 @@ export function validateZipBuffer(
|
|||||||
throw new ZipValidationError("Invalid zip archive");
|
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);
|
const normalized = normalizeZipFiles(files);
|
||||||
return { normalizedBuffer: zipSync(normalized) };
|
return { normalizedBuffer: zipSync(normalized) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ name = "web2app"
|
|||||||
main = "worker/src/index.ts"
|
main = "worker/src/index.ts"
|
||||||
compatibility_date = "2024-11-01"
|
compatibility_date = "2024-11-01"
|
||||||
|
|
||||||
|
# 付费 Workers 可提高 CPU 上限,避免大 zip 上传时触发 Cloudflare 1102
|
||||||
|
[limits]
|
||||||
|
cpu_ms = 300000
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
directory = "./frontend/dist"
|
directory = "./frontend/dist"
|
||||||
not_found_handling = "single-page-application"
|
not_found_handling = "single-page-application"
|
||||||
@@ -15,6 +19,6 @@ migrations_dir = "worker/migrations"
|
|||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
DEFAULT_BRANCH = "main"
|
DEFAULT_BRANCH = "main"
|
||||||
MAX_UPLOAD_MB = "50"
|
MAX_UPLOAD_MB = "25"
|
||||||
GITHUB_OWNER = "shumengya"
|
GITHUB_OWNER = "shumengya"
|
||||||
GITHUB_REPO = "Web2App"
|
GITHUB_REPO = "Web2App"
|
||||||
Reference in New Issue
Block a user