Return

All tests / src/helpers http.ts

85.71% Statements 6/7
100% Branches 3/3
100% Functions 1/1
100% Lines 4/4

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65                                        17x                   38x                                                         31x 31x        
/**
 * Shared outbound-HTTP and cache policy for the API routes.
 *
 * SDD-L02. Both halves of this file exist because the same thing was missing from seven of the eight
 * routes and present in one, which is how the drift started:
 *
 * - **Cache-Control.** Only /api/analytics set one, so every visitor rendering the header invoked six
 *   serverless functions, each hitting a third-party API. That let an anonymous caller spend the
 *   owner's GitHub, Vercel, Search Console, Open-Meteo, CoinGecko and Ariston quotas at will, with
 *   10:1 amplification on /api/weather (5 cities x 2 fetches). Caching at the edge collapses almost
 *   all of it, because the CDN absorbs the repeats.
 * - **Timeouts.** No outbound fetch had one. A slow upstream held a function open until the platform
 *   killed it, which is the cheapest available path to FUNCTION_INVOCATION_TIMEOUT.
 *
 * Centralised rather than copied per route for the reason recorded in helpers/slug.ts and
 * helpers/city.ts: logic duplicated across routes drifts, and tightening one copy while missing the
 * others is how the gap reopens.
 */
 
/** Every outbound call gets the same ceiling. Five seconds is well inside the function limit. */
export const FETCH_TIMEOUT_MS = 5_000;
 
/**
 * Cache windows, chosen per upstream rather than uniformly — the constraint is how stale a reader
 * would notice, not how fast the data can change.
 *
 * `stale-while-revalidate` is the important half: when an upstream is down or a quota is exhausted,
 * the edge serves the last known value instead of an error, so a widget degrades to slightly-old
 * rather than broken.
 */
export const CACHE = {
    /** Analytics view counts. A five-minute-old total reads identically to a fresh one. */
    analytics: 'public, s-maxage=300, stale-while-revalidate=86400',
    /** Indoor/outdoor temperature. Also the rate limiter for the Ariston login (see api/heating.ts). */
    heating: 'public, s-maxage=600, stale-while-revalidate=3600',
    /** Weather. Open-Meteo is IP-rate-limited and this route fans out up to 10 calls per request. */
    weather: 'public, s-maxage=600, stale-while-revalidate=3600',
    /** Crypto price. The widget shows a 24h change, so ten minutes of staleness is invisible. */
    price: 'public, s-maxage=600, stale-while-revalidate=3600',
    /** Google News RSS. Headlines do not turn over faster than this. */
    news: 'public, s-maxage=900, stale-while-revalidate=3600',
    /** Star count and indexed-page count. Both move on the order of days. */
    slow: 'public, s-maxage=3600, stale-while-revalidate=86400',
    /** Deployment status. Short, because this one is meant to look live. */
    deployment: 'public, s-maxage=60, stale-while-revalidate=600',
    /**
     * Errors are deliberately uncacheable. Caching a 500 would pin a transient upstream failure in
     * front of every visitor for the whole window.
     */
    error: 'no-store',
} as const;
 
/**
 * @description `fetch` with a hard timeout. Callers may pass their own `signal`; a caller-supplied
 * signal wins, on the assumption it is more specific than the default.
 * @param {string | URL} url - Absolute URL. No route builds this from user input.
 * @param {RequestInit} [init] - Standard fetch init.
 * @returns {Promise<Response>}
 */
export const fetchWithTimeout = async (url: string | URL, init: RequestInit = {}): Promise<Response> =>
    fetch(url, {
        ...init,
        signal: init.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
    });