@aeroswarm/live-server 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # @aeroswarm/live-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The live-picture runtime AeroSwarm's `client-service` uses, packaged so a backend can serve the
4
+ **same live WebSocket** (`/ws/tracks/live`) without running AeroSwarm's services. `@aeroswarm/client`
5
+ connects to it unchanged.
6
+
7
+ | Piece | What it does |
8
+ |---|---|
9
+ | `createTrackState` | Current tracks: `active → stale → lost → removed`, de-dupe by Mode-S address, flight-plan attachment, filtered snapshots |
10
+ | `createFlightPlanStore` | AFTN messages folded into flight plans; correlation to live tracks (Mode-S, then callsign; the leg in progress wins) |
11
+ | `createMovementDetector` | Takeoff / landing detection from consecutive track samples |
12
+ | `createLiveServer` | WebSocket handlers speaking the AeroSwarm envelope protocol |
13
+
14
+ No I/O: no database, Kafka, Redis or auth. The host injects `authenticate` and persists from the
15
+ events these classes emit. Depends only on `@aeroswarm/core` (bundled) and `zod`.
16
+
17
+ ## Wiring
18
+
19
+ ```ts
20
+ import { createFlightPlanStore, createTrackState, createLiveServer, createMovementDetector } from "@aeroswarm/live-server";
21
+
22
+ const plans = createFlightPlanStore();
23
+ const state = createTrackState({ attachFlightPlan: plans.attach });
24
+ plans.on("changed", (plan) => {
25
+ state.refreshFlightPlans((t) => plans.matches(plan, t)); // push the plan to tracks already on screen
26
+ persist(plan); // host's job
27
+ });
28
+ state.startSweep(); // recomputes stale/lost every 5 s; required, or tracks never go lost
29
+ plans.startSweep();
30
+
31
+ const live = createLiveServer({
32
+ state,
33
+ authenticate: async (token) => (await verify(token)) ? { airportId: "WARR" } : null,
34
+ });
35
+
36
+ // feeds (see @aeroswarm/ingest)
37
+ trackSource.start((ev) => { if (ev.kind === "track") state.ingest(ev.event as FusedTrackEvent); });
38
+ aftnSource.start((ev) => { if (ev.kind === "flightplan") plans.apply(ev.event); });
39
+
40
+ // movements
41
+ const movements = createMovementDetector();
42
+ state.on("update", (t) => {
43
+ const m = movements.update({ trackId: t.trackId, icao24: t.icao24, callsign: t.callsign, airportId: t.airportId,
44
+ at: t.lastSeenAt, lat: t.lat!, lon: t.lon!, altitudeFt: t.altitudeFt, groundSpeedKt: t.groundSpeedKt });
45
+ if (m) saveMovement(m);
46
+ });
47
+ state.on("removed", ({ trackId }) => movements.forget(trackId));
48
+ ```
49
+
50
+ ### Bun
51
+
52
+ ```ts
53
+ Bun.serve({
54
+ fetch(req, srv) {
55
+ const url = new URL(req.url);
56
+ if (url.pathname === "/ws/tracks/live")
57
+ return srv.upgrade(req, { data: { token: url.searchParams.get("token") } }) ? undefined : new Response("upgrade failed", { status: 400 });
58
+ // ...
59
+ },
60
+ websocket: {
61
+ open: (ws) => live.handlers.open(ws, { token: ws.data.token }),
62
+ message: (ws, raw) => live.handlers.message(ws, raw),
63
+ close: (ws) => live.handlers.close(ws),
64
+ },
65
+ });
66
+ ```
67
+
68
+ ### `ws` library
69
+
70
+ ```ts
71
+ wss.on("connection", (ws, req) => {
72
+ void live.handlers.open(ws, { token: new URL(req.url!, "http://x").searchParams.get("token") });
73
+ ws.on("message", (m) => live.handlers.message(ws, m.toString()));
74
+ ws.on("close", () => live.handlers.close(ws));
75
+ });
76
+ ```
77
+
78
+ ## Protocol
79
+
80
+ JSON envelopes `{ type, seq, serverTime, payload }`, `seq` strictly increasing across the server.
81
+
82
+ | Server sends | Payload |
83
+ |---|---|
84
+ | `snapshot` | `{ tracks: CurrentTrack[] }`, on `subscribe` and `update-filter` |
85
+ | `track.updated` | `{ track }`, rate-limited per client per track (`maxUpdateHz`); status and flight-plan changes are never throttled |
86
+ | `track.removed` | `{ trackId, reason }`, reason is `dropped`, `superseded`, `lost`, `evicted` (over `maxTracks`) or `filtered` (left the client's filter) |
87
+ | `source.status` / `stream.degraded` | via `live.sourceStatus()` / `live.degraded()` |
88
+ | `stream.heartbeat` | every `heartbeatMs` (default 15 s) and once on open |
89
+ | `stream.error` | `{ code, message, recoverable: true }` for `BAD_JSON`, `BAD_SUBSCRIBE`, `BAD_FILTER` |
90
+
91
+ Client sends `subscribe` (`bbox`, `airportId`, `minConfidence`, `maxTracks`, `maxUpdateHz`, …) and
92
+ `update-filter`. Always JSON; `encoding: "msgpack"` is accepted but ignored.
93
+
94
+ Close codes: `4001` no token, `4003` rejected by `authenticate`, `1001` server stopping.
95
+ Messages that arrive before `authenticate` resolves are queued, not dropped.
96
+
97
+ ### Airport scoping
98
+
99
+ `authenticate` returning `{ airportId }` pins the connection to that airport, whatever the client
100
+ subscribes with (a sensor token). Return `{}` for a viewer token that may ask for any airport.
101
+
102
+ ## Status semantics
103
+
104
+ `toCurrentTrack` computes `status` once. `TrackState.sweep()` recomputes it from `lastSeenAt` and
105
+ emits only when it actually changes, so a quiet stretch does not spam clients. Defaults: stale after
106
+ 15 s, lost after 90 s, removed after 30 min (`staleAfterMs`, `lostAfterMs`, `removeAfterMs`).
107
+
108
+ ## Scaling
109
+
110
+ State is in-process memory: run one instance, or put a shared fan-out in front.
111
+
112
+ ## Develop
113
+
114
+ ```
115
+ pnpm --filter @aeroswarm/live-server test
116
+ pnpm --filter @aeroswarm/live-server build
117
+ ```
118
+
119
+ `test/client-integration.test.ts` runs the real `AeroWsClient` / `TrackCache` from
120
+ `@aeroswarm/client` against a live server over a socket, including `reconnect()`.
@@ -0,0 +1,236 @@
1
+ import { CurrentTrack, FusedTrackEvent, FlightPlan, FlightPlanEvent } from '@aeroswarm/core/types';
2
+
3
+ /** Minimal socket surface. Satisfied by Bun's `ServerWebSocket` and the `ws` library. */
4
+ interface LiveSocket {
5
+ send(data: string): unknown;
6
+ close(code?: number, reason?: string): unknown;
7
+ /** Optional; used for backpressure when present. */
8
+ getBufferedAmount?(): number;
9
+ }
10
+ interface Logger {
11
+ info(obj: unknown, msg?: string): void;
12
+ warn(obj: unknown, msg?: string): void;
13
+ error(obj: unknown, msg?: string): void;
14
+ }
15
+ type Bbox = [number, number, number, number];
16
+ interface TrackFilter {
17
+ airportId?: string;
18
+ bbox?: Bbox;
19
+ minConfidence?: number;
20
+ maxTracks?: number;
21
+ }
22
+ type RemoveReason = "dropped" | "superseded" | "lost" | "evicted" | "filtered";
23
+ interface UpdateInfo {
24
+ /** Bypass per-client rate limiting (status change, flight-plan change). */
25
+ force: boolean;
26
+ }
27
+ type TrackListener = (track: CurrentTrack, info: UpdateInfo) => void;
28
+ type RemovedListener = (ev: {
29
+ trackId: string;
30
+ reason: RemoveReason;
31
+ }) => void;
32
+
33
+ interface TrackStateOptions {
34
+ /** Default STALE_AFTER_MS (15 s). */
35
+ staleAfterMs?: number;
36
+ /** Default LOST_AFTER_MS (90 s). */
37
+ lostAfterMs?: number;
38
+ /** Tracks unseen this long are removed. 0 disables. Default 30 min. */
39
+ removeAfterMs?: number;
40
+ /** Drop tracks outside this box unless heading into it within the lookahead. */
41
+ interestBbox?: Bbox;
42
+ /** Minutes. Default 90. */
43
+ interestLookaheadMin?: number;
44
+ /** Clock, for tests. Default Date.now. */
45
+ now?: () => number;
46
+ /** Attach flight-plan data, e.g. `flightPlanStore.attach`. */
47
+ attachFlightPlan?: (track: CurrentTrack) => CurrentTrack;
48
+ }
49
+ /** Live picture of all tracks: lifecycle (active, stale, lost, removed),
50
+ * de-duplication by Mode-S address, flight-plan attachment. No I/O. */
51
+ declare class TrackState {
52
+ private readonly opts;
53
+ private readonly tracks;
54
+ private readonly byIcao24;
55
+ private readonly onUpdate;
56
+ private readonly onRemoved;
57
+ private readonly staleAfterMs;
58
+ private readonly lostAfterMs;
59
+ private readonly removeAfterMs;
60
+ private sweepTimer;
61
+ private readonly now;
62
+ constructor(opts?: TrackStateOptions);
63
+ private statusFor;
64
+ on(event: "update", fn: TrackListener): () => void;
65
+ on(event: "removed", fn: RemovedListener): () => void;
66
+ get size(): number;
67
+ get(trackId: string): CurrentTrack | undefined;
68
+ values(): IterableIterator<CurrentTrack>;
69
+ /** Apply one fused event. Returns the resulting track, or null when the event
70
+ * removed a track or was ignored. */
71
+ ingest(fused: FusedTrackEvent): CurrentTrack | null;
72
+ /** Remove a track (no-op if unknown). */
73
+ drop(trackId: string, reason?: RemoveReason): void;
74
+ /** Recompute `status` from `lastSeenAt` and remove long-gone tracks.
75
+ * Emits only on a real status change. Call every few seconds. */
76
+ sweep(now?: number): void;
77
+ /** Re-attach flight plans, emitting only for tracks whose plan data changed.
78
+ * Call when a plan changes so clients see it before the next radar update. */
79
+ refreshFlightPlans(match?: (track: CurrentTrack) => boolean): void;
80
+ snapshot(filter?: TrackFilter): CurrentTrack[];
81
+ /** Run `sweep` on an interval. Idempotent. */
82
+ startSweep(intervalMs?: number): void;
83
+ stopSweep(): void;
84
+ private remove;
85
+ private emitUpdate;
86
+ private inInterestZone;
87
+ }
88
+ declare function createTrackState(opts?: TrackStateOptions): TrackState;
89
+
90
+ interface FlightPlanStoreOptions {
91
+ /** Plans untouched this long are dropped from memory. Default 48 h. */
92
+ ttlMs?: number;
93
+ }
94
+ interface FlightPlanFilter {
95
+ airportId?: string;
96
+ status?: string;
97
+ callsign?: string;
98
+ limit?: number;
99
+ }
100
+ type Listener<T> = (value: T) => void;
101
+ /** Flight plans folded from AFTN messages, indexed for correlation with live
102
+ * tracks (Mode-S address first, then callsign). No I/O: the host persists via
103
+ * the `changed` event and restores with `load`. */
104
+ declare class FlightPlanStore {
105
+ private readonly plans;
106
+ private readonly byCallsign;
107
+ private readonly byIcao24;
108
+ private readonly onChanged;
109
+ private readonly onExpired;
110
+ private readonly ttlMs;
111
+ private sweepTimer;
112
+ constructor(opts?: FlightPlanStoreOptions);
113
+ /** `changed`: a plan was created or updated. `expired`: a plan id left memory. */
114
+ on(event: "changed", fn: Listener<FlightPlan>): () => void;
115
+ on(event: "expired", fn: Listener<string>): () => void;
116
+ get size(): number;
117
+ get(id: string): FlightPlan | undefined;
118
+ /** Fold one AFTN message into its flight. Returns the updated plan, or null if ignored. */
119
+ apply(ev: FlightPlanEvent): FlightPlan | null;
120
+ /** Restore plans (e.g. from a database after a restart). Does not emit `changed`. */
121
+ load(plans: FlightPlan[], now?: number): number;
122
+ /** Best plan for a live track. Every plan sharing the Mode-S address or
123
+ * callsign is a candidate (an airframe flies several legs a day); the leg
124
+ * in progress, or nearest in time, wins. Cancelled plans never match. */
125
+ find(track: Pick<CurrentTrack, "icao24" | "callsign">, now?: number): FlightPlan | undefined;
126
+ /** Track with its flight-plan summary attached; unchanged when none matches.
127
+ * An arrow property so it can be passed unbound, e.g. `{ attachFlightPlan: store.attach }`. */
128
+ readonly attach: (track: CurrentTrack) => CurrentTrack;
129
+ /** True if `plan` could correlate with `track`. Pair with `TrackState.refreshFlightPlans`. */
130
+ matches(plan: FlightPlan, track: Pick<CurrentTrack, "icao24" | "callsign">): boolean;
131
+ list(filter?: FlightPlanFilter): FlightPlan[];
132
+ /** Drop plans past the TTL; returns their ids. */
133
+ sweep(now?: number): string[];
134
+ startSweep(intervalMs?: number): void;
135
+ stopSweep(): void;
136
+ private index;
137
+ private unindex;
138
+ }
139
+ declare function createFlightPlanStore(opts?: FlightPlanStoreOptions): FlightPlanStore;
140
+
141
+ /**
142
+ * Takeoff / landing detection from consecutive track states.
143
+ *
144
+ * Pure, dependency-free state machine. One detector instance tracks many
145
+ * targets. Copied from process-service so backends running without
146
+ * AeroSwarm's services get identical detection.
147
+ *
148
+ * Heuristics (dual-threshold hysteresis so noisy altitude/on-ground
149
+ * flags don't double-count):
150
+ * ground → airborne at ≥ TAKEOFF_MIN_GS_KT and (onGround=false or
151
+ * alt > AIRBORNE_ALT_FT) ⇒ takeoff
152
+ * airborne → ground at ≤ LANDING_MAX_GS_KT and (onGround=true or
153
+ * alt < GROUND_ALT_FT) ⇒ landing
154
+ */
155
+ type MovementType = "takeoff" | "landing";
156
+ interface MovementEvent {
157
+ trackId: string;
158
+ icao24: string | null;
159
+ callsign: string | null;
160
+ airportId?: string;
161
+ type: MovementType;
162
+ /** Unix epoch ms of the transition */
163
+ at: number;
164
+ lat: number;
165
+ lon: number;
166
+ }
167
+ interface MovementSample {
168
+ trackId: string;
169
+ icao24?: string | null;
170
+ callsign?: string | null;
171
+ airportId?: string;
172
+ at: number;
173
+ lat: number;
174
+ lon: number;
175
+ altitudeFt?: number | null;
176
+ groundSpeedKt?: number | null;
177
+ onGround?: boolean;
178
+ }
179
+ declare class MovementDetector {
180
+ private phases;
181
+ /** Feed one track state; returns a movement event on a transition. */
182
+ update(s: MovementSample): MovementEvent | null;
183
+ /** Forget one track (call when its track is removed). */
184
+ forget(trackId: string): void;
185
+ /** Forget tracks not updated within maxAgeMs (call periodically). */
186
+ sweep(maxAgeMs: number, now?: number): void;
187
+ get trackedCount(): number;
188
+ private makeEvent;
189
+ }
190
+ declare function createMovementDetector(): MovementDetector;
191
+
192
+ /** Result of `authenticate`. `airportId` pins the connection to that airport
193
+ * (a sensor token); leave it undefined for viewer tokens that may request any. */
194
+ interface LiveAuth {
195
+ airportId?: string;
196
+ }
197
+ interface LiveServerOptions {
198
+ state: TrackState;
199
+ /** Return null to reject. A missing token is rejected (4001) before this is called. */
200
+ authenticate: (token: string) => Promise<LiveAuth | null> | LiveAuth | null;
201
+ /** Per-client, per-track push rate when the client does not ask for one. Default 10. */
202
+ defaultUpdateHz?: number;
203
+ /** Default 15 000. */
204
+ heartbeatMs?: number;
205
+ /** Skip non-forced deltas for sockets buffering more than this. Default 1 MiB. */
206
+ maxBufferedBytes?: number;
207
+ logger?: Logger;
208
+ }
209
+ interface LiveOpenContext {
210
+ /** Value of the `token` query parameter, read by the host at upgrade time. */
211
+ token: string | null;
212
+ }
213
+ interface LiveServerStats {
214
+ clients: number;
215
+ subscribed: number;
216
+ messagesSent: number;
217
+ deltasSkipped: number;
218
+ }
219
+ type LiveMessage = string | ArrayBuffer | Uint8Array;
220
+ interface LiveServer {
221
+ handlers: {
222
+ open(ws: LiveSocket, ctx: LiveOpenContext): Promise<void>;
223
+ message(ws: LiveSocket, raw: LiveMessage): void;
224
+ close(ws: LiveSocket): void;
225
+ };
226
+ /** Tell clients a data source changed health. */
227
+ sourceStatus(sourceId: string, status: "healthy" | "degraded" | "offline"): void;
228
+ /** Tell clients the stream is degraded (e.g. a feed went silent). */
229
+ degraded(reason: string, sourceId?: string): void;
230
+ stats(): LiveServerStats;
231
+ /** Stop timers and close every socket. */
232
+ stop(): void;
233
+ }
234
+ declare function createLiveServer(opts: LiveServerOptions): LiveServer;
235
+
236
+ export { type Bbox, type FlightPlanFilter, FlightPlanStore, type FlightPlanStoreOptions, type LiveAuth, type LiveMessage, type LiveOpenContext, type LiveServer, type LiveServerOptions, type LiveServerStats, type LiveSocket, type Logger, MovementDetector, type MovementEvent, type MovementSample, type MovementType, type RemoveReason, type RemovedListener, type TrackFilter, type TrackListener, TrackState, type TrackStateOptions, type UpdateInfo, createFlightPlanStore, createLiveServer, createMovementDetector, createTrackState };
package/dist/index.js ADDED
@@ -0,0 +1,929 @@
1
+ // ../core/dist/chunk-J2UTSYKD.js
2
+ function toCurrentTrack(fused, staleAfterMs = 15e3, lostAfterMs = 9e4) {
3
+ const age = Date.now() - fused.updatedAt;
4
+ const status = age > lostAfterMs ? "lost" : age > staleAfterMs ? "stale" : "active";
5
+ const src = fused.sources.length > 1 ? "FUSED" : protocolToSource(fused.protocol);
6
+ const rawHeading = fused.velocity?.heading;
7
+ const headingDeg = rawHeading != null ? (rawHeading % 360 + 360) % 360 : null;
8
+ return {
9
+ trackId: fused.trackId,
10
+ source: src,
11
+ icao24: fused.identity.icao24 ?? null,
12
+ callsign: fused.identity.callsign ?? null,
13
+ squawk: fused.identity.squawk ?? null,
14
+ ...fused.identity.targetType !== void 0 && { targetType: fused.identity.targetType },
15
+ ...fused.identity.vehicleFleet !== void 0 && { vehicleFleet: fused.identity.vehicleFleet },
16
+ ...fused.airportId !== void 0 && { airportId: fused.airportId },
17
+ lat: fused.position?.lat ?? null,
18
+ lon: fused.position?.lon ?? null,
19
+ altitudeFt: fused.position?.alt ?? null,
20
+ groundSpeedKt: fused.velocity?.groundSpeed ?? null,
21
+ headingDeg,
22
+ verticalRateFpm: fused.velocity?.verticalRate ?? null,
23
+ confidence: fused.confidence.overall,
24
+ lastSeenAt: fused.updatedAt,
25
+ status
26
+ };
27
+ }
28
+ function protocolToSource(protocol) {
29
+ if (protocol === "asterix-cat010") return "ASTERIX_CAT010";
30
+ if (protocol === "asterix-cat011") return "ASTERIX_CAT011";
31
+ if (protocol === "asterix-cat020") return "ASTERIX_CAT020";
32
+ if (protocol === "asterix-cat021") return "ASTERIX_CAT021";
33
+ if (protocol === "asterix-cat048") return "ASTERIX_CAT048";
34
+ if (protocol === "asterix-cat062") return "ASTERIX_CAT062";
35
+ return "ADSB";
36
+ }
37
+ var STALE_AFTER_MS = 15e3;
38
+ var LOST_AFTER_MS = 9e4;
39
+
40
+ // src/types.ts
41
+ var noopLogger = { info() {
42
+ }, warn() {
43
+ }, error() {
44
+ } };
45
+ function trackScore(t) {
46
+ return t.confidence * 1e13 + t.lastSeenAt;
47
+ }
48
+ function inBbox(t, bbox) {
49
+ if (!bbox) return true;
50
+ if (t.lat == null || t.lon == null) return true;
51
+ return t.lon >= bbox[0] && t.lat >= bbox[1] && t.lon <= bbox[2] && t.lat <= bbox[3];
52
+ }
53
+ function matchesFilter(t, f) {
54
+ if (f.airportId && t.airportId !== f.airportId) return false;
55
+ if (!inBbox(t, f.bbox)) return false;
56
+ if (f.minConfidence !== void 0 && t.confidence < f.minConfidence) return false;
57
+ return true;
58
+ }
59
+
60
+ // src/track-state.ts
61
+ var DEFAULT_REMOVE_AFTER_MS = 18e5;
62
+ function inside(lon, lat, b) {
63
+ return lon >= b[0] && lat >= b[1] && lon <= b[2] && lat <= b[3];
64
+ }
65
+ var TrackState = class {
66
+ constructor(opts = {}) {
67
+ this.opts = opts;
68
+ this.staleAfterMs = opts.staleAfterMs ?? STALE_AFTER_MS;
69
+ this.lostAfterMs = opts.lostAfterMs ?? LOST_AFTER_MS;
70
+ this.removeAfterMs = opts.removeAfterMs ?? DEFAULT_REMOVE_AFTER_MS;
71
+ this.now = opts.now ?? Date.now;
72
+ }
73
+ opts;
74
+ tracks = /* @__PURE__ */ new Map();
75
+ byIcao24 = /* @__PURE__ */ new Map();
76
+ onUpdate = /* @__PURE__ */ new Set();
77
+ onRemoved = /* @__PURE__ */ new Set();
78
+ staleAfterMs;
79
+ lostAfterMs;
80
+ removeAfterMs;
81
+ sweepTimer = null;
82
+ now;
83
+ statusFor(age) {
84
+ return age > this.lostAfterMs ? "lost" : age > this.staleAfterMs ? "stale" : "active";
85
+ }
86
+ on(event, fn) {
87
+ const set = event === "update" ? this.onUpdate : this.onRemoved;
88
+ set.add(fn);
89
+ return () => {
90
+ set.delete(fn);
91
+ };
92
+ }
93
+ get size() {
94
+ return this.tracks.size;
95
+ }
96
+ get(trackId) {
97
+ return this.tracks.get(trackId);
98
+ }
99
+ values() {
100
+ return this.tracks.values();
101
+ }
102
+ /** Apply one fused event. Returns the resulting track, or null when the event
103
+ * removed a track or was ignored. */
104
+ ingest(fused) {
105
+ if (fused.lifecycle === "dropped") {
106
+ this.remove(fused.trackId, "dropped");
107
+ return null;
108
+ }
109
+ if (this.opts.interestBbox && !this.inInterestZone(fused, this.opts.interestBbox)) return null;
110
+ const icao24 = fused.identity?.icao24;
111
+ if (icao24) {
112
+ const existing = this.byIcao24.get(icao24);
113
+ if (existing && existing !== fused.trackId && this.tracks.has(existing)) {
114
+ this.remove(existing, "superseded");
115
+ }
116
+ this.byIcao24.set(icao24, fused.trackId);
117
+ }
118
+ let current = toCurrentTrack(fused, this.staleAfterMs, this.lostAfterMs);
119
+ current = { ...current, status: this.statusFor(this.now() - current.lastSeenAt) };
120
+ if (this.opts.attachFlightPlan) current = this.opts.attachFlightPlan(current);
121
+ this.tracks.set(current.trackId, current);
122
+ this.emitUpdate(current, false);
123
+ return current;
124
+ }
125
+ /** Remove a track (no-op if unknown). */
126
+ drop(trackId, reason = "dropped") {
127
+ this.remove(trackId, reason);
128
+ }
129
+ /** Recompute `status` from `lastSeenAt` and remove long-gone tracks.
130
+ * Emits only on a real status change. Call every few seconds. */
131
+ sweep(now = this.now()) {
132
+ for (const [trackId, track] of [...this.tracks]) {
133
+ const age = now - track.lastSeenAt;
134
+ if (this.removeAfterMs > 0 && age > this.removeAfterMs) {
135
+ this.remove(trackId, "lost");
136
+ continue;
137
+ }
138
+ const status = this.statusFor(age);
139
+ if (track.status === status) continue;
140
+ const updated = { ...track, status };
141
+ this.tracks.set(trackId, updated);
142
+ this.emitUpdate(updated, true);
143
+ }
144
+ }
145
+ /** Re-attach flight plans, emitting only for tracks whose plan data changed.
146
+ * Call when a plan changes so clients see it before the next radar update. */
147
+ refreshFlightPlans(match) {
148
+ const attach = this.opts.attachFlightPlan;
149
+ if (!attach) return;
150
+ for (const [trackId, track] of [...this.tracks]) {
151
+ if (match && !match(track)) continue;
152
+ const next = attach(track);
153
+ if (JSON.stringify(next.flightPlan) === JSON.stringify(track.flightPlan)) continue;
154
+ this.tracks.set(trackId, next);
155
+ this.emitUpdate(next, true);
156
+ }
157
+ }
158
+ snapshot(filter = {}) {
159
+ let out = [...this.tracks.values()].filter((t) => matchesFilter(t, filter));
160
+ out.sort((a, b) => trackScore(b) - trackScore(a));
161
+ if (filter.maxTracks !== void 0) out = out.slice(0, filter.maxTracks);
162
+ return out;
163
+ }
164
+ /** Run `sweep` on an interval. Idempotent. */
165
+ startSweep(intervalMs = 5e3) {
166
+ if (this.sweepTimer) return;
167
+ this.sweepTimer = setInterval(() => this.sweep(), intervalMs);
168
+ this.sweepTimer.unref?.();
169
+ }
170
+ stopSweep() {
171
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
172
+ this.sweepTimer = null;
173
+ }
174
+ remove(trackId, reason) {
175
+ const track = this.tracks.get(trackId);
176
+ if (!track) return;
177
+ this.tracks.delete(trackId);
178
+ if (track.icao24 && this.byIcao24.get(track.icao24) === trackId) this.byIcao24.delete(track.icao24);
179
+ for (const fn of this.onRemoved) fn({ trackId, reason });
180
+ }
181
+ emitUpdate(track, force) {
182
+ for (const fn of this.onUpdate) fn(track, { force });
183
+ }
184
+ inInterestZone(fused, bbox) {
185
+ const lat = fused.position?.lat;
186
+ const lon = fused.position?.lon;
187
+ if (lat == null || lon == null) return true;
188
+ if (inside(lon, lat, bbox)) return true;
189
+ const heading = fused.velocity?.heading;
190
+ const speedKt = fused.velocity?.groundSpeed;
191
+ if (heading == null || speedKt == null || speedKt < 10) return false;
192
+ const hours = (this.opts.interestLookaheadMin ?? 90) / 60;
193
+ const distDeg = speedKt * 1.852 * hours / 111.32;
194
+ const rad = heading * Math.PI / 180;
195
+ const projLat = lat + distDeg * Math.cos(rad);
196
+ const projLon = lon + distDeg * Math.sin(rad) / Math.cos(lat * Math.PI / 180);
197
+ return inside(projLon, projLat, bbox);
198
+ }
199
+ };
200
+ function createTrackState(opts) {
201
+ return new TrackState(opts);
202
+ }
203
+
204
+ // ../core/dist/chunk-3T3CHHEM.js
205
+ var FLIGHT_PLAN_MATCH_WINDOW_MS = 36 * 36e5;
206
+ function normalizeCallsign(cs) {
207
+ const v = cs?.trim().toUpperCase().replace(/\s+/g, "");
208
+ return v || void 0;
209
+ }
210
+ function dayKey(msg, receivedAt) {
211
+ if (msg.dof && /^\d{6}$/.test(msg.dof)) return `20${msg.dof}`;
212
+ const t = msg.eobtAt ?? msg.atdAt ?? msg.ataAt ?? receivedAt;
213
+ return new Date(t).toISOString().slice(0, 10).replace(/-/g, "");
214
+ }
215
+ function flightPlanId(msg, receivedAt) {
216
+ const callsign = normalizeCallsign(msg.callsign);
217
+ if (!callsign) return null;
218
+ return `${callsign}|${msg.adep ?? "-"}|${msg.ades ?? "-"}|${dayKey(msg, receivedAt)}`;
219
+ }
220
+ function pickMatchingPlan(candidates, msg, receivedAt) {
221
+ const ref = msg.eobtAt ?? msg.atdAt ?? msg.ataAt ?? receivedAt;
222
+ let best;
223
+ let bestDiff = Infinity;
224
+ for (const c of candidates) {
225
+ if (msg.adep && c.adep && msg.adep !== c.adep) continue;
226
+ if (msg.ades && c.ades && msg.ades !== c.ades) continue;
227
+ const planTime = c.atdAt ?? c.eobtAt ?? c.firstSeenAt;
228
+ const diff = Math.abs(planTime - ref);
229
+ if (diff <= FLIGHT_PLAN_MATCH_WINDOW_MS && diff < bestDiff) {
230
+ best = c;
231
+ bestDiff = diff;
232
+ }
233
+ }
234
+ return best;
235
+ }
236
+ function assignDefined(target, patch) {
237
+ for (const [k, v] of Object.entries(patch)) {
238
+ if (v !== void 0 && v !== null && v !== "") target[k] = v;
239
+ }
240
+ }
241
+ function eetMs(eet) {
242
+ if (!eet || !/^\d{4}$/.test(eet)) return void 0;
243
+ return (Number(eet.slice(0, 2)) * 60 + Number(eet.slice(2, 4))) * 6e4;
244
+ }
245
+ function applyFlightPlanEvent(prev, ev) {
246
+ const m = ev.message;
247
+ const callsign = normalizeCallsign(m.callsign);
248
+ if (!callsign) return null;
249
+ const id = prev?.id ?? flightPlanId(m, ev.receivedAt);
250
+ if (!id) return null;
251
+ const next = {
252
+ status: "filed",
253
+ airportId: ev.airportId,
254
+ firstSeenAt: ev.receivedAt,
255
+ ...prev,
256
+ id,
257
+ callsign,
258
+ other: { ...prev?.other ?? {} },
259
+ lastMessageType: m.type,
260
+ messageCount: (prev?.messageCount ?? 0) + 1,
261
+ updatedAt: ev.receivedAt
262
+ };
263
+ assignDefined(next, { icao24: m.icao24, registration: m.registration, dof: m.dof });
264
+ Object.assign(next.other, m.other);
265
+ if (!next.adep && m.adep) next.adep = m.adep;
266
+ if (!next.ades && m.ades) next.ades = m.ades;
267
+ switch (m.type) {
268
+ case "FPL":
269
+ assignDefined(next, {
270
+ aircraftType: m.aircraftType,
271
+ wake: m.wake,
272
+ flightRules: m.flightRules,
273
+ flightType: m.flightType,
274
+ adep: m.adep,
275
+ ades: m.ades,
276
+ alternates: m.alternates,
277
+ eobtAt: m.eobtAt,
278
+ eet: m.eet,
279
+ cruiseSpeed: m.cruiseSpeed,
280
+ cruiseLevel: m.cruiseLevel,
281
+ route: m.route,
282
+ equipment: m.equipment
283
+ });
284
+ if (prev?.status !== "departed" && prev?.status !== "arrived") next.status = "filed";
285
+ break;
286
+ case "CHG":
287
+ assignDefined(next, {
288
+ aircraftType: m.aircraftType,
289
+ wake: m.wake,
290
+ cruiseSpeed: m.cruiseSpeed,
291
+ cruiseLevel: m.cruiseLevel,
292
+ route: m.route
293
+ });
294
+ if (m.changes?.["13"]) assignDefined(next, { adep: m.adep, eobtAt: m.eobtAt });
295
+ if (m.changes?.["16"]) assignDefined(next, { ades: m.ades, eet: m.eet });
296
+ break;
297
+ case "DLA":
298
+ assignDefined(next, { eobtAt: m.eobtAt });
299
+ if (next.status === "filed" || next.status === "delayed") next.status = "delayed";
300
+ break;
301
+ case "DEP":
302
+ next.atdAt = m.atdAt ?? ev.receivedAt;
303
+ next.status = "departed";
304
+ break;
305
+ case "ARR":
306
+ next.ataAt = m.ataAt ?? ev.receivedAt;
307
+ next.status = "arrived";
308
+ break;
309
+ case "CNL":
310
+ next.status = "cancelled";
311
+ break;
312
+ }
313
+ const departure = next.atdAt ?? next.eobtAt;
314
+ const flightTime = eetMs(next.eet);
315
+ next.etaAt = next.ataAt ?? (departure !== void 0 && flightTime !== void 0 ? departure + flightTime : void 0);
316
+ return next;
317
+ }
318
+ function flightPhase(plan, airportId) {
319
+ const icao = airportId?.trim().toUpperCase();
320
+ if (icao && plan.ades === icao) return "ARRIVAL";
321
+ if (icao && plan.adep === icao) return "DEPARTURE";
322
+ return plan.adep || plan.ades ? "OVERFLIGHT" : "UNKNOWN";
323
+ }
324
+ function summarizeFlightPlan(plan, airportId) {
325
+ const summary = { id: plan.id, status: plan.status, phase: flightPhase(plan, airportId) };
326
+ assignDefined(summary, {
327
+ adep: plan.adep,
328
+ ades: plan.ades,
329
+ aircraftType: plan.aircraftType,
330
+ registration: plan.registration,
331
+ eobtAt: plan.eobtAt,
332
+ atdAt: plan.atdAt,
333
+ ataAt: plan.ataAt,
334
+ etaAt: plan.etaAt,
335
+ cruiseLevel: plan.cruiseLevel,
336
+ route: plan.route
337
+ });
338
+ return summary;
339
+ }
340
+
341
+ // src/flightplan-store.ts
342
+ var FlightPlanStore = class {
343
+ plans = /* @__PURE__ */ new Map();
344
+ byCallsign = /* @__PURE__ */ new Map();
345
+ byIcao24 = /* @__PURE__ */ new Map();
346
+ onChanged = /* @__PURE__ */ new Set();
347
+ onExpired = /* @__PURE__ */ new Set();
348
+ ttlMs;
349
+ sweepTimer = null;
350
+ constructor(opts = {}) {
351
+ this.ttlMs = opts.ttlMs ?? 48 * 36e5;
352
+ }
353
+ on(event, fn) {
354
+ const set = event === "changed" ? this.onChanged : this.onExpired;
355
+ set.add(fn);
356
+ return () => {
357
+ set.delete(fn);
358
+ };
359
+ }
360
+ get size() {
361
+ return this.plans.size;
362
+ }
363
+ get(id) {
364
+ return this.plans.get(id);
365
+ }
366
+ /** Fold one AFTN message into its flight. Returns the updated plan, or null if ignored. */
367
+ apply(ev) {
368
+ const m = ev.message;
369
+ const callsign = normalizeCallsign(m.callsign);
370
+ if (!callsign) return null;
371
+ let prev;
372
+ if (m.type === "FPL") {
373
+ const id = flightPlanId(m, ev.receivedAt);
374
+ prev = id ? this.plans.get(id) : void 0;
375
+ } else {
376
+ const candidates = [...this.byCallsign.get(callsign) ?? []].map((id) => this.plans.get(id)).filter((p) => p !== void 0);
377
+ prev = pickMatchingPlan(candidates, m, ev.receivedAt);
378
+ }
379
+ const next = applyFlightPlanEvent(prev, ev);
380
+ if (!next) return null;
381
+ if (prev) this.unindex(prev);
382
+ this.plans.set(next.id, next);
383
+ this.index(next);
384
+ for (const fn of this.onChanged) fn(next);
385
+ return next;
386
+ }
387
+ /** Restore plans (e.g. from a database after a restart). Does not emit `changed`. */
388
+ load(plans, now = Date.now()) {
389
+ const cutoff = now - this.ttlMs;
390
+ let loaded = 0;
391
+ for (const plan of plans) {
392
+ if (!plan?.id || plan.updatedAt < cutoff) continue;
393
+ const prev = this.plans.get(plan.id);
394
+ if (prev) this.unindex(prev);
395
+ this.plans.set(plan.id, plan);
396
+ this.index(plan);
397
+ loaded++;
398
+ }
399
+ return loaded;
400
+ }
401
+ /** Best plan for a live track. Every plan sharing the Mode-S address or
402
+ * callsign is a candidate (an airframe flies several legs a day); the leg
403
+ * in progress, or nearest in time, wins. Cancelled plans never match. */
404
+ find(track, now = Date.now()) {
405
+ const candidates = /* @__PURE__ */ new Set();
406
+ if (track.icao24) {
407
+ for (const id of this.byIcao24.get(track.icao24.toLowerCase()) ?? []) {
408
+ const p = this.plans.get(id);
409
+ if (p) candidates.add(p);
410
+ }
411
+ }
412
+ const callsign = normalizeCallsign(track.callsign);
413
+ if (callsign) {
414
+ for (const id of this.byCallsign.get(callsign) ?? []) {
415
+ const p = this.plans.get(id);
416
+ if (p) candidates.add(p);
417
+ }
418
+ }
419
+ let best;
420
+ let bestScore = Infinity;
421
+ for (const p of candidates) {
422
+ if (p.status === "cancelled") continue;
423
+ const s = Math.abs((p.ataAt ?? p.atdAt ?? p.eobtAt ?? p.firstSeenAt) - now);
424
+ if (s < bestScore || s === bestScore && best && p.updatedAt > best.updatedAt) {
425
+ best = p;
426
+ bestScore = s;
427
+ }
428
+ }
429
+ return best;
430
+ }
431
+ /** Track with its flight-plan summary attached; unchanged when none matches.
432
+ * An arrow property so it can be passed unbound, e.g. `{ attachFlightPlan: store.attach }`. */
433
+ attach = (track) => {
434
+ const plan = this.find(track);
435
+ if (!plan) {
436
+ if (!track.flightPlan) return track;
437
+ const { flightPlan: _drop, ...rest } = track;
438
+ return rest;
439
+ }
440
+ return { ...track, flightPlan: summarizeFlightPlan(plan, track.airportId) };
441
+ };
442
+ /** True if `plan` could correlate with `track`. Pair with `TrackState.refreshFlightPlans`. */
443
+ matches(plan, track) {
444
+ if (plan.icao24 && track.icao24?.toLowerCase() === plan.icao24) return true;
445
+ return normalizeCallsign(track.callsign) === plan.callsign;
446
+ }
447
+ list(filter = {}) {
448
+ const callsign = normalizeCallsign(filter.callsign);
449
+ const out = [...this.plans.values()].filter((p) => !filter.airportId || !p.airportId || p.airportId === filter.airportId).filter((p) => !filter.status || p.status === filter.status).filter((p) => !callsign || p.callsign.includes(callsign)).sort((a, b) => b.updatedAt - a.updatedAt);
450
+ return filter.limit && filter.limit > 0 ? out.slice(0, filter.limit) : out;
451
+ }
452
+ /** Drop plans past the TTL; returns their ids. */
453
+ sweep(now = Date.now()) {
454
+ const cutoff = now - this.ttlMs;
455
+ const removed = [];
456
+ for (const [id, plan] of [...this.plans]) {
457
+ if (plan.updatedAt >= cutoff) continue;
458
+ this.plans.delete(id);
459
+ this.unindex(plan);
460
+ removed.push(id);
461
+ for (const fn of this.onExpired) fn(id);
462
+ }
463
+ return removed;
464
+ }
465
+ startSweep(intervalMs = 10 * 6e4) {
466
+ if (this.sweepTimer) return;
467
+ this.sweepTimer = setInterval(() => this.sweep(), intervalMs);
468
+ this.sweepTimer.unref?.();
469
+ }
470
+ stopSweep() {
471
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
472
+ this.sweepTimer = null;
473
+ }
474
+ index(plan) {
475
+ let ids = this.byCallsign.get(plan.callsign);
476
+ if (!ids) this.byCallsign.set(plan.callsign, ids = /* @__PURE__ */ new Set());
477
+ ids.add(plan.id);
478
+ if (plan.icao24) {
479
+ let iids = this.byIcao24.get(plan.icao24);
480
+ if (!iids) this.byIcao24.set(plan.icao24, iids = /* @__PURE__ */ new Set());
481
+ iids.add(plan.id);
482
+ }
483
+ }
484
+ unindex(plan) {
485
+ const ids = this.byCallsign.get(plan.callsign);
486
+ ids?.delete(plan.id);
487
+ if (ids?.size === 0) this.byCallsign.delete(plan.callsign);
488
+ if (plan.icao24) {
489
+ const iids = this.byIcao24.get(plan.icao24);
490
+ iids?.delete(plan.id);
491
+ if (iids?.size === 0) this.byIcao24.delete(plan.icao24);
492
+ }
493
+ }
494
+ };
495
+ function createFlightPlanStore(opts) {
496
+ return new FlightPlanStore(opts);
497
+ }
498
+
499
+ // src/movement.ts
500
+ var AIRBORNE_ALT_FT = 300;
501
+ var GROUND_ALT_FT = 100;
502
+ var TAKEOFF_MIN_GS_KT = 70;
503
+ var LANDING_MAX_GS_KT = 190;
504
+ var DEBOUNCE_MS = 12e4;
505
+ var MovementDetector = class {
506
+ phases = /* @__PURE__ */ new Map();
507
+ /** Feed one track state; returns a movement event on a transition. */
508
+ update(s) {
509
+ const alt = s.altitudeFt ?? null;
510
+ const gs = s.groundSpeedKt ?? null;
511
+ const looksAirborne = s.onGround === false || alt !== null && alt > AIRBORNE_ALT_FT;
512
+ const looksGround = s.onGround === true || alt !== null && alt < GROUND_ALT_FT;
513
+ const phase = this.phases.get(s.trackId);
514
+ if (!phase) {
515
+ this.phases.set(s.trackId, {
516
+ airborne: looksAirborne && !looksGround,
517
+ lastAt: s.at,
518
+ lastMovementAt: 0
519
+ });
520
+ return null;
521
+ }
522
+ phase.lastAt = s.at;
523
+ let event = null;
524
+ const debounced = phase.lastMovementAt !== 0 && s.at - phase.lastMovementAt < DEBOUNCE_MS;
525
+ if (!phase.airborne && looksAirborne && !looksGround) {
526
+ if ((gs ?? 0) >= TAKEOFF_MIN_GS_KT && !debounced) {
527
+ event = this.makeEvent(s, "takeoff");
528
+ phase.lastMovementAt = s.at;
529
+ }
530
+ phase.airborne = true;
531
+ } else if (phase.airborne && looksGround) {
532
+ if ((gs ?? 0) <= LANDING_MAX_GS_KT && !debounced) {
533
+ event = this.makeEvent(s, "landing");
534
+ phase.lastMovementAt = s.at;
535
+ }
536
+ phase.airborne = false;
537
+ }
538
+ return event;
539
+ }
540
+ /** Forget one track (call when its track is removed). */
541
+ forget(trackId) {
542
+ this.phases.delete(trackId);
543
+ }
544
+ /** Forget tracks not updated within maxAgeMs (call periodically). */
545
+ sweep(maxAgeMs, now = Date.now()) {
546
+ for (const [id, phase] of this.phases) {
547
+ if (now - phase.lastAt > maxAgeMs) this.phases.delete(id);
548
+ }
549
+ }
550
+ get trackedCount() {
551
+ return this.phases.size;
552
+ }
553
+ makeEvent(s, type) {
554
+ return {
555
+ trackId: s.trackId,
556
+ icao24: s.icao24 ?? null,
557
+ callsign: s.callsign ?? null,
558
+ ...s.airportId ? { airportId: s.airportId } : {},
559
+ type,
560
+ at: s.at,
561
+ lat: s.lat,
562
+ lon: s.lon
563
+ };
564
+ }
565
+ };
566
+ function createMovementDetector() {
567
+ return new MovementDetector();
568
+ }
569
+
570
+ // ../core/dist/chunk-ZYX6U3ZK.js
571
+ import { z } from "zod";
572
+ var positionSchema = z.object({
573
+ lat: z.number().min(-90).max(90),
574
+ lon: z.number().min(-180).max(180),
575
+ alt: z.number().optional(),
576
+ altSource: z.enum(["baro", "gnss", "mlat"]).optional(),
577
+ onGround: z.boolean().optional()
578
+ });
579
+ var velocitySchema = z.object({
580
+ groundSpeed: z.number().nonnegative().optional(),
581
+ heading: z.number().min(-1).max(361).optional(),
582
+ verticalRate: z.number().optional()
583
+ });
584
+ var flightPlanSummarySchema = z.object({
585
+ id: z.string(),
586
+ adep: z.string().optional(),
587
+ ades: z.string().optional(),
588
+ aircraftType: z.string().optional(),
589
+ registration: z.string().optional(),
590
+ eobtAt: z.number().optional(),
591
+ atdAt: z.number().optional(),
592
+ ataAt: z.number().optional(),
593
+ etaAt: z.number().optional(),
594
+ cruiseLevel: z.string().optional(),
595
+ route: z.string().optional(),
596
+ status: z.enum(["filed", "delayed", "departed", "arrived", "cancelled"]),
597
+ phase: z.enum(["ARRIVAL", "DEPARTURE", "OVERFLIGHT", "UNKNOWN"])
598
+ });
599
+ var currentTrackSchema = z.object({
600
+ trackId: z.string(),
601
+ source: z.enum(["ADSB", "ASTERIX_CAT010", "ASTERIX_CAT011", "ASTERIX_CAT020", "ASTERIX_CAT021", "ASTERIX_CAT048", "ASTERIX_CAT062", "FUSED"]),
602
+ icao24: z.string().nullable(),
603
+ callsign: z.string().nullable(),
604
+ squawk: z.string().nullable(),
605
+ targetType: z.enum(["aircraft", "vehicle", "unknown"]).optional(),
606
+ vehicleFleet: z.number().optional(),
607
+ airportId: z.string().optional(),
608
+ lat: z.number().min(-90).max(90).nullable(),
609
+ lon: z.number().min(-180).max(180).nullable(),
610
+ altitudeFt: z.number().nullable(),
611
+ groundSpeedKt: z.number().nullable(),
612
+ headingDeg: z.number().min(-1).max(361).nullable(),
613
+ verticalRateFpm: z.number().nullable(),
614
+ confidence: z.number().min(0).max(1),
615
+ lastSeenAt: z.number(),
616
+ status: z.enum(["active", "stale", "lost"]),
617
+ flightPlan: flightPlanSummarySchema.optional()
618
+ });
619
+ var streamEnvelopeSchema = z.discriminatedUnion("type", [
620
+ z.object({
621
+ type: z.literal("snapshot"),
622
+ seq: z.number(),
623
+ serverTime: z.number(),
624
+ payload: z.object({ tracks: z.array(currentTrackSchema) })
625
+ }),
626
+ z.object({
627
+ type: z.literal("track.updated"),
628
+ seq: z.number(),
629
+ serverTime: z.number(),
630
+ payload: z.object({ track: currentTrackSchema })
631
+ }),
632
+ z.object({
633
+ type: z.literal("track.removed"),
634
+ seq: z.number(),
635
+ serverTime: z.number(),
636
+ payload: z.object({ trackId: z.string(), reason: z.string() })
637
+ }),
638
+ z.object({
639
+ type: z.literal("source.status"),
640
+ seq: z.number(),
641
+ serverTime: z.number(),
642
+ payload: z.object({ sourceId: z.string(), status: z.enum(["healthy", "degraded", "offline"]) })
643
+ }),
644
+ z.object({
645
+ type: z.literal("stream.heartbeat"),
646
+ seq: z.number(),
647
+ serverTime: z.number(),
648
+ payload: z.object({}).strict()
649
+ }),
650
+ z.object({
651
+ type: z.literal("stream.degraded"),
652
+ seq: z.number(),
653
+ serverTime: z.number(),
654
+ payload: z.object({ reason: z.string(), sourceId: z.string().optional() })
655
+ }),
656
+ z.object({
657
+ type: z.literal("stream.error"),
658
+ seq: z.number(),
659
+ serverTime: z.number(),
660
+ payload: z.object({ code: z.string(), message: z.string().optional(), recoverable: z.boolean() })
661
+ })
662
+ ]);
663
+ var sourceHeartbeatSchema = z.object({
664
+ sourceId: z.string(),
665
+ timestamp: z.number(),
666
+ packetsReceived: z.number().int(),
667
+ packetsSent: z.number().int(),
668
+ bufferFillRatio: z.number().min(0).max(1),
669
+ latencyMs: z.number().nonnegative(),
670
+ protocol: z.enum(["beast", "sbs", "asterix-cat021", "asterix-cat048"]),
671
+ receiverVersion: z.string().optional()
672
+ });
673
+ var wsSubscribeSchema = z.object({
674
+ type: z.literal("subscribe"),
675
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
676
+ source: z.enum(["fused", "normalized"]).default("fused"),
677
+ minConfidence: z.number().min(0).max(1).optional(),
678
+ maxTracks: z.number().int().min(1).max(1e4).optional(),
679
+ mode: z.enum(["delta", "full"]).default("delta"),
680
+ maxUpdateHz: z.number().min(1).max(60).default(20),
681
+ encoding: z.enum(["json", "msgpack"]).default("json"),
682
+ airportId: z.string().min(2).max(8).optional()
683
+ });
684
+ var wsUpdateFilterSchema = z.object({
685
+ type: z.literal("update-filter"),
686
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
687
+ minConfidence: z.number().min(0).max(1).optional(),
688
+ maxUpdateHz: z.number().min(1).max(60).optional(),
689
+ maxTracks: z.number().int().min(1).max(1e4).nullable().optional()
690
+ });
691
+ var apiErrorSchema = z.object({
692
+ ok: z.literal(false),
693
+ error: z.object({
694
+ code: z.string(),
695
+ message: z.string(),
696
+ recoverable: z.boolean()
697
+ })
698
+ });
699
+
700
+ // src/live-server.ts
701
+ function toText(raw) {
702
+ return typeof raw === "string" ? raw : new TextDecoder().decode(raw);
703
+ }
704
+ function createLiveServer(opts) {
705
+ const { state } = opts;
706
+ const log = opts.logger ?? noopLogger;
707
+ const defaultHz = opts.defaultUpdateHz ?? 10;
708
+ const maxBuffered = opts.maxBufferedBytes ?? 1048576;
709
+ const clients = /* @__PURE__ */ new Map();
710
+ let seq = 0;
711
+ const counters = { messagesSent: 0, deltasSkipped: 0 };
712
+ function send(c, type, payload, droppable = false) {
713
+ if (c.closed) return false;
714
+ if (droppable && c.socket.getBufferedAmount && c.socket.getBufferedAmount() > maxBuffered) {
715
+ counters.deltasSkipped++;
716
+ return false;
717
+ }
718
+ const envelope = { type, seq: ++seq, serverTime: Date.now(), payload };
719
+ try {
720
+ c.socket.send(JSON.stringify(envelope));
721
+ counters.messagesSent++;
722
+ return true;
723
+ } catch (err) {
724
+ log.warn({ err }, "live ws: send failed");
725
+ return false;
726
+ }
727
+ }
728
+ function sendError(c, code, message) {
729
+ send(c, "stream.error", { code, message, recoverable: true });
730
+ }
731
+ function shouldPush(c, trackId, now) {
732
+ const last = c.lastPushed.get(trackId) ?? 0;
733
+ if (now - last < 1e3 / c.maxUpdateHz) return false;
734
+ c.lastPushed.set(trackId, now);
735
+ return true;
736
+ }
737
+ function sendSnapshot(c) {
738
+ const tracks = state.snapshot(c.filter);
739
+ c.visible = new Set(tracks.map((t) => t.trackId));
740
+ c.lastPushed.clear();
741
+ send(c, "snapshot", { tracks });
742
+ }
743
+ function hide(c, trackId, reason) {
744
+ if (!c.visible.delete(trackId)) return;
745
+ c.lastPushed.delete(trackId);
746
+ send(c, "track.removed", { trackId, reason });
747
+ }
748
+ function admit(c, track) {
749
+ const cap = c.filter.maxTracks;
750
+ if (cap === void 0 || c.visible.size < cap) return true;
751
+ let lowestId = null;
752
+ let lowest = trackScore(track);
753
+ for (const id of c.visible) {
754
+ const t = state.get(id);
755
+ if (!t) {
756
+ c.visible.delete(id);
757
+ continue;
758
+ }
759
+ const s = trackScore(t);
760
+ if (s < lowest) {
761
+ lowest = s;
762
+ lowestId = id;
763
+ }
764
+ }
765
+ if (c.visible.size < cap) return true;
766
+ if (!lowestId) return false;
767
+ hide(c, lowestId, "evicted");
768
+ return true;
769
+ }
770
+ const offUpdate = state.on("update", (track, info) => {
771
+ const now = Date.now();
772
+ for (const c of clients.values()) {
773
+ if (!c.subscribed) continue;
774
+ if (!matchesFilter(track, c.filter)) {
775
+ hide(c, track.trackId, "filtered");
776
+ continue;
777
+ }
778
+ if (!c.visible.has(track.trackId)) {
779
+ if (!admit(c, track)) continue;
780
+ c.visible.add(track.trackId);
781
+ c.lastPushed.set(track.trackId, now);
782
+ } else if (!info.force && !shouldPush(c, track.trackId, now)) {
783
+ continue;
784
+ }
785
+ if (!send(c, "track.updated", { track }, !info.force)) {
786
+ c.lastPushed.delete(track.trackId);
787
+ }
788
+ }
789
+ });
790
+ const offRemoved = state.on("removed", ({ trackId, reason }) => {
791
+ for (const c of clients.values()) {
792
+ if (!c.visible.has(trackId)) continue;
793
+ c.visible.delete(trackId);
794
+ c.lastPushed.delete(trackId);
795
+ send(c, "track.removed", { trackId, reason });
796
+ }
797
+ });
798
+ const heartbeat = setInterval(() => {
799
+ for (const c of clients.values()) if (c.ready) send(c, "stream.heartbeat", {});
800
+ }, opts.heartbeatMs ?? 15e3);
801
+ heartbeat.unref?.();
802
+ function handle(c, raw) {
803
+ let msg;
804
+ try {
805
+ msg = JSON.parse(toText(raw));
806
+ } catch {
807
+ sendError(c, "BAD_JSON", "Message is not valid JSON");
808
+ return;
809
+ }
810
+ if (msg["type"] === "subscribe") {
811
+ const parsed = wsSubscribeSchema.safeParse(msg);
812
+ if (!parsed.success) {
813
+ sendError(c, "BAD_SUBSCRIBE", "Invalid subscribe message");
814
+ return;
815
+ }
816
+ const sub = parsed.data;
817
+ c.filter = {
818
+ airportId: c.pinnedAirportId ?? sub.airportId,
819
+ bbox: sub.bbox,
820
+ minConfidence: sub.minConfidence ?? 0,
821
+ maxTracks: sub.maxTracks
822
+ };
823
+ c.maxUpdateHz = sub.maxUpdateHz;
824
+ c.subscribed = true;
825
+ sendSnapshot(c);
826
+ } else if (msg["type"] === "update-filter") {
827
+ const parsed = wsUpdateFilterSchema.safeParse(msg);
828
+ if (!parsed.success) {
829
+ sendError(c, "BAD_FILTER", "Invalid update-filter message");
830
+ return;
831
+ }
832
+ const upd = parsed.data;
833
+ if (upd.bbox !== void 0) c.filter.bbox = upd.bbox;
834
+ if (upd.minConfidence !== void 0) c.filter.minConfidence = upd.minConfidence;
835
+ if (upd.maxUpdateHz !== void 0) c.maxUpdateHz = upd.maxUpdateHz;
836
+ if (upd.maxTracks !== void 0) c.filter.maxTracks = upd.maxTracks ?? void 0;
837
+ if (c.subscribed) sendSnapshot(c);
838
+ }
839
+ }
840
+ return {
841
+ handlers: {
842
+ async open(ws, ctx) {
843
+ const c = {
844
+ socket: ws,
845
+ ready: false,
846
+ closed: false,
847
+ pending: [],
848
+ subscribed: false,
849
+ filter: {},
850
+ maxUpdateHz: defaultHz,
851
+ lastPushed: /* @__PURE__ */ new Map(),
852
+ visible: /* @__PURE__ */ new Set()
853
+ };
854
+ clients.set(ws, c);
855
+ if (!ctx.token) {
856
+ ws.close(4001, "Unauthorized: token required");
857
+ return;
858
+ }
859
+ let auth;
860
+ try {
861
+ auth = await opts.authenticate(ctx.token);
862
+ } catch (err) {
863
+ log.error({ err }, "live ws: authenticate threw");
864
+ auth = null;
865
+ }
866
+ if (c.closed) return;
867
+ if (!auth) {
868
+ ws.close(4003, "Unauthorized: invalid token");
869
+ return;
870
+ }
871
+ c.pinnedAirportId = auth.airportId;
872
+ c.filter.airportId = auth.airportId;
873
+ c.ready = true;
874
+ send(c, "stream.heartbeat", {});
875
+ const queued = c.pending.splice(0);
876
+ for (const raw of queued) handle(c, raw);
877
+ },
878
+ message(ws, raw) {
879
+ const c = clients.get(ws);
880
+ if (!c || c.closed) return;
881
+ if (!c.ready) {
882
+ c.pending.push(raw);
883
+ return;
884
+ }
885
+ handle(c, raw);
886
+ },
887
+ close(ws) {
888
+ const c = clients.get(ws);
889
+ if (c) c.closed = true;
890
+ clients.delete(ws);
891
+ }
892
+ },
893
+ sourceStatus(sourceId, status) {
894
+ for (const c of clients.values()) if (c.ready) send(c, "source.status", { sourceId, status });
895
+ },
896
+ degraded(reason, sourceId) {
897
+ for (const c of clients.values()) {
898
+ if (c.ready) send(c, "stream.degraded", sourceId ? { reason, sourceId } : { reason });
899
+ }
900
+ },
901
+ stats() {
902
+ let subscribed = 0;
903
+ for (const c of clients.values()) if (c.subscribed) subscribed++;
904
+ return { clients: clients.size, subscribed, ...counters };
905
+ },
906
+ stop() {
907
+ clearInterval(heartbeat);
908
+ offUpdate();
909
+ offRemoved();
910
+ for (const c of clients.values()) {
911
+ c.closed = true;
912
+ try {
913
+ c.socket.close(1001, "server shutting down");
914
+ } catch {
915
+ }
916
+ }
917
+ clients.clear();
918
+ }
919
+ };
920
+ }
921
+ export {
922
+ FlightPlanStore,
923
+ MovementDetector,
924
+ TrackState,
925
+ createFlightPlanStore,
926
+ createLiveServer,
927
+ createMovementDetector,
928
+ createTrackState
929
+ };
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
- {
2
- "name": "@aeroswarm/live-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@aeroswarm/live-server",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
20
+ "scripts": {
21
+ "build": "tsup --config tsup.config.ts",
22
+ "dev": "tsup --config tsup.config.ts --watch",
23
+ "test": "vitest run --passWithNoTests",
24
+ "typecheck": "tsc --noEmit",
25
+ "clean": "rm -rf dist",
26
+ "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build",
27
+ "release": "release-it",
28
+ "release:dry": "release-it --dry-run",
29
+ "release:patch": "release-it patch --no-git.requireCleanWorkingDir",
30
+ "release:minor": "release-it minor --no-git.requireCleanWorkingDir",
31
+ "release:major": "release-it major --no-git.requireCleanWorkingDir"
32
+ },
33
+ "dependencies": {
34
+ "@aeroswarm/core": "^0.2.10",
35
+ "zod": "^4.4.3"
36
+ },
37
+ "devDependencies": {
38
+ "release-it": "^18.1.2",
39
+ "tsup": "^8.5.1",
40
+ "typescript": "^6.0.3",
41
+ "vitest": "^4.1.6",
42
+ "ws": "^8.16.0",
43
+ "@types/ws": "^8.5.10"
44
+ }
45
+ }