@sjvair/monitor-map 4.2.0 → 4.3.0

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.
Files changed (29) hide show
  1. package/README.md +14 -0
  2. package/dist/lib/abort.d.ts +16 -0
  3. package/dist/lib/abort.js +51 -0
  4. package/dist/lib/collocation-sites/collocations.svelte.d.ts +3 -2
  5. package/dist/lib/collocation-sites/collocations.svelte.js +8 -4
  6. package/dist/lib/data-chart/DataChart.svelte +16 -1
  7. package/dist/lib/data-chart/requests.d.ts +2 -1
  8. package/dist/lib/data-chart/requests.js +5 -2
  9. package/dist/lib/ev-stations/ev-stations.svelte.d.ts +3 -2
  10. package/dist/lib/ev-stations/ev-stations.svelte.js +8 -6
  11. package/dist/lib/hms/hms.svelte.d.ts +4 -3
  12. package/dist/lib/hms/hms.svelte.js +16 -6
  13. package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  14. package/dist/lib/monitors/monitor-details.svelte.js +11 -3
  15. package/dist/lib/monitors/monitors.svelte.d.ts +14 -1
  16. package/dist/lib/monitors/monitors.svelte.js +119 -27
  17. package/dist/lib/monitors/subscriptions.d.ts +3 -0
  18. package/dist/lib/monitors/subscriptions.js +9 -0
  19. package/package.json +2 -2
  20. package/src/lib/abort.ts +59 -0
  21. package/src/lib/collocation-sites/collocations.svelte.ts +7 -4
  22. package/src/lib/data-chart/DataChart.svelte +16 -1
  23. package/src/lib/data-chart/requests.ts +14 -7
  24. package/src/lib/ev-stations/ev-stations.svelte.ts +11 -6
  25. package/src/lib/hms/hms.svelte.ts +12 -6
  26. package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  27. package/src/lib/monitors/monitor-details.svelte.ts +12 -3
  28. package/src/lib/monitors/monitors.svelte.ts +142 -30
  29. package/src/lib/monitors/subscriptions.ts +17 -0
package/README.md CHANGED
@@ -106,6 +106,20 @@ parameter: the panel shows the map's pollutant when the monitor reports it and o
106
106
  one it does report, saying so. An inactive monitor gets a panel with its past data and a
107
107
  note that it has no current readings; an unknown id gets "Monitor not found."
108
108
 
109
+ ### Cancelling requests
110
+
111
+ `monitorsManager.init()` and `update()`, `collocationSitesManager.init()` and `update()`,
112
+ `hmsManager.init()`, `loadSmoke()` and `loadFire()`, and `evStationsManager.loadLvl2Stations()`
113
+ and `loadLvl3Stations()` take `{ signal }` (an `AbortSignal`). Aborting rejects that call with
114
+ the signal's reason (a call that is a no-op, such as `update()` before init or `init()` once
115
+ initialized, resolves as before). The panel, chart and subscription status cancel their own superseded
116
+ requests when the selection changes; that work resolves quietly. A `monitorsManager.init()`
117
+ run is shared: aborting your signal stops only your wait, and the run itself is cancelled only
118
+ when every caller waiting on it has aborted.
119
+
120
+ A host that depends on `@sjvair/sdk` directly must use `^4.7.0`, so npm installs one copy. A
121
+ nested copy ignores the host's `setOrigin`.
122
+
109
123
  ### Embedding `MapShell` directly, with a reduced integration set
110
124
 
111
125
  `MonitorMapLayout` is an opinionated wrapper around the lower-level `MapShell`
