@aeroswarm/client 0.1.13 → 0.1.15

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.
@@ -318,6 +318,87 @@ function createAeroTrpcClient(endpoint, token) {
318
318
  });
319
319
  }
320
320
 
321
+ // src/sdk/api/response.ts
322
+ var ENVELOPE_HEADER = "x-aero-envelope";
323
+ var STATUS_CODES = [
324
+ [401, "UNAUTHORIZED"],
325
+ [403, "FORBIDDEN"],
326
+ [404, "NOT_FOUND"],
327
+ [400, "BAD_REQUEST"],
328
+ [409, "CONFLICT"],
329
+ [429, "RATE_LIMITED"],
330
+ [503, "UNAVAILABLE"],
331
+ [500, "INTERNAL"]
332
+ ];
333
+ function codeForStatus(status) {
334
+ const hit = STATUS_CODES.find(([s]) => s === status);
335
+ if (hit) return hit[1];
336
+ return status >= 500 ? "INTERNAL" : "BAD_REQUEST";
337
+ }
338
+ var AeroApiError = class extends Error {
339
+ status;
340
+ code;
341
+ /** Retrying later may succeed (rate limit, server busy or failing). */
342
+ recoverable;
343
+ details;
344
+ constructor(init) {
345
+ super(init.message);
346
+ this.name = "AeroApiError";
347
+ this.status = init.status;
348
+ this.code = init.code;
349
+ this.recoverable = init.recoverable;
350
+ this.details = init.details;
351
+ }
352
+ };
353
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
354
+ function isEnvelope(body) {
355
+ return isObject(body) && body.ok === true && "data" in body;
356
+ }
357
+ function isFailure(body) {
358
+ return isObject(body) && body.ok === false;
359
+ }
360
+ function errorFrom(res, body, text, label) {
361
+ const prefix = label ? `${label} \u2192 ${res.status}` : `HTTP ${res.status}`;
362
+ const failure = isFailure(body) ? body : void 0;
363
+ const e = failure?.error;
364
+ if (isObject(e)) {
365
+ const code = typeof e.code === "string" ? e.code : codeForStatus(res.status);
366
+ const message2 = typeof e.message === "string" ? e.message : res.statusText || prefix;
367
+ return new AeroApiError({
368
+ status: res.status,
369
+ code,
370
+ message: `${prefix}: ${message2}`,
371
+ recoverable: typeof e.recoverable === "boolean" ? e.recoverable : res.status >= 500,
372
+ details: e.details
373
+ });
374
+ }
375
+ const message = typeof e === "string" ? e : text.slice(0, 200) || res.statusText;
376
+ return new AeroApiError({
377
+ status: res.status,
378
+ code: codeForStatus(res.status),
379
+ message: message ? `${prefix}: ${message}` : prefix,
380
+ recoverable: res.status >= 500 || res.status === 429,
381
+ details: failure?.detail
382
+ });
383
+ }
384
+ async function readApiBody(res, label) {
385
+ const text = await res.text();
386
+ let body;
387
+ try {
388
+ body = text ? JSON.parse(text) : void 0;
389
+ } catch {
390
+ body = void 0;
391
+ }
392
+ if (!res.ok || isFailure(body)) throw errorFrom(res, body, text, label);
393
+ return body;
394
+ }
395
+ async function parseApiEnvelope(res, label) {
396
+ const body = await readApiBody(res, label);
397
+ if (!isEnvelope(body)) return { data: body };
398
+ const meta = body.meta;
399
+ return { data: body.data, ...isObject(meta) && { meta } };
400
+ }
401
+
321
402
  // src/sdk/api/rest.ts
322
403
  var AeroRestClient = class {
323
404
  constructor(endpoint, token) {
@@ -335,8 +416,7 @@ var AeroRestClient = class {
335
416
  },
336
417
  body: body ? JSON.stringify(body) : void 0
337
418
  });
