AI scaffolded NodeJS version of the App.

This commit is contained in:
2026-04-19 18:54:31 +02:00
parent 4ff0a7205f
commit aca4998da7
27 changed files with 8733 additions and 0 deletions

3244
app-new/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "azure-image-chooser-backend",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/server.ts",
"start": "node ../../dist/backend/server.js",
"test": "vitest run"
},
"dependencies": {
"@azure/arm-compute": "^23.3.0",
"@azure/identity": "^4.13.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"nunjucks": "^3.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/nunjucks": "^3.2.6",
"@types/node": "^24.9.1",
"typescript": "^6.0.3",
"tsx": "^4.20.6",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,117 @@
import { ComputeManagementClient } from "@azure/arm-compute";
import { DefaultAzureCredential } from "@azure/identity";
import { MemoryCache } from "./cache";
import { sortImageVersionsIfSemantic } from "./version";
import type { LocationOption } from "./types";
const CACHE_TTL_MS = 5 * 60 * 1000;
export class AzureImageService {
private readonly credential = new DefaultAzureCredential();
private readonly computeClient: ComputeManagementClient;
private readonly cache = new MemoryCache();
public constructor(private readonly subscriptionId: string) {
this.computeClient = new ComputeManagementClient(this.credential, subscriptionId);
}
public async getLocations(): Promise<LocationOption[]> {
const cacheKey = "locations";
const cached = this.cache.get<LocationOption[]>(cacheKey);
if (cached) {
return cached;
}
const token = await this.credential.getToken("https://management.azure.com/.default");
const url = `https://management.azure.com/subscriptions/${this.subscriptionId}/locations?api-version=2022-12-01`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token?.token ?? ""}`
}
});
const payload = (await response.json()) as { value?: Array<{ name?: string; displayName?: string; metadata?: { regionType?: string } }> };
const locations = (payload.value ?? [])
.filter((loc) => loc.metadata?.regionType === "Physical" && Boolean(loc.name))
.map((loc) => ({
name: loc.name as string,
displayName: loc.displayName ?? (loc.name as string)
}));
locations.sort((a, b) => a.name.localeCompare(b.name));
this.cache.set(cacheKey, locations, CACHE_TTL_MS);
return locations;
}
public async getPublishers(location: string): Promise<string[]> {
const cacheKey = `publishers:${location}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listPublishers(location);
const publishers = this.extractNames(response);
this.cache.set(cacheKey, publishers, CACHE_TTL_MS);
return publishers;
}
public async getOffers(location: string, publisher: string): Promise<string[]> {
const cacheKey = `offers:${location}:${publisher}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listOffers(location, publisher);
const offers = this.extractNames(response);
this.cache.set(cacheKey, offers, CACHE_TTL_MS);
return offers;
}
public async getSkus(location: string, publisher: string, offer: string): Promise<string[]> {
const cacheKey = `skus:${location}:${publisher}:${offer}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.listSkus(location, publisher, offer);
const skus = this.extractNames(response);
this.cache.set(cacheKey, skus, CACHE_TTL_MS);
return skus;
}
public async getVersions(location: string, publisher: string, offer: string, sku: string): Promise<string[]> {
const cacheKey = `versions:${location}:${publisher}:${offer}:${sku}`;
const cached = this.cache.get<string[]>(cacheKey);
if (cached) {
return cached;
}
const response = await this.computeClient.virtualMachineImages.list(location, publisher, offer, sku);
const versions = this.extractNames(response);
const sorted = sortImageVersionsIfSemantic(versions);
this.cache.set(cacheKey, sorted, CACHE_TTL_MS);
return sorted;
}
private extractNames(source: unknown): string[] {
const items = Array.isArray(source)
? source
: typeof source === "object" && source !== null && "value" in source && Array.isArray((source as { value?: unknown }).value)
? ((source as { value: unknown[] }).value as unknown[])
: [];
return items
.map((item) => (typeof item === "object" && item !== null && "name" in item ? (item as { name?: string }).name : undefined))
.filter((value): value is string => Boolean(value));
}
}

View File

