Scrubbing off-live now REPLAYS actual recorded traffic instead of just hiding
aircraft (orbits were already computable in the past; live traffic was not).
- record.py (new, dev-only daemon): every 180s fetches through the local proxy
(so the shared cache dedupes quota — never hits OpenSky directly), reduces to
airborne essentials, zlib-compresses into SQLite data/history.db (WAL), keeps
72h, prunes + checkpoints hourly. Wired into John's ~/.jobs heartbeat.
- serve.py: GET history/aircraft?t=<epoch_s> returns the nearest snapshot within
±10 min (read-only per-request sqlite connection — WAL means never locks the
recorder), else a JSON 404.
- aircraft.js: onClockTick drives replay when not-live. Build loop factored into
renderPlanes() shared by live + replay. Replayed aircraft show amber ("replay
· <time> · N aircraft"); 404 → "no history for this time" + hide (this is the
prod-without-recorder path too). Requests coalesce so a scrub landing mid-fetch
still resolves to its final snapshot; debounced on requested-time distance.
- data/ gitignored (recorder db is local-only, never committed or deployed).
Verified: history endpoint hit/404/400; frontend replay across -1h/-2h/-3h maps
to distinct snapshots, 404 transitions, live-resume; recorder daemon writes
heartbeat + snapshots.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
8.9 KiB
Python
220 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""GODSIGH dev server: static files + same-origin proxy for feeds that block CORS.
|
|
|
|
Also implements the **OpenSky shared cache v1** contract (see SPEC2.md §4), which
|
|
lets GODSIGH and its sibling project godstrument poll OpenSky from the same IP
|
|
without racing each other into the anonymous daily quota. The contract is
|
|
mirrored verbatim in godstrument's brief — keep the two in sync.
|
|
"""
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zlib
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
UPSTREAMS = {
|
|
# OpenSky pins Access-Control-Allow-Origin to its own domain, so the browser
|
|
# can't call it directly — we forward it here instead.
|
|
"opensky": "https://opensky-network.org/api/states/all",
|
|
# Celestrak sends ACAO:* today; proxied too so the app keeps working if that changes.
|
|
"celestrak": "https://celestrak.org/NORAD/elements/gp.php",
|
|
}
|
|
|
|
# ---- OpenSky shared cache v1 ------------------------------------------------
|
|
# Path: ~/.cache/godverse/opensky-states.json (overridable via OPENSKY_CACHE_FILE).
|
|
# Content: the raw, unmodified /states/all JSON body (global, no bbox). Freshness
|
|
# = file mtime; readers treat <120 s as fresh. On upstream 429/failure a reader
|
|
# may serve a stale cache rather than nothing.
|
|
OPENSKY_CACHE_FRESH_SEC = 120
|
|
|
|
# ---- Historical replay (record.py writes this db; dev-only) -----------------
|
|
# GET history/aircraft?t=<epoch_s> returns the nearest recorded snapshot within
|
|
# ±10 min, or 404. Prod has no recorder → the file is absent → graceful 404.
|
|
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
|
|
HISTORY_TOLERANCE_SEC = 600
|
|
|
|
|
|
def opensky_cache_file():
|
|
override = os.environ.get("OPENSKY_CACHE_FILE")
|
|
return Path(override if override else "~/.cache/godverse/opensky-states.json").expanduser()
|
|
|
|
|
|
def has_bbox(query):
|
|
# A global states/all request has no bounding-box params; only those bypass the cache.
|
|
q = query.lower()
|
|
return any(k in q for k in ("lamin", "lomin", "lamax", "lomax"))
|
|
|
|
|
|
def read_cache_if_fresh(path, max_age):
|
|
try:
|
|
st = path.stat()
|
|
except OSError:
|
|
return None
|
|
if time.time() - st.st_mtime >= max_age:
|
|
return None
|
|
try:
|
|
return path.read_bytes()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def read_cache_any(path):
|
|
try:
|
|
return path.read_bytes()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def write_cache(path, body):
|
|
# Atomic write: a unique temp file *beside* the target (same filesystem, so
|
|
# os.replace is atomic) then os.replace over the target. A unique temp name
|
|
# means concurrent writers (GODSIGH + godstrument, or two threads here) never
|
|
# corrupt each other's cache or expose a half-written file. Best-effort —
|
|
# caching must never break the actual response.
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(body)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def end_headers(self):
|
|
# Dev server: never let the browser cache our static assets, so edits to
|
|
# js/css always take effect on reload (module graphs cache aggressively).
|
|
self.send_header("Cache-Control", "no-store")
|
|
super().end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/history/"):
|
|
return self._serve_history()
|
|
if not self.path.startswith("/proxy/"):
|
|
return super().do_GET()
|
|
name, _, query = self.path[len("/proxy/"):].partition("?")
|
|
base = UPSTREAMS.get(name)
|
|
if not base:
|
|
return self.send_error(404, f"unknown upstream {name!r}")
|
|
|
|
# Shared cache applies only to the OpenSky global states feed.
|
|
opensky_global = name == "opensky" and not has_bbox(query)
|
|
cache = opensky_cache_file()
|
|
|
|
# 1) Serve-from-cache: fresh (<120 s) global request costs zero quota.
|
|
if opensky_global:
|
|
fresh = read_cache_if_fresh(cache, OPENSKY_CACHE_FRESH_SEC)
|
|
if fresh is not None:
|
|
return self._send(200, "application/json", fresh, {"X-Godsigh-Cache": "hit"})
|
|
|
|
url = base + ("?" + query if query else "")
|
|
passthrough = {}
|
|
|
|
def grab(hdrs):
|
|
for k in ("X-Rate-Limit-Remaining", "X-Rate-Limit-Limit", "X-Expires-After"):
|
|
if hdrs.get(k) is not None:
|
|
passthrough[k] = hdrs.get(k)
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "godsigh-dev/0.1"})
|
|
with urllib.request.urlopen(req, timeout=45) as r:
|
|
body = r.read()
|
|
status, ctype = r.status, r.headers.get("Content-Type", "text/plain")
|
|
grab(r.headers)
|
|
# 2) Write-through: a healthy global fetch refreshes the shared cache.
|
|
if opensky_global and status == 200:
|
|
write_cache(cache, body)
|
|
passthrough["X-Godsigh-Cache"] = "miss"
|
|
except urllib.error.HTTPError as e:
|
|
# 3) Stale-on-error: on 429, keep the client working from last-known cache.
|
|
if opensky_global and e.code == 429:
|
|
stale = read_cache_any(cache)
|
|
if stale is not None:
|
|
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
|
|
body = e.read() or str(e).encode()
|
|
status, ctype = e.code, "text/plain"
|
|
grab(e.headers)
|
|
except Exception as e:
|
|
# Same stale fallback for connection errors/timeouts.
|
|
if opensky_global:
|
|
stale = read_cache_any(cache)
|
|
if stale is not None:
|
|
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
|
|
body, status, ctype = str(e).encode(), 502, "text/plain"
|
|
|
|
self._send(status, ctype, body, passthrough)
|
|
|
|
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-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After, X-Godsigh-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 _serve_history(self):
|
|
# /history/aircraft?t=<epoch_s> → nearest snapshot within ±10 min, or 404.
|
|
kind, _, query = self.path[len("/history/"):].partition("?")
|
|
params = urllib.parse.parse_qs(query)
|
|
t_raw = (params.get("t") or [None])[0]
|
|
if kind != "aircraft" or t_raw is None:
|
|
return self._send_json(400, {"error": "usage: history/aircraft?t=<epoch_s>"})
|
|
try:
|
|
t = int(float(t_raw))
|
|
except ValueError:
|
|
return self._send_json(400, {"error": "t must be epoch seconds"})
|
|
if not HISTORY_DB.exists():
|
|
return self._send_json(404, {"error": "no history for this time"})
|
|
try:
|
|
# Read-only, short-lived connection per request — WAL lets us read
|
|
# without ever locking out the recorder's writes.
|
|
con = sqlite3.connect(f"file:{HISTORY_DB}?mode=ro", uri=True, timeout=2)
|
|
try:
|
|
row = con.execute(
|
|
"SELECT ts, body FROM snapshots WHERE kind='aircraft' "
|
|
"ORDER BY ABS(ts - ?) ASC LIMIT 1", (t,)).fetchone()
|
|
finally:
|
|
con.close()
|
|
except sqlite3.Error as e:
|
|
return self._send_json(500, {"error": f"history db error: {e}"})
|
|
if not row or abs(row[0] - t) > HISTORY_TOLERANCE_SEC:
|
|
return self._send_json(404, {"error": "no history for this time"})
|
|
try:
|
|
snap = zlib.decompress(row[1]) # already-JSON bytes: {"t":..,"planes":[...]}
|
|
except zlib.error:
|
|
return self._send_json(500, {"error": "corrupt snapshot"})
|
|
return self._send(200, "application/json", snap, {"X-Godsigh-History": "hit"})
|
|
|
|
def log_message(self, fmt, *args):
|
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8137
|
|
print(f"GODSIGH serving on http://127.0.0.1:{port}")
|
|
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|