import { existsSync } from "node:fs"; import { join } from "node:path"; import cors from "cors"; import express from "express"; import { z } from "zod"; import { AzureImageService } from "./azure-service"; import { TemplateService } from "./template-service"; const findAppRoot = (): string => { const candidates = [join(__dirname, "../../.."), join(__dirname, "../..")]; for (const candidate of candidates) { if (existsSync(join(candidate, "templates.json"))) { return candidate; } } throw new Error("Unable to resolve app root"); }; const queryLocation = z.object({ location: z.string().min(1) }); const queryOffer = z.object({ location: z.string().min(1), publisher: z.string().min(1) }); const querySku = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1) }); const queryVersion = z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1), sku: z.string().min(1) }); const renderBody = z.object({ templateFile: z.string().min(1), selection: z.object({ location: z.string().min(1), publisher: z.string().min(1), offer: z.string().min(1), sku: z.string().min(1), version: z.string().min(1) }) }); const makeApp = () => { const app = express(); const port = Number.parseInt(process.env.PORT ?? "3000", 10); const host = process.env.HOST ?? "0.0.0.0"; const subscriptionId = process.env.AZURE_SUBSCRIPTION_ID; app.use(cors()); app.use(express.json()); const azure = subscriptionId ? new AzureImageService(subscriptionId) : null; const templates = new TemplateService(); app.get("/api/health", (_req, res) => { if (!subscriptionId) { res.status(500).json({ status: "error", message: "Missing AZURE_SUBSCRIPTION_ID" }); return; } res.json({ status: "ok" }); }); const requireAzure = (): AzureImageService => { if (!azure) { throw new Error("Missing AZURE_SUBSCRIPTION_ID"); } return azure; }; app.get("/api/locations", async (_req, res, next) => { try { res.json(await requireAzure().getLocations()); } catch (error) { next(error); } }); app.get("/api/publishers", async (req, res, next) => { try { const { location } = queryLocation.parse(req.query); res.json(await requireAzure().getPublishers(location)); } catch (error) { next(error); } }); app.get("/api/offers", async (req, res, next) => { try { const { location, publisher } = queryOffer.parse(req.query); res.json(await requireAzure().getOffers(location, publisher)); } catch (error) { next(error); } }); app.get("/api/skus", async (req, res, next) => { try { const { location, publisher, offer } = querySku.parse(req.query); res.json(await requireAzure().getSkus(location, publisher, offer)); } catch (error) { next(error); } }); app.get("/api/versions", async (req, res, next) => { try { const { location, publisher, offer, sku } = queryVersion.parse(req.query); res.json(await requireAzure().getVersions(location, publisher, offer, sku)); } catch (error) { next(error); } }); app.get("/api/vm-skus", async (req, res, next) => { try { const { location } = queryLocation.parse(req.query); res.json(await requireAzure().getVmSkus(location)); } catch (error) { next(error); } }); app.get("/api/templates", (_req, res) => { res.json(templates.getTemplates()); }); app.post("/api/render", (req, res, next) => { try { const payload = renderBody.parse(req.body); const rendered = templates.render(payload.templateFile, payload.selection); res.json({ rendered }); } catch (error) { next(error); } }); app.get("/api/sku-export", async (req, res, next) => { try { const { location, publisher, offer } = querySku.parse(req.query); const skus = await requireAzure().getSkus(location, publisher, offer); res.json({ rendered: templates.buildSkuExport(skus) }); } catch (error) { next(error); } }); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { if (err instanceof z.ZodError) { res.status(400).json({ message: "Invalid request", issues: err.issues }); return; } const message = err instanceof Error ? err.message : "Unexpected error"; res.status(500).json({ message }); }); const frontendRoot = join(findAppRoot(), "dist/frontend"); if (existsSync(frontendRoot)) { app.use(express.static(frontendRoot)); app.get(/^(?!\/api).*/, (_req, res) => { res.sendFile(join(frontendRoot, "index.html")); }); } return { app, port, host }; }; if (require.main === module) { const { app, port, host } = makeApp(); const server = app.listen(port, host, () => { // eslint-disable-next-line no-console console.log(`azure-image-chooser listening on ${host}:${port}`); }); let shuttingDown = false; const shutdown = (signal: NodeJS.Signals) => { if (shuttingDown) { return; } shuttingDown = true; // eslint-disable-next-line no-console console.log(`received ${signal}, shutting down`); server.close((error) => { if (error) { // eslint-disable-next-line no-console console.error("graceful shutdown failed", error); process.exit(1); } process.exit(0); }); // Force-exit if connections do not close in time. setTimeout(() => { // eslint-disable-next-line no-console console.error("shutdown timeout reached, forcing exit"); process.exit(1); }, 10_000).unref(); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); } export { makeApp };