338
- if (!res.ok) throw new Error(`${method} ${path} \u2192 ${res.status}`);
339
- return res.json();
419
+ return await readApiBody(res, `${method} ${path}`);
340
420
  }
341
421
  createSource(body) {
342
422
  return this.req("POST", "/v1/sources", body);
@@ -555,12 +635,16 @@ var AeroSwarm = class _AeroSwarm {
555
635
  return null;
556
636
  }
557
637
  }
558
- async federatedFetch(base, path, query) {
638
+ async federatedRequest(base, path, query) {
559
639
  const url = new URL(`${base}${path}`);
560
640
  for (const [k, v] of Object.entries(query ?? {})) if (v !== void 0) url.searchParams.set(k, v);
561
- const res = await fetch(url, { headers: { Authorization: `Bearer ${this.opts.token}` } });
562
- if (!res.ok) throw new Error(`Federated request failed: ${res.status} ${res.statusText} (${url.pathname})`);
563
- return res.json();
641
+ const res = await fetch(url, {
642
+ headers: { Authorization: `Bearer ${this.opts.token}`, [ENVELOPE_HEADER]: "1" }
643
+ });
644
+ return parseApiEnvelope(res, `Federated request ${url.pathname}`);
645
+ }
646
+ async federatedFetch(base, path, query) {
647
+ return (await this.federatedRequest(base, path, query)).data;
564
648
  }
565
649
  tracks = {
566
650
  subscribe: (opts, callbacks) => {
@@ -651,6 +735,42 @@ var AeroSwarm = class _AeroSwarm {
651
735
  });
652
736
  }
653
737
  return this.trpc.tracks.history.query(input);
738
+ },
739
+ /**
740
+ * The path an aircraft has flown, ready to draw: a GeoJSON line plus a point for every time
741
+ * mark, with the label already written in `tz`. By default the whole flight so far; `fromMs`
742
+ * and `toMs` choose a period, `sinceMs` fetches only what is new (see `createTrailPoller`),
743
+ * `stepM` asks for a coarser line. The airport's backend builds it (memory first, history
744
+ * as a fallback), so nothing is computed here. There is no AeroSwarm-side equivalent:
745
+ * pass `endpoint` or an `airportId` with a registered `historyEndpoint`.
746
+ */
747
+ trail: async (input) => {
748
+ const base = await this.resolveHistoryEndpoint(input.airportId, input.endpoint);
749
+ if (!base) {
750
+ throw new AeroApiError({
751
+ status: 400,
752
+ code: "BAD_REQUEST",
753
+ recoverable: false,
754
+ message: "tracks.trail needs the airport's backend: pass `endpoint`, or an `airportId` that has a historyEndpoint"
755
+ });
756
+ }
757
+ const num = (v) => v === void 0 ? void 0 : String(v);
758
+ const { data, meta } = await this.federatedRequest(
759
+ base,
760
+ `/tracks/${encodeURIComponent(input.trackId)}/trail`,
761
+ {
762
+ fromMs: num(input.fromMs),
763
+ toMs: num(input.toMs),
764
+ sinceMs: num(input.sinceMs),
765
+ stepM: num(input.stepM),
766
+ limit: num(input.limit),
767
+ labelEveryMs: num(input.labelEveryMs),
768
+ tz: input.tz,
769
+ labelFormat: input.labelFormat,
770
+ format: "geojson"
771
+ }
772
+ );
773
+ return { trail: data, ...meta && { meta } };
654
774
  }
655
775
  };
656
776
  /** Flight plans received from AFTN feeds. Live tracks that correlate with a
@@ -821,8 +941,135 @@ function AeroSwarmProvider({
821
941
  return /* @__PURE__ */ jsx(QueryClientProvider, { client: qc, children: /* @__PURE__ */ jsx(AeroSwarmContext.Provider, { value: { client }, children }) });
822
942
  }
823
943
 
