Pin API version + make frontend boot resilient to backend (async FE/BE deploy)
Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
This commit is contained in:
parent
7ece52db1b
commit
c95d5ec5f8
13 changed files with 155 additions and 77 deletions
|
|
@ -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)
|
||||
|
|
|
|||
66
content.py
66
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 /<key>.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 /<key>.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 /<key>.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
|
||||
+ '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>'
|
||||
: (discordEnabled ? `<a href="${APP_BASE}/api/v2/discord/link/start" class="acct-pop-link">Link Discord</a>` : "")}
|
||||
: (discordEnabled ? `<a href="${uv("discord/link/start")}" class="acct-pop-link">Link Discord</a>` : "")}
|
||||
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 || []);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = `<p class="alerts-loading">Offline. Can't load alerts.</p>`; return; }
|
||||
if (res.status === 401) { renderGate(); return; }
|
||||
if (!res.ok) { body.innerHTML = `<p class="alerts-loading">${esc(await readErr(res))}</p>`; 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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue