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

271 lines · 15437 bytes

16 symbols in this file
  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}