1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/http.ts
T
karti a229fb2721 The sky gets the things above the aeroplanes
Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.

Sending elements is the same trick `flights/plan.ts` plays and it has a
better excuse here — a TLE *is* the closed form, valid for days either
side of its epoch, so one cacheable fetch every six hours replaces a poll
and every viewer agrees about where everything is.

Two things are worth knowing about the shape of it:

  - There is no region parameter. An aeroplane at 10,000 m is local and
    a satellite at 550 km is above the horizon for a circle two thousand
    kilometres across, so one catalogue serves both boards and the client
    decides what is above its own horizon. Only the observer is per-city,
    which is why `main.ts` shares the elements and rebuilds the catalogue.
  - The layer draws on a dome, because it cannot draw anywhere else.
    `world.metres(550_000)` is 21,000 scene units against a far plane at
    3,000. Azimuth and elevation are real; the radius carries nothing.

Off by default: a clone that started pulling CelesTrak on `npm run dev`
would have volunteered somebody else's bandwidth for its onboarding.

Godmode gets the two dials that point at the sky rather than at the
light — fabricated traffic, which composes with a live ADS-B feed instead
of replacing it, and a switch for the satellite layer with a count beside
it. Both are god-only lies about the inputs, in the manner of the weather
override.

`satellite.js` is the second runtime dependency this package has taken.
Its entry point star-exports an Emscripten build that cannot be shaken
out, so `noWasmPropagator` in the Vite config cuts it: 308 kB of WASM
loader for a bulk propagator nothing calls, against 26 kB for the SGP4
that does the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:57:14 -07:00

132 lines
4.9 KiB
TypeScript

/**
* 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<string, string>;
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<GetTextOptions, "accept">;
/**
* `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<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> {
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<string | null> {
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<string | null> {
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})`;
}