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