feat: migrate to Cloudflare Workers with icon and version upload
Replace Express/SQLite and web/ with frontend/, worker/, and wrangler deploy. Add optional app icon upload, date-based app_version, and build-app workflow input. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
226
worker/src/routes/builds.ts
Normal file
226
worker/src/routes/builds.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Env } from "../env";
|
||||
import {
|
||||
getBuild,
|
||||
insertBuild,
|
||||
listBuilds,
|
||||
toPublicBuild,
|
||||
updateBuild,
|
||||
type BuildRecord,
|
||||
} from "../db/builds";
|
||||
import { errorResponse, jsonResponse } from "../lib/response";
|
||||
import {
|
||||
getActionsRunUrl,
|
||||
getReleaseAssets,
|
||||
getWorkflowRun,
|
||||
triggerBuildWorkflow,
|
||||
uploadBuildFile,
|
||||
uploadSiteZip,
|
||||
} from "../services/github";
|
||||
import { IconValidationError, resolveIconUpload } from "../services/icon";
|
||||
import {
|
||||
validateAppVersion,
|
||||
VersionValidationError,
|
||||
} from "../services/version";
|
||||
import {
|
||||
normalizeAppIdentifier,
|
||||
slugifyIdentifier,
|
||||
validateChineseAppName,
|
||||
validateEnglishAppName,
|
||||
validateZipBuffer,
|
||||
ZipValidationError,
|
||||
} from "../services/zip";
|
||||
|
||||
function maxUploadBytes(env: Env): number {
|
||||
const mb = Number(env.MAX_UPLOAD_MB ?? "50");
|
||||
return mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
export async function handleBuildsRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/builds" && request.method === "GET") {
|
||||
const builds = (await listBuilds(env)).map(toPublicBuild);
|
||||
return jsonResponse({ builds });
|
||||
}
|
||||
|
||||
const match = path.match(/^\/api\/builds\/([^/]+)$/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
|
||||
if (request.method === "GET") {
|
||||
const record = await getBuild(env, id);
|
||||
if (!record) {
|
||||
return errorResponse("Build not found", 404);
|
||||
}
|
||||
|
||||
const refreshed = await refreshBuildStatus(env, record);
|
||||
return jsonResponse({
|
||||
...toPublicBuild(refreshed),
|
||||
actionsUrl: getActionsRunUrl(env, refreshed.workflow_run_id),
|
||||
});
|
||||
}
|
||||
|
||||
return errorResponse("Method not allowed", 405);
|
||||
}
|
||||
|
||||
if (path === "/api/builds" && request.method === "POST") {
|
||||
return createBuild(request, env);
|
||||
}
|
||||
|
||||
return errorResponse("Not found", 404);
|
||||
}
|
||||
|
||||
async function createBuild(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const appNameZh = validateChineseAppName(
|
||||
String(formData.get("appNameZh") ?? formData.get("appName") ?? ""),
|
||||
);
|
||||
const appNameEn = validateEnglishAppName(
|
||||
String(formData.get("appNameEn") ?? ""),
|
||||
);
|
||||
const identifierInput = String(formData.get("identifier") ?? "").trim();
|
||||
const appVersion = validateAppVersion(
|
||||
String(formData.get("appVersion") ?? ""),
|
||||
);
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return errorResponse("file is required", 400);
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return errorResponse("Only .zip files are supported", 400);
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const { normalizedBuffer } = validateZipBuffer(buffer, maxUploadBytes(env));
|
||||
const jobId = nanoid(10);
|
||||
const slug = slugifyIdentifier(appNameEn) || jobId.toLowerCase();
|
||||
const appIdentifier = normalizeAppIdentifier(
|
||||
identifierInput || `com.web2app.${slug}`,
|
||||
);
|
||||
|
||||
await insertBuild(env, {
|
||||
id: jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
appVersion,
|
||||
});
|
||||
|
||||
await uploadSiteZip(env, jobId, normalizedBuffer);
|
||||
await uploadBuildFile(
|
||||
env,
|
||||
jobId,
|
||||
"version.txt",
|
||||
new TextEncoder().encode(appVersion),
|
||||
);
|
||||
|
||||
const iconFile = formData.get("icon");
|
||||
if (iconFile instanceof File && iconFile.size > 0) {
|
||||
const iconBuffer = new Uint8Array(await iconFile.arrayBuffer());
|
||||
const { repoPath } = resolveIconUpload(iconFile, iconBuffer);
|
||||
await uploadBuildFile(env, jobId, repoPath, iconBuffer);
|
||||
}
|
||||
|
||||
await updateBuild(env, jobId, { status: "queued" });
|
||||
|
||||
const workflowRunId = await triggerBuildWorkflow(env, {
|
||||
jobId,
|
||||
appName: appNameZh,
|
||||
appNameEn,
|
||||
appIdentifier,
|
||||
});
|
||||
|
||||
await updateBuild(env, jobId, {
|
||||
workflow_run_id: workflowRunId,
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
return jsonResponse(
|
||||
{
|
||||
id: jobId,
|
||||
status: "in_progress",
|
||||
workflowRunId,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ZipValidationError ||
|
||||
error instanceof VersionValidationError ||
|
||||
error instanceof IconValidationError
|
||||
) {
|
||||
return errorResponse(error.message, 400);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
return errorResponse(
|
||||
error instanceof Error ? error.message : "Failed to create build",
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBuildStatus(
|
||||
env: Env,
|
||||
record: BuildRecord,
|
||||
): Promise<BuildRecord> {
|
||||
if (!record.workflow_run_id) return record;
|
||||
if (record.status === "completed" || record.status === "failed") {
|
||||
return record;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await getWorkflowRun(env, record.workflow_run_id);
|
||||
|
||||
if (run.status === "queued") {
|
||||
return (await updateBuild(env, record.id, { status: "queued" })) ?? record;
|
||||
}
|
||||
|
||||
if (run.status === "in_progress") {
|
||||
return (
|
||||
(await updateBuild(env, record.id, { status: "in_progress" })) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion === "success") {
|
||||
const assets = await getReleaseAssets(env, record.id);
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "completed",
|
||||
windows_url: assets.windowsUrl,
|
||||
android_url: assets.androidUrl,
|
||||
error:
|
||||
assets.windowsUrl && assets.androidUrl
|
||||
? null
|
||||
: "Build finished but release assets were not found yet",
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "failed",
|
||||
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return (
|
||||
(await updateBuild(env, record.id, {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "Status refresh failed",
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user