Multi-agent adversarial review (verified findings) — the top finding (dead orbit drag) fable had already fixed in 3e36a0c; these are the rest, composing on top of fable's 228caf2/3e36a0c: - scale.js viewFromEcl: allocation-free + alias-safe (read x/y/z into locals before writing out) — it ran per body per frame and allocated an object each call (§11). - planets.js + comets.js: refill orbit paths on any P change, not just isTransitioning() — the MEGA↔TRUE tween's FINAL frame (P settled, tween nulled) was skipped, leaving body/orbit desync (§12). - almanac.js: a moon's heliocentric position adds its true orbital offset, so the Moon's "From Earth" is its real ~384,400 km / 1.3 s light, not 0. - asteroids.js: guard refresh() against null geometry when toggled on before the SBDB load completes (was a TypeError). - serve.py: CAD close-approach responses are never cached immutable — the database keeps growing for past dates as new NEOs are discovered (only Horizons ephemeris is truly immutable). Re-verified: 13/13 gates green; orbit zoom dollies 82→73 finite; Moon From Earth 0.003 AU; layer toggles throw nothing; no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
8.5 KiB
Python
230 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""SOLARGOD dev server: static files + a same-origin proxy for the JPL feeds a
|
|
browser can't call directly (no CORS on ssd.jpl.nasa.gov / ssd-api.jpl.nasa.gov).
|
|
|
|
Zero dependencies (stdlib only). Quickstart: python3 serve.py → http://127.0.0.1:8147
|
|
|
|
Proxy endpoints (allowlisted upstreams only — never a general proxy):
|
|
/proxy/horizons?<q> → https://ssd.jpl.nasa.gov/api/horizons.api?<q>
|
|
/proxy/sbdb?<q> → https://ssd-api.jpl.nasa.gov/sbdb_query.api?<q>
|
|
/proxy/cad?<q> → https://ssd-api.jpl.nasa.gov/cad.api?<q>
|
|
|
|
Disk cache in cache/ keyed by sha256(upstream URL). Responses whose requested
|
|
time span lies entirely in the past are immutable (never expire); everything else
|
|
gets a 24 h TTL. The cache is the rate limiter — be polite to JPL (brief §6, §12).
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8147
|
|
ROOT = Path(__file__).resolve().parent
|
|
CACHE_DIR = ROOT / "cache"
|
|
CACHE_TTL_SEC = 24 * 3600
|
|
|
|
UPSTREAMS = {
|
|
"horizons": "https://ssd.jpl.nasa.gov/api/horizons.api",
|
|
"sbdb": "https://ssd-api.jpl.nasa.gov/sbdb_query.api",
|
|
"cad": "https://ssd-api.jpl.nasa.gov/cad.api",
|
|
}
|
|
|
|
JD_UNIX_EPOCH = 2440587.5
|
|
|
|
|
|
def _jd_to_unix(jd):
|
|
return (jd - JD_UNIX_EPOCH) * 86400.0
|
|
|
|
|
|
def _parse_epoch_to_unix(tok):
|
|
"""Best-effort: a Horizons/CAD time token → unix seconds, or None."""
|
|
tok = tok.strip().strip("'\"")
|
|
if not tok:
|
|
return None
|
|
try: # bare Julian Date (e.g. 2461236.5)
|
|
f = float(tok)
|
|
if f > 2400000:
|
|
return _jd_to_unix(f)
|
|
except ValueError:
|
|
pass
|
|
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y-%b-%d %H:%M", "%Y-%b-%d"):
|
|
try:
|
|
return datetime.strptime(tok, fmt).replace(tzinfo=timezone.utc).timestamp()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _requested_span_is_past(name, query):
|
|
"""True if the whole requested time span is safely in the past → immutable."""
|
|
q = urllib.parse.parse_qs(query, keep_blank_values=True)
|
|
latest = None
|
|
|
|
def bump(u):
|
|
nonlocal latest
|
|
if u is not None:
|
|
latest = u if latest is None else max(latest, u)
|
|
|
|
if name == "horizons":
|
|
for key in ("STOP_TIME", "TLIST"):
|
|
for raw in q.get(key, []):
|
|
for tok in re.split(r"[,\s]+", raw):
|
|
bump(_parse_epoch_to_unix(tok))
|
|
if "STOP_TIME" not in q and "TLIST" not in q:
|
|
return False
|
|
# CAD is NOT immutable even for past dates: close-approach records keep growing
|
|
# as new NEOs are discovered. SBDB elements advance too. Both get the 24 h TTL.
|
|
else:
|
|
return False
|
|
|
|
# Margin: only call it immutable if the latest epoch is > 1 day ago.
|
|
return latest is not None and latest < time.time() - 86400
|
|
|
|
|
|
def _cache_paths(url):
|
|
h = hashlib.sha256(url.encode()).hexdigest()
|
|
return CACHE_DIR / f"{h}.body", CACHE_DIR / f"{h}.meta"
|
|
|
|
|
|
def _cache_read(url):
|
|
body_p, meta_p = _cache_paths(url)
|
|
try:
|
|
meta = json.loads(meta_p.read_text())
|
|
body = body_p.read_bytes()
|
|
except (OSError, ValueError):
|
|
return None
|
|
if meta.get("immutable"):
|
|
return body, meta.get("ctype", "text/plain")
|
|
if time.time() - meta.get("ts", 0) < CACHE_TTL_SEC:
|
|
return body, meta.get("ctype", "text/plain")
|
|
return None
|
|
|
|
|
|
def _cache_write(url, body, ctype, immutable):
|
|
body_p, meta_p = _cache_paths(url)
|
|
try:
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
_atomic_write(body_p, body)
|
|
_atomic_write(meta_p, json.dumps(
|
|
{"ts": time.time(), "immutable": bool(immutable), "ctype": ctype,
|
|
"url": url}).encode())
|
|
except OSError:
|
|
pass # caching must never break the actual response
|
|
|
|
|
|
def _atomic_write(path, data):
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(data)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=str(ROOT), **kw)
|
|
|
|
def end_headers(self):
|
|
# Dev server: never cache static assets so js/css edits always take effect.
|
|
self.send_header("Cache-Control", "no-store")
|
|
super().end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/proxy/"):
|
|
return self._serve_proxy()
|
|
return super().do_GET()
|
|
|
|
def _serve_proxy(self):
|
|
name, _, query = self.path[len("/proxy/"):].partition("?")
|
|
base = UPSTREAMS.get(name)
|
|
if not base:
|
|
return self._send_json(404, {"error": f"unknown upstream {name!r}"})
|
|
url = base + ("?" + query if query else "")
|
|
|
|
cached = _cache_read(url)
|
|
if cached is not None:
|
|
return self._send(200, cached[1], cached[0], {"X-Solargod-Cache": "hit"})
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "solargod-dev/0.1"})
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
body = r.read()
|
|
ctype = r.headers.get("Content-Type", "text/plain")
|
|
# Horizons returns HTTP 200 even for "no ephemeris" errors — don't cache
|
|
# those (a transient/burst failure would otherwise poison the cache). A
|
|
# real ephemeris always contains the $$SOE marker.
|
|
if name != "horizons" or b"$$SOE" in body:
|
|
_cache_write(url, body, ctype, _requested_span_is_past(name, query))
|
|
return self._send(200, ctype, body, {"X-Solargod-Cache": "miss"})
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read() or str(e).encode()
|
|
return self._send(e.code, "text/plain", body, {"X-Solargod-Cache": "err"})
|
|
except Exception as e:
|
|
return self._send(502, "text/plain", str(e).encode(), {"X-Solargod-Cache": "err"})
|
|
|
|
def do_POST(self):
|
|
# Dev-only screenshot sink: POST /snap?name=<n> with base64 PNG (or a
|
|
# data:image/png;base64,... URL) → docs/<n>.png. Bound to 127.0.0.1; the
|
|
# name is sanitized so the write can never escape docs/.
|
|
if not (self.path == "/snap" or self.path.startswith("/snap?")):
|
|
return self.send_error(404, "not found")
|
|
_, _, query = self.path.partition("?")
|
|
raw = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
|
|
name = re.sub(r"[^a-z0-9-]", "", raw.lower()) or "screenshot"
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
text = self.rfile.read(length).decode("utf-8", "replace") if length else ""
|
|
if text.strip().startswith("data:") and "," in text:
|
|
text = text.split(",", 1)[1]
|
|
try:
|
|
png = base64.b64decode(text, validate=False)
|
|
except Exception:
|
|
return self._send_json(400, {"error": "body must be base64 PNG"})
|
|
if not png:
|
|
return self._send_json(400, {"error": "empty image"})
|
|
docs = ROOT / "docs"
|
|
docs.mkdir(parents=True, exist_ok=True)
|
|
out = (docs / f"{name}.png").resolve()
|
|
if out.parent != docs.resolve():
|
|
return self._send_json(400, {"error": "invalid name"})
|
|
out.write_bytes(png)
|
|
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
|
|
|
|
def _send(self, status, ctype, body, extra=None):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Expose-Headers", "X-Solargod-Cache")
|
|
for k, v in (extra or {}).items():
|
|
self.send_header(k, v)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_json(self, status, obj, extra=None):
|
|
self._send(status, "application/json", json.dumps(obj).encode(), extra)
|
|
|
|
def log_message(self, fmt, *args):
|
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
print(f"SOLARGOD serving on http://127.0.0.1:{PORT} (proxy: horizons · sbdb · cad · cache→cache/)")
|
|
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|