// HTTP front for the winget REST source. Runs on unom-1 under a stock `oven/bun` image with this // file and the data bind-mounted — same shape as the flatpak server (stock caddy:2-alpine + a // bind-mounted Caddyfile), so there is no image to build, publish or pull. // // Plain HTTP on :3240. Caddy on the same box terminates TLS for winget.punktfunk.unom.io and // reverse-proxies here — that TLS is not optional decoration: `winget source add` refuses anything // but HTTPS with a publicly trusted certificate. // // Zero dependencies on purpose. The catalogue is a generated JSON file; parsing YAML and talking to // Gitea is build-data.mjs's job, which runs in CI. Nothing here needs node_modules, so the deploy // is two files and `docker compose up -d`. import { readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createServer } from "node:http"; import { respond } from "./handler.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const DATA_PATH = resolve(process.env.PF_WINGET_DATA ?? `${HERE}/data/data.json`); const PORT = Number(process.env.PORT ?? 3240); // Reload on mtime change rather than caching for the process lifetime. Publishing a release rsyncs // a new data.json onto the box; without this it would serve the old catalogue until someone // remembered to restart the container. Stat-per-request on an ~8 KB local file is free, and it // keeps "deploy" and "publish" decoupled the way the flatpak repo already is. let cache = { mtimeMs: 0, data: null, error: null }; function catalogue() { let mtimeMs; try { mtimeMs = statSync(DATA_PATH).mtimeMs; } catch (e) { // Keep serving the last good catalogue if the file goes missing mid-flight (e.g. a partial // rsync) — a transient publish must not take the source down. if (cache.data) return cache.data; cache.error = `cannot stat ${DATA_PATH}: ${e.message}`; return null; } if (cache.data && mtimeMs === cache.mtimeMs) return cache.data; try { const parsed = JSON.parse(readFileSync(DATA_PATH, "utf8")); if (!Array.isArray(parsed?.packages)) throw new Error("no `packages` array"); cache = { mtimeMs, data: parsed, error: null }; console.log(`[winget] loaded ${parsed.packages.length} package(s) from ${DATA_PATH}`); return parsed; } catch (e) { // A half-written file during rsync parses as garbage; prefer stale-but-valid over an outage. if (cache.data) { console.error(`[winget] reload failed, keeping previous catalogue: ${e.message}`); return cache.data; } cache.error = `cannot parse ${DATA_PATH}: ${e.message}`; return null; } } /** node:http request -> WHATWG Request, so handler.mjs stays runtime-agnostic. */ function toRequest(req) { const url = `http://${req.headers.host ?? "localhost"}${req.url}`; const init = { method: req.method, headers: req.headers }; if (req.method !== "GET" && req.method !== "HEAD") { init.body = req; init.duplex = "half"; // required when a stream is used as a body } return new Request(url, init); } const server = createServer(async (req, res) => { // Liveness for compose/monitoring — deliberately outside the winget routes so a broken catalogue // is visible as 503 here rather than as a confusing "no package found" in the client. if (req.url === "/healthz") { const data = catalogue(); const ok = !!data; res.writeHead(ok ? 200 : 503, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(ok ? { status: "ok", packages: data.packages.length } : { status: "error", error: cache.error })); return; } const data = catalogue(); if (!data) { res.writeHead(503, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ErrorCode: 503, ErrorMessage: cache.error ?? "catalogue unavailable" })); return; } let response; try { response = await respond(toRequest(req), data); } catch (e) { res.writeHead(500, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ErrorCode: 500, ErrorMessage: String(e?.message ?? e) })); return; } const headers = Object.fromEntries(response.headers.entries()); const body = response.status === 204 ? null : await response.text(); res.writeHead(response.status, headers); res.end(body ?? undefined); }); server.listen(PORT, () => { console.log(`[winget] REST source on :${PORT}, data=${DATA_PATH}`); // Warm the cache at boot so a bad mount is loud immediately, not on the first user request. if (!catalogue()) console.error(`[winget] WARNING: ${cache.error}`); }); for (const sig of ["SIGTERM", "SIGINT"]) { process.on(sig, () => server.close(() => process.exit(0))); }