やってみた 2026年9月10日

APIキー0本で3D地球儀「God's Eye View」を立ててみたら、キーレスを支えていたのは多段フォールバックだった

XECIN JavaScriptセルフホストCesiumレート制限

GitHubのトレンドを眺めていたら、bilawalsidhu/gods-eye-view が2位に上がっていました。説明は「ブラウザの中のスパイ衛星シミュレータ、ただしデータは本物」。READMEには 「アカウントもAPIキーも無しで始められる」 と大きく書いてあります。

正直なところ、私はこの手の「キー無しで動きます」に対してかなり懐疑的です。今まで色々な現場で、キーを入れないと実質空っぽの画面しか出ないものを何度も見てきました。デモ用のダミーデータが出るだけ、というケースもありました。だから今回試したかったのは「動くかどうか」ではなく、キーが0本のとき、どこまでが本物のデータで、どこからが飾りなのかという境界線のほうです。

結論から言うと、境界線はかなり誠実に引かれていました。そして境界線を引いている仕組みそのものが、いちばん面白い部分でした。

試した環境

いつも通り、ホスト(Windows)を汚さないように使い捨てコンテナの中だけで動かしています。ブラウザのキャプチャだけはホスト側から撮りました。

項目値
コンテナnode:24-bookworm(—memory=4g —cpus=2 —pids-limit=512)
ランタイムNode v24.21.0 / npm 11.19.0 / Debian GNU/Linux 12
対象v0.1.1 / commit 7596522 / MIT
キー1本も設定していない(.env も作らない)

ベースイメージに Node 24 を選んだのは好みではなく必要からで、package.json の engines が >=24.14.0 <25 || >=26 <27 と、上限まで含めてかなり狭く切ってあります。25系を除外しているあたり、たぶん実際に踏んだことがあるんだろうな、という切り方です。

起動まで

git clone --depth 1 が 4,010ms(169,083,669バイト・465ファイル)、npm ci が 11,134ms。ここは何の苦労もありませんでした。ただ、npm ci の最後に見慣れない警告が出ています。

# clone と npm ci
CLONE_MS=4010                       # 169,083,669B / 465 files
NPMCI_MS=11134                      # node_modules 234,153,960B / 173 packages

# npm 11.19 が postinstall を止めていた
$ npm install-scripts ls
3 packages have install scripts not yet covered by allowScripts:
  esbuild@0.25.12 (postinstall: node install.js)
  puppeteer@24.37.5 (postinstall: node install.mjs)
  sharp@0.34.5 (install: node install/check.js || npm run build)

# 同梱のセットアップ診断
$ npm run doctor                    # rc=0 / 128ms
[OK] Node 24.21.0: supported LTS and calibrated for release gates
[OK] npm 11.19.0
[OK] dependencies installed

Map:     Esri World Imagery (keyless satellite basemap) with keyless terrain
Flights: OpenSky OAuth credentials not configured
Voice:   off until an OpenAI key is added
Vessels: off until an AISStream key is added
Fires:   off until a FIRMS key is added
Traffic: built-in traffic simulation
Missions: Launch Library 2 public access

Ready. Run npm run dev, then open http://localhost:4173.

# 起動
$ npm run dev
  VITE v6.4.3  ready in 316 ms
  ➜  Local:   http://localhost:4173/

npm 11.19 が esbuild の postinstall を止めている時点で「これ vite 動かないんじゃないか」と身構えたんですが、316msで普通に上がりました。今のesbuildはプラットフォーム別バイナリをoptional依存で配っているので、postinstallが走らなくても困らない、ということですね。ここは杞憂でした。

もうひとつ地味に引っかかったのが HOST を指定しないときの待ち受けアドレスです。既定は localhost で、コンテナの中から http://127.0.0.1:4173/ を叩くと繋がらず、http://localhost:4173/ と http://[::1]:4173/ は200。IPv6ループバックだけを掴んでいます。コンテナに閉じ込めて外からポート公開する構成だと、ここで最初に一回転びます。PORT と HOST は両方とも環境変数で上書きできるので、私は HOST=0.0.0.0 PORT=5173 で起動し直しました。

そして開いた画面が、これです。

God's Eye Viewの初回起動画面。ダークな軍事HUD風のUIで、中央に「Choose your first view」のモーダル。LIVE CONTACTS / SPACE MISSIONS / ENVIRONMENTAL / EXPLORE MANUALLY の4択が並ぶ。背景はオースティン上空の衛星画像で描かれた3D地球儀、左上に「TOP SECRET // SI-TK // NOFORN」、左下にMGRS座標、右下に「Powered by Esri」のクレジット

