diff --git a/services/api/src/routes.rs b/services/api/src/routes.rs index a29cab2..524b60d 100644 --- a/services/api/src/routes.rs +++ b/services/api/src/routes.rs @@ -137,13 +137,31 @@ pub fn app(state: AppState) -> Router { } /// What is running: the crate version, the commit the image was built from, -/// and the arena image tag matches launch from. Unset values are null rather -/// than omitted, so the site can tell "old API" from "not configured". +/// the arena image tag matches launch from, and which unit library this +/// instance loaded. Unset values are null rather than omitted, so the site +/// can tell "old API" from "not configured". +/// +/// `library` is here rather than on a route of its own because every page +/// already asks for this one: `web/src/chrome.ts` fetches it once per load +/// for the footer and shares the promise, so a screen that needs the library +/// pays nothing for it. +/// +/// It is what makes the site and this service read the same index. The site +/// used to name a helm release in its own source, so a helm deploy needed a +/// matching site build - and when it did not get one, the force builder and +/// the match reports read different libraries and nothing said so. Now the +/// site asks what this instance loaded, and the answer comes off the index +/// document itself. async fn version(State(state): State) -> Response { + let library = state.units.as_ref().map(|units| { + let (megamek, helm) = units.library(); + serde_json::json!({ "megamek": megamek, "helm": helm }) + }); Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "build": state.build_ref, "arena": state.arena_version, + "library": library, })) .into_response() } @@ -2402,10 +2420,44 @@ mod tests { "version": env!("CARGO_PKG_VERSION"), "build": "abc123def456", "arena": "mm0.51.0-ws26.4.5-abc123def456", + "library": serde_json::Value::Null, }) ); } + /// The site reads the helm release off this rather than compiling one in, + /// so a helm deploy reaches the force builder without a site build. Both + /// halves come off the index document itself, which is what stops this + /// service and that screen from naming different libraries. + #[tokio::test] + async fn version_names_the_library_it_loaded() { + let (_dir, mut state) = loopback_state().await; + state.units = Some(std::sync::Arc::new(crate::units::for_test(&[( + "Atlas AS7-D", + "meks/atlas.png", + )]))); + let response = app(state) + .oneshot(Request::get("/api/version").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = body_json(response).await; + assert_eq!(body["library"]["megamek"], "0.51.0"); + assert_eq!(body["library"]["helm"], "main-0bb2bf14a1a3"); + } + + /// No index is null rather than absent, so the site can tell an API that + /// has none from one too old to say - and falls back to its own constants + /// in both cases. + #[tokio::test] + async fn version_without_an_index_says_so() { + let (_dir, state) = loopback_state().await; + let response = app(state) + .oneshot(Request::get("/api/version").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert!(body_json(response).await["library"].is_null()); + } + #[tokio::test] async fn version_in_development_is_nulls() { let (_dir, state) = loopback_state().await; diff --git a/services/api/src/units.rs b/services/api/src/units.rs index a9f447f..d3fc1e9 100644 --- a/services/api/src/units.rs +++ b/services/api/src/units.rs @@ -46,6 +46,9 @@ pub struct UnitIndex { /// The MegaMek release the index describes, which is also the one its /// art is published under. version: String, + /// The helm build that wrote the index, when it said. Reported to the + /// site so a screen reads the same library this service does. + helm: Option, /// Where a sprite path is relative to, inside the install: /// `data/images/units`. sprite_base: String, @@ -67,6 +70,10 @@ pub struct UnitIndex { #[derive(serde::Deserialize)] struct Document { megamek: String, + /// The helm build that wrote this file. helm keys its artifacts by both + /// ids, so this is half of the pair that names the library - and the + /// file says it itself, which is why nothing here has to parse a URL. + helm: Option, sprite_base: String, units: Vec, } @@ -123,6 +130,7 @@ impl UnitIndex { ); Some(Self { version: document.megamek, + helm: document.helm, sprite_base: document.sprite_base, by_name, origin: origin.to_owned(), @@ -157,6 +165,17 @@ impl UnitIndex { Some(url.into()) } + /// Which library this is: the MegaMek it describes and the helm build + /// that wrote it. + /// + /// Reported on `/api/version` so the site reads the same one. The site + /// used to name a helm release in its own source, which meant a helm + /// deploy needed a site build to match it - and when it did not get one, + /// the two read different indexes and neither said so. + pub fn library(&self) -> (&str, Option<&str>) { + (&self.version, self.helm.as_deref()) + } + /// The sprite a design is drawn with, or `None` for one the index does /// not carry - every vehicle, platoon and fighter, today. pub fn design(&self, name: &str) -> Option<&str> { @@ -210,6 +229,7 @@ impl UnitIndex { pub fn for_test(units: &[(&str, &str)]) -> UnitIndex { UnitIndex { version: "0.51.0".to_owned(), + helm: Some("main-0bb2bf14a1a3".to_owned()), sprite_base: "data/images/units".to_owned(), by_name: units .iter() diff --git a/web/src/api.ts b/web/src/api.ts index 84fc3fa..e661a8b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -268,6 +268,8 @@ export interface ApiVersion { version: string; build: string | null; arena: string | null; + /** Which unit library the API loaded, or null where it loaded none. */ + library: { helm: string | null; megamek: string } | null; } /** What the API is running — for the footer's debug line. */ @@ -278,6 +280,24 @@ export async function fetchVersion(): Promise { return (await response.json()) as ApiVersion; } +/** + * The same answer, fetched once per page load and shared. + * + * Two callers want it now — the footer's debug line and `megamek.ts`, which + * reads the helm release off it — and neither should cost the other a + * request. Memoised here rather than in either caller, so a third one is + * free too. A failed fetch is not cached: it resolves to null and the next + * caller asks again. + */ +let pending: Promise | null = null; +export function apiVersion(): Promise { + pending ??= fetchVersion().catch(() => { + pending = null; + return null; + }); + return pending; +} + export async function logout(): Promise { if (import.meta.env.DEV) { const { devAuthEnabled, devSignOut } = await import("./devauth"); diff --git a/web/src/chrome.ts b/web/src/chrome.ts index a99747e..b6246fb 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -7,7 +7,7 @@ */ import { alphaStamp } from "./alpha-stamp"; -import { fetchVersion } from "./api"; +import { apiVersion as sharedVersion } from "./api"; import { type AboutPageId, destinationForRoute } from "./destinations"; import { el } from "./dom"; @@ -83,8 +83,9 @@ const SITE_VERSION: string = import.meta.env.DEV ? "dev" : ((import.meta.env.VITE_BUILD_REF as string | undefined) ?? "unstamped"); -// Fetched once per page load; every footer render shares the answer. -const apiVersion = fetchVersion().catch(() => null); +// Fetched once per page load; every footer render shares the answer, and so +// does anything else that asks - see `apiVersion` in api.ts. +const apiVersion = sharedVersion(); export function footer(): HTMLElement { const debug = el("p", { diff --git a/web/src/megamek.ts b/web/src/megamek.ts index 06334b6..0b80994 100644 --- a/web/src/megamek.ts +++ b/web/src/megamek.ts @@ -26,6 +26,8 @@ * content hashes, so the two cannot collide. */ +import { apiVersion } from "./api"; + /** * The MegaMek whose files are served. * @@ -60,6 +62,30 @@ export const MEGAMEK_VERSION = "0.51.0"; */ export const HELM_RELEASE = "main-bf06475572ec"; +/** + * Which library to read, asked of the API rather than compiled in. + * + * The constants above are a fallback and nothing more. helm publishes under a + * prefix named for its own release, and this file naming one meant a helm + * deploy needed a site build to match it - which it did not always get, and + * then the force builder and the match reports read different indexes with + * nothing to say so. The API loads an index and reports what it loaded, so + * asking it is what makes the two agree. + * + * `/api/version` is already fetched once per page load for the footer, and + * `apiVersion` shares that one promise, so this costs no request of its own. + */ +export async function library(): Promise<{ helm: string; megamek: string }> { + // An unreachable API, or one with no index, is not a reason to show no + // designs at all: the constants are the last release this build knew + // about, and helm never deletes a prefix, so they still resolve. + const info = await apiVersion(); + return { + helm: info?.library?.helm ?? HELM_RELEASE, + megamek: info?.library?.megamek ?? MEGAMEK_VERSION, + }; +} + /** Where a release's files are served from. */ const PREFIX = "/assets/megamek"; diff --git a/web/src/screens/hangar.ts b/web/src/screens/hangar.ts index b8acefc..c07df07 100644 --- a/web/src/screens/hangar.ts +++ b/web/src/screens/hangar.ts @@ -20,7 +20,7 @@ import { footer, pageHead } from "../chrome"; import { el, render } from "../dom"; -import { unitIndexUrl, unitSprite } from "../megamek"; +import { library, unitIndexUrl, unitSprite } from "../megamek"; import { NO_TARGET, type Pick, @@ -423,22 +423,26 @@ export function hangarScreen(): Node[] { draw(); - void loadUnits(unitIndexUrl()).then( - (index) => { - state.units = index.units; - draw(); - }, - (error: unknown) => { - console.warn("hangar: could not load the unit index", error); - results.replaceChildren( - el("p", { - className: "error", - textContent: - "The unit library could not be loaded, so there is nothing to search.", - }), - ); - }, - ); + // Which library, asked of the API rather than compiled in, so a helm + // deploy reaches this screen without a site build. + void library() + .then(({ helm, megamek }) => loadUnits(unitIndexUrl(helm, megamek))) + .then( + (index) => { + state.units = index.units; + draw(); + }, + (error: unknown) => { + console.warn("hangar: could not load the unit index", error); + results.replaceChildren( + el("p", { + className: "error", + textContent: + "The unit library could not be loaded, so there is nothing to search.", + }), + ); + }, + ); return [ pageHead(