171 lines
4.4 KiB
TypeScript
171 lines
4.4 KiB
TypeScript
import { Router } from "express";
|
|
import multer from "multer";
|
|
import { nanoid } from "nanoid";
|
|
import {
|
|
getBuild,
|
|
insertBuild,
|
|
listBuilds,
|
|
toPublicBuild,
|
|
updateBuild,
|
|
type BuildStatus,
|
|
} from "../db/index.js";
|
|
import {
|
|
getActionsRunUrl,
|
|
getReleaseAssets,
|
|
getWorkflowRun,
|
|
triggerBuildWorkflow,
|
|
uploadSiteZip,
|
|
} from "../services/github.js";
|
|
import { slugifyIdentifier, validateZipBuffer, ZipValidationError } from "../services/zip.js";
|
|
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: 100 * 1024 * 1024 },
|
|
});
|
|
|
|
const maxUploadMb = Number(process.env.MAX_UPLOAD_MB ?? "50");
|
|
const maxUploadBytes = maxUploadMb * 1024 * 1024;
|
|
|
|
export const buildsRouter = Router();
|
|
|
|
buildsRouter.get("/", (_req, res) => {
|
|
const builds = listBuilds().map(toPublicBuild);
|
|
res.json({ builds });
|
|
});
|
|
|
|
buildsRouter.get("/:id", async (req, res) => {
|
|
const record = getBuild(req.params.id);
|
|
if (!record) {
|
|
res.status(404).json({ error: "Build not found" });
|
|
return;
|
|
}
|
|
|
|
const refreshed = await refreshBuildStatus(record);
|
|
res.json({
|
|
...toPublicBuild(refreshed),
|
|
actionsUrl: getActionsRunUrl(refreshed.workflow_run_id),
|
|
});
|
|
});
|
|
|
|
buildsRouter.post("/", upload.single("file"), async (req, res) => {
|
|
try {
|
|
const appName = String(req.body.appName ?? "").trim();
|
|
const identifierInput = String(req.body.identifier ?? "").trim();
|
|
|
|
if (!appName) {
|
|
res.status(400).json({ error: "appName is required" });
|
|
return;
|
|
}
|
|
|
|
if (!req.file) {
|
|
res.status(400).json({ error: "file is required" });
|
|
return;
|
|
}
|
|
|
|
if (!req.file.originalname.toLowerCase().endsWith(".zip")) {
|
|
res.status(400).json({ error: "Only .zip files are supported" });
|
|
return;
|
|
}
|
|
|
|
const { normalizedBuffer } = validateZipBuffer(req.file.buffer, maxUploadBytes);
|
|
const jobId = nanoid(10);
|
|
const appIdentifier =
|
|
identifierInput ||
|
|
`com.web2app.${slugifyIdentifier(appName) || jobId.toLowerCase()}`;
|
|
|
|
insertBuild({ id: jobId, appName, appIdentifier });
|
|
|
|
await uploadSiteZip(jobId, normalizedBuffer);
|
|
updateBuild(jobId, { status: "queued" });
|
|
|
|
const workflowRunId = await triggerBuildWorkflow({
|
|
jobId,
|
|
appName,
|
|
appIdentifier,
|
|
});
|
|
|
|
updateBuild(jobId, {
|
|
workflow_run_id: workflowRunId,
|
|
status: "in_progress",
|
|
});
|
|
|
|
res.status(201).json({
|
|
id: jobId,
|
|
status: "in_progress",
|
|
workflowRunId,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ZipValidationError) {
|
|
res.status(400).json({ error: error.message });
|
|
return;
|
|
}
|
|
|
|
console.error(error);
|
|
res.status(500).json({
|
|
error: error instanceof Error ? error.message : "Failed to create build",
|
|
});
|
|
}
|
|
});
|
|
|
|
async function refreshBuildStatus(record: ReturnType<typeof getBuild> & object) {
|
|
if (!record.workflow_run_id) return record;
|
|
if (record.status === "completed" || record.status === "failed") {
|
|
return record;
|
|
}
|
|
|
|
try {
|
|
const run = await getWorkflowRun(record.workflow_run_id);
|
|
|
|
if (run.status === "queued") {
|
|
return updateBuild(record.id, { status: "queued" }) ?? record;
|
|
}
|
|
|
|
if (run.status === "in_progress") {
|
|
return updateBuild(record.id, { status: "in_progress" }) ?? record;
|
|
}
|
|
|
|
if (run.status === "completed") {
|
|
if (run.conclusion === "success") {
|
|
const assets = await getReleaseAssets(record.id);
|
|
return (
|
|
updateBuild(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 (
|
|
updateBuild(record.id, {
|
|
status: "failed",
|
|
error: `Workflow failed with conclusion: ${run.conclusion ?? "unknown"}`,
|
|
}) ?? record
|
|
);
|
|
}
|
|
|
|
return record;
|
|
} catch (error) {
|
|
return (
|
|
updateBuild(record.id, {
|
|
status: "failed",
|
|
error: error instanceof Error ? error.message : "Status refresh failed",
|
|
}) ?? record
|
|
);
|
|
}
|
|
}
|
|
|
|
export function buildPublicMeta(record: ReturnType<typeof getBuild>) {
|
|
if (!record) return null;
|
|
return {
|
|
...toPublicBuild(record),
|
|
actionsUrl: getActionsRunUrl(record.workflow_run_id),
|
|
};
|
|
}
|
|
|
|
export type { BuildStatus };
|