@@ -0,0 +1,16 @@
1
+ export type SignalOptions = {
2
+ signal?: AbortSignal;
3
+ };
4
+ /**
5
+ * A signal that aborts as soon as any input does, with that input's reason. Already aborted if
6
+ * any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
7
+ */
8
+ export declare function anySignal(...signals: Array<AbortSignal | undefined>): {
9
+ signal: AbortSignal;
10
+ dispose(): void;
11
+ };
12
+ /**
13
+ * Settles like `promise`, unless `signal` aborts first, in which case it rejects with
14
+ * `signal.reason`. The inner promise's later outcome is swallowed.
15
+ */
16
+ export declare function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A signal that aborts as soon as any input does, with that input's reason. Already aborted if
3
+ * any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
4
+ */
5
+ export function anySignal(...signals) {
6
+ const controller = new AbortController();
7
+ const cleanups = [];
8
+ const dispose = () => {
9
+ for (const cleanup of cleanups.splice(0))
10
+ cleanup();
11
+ };
12
+ for (const input of signals) {
13
+ if (!input)
14
+ continue;
15
+ if (input.aborted) {
16
+ controller.abort(input.reason);
17
+ dispose();
18
+ break;
19
+ }
20
+ const onAbort = () => {
21
+ controller.abort(input.reason);
22
+ dispose();
23
+ };
24
+ input.addEventListener("abort", onAbort, { once: true });
25
+ cleanups.push(() => input.removeEventListener("abort", onAbort));
26
+ }
27
+ return { signal: controller.signal, dispose };
28
+ }
29
+ /**
30
+ * Settles like `promise`, unless `signal` aborts first, in which case it rejects with
31
+ * `signal.reason`. The inner promise's later outcome is swallowed.
32
+ */
33
+ export function untilAborted(promise, signal) {
34
+ if (!signal)
35
+ return promise;
36
+ if (signal.aborted) {
37
+ promise.catch(() => { });
38
+ return Promise.reject(signal.reason);
39
+ }
40
+ return new Promise((resolve, reject) => {
41
+ const onAbort = () => reject(signal.reason);
42
+ signal.addEventListener("abort", onAbort, { once: true });
43
+ promise.then((value) => {
44
+ signal.removeEventListener("abort", onAbort);
45
+ resolve(value);
46
+ }, (error) => {
47
+ signal.removeEventListener("abort", onAbort);
48
+ reject(error);
49
+ });
50
+ });
51
+ }
@@ -1,9 +1,10 @@
1
1
  import { type CollocationSite } from "@sjvair/sdk";
2
+ import type { SignalOptions } from "../abort";
2
3
  declare class CollocationSitesManager {
3
4
  initialized: boolean;
4
5
  collocationSites: Array<CollocationSite> | null;
5
- init(): Promise<void>;
6
- update(): Promise<void>;
6
+ init({ signal }?: SignalOptions): Promise<void>;
7
+ update({ signal }?: SignalOptions): Promise<void>;
7
8
  }
8
9
  export declare const collocationSitesManager: CollocationSitesManager;
9
10
  export type { CollocationSitesManager };
