server/src/index.ts
318 lines · 18634 bytes
1import { send, type ApnsConfig } from "./apns";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { compose, fetchGroups, fetchSupGP, fitsLaunch, supgpFile, supgpLaunches, type LaunchElements, type LaunchRef, type Row } from "./celestrak";
4import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live";
5import { fetchLaunches, PREVIOUS_URL, UPCOMING_URL, type Launch } from "./ll2";
6import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news";
7import { PRIVACY_HTML } from "./privacy";
8import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
9
10interface Env {
11 DB: D1Database;
12 APNS_KEY_ID: string;
13 APNS_TEAM_ID: string;
14 APNS_TOPIC: string;
15 APNS_KEY: string;
16 /** Optional Launch Library 2 API key. */
17 LL2_API_KEY?: string;
18}
19
20interface DeviceRequest {
21 token: string;
22 environment: "sandbox" | "production";
23 band: number;
24 utcOffsetMinutes: number;
25 aurora?: boolean;
26 news?: boolean;
27}
28
29const LAUNCH_CRON = "*/10 * * * *";
30const SATELLITE_CRON = "7 */2 * * *";
31/** Launches are kept this long after NET, so a launch page and its first pass outlive liftoff. */
32const RETENTION_MS = 14 * 86400_000;
33
34const json = (body: unknown, status = 200, maxAge?: number) =>
35 Response.json(body, { status, headers: maxAge && status === 200 ? { "cache-control": `public, max-age=${maxAge}` } : {} });
36
37/** How long clients may reuse each GET response, matched to how often its source is polled. */
38export const MAX_AGE = { today: 60, launches: 300, satellites: 3600, live: 300 } as const;
39
40function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
41 return (
42 typeof d.token === "string" && /^[0-9a-f]{64,200}$/i.test(d.token) &&
43 (d.environment === "sandbox" || d.environment === "production") &&
44 Number.isInteger(d.band) && Math.abs(d.band!) <= 90 && d.band! % 5 === 0 &&
45 Number.isInteger(d.utcOffsetMinutes) && Math.abs(d.utcOffsetMinutes!) <= 14 * 60
46 );
47}
48
49async function today(env: Env, now: Date): Promise<Response> {
50 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'swpc' AND ok = 1").first<{ fetched_at: string; body: string }>();
51 if (!row) return json({ spaceWeather: null }, 200, MAX_AGE.today);
52 const w = JSON.parse(row.body) as SpaceWeather;
53 w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) }));
54 return json({
55 spaceWeather: { kpNow: w.kpNow, observedAt: w.observedAt, kpNext24h: next24h(w, now), fetchedAt: row.fetched_at },
56 }, 200, MAX_AGE.today);
57}
58
59export default {
60 async fetch(request: Request, env: Env): Promise<Response> {
61 const url = new URL(request.url);
62 const now = new Date();
63
64 if (request.method === "GET" && url.pathname === "/healthz") {
65 const rows = await env.DB.prepare("SELECT feed, fetched_at, ok FROM feed_snapshots").all();
66 return json({ ok: true, feeds: rows.results });
67 }
68 if (request.method === "GET" && url.pathname === "/privacy")
69 return new Response(PRIVACY_HTML, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" } });
70 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now);
71 if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now);
72 if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now);
73 if (request.method === "GET" && url.pathname === "/v1/satellites") {
74 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'celestrak' AND ok = 1").first<{ fetched_at: string; body: string }>();
75 return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] }, 200, MAX_AGE.satellites);
76 }
77
78 if (request.method === "POST" && url.pathname === "/v1/devices") {
79 const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>;
80 if (!validDevice(d)) return json({ error: "invalid device" }, 400);
81 await env.DB.prepare(
82 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, news, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
83 ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, news = ?6, last_seen = ?7`,
84 ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, d.news === true ? 1 : 0, now.toISOString()).run();
85 return json({ ok: true });
86 }
87
88 const test = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})\/test$/i);
89 if (request.method === "POST" && test) return sendTest(env, test[1].toLowerCase(), now);
90
91 const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i);
92 if (request.method === "DELETE" && del) {
93 await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run();
94 return json({ ok: true });
95 }
96
97 return json({ error: "not found" }, 404);
98 },
99
100 async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
101 const now = new Date(controller.scheduledTime);
102 // Upcoming launches every 10 minutes and previous launches hourly: 7 of the 15 anonymous Launch Library 2 requests per hour.
103 if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)]));
104 else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now));
105 else ctx.waitUntil(pollSpaceWeather(env, now));
106 },
107} satisfies ExportedHandler<Env>;
108
109/** One test push to a registered device, at most once per 10 minutes per device. */
110async function sendTest(env: Env, token: string, now: Date): Promise<Response> {
111 const device = await env.DB.prepare("SELECT environment FROM devices WHERE token = ?1").bind(token).first<{ environment: string }>();
112 if (!device) return json({ error: "not registered" }, 404);
113 const window = `${token}:${now.toISOString().slice(0, 15)}`; // 10-minute bucket
114 const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('test', 0, ?1, ?2)")
115 .bind(window, now.toISOString()).run();
116 if (!claimed.meta.changes) return json({ error: "try again in a few minutes" }, 429);
117 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
118 const payload = { aps: { alert: { title: "Moonrise test alert", body: "Alerts reach this device." }, sound: "default" } };
119 const r = await send(config, token, payload, device.environment === "sandbox");
120 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(token).run();
121 return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502);
122}
123
124/**
125 * The live event at `?at=` (default now) and its geocentric ecliptic trajectory up to then:
126 * points are [unix seconds, x, y, z] km. The cached trajectory is refreshed from Horizons at most
127 * every 10 minutes while the event runs.
128 */
129async function live(env: Env, url: URL, now: Date): Promise<Response> {
130 const atParam = url.searchParams.get("at");
131 const at = atParam ? new Date(atParam) : now;
132 if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400);
133 const event = activeEvent(at);
134 if (!event) return json({ event: null }, 200, MAX_AGE.live);
135
136 const key = `live:${event.id}`;
137 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = ?1 AND ok = 1").bind(key).first<{ fetched_at: string; body: string }>();
138 let points: number[][] = row ? JSON.parse(row.body) : [];
139 const covered = (points.at(-1)?.[0] ?? 0) * 1000;
140 const wanted = Math.min(now.getTime(), Date.parse(event.end)) - 2 * STEP_MINUTES * 60_000;
141 const stale = !row || points.length === 0 || now.getTime() - Date.parse(row.fetched_at) > 10 * 60_000;
142 if (covered < wanted && stale) {
143 try {
144 const fresh = await fetchTrajectory(event, now);
145 if (fresh.length === 0) throw new Error("no Horizons data");
146 points = fresh;
147 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES (?1, ?2, 1, ?3) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?2, ok = 1, body = ?3")
148 .bind(key, now.toISOString(), JSON.stringify(points)).run();
149 } catch (e) {
150 console.error("horizons", String(e));
151 }
152 }
153 return json({ event, at: at.toISOString(), points: points.filter((p) => p[0] * 1000 <= at.getTime()) }, 200, MAX_AGE.live);
154}
155
156async function launches(env: Env, now: Date): Promise<Response> {
157 const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
158 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>();
159 const recent = await env.DB.prepare("SELECT * FROM launches WHERE net < ?1 AND net >= ?2 ORDER BY net DESC LIMIT 20")
160 .bind(since, new Date(now.getTime() - RETENTION_MS).toISOString()).all<Record<string, unknown>>();
161 const fetched = await env.DB.prepare("SELECT fetched_at FROM feed_snapshots WHERE feed = 'll2' AND ok = 1").first<{ fetched_at: string }>();
162 const els = await env.DB.prepare("SELECT body FROM feed_snapshots WHERE feed = 'launch-elements' AND ok = 1").first<{ body: string }>();
163 const elements: Record<string, LaunchElements> = els ? JSON.parse(els.body) : {};
164 const shape = (r: Record<string, unknown>) => ({
165 id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end,
166 status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket,
167 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
168 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
169 elements: elements[String(r.id)]?.elements ?? [], elementsSource: elements[String(r.id)]?.source ?? null,
170 });
171 return json({
172 fetchedAt: fetched?.fetched_at ?? null,
173 launches: rows.results.map(shape),
174 recent: recent.results.map(shape),
175 }, 200, MAX_AGE.launches);
176}
177
178// Failures are recorded under "<feed>:error" so the last good snapshot and its time survive.
179async function pollLaunches(env: Env, now: Date): Promise<void> {
180 let list: Launch[];
181 try {
182 list = await fetchLaunches(UPCOMING_URL, env.LL2_API_KEY);
183 } catch (e) {
184 console.error("ll2", String(e));
185 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
186 return;
187 }
188 // Previous launches on the first run of each hour: designators and outcomes arrive after a launch leaves the upcoming list.
189 if (now.getUTCMinutes() < 10) {
190 try {
191 list = [...list, ...(await fetchLaunches(PREVIOUS_URL, env.LL2_API_KEY))];
192 } catch (e) {
193 console.error("ll2 previous", String(e));
194 }
195 }
196 const upsert = env.DB.prepare(
197 `INSERT INTO launches (id, name, net, window_start, window_end, status, status_name, provider, rocket, mission, mission_type, mission_description, orbit, pad, location, pad_latitude, pad_longitude, last_updated, launch_designator)
198 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
199 ON CONFLICT (id) DO UPDATE SET name = ?2, net = ?3, window_start = ?4, window_end = ?5, status = ?6, status_name = ?7, provider = ?8, rocket = ?9,
200 mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18,
201 launch_designator = COALESCE(?19, launch_designator)`,
202 );
203 await env.DB.batch([
204 ...list.map((l) => upsert.bind(l.id, l.name, l.net, l.windowStart, l.windowEnd, l.status, l.statusName, l.provider, l.rocket, l.mission, l.missionType, l.missionDescription, l.orbit, l.pad, l.location, l.padLatitude, l.padLongitude, l.lastUpdated, l.designator)),
205 env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - RETENTION_MS).toISOString()),
206 env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 1) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1").bind(now.toISOString()),
207 ]);
208}
209
210async function pollSatellites(env: Env, now: Date): Promise<void> {
211 let groups: { visual: Row[]; recent: Row[] };
212 try {
213 groups = await fetchGroups();
214 } catch (e) {
215 console.error("celestrak", String(e));
216 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('celestrak:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
217 return;
218 }
219 const launches = await env.DB.prepare("SELECT id, mission, provider, net, launch_designator AS designator FROM launches WHERE net >= ?1")
220 .bind(new Date(now.getTime() - RETENTION_MS).toISOString()).all<LaunchRef>();
221 const supgp = await pollSupGP(env, launches.results, now);
222 const fitting = Object.fromEntries(launches.results.map((l) => [l.id, fitsLaunch(supgp[l.id] ?? [], l.net, now)]));
223 const out = compose(groups.visual, groups.recent, launches.results, fitting, now);
224 const save = env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES (?1, ?2, 1, ?3) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?2, ok = 1, body = ?3");
225 await env.DB.batch([
226 save.bind("celestrak", now.toISOString(), JSON.stringify(out.satellites)),
227 save.bind("launch-elements", now.toISOString(), JSON.stringify(out.launches)),
228 ]);
229}
230
231/**
232 * SupGP sets by launch id, fetched after the GP groups and kept in their own snapshot: on any failure no more
233 * requests are made and the last good sets are used, so the GP data is never lost to a SupGP problem.
234 */
235async function pollSupGP(env: Env, launches: LaunchRef[], now: Date): Promise<Record<string, Row[]>> {
236 const sets: Record<string, Row[]> = {};
237 try {
238 for (const l of supgpLaunches(launches, now)) sets[l.id] = await fetchSupGP(supgpFile(l.mission)!);
239 } catch (e) {
240 console.error("supgp", String(e));
241 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('supgp:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
242 const last = await env.DB.prepare("SELECT body FROM feed_snapshots WHERE feed = 'supgp' AND ok = 1").first<{ body: string }>();
243 return last ? JSON.parse(last.body) : {};
244 }
245 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('supgp', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2")
246 .bind(now.toISOString(), JSON.stringify(sets)).run();
247 return sets;
248}
249
250async function pollSpaceWeather(env: Env, now: Date): Promise<void> {
251 let w: SpaceWeather;
252 try {
253 w = await fetchSpaceWeather();
254 } catch (e) {
255 console.error("swpc", String(e));
256 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('swpc:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
257 return;
258 }
259 await env.DB.prepare(
260 "INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('swpc', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2",
261 ).bind(now.toISOString(), JSON.stringify(w)).run();
262 await sendAuroraAlerts(env, w, now);
263}
264
265async function sendAuroraAlerts(env: Env, w: SpaceWeather, now: Date): Promise<void> {
266 const kp = actionableKp(w.kpNow, w.forecast, now);
267 const bandRows = await env.DB.prepare("SELECT DISTINCT band FROM devices WHERE aurora = 1").all<{ band: number }>();
268 const bands = alertBands(kp, bandRows.results.map((r) => r.band));
269 if (!bands.length || !env.APNS_KEY) return;
270
271 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
272 const event = eventKey(now);
273 for (const band of bands) {
274 const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('aurora', ?1, ?2, ?3)")
275 .bind(band, event, now.toISOString()).run();
276 if (!claimed.meta.changes) continue;
277
278 const devices = await env.DB.prepare("SELECT token, environment, utc_offset_minutes FROM devices WHERE aurora = 1 AND band = ?1")
279 .bind(band).all<{ token: string; environment: string; utc_offset_minutes: number }>();
280 for (const d of devices.results) {
281 if (!isNight(now, d.utc_offset_minutes)) continue;
282 const payload = { aps: { alert: { title: "Aurora possible tonight", body: `Kp ${kp.toFixed(1)}. Look toward the ${band >= 0 ? "north" : "south"} after dark.` }, sound: "default" } };
283 const r = await send(config, d.token, payload, d.environment === "sandbox");
284 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(d.token).run();
285 else if (r.status !== 200) console.error("apns", r.status, r.reason);
286 }
287 }
288}
289
290/** The daily news digest, sent at 09:00 local time to devices that opted in, once per local day. */
291async function sendNewsDigests(env: Env, now: Date): Promise<void> {
292 if (!env.APNS_KEY) return;
293 const rows = await env.DB.prepare("SELECT token, environment, utc_offset_minutes FROM devices WHERE news = 1")
294 .all<{ token: string; environment: string; utc_offset_minutes: number }>();
295 const due = rows.results.filter((d) => digestDue(now, d.utc_offset_minutes));
296 if (!due.length) return;
297
298 let payload;
299 try {
300 const headlines = pickHeadlines(await fetchLatest(), now);
301 if (!headlines.length) return;
302 payload = digestPayload(headlines);
303 } catch (e) {
304 console.error("snapi", String(e));
305 return;
306 }
307
308 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
309 for (const d of due) {
310 const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('news', 0, ?1, ?2)")
311 .bind(`${d.token}:${localDate(now, d.utc_offset_minutes)}`, now.toISOString()).run();
312 if (!claimed.meta.changes) continue;
313 const r = await send(config, d.token, payload, d.environment === "sandbox");
314 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(d.token).run();
315 else if (r.status !== 200) console.error("apns", r.status, r.reason);
316 }
317 await env.DB.prepare("DELETE FROM alerts_sent WHERE kind = 'news' AND sent_at < ?1").bind(new Date(now.getTime() - 3 * 86400_000).toISOString()).run();
318}