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

Commit b690d442b1

b690d442b143bde1f3b8082f103825fd5abb3f19

parent: 2e552960dd

Unsigned

cmc <hello@cleberg.net> · 2026-10-19 06:36 UTC

Performance pass (!50)

Fixes from the performance pass. Measured on the iPhone 17 Pro simulator (release build) and the Mac.

1. Library images are decoded at display size (ImageIO thumbnails: 260 pt cards, 640 pt featured, 900 pt page header) and kept in a 60 MB NSCache. Memory after opening the Library: 209 MB → 56 MB. Same loader for Mars and news thumbnails.
2. URLCache.shared set to 24 MB memory / 400 MB disk at launch (default 512 KB / ~20 MB; responses over 5% of disk were never stored, which excluded most Library photos).
3. 3D view: surface maps load asynchronously and are swapped in as they arrive, instead of ~0.6 s of synchronous loading on the main thread when the view opens.
4. Library search uses a folded-text index built once and no longer does an O(n²) membership check: 62 → 10 ms per keystroke (Mac, release).
5. =LibraryPage.related()= is cached per page: 18 ms → under 1 µs on repeat.
6. =Catalog.starsByHR= is built once; constellation thumbnails and the Library's tonight picks used to rebuild a 9,000-star dictionary each time.
7. Satellite passes (~0.7 s on a Mac for 158 objects) are reused for an hour unless the place or orbits change.
8. Feeds are kept in memory between tab switches: Sun 5 min, launches 5 min, news 10 min, Mars images 30 min. Pull to refresh still forces a fetch.
9. Backend GET responses send Cache-Control: today 60 s, launches 300 s, satellites 3600 s, live 300 s. Deployed.

Not changed: launch time. On the simulator under Instruments it is dominated by system and SwiftUI start-up, not app code; it needs measuring on a device.

Layout: unified · split

