/** * The one place this service talks to somebody else's server. * * Every outbound call is bounded and every failure is a returned `null` rather * than a thrown exception, because the callers are all route handlers whose * contract is that they answer. An upstream that has gone away must degrade the * body, never the response. */ const DEFAULT_TIMEOUT_MS = 6000; /** * The largest upstream body this service will read. * * "Every outbound call is bounded" was true of the *time* and not of the size: * a bare `res.json()` reads whatever arrives, and what arrives is chosen by * somebody else's server. An ADS-B endpoint answering with 200,000 aircraft was * measured at 14.9 MB, parsed into an array this process then held and served * — cached, publicly — to every anonymous caller for the length of the TTL. * * Four megabytes is roughly two orders of magnitude above any honest answer * from the four upstreams here (a busy adsb.lol circle is tens of kilobytes, an * NWS observation is under ten) and well under anything that would trouble the * heap. It bounds the damage; `flights/adsb.ts` caps the row count, which * bounds what is kept. */ const MAX_BODY_BYTES = 4 * 1024 * 1024; export interface GetTextOptions { headers?: Record; timeoutMs?: number; /** Body-size ceiling in bytes. Defaults to `MAX_BODY_BYTES`. */ maxBytes?: number; /** Sent as `Accept` when set. Omitted entirely when not. */ accept?: string; } export type GetJsonOptions = Omit; /** * `null` on any failure at all — transport, status, an oversized body, or one * that will not parse. The caller decides what a missing answer means; nothing * here does. */ export async function getJson(url: string, opts: GetJsonOptions = {}): Promise { const text = await getText(url, { accept: "application/json", ...opts }); if (text === null) return null; try { return JSON.parse(text) as T; } catch { return null; } } /** * The same bounded fetch, stopping one step earlier. * * Not every upstream speaks JSON. CelesTrak serves element sets as fixed-width * text, and the choice was between this and a second copy of the size-bounding * that `getJson` already had — which is the copy that gets a fix applied to one * of it. So the ceiling, the timeout, the streaming cancel and the * failure-is-`null` contract live here once, and `getJson` is this plus a parse. * * `accept` defaults to nothing rather than to `text/plain`, because a server * that content-negotiates should be free to send its own idea of the right type; * `getJson` passes the header it actually needs. */ export async function getText(url: string, opts: GetTextOptions = {}): Promise { const maxBytes = opts.maxBytes ?? MAX_BODY_BYTES; try { const res = await fetch(url, { headers: { ...(opts.accept === undefined ? {} : { accept: opts.accept }), ...opts.headers }, signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS), }); if (!res.ok) return null; /** * The header first, because it is free and it is the one that stops the * transfer before it happens. It is only advisory — a chunked response * sends none — so the body is counted as it streams as well, and the * `cancel()` closes the socket on a server that lied or did not say. */ const declared = Number(res.headers.get("content-length")); if (Number.isFinite(declared) && declared > maxBytes) { await res.body?.cancel(); return null; } return await readBounded(res, maxBytes); } catch { return null; } } /** The body as text, or `null` the moment it goes over `maxBytes`. */ async function readBounded(res: Response, maxBytes: number): Promise { const body = res.body; // Undici always gives a stream; a test double or a `fetch` polyfill may not, // and falling back to `res.text()` there is still bounded by the header check // above and by the timeout. if (!body) { const text = await res.text(); return text.length > maxBytes ? null : text; } const reader = body.getReader(); const decoder = new TextDecoder(); let size = 0; let out = ""; for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBytes) { await reader.cancel(); return null; } out += decoder.decode(value, { stream: true }); } return out + decoder.decode(); } /** * A User-Agent that identifies this software and the operator running it. * * NWS and MET Norway both ask for a contact and are entitled to block a caller * who sends a generic agent. This is also why an empty contact demotes the * source in `config.ts` rather than being papered over here with a fake address. */ export function userAgent(contact: string): string { return `tera-api (+https://github.com/lumbridge-public/tera; ${contact})`; }