Skip to content

Commit 6affe7e

Browse files
itstejaswiCopilot
andcommitted
Replace AirLabs with a shim you host yourself
AirLabs was the one free service still sending the CORS header, so the last commit reached for it. Its registration turns out to be closed - a waiting list with no date - which makes it useless as a recommendation. I should have checked that before suggesting it. adsb.lol still serves the data openly, and in the same schema Vector was built around: 16 aircraft over Delhi in testing, hex, callsign, position, altitude and speed all present. The only thing missing is one header, and only a server can add it. worker/index.js is that server: about a hundred lines on Cloudflare's free tier, which fetches from adsb.lol and adds the header. It allows only two known upstreams so it cannot become an open relay, validates coordinates rather than forwarding nonsense to a free service, and sends a user-agent naming this project - anonymous scrapers are precisely what these networks have been shutting out. The URL is supplied by the visitor and nothing is bundled. This repository is public, so a shipped address would become everyone's address and land adsb.lol with traffic from every fork at once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 31173c6 commit 6affe7e

7 files changed

Lines changed: 238 additions & 122 deletions

File tree

NOTICE.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Every other icon in the HUD is drawn for this project.
4040
These are services rather than bundled code, credited in-app as their terms
4141
require:
4242

43-
- **AirLabs** — live aircraft positions, when a key is configured
43+
- **adsb.lol** — live aircraft positions, via a shim you host
4444
- **airplanes.live** — live aircraft positions (see the note below)
4545
- **adsbdb** — route and airframe lookups
4646
- **Nominatim** / OpenStreetMap contributors — place-name geocoding
@@ -59,17 +59,24 @@ network closed browser access within roughly the same window.
5959
| adsb.lol | Serves data, sends no `access-control-allow-origin` |
6060
| adsb.fi | Serves data, sends no `access-control-allow-origin` |
6161
| OpenSky | `access-control-allow-origin: https://opensky-network.org` — its own site only |
62+
| AirLabs | Sends the header, but registration is closed to a waiting list |
6263

6364
None of that is something a browser can work around. CORS is enforced by the
6465
browser and only the API's owner can relax it.
6566