@@ -2,16 +2,20 @@ import { getCollocationSites } from "@sjvair/sdk";
2
2
  class CollocationSitesManager {
3
3
  initialized = $state(false);
4
4
  collocationSites = $state(null);
5
- async init() {
5
+ async init({ signal } = {}) {
6
6
  if (this.initialized)
7
7
  return;
8
- this.collocationSites = await getCollocationSites();
8
+ if (signal?.aborted)
9
+ throw signal.reason;
10
+ this.collocationSites = await getCollocationSites({ signal });
9
11
  this.initialized = true;
10
12
  }
11
- async update() {
13
+ async update({ signal } = {}) {
12
14
  if (!this.initialized)
13
15
  return;
14
- this.collocationSites = await getCollocationSites();
16
+ if (signal?.aborted)
17
+ throw signal.reason;
18
+ this.collocationSites = await getCollocationSites({ signal });
15
19
  }
16
20
  }
17
21
  export const collocationSitesManager = new CollocationSitesManager();
@@ -143,12 +143,27 @@
143
143
  };
144
144
 
145
145
  let chartRequest = 0;
146
+ let chartController: AbortController | undefined;
147
+
148
+ // A closed panel cancels its in-flight fetch.
149
+ $effect(() => () => chartController?.abort());
146
150
 
147
151
  // `silent` keeps the current chart on screen while a timed refresh loads.
148
152
  async function loadChartData({ silent = false } = {}) {
149
153
  const request = ++chartRequest;
154
+ chartController?.abort();
155
+ const controller = (chartController = new AbortController());
150
156
  if (!silent) chartManager.loading = true;
151
- const data = await fetchChartData(monitor, chartManager.dateRange, pollutant);
157
+ let data: Awaited<ReturnType<typeof fetchChartData>>;
158
+ try {
159
+ data = await fetchChartData(monitor, chartManager.dateRange, pollutant, {
160
+ signal: controller.signal
161
+ });
162
+ } catch (err) {
163
+ // Superseded or closed: the newer call owns `loading` and the chart, so leave both.
164
+ if (controller.signal.aborted) return;
165
+ throw err;
166
+ }
152
167
  // A newer request (another range, monitor, pollutant or refresh) owns the chart now.
153
168
  if (request !== chartRequest) return;
154
169
  // A failed request resolves to [] (an empty range is [[], []]); a background refresh
@@ -1,6 +1,7 @@
1
1
  import { type MonitorDetails } from "@sjvair/sdk";
2
2
  import type { PanelPollutant } from "../monitors/panel-pollutant";
3
3
  import type { DateRange } from "./DateRange";
4
+ import type { SignalOptions } from "../abort";
4
5
  /** The monitor fields the chart reads. */
5
6
  export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
6
- export declare function fetchChartData(m: ChartMonitor, d: DateRange, entryType: PanelPollutant): Promise<uPlot.AlignedData>;
7
+ export declare function fetchChartData(m: ChartMonitor, d: DateRange, entryType: PanelPollutant, options?: SignalOptions): Promise<uPlot.AlignedData>;
@@ -1,12 +1,12 @@
1
1
  import { getMonitorEntries } from "@sjvair/sdk";
2
2
  import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
3
- export async function fetchChartData(m, d, entryType) {
3
+ export async function fetchChartData(m, d, entryType, options = {}) {
4
4
  return await getMonitorEntries({
5
5
  entryType,
6
6
  monitorId: m.id,
7
7
  timestampGte: d.start,
8
8
  timestampLte: d.end
9
- })
9
+ }, { signal: options.signal })
10
10
  .then((entries) => {
11
11
  const xAxisData = [];
12
12
  const updateDuration = m.data_source.name === "PurpleAir" ? 2 : 60;
@@ -26,6 +26,9 @@ export async function fetchChartData(m, d, entryType) {
26
26
  return [xAxisData.map((d) => getUnixTime(d)), yAxisRecord];
27
27
  })
28
28
  .catch((err) => {
29
+ // A cancelled request is not a failure: let the caller see the reason, log nothing.
30
+ if (options.signal?.aborted)
31
+ throw options.signal.reason;
29
32
  console.error("Failed to load chart data: ", err);
30
33
  return [];
31
34
  });
@@ -1,9 +1,10 @@
1
+ import type { SignalOptions } from "../abort";
1
2
  import type { EvStation } from "./types";
2
3
  declare class EvStationsManager {
3
4
  lvl2Stations: Array<EvStation> | undefined;
4
5
  lvl3Stations: Array<EvStation> | undefined;
5
- loadLvl3Stations(): Promise<void>;
6
- loadLvl2Stations(): Promise<void>;
6
+ loadLvl3Stations({ signal }?: SignalOptions): Promise<void>;
7
+ loadLvl2Stations({ signal }?: SignalOptions): Promise<void>;
7
8
  }
8
9
  export declare const evStationsManager: EvStationsManager;
9
10
  export type { EvStationsManager };
@@ -3,17 +3,19 @@ import * as ZipCodes from "./zip-codes";
3
3
  class EvStationsManager {
4
4
  lvl2Stations = $state();
5
5
  lvl3Stations = $state();
6
- async loadLvl3Stations() {
7
- this.lvl3Stations = await fetchEvStations("dc_fast");
6
+ async loadLvl3Stations({ signal } = {}) {
7
+ this.lvl3Stations = await fetchEvStations("dc_fast", signal);
8
8
  }
9
- async loadLvl2Stations() {
10
- this.lvl2Stations = await fetchEvStations("2");
9
+ async loadLvl2Stations({ signal } = {}) {
10
+ this.lvl2Stations = await fetchEvStations("2", signal);
11
11
  }
12
12
  }
13
13
  const zipCodes = Object.values(ZipCodes)
14
14
  .reduce((prev, curr) => prev.concat(curr))
15
15
  .join(",");
16
- async function fetchEvStations(chargingLevel) {
16
+ async function fetchEvStations(chargingLevel, signal) {
17
+ if (signal?.aborted)
18
+ throw signal.reason;
17
19
  const url = new URL(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json`);
18
20
  url.searchParams.set("fuel_type", "ELEC");
19
21
  url.searchParams.set("state", "CA");
@@ -22,7 +24,7 @@ async function fetchEvStations(chargingLevel) {
22
24
  url.searchParams.set("ev_charging_level", chargingLevel);
23
25
  url.searchParams.set("zip", zipCodes);
24
26
  url.searchParams.set("api_key", import.meta.env.VITE_NREL_KEY);
25
- return await fetch(url)
27
+ return await fetch(url, { signal })
26
28
  .then(async (res) => await res.json())
27
29
  .then((data) => {
28
30
  const fuelStations = data.fuel_stations;
@@ -1,4 +1,5 @@
1
1
  import { type HMSFireGeoJSON, type HMSSmokeGeoJSON } from "@sjvair/sdk/hms";
2
+ import type { SignalOptions } from "../abort";
2
3
  export interface HMSFireGroup {
3
4
  id: string;
4
5
  coordinates: [number, number];
@@ -11,9 +12,9 @@ declare class HmsManager {
11
12
  frp: number;
12
13
  }> | undefined;
13
14
  fireGroups: Array<HMSFireGroup> | undefined;
14
- init(): Promise<void>;
15
- loadSmoke(): Promise<void>;
16
- loadFire(): Promise<void>;
15
+ init({ signal }?: SignalOptions): Promise<void>;
16
+ loadSmoke({ signal }?: SignalOptions): Promise<void>;
17
+ loadFire({ signal }?: SignalOptions): Promise<void>;
17
18
  }
18
19
  export declare const hmsManager: HmsManager;
19
20
  export type { HmsManager as HMSManager };
@@ -7,21 +7,31 @@ class HmsManager {
7
7
  smoke = $state();
8
8
  fire = $state();
9
9
  fireGroups = $state();
10
- async init() {
11
- await Promise.allSettled([this.loadSmoke(), this.loadFire()]);
10
+ async init({ signal } = {}) {
11
+ if (signal?.aborted)
12
+ throw signal.reason;
13
+ await Promise.allSettled([this.loadSmoke({ signal }), this.loadFire({ signal })]);
14
+ if (signal?.aborted)
15
+ throw signal.reason;
12
16
  }
13
- async loadSmoke() {
14
- this.smoke = await getHMSSmoke();
17
+ async loadSmoke({ signal } = {}) {
18
+ if (signal?.aborted)
19
+ throw signal.reason;
20
+ this.smoke = await getHMSSmoke({ signal });
15
21
  }
16
- async loadFire() {
22
+ async loadFire({ signal } = {}) {
17
23
  try {
18
- const raw = await getHMSFire();
24
+ if (signal?.aborted)
25
+ throw signal.reason;
26
+ const raw = await getHMSFire({ signal });
19
27
  const filtered = raw.filter((d) => d.frp !== null);
20
28
  const deduped = deduplicateByCoordinates(filtered);
21
29
  this.fire = deduped;
22
30
  this.fireGroups = computeFireGroups(deduped);
23
31
  }
24
32
  catch (err) {
33
+ if (signal?.aborted)
34
+ throw err;
25
35
  console.error("HMS fire load failed:", err);
26
36
  }
27
37
  }
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { BellCheckIcon, BellOffIcon, ChevronDownIcon } from "@lucide/svelte";
3
- import { getSubscriptions, subscribe, unsubscribe } from "@sjvair/sdk/account";
3
+ import { subscribe, unsubscribe } from "@sjvair/sdk/account";
4
4
  import { monitorsManager } from "../..";
5
+ import { getApiToken, loadSubscriptionLevel } from "../subscriptions";
5
6
 
6
7
  interface MonitorSubscriptionManagerProps {
7
8
  monitorId: string;
@@ -35,24 +36,24 @@
35
36
  });
36
37
 
37
38
  $effect(() => {
38
- void monitorId;
39
+ const id = monitorId;
39
40
  open = false;
40
41
  subscribedLevel = null;
41
- loadSubscription();
42
- });
43
-
44
- async function loadSubscription() {
45
- const id = monitorId;
42
+ const controller = new AbortController();
43
+ const { signal } = controller;
46
44
  loading = true;
47
- try {
48
- const subscriptions = await getSubscriptions(getApiToken());
49
- if (id !== monitorId) return;
50
- const match = subscriptions.find((s) => s.monitor === id);
51
- subscribedLevel = match?.level ?? null;
52
- } finally {
53
- if (id === monitorId) loading = false;
54
- }
55
- }
45
+ loadSubscriptionLevel(id, { signal })
46
+ .then((level) => {
47
+ if (!signal.aborted) subscribedLevel = level;
48
+ })
49
+ .catch((error) => {
50
+ if (!signal.aborted) throw error;
51
+ })
52
+ .finally(() => {
53
+ if (!signal.aborted) loading = false;
54
+ });
55
+ return () => controller.abort();
56
+ });
56
57
 
57
58
  async function handleLevelClick(level: string | null) {
58
59
  if (saving || !level) return;
@@ -75,12 +76,6 @@
75
76
  }
76
77
  open = false;
77
78
  }
78
-
79
- function getApiToken(): string {
80
- return (
81
- (globalThis as unknown as Record<string, Record<string, string>>)["USER"]?.api_token ?? ""
82
- );
83
- }
84
79
  </script>
85
80
 
86
81
  <div class="relative select-none">
@@ -9,13 +9,15 @@ export class MonitorDetailsState {
9
9
  #id = null;
10
10
  // Bumped per request; a response for anything but the latest request is dropped.
11
11
  #request = 0;
12
+ #controller = null;
12
13
  async load(id) {
13
14
  this.#id = id;
14
15
  const request = ++this.#request;
16
+ const { signal } = this.#restart();
15
17
  this.monitor = null;
16
18
  this.status = "loading";
17
19
  try {
18
- const monitor = await getMonitorDetails(id);
20
+ const monitor = await getMonitorDetails(id, { signal });
19
21
  if (request !== this.#request)
20
22
  return;
21
23
  this.monitor = monitor;
@@ -37,17 +39,23 @@ export class MonitorDetailsState {
37
39
  if (this.status !== "ready" || this.#id === null)
38
40
  return;
39
41
  const request = ++this.#request;
42
+ const { signal } = this.#restart();
40
43
  try {
41
- const monitor = await getMonitorDetails(this.#id);
44
+ const monitor = await getMonitorDetails(this.#id, { signal });
42
45
  if (request !== this.#request)
43
46
  return;
44
47
  this.monitor = monitor;
45
48
  }
46
49
  catch (error) {
47
- if (request === this.#request)
50
+ if (!signal.aborted && request === this.#request)
48
51
  console.error(error);
49
52
  }
50
53
  }
54
+ /** Aborts whatever request is in flight and starts a new controller for the next one. */
55
+ #restart() {
56
+ this.#controller?.abort();
57
+ return (this.#controller = new AbortController());
58
+ }
51
59
  retry() {
52
60
  return this.#id === null ? Promise.resolve() : this.load(this.#id);
53
61
  }
@@ -1,6 +1,7 @@
1
1
  import { type MonitorData, type MonitorLatestType, type MonitorsMeta, type SJVAirEntryLevel } from "@sjvair/sdk";
2
2
  import { XMap } from "@tstk/builtin-extensions";
3
3
  import { Interval } from "@tstk/utils";
4
+ import { type SignalOptions } from "../abort";
4
5
  import type { MonitorsDataSource } from "./types";
5
6
  export interface MonitorsInitOptions {
6
7
  /**
@@ -8,6 +9,13 @@ export interface MonitorsInitOptions {
8
9
  * load the data without showing a map (`resumeAutoUpdate()` starts polling later).
9
10
  */
10
11
  autoUpdate?: boolean;
12
+ /**
13
+ * Stops waiting: the returned promise rejects with the signal's reason. The shared run keeps
14
+ * going for the other calls waiting on it, and is cancelled only when every call waiting on it
15
+ * passed a signal and all of those aborted. An aborted call's pollutant and `autoUpdate` stay
16
+ * recorded. Ignored once initialized.
17
+ */
18
+ signal?: AbortSignal;
11
19
  }
12
20
  declare class MonitorsManager implements MonitorsDataSource {
13
21
  #private;
@@ -33,7 +41,12 @@ declare class MonitorsManager implements MonitorsDataSource {
33
41
  * be stale. A no-op before init or while polling.
34
42
  */
35
43
  resumeAutoUpdate(): void;
36
- update(): Promise<void>;
44
+ /**
45
+ * Refreshes the list and the latest readings. Starting an update aborts the previous
46
+ * in-flight one, which then resolves without touching state. Pass `signal` to cancel from the
47
+ * outside: that rejects with the signal's reason.
48
+ */
49
+ update(options?: SignalOptions): Promise<void>;
37
50
  }
38
51
  export declare const monitorsManager: MonitorsManager;
39
52
  export type { MonitorsManager };
@@ -2,6 +2,7 @@ import { getMonitors, getMonitorsList, getMonitorsMeta } from "@sjvair/sdk";
2
2
  import { XMap } from "@tstk/builtin-extensions";
3
3
  import { Interval } from "@tstk/utils";
4
4
  import { untrack } from "svelte";
5
+ import { anySignal, untilAborted } from "../abort";
5
6
  class MonitorsManager {
6
7
  autoUpdate = new Interval(async () => await this.update(), 2 * 60 * 1000);
7
8
  initialized = $state(false);
@@ -10,9 +11,10 @@ class MonitorsManager {
10
11
  meta = $state(null);
11
12
  pollutant = $state(null);
12
13
  levels = $derived(this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null);
13
- #initPromise = null;
14
+ #initRun = null;
14
15
  #requestedPollutant = null;
15
16
  #requestedAutoUpdate = false;
17
+ #updateController = null;
16
18
  constructor() {
17
19
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
18
20
  // comes from (Pollutant menu, URL sync, a host's own UI). Clearing first blanks the markers
@@ -44,23 +46,67 @@ class MonitorsManager {
44
46
  init(urlPollutant, options = {}) {
45
47
  if (this.initialized)
46
48
  return Promise.resolve();
49
+ const { signal } = options;
50
+ // Nothing is recorded or started for a call that is already cancelled.
51
+ if (signal?.aborted)
52
+ return Promise.reject(signal.reason);
47
53
  if (options.autoUpdate !== false)
48
54
  this.#requestedAutoUpdate = true;
49
55
  if (urlPollutant === "pm25" || urlPollutant === "o3") {
50
56
  this.#requestedPollutant = urlPollutant;
51
57
  }
52
- this.#initPromise ??= this.#runInit().finally(() => {
53
- this.#initPromise = null;
54
- this.#requestedPollutant = null;
55
- this.#requestedAutoUpdate = false;
56
- });
57
- return this.#initPromise;
58
+ const run = (this.#initRun ??= this.#startInitRun());
59
+ if (!signal) {
60
+ run.unsignalled = true;
61
+ return run.promise;
62
+ }
63
+ run.signalled++;
64
+ const onAbort = () => {
65
+ run.aborted++;
66
+ if (!run.unsignalled && run.aborted === run.signalled)
67
+ this.#abortInitRun(run);
68
+ };
69
+ signal.addEventListener("abort", onAbort, { once: true });
70
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
71
+ run.promise.then(cleanup, cleanup);
72
+ return untilAborted(run.promise, signal);
73
+ }
74
+ #startInitRun() {
75
+ const controller = new AbortController();
76
+ const run = {
77
+ promise: this.#runInit(controller.signal).finally(() => {
78
+ if (this.#initRun === run)
79
+ this.#clearInitRun();
80
+ }),
81
+ controller,
82
+ signalled: 0,
83
+ aborted: 0,
84
+ unsignalled: false
85
+ };
86
+ return run;
87
+ }
88
+ /**
89
+ * Cancels a run nobody is waiting on. It is detached at once, so a call arriving before its
90
+ * rejection settles starts a fresh run instead of joining the cancelled one.
91
+ */
92
+ #abortInitRun(run) {
93
+ if (this.#initRun !== run)
94
+ return;
95
+ this.#clearInitRun();
96
+ run.controller.abort();
97
+ }
98
+ #clearInitRun() {
99
+ this.#initRun = null;
100
+ this.#requestedPollutant = null;
101
+ this.#requestedAutoUpdate = false;
58
102
  }
59
- async #runInit() {
103
+ async #runInit(signal) {
60
104
  // The pollutant the latest readings were requested for.
61
105
  let fetchedPollutant = this.#requestedPollutant;
62
- const metaRequest = getMonitorsMeta();
63
- const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
106
+ const metaRequest = getMonitorsMeta({ signal });
107
+ const listAndMeta = Promise.all([getMonitorsList({ signal }), metaRequest]).then(([list, meta]) => {
108
+ if (signal.aborted)
109
+ throw signal.reason;
64
110
  this.list = list;
65
111
  this.meta = meta;
66
112
  return meta;
@@ -68,11 +114,11 @@ class MonitorsManager {
68
114
  // The latest readings only need the pollutant, so they never wait for the list. An explicit
69
115
  // pollutant may still arrive before meta does, in which case the default is never fetched.
70
116
  const latestRequest = fetchedPollutant
71
- ? getMonitorsLatestMap(fetchedPollutant)
117
+ ? getMonitorsLatestMap(fetchedPollutant, signal)
72
118
  : metaRequest.then((meta) => {
73
119
  const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
74
120
  fetchedPollutant = pollutant;
75
- return getMonitorsLatestMap(pollutant);
121
+ return getMonitorsLatestMap(pollutant, signal);
76
122
  });
77
123
  // One Promise.all so a failure in either never leaves the other unhandled.
78
124
  const [meta, firstLatest] = await Promise.all([listAndMeta, latestRequest]);
@@ -80,10 +126,13 @@ class MonitorsManager {
80
126
  // An explicit pollutant may have arrived while the requests above were in flight.
81
127
  let wanted = this.#requestedPollutant ?? meta.default_pollutant;
82
128
  while (wanted !== fetchedPollutant) {
83
- latest = await getMonitorsLatestMap(wanted);
129
+ latest = await getMonitorsLatestMap(wanted, signal);
84
130
  fetchedPollutant = wanted;
85
131
  wanted = this.#requestedPollutant ?? meta.default_pollutant;
86
132
  }
133
+ // Cancelled while a request that ignores the signal was settling: leave state to the next run.
134
+ if (signal.aborted)
135
+ throw signal.reason;
87
136
  this.pollutant = fetchedPollutant;
88
137
  this.latest = latest;
89
138
  if (this.#requestedAutoUpdate)
@@ -101,24 +150,67 @@ class MonitorsManager {
101
150
  this.update().catch(console.error);
102
151
  this.autoUpdate.start();
103
152
  }
104
- async update() {
153
+ /**
154
+ * Refreshes the list and the latest readings. Starting an update aborts the previous
155
+ * in-flight one, which then resolves without touching state. Pass `signal` to cancel from the
156
+ * outside: that rejects with the signal's reason.
157
+ */
158
+ async update(options = {}) {
105
159
  if (!this.initialized)
106
160
  return;
107
- const pollutant = this.pollutant;
108
- const [list, latest] = await Promise.all([
109
- getMonitorsList(),
110
- getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
111
- ]);
112
- // The pollutant changed while this was in flight: these readings belong to the previous
113
- // one, and the change has already started its own update.
114
- if (this.pollutant !== pollutant)
115
- return;
116
- this.list = list;
117
- this.latest = latest;
161
+ // Nothing has started yet, so an in-flight update is left alone.
162
+ if (options.signal?.aborted)
163
+ throw options.signal.reason;
164
+ const supersededInFlight = this.#updateController !== null;
165
+ this.#updateController?.abort();
166
+ const controller = new AbortController();
167
+ this.#updateController = controller;
168
+ const combined = anySignal(controller.signal, options.signal);
169
+ // The caller cancelled this update. If it is still the latest and it cut off an update that
170
+ // was in flight, that update's data is lost too, so refresh again without a signal.
171
+ const callerAborted = (signal) => {
172
+ if (supersededInFlight && this.#updateController === controller) {
173
+ this.update().catch(console.error);
174
+ }
175
+ return signal.reason;
176
+ };
177
+ // Set once callerAborted has run, so the catch below does not run it a second time.
178
+ let abortedError;
179
+ try {
180
+ const pollutant = this.pollutant;
181
+ const [list, latest] = await Promise.all([
182
+ getMonitorsList({ signal: combined.signal }),
183
+ getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25", combined.signal)
184
+ ]);
185
+ // Superseded or cancelled while the requests were settling.
186
+ if (options.signal?.aborted)
187
+ throw (abortedError = callerAborted(options.signal));
188
+ if (controller.signal.aborted)
189
+ return;
190
+ // The pollutant changed while this was in flight: these readings belong to the previous
191
+ // one, and the change has already started its own update.
192
+ if (this.pollutant !== pollutant)
193
+ return;
194
+ this.list = list;
195
+ this.latest = latest;
196
+ }
197
+ catch (error) {
198
+ // The caller's own abort rejects with its reason. A superseded update resolves quietly.
199
+ if (options.signal?.aborted)
200
+ throw error === abortedError ? error : callerAborted(options.signal);
201
+ if (controller.signal.aborted)
202
+ return;
203
+ throw error;
204
+ }
205
+ finally {
206
+ combined.dispose();
207
+ if (this.#updateController === controller)
208
+ this.#updateController = null;
209
+ }
118
210
  }
119
211
  }
120
- async function getMonitorsLatestMap(pollutant) {
121
- const monitors = await getMonitors(pollutant);
212
+ async function getMonitorsLatestMap(pollutant, signal) {
213
+ const monitors = await getMonitors(pollutant, undefined, { signal });
122
214
  const latest = new XMap();
123
215
  for (const monitor of monitors) {
124
216
  latest.set(monitor.id, monitor);
@@ -0,0 +1,3 @@
1
+ import type { SignalOptions } from "../abort";
2
+ export declare function getApiToken(): string | undefined;
3
+ export declare function loadSubscriptionLevel(monitorId: string, options?: SignalOptions): Promise<string | null>;