From c95d5ec5f8240fe9869b6f8810e86dcc7ec01f9e Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Wed, 22 Jul 2026 11:50:00 -0700 Subject: [PATCH] Pin API version + make frontend boot resilient to backend (async FE/BE deploy) Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z --- api_client.py | 20 ++++++++----- content.py | 66 ++++++++++++++++++++++++++++++++++++----- static/account.js | 53 ++++++++++++++++++++++----------- static/app.js | 4 +-- static/cache.js | 31 ++++++++++--------- static/calendar.js | 6 ++-- static/compare.js | 6 ++-- static/day.js | 4 +-- static/digest.js | 12 ++++---- static/mappicker.js | 4 +-- static/push-client.js | 10 +++---- static/score.js | 4 +-- static/subscriptions.js | 12 ++++---- 13 files changed, 155 insertions(+), 77 deletions(-) diff --git a/api_client.py b/api_client.py index c40cb2a..e5f2ed4 100644 --- a/api_client.py +++ b/api_client.py @@ -20,6 +20,12 @@ API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL") if not API_BASE: raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)") +# Single pin point for the backend content-API version this client speaks. +# Every path builder below reads this instead of hardcoding "v2" so a backend +# version bump is a one-line change here (plus the analogous JS pin in +# static/account.js) instead of a hunt-and-replace across ~10 files. +API_VERSION = os.environ.get("THERMOGRAPH_API_VERSION", "v2") + # Backend's own routes (including the content API this client calls) sit # under ITS OWN THERMOGRAPH_BASE, exactly like content.py computes for # frontend's own routes -- both processes are always configured with the same @@ -63,31 +69,31 @@ def _get(path: str, *, origin: str | None = None) -> object: def hub() -> dict: - return _get("/api/v2/content/hub") + return _get(f"/api/{API_VERSION}/content/hub") def sitemap() -> list[dict]: - return _get("/api/v2/content/sitemap") + return _get(f"/api/{API_VERSION}/content/sitemap") def indexnow_key() -> str: - return _get("/api/v2/content/indexnow-key")["key"] + return _get(f"/api/{API_VERSION}/content/indexnow-key")["key"] def home() -> dict: - return _get("/api/v2/content/home") + return _get(f"/api/{API_VERSION}/content/home") def city(slug: str, *, origin: str | None = None) -> dict: """Raises httpx.HTTPStatusError with .response.status_code 404 (unknown city) or 503 (warming) -- callers translate these to FastAPI HTTPException, same status codes content.py's _resolve_city raised in-process.""" - return _get(f"/api/v2/content/city/{slug}", origin=origin) + return _get(f"/api/{API_VERSION}/content/city/{slug}", origin=origin) def city_month(slug: str, month: str) -> dict: - return _get(f"/api/v2/content/city/{slug}/month/{month}") + return _get(f"/api/{API_VERSION}/content/city/{slug}/month/{month}") def city_records(slug: str, *, origin: str | None = None) -> dict: - return _get(f"/api/v2/content/city/{slug}/records", origin=origin) + return _get(f"/api/{API_VERSION}/content/city/{slug}/records", origin=origin) diff --git a/content.py b/content.py index 207d678..82a539b 100644 --- a/content.py +++ b/content.py @@ -11,6 +11,7 @@ computed by the backend now (backend/api/content_payloads.py), not here. import datetime import hashlib import json +import logging import os import httpx @@ -47,6 +48,8 @@ _env.globals["temp"] = fmt.temp _env.globals["temp_bare"] = fmt.temp_bare _env.filters["ordinal"] = fmt.pct_ordinal +_log = logging.getLogger(__name__) + # --- helpers ------------------------------------------------------------- def _origin(request: Request) -> str: @@ -403,15 +406,64 @@ def register(app) -> None: """Attach all content routes. Call from app.py BEFORE the StaticFiles mount.""" app.add_api_route(f"{BASE}/robots.txt", robots_txt, methods=["GET"], include_in_schema=False) app.add_api_route(f"{BASE}/sitemap.xml", sitemap_xml, methods=["GET"], include_in_schema=False) - _inkey = api_client.indexnow_key() - def _indexnow_key_file() -> Response: - return PlainTextResponse(_inkey + "\n") + # --- IndexNow key file --------------------------------------------------- + # IndexNow verification works by serving a file at /.txt -- so the + # key isn't just response *content*, it's baked into the route *path* + # itself, which FastAPI needs at registration time. That used to mean an + # eager, unretried `api_client.indexnow_key()` call right here in + # register() -- called at import time, before the app has served a single + # request -- so if the backend was unreachable (down, mid-restart, a + # renamed/removed route on a newer/older version, a network blip) this + # frontend process would never finish booting at all. That's exactly the + # coupling this task exists to remove: frontend and backend now deploy + # asynchronously, so "backend happens to be briefly unreachable" must be a + # normal, survivable condition at frontend boot, not a crash. + # + # Fix: try the eager fetch once (this preserves today's exact route/ + # behavior -- a single static route at /.txt -- for the overwhelming + # common case where the backend IS reachable at boot). If it fails, don't + # let the exception propagate out of register() / crash boot -- log a + # warning and fall back to a catch-all route that lazily (re)fetches the + # key on each request via api_client.indexnow_key(), which already caches + # successful lookups for THERMOGRAPH_SSR_CACHE_TTL seconds (api_client.py's + # TTL cache), so once the backend comes back up the correct key starts + # being served automatically -- no frontend restart required, and no + # request ever gets a permanently wrong/empty key. + try: + _inkey = api_client.indexnow_key() + except Exception: + _log.warning( + "indexnow_key() fetch failed at boot (backend unreachable?) -- " + "continuing boot without it; falling back to lazy per-request " + "lookup at /.txt so the frontend doesn't depend on backend " + "liveness to start up", + exc_info=True, + ) + _inkey = None - app.add_api_route( - f"{BASE}/{_inkey}.txt", _indexnow_key_file, - methods=["GET", "HEAD"], include_in_schema=False, - ) + if _inkey is not None: + def _indexnow_key_file() -> Response: + return PlainTextResponse(_inkey + "\n") + + app.add_api_route( + f"{BASE}/{_inkey}.txt", _indexnow_key_file, + methods=["GET", "HEAD"], include_in_schema=False, + ) + else: + def _indexnow_key_file_lazy(token: str) -> Response: + try: + key = api_client.indexnow_key() + except Exception as e: + raise HTTPException(status_code=503, detail="backend unavailable") from e + if token != key: + raise HTTPException(status_code=404) + return PlainTextResponse(key + "\n") + + app.add_api_route( + f"{BASE}/{{token}}.txt", _indexnow_key_file_lazy, + methods=["GET", "HEAD"], include_in_schema=False, + ) app.add_api_route(f"{BASE}/", home_page, methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/about", about_page, methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{BASE}/privacy", privacy_page, methods=["GET", "HEAD"], include_in_schema=False) diff --git a/static/account.js b/static/account.js index ce11947..2db3745 100644 --- a/static/account.js +++ b/static/account.js @@ -25,12 +25,29 @@ const authCbs = []; // notified on login/logout so pages can re-gat export const APP_BASE = new URL(".", import.meta.url).href.replace(/\/$/, ""); export const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`; +// Single pin point for the backend content/data API version every fetch below +// targets — was ~30 hardcoded "api/v2/..." literals spread across ~10 files +// with no one place to bump. Bump only in lockstep with a verified backend +// /api/version check: this frontend and its backend now deploy asynchronously +// (repo-split), so an unverified bump here would just turn every API call +// into a 404 the moment it ships ahead of a backend that doesn't speak it yet. +export const API_VERSION = "v2"; // bump only in lockstep with a verified backend /api/version check + +// Turns a bare resource path ("grade", "users/me") — or one a caller already +// prefixed with a (possibly stale) version ("api/v2/place") — into a full +// request URL pinned to API_VERSION. Accepting the already-prefixed form too +// means a copy-pasted literal from an old call site normalizes instead of +// silently double-prefixing. +export const uv = (path) => + u(`api/${API_VERSION}/${String(path).replace(/^\/?(api\/v\d+\/)?/, "")}`); + // --- shared fetch helper ----------------------------------------------------- -// Absolute app-base URL (so it works from any page depth, and from any origin -// once split), cookie sent, and a constant X-TG-Auth header the server can -// require as cheap CSRF defense (still meaningful cross-origin: forging it -// needs a preflight-clearing request we never grant, same as same-origin). -export async function apiFetch(path, { method = "GET", json, form } = {}) { +// `url` must already be a full request URL — build it with uv() so every call +// is pinned to API_VERSION. Cookie sent, and a constant X-TG-Auth header the +// server can require as cheap CSRF defense (still meaningful cross-origin: +// forging it needs a preflight-clearing request we never grant, same as +// same-origin). +export async function apiFetch(url, { method = "GET", json, form } = {}) { const opts = { method, credentials: "include", headers: { "X-TG-Auth": "1" } }; if (json !== undefined) { opts.headers["Content-Type"] = "application/json"; @@ -39,7 +56,7 @@ export async function apiFetch(path, { method = "GET", json, form } = {}) { opts.headers["Content-Type"] = "application/x-www-form-urlencoded"; opts.body = new URLSearchParams(form).toString(); } - return fetch(u(path), opts); + return fetch(url, opts); } // Parse a JSON response, throwing a friendly Error on failure. fastapi-users @@ -69,7 +86,7 @@ function emitAuth() { authCbs.forEach((cb) => { try { cb(currentUser); } catch ( async function refreshUser() { try { - const res = await apiFetch("api/v2/users/me"); + const res = await apiFetch(uv("users/me")); currentUser = res.ok ? await res.json() : null; } catch (e) { currentUser = null; } // Learn once whether Discord linking is configured, so the account menu only @@ -78,7 +95,7 @@ async function refreshUser() { if (currentUser && !discordChecked) { discordChecked = true; try { - const r = await apiFetch("api/v2/discord/config"); + const r = await apiFetch(uv("discord/config")); if (r.ok) discordEnabled = (await r.json()).enabled === true; } catch (e) { /* leave it hidden */ } } @@ -88,19 +105,19 @@ async function refreshUser() { // --- auth calls -------------------------------------------------------------- async function login(email, password) { // fastapi-users login is an OAuth2 form: username=email, password. - const res = await apiFetch("api/v2/auth/login", { method: "POST", form: { username: email, password } }); + const res = await apiFetch(uv("auth/login"), { method: "POST", form: { username: email, password } }); await readJson(res); // throws on bad creds; 204 body is empty } async function register(email, password) { - const res = await apiFetch("api/v2/auth/register", { method: "POST", json: { email, password } }); + const res = await apiFetch(uv("auth/register"), { method: "POST", json: { email, password } }); await readJson(res); } async function verifyEmail(token) { - const res = await apiFetch("api/v2/auth/verify", { method: "POST", json: { token } }); + const res = await apiFetch(uv("auth/verify"), { method: "POST", json: { token } }); await readJson(res); } export async function logout() { - try { await apiFetch("api/v2/auth/logout", { method: "POST" }); } catch (e) {} + try { await apiFetch(uv("auth/logout"), { method: "POST" }); } catch (e) {} currentUser = null; renderHeader(); emitAuth(); @@ -213,7 +230,7 @@ function timeAgo(sec) { async function loadNotifications() { try { - const res = await apiFetch("api/v2/notifications?limit=50"); + const res = await apiFetch(uv("notifications?limit=50")); if (!res.ok) return; const data = await res.json(); notifications = data.notifications || []; @@ -252,7 +269,7 @@ function paintNotifList() { async function markRead(id, li) { try { - const res = await apiFetch(`api/v2/notifications/${id}/read`, { method: "POST" }); + const res = await apiFetch(uv(`notifications/${id}/read`), { method: "POST" }); if (!res.ok) return; const n = notifications.find((x) => x.id === id); if (n && !n.read_at) { n.read_at = Date.now() / 1000; unreadCount = Math.max(0, unreadCount - 1); } @@ -263,7 +280,7 @@ async function markRead(id, li) { async function markAllRead() { try { - const res = await apiFetch("api/v2/notifications/read-all", { method: "POST" }); + const res = await apiFetch(uv("notifications/read-all"), { method: "POST" }); if (!res.ok) return; notifications.forEach((n) => { n.read_at = n.read_at || Date.now() / 1000; }); unreadCount = 0; @@ -331,7 +348,7 @@ function renderHeader() { ${currentUser.discord_id ? `` + '' - : (discordEnabled ? `Link Discord` : "")} + : (discordEnabled ? `Link Discord` : "")} `; @@ -362,7 +379,7 @@ function renderHeader() { const unlinkBtn = el.querySelector(".acct-discord-unlink"); if (unlinkBtn) unlinkBtn.addEventListener("click", async () => { unlinkBtn.disabled = true; - try { await apiFetch("api/v2/discord/unlink", { method: "POST" }); } catch (e) {} + try { await apiFetch(uv("discord/unlink"), { method: "POST" }); } catch (e) {} await refreshUser(); emitAuth(); // repaint the popover in its unlinked state }); @@ -370,7 +387,7 @@ function renderHeader() { if (dmBtn) dmBtn.addEventListener("click", async () => { dmBtn.disabled = true; try { - await apiFetch("api/v2/discord/dm", { method: "POST", json: { enabled: !currentUser.discord_dm } }); + await apiFetch(uv("discord/dm"), { method: "POST", json: { enabled: !currentUser.discord_dm } }); } catch (e) {} await refreshUser(); emitAuth(); // repaint with the new on/off label diff --git a/static/app.js b/static/app.js index b52df92..4765519 100644 --- a/static/app.js +++ b/static/app.js @@ -1,7 +1,7 @@ // Weekly (map) page. Imports replace the old script-tag ordering contract. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; import { fmtTemp, onUnitChange, toWind, windUnit, precipUnit } from "./units.js"; -import "./account.js"; // header sign-in entry + notification bell +import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette, @@ -163,7 +163,7 @@ async function runGrade() { // Omit the date when it's today, so the URL (and cache key) matches the prefetch // fired from the other views — a same-tab navigation then reuses the response. const q = `lat=${selected.lat}&lon=${selected.lon}`; - const url = dateInput.value === todayISO() ? `api/grade?${q}` : `api/grade?${q}&date=${dateInput.value}`; + const url = dateInput.value === todayISO() ? uv(`grade?${q}`) : uv(`grade?${q}&date=${dateInput.value}`); let data; try { // Stale-while-revalidate: a stale cached copy renders immediately and the diff --git a/static/cache.js b/static/cache.js index b4262e9..190e06d 100644 --- a/static/cache.js +++ b/static/cache.js @@ -4,12 +4,15 @@ // multi-year calendar payloads persist across tabs and restarts — with a small // in-memory map in front for same-page rereads. // -// URLs are resolved via account.js's `u()` (backend's own origin+base) rather -// than a bare relative fetch() — account.js is always served by backend, so -// this keeps every request correctly targeted at backend even when this -// script is loaded from a frontend_ssr-rendered page on a different origin -// (repo-split Stage 5). credentials: "include" (not the fetch default) is -// what makes the auth cookie ride along in that same case. +// URLs are resolved via account.js's `uv()` (backend's own origin+base, API +// version pinned) rather than a bare relative fetch() — account.js is always +// served by backend, so this keeps every request correctly targeted at +// backend even when this script is loaded from a frontend_ssr-rendered page +// on a different origin (repo-split Stage 5). credentials: "include" (not the +// fetch default) is what makes the auth cookie ride along in that same case. +// Every URL a caller passes into getJSON/chunkedFetch below is expected to +// already be uv()-resolved (a full absolute URL) — this module doesn't +// resolve raw resource paths itself. // // Freshness is layered: // * fresh (within TTL, stored today) → served as-is, no request. @@ -20,7 +23,7 @@ // etag is stored, so an unchanged payload costs an empty 304). // * network failure with any entry cached → the stale copy, rather than an error. -import { u } from "./account.js"; +import { uv } from "./account.js"; export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000, score: 21600000 }; // calendar/score: 6h @@ -101,7 +104,7 @@ const isFresh = (rec, ttlMs) => !!(rec && rec.day === localDay() && Date.now() - // entry (fresh again, no payload moved); a 200 stores payload + etag. async function fetchStore(url, rec) { const headers = rec && rec.etag ? { "If-None-Match": rec.etag } : {}; - const res = await fetch(u(url), { headers, credentials: "include" }); + const res = await fetch(url, { headers, credentials: "include" }); if (res.status === 304 && rec) { cachePut({ ...rec, t: Date.now(), day: localDay() }); return rec.d; @@ -183,10 +186,10 @@ function seedCache(url, data, etag) { // lives here next to the bundle fetch — not knowledge of any one page. function viewFetches(q, today) { return { - grade: [`api/grade?${q}`, TTL.grade], - forecast: [`api/v2/forecast?${q}`, TTL.forecast], - calendar: [`api/v2/calendar?${q}&months=24`, TTL.calendar], - day: [`api/v2/day?${q}&date=${today}`, TTL.day], + grade: [uv(`grade?${q}`), TTL.grade], + forecast: [uv(`forecast?${q}`), TTL.forecast], + calendar: [uv(`calendar?${q}&months=24`), TTL.calendar], + day: [uv(`day?${q}&date=${today}`), TTL.day], }; } @@ -215,7 +218,7 @@ export function prefetchViews(lat, lon, ownViews) { const ek = "tg-cell-etag:" + q; let prev = null; try { prev = sessionStorage.getItem(ek); } catch (e) {} - const res = await fetch(u(`api/v2/cell?${q}&neighbors=1`), + const res = await fetch(uv(`cell?${q}&neighbors=1`), { credentials: "include", ...(prev ? { headers: { "If-None-Match": prev } } : {}) }); if (res.ok && res.status !== 304) { const bundle = await res.json(); @@ -224,7 +227,7 @@ export function prefetchViews(lat, lon, ownViews) { for (const [name, slice] of Object.entries(bundle.slices || {})) { if (own.has(name) || !slice || !slice.data) continue; const url = name === "day" - ? `api/v2/day?${q}&date=${bundle.today}` // the day slice targets today + ? uv(`day?${q}&date=${bundle.today}`) // the day slice targets today : (fetches[name] || [])[0]; if (url) seedCache(url, slice.data, slice.etag); } diff --git a/static/calendar.js b/static/calendar.js index 1b0ce93..9b97a2f 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -3,7 +3,7 @@ import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; // The tooltip reads fmtTemp live, so a unit switch shows on the next hover. import { fmtTemp, onUnitChange } from "./units.js"; -import "./account.js"; // header sign-in entry + notification bell +import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd, @@ -427,8 +427,8 @@ async function fetchCalendar() { }; const urls = chunks.map((ch) => ch - ? `api/v2/calendar?${q}&start=${ch.start}&end=${ch.end}` - : `api/v2/calendar?${q}&months=24`); + ? uv(`calendar?${q}&start=${ch.start}&end=${ch.end}`) + : uv(`calendar?${q}&months=24`)); // Bounded-parallel streaming load (see cache.js). Stale-while-revalidate // updates re-merge that chunk's days and repaint — the cell/place metadata is // identical across versions, and initOnce self-guards after the first chunk. diff --git a/static/compare.js b/static/compare.js index cd86687..5bc0ba2 100644 --- a/static/compare.js +++ b/static/compare.js @@ -25,7 +25,7 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks, allMonths, monthsToMask, maskToMonths, namePrimary, nameParts } from "./shared.js"; import { fmtTemp, fmtDelta, onUnitChange } from "./units.js"; -import { u } from "./account.js"; // header sign-in entry + notification bell, and the shared URL resolver +import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { initFilterSheet } from "./filtersheet.js"; const CMP = { @@ -211,7 +211,7 @@ async function fetchSeries(loc, token) { const days = []; let place = null; for (const ch of chunks) { - const url = `api/v2/calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`; + const url = uv(`calendar?lat=${loc.lat}&lon=${loc.lon}&start=${ch.start}&end=${ch.end}`); const d = await getJSON(url, TTL.calendar, true); if (token !== loadToken) return null; // superseded place = place || d.place || `${d.cell.center_lat.toFixed(2)}, ${d.cell.center_lon.toFixed(2)}`; @@ -262,7 +262,7 @@ function addLocation(lat, lon) { // the name too, so a failure here is harmless. async function resolveName(loc) { try { - const res = await fetch(u(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" }); + const res = await fetch(uv(`place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" }); if (!res.ok) return; const d = await res.json(); // Skip if the location was removed meanwhile, or already got named by a load. diff --git a/static/day.js b/static/day.js index 8a238b8..c07aec3 100644 --- a/static/day.js +++ b/static/day.js @@ -3,7 +3,7 @@ // and where the observed values land. Talks to /api/v2/day + /api/v2/geocode. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; import { fmtTemp, onUnitChange } from "./units.js"; -import "./account.js"; // header sign-in entry + notification bell +import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid, @@ -72,7 +72,7 @@ async function fetchDay() { // Stale-while-revalidate: a stale cached copy renders immediately; the update // callback repaints if the background revalidation finds new data. data = await getJSON( - `api/v2/day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`, TTL.day, + uv(`day?lat=${selected.lat}&lon=${selected.lon}${dateQ}`), TTL.day, false, (upd) => { if (seq === daySeq) finishDay(upd); }); } catch (err) { clearTimeout(spin); diff --git a/static/digest.js b/static/digest.js index 50913ef..cfef64e 100644 --- a/static/digest.js +++ b/static/digest.js @@ -4,17 +4,17 @@ // this file blocked, and the beacons are fire-and-forget with no UI depending // on them. -// Backend's own origin+base, shared with account.js rather than a second -// independent import.meta.url-derived copy (repo-split Stage 5 — this file, -// like account.js, is always served by backend, possibly from a page on a -// different origin once frontend/backend genuinely split). -import { APP_BASE } from "./account.js"; +// Backend's own origin+base (+ pinned API version), shared with account.js +// rather than a second independent import.meta.url-derived copy (repo-split +// Stage 5 — this file, like account.js, is always served by backend, possibly +// from a page on a different origin once frontend/backend genuinely split). +import { uv } from "./account.js"; /** Report one product event. Aggregate-only: the server takes the referrer from * the request itself, so nothing identifying is sent from here. */ export function track(event) { try { - const url = `${APP_BASE}/api/v2/event`; + const url = uv("event"); const body = JSON.stringify({ event }); // sendBeacon survives the page unloading, which a fetch() started on a link // click usually does not — that's the whole point for nav/records clicks. diff --git a/static/mappicker.js b/static/mappicker.js index 24410f8..068b3ba 100644 --- a/static/mappicker.js +++ b/static/mappicker.js @@ -7,7 +7,7 @@ // Leaflet stays a classic script (global L), loaded before the page modules. The // map itself is created lazily the first time the picker opens (and reused after), // so pages that never open it pay nothing. -import { u } from "./account.js"; +import { uv } from "./account.js"; let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null; let searchForm, searchInput, sugEl, confirmBtn, hintEl; @@ -58,7 +58,7 @@ function build() { if (sugAbort) sugAbort.abort(); sugAbort = new AbortController(); try { - const res = await fetch(u(`api/v2/suggest?q=${encodeURIComponent(q)}`), + const res = await fetch(uv(`suggest?q=${encodeURIComponent(q)}`), { signal: sugAbort.signal, credentials: "include" }); const d = await res.json(); if (seq === sugSeq) renderSug(d.results || []); diff --git a/static/push-client.js b/static/push-client.js index f6efadb..8e61f8b 100644 --- a/static/push-client.js +++ b/static/push-client.js @@ -8,7 +8,7 @@ // Push + service workers require a secure context (HTTPS, or http://localhost), // so on a plain-HTTP LAN origin this cleanly reports "unsupported" instead of // throwing. -import { apiFetch } from "./account.js"; +import { apiFetch, uv } from "./account.js"; export function supported() { return ( @@ -61,7 +61,7 @@ export async function enable() { const reg = await registration(); let sub = await reg.pushManager.getSubscription(); if (!sub) { - const res = await apiFetch("api/v2/push/vapid-key"); + const res = await apiFetch(uv("push/vapid-key")); if (!res.ok) throw new Error("Couldn't fetch the server key."); const { key } = await res.json(); sub = await reg.pushManager.subscribe({ @@ -69,7 +69,7 @@ export async function enable() { applicationServerKey: urlB64ToUint8Array(key), }); } - const res = await apiFetch("api/v2/push/subscribe", { method: "POST", json: sub.toJSON() }); + const res = await apiFetch(uv("push/subscribe"), { method: "POST", json: sub.toJSON() }); if (!res.ok) throw new Error("Couldn't register this device."); } @@ -80,14 +80,14 @@ export async function disable() { const sub = await reg.pushManager.getSubscription(); if (!sub) return; try { - await apiFetch("api/v2/push/subscribe", { method: "DELETE", json: { endpoint: sub.endpoint } }); + await apiFetch(uv("push/subscribe"), { method: "DELETE", json: { endpoint: sub.endpoint } }); } catch (e) { /* best-effort server cleanup */ } await sub.unsubscribe(); } // Ask the server to push a test notification to every device on the account. export async function sendTest() { - const res = await apiFetch("api/v2/push/test", { method: "POST" }); + const res = await apiFetch(uv("push/test"), { method: "POST" }); if (!res.ok) throw new Error("Couldn't send a test notification."); return res.json(); } diff --git a/static/score.js b/static/score.js index 5b34eb7..7ce5eeb 100644 --- a/static/score.js +++ b/static/score.js @@ -3,7 +3,7 @@ // equal-weighted overall score and a button-revealed summary. Talks to /api/v2/score. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; import { fmtTemp, onUnitChange } from "./units.js"; -import "./account.js"; // header sign-in entry + notification bell +import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, esc, placeLabel } from "./shared.js"; @@ -68,7 +68,7 @@ async function fetchScore() { // Stale-while-revalidate: a stale cached copy renders immediately; the update // callback repaints if the background revalidation finds new data. data = await getJSON( - `api/v2/score?lat=${selected.lat}&lon=${selected.lon}`, TTL.score, + uv(`score?lat=${selected.lat}&lon=${selected.lon}`), TTL.score, false, (upd) => { if (seq === seqCounter) finishScore(upd); }); } catch (err) { clearTimeout(spin); diff --git a/static/subscriptions.js b/static/subscriptions.js index c6e332a..22dc124 100644 --- a/static/subscriptions.js +++ b/static/subscriptions.js @@ -3,7 +3,7 @@ // watched metrics, toggle active, and remove. Auth-gated: signed-out visitors get // a sign-in prompt. All calls go through account.js's cookie-aware apiFetch. import "./nav.js"; -import { apiFetch, openAuth, onAuthChange } from "./account.js"; +import { apiFetch, openAuth, onAuthChange, uv } from "./account.js"; import { open as openPicker } from "./mappicker.js"; import * as pushClient from "./push-client.js"; @@ -44,7 +44,7 @@ async function readErr(res) { // --- load + top-level render ------------------------------------------------- async function load() { let res; - try { res = await apiFetch("api/v2/subscriptions"); } + try { res = await apiFetch(uv("subscriptions")); } catch (e) { body.innerHTML = `

