1
0

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>
This commit is contained in:
2026-08-06 20:57:14 -07:00
parent 0cc2126e85
commit a229fb2721
23 changed files with 2062 additions and 12 deletions
+2
View File
@@ -20,6 +20,7 @@ import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerPresence } from "./routes/presence.ts";
import { registerSatellites } from "./routes/satellites.ts";
import { registerSession } from "./routes/session.ts";
import { registerWeather } from "./routes/weather.ts";
import { createServices } from "./services.ts";
@@ -45,6 +46,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
const services = createServices(config, app.log);
registerHealth(app, services);
registerFlights(app, services);
registerSatellites(app, services);
registerWeather(app, services);
registerMarkers(app, services);
registerOffices(app, services);
+51
View File
@@ -24,6 +24,7 @@ import type {
AuthMode,
FlightsSourceId,
MarkersSourceId,
SatellitesSourceId,
WeatherSourceId,
} from "../../src/server/wire.ts";
@@ -48,6 +49,17 @@ export interface FlightsConfig {
ttlSeconds: number;
}
export interface SatellitesConfig {
source: SatellitesSourceId;
/**
* Sent as the User-Agent. Not required — CelesTrak does not demand one the way
* NWS does — but sending it is the same courtesy, and it is what lets them
* mail an operator who is hammering them instead of just blocking the address.
*/
contact: string;
ttlSeconds: number;
}
export interface MarkersConfig {
source: MarkersSourceId;
/** Path to the JSON snapshot written by the sync oneshot. */
@@ -146,6 +158,7 @@ export interface Config {
publicMaxAge: number;
weather: WeatherConfig;
flights: FlightsConfig;
satellites: SatellitesConfig;
markers: MarkersConfig;
offices: { dir: string };
/**
@@ -166,6 +179,7 @@ export function loadConfig(env: Env = process.env): Config {
const weather = loadWeather(env, degraded);
const flights = loadFlights(env, degraded);
const satellites = loadSatellites(env, degraded);
const auth = loadAuth(env, degraded);
// After auth, because a marker feed with nobody able to sign in is worth a
// sentence and the sentence is only true once `mode` has finished demoting.
@@ -193,6 +207,7 @@ export function loadConfig(env: Env = process.env): Config {
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
weather,
flights,
satellites,
markers,
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
@@ -305,6 +320,42 @@ function radius(asked: number, degraded: string[]): number {
return clamped;
}
const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"];
/**
* Off by default, which is the opposite of the flight plan and is the right way
* round for this one.
*
* The simulated sky costs nothing and touches no network, so it can be the
* default. A satellite catalogue is somebody else's multi-megabyte file, fetched
* from a service run by one person, and a repo whose every clone starts pulling
* it the moment `npm run dev` finishes is a repo that has volunteered CelesTrak
* to host its onboarding. An operator who wants the layer says so.
*
* The zero-config boot check (`scripts/check-zero-config-boot.mjs`) depends on
* this too: a box with no environment at all must make no outbound requests.
*/
function loadSatellites(env: Env, degraded: string[]): SatellitesConfig {
const asked = str(env, "TERA_SATELLITES_SOURCE", "none");
let source = oneOf(asked, SATELLITE_SOURCES);
if (source === null) {
degraded.push(
`TERA_SATELLITES_SOURCE="${asked}" is not one of ${SATELLITE_SOURCES.join(", ")}; ` +
`serving no satellites.`,
);
source = "none";
}
return {
source,
// Falls back to the weather contact, because it is the same operator and the
// same courtesy, and making somebody type their email into two variables to
// get one User-Agent is a way of ensuring one of them stays empty.
contact: str(env, "TERA_SATELLITES_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")),
ttlSeconds: num(env, "TERA_SATELLITES_TTL", 21_600, degraded),
};
}
const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"];
/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */
+30 -5
View File
@@ -26,23 +26,50 @@ const DEFAULT_TIMEOUT_MS = 6000;
*/
const MAX_BODY_BYTES = 4 * 1024 * 1024;
export interface GetJsonOptions {
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: { accept: "application/json", ...opts.headers },
headers: { ...(opts.accept === undefined ? {} : { accept: opts.accept }), ...opts.headers },
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
if (!res.ok) return null;
@@ -59,9 +86,7 @@ export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promis
return null;
}
const text = await readBounded(res, maxBytes);
if (text === null) return null;
return JSON.parse(text) as T;
return await readBounded(res, maxBytes);
} catch {
return null;
}
+1
View File
@@ -48,6 +48,7 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
sources: {
weather: config.weather.source,
flights: config.flights.source,
satellites: config.satellites.source,
markers: config.markers.source,
},
auth: {
+27
View File
@@ -0,0 +1,27 @@
/**
* `GET /api/v1/satellites` — the catalogue, for everybody, everywhere.
*
* The one route here that takes no query at all, and the absence is the design
* rather than an omission. `/flights` and `/weather` both resolve a region
* because their answers are local; a satellite catalogue is not. See the note at
* the top of `satellites/index.ts` for the reasoning, and note the consequence:
* with no parameter there is no key space, so none of the amplification concerns
* that shape `regions.ts` apply. There is exactly one body and it is the same one
* for every caller.
*
* Publicly cacheable, for a TTL measured in hours. Orbital elements for objects
* broadcasting their position to anyone with a radio are not personal data, and
* this body does not vary by who asked.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { Services } from "../services.ts";
export function registerSatellites(app: FastifyInstance, services: Services): void {
app.get("/api/v1/satellites", async (req, reply) => {
const body = await services.satellites.current();
publicCache(req, reply, body.ttlSeconds);
return body;
});
}
+264
View File
@@ -0,0 +1,264 @@
/**
* celestrak.org — the general perturbations element sets.
*
* CelesTrak is Dr T.S. Kelso's, it has been running since 1985, and it is the
* canonical public mirror of the US Space Force's catalogue. The data are US
* Government works and are not copyrightable; the *service* is one person's
* bandwidth bill, which is the constraint that shapes everything below.
*
* ### The rate limit, and the one URL that gets around it honestly
*
* CelesTrak asks callers not to fetch more often than the data changes — element
* sets are regenerated a few times a day — and enforces it. A caller who polls
* `gp.php?GROUP=starlink` on a short interval gets a 403, or worse gets a 200
* whose body is the sentence "has not updated since", which parses as zero
* satellites and looks exactly like an empty sky.
*
* There are two answers and this module uses both:
*
* 1. **The supplemental feed for Starlink.** SpaceX publishes its own ephemeris,
* CelesTrak republishes it at `supplemental/sup-gp.php?FILE=starlink`, and
* that endpoint is not under the same ceiling as the primary group — which is
* the point of it existing. It is also *better data*: supplemental elements
* come from the operator rather than from radar, so a satellite that
* manoeuvred last night is in the right place this morning.
* 2. **A six-hour TTL**, which is longer than it looks like it needs to be and is
* still four fetches a day against a source that regenerates a handful of
* times. `upstream.ts` rule 3 does the rest: a failed fetch stamps the clock,
* so a 403 costs one request every six hours rather than one per viewer.
*
* The body that says "has not updated since" is caught explicitly rather than
* left to the parser, because rule 3 only helps if a soft failure is recognised
* as a failure. A 200 that yields no satellites and a 403 must reach the cache
* as the same thing, or the last good catalogue gets overwritten with nothing.
*
* ### What this deliberately does not do
*
* No key, no account, no `space-track.org`. Space-Track has the fuller catalogue
* and requires credentials and a login flow whose terms restrict redistribution —
* which would put a licence argument between this repo and a stranger who cloned
* it, and the acceptance test for the whole server is that a stranger with no
* keys gets something that works. See CONTRACT.md §5.1.
*
* And no User-Agent games. The identifying agent from `http.ts` goes out here
* exactly as it does to NWS. A source that would rather not serve us is entitled
* to that, and the response to a rate limit is a longer TTL, never a costume.
*/
import { getText, userAgent } from "../http.ts";
import type { SatelliteGroup, WireSatellite } from "../../../src/server/wire.ts";
export interface CelestrakLog {
warn(msg: string): void;
}
/**
* A parsed catalogue and the instant it was read.
*
* The timestamp is the *fetch* and not the element epoch, because a body can mix
* element sets from several days and the client's question is "how old is the
* freshest thing this server knows", which the fetch answers and the epochs do
* not.
*/
export interface SatelliteSnapshot {
/** Epoch milliseconds. */
fetchedAt: number;
satellites: WireSatellite[];
}
/**
* The groups fetched, in the order they are merged.
*
* This is a short list where Osiris-style aggregators run forty, and the length
* is the design. Every entry is one more request against a volunteer's server
* every six hours, and this layer draws satellites over a city — the debris
* fields and the cubesat catalogue would add eleven thousand objects nobody can
* see and nobody asked about, at the cost of a slower boot and a fatter cache.
*
* Order matters only for de-duplication: a NORAD id seen twice keeps the **first**
* set, so the supplemental Starlink feed must come before anything that might
* also carry a Starlink. See `merge`.
*/
const GROUPS: { url: string; group: SatelliteGroup }[] = [
// Supplemental first, and first for a reason — see the note above and `merge`.
{
url: "https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=starlink&FORMAT=tle",
group: "starlink",
},
{ url: group("stations"), group: "station" },
{ url: group("gps-ops"), group: "navigation" },
{ url: group("galileo"), group: "navigation" },
{ url: group("oneweb"), group: "comms" },
{ url: group("iridium-NEXT"), group: "comms" },
{ url: group("weather"), group: "weather" },
// The naked-eye catalogue: the hundred-odd objects bright enough to be worth
// drawing for somebody who might go outside and look for one.
{ url: group("visual"), group: "other" },
];
function group(name: string): string {
return `https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=tle`;
}
/**
* The ceiling on what is kept, after merging.
*
* Starlink alone is past eight thousand objects and the supplemental feed serves
* all of them. Every one costs a `SatRec` in every browser that opens the map,
* and SGP4 is not free — the client propagates the whole set on a timer. Six
* thousand is comfortably more than a city view can draw legibly and comfortably
* under what a laptop can propagate at 4 Hz, and going over it truncates rather
* than failing, because a partial sky beats no sky.
*
* The truncation is not silent: `createSatellitesService` logs the drop.
*/
export const MAX_SATELLITES = 6000;
/**
* TLE text is at most a few megabytes; the default 4 MB ceiling in `http.ts` is
* the right order but the Starlink supplemental feed alone runs to roughly
* 1.6 MB and the merged set is larger, so this is stated rather than inherited.
*/
const MAX_BODY_BYTES = 8 * 1024 * 1024;
/**
* Longer than the six-second default because these are big text bodies from one
* modestly-resourced server, and a timeout here is not an outage — it is one
* group missing from a merge that still returns the other seven.
*/
const TIMEOUT_MS = 20_000;
/**
* The whole catalogue, or `null` if **nothing at all** answered.
*
* A partial answer is a success: seven groups out of eight is a sky, and
* returning `null` for it would discard seven good fetches and serve the
* previous snapshot instead. Only a total failure is a failure, which is the
* same rule `upstream.ts` applies one level up.
*/
export async function fetchCatalogue(
contact: string,
log: CelestrakLog,
): Promise<SatelliteSnapshot | null> {
const headers = { "user-agent": userAgent(contact) };
// Concurrently, because they are independent and eight sequential 20-second
// timeouts is a two-and-a-half-minute worst case on a route somebody is
// waiting on. Eight parallel requests every six hours is not a flood.
const fetched = await Promise.all(
GROUPS.map(async (spec) => {
const text = await getText(spec.url, {
headers,
timeoutMs: TIMEOUT_MS,
maxBytes: MAX_BODY_BYTES,
});
if (text === null) {
log.warn(`satellites:celestrak: ${spec.url} did not answer`);
return [];
}
// The decision is the parse, not a guess about the body — see `whyEmpty`.
const parsed = parseTle(text, spec.group);
if (parsed.length === 0) {
log.warn(`satellites:celestrak: ${spec.url} ${whyEmpty(text)}`);
return [];
}
return parsed;
}),
);
const satellites = merge(fetched);
if (satellites.length === 0) return null;
return { fetchedAt: Date.now(), satellites };
}
/**
* Why a 200 yielded no element sets, for the log line and for nothing else.
*
* The *decision* is made by the parse: a body that produces no element sets did
* not answer, whatever it contained. That is the honest test and it needs no
* heuristics — an earlier version of this guessed from the body length instead,
* with a 200-byte floor, and a group holding one satellite is about 160 bytes.
* The guard rejected legitimate answers, which is exactly the failure mode it
* was written to prevent, aimed the other way.
*
* What the body *said* still matters to a human, though: "CelesTrak is telling
* us to slow down" calls for a longer TTL and "the group is genuinely empty"
* calls for editing `GROUPS`, and one warning line that distinguishes them saves
* an operator the trip.
*/
function whyEmpty(text: string): string {
if (text.includes("has not updated since")) {
return "was refused for asking too often (CelesTrak answers this with a 200); " +
"raise TERA_SATELLITES_TTL";
}
if (text.trim() === "") return "answered with an empty body";
return "answered with something that holds no element sets";
}
/**
* Three-line TLE text into element sets.
*
* The format is fixed-width and unforgiving, and the parse leans on that: a line
* starting `1 ` followed by a line starting `2 ` is an element set, and whatever
* came immediately before it is the name. That is enough to handle both the
* three-line form CelesTrak serves and the bare two-line form some mirrors do,
* without a mode flag.
*
* Anything that does not fit is skipped rather than throwing. This function is
* reading somebody else's file over the network; the one thing it must not do is
* take the server down because a line was short.
*/
export function parseTle(text: string, group: SatelliteGroup): WireSatellite[] {
const lines = text.split("\n").map((line) => line.trimEnd());
const out: WireSatellite[] = [];
for (let i = 0; i < lines.length - 1; i += 1) {
const line1 = lines[i] ?? "";
const line2 = lines[i + 1] ?? "";
if (!line1.startsWith("1 ") || !line2.startsWith("2 ")) continue;
const noradId = Number.parseInt(line1.slice(2, 7).trim(), 10);
if (!Number.isInteger(noradId) || noradId <= 0) continue;
// The name is the preceding line when there is one that is not itself an
// element line. A catalogue number is a poor name but it is a true one, and
// it beats dropping the object.
const previous = i > 0 ? (lines[i - 1] ?? "").trim() : "";
const named = previous !== "" && !previous.startsWith("1 ") && !previous.startsWith("2 ");
// Some feeds prefix the name line with "0 ", per the original TLE spec.
const name = named ? previous.replace(/^0\s+/, "") : `NORAD ${noradId}`;
out.push({ noradId, name, group, line1, line2 });
// The element lines are consumed as a pair; the loop's own increment covers
// the second, so one extra step here lands on the line after it.
i += 1;
}
return out;
}
/**
* One list, first set per NORAD id wins, capped at `MAX_SATELLITES`.
*
* First-wins is what makes the group ordering in `GROUPS` load-bearing: a
* Starlink appearing in both the supplemental feed and, say, `last-30-days`
* should keep the operator's ephemeris and the `starlink` display bucket, and it
* does so purely because the supplemental feed is fetched first. Last-wins would
* silently re-bucket half the constellation as `other` and nobody would notice
* until the colours looked wrong.
*/
function merge(groups: WireSatellite[][]): WireSatellite[] {
const seen = new Set<number>();
const out: WireSatellite[] = [];
for (const list of groups) {
for (const sat of list) {
if (seen.has(sat.noradId)) continue;
seen.add(sat.noradId);
out.push(sat);
if (out.length >= MAX_SATELLITES) return out;
}
}
return out;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Which satellites this box serves — which is either all of them or none.
*
* The shortest service in the server, and the shape is worth noticing because of
* what it *lacks*. There is no region parameter. Flights and weather are both
* keyed per region because an aeroplane at 3,000 m over Oakland is not visible
* from Long Beach and a rain shower is not either; a satellite at 550 km is
* above the horizon for a circle roughly two thousand kilometres across, and the
* two cities this repo ships are six hundred apart. One catalogue covers both,
* and it would cover a continent.
*
* So the cache has exactly one key, the client is handed the whole catalogue, and
* **the client decides what is above its own horizon** — which it must do anyway,
* continuously, because the answer changes every few seconds as things move. A
* server that filtered by region would be doing a worse version of a calculation
* the client cannot avoid doing, and would break the property that makes this
* cacheable at all: one body, every viewer, no variation.
*
* ### Six hours, and why that is not laziness
*
* `flights/index.ts` clamps its live TTL between 5 and 15 seconds. This one
* defaults to six hours and is floored at one, and both numbers are chosen the
* same way: how often does the upstream change, and what does asking cost?
*
* CelesTrak regenerates element sets a handful of times a day. A TLE stays
* accurate to within a few kilometres for days either side of its epoch — that
* is the entire design of the format. Fetching it every minute would produce
* identical bytes, at the expense of one person's bandwidth, and would get this
* box rate-limited into serving an empty sky. The floor exists because
* `TERA_SATELLITES_TTL=0` reads like "as fresh as possible" and means "one
* multi-megabyte fetch of eight groups per inbound request", which is the same
* trap `TERA_FLIGHTS_TTL=0` used to be.
*/
import { fetchCatalogue, MAX_SATELLITES, type SatelliteSnapshot } from "./celestrak.ts";
import { createUpstream } from "../upstream.ts";
import type { Config } from "../config.ts";
import type { SatellitesBody } from "../../../src/server/wire.ts";
export interface SatellitesService {
current(): Promise<SatellitesBody>;
}
export interface SatellitesLog {
warn(msg: string): void;
}
/** See the note above. One catalogue, one key. */
const CATALOGUE_KEY = "catalogue";
/**
* An hour is already twelve times more often than the data changes. Below that,
* a caller is spending somebody else's bandwidth to receive the same bytes.
*/
const MIN_TTL_SECONDS = 3600;
/**
* The body a box with no satellite source serves: real and empty, never invented.
* See the note on `SatellitesBody` in `wire.ts` for why there is no synthetic
* constellation to fall back to the way there is a synthetic sky.
*
* `fetchedAt` is the Unix epoch rather than "now", because "now" would be a
* claim that this catalogue was fetched a moment ago. It was never fetched.
*/
function emptyBody(ttlSeconds: number): SatellitesBody {
return {
source: "none",
fetchedAt: new Date(0).toISOString(),
satellites: [],
ttlSeconds,
};
}
export function createSatellitesService(config: Config, log: SatellitesLog): SatellitesService {
const { source, contact, ttlSeconds } = config.satellites;
const ttl = Math.max(MIN_TTL_SECONDS, ttlSeconds);
const upstream = createUpstream<SatelliteSnapshot>({
label: "satellites:celestrak",
ttlSeconds: ttl,
log,
});
// Once, not per response: a truncated catalogue is a property of the
// deployment and the feed, and logging it on every request would turn one
// useful sentence into a wall of the same sentence.
let warnedTruncated = false;
return {
async current(): Promise<SatellitesBody> {
if (source === "none") return emptyBody(ttl);
const snapshot = await upstream.get(CATALOGUE_KEY, () => fetchCatalogue(contact, log));
if (snapshot === null) return emptyBody(ttl);
if (snapshot.satellites.length >= MAX_SATELLITES && !warnedTruncated) {
warnedTruncated = true;
log.warn(
`satellites:celestrak: the merged catalogue hit the ${MAX_SATELLITES}-object ` +
`ceiling and was truncated; later groups in celestrak.ts are missing from ` +
`the served sky.`,
);
}
return {
source,
fetchedAt: new Date(snapshot.fetchedAt).toISOString(),
satellites: snapshot.satellites,
ttlSeconds: ttl,
attribution: ["Orbital element sets from CelesTrak"],
};
},
};
}
+3
View File
@@ -12,6 +12,7 @@ import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
import { createSatellitesService, type SatellitesService } from "./satellites/index.ts";
import { createWeatherService, type WeatherService } from "./weather/index.ts";
import type { Config } from "./config.ts";
@@ -19,6 +20,7 @@ export interface Services {
config: Config;
weather: WeatherService;
flights: FlightsService;
satellites: SatellitesService;
markers: MarkerStore;
offices: OfficeStore;
presence: PresenceStore;
@@ -37,6 +39,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
config,
weather: createWeatherService(config, log),
flights: createFlightsService(config, log),
satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log),
offices: createOfficeStore(config.offices.dir),
presence: createPresenceStore(config.presence.dir),
+284
View File
@@ -0,0 +1,284 @@
/**
* The catalogue, and the three ways it is allowed to be wrong.
*
* CelesTrak is one person's bandwidth and it rate-limits, so — as with the ADS-B
* tests — most of this file is about **how often this server calls out** rather
* than about what comes back. The difference is the failure mode being guarded
* against: adsb.lol answers a caller who is asking too often with a status code,
* and CelesTrak answers with **200 and a sentence**. A soft failure that parses
* as an empty catalogue would overwrite the last good sky with nothing and look
* like a clear night — so a group that yields no element sets is treated as a
* group that did not answer, and that is worth a test of its own.
*
* The last group of tests is about the shape of the answer, and specifically
* about `merge` keeping the *first* element set per NORAD id. That ordering is
* what puts the operator's supplemental Starlink ephemeris ahead of a radar one
* and keeps the display bucket right; reverse it and half the constellation
* quietly re-colours itself.
*/
import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { parseTle } from "../satellites/celestrak.ts";
import type { SatellitesBody } from "../../../src/server/wire.ts";
const realFetch = globalThis.fetch;
let calls: string[] = [];
/** Every User-Agent this server actually put on the wire, in order. */
let agents: string[] = [];
let feedIsUp = true;
/** Served instead of a real TLE body, for the soft-failure tests. */
let feedOverride: string | undefined = undefined;
/**
* A plausible three-line element set. The numbers are ISS's published TLE
* checksummed shape rather than a hand-mangled one, because `parseTle` reads
* fixed columns and a shortened line would exercise a path no real feed takes.
*/
const ISS = [
"ISS (ZARYA)",
"1 25544U 98067A 26037.51782528 -.00002182 00000-0 -11606-4 0 2927",
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
].join("\n");
const STARLINK = [
"STARLINK-1007",
"1 44713U 19074A 26037.54166667 .00002182 00000-0 16538-3 0 9995",
"2 44713 53.0538 156.7285 0001367 86.7419 273.3728 15.06391223 12345",
].join("\n");
function feed(url: string): string | undefined {
if (!feedIsUp) return undefined;
if (feedOverride !== undefined) return feedOverride;
if (url.includes("FILE=starlink")) return STARLINK;
if (url.includes("GROUP=stations")) return ISS;
// Every other configured group answers with nothing usable, which is a
// partial-merge case and must still produce a sky.
return "";
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = String(input);
calls.push(url);
agents.push(String((init?.headers as Record<string, string> | undefined)?.["user-agent"] ?? ""));
const body = feed(url);
if (body === undefined) return new Response("nope", { status: 503 });
return new Response(body, { status: 200, headers: { "content-type": "text/plain" } });
}) as unknown as typeof globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
beforeEach(() => {
calls = [];
agents = [];
feedIsUp = true;
feedOverride = undefined;
});
function appWith(env: Record<string, string>) {
const config = loadConfig(env);
config.logLevel = "silent";
return buildApp(config);
}
const celestrakEnv = { TERA_SATELLITES_SOURCE: "celestrak" };
async function get(app: ReturnType<typeof buildApp>): Promise<SatellitesBody> {
const res = await app.inject({ method: "GET", url: "/api/v1/satellites" });
assert.equal(res.statusCode, 200);
return res.json() as SatellitesBody;
}
describe("a box with no satellite source", () => {
it("serves an empty catalogue rather than an invented one", async () => {
const app = appWith({});
after(() => app.close());
const body = await get(app);
assert.equal(body.source, "none");
assert.deepEqual(body.satellites, []);
});
it("makes no outbound request at all", async () => {
const app = appWith({});
after(() => app.close());
await get(app);
await get(app);
assert.deepEqual(calls, []);
});
});
describe("the CelesTrak source", () => {
it("takes the supplemental Starlink feed, not the rate-limited group", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
await get(app);
const starlink = calls.filter((url) => url.includes("starlink"));
assert.equal(starlink.length, 1);
assert.match(starlink[0] ?? "", /supplemental\/sup-gp\.php\?FILE=starlink/);
// The primary group is the one that 403s under polling. It must not be here.
assert.equal(
calls.some((url) => url.includes("GROUP=starlink")),
false,
);
});
it("identifies the operator, rather than disguising the caller", async () => {
const app = appWith({ ...celestrakEnv, TERA_SATELLITES_CONTACT: "ops@example.com" });
after(() => app.close());
await get(app);
assert.ok(agents.length > 0, "nothing was fetched");
for (const agent of agents) {
assert.match(agent, /^tera-api /);
assert.match(agent, /ops@example\.com/);
}
});
it("falls back to the weather contact rather than sending an empty one", async () => {
const config = loadConfig({ ...celestrakEnv, TERA_WEATHER_CONTACT: "ops@example.com" });
assert.equal(config.satellites.contact, "ops@example.com");
});
it("asks once per TTL however many callers turn up", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
await Promise.all([get(app), get(app), get(app), get(app)]);
// One pass over the group list, not four.
const starlink = calls.filter((url) => url.includes("starlink"));
assert.equal(starlink.length, 1);
});
it("floors the TTL at an hour, whatever the environment asked for", async () => {
const config = loadConfig({ ...celestrakEnv, TERA_SATELLITES_TTL: "0" });
const app = appWith({ ...celestrakEnv, TERA_SATELLITES_TTL: "0" });
after(() => app.close());
// The config keeps what was asked; the service is what enforces the floor,
// so the assertion belongs on the body the client is handed.
assert.equal(config.satellites.ttlSeconds, 0);
const body = await get(app);
assert.ok(body.ttlSeconds >= 3600, `ttlSeconds was ${body.ttlSeconds}`);
});
it("serves a partial merge rather than nothing when a group goes quiet", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
// Six of the eight groups answer with an empty body in the fixture above.
const body = await get(app);
assert.equal(body.source, "celestrak");
assert.equal(body.satellites.length, 2);
});
it("reports the fetch time, so a stale catalogue can be recognised as one", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
const body = await get(app);
assert.notEqual(body.fetchedAt, new Date(0).toISOString());
assert.ok(Date.now() - Date.parse(body.fetchedAt) < 60_000);
});
it("credits CelesTrak", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
const body = await get(app);
assert.match(body.attribution?.[0] ?? "", /CelesTrak/);
});
});
/**
* The failure this file exists for.
*
* A 200 carrying "has not updated since" is CelesTrak's way of saying no. Parsed
* naively it is an empty catalogue, and an empty catalogue cached for six hours
* is a sky that stays dark until somebody restarts the box.
*/
describe("a rate-limited fetch", () => {
it("is treated as a failure, not as an empty sky", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
// Warm the cache with a good answer first.
const good = await get(app);
assert.equal(good.satellites.length, 2);
feedOverride =
"The requested group has not updated since the last request. Please try again later.";
// Force the TTL to have elapsed by standing up a second app against the same
// fixture — a fresh service, so this is really "what does one bad fetch do".
const cold = appWith(celestrakEnv);
after(() => cold.close());
const body = await get(cold);
// Nothing was ever cached in this instance, so the honest answer is the
// empty one — but it must be reported as `source: none`-shaped emptiness
// rather than as a successful catalogue of zero satellites.
assert.equal(body.satellites.length, 0);
assert.equal(body.fetchedAt, new Date(0).toISOString());
});
it("does not overwrite the last good catalogue", async () => {
const app = appWith({ ...celestrakEnv, TERA_SATELLITES_TTL: "3600" });
after(() => app.close());
const good = await get(app);
assert.equal(good.satellites.length, 2);
feedOverride = "has not updated since";
const again = await get(app);
assert.equal(again.satellites.length, 2, "the last good sky must survive a refused fetch");
});
});
describe("parsing element sets", () => {
it("reads the three-line form CelesTrak serves", () => {
const parsed = parseTle(ISS, "station");
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.noradId, 25544);
assert.equal(parsed[0]?.name, "ISS (ZARYA)");
assert.equal(parsed[0]?.group, "station");
});
it("carries the two lines through unmodified, because SGP4 reads columns", () => {
const parsed = parseTle(ISS, "station");
const lines = ISS.split("\n");
assert.equal(parsed[0]?.line1, lines[1]);
assert.equal(parsed[0]?.line2, lines[2]);
});
it("names an object by its catalogue number when the feed gave no name line", () => {
const bare = ISS.split("\n").slice(1).join("\n");
const parsed = parseTle(bare, "other");
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.name, "NORAD 25544");
});
it("strips the leading zero some feeds put on the name line", () => {
const parsed = parseTle(`0 ISS (ZARYA)\n${ISS.split("\n").slice(1).join("\n")}`, "station");
assert.equal(parsed[0]?.name, "ISS (ZARYA)");
});
it("skips what it cannot read instead of throwing", () => {
const parsed = parseTle("nonsense\nmore nonsense\n\n", "other");
assert.deepEqual(parsed, []);
});
it("reads every set in a multi-object body", () => {
const parsed = parseTle(`${ISS}\n${STARLINK}`, "other");
assert.equal(parsed.length, 2);
assert.deepEqual(
parsed.map((s) => s.noradId),
[25544, 44713],
);
});
});