@aeroswarm/client 0.0.0-stage → 0.1.12

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/dist/index.js ADDED
@@ -0,0 +1,803 @@
1
+ // src/sdk/websocket/index.ts
2
+ import { streamEnvelopeSchema, currentTrackSchema } from "@aeroswarm/core/validation";
3
+ var AeroWsClient = class {
4
+ constructor(url, token, maxReconnectDelay = 3e4, heartbeatTimeoutMs = 35e3) {
5
+ this.url = url;
6
+ this.token = token;
7
+ this.maxReconnectDelay = maxReconnectDelay;
8
+ this.heartbeatTimeoutMs = heartbeatTimeoutMs;
9
+ }
10
+ url;
11
+ token;
12
+ maxReconnectDelay;
13
+ heartbeatTimeoutMs;
14
+ ws = null;
15
+ attempt = 0;
16
+ stopped = false;
17
+ lastSubscribe = null;
18
+ callbacks = {};
19
+ lastSeq = -1;
20
+ lastHeartbeat = Date.now();
21
+ heartbeatTimer = null;
22
+ reconnectTimer = null;
23
+ status = "idle";
24
+ connect(opts, callbacks) {
25
+ this.lastSubscribe = opts;
26
+ this.callbacks = callbacks;
27
+ this.stopped = false;
28
+ this._clearReconnectTimer();
29
+ this._setStatus("connecting");
30
+ this._connect();
31
+ }
32
+ updateFilter(opts) {
33
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
34
+ this.ws.send(JSON.stringify({ type: "update-filter", ...opts }));
35
+ }
36
+ disconnect() {
37
+ this.stopped = true;
38
+ this._clearHeartbeatTimer();
39
+ this._clearReconnectTimer();
40
+ this._dropSocket();
41
+ this._setStatus("offline");
42
+ }
43
+ /**
44
+ * Force a fresh connection now, skipping any backoff wait. Re-subscribes with
45
+ * the last `connect()` options (the server then sends a new snapshot).
46
+ * Also resumes after `disconnect()`. No-op if `connect()` was never called.
47
+ * Returns false in that no-op case.
48
+ */
49
+ reconnect() {
50
+ if (!this.lastSubscribe) return false;
51
+ this.stopped = false;
52
+ this.attempt = 0;
53
+ this._clearHeartbeatTimer();
54
+ this._clearReconnectTimer();
55
+ this._dropSocket();
56
+ this._setStatus("reconnecting");
57
+ this._connect();
58
+ return true;
59
+ }
60
+ getStatus() {
61
+ return this.status;
62
+ }
63
+ _setStatus(s) {
64
+ if (this.status === s) return;
65
+ this.status = s;
66
+ this.callbacks.onStatusChange?.(s);
67
+ }
68
+ /** Close the current socket without letting its onclose schedule a reconnect. */
69
+ _dropSocket() {
70
+ const ws = this.ws;
71
+ this.ws = null;
72
+ if (!ws) return;
73
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
74
+ ws.close();
75
+ }
76
+ _connect() {
77
+ const wsUrl = `${this.url}?token=${encodeURIComponent(this.token)}`;
78
+ this._dropSocket();
79
+ try {
80
+ this.ws = new WebSocket(wsUrl);
81
+ this.ws.binaryType = "arraybuffer";
82
+ } catch {
83
+ this._scheduleReconnect();
84
+ return;
85
+ }
86
+ this.ws.onopen = () => {
87
+ this.attempt = 0;
88
+ this.lastSeq = -1;
89
+ this._setStatus("connected");
90
+ this._resetHeartbeatTimer();
91
+ if (this.lastSubscribe) {
92
+ this.ws.send(JSON.stringify({
93
+ type: "subscribe",
94
+ encoding: "json",
95
+ ...this.lastSubscribe
96
+ }));
97
+ }
98
+ };
99
+ this.ws.onmessage = (event) => {
100
+ this._handleRaw(event.data);
101
+ };
102
+ this.ws.onclose = () => {
103
+ this._clearHeartbeatTimer();
104
+ if (!this.stopped) {
105
+ this._setStatus("reconnecting");
106
+ this._scheduleReconnect();
107
+ }
108
+ };
109
+ this.ws.onerror = () => {
110
+ this.callbacks.onError?.({
111
+ code: "WS_ERROR",
112
+ message: "WebSocket connection error",
113
+ recoverable: true
114
+ });
115
+ };
116
+ }
117
+ _handleRaw(raw) {
118
+ this._resetHeartbeatTimer();
119
+ let parsed;
120
+ try {
121
+ const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
122
+ parsed = JSON.parse(text);
123
+ } catch {
124
+ return;
125
+ }
126
+ let msg;
127
+ const result = streamEnvelopeSchema.safeParse(parsed);
128
+ if (!result.success) {
129
+ const raw2 = parsed;
130
+ if (raw2["type"] === "snapshot" && Array.isArray(raw2["payload"]?.["tracks"])) {
131
+ const rawTracks = raw2["payload"]["tracks"];
132
+ const goodTracks = rawTracks.map((t) => currentTrackSchema.safeParse(t)).filter((r) => r.success).map((r) => r.data);
133
+ if (goodTracks.length > 0) this.callbacks.onSnapshot?.(goodTracks);
134
+ }
135
+ return;
136
+ }
137
+ msg = result.data;
138
+ if (msg.seq <= this.lastSeq && this.lastSeq > 0) return;
139
+ if (msg.seq > this.lastSeq + 10 && this.lastSeq > 0) {
140
+ this._requestSnapshot();
141
+ }
142
+ this.lastSeq = msg.seq;
143
+ switch (msg.type) {
144
+ case "snapshot":
145
+ this.callbacks.onSnapshot?.(msg.payload.tracks);
146
+ break;
147
+ case "track.updated":
148
+ this.callbacks.onTrackUpdate?.(msg.payload.track);
149
+ break;
150
+ case "track.removed":
151
+ this.callbacks.onTrackRemoved?.(msg.payload);
152
+ break;
153
+ case "source.status":
154
+ this.callbacks.onSourceStatus?.(msg.payload);
155
+ break;
156
+ case "stream.heartbeat":
157
+ this.lastHeartbeat = Date.now();
158
+ break;
159
+ case "stream.degraded":
160
+ this._setStatus("degraded");
161
+ this.callbacks.onDegraded?.(msg.payload.reason);
162
+ break;
163
+ case "stream.error":
164
+ this.callbacks.onError?.({
165
+ code: msg.payload.code,
166
+ message: msg.payload.message ?? msg.payload.code,
167
+ recoverable: msg.payload.recoverable
168
+ });
169
+ break;
170
+ }
171
+ }
172
+ _requestSnapshot() {
173
+ if (!this.lastSubscribe || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
174
+ this.ws.send(JSON.stringify({ type: "subscribe", ...this.lastSubscribe }));
175
+ }
176
+ _resetHeartbeatTimer() {
177
+ this._clearHeartbeatTimer();
178
+ this.heartbeatTimer = setInterval(() => {
179
+ if (Date.now() - this.lastHeartbeat > this.heartbeatTimeoutMs) {
180
+ this._setStatus("degraded");
181
+ this.callbacks.onDegraded?.("heartbeat_timeout");
182
+ }
183
+ }, this.heartbeatTimeoutMs / 2);
184
+ }
185
+ _clearHeartbeatTimer() {
186
+ if (this.heartbeatTimer) {
187
+ clearInterval(this.heartbeatTimer);
188
+ this.heartbeatTimer = null;
189
+ }
190
+ }
191
+ _scheduleReconnect() {
192
+ const delay = Math.min(1e3 * 2 ** this.attempt, this.maxReconnectDelay);
193
+ this.attempt++;
194
+ this._clearReconnectTimer();
195
+ this.reconnectTimer = setTimeout(() => {
196
+ this.reconnectTimer = null;
197
+ if (!this.stopped) this._connect();
198
+ }, delay);
199
+ }
200
+ _clearReconnectTimer() {
201
+ if (this.reconnectTimer) {
202
+ clearTimeout(this.reconnectTimer);
203
+ this.reconnectTimer = null;
204
+ }
205
+ }
206
+ };
207
+
208
+ // src/sdk/websocket/replay.ts
209
+ var toIso = (v) => v instanceof Date ? v.toISOString() : v;
210
+ var AeroReplayClient = class {
211
+ constructor(wsBase, token, wsPath = "/ws/tracks/replay") {
212
+ this.wsBase = wsBase;
213
+ this.token = token;
214
+ this.wsPath = wsPath;
215
+ }
216
+ wsBase;
217
+ token;
218
+ wsPath;
219
+ /** Open a replay stream. Returns a handle for transport control. */
220
+ stream(opts, callbacks) {
221
+ const params = new URLSearchParams({ token: this.token });
222
+ if (opts.airportId) params.set("airportId", opts.airportId);
223
+ if (opts.sessionId) params.set("sessionId", opts.sessionId);
224
+ const ws = new WebSocket(`${this.wsBase}${this.wsPath}?${params.toString()}`);
225
+ let sessionId = opts.sessionId ?? null;
226
+ let closedByUser = false;
227
+ ws.onopen = () => {
228
+ if (!opts.sessionId) {
229
+ ws.send(JSON.stringify({
230
+ type: "replay.start",
231
+ from: toIso(opts.from),
232
+ to: toIso(opts.to),
233
+ speed: opts.speed ?? 1
234
+ }));
235
+ }
236
+ };
237
+ ws.onmessage = (ev) => {
238
+ let msg;
239
+ try {
240
+ msg = JSON.parse(String(ev.data));
241
+ } catch {
242
+ return;
243
+ }
244
+ const payload = msg.payload ?? {};
245
+ switch (msg.type) {
246
+ case "snapshot": {
247
+ if (typeof payload["sessionId"] === "string") sessionId = payload["sessionId"];
248
+ callbacks.onSnapshot?.(payload["tracks"] ?? [], sessionId ?? "");
249
+ break;
250
+ }
251
+ case "track.updated":
252
+ callbacks.onTrackUpdate?.(payload["track"]);
253
+ break;
254
+ case "track.removed":
255
+ callbacks.onTrackRemoved?.({
256
+ trackId: String(payload["trackId"] ?? ""),
257
+ reason: String(payload["reason"] ?? "")
258
+ });
259
+ break;
260
+ case "replay.progress": {
261
+ const progress = payload;
262
+ sessionId = progress.sessionId ?? sessionId;
263
+ callbacks.onProgress?.(progress);
264
+ break;
265
+ }
266
+ case "replay.done":
267
+ callbacks.onDone?.(String(payload["sessionId"] ?? sessionId ?? ""));
268
+ break;
269
+ }
270
+ };
271
+ ws.onerror = () => {
272
+ if (!closedByUser) callbacks.onError?.(new Error("replay websocket error"));
273
+ };
274
+ ws.onclose = (ev) => {
275
+ if (!closedByUser && ev.code >= 4e3) {
276
+ callbacks.onError?.(new Error(`replay websocket closed: ${ev.reason || ev.code}`));
277
+ }
278
+ };
279
+ const send = (body) => {
280
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(body));
281
+ };
282
+ return {
283
+ get sessionId() {
284
+ return sessionId;
285
+ },
286
+ pause: () => send({ type: "replay.pause" }),
287
+ resume: () => send({ type: "replay.resume" }),
288
+ seek: (at) => send({ type: "replay.seek", at: toIso(at) }),
289
+ stop: () => {
290
+ closedByUser = true;
291
+ send({ type: "replay.stop" });
292
+ try {
293
+ ws.close();
294
+ } catch {
295
+ }
296
+ }
297
+ };
298
+ }
299
+ };
300
+
301
+ // src/sdk/api/trpc.ts
302
+ import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
303
+ function createAeroTrpcClient(endpoint, token) {
304
+ const base = endpoint.replace(/\/$/, "");
305
+ return createTRPCProxyClient({
306
+ links: [
307
+ httpBatchLink({
308
+ url: `${base}/trpc`,
309
+ headers: () => ({ "x-aero-token": token }),
310
+ methodOverride: "POST"
311
+ })
312
+ ]
313
+ });
314
+ }
315
+
316
+ // src/sdk/api/rest.ts
317
+ var AeroRestClient = class {
318
+ constructor(endpoint, token) {
319
+ this.endpoint = endpoint;
320
+ this.token = token;
321
+ }
322
+ endpoint;
323
+ token;
324
+ async req(method, path, body) {
325
+ const res = await fetch(`${this.endpoint}${path}`, {
326
+ method,
327
+ headers: {
328
+ "Content-Type": "application/json",
329
+ "x-aero-token": this.token
330
+ },
331
+ body: body ? JSON.stringify(body) : void 0
332
+ });
333
+ if (!res.ok) throw new Error(`${method} ${path} \u2192 ${res.status}`);
334
+ return res.json();
335
+ }
336
+ createSource(body) {
337
+ return this.req("POST", "/v1/sources", body);
338
+ }
339
+ listSources() {
340
+ return this.req("GET", "/v1/sources");
341
+ }
342
+ deleteSource(id) {
343
+ return this.req("DELETE", `/v1/sources/${id}`);
344
+ }
345
+ createWebhook(body) {
346
+ return this.req("POST", "/v1/webhooks", body);
347
+ }
348
+ getTracksCurrent(params) {
349
+ const search = new URLSearchParams();
350
+ if (params?.bbox) search.set("bbox", params.bbox.join(","));
351
+ if (params?.minConfidence != null) search.set("minConfidence", String(params.minConfidence));
352
+ const qs = search.size ? `?${search.toString()}` : "";
353
+ return this.req("GET", `/v1/tracks/current${qs}`);
354
+ }
355
+ /** Flight plans received from AFTN feeds (FPL/CHG/DLA/DEP/ARR/CNL folded per flight). */
356
+ listFlightPlans(params) {
357
+ const search = new URLSearchParams();
358
+ if (params?.airportId) search.set("airportId", params.airportId);
359
+ if (params?.status) search.set("status", params.status);
360
+ if (params?.callsign) search.set("callsign", params.callsign);
361
+ if (params?.limit != null) search.set("limit", String(params.limit));
362
+ const qs = search.size ? `?${search.toString()}` : "";
363
+ return this.req("GET", `/v1/flight-plans${qs}`);
364
+ }
365
+ getFlightPlan(id) {
366
+ return this.req("GET", `/v1/flight-plans/${encodeURIComponent(id)}`);
367
+ }
368
+ /** Raw AFTN messages (every parsed ATS message, newest first — the input
369
+ * side of the flight-plan pipeline). */
370
+ listAftnMessages(params) {
371
+ const search = new URLSearchParams();
372
+ if (params?.airportId) search.set("airportId", params.airportId);
373
+ if (params?.limit != null) search.set("limit", String(params.limit));
374
+ const qs = search.size ? `?${search.toString()}` : "";
375
+ return this.req("GET", `/v1/aftn/messages${qs}`);
376
+ }
377
+ health() {
378
+ return this.req("GET", "/health");
379
+ }
380
+ };
381
+
382
+ // src/sdk/api/graphql.ts
383
+ var AeroGraphQLClient = class {
384
+ constructor(endpoint, token) {
385
+ this.endpoint = endpoint;
386
+ this.token = token;
387
+ }
388
+ endpoint;
389
+ token;
390
+ async query(query, variables) {
391
+ const res = await fetch(`${this.endpoint}/graphql`, {
392
+ method: "POST",
393
+ headers: {
394
+ "Content-Type": "application/json",
395
+ "x-aero-token": this.token
396
+ },
397
+ body: JSON.stringify({ query, variables })
398
+ });
399
+ if (!res.ok) throw new Error(`GraphQL ${res.status}: ${await res.text().catch(() => "")}`);
400
+ const json = await res.json();
401
+ if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
402
+ return json.data;
403
+ }
404
+ };
405
+
406
+ // src/sdk/cache/index.ts
407
+ import { isRenderableTrack } from "@aeroswarm/core/utils";
408
+
409
+ // src/shared/label/index.ts
410
+ var LABEL_COLORS = {
411
+ ARRIVAL: "#FACC15",
412
+ // yellow
413
+ DEPARTURE: "#22C55E",
414
+ // green
415
+ GROUND_VEHICLE: "#3B82F6",
416
+ // blue
417
+ LOST: "#FFFFFF",
418
+ // white
419
+ UNKNOWN: "#FFFFFF"
420
+ // white — no flight plan
421
+ };
422
+ function getLabelCategory(track) {
423
+ if (track.status === "lost") return "LOST";
424
+ if (track.targetType === "vehicle") return "GROUND_VEHICLE";
425
+ switch (track.flightPlan?.phase) {
426
+ case "ARRIVAL":
427
+ return "ARRIVAL";
428
+ case "DEPARTURE":
429
+ return "DEPARTURE";
430
+ default:
431
+ return "UNKNOWN";
432
+ }
433
+ }
434
+ function withLabel(track) {
435
+ const labelCategory = getLabelCategory(track);
436
+ return { ...track, labelCategory, labelColor: LABEL_COLORS[labelCategory] };
437
+ }
438
+
439
+ // src/sdk/cache/index.ts
440
+ var TypedEmitter = class {
441
+ listeners = /* @__PURE__ */ new Map();
442
+ on(event, fn) {
443
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
444
+ this.listeners.get(event).add(fn);
445
+ return () => this.listeners.get(event)?.delete(fn);
446
+ }
447
+ emit(event, payload) {
448
+ this.listeners.get(event)?.forEach((fn) => fn(payload));
449
+ }
450
+ };
451
+ var TrackCache = class extends TypedEmitter {
452
+ tracks = /* @__PURE__ */ new Map();
453
+ /** Merges into the cached track and returns the labeled result. A "lost"
454
+ * track is evicted from the cache but still returned (labelCategory LOST). */
455
+ applyUpdate(track) {
456
+ const merged = withLabel({ ...this.tracks.get(track.trackId), ...track });
457
+ if (track.status === "lost") {
458
+ this.tracks.delete(track.trackId);
459
+ this.emit("removed", { trackId: track.trackId });
460
+ return merged;
461
+ }
462
+ this.tracks.set(track.trackId, merged);
463
+ this.emit("update", merged);
464
+ return merged;
465
+ }
466
+ applyRemoved(ev) {
467
+ this.tracks.delete(ev.trackId);
468
+ this.emit("removed", ev);
469
+ }
470
+ applySnapshot(tracks) {
471
+ this.tracks.clear();
472
+ for (const t of tracks) {
473
+ if (isRenderableTrack(t)) this.tracks.set(t.trackId, withLabel(t));
474
+ }
475
+ const all = Array.from(this.tracks.values());
476
+ this.emit("snapshot", all);
477
+ return all;
478
+ }
479
+ getAll() {
480
+ return Array.from(this.tracks.values());
481
+ }
482
+ get(id) {
483
+ return this.tracks.get(id);
484
+ }
485
+ get size() {
486
+ return this.tracks.size;
487
+ }
488
+ clear() {
489
+ this.tracks.clear();
490
+ }
491
+ getRenderable() {
492
+ return this.getAll().filter(isRenderableTrack);
493
+ }
494
+ };
495
+
496
+ // src/sdk/index.ts
497
+ var AeroSwarm = class _AeroSwarm {
498
+ constructor(opts) {
499
+ this.opts = opts;
500
+ this.trpc = createAeroTrpcClient(opts.endpoint, opts.token);
501
+ this.rest = new AeroRestClient(opts.endpoint, opts.token);
502
+ this.graphql = new AeroGraphQLClient(opts.endpoint, opts.token);
503
+ this.cache = new TrackCache();
504
+ const wsBase = (opts.wsEndpoint ?? opts.endpoint).replace(/^http/, "ws");
505
+ this.wsClient = new AeroWsClient(
506
+ `${wsBase}/ws/tracks/live`,
507
+ opts.token,
508
+ opts.maxReconnectDelay ?? 3e4
509
+ );
510
+ }
511
+ opts;
512
+ trpc;
513
+ rest;
514
+ graphql;
515
+ cache;
516
+ wsClient;
517
+ _status = "idle";
518
+ _lastUpdateAt = null;
519
+ _error = null;
520
+ _sourceStatus = {};
521
+ // ── Federated history resolution ──────────────────────────────────────
522
+ // An airport can serve its own replay/history through a backend mounting
523
+ // @aeroswarm/replay-server instead of AeroSwarm's own (see
524
+ // docs/development/federation/). `airports.list()`'s `historyEndpoint`
525
+ // field drives this automatically; every federatable call also accepts
526
+ // an explicit `endpoint` to bypass the registry entirely ("connect
527
+ // manual"). Priority: explicit endpoint > registry > AeroSwarm itself.
528
+ airportsCache = null;
529
+ static AIRPORTS_CACHE_TTL_MS = 6e4;
530
+ /** Resolves which base URL (if any) should serve a federated call.
531
+ * Returns null to mean "use AeroSwarm's own endpoint" — the normal,
532
+ * unfederated path, which is also what a lookup failure falls back to
533
+ * (a broken airports.list() call must never brick replay/history for
534
+ * everyone else). */
535
+ async resolveHistoryEndpoint(airportId, override) {
536
+ if (override) return override.replace(/\/$/, "");
537
+ if (!airportId) return null;
538
+ try {
539
+ const now = Date.now();
540
+ if (!this.airportsCache || now - this.airportsCache.fetchedAt > _AeroSwarm.AIRPORTS_CACHE_TTL_MS) {
541
+ const { airports } = await this.airports.list();
542
+ this.airportsCache = {
543
+ byId: new Map(airports.map((a) => [a.id, a.historyEndpoint])),
544
+ fetchedAt: now
545
+ };
546
+ }
547
+ const endpoint = this.airportsCache.byId.get(airportId) ?? null;
548
+ return endpoint ? endpoint.replace(/\/$/, "") : null;
549
+ } catch {
550
+ return null;
551
+ }
552
+ }
553
+ async federatedFetch(base, path, query) {
554
+ const url = new URL(`${base}${path}`);
555
+ for (const [k, v] of Object.entries(query ?? {})) if (v !== void 0) url.searchParams.set(k, v);
556
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${this.opts.token}` } });
557
+ if (!res.ok) throw new Error(`Federated request failed: ${res.status} ${res.statusText} (${url.pathname})`);
558
+ return res.json();
559
+ }
560
+ tracks = {
561
+ subscribe: (opts, callbacks) => {
562
+ this.wsClient.connect(
563
+ { encoding: this.opts.encoding ?? "json", ...opts },
564
+ {
565
+ ...callbacks,
566
+ onStatusChange: (s) => {
567
+ this._status = s;
568
+ callbacks.onStatusChange?.(s);
569
+ },
570
+ onSnapshot: (tracks) => {
571
+ const labeled = this.cache.applySnapshot(tracks);
572
+ this._lastUpdateAt = Date.now();
573
+ callbacks.onSnapshot?.(labeled);
574
+ },
575
+ onTrackUpdate: (track) => {
576
+ const labeled = this.cache.applyUpdate(track);
577
+ this._lastUpdateAt = Date.now();
578
+ callbacks.onTrackUpdate?.(labeled);
579
+ },
580
+ onTrackRemoved: (ev) => {
581
+ this.cache.applyRemoved(ev);
582
+ callbacks.onTrackRemoved?.(ev);
583
+ },
584
+ onSourceStatus: (ev) => {
585
+ this._sourceStatus[ev.sourceId] = ev.status;
586
+ callbacks.onSourceStatus?.(ev);
587
+ },
588
+ onError: (err) => {
589
+ this._error = err;
590
+ callbacks.onError?.(err);
591
+ }
592
+ }
593
+ );
594
+ return () => this.wsClient.disconnect();
595
+ },
596
+ updateFilter: (opts) => this.wsClient.updateFilter(opts),
597
+ /** Drop the live socket and reconnect immediately (no backoff wait),
598
+ * re-subscribing with the last options. Returns false if nothing has
599
+ * been subscribed yet. */
600
+ reconnect: () => this.wsClient.reconnect(),
601
+ stream: (opts) => {
602
+ const queue = [];
603
+ let resolve = null;
604
+ let done = false;
605
+ this.wsClient.connect({ encoding: "json", ...opts }, {
606
+ onSnapshot: (tracks) => {
607
+ this.cache.applySnapshot(tracks);
608
+ },
609
+ onTrackUpdate: (raw) => {
610
+ const t = this.cache.applyUpdate(raw);
611
+ if (resolve) {
612
+ resolve({ value: t, done: false });
613
+ resolve = null;
614
+ } else queue.push(t);
615
+ },
616
+ onStatusChange: (s) => {
617
+ if (s === "offline") done = true;
618
+ }
619
+ });
620
+ return {
621
+ [Symbol.asyncIterator]: () => ({
622
+ next: () => {
623
+ if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
624
+ if (done) return Promise.resolve({ value: void 0, done: true });
625
+ return new Promise((r) => {
626
+ resolve = r;
627
+ });
628
+ },
629
+ return: async () => {
630
+ this.wsClient.disconnect();
631
+ return { value: void 0, done: true };
632
+ }
633
+ })
634
+ };
635
+ },
636
+ snapshot: (input) => this.trpc.tracks.snapshot.query(input ?? {}),
637
+ /** Persisted position history for one track. If the airport has a
638
+ * `historyEndpoint` (or `endpoint` is passed explicitly), this calls
639
+ * that backend's `@aeroswarm/replay-server` instead of AeroSwarm's
640
+ * own local database — see docs/development/federation/003-*.md. */
641
+ history: async (input) => {
642
+ const base = await this.resolveHistoryEndpoint(input.airportId, input.endpoint);
643
+ if (base) {
644
+ return this.federatedFetch(base, `/tracks/${encodeURIComponent(input.trackId)}/history`, {
645
+ limit: input.limit?.toString()
646
+ });
647
+ }
648
+ return this.trpc.tracks.history.query(input);
649
+ }
650
+ };
651
+ /** Flight plans received from AFTN feeds. Live tracks that correlate with a
652
+ * plan (Mode-S address, else callsign) also carry `track.flightPlan`. */
653
+ flightPlans = {
654
+ list: (input) => this.rest.listFlightPlans(input).then((r) => r.data.flightPlans),
655
+ get: (id) => this.rest.getFlightPlan(id).then((r) => r.data.flightPlan)
656
+ };
657
+ /** Raw AFTN/ATS messages received by AFTN feeds — the input side of the
658
+ * flight-plan pipeline (newest first, capped by the service). */
659
+ aftn = {
660
+ messages: (input) => this.rest.listAftnMessages(input).then((r) => r.data.messages)
661
+ };
662
+ sources = {
663
+ /** Registers a source and returns its token, tied to `airportId` — a
664
+ * source is a sensor physically at one airport, so this is required.
665
+ * Anything that later authenticates with the returned token (WS
666
+ * live/replay, tRPC calls) is scoped to that airport automatically,
667
+ * with no need to keep passing `airportId` on every subsequent call.
668
+ * For access spanning multiple airports, use the viewer/dashboard
669
+ * token instead — it's never auto-scoped. */
670
+ create: (body) => this.rest.createSource(body),
671
+ health: (sourceId) => this.trpc.sources.health.query({ sourceId }),
672
+ list: (input) => this.trpc.sources.list.query(input ?? {}),
673
+ /** Reassigning `airportId` (including to `null` to clear it) re-caches
674
+ * the source's token immediately, so the new scoping takes effect
675
+ * without waiting out the token cache's TTL. */
676
+ update: (input) => this.trpc.sources.update.mutate(input),
677
+ delete: (id) => this.trpc.sources.delete.mutate({ id })
678
+ };
679
+ /** Airport registry (multi-airport tenancy). */
680
+ airports = {
681
+ list: () => this.trpc.airports.list.query(),
682
+ create: (input) => this.trpc.airports.create.mutate(input),
683
+ update: (input) => this.trpc.airports.update.mutate(input),
684
+ delete: (input) => this.trpc.airports.delete.mutate(input),
685
+ /** Surface geometry (runway/taxiway/apron GeoJSON) for the map. */
686
+ surface: (input) => this.trpc.airports.surface.query(input),
687
+ setSurface: (input) => this.trpc.airports.setSurface.mutate(input)
688
+ };
689
+ replay = {
690
+ /** Data-availability calendar: which days have recorded tracks.
691
+ * Use this to populate/validate replay date pickers — days not in
692
+ * the list have no data. Federates to the airport's own backend when
693
+ * it has a `historyEndpoint` (or `endpoint` is passed explicitly) —
694
+ * see docs/development/federation/003-*.md. */
695
+ days: async (input) => {
696
+ const base = await this.resolveHistoryEndpoint(input?.airportId, input?.endpoint);
697
+ if (base) {
698
+ return this.federatedFetch(base, "/replay/days", {
699
+ lookbackDays: input?.lookbackDays?.toString()
700
+ });
701
+ }
702
+ return this.trpc.replay.days.query(input ?? {});
703
+ },
704
+ range: async (input) => {
705
+ const base = await this.resolveHistoryEndpoint(input.airportId, input.endpoint);
706
+ if (base) {
707
+ const summary = await this.federatedFetch(
708
+ base,
709
+ "/replay/range",
710
+ { from: input.from, to: input.to }
711
+ );
712
+ return { from: input.from, to: input.to, ...summary };
713
+ }
714
+ return this.trpc.replay.range.query(input);
715
+ },
716
+ // replay.window / replay.seek (bulk sample download for client-side
717
+ // interpolation) were removed — they were never part of the federation
718
+ // contract, and `replay.stream`'s server-paced WS (with server-side
719
+ // seek/re-snapshot) covers every use they had at a fraction of the
720
+ // payload. See docs/development/federation/005-*.md.
721
+ /** Server-paced WS playback: streams historical tracks at `speed`× in
722
+ * the same envelope format as the live stream. Returns a transport
723
+ * handle (pause/resume/seek/stop) — a Promise, since resolving which
724
+ * backend serves this airport (see docs/development/federation/003-*.md)
725
+ * is async. See `AeroReplayClient` for the raw wire protocol if you
726
+ * want to speak WS manually instead. */
727
+ stream: async (opts, callbacks) => {
728
+ const federatedBase = await this.resolveHistoryEndpoint(opts.airportId, opts.endpoint);
729
+ if (federatedBase) {
730
+ const wsBase2 = federatedBase.replace(/^http/, "ws");
731
+ const { airportId: _drop, ...rest } = opts;
732
+ return new AeroReplayClient(wsBase2, this.opts.token, "/replay").stream(rest, callbacks);
733
+ }
734
+ const wsBase = (this.opts.wsEndpoint ?? this.opts.endpoint).replace(/^http/, "ws");
735
+ return new AeroReplayClient(wsBase, this.opts.token).stream(opts, callbacks);
736
+ }
737
+ };
738
+ /** Movement analytics: takeoffs/landings per hour + peak hour, recorded
739
+ * by the pipeline's movement detector. */
740
+ analytics = {
741
+ movements: (input) => this.trpc.analytics.movements.query(input ?? {})
742
+ };
743
+ /** Route conformance: pre-defined ground routes + live track clearances.
744
+ * Assign a route to a track and the safety engine can detect when it
745
+ * strays beyond the route's corridor. */
746
+ routes = {
747
+ list: (input) => this.trpc.routes.list.query(input ?? {}),
748
+ create: (input) => this.trpc.routes.create.mutate(input),
749
+ delete: (input) => this.trpc.routes.delete.mutate(input),
750
+ assign: (input) => this.trpc.routes.assign.mutate(input),
751
+ unassign: (input) => this.trpc.routes.unassign.mutate(input),
752
+ assignments: () => this.trpc.routes.assignments.query()
753
+ };
754
+ webhooks = {
755
+ list: () => this.trpc.webhooks.list.query(),
756
+ create: (body) => this.trpc.webhooks.create.mutate(body),
757
+ delete: (input) => this.trpc.webhooks.delete.mutate(input)
758
+ };
759
+ system = {
760
+ stats: () => this.trpc.system.stats.query(),
761
+ infraHealth: () => this.trpc.system.infraHealth.query(),
762
+ ingestHistory: () => this.trpc.system.ingestHistory.query(),
763
+ getConfig: () => this.trpc.system.getConfig.query(),
764
+ setConfig: (input) => this.trpc.system.setConfig.mutate(input),
765
+ clearTable: (input) => this.trpc.system.clearTable.mutate(input)
766
+ };
767
+ getStatus() {
768
+ return this._status;
769
+ }
770
+ getLastUpdateAt() {
771
+ return this._lastUpdateAt;
772
+ }
773
+ getError() {
774
+ return this._error;
775
+ }
776
+ getSourceStatus() {
777
+ return { ...this._sourceStatus };
778
+ }
779
+ disconnect() {
780
+ this.wsClient.disconnect();
781
+ }
782
+ reconnect() {
783
+ return this.wsClient.reconnect();
784
+ }
785
+ };
786
+
787
+ // src/shared/utils/index.ts
788
+ import { isRenderableTrack as isRenderableTrack2, haversineKm, isInsideBbox, toCurrentTrack, clamp } from "@aeroswarm/core/utils";
789
+ export {
790
+ AeroReplayClient,
791
+ AeroSwarm,
792
+ AeroWsClient,
793
+ LABEL_COLORS,
794
+ TrackCache,
795
+ clamp,
796
+ getLabelCategory,
797
+ haversineKm,
798
+ isInsideBbox,
799
+ isRenderableTrack2 as isRenderableTrack,
800
+ toCurrentTrack,
801
+ withLabel
802
+ };
803
+ //# sourceMappingURL=index.js.map