wayfarer-weather/lib/weather/http.ts

94 lines
3.1 KiB
TypeScript
Raw Permalink Normal View History

2026-10-31 21:20:16 -05:00
/**
* Small server-side helpers shared by every provider adapter.
*/
/** An error that knows which HTTP status the API route should answer with. */
export class ProviderError extends Error {
constructor(
message: string,
public readonly status: number = 502,
) {
super(message);
this.name = "ProviderError";
}
}
/** Reads an API key from the environment, or fails with a clear message. */
export function readKey(envKey: string): string {
const key = process.env[envKey]?.trim();
if (!key) {
throw new ProviderError(`${envKey} is not configured`, 500);
}
return key;
}
interface FetchOptions {
/** Seconds to cache the response. Weather barely changes in 10 minutes. */
cacheSeconds?: number;
headers?: Record<string, string>;
}
/**
* Fetches JSON with caching (default 10 minutes) to protect free-tier quotas.
* The URL often contains an API key, so it is never logged or echoed back.
*/
export async function fetchJson<T>(url: string, providerLabel: string, options: FetchOptions = {}): Promise<T> {
const { cacheSeconds = 600, headers } = options;
let response: Response;
try {
response = await fetch(url, { headers, next: { revalidate: cacheSeconds } });
} catch {
// DNS failure, offline, firewall… the request never got an answer.
throw new ProviderError(`Couldn't reach ${providerLabel}. Check your connection and try again.`, 503);
}
if (response.status === 401 || response.status === 403) {
throw new ProviderError(`${providerLabel} rejected the API key`, 401);
}
if (response.status === 429) {
throw new ProviderError(`${providerLabel} rate limit reached. Try again shortly.`, 429);
}
if (!response.ok) {
throw new ProviderError(`${providerLabel} responded with HTTP ${response.status}`, 502);
}
return (await response.json()) as T;
}
// ---------- Number and time helpers ----------
/** Rounds to one decimal place so the JSON stays tidy. */
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/** Same as round1, but passes null/undefined/NaN through as null. */
export function maybe(value: number | null | undefined): number | null {
return value === null || value === undefined || !Number.isFinite(value) ? null : round1(value);
}
/** Metres per second → km/h. */
export function msToKph(ms: number): number {
return round1(ms * 3.6);
}
export function maybeMsToKph(ms: number | null | undefined): number | null {
return ms === null || ms === undefined ? null : msToKph(ms);
}
/** Unix seconds → ISO instant. */
export function fromUnix(seconds: number): string {
return new Date(seconds * 1000).toISOString();
}
/** Keeps the first `hours` of an hourly list, starting from the current hour. */
export function nextHours<T extends { time: string }>(items: T[], hours: number): T[] {
const startOfThisHour = Date.now() - 60 * 60 * 1000;
return items.filter((item) => Date.parse(item.time) >= startOfThisHour).slice(0, hours);
}
/** The app shows at most 7 days of hourly data and 14 days of daily data. */
export const MAX_HOURS = 168;
export const MAX_DAYS = 14;