@aeroswarm/client 0.1.15 → 0.1.17

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,617 +1,631 @@
1
- # @aeroswarm/client
2
-
3
- TypeScript SDK and React bindings for consuming AeroSwarm data: live track streaming over WebSocket, REST/tRPC/GraphQL APIs, alerts, replay, and map-ready track state management.
4
-
5
- This is the **only** package frontends should use — surveillance decoding, Kafka, and Redis all stay server-side; the SDK consumes the normalized event stream.
6
-
7
- ## Installation
8
-
9
- ```bash
10
- pnpm add @aeroswarm/client
11
- ```
12
-
13
- ## Entry points
14
-
15
- | Import | Contents |
16
- |--------|----------|
17
- | `@aeroswarm/client` | Everything from `/sdk` + shared types/utils |
18
- | `@aeroswarm/client/sdk` | `AeroSwarm` class, `AeroWsClient`, `TrackCache` |
19
- | `@aeroswarm/client/react` | React hooks, `AeroSwarmProvider`, Zustand track store |
20
-
21
- ## SDK usage (framework-agnostic)
22
-
23
- ```ts
24
- import { AeroSwarm } from "@aeroswarm/client/sdk";
25
-
26
- const client = new AeroSwarm({
27
- endpoint: "http://192.168.2.231:8070", // REST/tRPC/GraphQL base
28
- token: "<viewer-or-source-token>",
29
- // wsEndpoint?: string — defaults to endpoint with ws:// scheme
30
- // encoding?: "json" | "msgpack"
31
- // reconnect?: boolean, maxReconnectDelay?: number (ms, default 30 000)
32
- // staleAfterMs?, removeAfterMs? — client-side track lifecycle overrides
33
- // onTokenExpired?: () => Promise<string> — refresh hook
34
- });
35
-
36
- const unsubscribe = client.tracks.subscribe(
37
- {
38
- bbox: [94, -11, 141, 6.5], // lonMin, latMin, lonMax, latMax
39
- source: "fused", // "fused" | "normalized"
40
- minConfidence: 0.5,
41
- maxTracks: 500, // server-side top-N
42
- maxUpdateHz: 5, // per-track update cap — see performance notes
43
- mode: "delta", // "delta" (updates) | "full" (periodic snapshots)
44
- includeAlerts: false,
45
- },
46
- {
47
- onSnapshot: (tracks) => console.log("initial state", tracks.length),
48
- onTrackUpdate: (track) => console.log(track.trackId, track.lat, track.lon),
49
- onTrackRemoved:(ev) => console.log("removed", ev.trackId),
50
- onStatusChange:(status) => console.log("ws:", status),
51
- onError: (err) => console.error(err.code, err.message),
52
- },
53
- );
54
-
55
- // later
56
- unsubscribe();
57
- client.disconnect();
58
- ```
59
-
60
- ### `AeroSwarm` surface
61
-
62
- | Member | Description |
63
- |--------|-------------|
64
- | `tracks.subscribe(opts, callbacks)` | Live WS stream (`/ws/tracks/live`); returns unsubscribe fn. Auto-reconnects with backoff; server sends a fresh authoritative snapshot on reconnect |
65
- | `tracks.stream(opts)` | Same live stream as an `AsyncIterable<LabeledTrack>` (`for await`) |
66
- | `tracks.updateFilter(opts)` | Change bbox / minConfidence / maxUpdateHz / maxTracks without reconnecting |
67
- | `tracks.reconnect()` | Force an immediate reconnect (skips backoff, re-subscribes with the last options, resumes after `disconnect()`); `false` if never subscribed. Also `AeroSwarm.reconnect()` and `useAeroTracks().reconnect` |
68
- | `tracks.snapshot(input?)` | One-shot current state via tRPC (`airportId`, `bbox`, `source`, `minConfidence`, `limit`, `offset`) |
69
- | `tracks.history(input)` | Persisted position history for one track (`trackId`, `airportId?`, `limit?`) — federated airports are served by their own backend |
70
- | `airports.*` | Airport registry: `list/create/update/delete` + `surface`/`setSurface` (runway/taxiway/apron GeoJSON for the map) |
71
- | `sources.*` | Register/list (optionally by `airportId`)/delete data sources (returns per-source auth tokens) |
72
- | `alerts.*` | Alert history + live alert subscription |
73
- | `rules.*` | Detection-rule CRUD — **global or per-airport** (see example below) |
74
- | `routes.*` | Route conformance: pre-defined ground routes + live track clearances (deviation alerts) |
75
- | `analytics.movements()` | Takeoffs/landings per hour + peak hour, per airport |
76
- | `webhooks.*` | Webhook CRUD |
77
- | `replay.days(input?)` | Data-availability calendar — which days have recorded tracks (see example below) |
78
- | `replay.range/seek/window` | Historical replay metadata: range summary, point-in-time seek, time-bucketed window for scrubbing |
79
- | `replay.stream(opts, cb)` | **Server-paced WS playback** — historical tracks streamed like live (see example below) |
80
- | `system.*` | `stats`, `infraHealth`, `getConfig`/`setConfig`, `clearTable`, `ingestHistory` |
81
- | `trpc` / `rest` / `graphql` | Direct typed API clients for anything not wrapped above — **untyped** (`createTRPCProxyClient<any>`), so prefer the wrappers above; fall back to `client.trpc.<router>.<procedure>.query/mutate(...)` only for endpoints not yet wrapped |
82
- | `cache` | A `TrackCache` instance (see below) |
83
- | `getStatus()` / `getLastUpdateAt()` / `getError()` / `getSourceStatus()` | Connection introspection |
84
- | `disconnect()` | Tear down the WS connection |
85
-
86
- ### Label category / color
87
-
88
- Every track from `tracks.subscribe` / `tracks.stream` callbacks, the cache and `useAeroTracks` carries `labelCategory` and `labelColor`, derived after merging deltas so a delta without `flightPlan` never changes the color:
89
-
90
- | Category | Color | When |
91
- |---|---|---|
92
- | `ARRIVAL` | `#FACC15` yellow | `flightPlan.phase === "ARRIVAL"` |
93
- | `DEPARTURE` | `#22C55E` green | `flightPlan.phase === "DEPARTURE"` |
94
- | `GROUND_VEHICLE` | `#3B82F6` blue | `targetType === "vehicle"` |
95
- | `LOST` | `#FFFFFF` white | `status === "lost"` (takes priority) |
96
- | `UNKNOWN` | `#FFFFFF` white | no flight plan / overflight |
97
-
98
- Also exported: `LABEL_COLORS`, `getLabelCategory(track)`, `withLabel(track)`.
99
-
100
- ### Response data (live WS)
101
-
102
- The server sends JSON envelopes `{ type, seq, serverTime, payload }`. The client validates them, drops out-of-order `seq`, and re-requests a snapshot after a gap. You never see envelopes — you get typed callbacks:
103
-
104
- | Callback | Payload | Notes |
105
- |---|---|---|
106
- | `onSnapshot` | `LabeledTrack[]` | Full authoritative state; sent on subscribe and on every reconnect |
107
- | `onTrackUpdate` | `LabeledTrack` | Delta **merged with the previous state** of that track (fields the delta omits keep their last value). Also fires when `status` becomes `"lost"` (the track is then evicted from `cache`) |
108
- | `onTrackRemoved` | `{ trackId, reason }` | Server dropped the track |
109
- | `onSourceStatus` | `{ sourceId, status }` | `healthy` \| `degraded` \| `offline` |
110
- | `onStatusChange` | `AeroConnectionStatus` | `idle` \| `connecting` \| `connected` \| `reconnecting` \| `degraded` \| `offline` |
111
- | `onDegraded` | `reason: string` | e.g. `heartbeat_timeout` |
112
- | `onError` | `{ code, message, recoverable }` | |
113
-
114
- `LabeledTrack` = `CurrentTrack` + label fields:
115
-
116
- ```jsonc
117
- {
118
- "trackId": "fused-4b1a2c",
119
- "source": "FUSED", // ADSB | ASTERIX_CAT010/011/020/021/048/062 | FUSED
120
- "icao24": "8a0123", // string | null
121
- "callsign": "GIA123", // string | null
122
- "squawk": "1200", // string | null
123
- "targetType": "aircraft", // optional: aircraft | vehicle | unknown
124
- "vehicleFleet": 3, // optional, only for vehicles
125
- "airportId": "WARR", // optional ICAO scope
126
- "dataSource": { "sac": 32, "sic": 136 }, // optional: the sensor behind the position shown (ASTERIX feeds)
127
- "lat": -7.3797, "lon": 112.7868,// number | null
128
- "altitudeFt": 0,
129
- "groundSpeedKt": 12,
130
- "headingDeg": 90,
131
- "verticalRateFpm": 0,
132
- "confidence": 0.98, // 0..1
133
- "lastSeenAt": 1761640000000, // epoch ms
134
- "status": "active", // active | stale | lost
135
- "flightPlan": { // optional: only when correlated with an AFTN plan
136
- "id": "GIA123|WARR|WIII|20261028",
137
- "adep": "WARR", "ades": "WIII",
138
- "status": "filed", // filed | delayed | departed | arrived | cancelled
139
- "phase": "DEPARTURE" // ARRIVAL | DEPARTURE | OVERFLIGHT | UNKNOWN (relative to airportId)
140
- },
141
- // added by the client:
142
- "labelCategory": "DEPARTURE", // ARRIVAL | DEPARTURE | GROUND_VEHICLE | LOST | UNKNOWN
143
- "labelColor": "#22C55E"
144
- }
145
- ```
146
-
147
- `status` and `labelColor` change only when the track's category or status actually changes — position-only deltas leave them untouched, so a label painted from `labelColor` does not flicker.
148
-
149
- `dataSource` is the ASTERIX System Area / System Identification Code (SAC/SIC) of the sensor that reported the position shown (for a merged track, the sensor that is currently preferred). `formatDataSource(track)` gives the display string `"ADSB 32/136"` (`sourceLabel(track.source)` gives just the kind: `ADSB`, `SURFACE` for CAT010/011, `MLAT`, `RADAR`, `SYSTEM`, `FUSED`); without a SAC/SIC it is the kind alone.
150
-
151
- ## Per-domain usage examples
152
-
153
- Everything below assumes `const client = new AeroSwarm({ endpoint, token })`.
154
-
155
- ### Airports (multi-airport tenancy)
156
-
157
- ```ts
158
- const { airports } = await client.airports.list();
159
- // [{ id: "WARR", name: "Juanda International Airport", city, lat, lon, bbox, timezone }, …]
160
-
161
- await client.airports.create({
162
- id: "WARR", name: "Juanda International Airport",
163
- lat: -7.3798, lon: 112.7869, bbox: [112.7, -7.42, 112.86, -7.33],
164
- });
165
- await client.airports.update({ id: "WARR", city: "Surabaya" });
166
- await client.airports.delete({ id: "WARR" });
167
-
168
- // Runway/taxiway/apron GeoJSON for the map's surface layer
169
- const { surface } = await client.airports.surface({ id: "WARR" });
170
- ```
171
-
172
- Most other calls accept an `airportId` (ICAO code) to scope to one airport; omitting it means "all airports".
173
-
174
- ### Rules — global vs per-airport
175
-
176
- Rules with no `airportId` are **global**: the safety engine evaluates them against tracks from every airport. With an `airportId` they only apply to tracks stamped with that airport.
177
-
178
- ```ts
179
- // GLOBAL geofence — applies everywhere
180
- const { ruleId } = await client.rules.create({
181
- name: "Restricted area — national",
182
- type: "geofence", // geofence | altitude | speed | squawk | custom
183
- severity: "high", // low | medium | high | critical
184
- conditions: { kind: "restricted", polygon: [[lon, lat], …] },
185
- actions: [],
186
- });
187
-
188
- // Airport-scoped geofence — WARR tracks only
189
- await client.rules.create({
190
- name: "South Apron",
191
- type: "geofence",
192
- severity: "medium",
193
- conditions: { kind: "apron", polygon: [[lon, lat], …] },
194
- actions: [],
195
- airportId: "WARR",
196
- });
197
-
198
- await client.rules.list(); // every rule
199
- await client.rules.list({ airportId: "WARR" }); // one airport's rules
200
- await client.rules.update({ id: ruleId, enabled: false });
201
- await client.rules.delete({ id: ruleId });
202
- ```
203
-
204
- Geofence `conditions`: `kind` (`runway | taxiway | apron | restricted | custom`), `polygon` (`[lon, lat]` ring), optional `maxSpeedKt`, `appliesTo` (`all | aircraft | vehicle`), `runwayId` (ties a runway zone to active-runway suppression). Note: the safety engine currently evaluates `geofence` rules (plus built-in emergency-squawk/STCA/deviation); other types are stored but not evaluated.
205
-
206
- ### Routes — assigned-route conformance (deviation alerts)
207
-
208
- Routes are polylines of `[lat, lon]` vertices per airport (seeded from real OSM taxiways for WARR/WAHS/WAHI). Assign one to a live track and the safety engine starts emitting `deviation` alerts whenever the track strays beyond `maxDeviationM`; taxiway/apron incursion alerts are suppressed while the track conforms.
209
-
210
- ```ts
211
- // Route definitions
212
- const { routes } = await client.routes.list({ airportId: "WARR" });
213
- const { id } = await client.routes.create({
214
- airportId: "WARR",
215
- name: "Stand A1 → RWY 10",
216
- kind: "taxi", // taxi | departure | arrival
217
- points: [[-7.3822, 112.7794], [-7.3820, 112.7900], …], // [lat, lon]
218
- maxDeviationM: 50,
219
- });
220
- await client.routes.delete({ id });
221
-
222
- // Clearances (live track → route). Stored in Redis; the safety engine
223
- // refreshes them every 5 s.
224
- await client.routes.assign({ trackId: "trk_abc", routeId: routes[0].routeId });
225
- await client.routes.unassign({ trackId: "trk_abc" });
226
- const { assignments } = await client.routes.assignments();
227
- // [{ trackId, routeId, assignedAt }, …]
228
- ```
229
-
230
- The resulting `deviation` alerts arrive through the normal alert stream with `details: { routeId, route, deviationM, maxDeviationM, routeProgress }`.
231
-
232
- ### Alerts
233
-
234
- ```ts
235
- // History (tRPC)
236
- const { alerts } = await client.alerts.list({ severity: "high", limit: 50 });
237
-
238
- // Live subscription (WS)
239
- const stop = client.alerts.subscribe((alert) => {
240
- // { alertId, type, severity, trackId, triggeredAt, reason, details }
241
- // type: emergency | area_penetration | runway_incursion |
242
- // taxiway_incursion | deviation | stca | …
243
- showToast(alert);
244
- });
245
- stop();
246
- ```
247
-
248
- ### Movement analytics (takeoffs/landings per hour)
249
-
250
- ```ts
251
- const stats = await client.analytics.movements({ airportId: "WARR", day: "2026-07-14" });
252
- // {
253
- // day: "2026-07-14",
254
- // hours: [{ hour: "2026-07-14T07:00:00.000Z", takeoffs: 21, landings: 17 }, …],
255
- // totals: { takeoffs: 132, landings: 128 },
256
- // peak: { hour: "2026-07-14T07:00:00.000Z", count: 38 }, // jam puncak + jumlah
257
- // }
258
- ```
259
-
260
- Omit `airportId` for all airports; omit `day` for today (UTC). Movements are detected server-side by the shared `MovementDetector` (`@aeroswarm/core`) from fused-track transitions.
261
-
262
- ### Replay — server-paced WS playback
263
-
264
- The server streams historical tracks **at the requested speed** using the same envelopes as the live stream, so any renderer that handles live data can render a replay unchanged. Drive your playhead from `onProgress` (the server's clock) — never a local timer.
265
-
266
- ```ts
267
- const handle = await client.replay.stream(
268
- {
269
- from: "2026-07-14T02:00:00Z",
270
- to: "2026-07-14T03:00:00Z",
271
- speed: 2, // 2× real time (server-paced, drift-free)
272
- airportId: "WARR", // federated airport → its own backend serves this
273
- // sessionId: "…" // resume a previous session (kept 1 h in Redis)
274
- },
275
- {
276
- onSnapshot: (tracks) => seedMap(tracks), // state AT `from`
277
- onTrackUpdate: (t) => upsertMarker(t),
278
- onTrackRemoved: (ev) => removeMarker(ev.trackId),
279
- onProgress: (p) => setPlayhead(p.at, p.pct), // ISO time + 0-100 %
280
- onDone: () => setFinished(),
281
- onError: (err) => showError(err.message),
282
- },
283
- );
284
- // `stream()` is async — it first resolves whether `airportId` is a
285
- // federated airport (see "Federated history" below), which needs a lookup.
286
-
287
- handle.pause();
288
- handle.resume();
289
- handle.seek(new Date("2026-07-14T02:45:00Z")); // server restarts the paced stream there
290
- handle.stop(); // ends the session, closes the socket
291
- handle.sessionId; // save to resume after a page reload
292
- ```
293
-
294
- For range pickers, the metadata endpoints are still available:
295
-
296
- ```ts
297
- // Which days actually HAVE data (populate/validate the date picker)
298
- const { days } = await client.replay.days({ airportId: "WARR", lookbackDays: 60 });
299
- // [{ day: "2026-07-14", eventCount: 51230, trackCount: 42,
300
- // firstAt: "2026-07-14T01:58:03Z", lastAt: "2026-07-14T09:12:44Z" }, …]
301
- // A date absent from the list has no recorded data — don't offer it.
302
-
303
- const range = await client.replay.range({ from, to });
304
- // { earliest, latest, eventCount, trackCount }
305
- ```
306
-
307
- Not using the SDK? The wire protocol is plain JSON on `/ws/tracks/replay` — documented in `docs/sdk/websocket.md` (§ Replay Streaming). `AeroReplayClient` is also exported standalone.
308
-
309
- ### Federated history
310
-
311
- An airport can serve its **own** replay/history instead of AeroSwarm's — its own backend, its
312
- own database, reached directly (see `docs/development/federation/`). `replay.stream`,
313
- `replay.days`, `replay.range`, `tracks.history`, and `alerts.list` all resolve this
314
- automatically, in priority order:
315
-
316
- 1. **Explicit override** — pass `endpoint` and the registry is skipped entirely:
317
- ```ts
318
- await client.replay.days({ endpoint: "http://192.168.2.225:4000/aero" });
319
- ```
320
- 2. **Registry** — if the airport's `history_endpoint` is set (`client.airports.list()` /
321
- dashboard's Airports tab), calls for that `airportId` go there instead:
322
- ```ts
323
- const handle = await client.replay.stream({ from, to, airportId: "WARR" }, callbacks);
324
- // WARR has history_endpoint set → connects to that backend's WS replay, transparently
325
- ```
326
- 3. **AeroSwarm itself** — the default, unchanged behavior, for any airport with no endpoint set.
327
-
328
- A lookup failure (e.g. `airports.list()` errors) always falls back to AeroSwarm's own endpoint
329
- rather than breaking replay — federation degrades safely.
330
-
331
- `replay.window` and `replay.seek` are **not** part of the federation contract — they throw for a
332
- federated airport; use `replay.stream` (which covers the same "jump to a point in time" need via
333
- `seek()` on the returned handle) instead.
334
-
335
- Building the receiving side (an airport's own backend)? See
336
- [`@aeroswarm/replay-server`](../../server/replay-server/README.md) — mounts this exact protocol
337
- on top of your own database with a few lines of code.
338
-
339
- **Multi-airport.** Every replay call accepts `airportId` as *optional*. For a **federated**
340
- airport (one with a `historyEndpoint` — see the next section) it selects which airport backend
341
- to connect to; always pass it for those. Against AeroSwarm's own endpoint (dev/single-node
342
- mode) it's a row filter on the local database — omitting it returns all local rows. There is no
343
- cross-backend "all airports" merged replay: each federated airport's history lives behind its
344
- own backend, replayed one airport at a time.
345
-
346
- ### Sources & webhooks
347
-
348
- A **source** is a registered sensor/feed (radar, ADS-B, synthetic, …) identified by a bearer
349
- token (`sourceToken`). Tag it with `airportId` at creation and that token **carries its airport
350
- with it** from then on — anything that authenticates with it (WS `/ws/tracks/live`,
351
- `/ws/tracks/replay`, tRPC, REST) is automatically scoped to that airport, with no need to keep
352
- passing `airportId` on every call:
353
-
354
- ```ts
355
- const { sources } = await client.sources.list(); // all sources
356
- const { sources: warrSources } = await client.sources.list({ airportId: "WARR" });
357
-
358
- const created = await client.sources.create({
359
- name: "WARR ASTERIX feed",
360
- sourceType: "ASTERIX_CAT021",
361
- airportId: "WARR", // ← ties this token to WARR
362
- });
363
- // { ok: true, data: { sourceId: "src_...", sourceToken: "srctk_...", airportId: "WARR" } }
364
-
365
- // Later, a client connecting with that token doesn't need to pass airportId at all:
366
- const scoped = new AeroSwarm({ endpoint, wsEndpoint, token: created.data.sourceToken });
367
- await scoped.tracks.stream({}); // already scoped to WARR — the token carries it
368
-
369
- await client.sources.health("src_abc");
370
-
371
- // Reassign (or clear, with null) a source's airport after the fact — takes
372
- // effect immediately, no need to wait out the token cache TTL.
373
- await client.sources.update({ id: "src_abc", airportId: "CGK" });
374
- await client.sources.update({ id: "src_abc", airportId: null });
375
-
376
- await client.webhooks.create({ url: "https://ops.example/hook", events: ["alert"] });
377
- const hooks = await client.webhooks.list();
378
- await client.webhooks.delete({ id: hooks[0].id });
379
- ```
380
-
381
- `airportId` is required on `sources.create` — a source is a sensor physically at one airport, so
382
- a token that isn't tied to any airport is more footgun than feature. (Sources created before this
383
- was enforced may still have `airportId: null`; `sources.update` can backfill one.) For access
384
- that spans multiple airports, use the viewer/dashboard token instead — it's never auto-scoped
385
- this way, regardless of any source's configuration, and always sees whatever `airportId` (or lack
386
- thereof) it explicitly asks for.
387
-
388
- ### `TrackCache`
389
-
390
- In-memory `Map`-backed track store with a typed emitter — the primitive the React store builds on:
391
-
392
- `applyUpdate(track)`, `applyRemoved(ev)`, `applySnapshot(tracks)` (authoritative — replaces contents), `getAll()`, `get(id)`, `getRenderable()` (only tracks with valid lat/lon), `size`, `clear()`, plus `update`/`removed`/`snapshot` events.
393
-
394
- ### `AeroWsClient`
395
-
396
- Low-level WebSocket client (subscribe payloads, sequence tracking, heartbeats, reconnect/backoff, JSON + msgpack). Use `AeroSwarm.tracks.subscribe` unless you're building your own state layer.
397
-
398
- ## React usage
399
-
400
- ```tsx
401
- import { AeroSwarmProvider, useAeroTracks } from "@aeroswarm/client/react";
402
-
403
- function App() {
404
- return (
405
- <AeroSwarmProvider endpoint="http://…:8070" token={token}>
406
- <LiveMap />
407
- </AeroSwarmProvider>
408
- );
409
- }
410
-
411
- function LiveMap() {
412
- const { tracks, status, error, lastUpdateAt } = useAeroTracks({
413
- bbox: [94, -11, 141, 6.5],
414
- minConfidence: 0.5,
415
- maxUpdateHz: 5,
416
- });
417
-
418
- if (status !== "connected") return <p>{status}…</p>;
419
- return <ul>{tracks.map((t) => <li key={t.trackId}>{t.callsign ?? t.icao24}</li>)}</ul>;
420
- }
421
- ```
422
-
423
- ### Hooks (`@aeroswarm/client/react`)
424
-
425
- | Hook | Purpose |
426
- |------|---------|
427
- | `useAeroTracks(opts?)` (alias `useTracks`) | Live track list + connection state for a subscription |
428
- | `useAeroTrack(trackId)` | Single track by id |
429
- | `useAeroSnapshot(opts?)` | One-shot snapshot fetch |
430
- | `useAeroStatus()` | Connection status only |
431
- | `useTrackCount()` | Track count only (cheap selector) |
432
- | `useSourceHealth(sourceId?)` | Source health/status |
433
- | `useAlerts(opts?)` | Live alerts |
434
- | `useRules(opts?)` / `useRule(id, opts?)` | Alert rules |
435
- | `useWebhooks(opts?)` | Webhooks |
436
- | `useReplayStatus(sessionId, opts?)` / `useReplayStop()` | Replay session control |
437
- | `useLineage(eventId, opts?)` / `useLineageTrace(trackId, opts?)` | Event lineage/provenance |
438
- | `useTrackHistory(options)` | **Map animation engine** — see below |
439
- | `useAeroSwarmContext()` | Raw client from the provider |
440
-
441
- ### `useTrackStore` — Zustand store
442
-
443
- Shared live-track state; feed it from your subscription callbacks and read it imperatively (maps) or via selectors (React):
444
-
445
- | Member | Notes |
446
- |--------|-------|
447
- | `tracks: Map<string, LabeledTrack>` | Current state (each track carries `labelCategory` / `labelColor`) |
448
- | `applyUpdate(track)` | One store write per event — prefer `applyBatch` at high rates |
449
- | `applyBatch(updates, removedIds)` | **One Map clone per batch** — use this from buffered/throttled feeds |
450
- | `applyRemoved({trackId})` / `applySnapshot(tracks)` | Snapshot is authoritative (replaces state — ghosts from a dropped connection are cleared) |
451
- | `pruneStale(maxAgeMs)` | Remove tracks not updated within `maxAgeMs`; returns removed ids. **Call on an interval** (e.g. 60 s sweep every 10 s) — track ids churn on feed/replay restarts and abandoned ids never get a `removed` event, so without pruning they linger as ghost icons |
452
- | `setStatus` / `setSourceStatus` / `setError` / `setDegraded` / `clear` | Connection metadata |
453
- | Selectors | `selectAllTracks`, `selectRenderableTracks`, `selectTrack(id)`, `selectTrackCount`, `selectStatus`, `selectIsDegraded`, `selectLastUpdateAt`, `selectError` |
454
-
455
- ### REST errors: `AeroApiError`
456
-
457
- Every failed REST call (`client.rest.*`, federated `tracks.history` and the like) throws an `AeroApiError` that keeps what the server said, instead of just `GET /x → 500`:
458
-
459
- ```ts
460
- try { await client.tracks.history({ trackId, airportId: "WARR" }); }
461
- catch (e) {
462
- if (e instanceof AeroApiError) {
463
- e.status; // 403
464
- e.code; // "FORBIDDEN" — stable, for switch; a newer server may send a code you do not know
465
- e.recoverable; // retrying later may help (rate limit, server busy)
466
- e.message; // "Federated request /tracks/t1/history → 403: not your airport"
467
- e.details; // whatever the server attached
468
- }
469
- }
470
- ```
471
- Servers answer either in the standard format (`{ ok, data, meta }` / `{ ok: false, error: { code, message, recoverable } }`, see `@aeroswarm/core/api`) or in the older shape (bare data, `{ ok: false, error: "text" }`); both are read. Federated calls send `x-aero-envelope: 1` to get the standard format. `parseApiResponse(res)` and `readApiBody(res)` are exported for your own `fetch` calls.
472
-
473
- ### `tracks.trail` / `createTrailPoller` / `useTrackTrail` — the path an aircraft has flown
474
-
475
- The airport's backend builds the trail (memory first, stored history as a fallback), so the client computes nothing: it gets a GeoJSON `FeatureCollection` with one `LineString` (`[lon, lat, altitude m]`) and a `Point` per time mark, each with `properties.label` already written in the time zone you ask for.
476
-
477
- ```ts
478
- const { trail } = await client.tracks.trail({
479
- trackId,
480
- endpoint: "https://asmgcs.example/aero", // the airport backend (or an airportId with a historyEndpoint)
481
- tz: "Asia/Jakarta", // labels such as "01-11-26 11:16"; default UTC
482
- });
483
- // whole flight by default. fromMs/toMs: a period. stepM: coarser line. limit: at most n points. labelEveryMs: marks (default 2 min)
484
- ```
485
-
486
- Keep it current while an aircraft is selected: the first request is the whole trail, after that only what is new is fetched (`sinceMs`), so polling every few seconds costs a few hundred bytes.
487
-
488
- ```ts
489
- const poller = createTrailPoller(
490
- (sinceMs) => client.tracks.trail({ trackId, endpoint, tz: "Asia/Jakarta", sinceMs }),
491
- { pollMs: 5000, onUpdate: (trail) => draw(trail) }, // draw the line, and each mark's properties.label
492
- );
493
- poller.stop(); // when it is deselected
494
- ```
495
-
496
- React: `const { trail, loading, error } = useTrackTrail(selectedTrackId, { endpoint, tz: "Asia/Jakarta" })` (pass `null` while nothing is selected). A fixed period (`toMs`) is fetched once. A trail already shown stays when a request fails; polling stops for good when the server says the track is unknown (`NOT_FOUND`). `mergeTrail(prev, delta)` and `trailEnd(trail)` are exported if you manage the polling yourself. To end the line at the icon, draw one more segment from the trail's last point to `createTrackMotion().sample(trackId)`.
497
-
498
- ### `useTrackMotion` / `createTrackMotion` — smooth live tracks
499
-
500
- Feeds update a track once a second (MLAT, ADS-B) or once per antenna sweep (radar, ~7.5 s). Drawn as they arrive, icons jump. `createTrackMotion` (no React needed) shows where the aircraft *probably is right now* and eases corrections in, like a flight tracker:
501
-
502
- ```ts
503
- import { createTrackMotion } from "@aeroswarm/client";
504
-
505
- const motion = createTrackMotion(); // all options optional
506
-
507
- client.tracks.subscribe(opts, {
508
- onSnapshot: (tracks, meta) => tracks.forEach((t) => motion.update(t, meta)),
509
- onTrackUpdate: (t, meta) => motion.update(t, meta), // meta.serverTime keeps the clocks aligned
510
- onTrackRemoved: ({ trackId }) => motion.remove(trackId),
511
- });
512
-
513
- const stop = motion.start(({ changed }) => { // ~30 frames/s, only tracks that moved
514
- for (const [id, s] of changed) layer.put(id, s.lat, s.lon, s.headingDeg);
515
- });
516
- ```
517
-
518
- React: `const motion = useTrackMotion({ onFrame })`, then call `motion.update(...)` from the subscription. Nothing here renders React.
519
-
520
- What it does:
521
-
522
- | | |
523
- |---|---|
524
- | **Dead reckoning** | From the last fix, speed and heading carry the position forward, up to `maxExtrapolateMs` (20 s in the air, 8 s on the surface). `lost` tracks freeze. |
525
- | **Eased corrections** | A new fix does not snap the icon. The gap between where it was shown and where the fix says it should be becomes an offset that fades out (smoothstep over `blendMs`, 1.5 s). The icon keeps moving at its own speed meanwhile, so there is no stop-and-go at every update. A correction larger than `snapDistanceM` (5 km) is a glitch and jumps. |
526
- | **Heading** | Eases along the shortest arc (`headingSmoothMs`, 0.8 s); keeps the last heading when the track stops. |
527
- | **Server clock** | `lastSeenAt` is the server's clock. `meta.serverTime` of each message lets the engine learn the offset to the browser clock (the estimate with the least network delay wins), so a wrong browser clock does not stretch or shrink the prediction. |
528
- | **Cheap frames** | Tracks that stand still, or whose picture changed by less than `epsilonM` (0.5 m) / 0.5°, are not reported; no timer runs while nothing moves; `frameHz` caps the rate (30). |
529
-
530
- Options (`MotionOptions`): `blendMs`, `headingSmoothMs`, `maxExtrapolateMs`, `minMovingKt` (5), `snapDistanceM`, `frameHz`, `epsilonM`, `extrapolate` (`false` shows raw positions, for comparing sources), `isSurface`, `clock` (inject the replay clock to play back at another speed), `scheduler` (tests). `motion.sample(id)` gives the shown position at any time without touching frame state, e.g. to attach a trail's head to the icon.
531
-
532
- ### `useTrackHistory` — smooth map animation (deprecated: use `useTrackMotion`)
533
-
534
- Maintains per-track position interpolation (tweening), dead-reckoning between updates, and bounded trail history, emitting animation frames outside React render:
535
-
536
- ```ts
537
- const { processUpdate, processRemove } = useTrackHistory({
538
- maxHistoryPoints: 500, // trail length cap per track
539
- minDeltaDeg: 0.0001, // ignore sub-threshold jitter
540
- maxJumpDeg: 5.0, // discard implausible teleports
541
- onFrame: ({ currentPositions, history, isMoving }) => drawMarkers(...),
542
- });
543
- ```
544
-
545
- Update gaps longer than 30 s (lost coverage, socket offline) restart the track's trail instead of drawing a straight line across the map. Speeds beyond 1 200 kt (misdecodes, or position deltas divided by a compressed replay clock) are rejected and never drive dead reckoning.
546
-
547
- ## Frontend recipes
548
-
549
- ### Live map, end to end
550
-
551
- The proven wiring (this is what the reference dashboard does):
552
-
553
- ```tsx
554
- // 1. Subscription → store, batched (never setState per message)
555
- const pending = new Map<string, CurrentTrack>();
556
- const removed = new Set<string>();
557
- client.tracks.subscribe(opts, {
558
- onSnapshot: (ts) => useTrackStore.getState().applySnapshot(ts),
559
- onTrackUpdate: (t) => { pending.set(t.trackId, t); removed.delete(t.trackId); },
560
- onTrackRemoved: (ev) => { pending.delete(ev.trackId); removed.add(ev.trackId); },
561
- });
562
- setInterval(() => {
563
- useTrackStore.getState().applyBatch([...pending.values()], [...removed]);
564
- pending.clear(); removed.clear();
565
- }, 250);
566
-
567
- // 2. Ghost cleanup — ids churn on feed/replay restarts
568
- setInterval(() => { useTrackStore.getState().pruneStale(60_000); }, 10_000);
569
-
570
- // 3. Store → animation engine → map markers (imperative, no React re-render)
571
- const { processUpdate, processRemove } = useTrackHistory({ onFrame: drawMarkers });
572
- useTrackStore.subscribe((state, prev) => {
573
- for (const [id, t] of state.tracks) if (prev.tracks.get(id) !== t) processUpdate(t);
574
- for (const id of prev.tracks.keys()) if (!state.tracks.has(id)) processRemove(id);
575
- });
576
- ```
577
-
578
- ### Replay in the same map
579
-
580
- Feed `replay.stream` callbacks into the **same** store/animation path and pause the live subscription while a session is active — replay then looks exactly like live. Batch `onTrackUpdate` into ~250 ms flushes the same way.
581
-
582
- ### Assigned-route overlay
583
-
584
- ```ts
585
- const { routes } = await client.routes.list({ airportId });
586
- const { assignments } = await client.routes.assignments();
587
- const route = routes.find(r => r.routeId === assignments.find(a => a.trackId === selectedId)?.routeId);
588
- // draw route.points ([lat, lon][]) as a dashed LineString: points.map(([lat, lon]) => [lon, lat])
589
- ```
590
-
591
- ## Performance notes (important for dashboards)
592
-
593
- - **Don't call a React `setState` per WS message.** At realistic rates (hundreds of tracks × several Hz) that renders your page thousands of times per second. Buffer updates and flush on an interval (~250 ms to the store for the map, ~1 s to React state for panels), using `applyBatch`.
594
- - **Request only the rate you need** — `maxUpdateHz: 5` with client-side interpolation looks identical to 20 Hz on a map and cuts WS traffic 4×.
595
- - Drive map markers **imperatively** from `useTrackStore.subscribe(...)` + `useTrackHistory`, not through React props.
596
-
597
- ## Wire encoding
598
-
599
- `json` (default) or `msgpack` (binary, lower bandwidth) — set once in `AeroSwarmOptions.encoding` or per-subscription.
600
-
601
- ## Environment variables (Vite frontends)
602
-
603
- | Variable | Description |
604
- |----------|-------------|
605
- | `VITE_AEROSWARM_API_URL` | REST/tRPC/GraphQL base URL |
606
- | `VITE_AEROSWARM_WS_URL` | WebSocket base URL |
607
- | `VITE_AEROSWARM_API_KEY` | Viewer token |
608
-
609
- These are baked into the bundle at build time (Vite), not read at runtime.
610
-
611
- ## Building & testing
612
-
613
- ```bash
614
- pnpm run build # tsup → dist/ (index + /sdk + /react)
615
- pnpm run typecheck
616
- pnpm test
617
- ```
1
+ # @aeroswarm/client
2
+
3
+ TypeScript SDK and React bindings for consuming AeroSwarm data: live track streaming over WebSocket, REST/tRPC/GraphQL APIs, alerts, replay, and map-ready track state management.
4
+
5
+ This is the **only** package frontends should use — surveillance decoding, Kafka, and Redis all stay server-side; the SDK consumes the normalized event stream.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @aeroswarm/client
11
+ ```
12
+
13
+ ## Entry points
14
+
15
+ | Import | Contents |
16
+ |--------|----------|
17
+ | `@aeroswarm/client` | Everything from `/sdk` + shared types/utils |
18
+ | `@aeroswarm/client/sdk` | `AeroSwarm` class, `AeroWsClient`, `TrackCache` |
19
+ | `@aeroswarm/client/react` | React hooks, `AeroSwarmProvider`, Zustand track store |
20
+
21
+ ## SDK usage (framework-agnostic)
22
+
23
+ ```ts
24
+ import { AeroSwarm } from "@aeroswarm/client/sdk";
25
+
26
+ const client = new AeroSwarm({
27
+ endpoint: "http://192.168.2.231:8070", // REST/tRPC/GraphQL base
28
+ token: "<viewer-or-source-token>",
29
+ // wsEndpoint?: string — defaults to endpoint with ws:// scheme
30
+ // encoding?: "json" | "msgpack"
31
+ // reconnect?: boolean, maxReconnectDelay?: number (ms, default 30 000)
32
+ // staleAfterMs?, removeAfterMs? — client-side track lifecycle overrides
33
+ // onTokenExpired?: () => Promise<string> — refresh hook
34
+ });
35
+
36
+ const unsubscribe = client.tracks.subscribe(
37
+ {
38
+ bbox: [94, -11, 141, 6.5], // lonMin, latMin, lonMax, latMax
39
+ source: "fused", // "fused" | "normalized"
40
+ minConfidence: 0.5,
41
+ maxTracks: 500, // server-side top-N
42
+ maxUpdateHz: 5, // per-track update cap — see performance notes
43
+ mode: "delta", // "delta" (updates) | "full" (periodic snapshots)
44
+ includeAlerts: false,
45
+ },
46
+ {
47
+ onSnapshot: (tracks) => console.log("initial state", tracks.length),
48
+ onTrackUpdate: (track) => console.log(track.trackId, track.lat, track.lon),
49
+ onTrackRemoved:(ev) => console.log("removed", ev.trackId),
50
+ onStatusChange:(status) => console.log("ws:", status),
51
+ onError: (err) => console.error(err.code, err.message),
52
+ },
53
+ );
54
+
55
+ // later
56
+ unsubscribe();
57
+ client.disconnect();
58
+ ```
59
+
60
+ ### `AeroSwarm` surface
61
+
62
+ | Member | Description |
63
+ |--------|-------------|
64
+ | `tracks.subscribe(opts, callbacks)` | Live WS stream (`/ws/tracks/live`); returns unsubscribe fn. Auto-reconnects with backoff; server sends a fresh authoritative snapshot on reconnect |
65
+ | `tracks.stream(opts)` | Same live stream as an `AsyncIterable<LabeledTrack>` (`for await`) |
66
+ | `tracks.updateFilter(opts)` | Change bbox / minConfidence / maxUpdateHz / maxTracks without reconnecting |
67
+ | `tracks.reconnect()` | Force an immediate reconnect (skips backoff, re-subscribes with the last options, resumes after `disconnect()`); `false` if never subscribed. Also `AeroSwarm.reconnect()` and `useAeroTracks().reconnect` |
68
+ | `tracks.snapshot(input?)` | One-shot current state via tRPC (`airportId`, `bbox`, `source`, `minConfidence`, `limit`, `offset`) |
69
+ | `tracks.history(input)` | Persisted position history for one track (`trackId`, `airportId?`, `limit?`) — federated airports are served by their own backend |
70
+ | `airports.*` | Airport registry: `list/create/update/delete` + `surface`/`setSurface` (runway/taxiway/apron GeoJSON for the map) |
71
+ | `sources.*` | Register/list (optionally by `airportId`)/delete data sources (returns per-source auth tokens) |
72
+ | `alerts.*` | Alert history + live alert subscription |
73
+ | `rules.*` | Detection-rule CRUD — **global or per-airport** (see example below) |
74
+ | `routes.*` | Route conformance: pre-defined ground routes + live track clearances (deviation alerts) |
75
+ | `analytics.movements()` | Takeoffs/landings per hour + peak hour, per airport |
76
+ | `webhooks.*` | Webhook CRUD |
77
+ | `replay.days(input?)` | Data-availability calendar — which days have recorded tracks (see example below) |
78
+ | `replay.range/seek/window` | Historical replay metadata: range summary, point-in-time seek, time-bucketed window for scrubbing |
79
+ | `replay.stream(opts, cb)` | **Server-paced WS playback** — historical tracks streamed like live (see example below) |
80
+ | `system.*` | `stats`, `infraHealth`, `getConfig`/`setConfig`, `clearTable`, `ingestHistory` |
81
+ | `trpc` / `rest` / `graphql` | Direct typed API clients for anything not wrapped above — **untyped** (`createTRPCProxyClient<any>`), so prefer the wrappers above; fall back to `client.trpc.<router>.<procedure>.query/mutate(...)` only for endpoints not yet wrapped |
82
+ | `cache` | A `TrackCache` instance (see below) |
83
+ | `getStatus()` / `getLastUpdateAt()` / `getError()` / `getSourceStatus()` | Connection introspection |
84
+ | `disconnect()` | Tear down the WS connection |
85
+
86
+ ### Label category / color
87
+
88
+ Every track from `tracks.subscribe` / `tracks.stream` callbacks, the cache and `useAeroTracks` carries `labelCategory` and `labelColor`, derived after merging deltas so a delta without `flightPlan` never changes the color:
89
+
90
+ | Category | Color | When |
91
+ |---|---|---|
92
+ | `ARRIVAL` | `#FACC15` yellow | `flightPlan.phase === "ARRIVAL"` |
93
+ | `DEPARTURE` | `#22C55E` green | `flightPlan.phase === "DEPARTURE"` |
94
+ | `GROUND_VEHICLE` | `#3B82F6` blue | `targetType === "vehicle"` |
95
+ | `LOST` | `#FFFFFF` white | `status === "lost"` (takes priority) |
96
+ | `UNKNOWN` | `#FFFFFF` white | no flight plan / overflight |
97
+
98
+ Also exported: `LABEL_COLORS`, `getLabelCategory(track)`, `withLabel(track)`.
99
+
100
+ ### Response data (live WS)
101
+
102
+ The server sends JSON envelopes `{ type, seq, serverTime, payload }`. The client validates them, drops out-of-order `seq`, and re-requests a snapshot after a gap. You never see envelopes — you get typed callbacks:
103
+
104
+ | Callback | Payload | Notes |
105
+ |---|---|---|
106
+ | `onSnapshot` | `LabeledTrack[]` | Full authoritative state; sent on subscribe and on every reconnect |
107
+ | `onTrackUpdate` | `LabeledTrack` | Delta **merged with the previous state** of that track (fields the delta omits keep their last value). Also fires when `status` becomes `"lost"` (the track is then evicted from `cache`) |
108
+ | `onTrackRemoved` | `{ trackId, reason }` | Server dropped the track |
109
+ | `onSourceStatus` | `{ sourceId, status }` | `healthy` \| `degraded` \| `offline` |
110
+ | `onStatusChange` | `AeroConnectionStatus` | `idle` \| `connecting` \| `connected` \| `reconnecting` \| `degraded` \| `offline` |
111
+ | `onDegraded` | `reason: string` | e.g. `heartbeat_timeout` |
112
+ | `onError` | `{ code, message, recoverable }` | |
113
+
114
+ `LabeledTrack` = `CurrentTrack` + label fields:
115
+
116
+ ```jsonc
117
+ {
118
+ "trackId": "fused-4b1a2c",
119
+ "source": "FUSED", // ADSB | ASTERIX_CAT010/011/020/021/048/062 | FUSED
120
+ "icao24": "8a0123", // string | null
121
+ "callsign": "GIA123", // string | null
122
+ "squawk": "1200", // string | null
123
+ "targetType": "aircraft", // optional: aircraft | vehicle | unknown
124
+ "vehicleFleet": 3, // optional, only for vehicles
125
+ "airportId": "WARR", // optional ICAO scope
126
+ "dataSource": { "sac": 32, "sic": 136 }, // optional: the sensor behind the position shown (ASTERIX feeds)
127
+ "lat": -7.3797, "lon": 112.7868,// number | null
128
+ "altitudeFt": 0,
129
+ "groundSpeedKt": 12,
130
+ "headingDeg": 90,
131
+ "verticalRateFpm": 0,
132
+ "confidence": 0.98, // 0..1
133
+ "lastSeenAt": 1761640000000, // epoch ms
134
+ "status": "active", // active | stale | lost
135
+ "flightPlan": { // optional: only when correlated with an AFTN plan
136
+ "id": "GIA123|WARR|WIII|20261028",
137
+ "adep": "WARR", "ades": "WIII",
138
+ "status": "filed", // filed | delayed | departed | arrived | cancelled
139
+ "phase": "DEPARTURE" // ARRIVAL | DEPARTURE | OVERFLIGHT | UNKNOWN (relative to airportId)
140
+ },
141
+ // added by the client:
142
+ "labelCategory": "DEPARTURE", // ARRIVAL | DEPARTURE | GROUND_VEHICLE | LOST | UNKNOWN
143
+ "labelColor": "#22C55E"
144
+ }
145
+ ```
146
+
147
+ `status` and `labelColor` change only when the track's category or status actually changes — position-only deltas leave them untouched, so a label painted from `labelColor` does not flicker.
148
+
149
+ `dataSource` is the ASTERIX System Area / System Identification Code (SAC/SIC) of the sensor that reported the position shown (for a merged track, the sensor that is currently preferred). `formatDataSource(track)` gives the display string `"ADSB 32/136"` (`sourceLabel(track.source)` gives just the kind: `ADSB`, `SURFACE` for CAT010/011, `MLAT`, `RADAR`, `SYSTEM`, `FUSED`); without a SAC/SIC it is the kind alone.
150
+
151
+ ## Per-domain usage examples
152
+
153
+ Everything below assumes `const client = new AeroSwarm({ endpoint, token })`.
154
+
155
+ ### Airports (multi-airport tenancy)
156
+
157
+ ```ts
158
+ const { airports } = await client.airports.list();
159
+ // [{ id: "WARR", name: "Juanda International Airport", city, lat, lon, bbox, timezone }, …]
160
+
161
+ await client.airports.create({
162
+ id: "WARR", name: "Juanda International Airport",
163
+ lat: -7.3798, lon: 112.7869, bbox: [112.7, -7.42, 112.86, -7.33],
164
+ });
165
+ await client.airports.update({ id: "WARR", city: "Surabaya" });
166
+ await client.airports.delete({ id: "WARR" });
167
+
168
+ // Runway/taxiway/apron GeoJSON for the map's surface layer
169
+ const { surface } = await client.airports.surface({ id: "WARR" });
170
+ ```
171
+
172
+ Most other calls accept an `airportId` (ICAO code) to scope to one airport; omitting it means "all airports".
173
+
174
+ ### Rules — global vs per-airport
175
+
176
+ Rules with no `airportId` are **global**: the safety engine evaluates them against tracks from every airport. With an `airportId` they only apply to tracks stamped with that airport.
177
+
178
+ ```ts
179
+ // GLOBAL geofence — applies everywhere
180
+ const { ruleId } = await client.rules.create({
181
+ name: "Restricted area — national",
182
+ type: "geofence", // geofence | altitude | speed | squawk | custom
183
+ severity: "high", // low | medium | high | critical
184
+ conditions: { kind: "restricted", polygon: [[lon, lat], …] },
185
+ actions: [],
186
+ });
187
+
188
+ // Airport-scoped geofence — WARR tracks only
189
+ await client.rules.create({
190
+ name: "South Apron",
191
+ type: "geofence",
192
+ severity: "medium",
193
+ conditions: { kind: "apron", polygon: [[lon, lat], …] },
194
+ actions: [],
195
+ airportId: "WARR",
196
+ });
197
+
198
+ await client.rules.list(); // every rule
199
+ await client.rules.list({ airportId: "WARR" }); // one airport's rules
200
+ await client.rules.update({ id: ruleId, enabled: false });
201
+ await client.rules.delete({ id: ruleId });
202
+ ```
203
+
204
+ Geofence `conditions`: `kind` (`runway | taxiway | apron | restricted | custom`), `polygon` (`[lon, lat]` ring), optional `maxSpeedKt`, `appliesTo` (`all | aircraft | vehicle`), `runwayId` (ties a runway zone to active-runway suppression). Note: the safety engine currently evaluates `geofence` rules (plus built-in emergency-squawk/STCA/deviation); other types are stored but not evaluated.
205
+
206
+ ### Routes — assigned-route conformance (deviation alerts)
207
+
208
+ Routes are polylines of `[lat, lon]` vertices per airport (seeded from real OSM taxiways for WARR/WAHS/WAHI). Assign one to a live track and the safety engine starts emitting `deviation` alerts whenever the track strays beyond `maxDeviationM`; taxiway/apron incursion alerts are suppressed while the track conforms.
209
+
210
+ ```ts
211
+ // Route definitions
212
+ const { routes } = await client.routes.list({ airportId: "WARR" });
213
+ const { id } = await client.routes.create({
214
+ airportId: "WARR",
215
+ name: "Stand A1 → RWY 10",
216
+ kind: "taxi", // taxi | departure | arrival
217
+ points: [[-7.3822, 112.7794], [-7.3820, 112.7900], …], // [lat, lon]
218
+ maxDeviationM: 50,
219
+ });
220
+ await client.routes.delete({ id });
221
+
222
+ // Clearances (live track → route). Stored in Redis; the safety engine
223
+ // refreshes them every 5 s.
224
+ await client.routes.assign({ trackId: "trk_abc", routeId: routes[0].routeId });
225
+ await client.routes.unassign({ trackId: "trk_abc" });
226
+ const { assignments } = await client.routes.assignments();
227
+ // [{ trackId, routeId, assignedAt }, …]
228
+ ```
229
+
230
+ The resulting `deviation` alerts arrive through the normal alert stream with `details: { routeId, route, deviationM, maxDeviationM, routeProgress }`.
231
+
232
+ ### Alerts
233
+
234
+ ```ts
235
+ // History (tRPC)
236
+ const { alerts } = await client.alerts.list({ severity: "high", limit: 50 });
237
+
238
+ // Live subscription (WS)
239
+ const stop = client.alerts.subscribe((alert) => {
240
+ // { alertId, type, severity, trackId, triggeredAt, reason, details }
241
+ // type: emergency | area_penetration | runway_incursion |
242
+ // taxiway_incursion | deviation | stca | …
243
+ showToast(alert);
244
+ });
245
+ stop();
246
+ ```
247
+
248
+ ### Movement analytics (takeoffs/landings per hour)
249
+
250
+ ```ts
251
+ const stats = await client.analytics.movements({ airportId: "WARR", day: "2026-07-14" });
252
+ // {
253
+ // day: "2026-07-14",
254
+ // hours: [{ hour: "2026-07-14T07:00:00.000Z", takeoffs: 21, landings: 17 }, …],
255
+ // totals: { takeoffs: 132, landings: 128 },
256
+ // peak: { hour: "2026-07-14T07:00:00.000Z", count: 38 }, // jam puncak + jumlah
257
+ // }
258
+ ```
259
+
260
+ Omit `airportId` for all airports; omit `day` for today (UTC). Movements are detected server-side by the shared `MovementDetector` (`@aeroswarm/core`) from fused-track transitions.
261
+
262
+ ### Replay — server-paced WS playback
263
+
264
+ The server streams historical tracks **at the requested speed** using the same envelopes as the live stream, so any renderer that handles live data can render a replay unchanged. Drive your playhead from `onProgress` (the server's clock) — never a local timer.
265
+
266
+ ```ts
267
+ const handle = await client.replay.stream(
268
+ {
269
+ from: "2026-07-14T02:00:00Z",
270
+ to: "2026-07-14T03:00:00Z",
271
+ speed: 2, // 2× real time (server-paced, drift-free)
272
+ airportId: "WARR", // federated airport → its own backend serves this
273
+ // sessionId: "…" // resume a previous session (kept 1 h in Redis)
274
+ },
275
+ {
276
+ onSnapshot: (tracks) => seedMap(tracks), // state AT `from`
277
+ onTrackUpdate: (t) => upsertMarker(t),
278
+ onTrackRemoved: (ev) => removeMarker(ev.trackId),
279
+ onProgress: (p) => setPlayhead(p.at, p.pct), // ISO time + 0-100 %
280
+ onDone: () => setFinished(),
281
+ onError: (err) => showError(err.message),
282
+ },
283
+ );
284
+ // `stream()` is async — it first resolves whether `airportId` is a
285
+ // federated airport (see "Federated history" below), which needs a lookup.
286
+
287
+ handle.pause();
288
+ handle.resume();
289
+ handle.seek(new Date("2026-07-14T02:45:00Z")); // server restarts the paced stream there
290
+ handle.stop(); // ends the session, closes the socket
291
+ handle.sessionId; // save to resume after a page reload
292
+ ```
293
+
294
+ For range pickers, the metadata endpoints are still available:
295
+
296
+ ```ts
297
+ // Which days actually HAVE data (populate/validate the date picker)
298
+ const { days } = await client.replay.days({ airportId: "WARR", lookbackDays: 60 });
299
+ // [{ day: "2026-07-14", eventCount: 51230, trackCount: 42,
300
+ // firstAt: "2026-07-14T01:58:03Z", lastAt: "2026-07-14T09:12:44Z" }, …]
301
+ // A date absent from the list has no recorded data — don't offer it.
302
+
303
+ const range = await client.replay.range({ from, to });
304
+ // { earliest, latest, eventCount, trackCount }
305
+ ```
306
+
307
+ Not using the SDK? The wire protocol is plain JSON on `/ws/tracks/replay` — documented in `docs/sdk/websocket.md` (§ Replay Streaming). `AeroReplayClient` is also exported standalone.
308
+
309
+ ### Federated history
310
+
311
+ An airport can serve its **own** replay/history instead of AeroSwarm's — its own backend, its
312
+ own database, reached directly (see `docs/development/federation/`). `replay.stream`,
313
+ `replay.days`, `replay.range`, `tracks.history`, and `alerts.list` all resolve this
314
+ automatically, in priority order:
315
+
316
+ 1. **Explicit override** — pass `endpoint` and the registry is skipped entirely:
317
+ ```ts
318
+ await client.replay.days({ endpoint: "http://192.168.2.225:4000/aero" });
319
+ ```
320
+ 2. **Registry** — if the airport's `history_endpoint` is set (`client.airports.list()` /
321
+ dashboard's Airports tab), calls for that `airportId` go there instead:
322
+ ```ts
323
+ const handle = await client.replay.stream({ from, to, airportId: "WARR" }, callbacks);
324
+ // WARR has history_endpoint set → connects to that backend's WS replay, transparently
325
+ ```
326
+ 3. **AeroSwarm itself** — the default, unchanged behavior, for any airport with no endpoint set.
327
+
328
+ A lookup failure (e.g. `airports.list()` errors) always falls back to AeroSwarm's own endpoint
329
+ rather than breaking replay — federation degrades safely.
330
+
331
+ **Credential for the airport's backend.** By default the airport's backend gets the same `token`
332
+ as AeroSwarm. When it expects something else — typically the user's login JWT, checked by the
333
+ airport's gateway — pass `backendToken`; a function is read on **every** request, so a refreshed
334
+ login is picked up without rebuilding the client. It falls back to `token` when it returns nothing.
335
+ It applies only to calls that go to an airport's backend (`tracks.history`, `tracks.trail`,
336
+ `replay.days`/`range`), never to AeroSwarm's own endpoint or the live WebSocket.
337
+
338
+ ```ts
339
+ const client = new AeroSwarm({
340
+ endpoint, token,
341
+ backendToken: () => useAuthStore.getState().accessToken,
342
+ });
343
+ ```
344
+
345
+ `replay.window` and `replay.seek` are **not** part of the federation contract — they throw for a
346
+ federated airport; use `replay.stream` (which covers the same "jump to a point in time" need via
347
+ `seek()` on the returned handle) instead.
348
+
349
+ Building the receiving side (an airport's own backend)? See
350
+ [`@aeroswarm/replay-server`](../../server/replay-server/README.md) — mounts this exact protocol
351
+ on top of your own database with a few lines of code.
352
+
353
+ **Multi-airport.** Every replay call accepts `airportId` as *optional*. For a **federated**
354
+ airport (one with a `historyEndpoint` — see the next section) it selects which airport backend
355
+ to connect to; always pass it for those. Against AeroSwarm's own endpoint (dev/single-node
356
+ mode) it's a row filter on the local database — omitting it returns all local rows. There is no
357
+ cross-backend "all airports" merged replay: each federated airport's history lives behind its
358
+ own backend, replayed one airport at a time.
359
+
360
+ ### Sources & webhooks
361
+
362
+ A **source** is a registered sensor/feed (radar, ADS-B, synthetic, …) identified by a bearer
363
+ token (`sourceToken`). Tag it with `airportId` at creation and that token **carries its airport
364
+ with it** from then on — anything that authenticates with it (WS `/ws/tracks/live`,
365
+ `/ws/tracks/replay`, tRPC, REST) is automatically scoped to that airport, with no need to keep
366
+ passing `airportId` on every call:
367
+
368
+ ```ts
369
+ const { sources } = await client.sources.list(); // all sources
370
+ const { sources: warrSources } = await client.sources.list({ airportId: "WARR" });
371
+
372
+ const created = await client.sources.create({
373
+ name: "WARR ASTERIX feed",
374
+ sourceType: "ASTERIX_CAT021",
375
+ airportId: "WARR", // ← ties this token to WARR
376
+ });
377
+ // { ok: true, data: { sourceId: "src_...", sourceToken: "srctk_...", airportId: "WARR" } }
378
+
379
+ // Later, a client connecting with that token doesn't need to pass airportId at all:
380
+ const scoped = new AeroSwarm({ endpoint, wsEndpoint, token: created.data.sourceToken });
381
+ await scoped.tracks.stream({}); // already scoped to WARR — the token carries it
382
+
383
+ await client.sources.health("src_abc");
384
+
385
+ // Reassign (or clear, with null) a source's airport after the fact — takes
386
+ // effect immediately, no need to wait out the token cache TTL.
387
+ await client.sources.update({ id: "src_abc", airportId: "CGK" });
388
+ await client.sources.update({ id: "src_abc", airportId: null });
389
+
390
+ await client.webhooks.create({ url: "https://ops.example/hook", events: ["alert"] });
391
+ const hooks = await client.webhooks.list();
392
+ await client.webhooks.delete({ id: hooks[0].id });
393
+ ```
394
+
395
+ `airportId` is required on `sources.create` — a source is a sensor physically at one airport, so
396
+ a token that isn't tied to any airport is more footgun than feature. (Sources created before this
397
+ was enforced may still have `airportId: null`; `sources.update` can backfill one.) For access
398
+ that spans multiple airports, use the viewer/dashboard token instead — it's never auto-scoped
399
+ this way, regardless of any source's configuration, and always sees whatever `airportId` (or lack
400
+ thereof) it explicitly asks for.
401
+
402
+ ### `TrackCache`
403
+
404
+ In-memory `Map`-backed track store with a typed emitter — the primitive the React store builds on:
405
+
406
+ `applyUpdate(track)`, `applyRemoved(ev)`, `applySnapshot(tracks)` (authoritative — replaces contents), `getAll()`, `get(id)`, `getRenderable()` (only tracks with valid lat/lon), `size`, `clear()`, plus `update`/`removed`/`snapshot` events.
407
+
408
+ ### `AeroWsClient`
409
+
410
+ Low-level WebSocket client (subscribe payloads, sequence tracking, heartbeats, reconnect/backoff, JSON + msgpack). Use `AeroSwarm.tracks.subscribe` unless you're building your own state layer.
411
+
412
+ ## React usage
413
+
414
+ ```tsx
415
+ import { AeroSwarmProvider, useAeroTracks } from "@aeroswarm/client/react";
416
+
417
+ function App() {
418
+ return (
419
+ <AeroSwarmProvider endpoint="http://…:8070" token={token}>
420
+ <LiveMap />
421
+ </AeroSwarmProvider>
422
+ );
423
+ }
424
+
425
+ function LiveMap() {
426
+ const { tracks, status, error, lastUpdateAt } = useAeroTracks({
427
+ bbox: [94, -11, 141, 6.5],
428
+ minConfidence: 0.5,
429
+ maxUpdateHz: 5,
430
+ });
431
+
432
+ if (status !== "connected") return <p>{status}…</p>;
433
+ return <ul>{tracks.map((t) => <li key={t.trackId}>{t.callsign ?? t.icao24}</li>)}</ul>;
434
+ }
435
+ ```
436
+
437
+ ### Hooks (`@aeroswarm/client/react`)
438
+
439
+ | Hook | Purpose |
440
+ |------|---------|
441
+ | `useAeroTracks(opts?)` (alias `useTracks`) | Live track list + connection state for a subscription |
442
+ | `useAeroTrack(trackId)` | Single track by id |
443
+ | `useAeroSnapshot(opts?)` | One-shot snapshot fetch |
444
+ | `useAeroStatus()` | Connection status only |
445
+ | `useTrackCount()` | Track count only (cheap selector) |
446
+ | `useSourceHealth(sourceId?)` | Source health/status |
447
+ | `useAlerts(opts?)` | Live alerts |
448
+ | `useRules(opts?)` / `useRule(id, opts?)` | Alert rules |
449
+ | `useWebhooks(opts?)` | Webhooks |
450
+ | `useReplayStatus(sessionId, opts?)` / `useReplayStop()` | Replay session control |
451
+ | `useLineage(eventId, opts?)` / `useLineageTrace(trackId, opts?)` | Event lineage/provenance |
452
+ | `useTrackHistory(options)` | **Map animation engine** — see below |
453
+ | `useAeroSwarmContext()` | Raw client from the provider |
454
+
455
+ ### `useTrackStore` — Zustand store
456
+
457
+ Shared live-track state; feed it from your subscription callbacks and read it imperatively (maps) or via selectors (React):
458
+
459
+ | Member | Notes |
460
+ |--------|-------|
461
+ | `tracks: Map<string, LabeledTrack>` | Current state (each track carries `labelCategory` / `labelColor`) |
462
+ | `applyUpdate(track)` | One store write per event — prefer `applyBatch` at high rates |
463
+ | `applyBatch(updates, removedIds)` | **One Map clone per batch** — use this from buffered/throttled feeds |
464
+ | `applyRemoved({trackId})` / `applySnapshot(tracks)` | Snapshot is authoritative (replaces state — ghosts from a dropped connection are cleared) |
465
+ | `pruneStale(maxAgeMs)` | Remove tracks not updated within `maxAgeMs`; returns removed ids. **Call on an interval** (e.g. 60 s sweep every 10 s) — track ids churn on feed/replay restarts and abandoned ids never get a `removed` event, so without pruning they linger as ghost icons |
466
+ | `setStatus` / `setSourceStatus` / `setError` / `setDegraded` / `clear` | Connection metadata |
467
+ | Selectors | `selectAllTracks`, `selectRenderableTracks`, `selectTrack(id)`, `selectTrackCount`, `selectStatus`, `selectIsDegraded`, `selectLastUpdateAt`, `selectError` |
468
+
469
+ ### REST errors: `AeroApiError`
470
+
471
+ Every failed REST call (`client.rest.*`, federated `tracks.history` and the like) throws an `AeroApiError` that keeps what the server said, instead of just `GET /x → 500`:
472
+
473
+ ```ts
474
+ try { await client.tracks.history({ trackId, airportId: "WARR" }); }
475
+ catch (e) {
476
+ if (e instanceof AeroApiError) {
477
+ e.status; // 403
478
+ e.code; // "FORBIDDEN" — stable, for switch; a newer server may send a code you do not know
479
+ e.recoverable; // retrying later may help (rate limit, server busy)
480
+ e.message; // "Federated request /tracks/t1/history → 403: not your airport"
481
+ e.details; // whatever the server attached
482
+ }
483
+ }
484
+ ```
485
+ Servers answer either in the standard format (`{ ok, data, meta }` / `{ ok: false, error: { code, message, recoverable } }`, see `@aeroswarm/core/api`) or in the older shape (bare data, `{ ok: false, error: "text" }`); both are read. Federated calls send `x-aero-envelope: 1` to get the standard format. `parseApiResponse(res)` and `readApiBody(res)` are exported for your own `fetch` calls.
486
+
487
+ ### `tracks.trail` / `createTrailPoller` / `useTrackTrail` — the path an aircraft has flown
488
+
489
+ The airport's backend builds the trail (memory first, stored history as a fallback), so the client computes nothing: it gets a GeoJSON `FeatureCollection` with one `LineString` (`[lon, lat, altitude m]`) and a `Point` per time mark, each with `properties.label` already written in the time zone you ask for.
490
+
491
+ ```ts
492
+ const { trail } = await client.tracks.trail({
493
+ trackId,
494
+ endpoint: "https://asmgcs.example/aero", // the airport backend (or an airportId with a historyEndpoint)
495
+ tz: "Asia/Jakarta", // labels such as "01-11-26 11:16"; default UTC
496
+ });
497
+ // whole flight by default. fromMs/toMs: a period. stepM: coarser line. limit: at most n points. labelEveryMs: marks (default 2 min)
498
+ ```
499
+
500
+ Keep it current while an aircraft is selected: the first request is the whole trail, after that only what is new is fetched (`sinceMs`), so polling every few seconds costs a few hundred bytes.
501
+
502
+ ```ts
503
+ const poller = createTrailPoller(
504
+ (sinceMs) => client.tracks.trail({ trackId, endpoint, tz: "Asia/Jakarta", sinceMs }),
505
+ { pollMs: 5000, onUpdate: (trail) => draw(trail) }, // draw the line, and each mark's properties.label
506
+ );
507
+ poller.stop(); // when it is deselected
508
+ ```
509
+
510
+ React: `const { trail, loading, error } = useTrackTrail(selectedTrackId, { endpoint, tz: "Asia/Jakarta" })` (pass `null` while nothing is selected). A fixed period (`toMs`) is fetched once. A trail already shown stays when a request fails; polling stops for good when the server says the track is unknown (`NOT_FOUND`). `mergeTrail(prev, delta)` and `trailEnd(trail)` are exported if you manage the polling yourself. To end the line at the icon, draw one more segment from the trail's last point to `createTrackMotion().sample(trackId)`.
511
+
512
+ ### `useTrackMotion` / `createTrackMotion` — smooth live tracks
513
+
514
+ Feeds update a track once a second (MLAT, ADS-B) or once per antenna sweep (radar, ~7.5 s). Drawn as they arrive, icons jump. `createTrackMotion` (no React needed) shows where the aircraft *probably is right now* and eases corrections in, like a flight tracker:
515
+
516
+ ```ts
517
+ import { createTrackMotion } from "@aeroswarm/client";
518
+
519
+ const motion = createTrackMotion(); // all options optional
520
+
521
+ client.tracks.subscribe(opts, {
522
+ onSnapshot: (tracks, meta) => tracks.forEach((t) => motion.update(t, meta)),
523
+ onTrackUpdate: (t, meta) => motion.update(t, meta), // meta.serverTime keeps the clocks aligned
524
+ onTrackRemoved: ({ trackId }) => motion.remove(trackId),
525
+ });
526
+
527
+ const stop = motion.start(({ changed }) => { // ~30 frames/s, only tracks that moved
528
+ for (const [id, s] of changed) layer.put(id, s.lat, s.lon, s.headingDeg);
529
+ });
530
+ ```
531
+
532
+ React: `const motion = useTrackMotion({ onFrame })`, then call `motion.update(...)` from the subscription. Nothing here renders React.
533
+
534
+ What it does:
535
+
536
+ | | |
537
+ |---|---|
538
+ | **Dead reckoning** | From the last fix, speed and heading carry the position forward, up to `maxExtrapolateMs` (20 s in the air, 8 s on the surface). `lost` tracks freeze. |
539
+ | **Eased corrections** | A new fix does not snap the icon. The gap between where it was shown and where the fix says it should be becomes an offset that fades out (smoothstep over `blendMs`, 1.5 s). The icon keeps moving at its own speed meanwhile, so there is no stop-and-go at every update. A correction larger than `snapDistanceM` (5 km) is a glitch and jumps. |
540
+ | **Heading** | Eases along the shortest arc (`headingSmoothMs`, 0.8 s); keeps the last heading when the track stops. |
541
+ | **Server clock** | `lastSeenAt` is the server's clock. `meta.serverTime` of each message lets the engine learn the offset to the browser clock (the estimate with the least network delay wins), so a wrong browser clock does not stretch or shrink the prediction. |
542
+ | **Cheap frames** | Tracks that stand still, or whose picture changed by less than `epsilonM` (0.5 m) / 0.5°, are not reported; no timer runs while nothing moves; `frameHz` caps the rate (30). |
543
+
544
+ Options (`MotionOptions`): `blendMs`, `headingSmoothMs`, `maxExtrapolateMs`, `minMovingKt` (5), `snapDistanceM`, `frameHz`, `epsilonM`, `extrapolate` (`false` shows raw positions, for comparing sources), `isSurface`, `clock` (inject the replay clock to play back at another speed), `scheduler` (tests). `motion.sample(id)` gives the shown position at any time without touching frame state, e.g. to attach a trail's head to the icon.
545
+
546
+ ### `useTrackHistory` — smooth map animation (deprecated: use `useTrackMotion`)
547
+
548
+ Maintains per-track position interpolation (tweening), dead-reckoning between updates, and bounded trail history, emitting animation frames outside React render:
549
+
550
+ ```ts
551
+ const { processUpdate, processRemove } = useTrackHistory({
552
+ maxHistoryPoints: 500, // trail length cap per track
553
+ minDeltaDeg: 0.0001, // ignore sub-threshold jitter
554
+ maxJumpDeg: 5.0, // discard implausible teleports
555
+ onFrame: ({ currentPositions, history, isMoving }) => drawMarkers(...),
556
+ });
557
+ ```
558
+
559
+ Update gaps longer than 30 s (lost coverage, socket offline) restart the track's trail instead of drawing a straight line across the map. Speeds beyond 1 200 kt (misdecodes, or position deltas divided by a compressed replay clock) are rejected and never drive dead reckoning.
560
+
561
+ ## Frontend recipes
562
+
563
+ ### Live map, end to end
564
+
565
+ The proven wiring (this is what the reference dashboard does):
566
+
567
+ ```tsx
568
+ // 1. Subscription → store, batched (never setState per message)
569
+ const pending = new Map<string, CurrentTrack>();
570
+ const removed = new Set<string>();
571
+ client.tracks.subscribe(opts, {
572
+ onSnapshot: (ts) => useTrackStore.getState().applySnapshot(ts),
573
+ onTrackUpdate: (t) => { pending.set(t.trackId, t); removed.delete(t.trackId); },
574
+ onTrackRemoved: (ev) => { pending.delete(ev.trackId); removed.add(ev.trackId); },
575
+ });
576
+ setInterval(() => {
577
+ useTrackStore.getState().applyBatch([...pending.values()], [...removed]);
578
+ pending.clear(); removed.clear();
579
+ }, 250);
580
+
581
+ // 2. Ghost cleanup — ids churn on feed/replay restarts
582
+ setInterval(() => { useTrackStore.getState().pruneStale(60_000); }, 10_000);
583
+
584
+ // 3. Store → animation engine → map markers (imperative, no React re-render)
585
+ const { processUpdate, processRemove } = useTrackHistory({ onFrame: drawMarkers });
586
+ useTrackStore.subscribe((state, prev) => {
587
+ for (const [id, t] of state.tracks) if (prev.tracks.get(id) !== t) processUpdate(t);
588
+ for (const id of prev.tracks.keys()) if (!state.tracks.has(id)) processRemove(id);
589
+ });
590
+ ```
591
+
592
+ ### Replay in the same map
593
+
594
+ Feed `replay.stream` callbacks into the **same** store/animation path and pause the live subscription while a session is active — replay then looks exactly like live. Batch `onTrackUpdate` into ~250 ms flushes the same way.
595
+
596
+ ### Assigned-route overlay
597
+
598
+ ```ts
599
+ const { routes } = await client.routes.list({ airportId });
600
+ const { assignments } = await client.routes.assignments();
601
+ const route = routes.find(r => r.routeId === assignments.find(a => a.trackId === selectedId)?.routeId);
602
+ // draw route.points ([lat, lon][]) as a dashed LineString: points.map(([lat, lon]) => [lon, lat])
603
+ ```
604
+
605
+ ## Performance notes (important for dashboards)
606
+
607
+ - **Don't call a React `setState` per WS message.** At realistic rates (hundreds of tracks × several Hz) that renders your page thousands of times per second. Buffer updates and flush on an interval (~250 ms to the store for the map, ~1 s to React state for panels), using `applyBatch`.
608
+ - **Request only the rate you need** — `maxUpdateHz: 5` with client-side interpolation looks identical to 20 Hz on a map and cuts WS traffic 4×.
609
+ - Drive map markers **imperatively** from `useTrackStore.subscribe(...)` + `useTrackHistory`, not through React props.
610
+
611
+ ## Wire encoding
612
+
613
+ `json` (default) or `msgpack` (binary, lower bandwidth) — set once in `AeroSwarmOptions.encoding` or per-subscription.
614
+
615
+ ## Environment variables (Vite frontends)
616
+
617
+ | Variable | Description |
618
+ |----------|-------------|
619
+ | `VITE_AEROSWARM_API_URL` | REST/tRPC/GraphQL base URL |
620
+ | `VITE_AEROSWARM_WS_URL` | WebSocket base URL |
621
+ | `VITE_AEROSWARM_API_KEY` | Viewer token |
622
+
623
+ These are baked into the bundle at build time (Vite), not read at runtime.
624
+
625
+ ## Building & testing
626
+
627
+ ```bash
628
+ pnpm run build # tsup → dist/ (index + /sdk + /react)
629
+ pnpm run typecheck
630
+ pnpm test
631
+ ```