キーは1本も入れていません。それでも衛星画像の地形付き3D地球儀がちゃんと立ち上がって、右下に「Powered by Esri」と出ています。診断が言っていた「keyless satellite basemap」はそのままの意味でした。

キー0本で、何が本当に生きているのか

見た目が出たのはいいとして、私が知りたいのは中身です。この製品はデータ取得をすべてdevサーバ側のプロキシ経由でやっていて、vite.config.js の中に /api/* のミドルウェアが 26本 生えています(ちなみにこのファイル、7,798行あります。バックエンド一式がvite設定に同居している構成で、好みは分かれるところですが、追いかけるぶんには全部1ファイルなので楽でした)。

なので、画面の印象ではなくエンドポイントを直接叩いて数えました。

# キーを1本も入れていない状態で、devサーバのプロキシを直接叩く
/api/setup/status            200 1917B    0.004s  :: {"keys":[{"id":"google-maps",...
/api/launches                200 993300B  7.110s  :: {"count":24,...            # Launch Library 2
/api/opensky                 200 1370090B 1.841s  :: {"time":1789042078,"states":[[...
/api/adsblol/mil             200 133216B  1.718s  :: {"ac":[{"hex":"a2b320","flight":"JANET44 ",...
/api/celestrak               400 13B              :: invalid group               # group指定が必須
/api/gbfs                    400 40B              :: {"error":"Missing GBFS upstream target"}
/api/radio                   404 31B              :: {"error":"Unknown radio route"}

# キーが要る側の応答(エラーではない、という点が肝)
/api/google/nearby-places    200  :: {"configured":false,"error":null,"places":[]}
/api/openai/hud-summary      200  :: {"configured":false,"code":"OPENAI_NOT_CONFIGURED","error":null,"summary":null}

# devサーバのログ(起動直後に勝手に読み込まれていた公開カメラ)
[CCTV] Loaded TfL JamCam sources: 842 available (using nearest 250)
[CCTV] Loaded Austin camera sources: 817 (using nearest 250)
[CCTV] Loaded Caltrans camera sources: 1829 inService (using nearest 300)

/api/opensky が 1,370,090バイト返ってきて、中に 10,365機 分の状態ベクタが入っていました。キーは入れていません。ロンドン交通局のJamCam 842台、オースティン 817台、カリフォルニア州交通局 1,829台も、キー無しで実在のカタログとして読み込まれています。「データは本物」は本当でした。

面白かったのはキーが要る側の返し方で、{"configured":false,...} を HTTP 200 で返してきます。ソースにもその意図がはっきり書いてありました。

Describe the optional HUD summary capability without turning a deliberately keyless boot into an HTTP failure.

私はこの設計は好きです。エラーハンドリングを書く側からすると、「サーバが壊れている」と「キーが設定されていない」は完全に別事象なのに、後者を4xx/5xxで返されると呼び出し側で必ず分岐が要る。200 + configured:false にしておけば、UIは「機能が無い」だけを見ればよくなります。判定関数のほうは configured/code/error/summary の4キー厳密一致まで見ていて、そこは律儀だなと思いました。

いちばん面白かったところ:キーレスの飛行機は、5分に1回しか動かない

さて、/api/opensky がキー無しで1万機返してくるのは分かりました。では、これはリアルタイムなのか。

最初、3秒おきに5回叩いて中身を比べてみたんですが、5回とも同じでした。バイト数も1,370,090で完全一致、応答は1.4〜4.2ms、ボディの中の観測時刻 time も同じ値のまま。「ああキャッシュか」と思ってソースを見ると OPENSKY_CACHE_MS = 9000、9秒です。ならば12秒間隔なら更新されるはず——と思って測り直したのが、これです。

# 12秒間隔で3回。X-OpenSky-* の診断ヘッダを見る
t=12:08:59 200 1370090B  X-OpenSky-Cache: HIT   time=1789042078 states=10365
t=12:09:11 200 1370090B  X-OpenSky-Cache: HIT   time=1789042078 states=10365
t=12:09:23 200 1370090B  X-OpenSky-Cache: HIT   time=1789042078 states=10365

$ curl -sD - .../api/opensky | grep X-OpenSky
X-OpenSky-Cache: HIT
X-OpenSky-Auth: anon
X-OpenSky-Auth-Mode-Requested: oauth
X-OpenSky-Auth-Mode-Used: anon
X-OpenSky-Auth-Reason: oauth_invalid_or_missing
# このときの now=1789042205 に対して body.time=1789042078 → 127秒前のスナップショット

# 上流(OpenSky)を匿名で1回だけ直接叩いて、残クレジットを見る
$ curl -sD - "https://opensky-network.org/api/states/all?lamin=35&lomin=139&lamax=36&lomax=140"
HTTP/2 200
x-rate-limit-remaining: 395

# 視界の緯度経度を付けて叩き直したもの(結果は後述)
$ curl -sD - ".../api/opensky?lat=35.68&lon=139.77"

127秒前のスナップショットを平然と HIT で返している。9秒のはずのTTLが効いていません。答えは vite.config.js の中にありました。

// vite.config.js(抜粋)
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
// The global /states/all this proxy fetches costs 4 CREDITS per call against
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
// the whole quota in ~8h and the layer then hard-died until the daily reset
function openskyAdaptiveTtlMs(remaining) {
  if (!Number.isFinite(remaining)) return OPENSKY_CACHE_MS;  // 9s
  if (remaining > 2400) return OPENSKY_CACHE_MS;             // 9s
  if (remaining > 1200) return 30_000;
  if (remaining > 400)  return 90_000;
  return 300_000;                                            // ← 残395はここ
}

// 200 応答でも中身が古すぎるなら、視界スコープの別ソースへ逃がす
const OPENSKY_SOURCE_STALE_MS = 120_000;

つまり、上流が返す残クレジットに応じてキャッシュTTLを自分で伸ばす仕組みが入っていて、匿名アクセスの私の残高は 395。最下段の 300,000ms、つまり 5分間隔まで引き伸ばされていたわけです。9秒どころではありませんでした。コメントには「アプリを開きっぱなしにした1日で8時間ほどで日次クォータを焼き切り、レイヤーが日次リセットまで死んだ」という実戦記録がそのまま書いてあって、この数字が机上の設計ではなく事故の後始末だと分かります。

そして、ここからが本当に良くできているところでした。ひとつ前のコードブロックの ?lat=35.68&lon=139.77——つまり「いま見ている場所」——を付けて叩き直すと、返ってくるヘッダが X-OpenSky-Cache: MISS / X-OpenSky-Auth: adsblol-regional / X-OpenSky-Auth-Reason: opensky_snapshot_stale_regional_fallback に変わります。ボディの time は叩いた瞬間の値、states は東京から250海里圏の 126機。OPENSKY_SOURCE_STALE_MS = 120_000 を超えた古いスナップショットは、視界の緯度経度がある限り別ソースに切り替わるわけです。

全球1万機の2分前のスナップショットを見せ続けるより、視界250海里の126機を今の時刻で見せるほうが誠実だ、という判断です。ソースのコメントにも「a 200 response can still contain an old OpenSky snapshot」と、まさにその理由が書いてありました。200が返ってきたことと、データが新しいことは別という、運用側からすると身に染みる話です。

しかもこの切り替えは画面にちゃんと出てきます。

God's Eye Viewのコンタクト画面。オースティン上空の3D地球儀に航空機のマーカーが並び、中央に「N525MC 13900 ft · 332 kts」のラベル。右のCONTEXTパネルには「Flights 121・1/4 adsb.lol · observed or mapped nearby context」「Military flights 1 adsb.lol」「AIS vessels ? AISStream · feed unavailable」と、データソース名と件数が項目ごとに表示されている

右のパネルで、Flights の下に小さく adsb.lol と出ているのが、いま話したフォールバック先です。しばらく待つと同じ欄が OpenSky Network に戻りました(5分のTTLが切れて全球スナップショットが更新されたタイミングですね)。AIS vessels は AISStream · feed unavailable と、キーが無いことを隠さずに書いてあります。この画面を撮った直後には OVERPASS TEMPORARILY UNAVAILABLE / RETRYING IN 24S というトーストも出ていました。OpenStreetMapのOverpassは4つのミラーを順に試す実装になっているんですが、そのときは全部落ちていたようです。

このパネルの最下部に、こういう1文が添えられています。

Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.

「映っていないことは、無いことの証明ではない」。公開データを集めて地図に載せる種類のソフトで、この但し書きをUIに常設しているのは、個人的にかなり好感が持てました。

ハマったところ:設定パネルが、そもそも存在しない

さて、ここで私は一度詰まりました。READMEには「アプリ内の POWER UP パネルからキーを貼れる」と書いてあるのに、その POWER UP ボタンが画面のどこにも無いのです。

DevToolsのネットワークを見ると、ブラウザから出た GET /api/setup/status だけが 403。コンテナの中から curl で叩いたときは200だったので、最初は「Hostヘッダの検査に引っかかっているんだろう」と当たりを付けました。ところが Host: を差し替えて試すと、ポート違いの 127.0.0.1:38610 でも普通に200が返ってきます。仮説が外れました。

エラーメッセージをよく見ると、2種類あることに気づきます。

# コンテナ内から Host だけ差し替え(ソケットはループバック)
Host: 127.0.0.1:5173    200 1917B
Host: localhost:5173    200 1917B
Host: 127.0.0.1:38610   200 1917B
Host: 172.17.0.9:5173   403 {"error":"Provider Settings answers only local hostnames"}

# ホストのブラウザから(docker のポート公開越し)
403 {"error":"Provider Settings answers only the machine running the server"}

# ソケットの接続元がループバックになるよう socat で中継してみる
$ socat TCP4-LISTEN:5173,fork,reuseaddr TCP6:[::1]:4173
$ curl -s .../api/setup/status
{"keys":[{"id":"google-maps","title":"GOOGLE MAPS",...   [200]

犯人は Hostヘッダではなく ソケットの接続元アドレス でした。ゲートは remoteAddress がループバックかどうかと、Hostヘッダがローカル名かどうかを両方見ていて(src/keySetupCore.mjs の admitKeySetupRequest)、私の構成ではdockerのポート公開を経由するぶん、devサーバから見た接続元がブリッジのゲートウェイになっていた。だから前者で弾かれていたわけです。

socat でループバック経由に中継したら、同じブラウザ・同じURLで POWER UP · 8 KEYS WAITING のチップが現れました。

Provider Settingsのダイアログ。見出しは「Power up the globe」で、「The globe already flies keyless. Every key below switches on another real feed」の説明文。GOOGLE MAPS / OPENAI / AISSTREAM / NASA FIRMS / TOMTOM / CESIUM ION の入力欄が縦に並び、TOMTOMには「Real live traffic (keyless runs a simulation)」の注記。右下に「POWER UP · 8 KEYS WAITING」のチップ

src/main.js にはこう書いてありました。「the module removes its own surface when the dev-server endpoint is absent (prod builds, non-local visitors)」。つまり意図どおりの挙動で、ボタンが無いのはバグではなく、キーを書き込めるエンドポイントをサーバと同じマシン以外に一切見せない、という設計です。APIキーを平文で .env に書き込むエンドポイントなので、この厳しさは妥当だと思います。

ただ、踏んだ側の実感として言うと、403の本文がブラウザのコンソールに出ないまま機能ごと消えるので、原因に辿り着くまでが少し遠回りでした。ここは好みが分かれるところですが、私だったらチップ自体は出しておいて「このホストからは設定できません」と理由を1行出すかもしれません。ポート公開やトンネル越しに触る人は、まず確実にここを踏むはずなので。とはいえ、これは私の立て方(コンテナ+ポート公開)が想定の外側だっただけで、READMEどおりに自分のマシンで npm run dev する人には起きません。

ついでに測ったもの

同梱のテストも回しました。npm test は rc=0、報告された所要時間は41,324ms(体感の実測は70.7秒)。src 配下の *.test.mjs は 168本で、同じ src 配下の .js が 147本。テストファイルのほうがソースより多い構成です。

面白かったのはテストランナーの作りで、割り当て量(アロケーション)を測るマイクロベンチだけ --expose-gc を付けて直列に、しかも Node 24 でしか実行しないようになっていました。他のバージョンでは警告付きでスキップし、CIなど本気の場面では GEV_REQUIRE_ALLOCATION_GATE=1 でスキップを失敗に変えられる。engines が >=24.14.0 <25 と狭かった理由の一端はこれかもしれません(と書きつつ、ここは推測で、確認はしていません)。

試してみての所感

「APIキー無しで始められる」の中身は、私が最初に疑っていたようなダミーデータではありませんでした。衛星画像もフライトも公開カメラのカタログも実データで、キーが要る機能だけが明示的に灰色になる。境界線の引き方は誠実です。

そのうえで、いちばん学びがあったのはキーレスを成立させている裏側のほうでした。無料枠は当然ながら細いので、そこを「上流の残クレジットを見てキャッシュTTLを自分で伸ばす」「古すぎるスナップショットは視界スコープの別ソースに逃がす」「いま何を見せているかソース名を画面に出す」の3段で受けている。しかもそのコードのコメントに、8時間でクォータを焼き切った日の記録が残っている。こういう「事故のあとに増えた行」が読めるリポジトリは、それだけで参考になります。

私は普段バックエンド側にいるので、外部の無料APIに依存する画面を作るときの落としどころとして、この3段構えはそのまま持ち帰れると思っています。特に「200を返しつつ、鮮度が足りなければソースを切り替えて、切り替えたことをUIに出す」の部分。ここを黙ってやると、ユーザーは古い画面を新しいと思って見続けることになりますから。

自分で常用するかというと、そこは正直まだ分かりません。ただ、無料枠と付き合う設計のサンプルとしては、しばらく手元に置いておくつもりです。