App/Sources/LaunchesScreen.swift +5 −2
@@ -49,7 +49,7 @@ struct LaunchesScreen: View {
4949 }
5050 .foregroundStyle(ink)
5151 .background(background.ignoresSafeArea())
52 .refreshable { if router.showNews { newsReload += 1 } else { await load() } }
52 .refreshable { if router.showNews { newsReload += 1 } else { await load(force: true) } }
5353 .task { await load() }
5454 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
5555 }
@@ -71,10 +71,13 @@ struct LaunchesScreen: View {
7171 }
7272 }
7373
74 private func load() async {
74 /// The backend polls every 10 minutes; a visit within 5 minutes of the last load reuses it.
75 private func load(force: Bool = false) async {
7576 guard let apiBase else { error = "No backend configured for this build."; return }
77 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
7678 do {
7779 list = try await LaunchFeed.fetch(base: apiBase)
80 if let list { FeedMemo.set("launches", list) }
7881 error = nil
7982 } catch {
8083 self.error = "Couldn't load launches. Pull to retry."
App/Sources/LibraryScreen.swift +5 −17
@@ -39,10 +39,9 @@ private struct TonightPicks: Sendable {
3939 }
4040 // Constellations whose stars are on average more than 25° up two hours into the night.
4141 let when = t.start.addingTimeInterval(2 * 3600)
42 let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) })
4342 let scored: [(LibraryPage, Double)] = Catalog.constellations.compactMap { c in
4443 guard let page = Library.page(constellation: c.id) else { return nil }
45 let stars = Set(c.lines.joined()).compactMap { byHR[$0] }
44 let stars = Set(c.lines.joined()).compactMap { Catalog.starsByHR[$0] }
4645 guard !stars.isEmpty else { return nil }
4746 let alts = SkyEngine.horizontal(j2000: stars.map { $0.position(at: when) }, at: when, from: observer).map(\.altitude)
4847 return (page, alts.reduce(0, +) / Double(alts.count))
@@ -283,14 +282,9 @@ struct PageThumb: View {
283282 ZStack {
284283 tile
285284 if let image = page.image {
286 AsyncImage(url: image.url) { phase in
287 if case .success(let img) = phase {
288 // Library of Congress scans carry a frame and colour bars; crop to the plate.
289 img.resizable().scaledToFill().scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1)
290 } else {
291 fallback
292 }
293 }
285 // Library of Congress scans carry a frame and colour bars; crop to the plate.
286 RemoteImage(url: image.url, points: large ? 640 : 260) { fallback }
287 .scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1)
294288 } else {
295289 fallback
296290 }
@@ -529,13 +523,7 @@ struct LibraryPageView: View {
529523 Color(white: 0.06)
530524 .frame(height: 340)
531525 .overlay {
532 AsyncImage(url: image.url) { phase in
533 switch phase {
534 case .success(let img): img.resizable().scaledToFill()
535 case .failure: Text("Image unavailable").font(.caption).dimmed(0.6)
536 default: ProgressView().tint(ink)
537 }
538 }
526 RemoteImage(url: image.url, points: 900) { ProgressView().tint(ink) }
539527 }
540528 .clipped()
541529 .accessibilityLabel(image.caption)
App/Sources/MarsSection.swift +6 −4
@@ -31,7 +31,11 @@ struct MarsSection: View {
3131 }
3232 Text("Local mean solar time. A Mars sol is 24 h 39 m 35 s. Raw images: NASA/JPL-Caltech.").font(.caption2).dimmed(0.5)
3333 }
34 .task { images = (try? await MarsImage.latest()) ?? [] }
34 .task {
35 // The rovers send a few batches a day; reuse a fetch from the last 30 minutes.
36 if let cached: [MarsImage] = FeedMemo.get("mars", maxAge: 1800) { images = cached; return }
37 if let fresh = try? await MarsImage.latest() { images = fresh; FeedMemo.set("mars", fresh) }
38 }
3539 .sheet(item: $shown) { MarsImageView(image: $0) }
3640 }
3741
@@ -43,9 +47,7 @@ struct MarsSection: View {
4347 HStack(spacing: 6) {
4448 ForEach(mine) { image in
4549 Button { shown = image } label: {
46 AsyncImage(url: image.thumbnail) { phase in
47 if case .success(let img) = phase { img.resizable().scaledToFill() } else { Color.white.opacity(0.06) }
48 }
50 RemoteImage(url: image.thumbnail, points: 130) { Color.white.opacity(0.06) }
4951 .frame(width: 88, height: 88)
5052 .clipped()
5153 }
App/Sources/MoonriseApp.swift +1
@@ -36,6 +36,7 @@ struct MoonriseApp: App {
3636
3737final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
3838 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
39 ImageLoader.configureURLCache()
3940 UNUserNotificationCenter.current().delegate = self
4041 application.registerForRemoteNotifications()
4142 return true
App/Sources/NewsList.swift +6 −5
@@ -26,12 +26,15 @@ struct NewsList: View {
2626 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
2727 }
2828 }
29 .task(id: reload) { await load() }
29 .task(id: reload) { await load(force: reload > 0) }
3030 }
3131
32 func load() async {
32 /// The API caches for 10 minutes; reuse a fetch from that window unless pulled to refresh.
33 func load(force: Bool) async {
34 if !force, let cached: [NewsArticle] = FeedMemo.get("news", maxAge: 600) { articles = cached; return }
3335 do {
3436 articles = try await NewsArticle.latest()
37 if let articles { FeedMemo.set("news", articles) }
3538 failed = false
3639 } catch {
3740 failed = articles == nil
@@ -47,9 +50,7 @@ struct NewsList: View {
4750 }
4851 Spacer(minLength: 0)
4952 if let image = a.image {
50 AsyncImage(url: image) { phase in
51 if case .success(let img) = phase { img.resizable().scaledToFill() } else { ink.opacity(0.06) }
52 }
53 RemoteImage(url: image, points: 110) { ink.opacity(0.06) }
5354 .frame(width: 72, height: 72)
5455 .clipped()
5556 .accessibilityHidden(true)
App/Sources/PageDrawing.swift +1 −2
@@ -194,8 +194,7 @@ struct ConstellationFigure: View {
194194
195195 var body: some View {
196196 let lines = Catalog.constellations.first { $0.id == id }?.lines ?? []
197 let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) })
198 let stars = Set(lines.joined()).compactMap { byHR[$0] }
197 let stars = Set(lines.joined()).compactMap { Catalog.starsByHR[$0] }
199198 Canvas { context, size in
200199 guard !stars.isEmpty else { return }
201200 // Gnomonic projection about the figure's centre.
App/Sources/RemoteImage.swift added +88
@@ -0,0 +1,88 @@
1import ImageIO
2import SwiftUI
3import UIKit
4
5/// Images from the network, decoded at the size they are shown rather than full size, and kept in memory.
6/// Downloads go through URLSession.shared, so the disk cache set up in `configureURLCache` applies.
7enum ImageLoader {
8 // NSCache is thread-safe.
9 nonisolated(unsafe) private static let cache: NSCache<NSString, UIImage> = {
10 let c = NSCache<NSString, UIImage>()
11 c.totalCostLimit = 60 * 1024 * 1024
12 return c
13 }()
14
15 static func cached(_ url: URL, maxPixels: Int) -> UIImage? {
16 cache.object(forKey: key(url, maxPixels))
17 }
18
19 static func load(_ url: URL, maxPixels: Int) async throws -> UIImage {
20 if let hit = cached(url, maxPixels: maxPixels) { return hit }
21 let (data, _) = try await URLSession.shared.data(from: url)
22 let image = try await Task.detached(priority: .userInitiated) { try downsample(data, maxPixels: maxPixels) }.value
23 let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
24 cache.setObject(image, forKey: key(url, maxPixels), cost: cost)
25 return image
26 }
27
28 private static func key(_ url: URL, _ maxPixels: Int) -> NSString { "\(maxPixels)|\(url.absoluteString)" as NSString }
29
30 /// Decodes straight to a thumbnail no larger than `maxPixels` on its long side.
31 nonisolated static func downsample(_ data: Data, maxPixels: Int) throws -> UIImage {
32 guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
33 let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, [
34 kCGImageSourceCreateThumbnailFromImageAlways: true,
35 kCGImageSourceCreateThumbnailWithTransform: true,
36 kCGImageSourceShouldCacheImmediately: true,
37 kCGImageSourceThumbnailMaxPixelSize: maxPixels,
38 ] as CFDictionary)
39 else { throw URLError(.cannotDecodeContentData) }
40 return UIImage(cgImage: cg)
41 }
42
43 /// A bigger shared cache than the default (512 KB memory, about 20 MB disk), so Library photos and
44 /// feed responses survive between visits. Responses over 5% of the disk size are never stored.
45 static func configureURLCache() {
46 URLCache.shared = URLCache(memoryCapacity: 24 * 1024 * 1024, diskCapacity: 400 * 1024 * 1024)
47 }
48}
49
50/// A remote image decoded for `points` (the longer side it is drawn at), with a placeholder while loading.
51struct RemoteImage<Placeholder: View>: View {
52 var url: URL?
53 var points: CGFloat
54 var contentMode: ContentMode = .fill
55 @ViewBuilder var placeholder: () -> Placeholder
56 @Environment(\.displayScale) private var scale
57 @State private var image: UIImage?
58
59 private var maxPixels: Int { Int(points * scale) }
60
61 var body: some View {
62 Group {
63 if let image = image ?? url.flatMap({ ImageLoader.cached($0, maxPixels: maxPixels) }) {
64 Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
65 } else {
66 placeholder()
67 }
68 }
69 .task(id: url) {
70 guard let url, image == nil else { return }
71 image = try? await ImageLoader.load(url, maxPixels: maxPixels)
72 }
73 }
74}
75
76/// Recent feed results kept in memory, so switching tabs or views doesn't refetch them.
77/// Pull to refresh bypasses it.
78@MainActor
79enum FeedMemo {
80 private static var store: [String: (at: Date, value: Any)] = [:]
81
82 static func get<T>(_ key: String, maxAge: TimeInterval) -> T? {
83 guard let hit = store[key], Date.now.timeIntervalSince(hit.at) < maxAge else { return nil }
84 return hit.value as? T
85 }
86
87 static func set(_ key: String, _ value: Any) { store[key] = (.now, value) }
88}
App/Sources/SkyModel.swift +8
@@ -55,6 +55,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
5555 private(set) var passes: [SatellitePass] = []
5656 private var elements: [OrbitalElements] = []
5757 private var elementsFetched: Date?
58 private var passesComputed: (at: Date, observer: Observer, elementsFetched: Date?)?
5859 private var weatherFetched: Date?
5960 /// Hours from now shown by the time scrubber.
6061 var offsetHours = 0.0 { didSet { refresh() } }
@@ -154,10 +155,17 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
154155 elementsFetched = .now
155156 }
156157 }
158 // Reuse the last result for an hour unless the place or the orbits changed (about 1 s of work for 158 objects).
159 if let last = passesComputed, last.observer == observer, last.elementsFetched == elementsFetched,
160 Date.now.timeIntervalSince(last.at) < 3600 {
161 passes.removeAll { $0.set < .now }
162 return
163 }
157164 let elements = elements, observer = observer
158165 passes = await Task.detached {
159166 elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise }
160167 }.value
168 passesComputed = (.now, observer, elementsFetched)
161169 }
162170
163171 /// SWPC asks for no more than once a minute; this checks every 10.
App/Sources/SolarSystem3D.swift +21 −10
@@ -382,10 +382,7 @@ struct SolarSystem3DView: View {
382382 e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees
383383 e.root.addChild(e.camera)
384384
385 var sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1))
386 if let url = Bundle.main.url(forResource: "sun", withExtension: "jpg", subdirectory: "Globes"), let t = try? TextureResource.load(contentsOf: url) {
387 sunMaterial.color = .init(tint: .white, texture: .init(t))
388 }
385 let sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1))
389386 e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial])
390387 e.root.addChild(e.sun)
391388 // Glow: nested translucent shells, fainter outward.
@@ -404,12 +401,8 @@ struct SolarSystem3DView: View {
404401 spheres += SolarMoon.all.map { ($0.id, $0.color, 0) }
405402 for s in spheres {
406403 let body = ModelEntity()
407 var m = UnlitMaterial()
408 if let url = GlobeView.mapURL(s.id), let texture = try? TextureResource.load(contentsOf: url) {
409 m.color = .init(texture: .init(texture))
410 } else {
411 m.color = .init(tint: UIColor(Color(hex: s.color) ?? .gray))
412 }
404 // Plain colour first; the map, if there is one, is swapped in once it has loaded.
405 let m = UnlitMaterial(color: UIColor(Color(hex: s.color) ?? .gray))
413406 body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m])
414407 body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0])
415408 if s.id == "saturn" { body.addChild(Self.annulus(inner: 1.24, outer: 2.27, color: UIColor(red: 0.85, green: 0.78, blue: 0.62, alpha: 0.7))) }
@@ -460,6 +453,24 @@ struct SolarSystem3DView: View {
460453 }
461454 e.bodies["earth"]?.addChild(e.aurora)
462455 place()
456 loadTextures()
457 }
458
459 /// Loads surface maps off the main thread (about 0.6 s for all of them) and applies each as it arrives.
460 private func loadTextures() {
461 let e = entities
462 var jobs: [(entity: ModelEntity, url: URL, tint: UIColor)] = e.bodies.compactMap { id, entity in
463 GlobeView.mapURL(id).map { (entity, $0, .white) }
464 }
465 if let sun = Bundle.main.url(forResource: "sun", withExtension: "jpg", subdirectory: "Globes") { jobs.append((e.sun, sun, .white)) }
466 for job in jobs {
467 Task {
468 guard let texture = try? await TextureResource(contentsOf: job.url) else { return }
469 var m = UnlitMaterial()
470 m.color = .init(tint: job.tint, texture: .init(texture))
471 job.entity.model?.materials = [m]
472 }
473 }
463474 }
464475
465476 /// Moves everything for the current date, scale, layers and camera.
App/Sources/SunScreen.swift +9 −3
@@ -47,11 +47,16 @@ struct SunScreen: View {
4747 }
4848 .foregroundStyle(ink)
4949 .background(background.ignoresSafeArea())
50 .refreshable { await load() }
51 .task { await load() }
50 .refreshable { await load(force: true) }
51 .task { await load(force: false) }
5252 }
5353
54 private func load() async {
54 /// NOAA updates these every minute or so; a visit within 5 minutes of the last load reuses it.
55 private func load(force: Bool) async {
56 if !force, let cached: ([KpBar], [XRaySample], Ovation?) = FeedMemo.get("sun", maxAge: 300) {
57 (kp, xray, ovation) = cached
58 return
59 }
5560 imageID = .now
5661 async let k = try? SpaceWeather.fetchKpBars()
5762 async let x = try? XRay.fetch()
@@ -61,6 +66,7 @@ struct SunScreen: View {
6166 xray = xrayResult ?? xray
6267 ovation = ovationResult ?? ovation
6368 failed = kpResult == nil || xrayResult == nil || ovationResult == nil
69 if !failed { FeedMemo.set("sun", (kp, xray, ovation)) }
6470 }
6571
6672 private var image: some View {
Packages/MoonriseKit/Sources/Library/Library.swift +32 −5
@@ -137,15 +137,38 @@ public enum Library {
137137
138138 /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay.
139139 public static func search(_ query: String) -> [LibraryPage] {
140 let q = query.trimmingCharacters(in: .whitespaces)
140 let q = fold(query.trimmingCharacters(in: .whitespaces))
141141 guard !q.isEmpty else { return [] }
142 let opts: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive]
143 let strong = pages.filter { $0.title.range(of: q, options: opts) != nil || $0.subtitle.range(of: q, options: opts) != nil || $0.id == q.lowercased() }
144 let weak = pages.filter { p in
145 !strong.contains(p) && (p.essay.range(of: q, options: opts) != nil || p.facts.contains { $0.value.range(of: q, options: opts) != nil })
142 var strong: [LibraryPage] = [], weak: [LibraryPage] = []
143 for entry in searchIndex {
144 if entry.head.contains(q) || entry.page.id == q { strong.append(entry.page) } else if entry.body.contains(q) { weak.append(entry.page) }
146145 }
147146 return strong + weak
148147 }
148
149 /// Lowercased, diacritic-free text per page, built once: title and subtitle, then essay and facts.
150 private static let searchIndex: [(page: LibraryPage, head: String, body: String)] = pages.map { p in
151 (p, fold(p.title + "\n" + p.subtitle), fold(p.essay + "\n" + p.facts.map(\.value).joined(separator: "\n")))
152 }
153
154 static func fold(_ s: String) -> String {
155 s.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
156 }
157
158 private static let relatedLock = NSLock()
159 nonisolated(unsafe) private static var relatedCache: [String: [LibraryPage]] = [:]
160
161 /// `page.related()` computed once per page.
162 static func cachedRelated(_ page: LibraryPage, compute: () -> [LibraryPage]) -> [LibraryPage] {
163 relatedLock.lock()
164 if let hit = relatedCache[page.id] { relatedLock.unlock(); return hit }
165 relatedLock.unlock()
166 let value = compute()
167 relatedLock.lock()
168 relatedCache[page.id] = value
169 relatedLock.unlock()
170 return value
171 }
149172}
150173
151174extension LibraryPage {
@@ -153,6 +176,10 @@ extension LibraryPage {
153176 /// A title also matches by its part before a dash or bracket ("Cassini" for "Cassini–Huygens").
154177 /// Titles shorter than five characters ("Sun", "Moon", "Io") only count when they are mentioned here.
155178 public func related(limit: Int = 10) -> [LibraryPage] {
179 Library.cachedRelated(self) { uncachedRelated(limit: limit) }
180 }
181
182 private func uncachedRelated(limit: Int) -> [LibraryPage] {
156183 func mentions(_ text: String, _ title: String) -> Bool {
157184 var search = text.startIndex..<text.endIndex
158185 while let r = text.range(of: title, range: search) {
Packages/MoonriseKit/Sources/SkyRenderer/SkyScene.swift +3
@@ -52,6 +52,9 @@ public enum Catalog {
5252 return rows.map { Star(hr: Int($0[0]), ra: $0[1], dec: $0[2], magnitude: $0[3], colorIndex: $0[4], pmRA: $0[5], pmDec: $0[6]) }
5353 }()
5454
55 /// Stars by HR number.
56 public static let starsByHR: [Int: Star] = Dictionary(uniqueKeysWithValues: stars.map { ($0.hr, $0) })
57
5558 /// Built by scripts/make-skycultures.py.
5659 public static let skyCultures: [SkyCulture] = {
5760 let url = Bundle.module.url(forResource: "skycultures", withExtension: "json")!
server/src/index.ts +11 −7
@@ -29,7 +29,11 @@ interface DeviceRequest {
2929const LAUNCH_CRON = "*/10 * * * *";
3030const SATELLITE_CRON = "7 */2 * * *";
3131
32const json = (body: unknown, status = 200) => Response.json(body, { status });
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;
3337
3438function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
3539 return (
@@ -42,12 +46,12 @@ function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
4246
4347async function today(env: Env, now: Date): Promise<Response> {
4448 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 }>();
45 if (!row) return json({ spaceWeather: null });
49 if (!row) return json({ spaceWeather: null }, 200, MAX_AGE.today);
4650 const w = JSON.parse(row.body) as SpaceWeather;
4751 w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) }));
4852 return json({
4953 spaceWeather: { kpNow: w.kpNow, observedAt: w.observedAt, kpNext24h: next24h(w, now), fetchedAt: row.fetched_at },
50 });
54 }, 200, MAX_AGE.today);
5155}
5256
5357export default {
@@ -66,7 +70,7 @@ export default {
6670 if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now);
6771 if (request.method === "GET" && url.pathname === "/v1/satellites") {
6872 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 }>();
69 return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] });
73 return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] }, 200, MAX_AGE.satellites);
7074 }
7175
7276 if (request.method === "POST" && url.pathname === "/v1/devices") {
@@ -125,7 +129,7 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
125129 const at = atParam ? new Date(atParam) : now;
126130 if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400);
127131 const event = activeEvent(at);
128 if (!event) return json({ event: null });
132 if (!event) return json({ event: null }, 200, MAX_AGE.live);
129133
130134 const key = `live:${event.id}`;
131135 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 }>();
@@ -144,7 +148,7 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
144148 console.error("horizons", String(e));
145149 }
146150 }
147 return json({ event, at: at.toISOString(), points: points.filter((p) => p[0] * 1000 <= at.getTime()) });
151 return json({ event, at: at.toISOString(), points: points.filter((p) => p[0] * 1000 <= at.getTime()) }, 200, MAX_AGE.live);
148152}
149153
150154async function launches(env: Env, now: Date): Promise<Response> {
@@ -159,7 +163,7 @@ async function launches(env: Env, now: Date): Promise<Response> {
159163 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
160164 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
161165 })),
162 });
166 }, 200, MAX_AGE.launches);
163167}
164168
165169// Failures are recorded under "<feed>:error" so the last good snapshot and its time survive.