@@ -0,0 +1,29 @@
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
export class MemoryCache {
private readonly store = new Map<string, CacheEntry<unknown>>();
public get<T>(key: string): T | undefined {
const hit = this.store.get(key);
if (!hit) {
return undefined;
}
if (Date.now() >= hit.expiresAt) {
this.store.delete(key);
return undefined;
}
return hit.value as T;
}
public set<T>(key: string, value: T, ttlMs: number): void {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
}

View File

@@ -0,0 +1,149 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import cors from "cors";
import "dotenv/config";
import express from "express";
import { z } from "zod";
import { AzureImageService } from "./azure-service";
import { TemplateService } from "./template-service";
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 subscriptionId = process.env.AZURE_SUBSCRIPTION_ID;
app.use(cors());
app.use(express.json());
if (!subscriptionId) {
app.get("/api/health", (_req, res) => {
res.status(500).json({
status: "error",
message: "Missing AZURE_SUBSCRIPTION_ID"
});
});
return { app, port };
}
const azure = new AzureImageService(subscriptionId);
const templates = new TemplateService();
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/api/locations", async (_req, res, next) => {
try {
res.json(await azure.getLocations());
} catch (error) {
next(error);
}
});
app.get("/api/publishers", async (req, res, next) => {
try {
const { location } = queryLocation.parse(req.query);
res.json(await azure.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 azure.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 azure.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 azure.getVersions(location, publisher, offer, sku));
} 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 azure.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(process.cwd(), "dist/frontend");
if (existsSync(frontendRoot)) {
app.use(express.static(frontendRoot));
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(join(frontendRoot, "index.html"));
});
}
return { app, port };
};
if (require.main === module) {
const { app, port } = makeApp();
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`azure-image-chooser listening on ${port}`);
});
}
export { makeApp };

View File

@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import nunjucks from "nunjucks";
import type { ImageSelection, UsageTemplate } from "./types";
const findAppRoot = (): string => {
// Supports running from src/backend (dev) and dist/backend (compiled).
const devPath = join(__dirname, "../../../app");
const buildPath = join(__dirname, "../../app");
try {
readFileSync(join(devPath, "templates.json"), "utf8");
return devPath;
} catch {
return buildPath;
}
};
export class TemplateService {
private readonly appRoot = findAppRoot();
private readonly env = nunjucks.configure(join(this.appRoot, "templates"), {
autoescape: false,
noCache: true
});
private readonly templates: UsageTemplate[] = JSON.parse(
readFileSync(join(this.appRoot, "templates.json"), "utf8")
) as UsageTemplate[];
public getTemplates(): UsageTemplate[] {
return this.templates;
}
public render(templateFile: string, selection: ImageSelection): string {
return this.env.render(templateFile, selection);
}
public buildSkuExport(skus: string[]): string {
return `[\n${skus.map((sku) => `\t\"${sku}\"`).join(",\n")}\n]`;
}
}

View File

@@ -0,0 +1,18 @@
export type LocationOption = {
name: string;
displayName: string;
};
export type UsageTemplate = {
label: string;
language: string;
file: string;
};
export type ImageSelection = {
location: string;
publisher: string;
offer: string;
sku: string;
version: string;
};

View File

@@ -0,0 +1,22 @@
const SEMVER_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/;
const semverSortKey = (value: string): number[] => value.split(".").map((part) => Number.parseInt(part, 10));
export const sortImageVersionsIfSemantic = (versions: string[]): string[] => {
if (!versions.every((value) => SEMVER_PATTERN.test(value))) {
return versions;
}
return [...versions].sort((a, b) => {
const aParts = semverSortKey(a);
const bParts = semverSortKey(b);
for (let i = 0; i < aParts.length; i += 1) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i];
}
}
return 0;
});
};

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { sortImageVersionsIfSemantic } from "../src/version";
describe("sortImageVersionsIfSemantic", () => {
it("sorts only semantic versions", () => {
const sorted = sortImageVersionsIfSemantic(["1.10.0", "1.2.0", "2.0.0"]);
expect(sorted).toEqual(["1.2.0", "1.10.0", "2.0.0"]);
});
it("returns source order for non-semantic versions", () => {
const original = ["latest", "1.0.0", "beta"];
expect(sortImageVersionsIfSemantic(original)).toEqual(original);
});
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "../../dist/backend",
"types": ["node", "vitest/globals"]
},
"include": ["src"]
}