Offline. Can't load alerts.

`; return; } if (res.status === 401) { renderGate(); return; } if (!res.ok) { body.innerHTML = `

${esc(await readErr(res))}

`; return; } @@ -208,7 +208,7 @@ function subCard(s) { async function patch(id, data, li) { li.classList.add("is-busy"); try { - const res = await apiFetch(`api/v2/subscriptions/${id}`, { method: "PATCH", json: data }); + const res = await apiFetch(uv(`subscriptions/${id}`), { method: "PATCH", json: data }); if (!res.ok) throw new Error(await readErr(res)); const updated = await res.json(); subs = subs.map((s) => (s.id === id ? updated : s)); @@ -222,7 +222,7 @@ async function patch(id, data, li) { async function removeSub(id, li) { li.classList.add("is-busy"); try { - const res = await apiFetch(`api/v2/subscriptions/${id}`, { method: "DELETE" }); + const res = await apiFetch(uv(`subscriptions/${id}`), { method: "DELETE" }); if (!res.ok && res.status !== 404) throw new Error(await readErr(res)); subs = subs.filter((s) => s.id !== id); li.remove(); @@ -238,7 +238,7 @@ function startAdd() { openPicker(null, async (lat, lon) => { let label = null; try { - const r = await apiFetch(`api/v2/place?lat=${lat}&lon=${lon}`); + const r = await apiFetch(uv(`place?lat=${lat}&lon=${lon}`)); const d = await r.json(); label = d.place || null; } catch (e) { /* label optional; server falls back to cached/coords */ } @@ -361,7 +361,7 @@ async function onCreate(e) { btn.disabled = true; showFormError(""); try { - const res = await apiFetch("api/v2/subscriptions", { method: "POST", json: payload }); + const res = await apiFetch(uv("subscriptions"), { method: "POST", json: payload }); if (!res.ok) throw new Error(await readErr(res)); const created = await res.json(); subs.push(created);