@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 +119 -2
- package/dist/index.d.ts +236 -0
- package/dist/index.js +929 -0
- package/package.json +45 -6
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aeroswarm/live-server
|
|
2
2
|
|
|
3
|
-
|
|
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()`.
|
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|