944
+ // src/react/hooks/use-track-trail.ts
945
+ import { useEffect, useRef, useState } from "react";
946
+
947
+ // src/shared/trail/index.ts
948
+ var lineOf = (fc) => fc.features.find((f) => f.properties.kind === "trail");
949
+ var marksOf = (fc) => fc.features.filter((f) => f.properties.kind === "mark");
950
+ function trailEnd(trail) {
951
+ const line = lineOf(trail);
952
+ return line && line.geometry.coordinates.length > 0 ? line.properties.to : void 0;
953
+ }
954
+ function mergeTrail(prev, delta) {
955
+ if (!prev) return delta;
956
+ const before = lineOf(prev);
957
+ const added = lineOf(delta);
958
+ if (!before || !added) return prev;
959
+ if (added.geometry.coordinates.length === 0) return prev;
960
+ const seen = new Set(marksOf(prev).map((m) => m.properties.t));
961
+ const newMarks = marksOf(delta).filter((m) => !seen.has(m.properties.t));
962
+ const line = {
963
+ ...before,
964
+ properties: { ...before.properties, to: added.properties.to, stepM: Math.max(before.properties.stepM, added.properties.stepM) },
965
+ geometry: { type: "LineString", coordinates: [...before.geometry.coordinates, ...added.geometry.coordinates] }
966
+ };
967
+ return { type: "FeatureCollection", features: [line, ...marksOf(prev), ...newMarks] };
968
+ }
969
+ var isGone = (err) => typeof err === "object" && err !== null && err.code === "NOT_FOUND";
970
+ function createTrailPoller(fetchTrail, options) {
971
+ const pollMs = options.pollMs ?? 5e3;
972
+ const stopWhenGone = options.stopWhenGone ?? true;
973
+ let current;
974
+ let stopped = false;
975
+ let timer;
976
+ let running;
977
+ const schedule = () => {
978
+ if (stopped || pollMs <= 0) return;
979
+ timer = setTimeout(() => void tick(), pollMs);
980
+ };
981
+ async function once() {
982
+ try {
983
+ const since = current ? trailEnd(current) : void 0;
984
+ const { trail } = await fetchTrail(since);
985
+ if (stopped) return;
986
+ const next = mergeTrail(current, trail);
987
+ if (next !== current) {
988
+ current = next;
989
+ options.onUpdate(next);
990
+ }
991
+ } catch (err) {
992
+ if (stopped) return;
993
+ options.onError?.(err);
994
+ if (stopWhenGone && isGone(err)) stopped = true;
995
+ }
996
+ }
997
+ function tick() {
998
+ if (stopped) return Promise.resolve();
999
+ running ??= once().finally(() => {
1000
+ running = void 0;
1001
+ schedule();
1002
+ });
1003
+ return running;
1004
+ }
1005
+ void tick();
1006
+ return {
1007
+ stop() {
1008
+ stopped = true;
1009
+ if (timer) clearTimeout(timer);
1010
+ },
1011
+ async refresh() {
1012
+ if (timer) clearTimeout(timer);
1013
+ await (running ?? tick());
1014
+ },
1015
+ current: () => current
1016
+ };
1017
+ }
1018
+
1019
+ // src/react/hooks/use-track-trail.ts
1020
+ function useTrackTrail(trackId, options = {}) {
1021
+ const { client } = useAeroSwarmContext();
1022
+ const [trail, setTrail] = useState(void 0);
1023
+ const [loading, setLoading] = useState(false);
1024
+ const [error, setError] = useState(void 0);
1025
+ const optionsRef = useRef(options);
1026
+ optionsRef.current = options;
1027
+ const enabled = options.enabled !== false && !!trackId;
1028
+ const key = JSON.stringify([
1029
+ trackId,
1030
+ options.endpoint,
1031
+ options.airportId,
1032
+ options.fromMs,
1033
+ options.toMs,
1034
+ options.stepM,
1035
+ options.limit,
1036
+ options.labelEveryMs,
1037
+ options.tz,
1038
+ options.labelFormat,
1039
+ options.pollMs
1040
+ ]);
1041
+ useEffect(() => {
1042
+ setTrail(void 0);
1043
+ setError(void 0);
1044
+ if (!enabled || !trackId) {
1045
+ setLoading(false);
1046
+ return;
1047
+ }
1048
+ setLoading(true);
1049
+ const { enabled: _enabled, pollMs, ...request } = optionsRef.current;
1050
+ const poller = createTrailPoller(
1051
+ (sinceMs) => client.tracks.trail({ ...request, trackId, sinceMs }),
1052
+ {
1053
+ // A fixed period in the past does not grow: ask once.
1054
+ pollMs: request.toMs !== void 0 ? 0 : pollMs,
1055
+ onUpdate: (t) => {
1056
+ setTrail(t);
1057
+ setLoading(false);
1058
+ setError(void 0);
1059
+ },
1060
+ onError: (e) => {
1061
+ setError(e);
1062
+ setLoading(false);
1063
+ }
1064
+ }
1065
+ );
1066
+ return () => poller.stop();
1067
+ }, [client, enabled, key]);
1068
+ return { trail, loading, error };
1069
+ }
1070
+
824
1071
  // src/react/hooks/use-track-motion.ts
