krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

server/src/index.ts

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/server/src/index.ts history · blame · raw

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}