66-
AirLabs still answers browsers and is used when a key is supplied. **No key is
67-
bundled with this repository**, and that is deliberate: the project is public
68-
and MIT licensed, so a shipped key would become everyone's key and land the
69-
provider with traffic from every fork at once. Bring your own from
70-
[airlabs.co](https://airlabs.co) and enter it in settings.
67+
The data itself is still public: adsb.lol serves it to anyone who asks, just
68+
not to a browser. `worker/index.js` is a small Cloudflare Worker that fetches
69+
from adsb.lol and adds the one header a browser needs — nothing else. Deploy
70+
your own and give Vector the URL.
71+
72+
**No endpoint is bundled with this repository.** That is deliberate: the
73+
project is public and MIT licensed, so a shipped address would become
74+
everyone's address, and land whichever service it points at with traffic from
75+
every fork at once. The same reasoning is why the Worker sends a `user-agent`
76+
naming this project — a free feed deserves to know who is calling it.
7177

7278
Thanks are owed to all of these operators regardless. Running a public ADS-B
7379
network is expensive and largely thankless, and a hobby project like this one
7480
exists entirely on their generosity.
7581

82+

shared/src/config.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ export interface Config {
2020
/** Unit for the speed shown on labels. */
2121
speedUnit: SpeedUnit;
2222
/**
23-
* Key for the position feed, supplied by the visitor.
23+
* URL of a CORS shim for the position feed, hosted by the visitor.
2424
*
2525
* Empty by default and never bundled. Free ADS-B networks closed browser
26-
* access in August 2026; the one that still answers needs a key, and since
27-
* this repository is public a shipped key would become everyone's key.
26+
* access in August 2026; the data is still public, but a browser cannot read
27+
* it directly. `worker/index.js` in this repository is a ~100 line Cloudflare
28+
* Worker that adds the one header a browser needs.
2829
*/
29-
apiKey?: string;
30+
feedProxy?: string;
3031
}
3132

3233
export const DEFAULT_CONFIG: Config = {
@@ -39,7 +40,7 @@ export const DEFAULT_CONFIG: Config = {
3940
altitudeColor: true,
4041
showAirports: true,
4142
speedUnit: "kmh",
42-
apiKey: "",
43+
feedProxy: "",
4344
};
4445

4546
// --- guard rails -----------------------------------------------------------
@@ -95,7 +96,7 @@ export function sanitizeConfig(cfg: Config): Config {
9596
locationName:
9697
typeof cfg.locationName === "string" ? cfg.locationName : "",
9798
// Trimmed so a stray paste cannot produce a malformed request URL.
98-
apiKey: typeof cfg.apiKey === "string" ? cfg.apiKey.trim() : "",
99+
feedProxy: typeof cfg.feedProxy === "string" ? cfg.feedProxy.trim() : "",
99100
};
100101
}
101102

web/src/display/Display.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ export function Display() {
179179
while aircraft are arriving. */}
180180
{state.status?.ok === false && (
181181
<FeedKeyPanel
182-
apiKey={cfg.apiKey ?? ""}
183-
onSave={(apiKey) => conn.patchConfig({ apiKey })}
182+
feedProxy={cfg.feedProxy ?? ""}
183+
onSave={(feedProxy) => conn.patchConfig({ feedProxy })}
184184
/>
185185
)}
186186

web/src/display/FeedKeyPanel.tsx

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,39 @@ import { useEffect, useState } from "react";
22
import { Icon } from "./Icon.js";
33

44
interface Props {
5-
apiKey: string;
6-
onSave: (key: string) => void;
5+
feedProxy: string;
6+
onSave: (url: string) => void;
77
}
88

9+
const REPO = "https://github.com/itstejaswi/vector";
10+
911
/**
10-
* Feed key panel.
12+
* Feed panel.
1113
*
1214
* Shown only when the position feed is failing, because until August 2026 it
13-
* never needed to exist: airplanes.live answered browsers without a key and
15+
* had no reason to exist: airplanes.live answered browsers without a key and
1416
* the app had nothing to configure. Then every free ADS-B network closed
15-
* browser access at once, and the only one still answering wants a key.
17+
* browser access at once.
18+
*
19+
* The data is still public - adsb.lol serves it openly - but without an
20+
* `access-control-allow-origin` header the browser discards the response
21+
* before the page sees it, and only the server can change that. So the fix is
22+
* a small shim the visitor runs themselves, and this panel is where its URL
23+
* goes.
1624
*
1725
* It is deliberately not a permanent settings pane. Nobody opens a flight
18-
* tracker wanting to think about API credentials, so the panel appears when
19-
* there is a problem it can solve, explains what happened, and goes away once
20-
* aircraft are on screen.
26+
* tracker wanting to think about infrastructure, so the panel appears when
27+
* there is a problem it can solve and goes away once aircraft are on screen.
2128
*/
22-
export function FeedKeyPanel({ apiKey, onSave }: Props) {
23-
const [value, setValue] = useState(apiKey);
29+
export function FeedKeyPanel({ feedProxy, onSave }: Props) {
30+
const [value, setValue] = useState(feedProxy);
2431
const [open, setOpen] = useState(false);
2532

26-
// Reflect a key set elsewhere (another tab, a reset) without clobbering
33+
// Reflect a URL set elsewhere (another tab, a reset) without clobbering
2734
// whatever is being typed here.
2835
useEffect(() => {
29-
if (!open) setValue(apiKey);
30-
}, [apiKey, open]);
36+
if (!open) setValue(feedProxy);
37+
}, [feedProxy, open]);
3138

3239
function save() {
3340
onSave(value.trim());
@@ -42,15 +49,15 @@ export function FeedKeyPanel({ apiKey, onSave }: Props) {
4249
onClick={() => setOpen(true)}
4350
>
4451
<Icon name="status" size={13} />
45-
<span>Fix the feed</span>
52+
<span>Why is the map empty?</span>
4653
</button>
4754
);
4855
}
4956

5057
return (
5158
<div className="feedkey">
5259
<div className="feedkey-head">
53-
<strong>The feed needs a key</strong>
60+
<strong>The feed closed its doors</strong>
5461
<button
5562
type="button"
5663
className="feedkey-close"
@@ -64,25 +71,32 @@ export function FeedKeyPanel({ apiKey, onSave }: Props) {
6471
<p className="feedkey-body">
6572
Vector read positions from airplanes.live, which was open to browsers
6673
and needed no key. In August 2026 that changed, and every other free
67-
ADS-B network closed browser access at about the same time. Nothing
68-
here can work around it: only the API's owner can allow a browser to
69-
call it.
74+
ADS-B network closed browser access at about the same time.
7075
</p>
7176
<p className="feedkey-body">
72-
AirLabs still does.{" "}
73-
<a href="https://airlabs.co" target="_blank" rel="noreferrer">
74-
Take a free key
77+
The data is still public. adsb.lol serves it to anyone who asks - just
78+
not to a browser, because it sends no CORS header and only the server
79+
can change that.
80+
</p>
81+
<p className="feedkey-body">
82+
The way round it is a shim you run yourself:{" "}
83+
<a
84+
href={`${REPO}/blob/main/worker/index.js`}
85+
target="_blank"
86+
rel="noreferrer"
87+
>
88+
worker/index.js
7589
</a>{" "}
76-
and paste it below. It stays in this browser and is sent only to
77-
AirLabs.
90+
is about a hundred lines, free to run on Cloudflare, and does nothing
91+
but fetch and add the header. Deploy it, then paste the URL here.
7892
</p>
7993

8094
<div className="feedkey-row">
8195
<input
82-
type="password"
96+
type="url"
8397
className="feedkey-input"
8498
value={value}
85-
placeholder="AirLabs API key"
99+
placeholder="https://your-worker.workers.dev"
86100
spellCheck={false}
87101
autoComplete="off"
88102
onChange={(e) => setValue(e.target.value)}
@@ -96,7 +110,7 @@ export function FeedKeyPanel({ apiKey, onSave }: Props) {
96110
</button>
97111
</div>
98112

99-
{apiKey && (
113+
{feedProxy && (
100114
<button
101115
type="button"
102116
className="feedkey-clear"
@@ -105,7 +119,7 @@ export function FeedKeyPanel({ apiKey, onSave }: Props) {
105119
onSave("");
106120
}}
107121
>
108-
Remove the saved key
122+
Forget this address
109123
</button>
110124
)}
111125
</div>

web/src/lib/providers.ts

Lines changed: 36 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@
88
// adsb.lol serves data, sends no access-control-allow-origin
99
// adsb.fi serves data, sends no access-control-allow-origin
1010
// OpenSky allows only its own origin
11+
// AirLabs sends the header, but registration is closed
1112
//
1213
// A browser cannot work around any of that: CORS is enforced by the browser
13-
// and only the API's owner can relax it. So the feed is now pluggable, and the
14-
// one provider still answering browsers - AirLabs - is used when the visitor
15-
// supplies a key.
14+
// and only the API's owner can relax it. So the feed is pluggable, and the
15+
// working route is now a shim the visitor hosts themselves - see
16+
// `worker/index.js`, which fetches from adsb.lol and adds the one header a
17+
// browser needs.
1618
//
17-
// Keys are supplied by the visitor and kept in their own localStorage. None is
18-
// bundled. That is partly hygiene, and partly courtesy: this repository is
19-
// public and permissively licensed, so a shipped key would become everyone's
20-
// key and land the provider with traffic from every fork at once.
19+
// Nothing is bundled and no endpoint of ours is shared. This repository is
20+
// public and permissively licensed, so anything shipped here would become
21+
// everyone's, and land whichever service it points at with traffic from every
22+
// fork at once.
2123

2224
import type { Config } from "@shared/index.js";
2325

@@ -53,9 +55,6 @@ export interface Provider {
5355
parse(json: unknown): RawAircraft[];
5456
}
5557

56-
const M_PER_FOOT = 0.3048;
57-
const KMH_PER_KNOT = 1.852;
58-
5958
/**
6059
* airplanes.live - the original feed.
6160
*
@@ -77,88 +76,50 @@ export const airplanesLive: Provider = {
7776
};
7877

7978
/**
80-
* AirLabs - the fallback, and as of August 2026 the only free service still
81-
* sending `access-control-allow-origin: *`.
79+
* A CORS shim you host yourself.
80+
*
81+
* adsb.lol and adsb.fi still serve the data Vector was built around; they just
82+
* do not send `access-control-allow-origin`, so a browser discards it. A tiny
83+
* Cloudflare Worker in between adds the header and nothing else - see
84+
* `worker/index.js` in this repository, roughly 100 lines and free to run.
8285
*
83-
* Needs a free key, which the visitor supplies. Its units differ from the
84-
* ADS-B convention the rest of the app uses: metres and km/h rather than feet
85-
* and knots, so both are converted here rather than leaking outward.
86+
* The URL is supplied by the visitor, because it is their Worker: nothing is
87+
* bundled, nothing is shared, and no quota of ours is spent by a fork.
8688
*/
87-
export const airlabs: Provider = {
88-
id: "airlabs",
89-
label: "AirLabs",
90-
ready: (cfg) => Boolean(cfg.apiKey?.trim()),
91-
// AirLabs takes a radius in kilometres; 250 nm keeps the two providers
92-
// interchangeable and sits inside what it will answer.
89+
export const proxy: Provider = {
90+
id: "proxy",
91+
label: "adsb.lol",
92+
ready: (cfg) => Boolean(cfg.feedProxy?.trim()),
9393
maxRadiusNm: 250,
9494
url: (cfg, radiusNm) => {
95-
const km = Math.round(radiusNm * KMH_PER_KNOT);
96-
const key = encodeURIComponent(cfg.apiKey?.trim() ?? "");
95+
const base = (cfg.feedProxy ?? "").trim().replace(/\/+$/, "");
9796
return (
98-
`https://airlabs.co/api/v9/flights?lat=${cfg.centerLat}` +
99-
`&lng=${cfg.centerLon}&distance=${km}&api_key=${key}`
97+
`${base}?lat=${cfg.centerLat}&lon=${cfg.centerLon}` +
98+
`&radius=${radiusNm}&source=adsb.lol`
10099
);
101100
},
102101
parse: (json) => {
103102
const body = json as {
104-
response?: AirLabsFlight[];
105-
error?: { message?: string };
103+
ac?: RawAircraft[];
104+
aircraft?: RawAircraft[];
105+
error?: string;
106106
};
107-
if (body.error) throw new Error(body.error.message ?? "AirLabs error");
108-
const list = body.response ?? [];
109-
110-
return list.map((f) => ({
111-
hex: f.hex,
112-
flight: f.flight_icao ?? f.flight_iata ?? f.flight_number,
113-
lat: f.lat,
114-
lon: f.lng,
115-
// AirLabs reports altitude in metres; the app works in feet.
116-
alt_baro:
117-
typeof f.alt === "number" ? Math.round(f.alt / M_PER_FOOT) : undefined,
118-
// ...and ground speed in km/h, where the app works in knots.
119-
gs: typeof f.speed === "number" ? f.speed / KMH_PER_KNOT : undefined,
120-
track: f.dir,
121-
baro_rate:
122-
typeof f.v_speed === "number"
123-
? Math.round((f.v_speed / M_PER_FOOT) * 60)
124-
: undefined,
125-
r: f.reg_number,
126-
t: f.aircraft_icao,
127-
seen: f.updated ? Math.max(0, Date.now() / 1000 - f.updated) : undefined,
128-
}));
107+
if (body.error) throw new Error(body.error);
108+
// adsb.lol answers in the same shape as airplanes.live, so nothing
109+
// downstream has to know which one it is reading.
110+
return body.ac ?? body.aircraft ?? [];
129111
},
130112
};
131113

132-
/** The subset of AirLabs' flight record this app consumes. */
133-
interface AirLabsFlight {
134-
hex?: string;
135-
reg_number?: string;
136-
lat?: number;
137-
lng?: number;
138-
/** Altitude in metres. */
139-
alt?: number;
140-
dir?: number;
141-
/** Ground speed in km/h. */
142-
speed?: number;
143-
/** Vertical speed in metres per second. */
144-
v_speed?: number;
145-
flight_number?: string;
146-
flight_icao?: string;
147-
flight_iata?: string;
148-
aircraft_icao?: string;
149-
/** Unix seconds. */
150-
updated?: number;
151-
}
152-
153-
export const PROVIDERS: Provider[] = [airlabs, airplanesLive];
114+
export const PROVIDERS: Provider[] = [proxy, airplanesLive];
154115

155116
/**
156117
* Pick the provider to poll.
157118
*
158-
* A supplied key means the visitor has chosen AirLabs deliberately, so it wins.
159-
* Without one there is only airplanes.live, which will fail while its block
160-
* stands - but failing against the original feed reports something truer than
161-
* failing against a service the visitor never configured.
119+
* A configured proxy means the visitor has set one up deliberately, so it
120+
* wins. Without one there is only airplanes.live, which will fail while its
121+
* block stands - but failing against the original feed reports something truer
122+
* than failing against a service the visitor never configured.
162123
*/
163124
export function selectProvider(cfg: Config): Provider {
164125
return PROVIDERS.find((p) => p.ready(cfg)) ?? airplanesLive;

web/src/lib/skyfeed.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,8 @@ function describeFeedError(err: unknown, provider: Provider): string {
123123
if (/Failed to fetch|NetworkError|Load failed/i.test(message)) {
124124
// The likely cause, and the only one the visitor can do anything about.
125125
return provider.id === "airplanes.live"
126-
? "airplanes.live is refusing browser requests - add an AirLabs key in settings"
127-
: `${provider.label} is unreachable`;
128-
}
129-
if (/api_key|apikey/i.test(message)) {
130-
return "that key was not accepted";
126+
? "airplanes.live is refusing browser requests - see the feed panel"
127+
: `${provider.label} is unreachable - check the proxy URL`;
131128
}
132129
if (/^HTTP 4\d\d$/.test(message)) {
133130
return `${provider.label} refused the request (${message})`;
@@ -446,7 +443,7 @@ export class SkyFeed {
446443
patch.centerLat !== undefined ||
447444
patch.centerLon !== undefined ||
448445
patch.radiusMiles !== undefined ||
449-
patch.apiKey !== undefined
446+
patch.feedProxy !== undefined
450447
) {
451448
// Old tracks belong to the old view; keep them and they'd smear across
452449
// the map as unrelated streaks.

0 commit comments

Comments
 (0)