825
- import { useEffect, useMemo as useMemo2, useRef } from "react";
1072
+ import { useEffect as useEffect2, useMemo as useMemo2, useRef as useRef2 } from "react";
826
1073
 
827
1074
  // src/shared/motion/index.ts
828
1075
  var KT_TO_MS = 0.514444;
@@ -1131,10 +1378,10 @@ function createTrackMotion(options = {}) {
1131
1378
  // src/react/hooks/use-track-motion.ts
1132
1379
  function useTrackMotion(options) {
1133
1380
  const { onFrame, ...motionOptions } = options;
1134
- const onFrameRef = useRef(onFrame);
1381
+ const onFrameRef = useRef2(onFrame);
1135
1382
  onFrameRef.current = onFrame;
1136
1383
  const motion = useMemo2(() => createTrackMotion(motionOptions), []);
1137
- useEffect(() => {
1384
+ useEffect2(() => {
1138
1385
  const stop = motion.start((frame) => onFrameRef.current(frame));
1139
1386
  return () => {
1140
1387
  stop();
@@ -1145,7 +1392,7 @@ function useTrackMotion(options) {
1145
1392
  }
1146
1393
 
1147
1394
  // src/react/hooks/use-track-history.ts
1148
- import { useCallback, useEffect as useEffect2, useRef as useRef2 } from "react";
1395
+ import { useCallback, useEffect as useEffect3, useRef as useRef3 } from "react";
1149
1396
  var DR_MIN_SPEED_KT = 10;
1150
1397
  var MAX_SPEED_KT = 1200;
1151
1398
  var GAP_RESET_MS = 3e4;
@@ -1165,22 +1412,22 @@ function useTrackHistory(options) {
1165
1412
  maxJumpDeg = 5,
1166
1413
  onFrame
1167
1414
  } = options;
1168
- const currentPositions = useRef2(/* @__PURE__ */ new Map());
1169
- const lastActualPos = useRef2(/* @__PURE__ */ new Map());
1170
- const history = useRef2(/* @__PURE__ */ new Map());
1171
- const lastSeen = useRef2(/* @__PURE__ */ new Map());
1172
- const lastUpdPerf = useRef2(/* @__PURE__ */ new Map());
1173
- const interp = useRef2(/* @__PURE__ */ new Map());
1174
- const velocity = useRef2(/* @__PURE__ */ new Map());
1175
- const drState = useRef2(/* @__PURE__ */ new Map());
1176
- const rafRef = useRef2(null);
1177
- const onFrameRef = useRef2(onFrame);
1415
+ const currentPositions = useRef3(/* @__PURE__ */ new Map());
1416
+ const lastActualPos = useRef3(/* @__PURE__ */ new Map());
1417
+ const history = useRef3(/* @__PURE__ */ new Map());
1418
+ const lastSeen = useRef3(/* @__PURE__ */ new Map());
1419
+ const lastUpdPerf = useRef3(/* @__PURE__ */ new Map());
1420
+ const interp = useRef3(/* @__PURE__ */ new Map());
1421
+ const velocity = useRef3(/* @__PURE__ */ new Map());
1422
+ const drState = useRef3(/* @__PURE__ */ new Map());
1423
+ const rafRef = useRef3(null);
1424
+ const onFrameRef = useRef3(onFrame);
1178
1425
  onFrameRef.current = onFrame;
1179
- const maxHistoryPointsRef = useRef2(maxHistoryPoints);
1426
+ const maxHistoryPointsRef = useRef3(maxHistoryPoints);
1180
1427
  maxHistoryPointsRef.current = maxHistoryPoints;
1181
- const minDeltaDegRef = useRef2(minDeltaDeg);
1428
+ const minDeltaDegRef = useRef3(minDeltaDeg);
1182
1429
  minDeltaDegRef.current = minDeltaDeg;
1183
- const maxJumpDegRef = useRef2(maxJumpDeg);
1430
+ const maxJumpDegRef = useRef3(maxJumpDeg);
1184
1431
  maxJumpDegRef.current = maxJumpDeg;
1185
1432
  function emitFrame() {
1186
1433
  onFrameRef.current({
@@ -1343,7 +1590,7 @@ function useTrackHistory(options) {
1343
1590
  drState.current.delete(trackId);
1344
1591
  emitFrame();
1345
1592
  }, []);
1346
- useEffect2(() => {
1593
+ useEffect3(() => {
1347
1594
  return () => {
1348
1595
  if (rafRef.current !== null) {
1349
1596
  cancelAnimationFrame(rafRef.current);
@@ -1358,7 +1605,7 @@ function useTrackHistory(options) {
1358
1605
  }
1359
1606
 
1360
1607
  // src/react/hooks/index.ts
1361
- import { useCallback as useCallback2, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef3 } from "react";
1608
+ import { useCallback as useCallback2, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4 } from "react";
1362
1609
  import { useQuery } from "@tanstack/react-query";
1363
1610
  import { useShallow } from "zustand/react/shallow";
1364
1611
 
@@ -1458,7 +1705,7 @@ function useAeroTracks(opts = {}) {
1458
1705
  const isDegraded = useTrackStore(selectIsDegraded);
1459
1706
  const lastUpdateAt = useTrackStore(selectLastUpdateAt);
1460
1707
  const error = useTrackStore(selectError);
1461
- const unsubRef = useRef3(null);
1708
+ const unsubRef = useRef4(null);
1462
1709
  const { isLoading } = useQuery({
1463
1710
  queryKey: ["aero-snapshot", opts.bbox?.join(","), opts.source, opts.minConfidence],
1464
1711
  enabled: opts.enabled !== false,
@@ -1472,7 +1719,7 @@ function useAeroTracks(opts = {}) {
1472
1719
  return result;
1473
1720
  }
1474
1721
  });
1475
- useEffect3(() => {
1722
+ useEffect4(() => {
1476
1723
  if (opts.enabled === false) return;
1477
1724
  const actions = useTrackStore.getState();
1478
1725
  unsubRef.current = client.tracks.subscribe(
@@ -1609,6 +1856,7 @@ export {
1609
1856
  useTrackHistory,
1610
1857
  useTrackMotion,
1611
1858
  useTrackStore,
1859
+ useTrackTrail,
1612
1860
  useTracks,
1613
1861
  useWebhooks
1614
1862
  };