use-weather-forecast 0.1.183 → 0.1.185

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
@@ -24,7 +24,7 @@
24
24
 
25
25
  [![Coverage](https://codecov.io/gh/OpenSourceAGI/qwksearch-research-agent/graph/badge.svg?component=package-react-weather-forecast)](https://codecov.io/gh/OpenSourceAGI/qwksearch-research-agent)
26
26
 
27
- React weather forecast component using Open-Meteo for current, hourly, and daily forecasts and Cloudflare/ipapi.co for IP geolocation, with a fallback chain behind both.
27
+ React weather forecast component using Open-Meteo for current, hourly, and daily forecasts and Cloudflare/ipwho.is for IP geolocation, with a fallback chain behind both.
28
28
 
29
29
  ## Features
30
30
 
@@ -33,6 +33,8 @@ React weather forecast component using Open-Meteo for current, hourly, and daily
33
33
  - Next days forecast.
34
34
  - Four weather upstreams tried in order, four IP geolocation upstreams behind that.
35
35
  - Every request validated before it is sent, retried with backoff when it can recover.
36
+ - A resolved location cached for 12 hours, so a refreshing widget never spends a
37
+ lookup it has already made -- the difference between working all day and a `429`.
36
38
  - Stale-cache fallback so a total outage still renders a widget.
37
39
  - Latitude/longitude override.
38
40
  - Split SVG weather icon components.
@@ -110,16 +112,30 @@ exponential backoff from `retryDelay` (400ms, then 800ms, ...). A `429`, a
110
112
  called invalid (`400`, `404`) is not, since replaying it only burns the
111
113
  upstream's rate limit.
112
114
 
115
+ One exception, in the geolocation chain: a provider that answered with a rate
116
+ limit is *not* retried while another provider is still to be tried. A free IP
117
+ lookup's limit is a daily quota, so the retry would be spent asking the one
118
+ operator that has already said no. The last provider in the chain, which has
119
+ nowhere left to move on to, still retries.
120
+
113
121
  ### Fallback APIs
114
122
 
115
123
  | Order | Weather | IP geolocation |
116
124
  | --- | --- | --- |
117
125
  | 1 | `open-meteo` -- `api.open-meteo.com/v1/forecast` | `geoEndpoint` (the bundled worker), when given |
118
- | 2 | `open-meteo-gfs` -- the GFS model endpoint | `ipapi` -- ipapi.co |
119
- | 3 | `met-no` -- met.no, a different operator and model | `ipwho` -- ipwho.is |
126
+ | 2 | `open-meteo-gfs` -- the GFS model endpoint | `ipwho` -- ipwho.is |
127
+ | 3 | `met-no` -- met.no, a different operator and model | `ipapi` -- ipapi.co |
120
128
  | 4 | `wttr` -- wttr.in, no key required | `geojs` -- get.geojs.io |
121
129
  | 5 | | `freeipapi` -- freeipapi.com |
122
130
 
131
+ ipwho.is leads the geolocation chain: it is free, keyless and CORS-enabled, and
132
+ unlike ipapi.co it does not spend the rest of the day answering `RateLimited` as
133
+ a `200` once the quota is gone. `browser` -- the device's own geolocation -- is
134
+ bundled too but deliberately left out of the default chain, since asking for it
135
+ raises the browser's permission prompt; name it first in `geoProviders` in a
136
+ place where asking is expected and the IP lookups stay behind it for everyone
137
+ who declines.
138
+
123
139
  The fallbacks normalize their own condition codes, units and timestamps into
124
140
  the same shape Open-Meteo returns, so a failover is invisible in the rendered
125
141
  widget (wttr.in is 3-hourly rather than hourly). If every provider fails, a
@@ -179,20 +195,29 @@ npm run build
179
195
  This package no longer uses ipinfo.io. Instead:
180
196
 
181
197
  - Pass `geoEndpoint` pointing at a deployed instance of the bundled Cloudflare Worker
182
- (`worker/geo-worker.ts`) for accurate results. The worker reads Cloudflare's built-in
183
- geolocation (`request.cf`) for the visitor's own IP, and falls back to `ipapi.co`
184
- when a `?ip=` query param (or the `ip` prop) is supplied for an arbitrary address.
185
- - If `geoEndpoint` is omitted, the package falls back to calling `ipapi.co` directly
186
- from the browser (`https://ipapi.co/json/`, or `https://ipapi.co/<ip>/json/` when an
187
- `ip` is supplied). Unlike the previous ip-api.com fallback, this works over HTTPS with
188
- no mixed-content issues, though ipapi.co's free tier is rate-limited (1,000
189
- requests/day) — deploy the worker and pass `geoEndpoint` for higher-volume or
190
- production use.
198
+ (`worker/geo-worker.ts`) for accurate results, and for production prefer it over any
199
+ public IP API. The worker reads Cloudflare's built-in geolocation (`request.cf`) for
200
+ the visitor's own IP: no third-party request, no shared quota, no CORS, and nothing
201
+ an ad blocker recognises as a tracker. It falls back to the public lookups only for a
202
+ `?ip=` query param (or the `ip` prop) naming an arbitrary address, or for the rare
203
+ network Cloudflare has no coordinates for. A successful answer is returned
204
+ `Cache-Control: private, max-age=43200`, so a reloaded page does not re-ask.
205
+ - If `geoEndpoint` is omitted, the package calls `ipwho.is` directly from the browser
206
+ (`https://ipwho.is/?rate=1`, or `https://ipwho.is/<ip>?rate=1` when an `ip` is
207
+ supplied). It is free, needs no key, supports CORS and works over HTTPS with no
208
+ mixed-content issues; `rate=1` makes it report what is left of the quota alongside
209
+ the location, which is useful while watching request volume. Its free tier allows
210
+ 1,000 requests/day, **shared by every visitor to your domain** for a browser call --
211
+ deploy the worker and pass `geoEndpoint` for higher-volume or production use.
212
+ - A resolved location is **cached for 12 hours** (see [Caching](#caching)), so the
213
+ quota is spent once per visitor per half-day rather than once per render. Turning
214
+ this off with `cacheLocation={false}` is what re-creates the `429`.
191
215
  - A failed lookup is **repeated** before the chain moves on: `getClientLocation`
192
216
  tries each provider twice by default, waiting 500ms, then walks the rest of the
193
- chain (ipapi.co, ipwho.is, get.geojs.io, freeipapi.com) so a single rate-limited
194
- or cold-start response doesn't take the whole forecast down. Tune it with the
195
- third argument: `getClientLocation(geoEndpoint, ip, { attempts: 5, retryDelay: 250, providers: ['ipwho'] })`.
217
+ chain (ipwho.is, ipapi.co, get.geojs.io, freeipapi.com) so a single cold-start
218
+ response doesn't take the whole forecast down. A rate-limited provider is not
219
+ retried while another one is left to ask. Tune it with the third argument:
220
+ `getClientLocation(geoEndpoint, ip, { attempts: 5, retryDelay: 250, providers: ['ipwho'] })`.
196
221
  - A response **without usable coordinates counts as a failure** rather than being
197
222
  passed on as `NaN`, and `fallbackLocation` covers the case where every provider
198
223
  is down.
@@ -212,7 +237,24 @@ This deploys `worker/geo-worker.ts` via Wrangler. Use the resulting `*.workers.d
212
237
 
213
238
  ## Caching
214
239
 
215
- `getWeatherForecast` caches each response in `localStorage` for 30 minutes, keyed by
240
+ Two things are cached in `localStorage`, on different clocks, because they go
241
+ stale at different rates.
242
+
243
+ **The resolved location, for 12 hours.** The weather at a place changes through
244
+ the day; the place a visitor is looking from does not. Every free IP lookup
245
+ bills per request against a daily quota, and for a browser call that quota is
246
+ shared by everyone on the domain -- so a widget that re-resolves its location on
247
+ each refresh, each tab and each remount exhausts 1,000 requests without needing
248
+ many users, and then answers `429 Too Many Requests` for the rest of the day.
249
+ Caching the location is the single most effective thing against that, and it is
250
+ on by default. `cacheLocation={false}` forces a fresh lookup every time;
251
+ `locationCacheTtl` changes the window; `clearCachedLocations()` empties it.
252
+
253
+ Note that the forecast cache below cannot help here: it is keyed by coordinates,
254
+ so it can only be consulted *after* the location is known.
255
+
256
+ **The forecast itself, for 30 minutes.** `getWeatherForecast` caches each response
257
+ in `localStorage` for 30 minutes, keyed by
216
258
  the request itself (location + units + forecast range + timezone), so a fallback
217
259
  provider's answer is reused exactly like the primary one's. Repeated calls for the same
218
260
  location/options within that window are served from the cache instead of hitting
@@ -225,7 +267,7 @@ or when `localStorage` is unavailable/full.
225
267
  ## Notes
226
268
 
227
269
  - Open-Meteo powers the forecast data, with met.no and wttr.in behind it.
228
- - Cloudflare's `request.cf`, ipapi.co, ipwho.is, get.geojs.io and freeipapi.com
270
+ - Cloudflare's `request.cf`, ipwho.is, ipapi.co, get.geojs.io and freeipapi.com
229
271
  power IP geolocation (see above).
230
272
  - HTTP requests go through [`grab-url`](https://www.npmjs.com/package/grab-url), the
231
273
  repo-wide client, rather than raw `fetch`. It is the package's only runtime
@@ -2,16 +2,30 @@ import type { WeatherLocation } from '../types';
2
2
  /** One IP geolocation upstream. */
3
3
  export type GeoProvider = {
4
4
  id: string;
5
- /** Used in the thrown message, e.g. `ipapi.co lookup`. */
5
+ /** Used in the thrown message, e.g. `ipwho.is lookup`. */
6
6
  label: string;
7
7
  /** Full URL for the lookup; `ip` is the address to look up, if any. */
8
- url: (ip?: string) => string;
8
+ url?: (ip?: string) => string;
9
9
  /** Map the body onto a location, or return `null` if it carries none. */
10
- parse: (body: Record<string, any>) => Partial<WeatherLocation> | null;
10
+ parse?: (body: Record<string, any>) => Partial<WeatherLocation> | null;
11
+ /**
12
+ * Answer without an HTTP request at all, for a source that is not a URL --
13
+ * {@link browserProvider} asks the device. Used instead of `url`/`parse`.
14
+ */
15
+ resolve?: (ip: string | undefined, options: {
16
+ timeout: number;
17
+ }) => Promise<Partial<WeatherLocation> | null>;
11
18
  };
12
19
  export type GeolocationOptions = {
13
20
  /** default=2 How many times each provider is tried before the next one is used. */
14
21
  attempts?: number;
22
+ /**
23
+ * default=true Reuse a location resolved in the last 12 hours instead of
24
+ * spending a lookup. The single most effective thing against a `429`.
25
+ */
26
+ cache?: boolean;
27
+ /** default=43200000 Milliseconds a cached location stays usable. */
28
+ cacheTtl?: number;
15
29
  /** default=500 Milliseconds before a retry; each further wait doubles. */
16
30
  retryDelay?: number;
17
31
  /** default=8 Seconds before a single lookup is aborted. */
@@ -26,25 +40,66 @@ export type GeolocationOptions = {
26
40
  error: Error;
27
41
  }) => void;
28
42
  };
29
- export declare const ipapiProvider: GeoProvider;
43
+ /**
44
+ * Default primary.
45
+ *
46
+ * `rate=1` asks ipwho.is to report the quota it has left alongside the
47
+ * location, which is what makes an approaching limit visible before it is a
48
+ * `429`. It is a free, no-key, CORS-enabled endpoint, so it works from the
49
+ * browser without a proxy of any kind.
50
+ */
30
51
  export declare const ipwhoProvider: GeoProvider;
52
+ export declare const ipapiProvider: GeoProvider;
31
53
  export declare const geojsProvider: GeoProvider;
32
54
  export declare const freeipapiProvider: GeoProvider;
33
- /** Tried in order; each is a different operator, so a rate limit on one is not a rate limit on all. */
55
+ /**
56
+ * The device's own geolocation, which costs no quota and is far more precise
57
+ * than any IP lookup -- but only after the user has granted permission, and
58
+ * the request is what raises the browser's permission prompt.
59
+ *
60
+ * Deliberately **not** in {@link DEFAULT_GEO_PROVIDERS}: a weather widget
61
+ * should not make the page ask for the user's location the moment it renders.
62
+ * Put `'browser'` at the front of `geoProviders` in a place where asking is
63
+ * expected, and the IP lookups stay behind it as the answer for everyone who
64
+ * declines.
65
+ */
66
+ export declare const browserProvider: GeoProvider;
67
+ /**
68
+ * Tried in order; each is a different operator, so a rate limit on one is not
69
+ * a rate limit on all.
70
+ *
71
+ * ipwho.is leads because ipapi.co is the stricter of the two in practice: it
72
+ * counts a browser request against a quota shared by the whole domain and then
73
+ * spends the rest of the day answering `RateLimited` as a 200, which is the
74
+ * failure this chain was built around in the first place.
75
+ */
34
76
  export declare const DEFAULT_GEO_PROVIDERS: GeoProvider[];
35
77
  /** The bundled Cloudflare worker, wrapped as a provider so it joins the same chain. */
36
78
  export declare function createWorkerGeoProvider(geoEndpoint: string): GeoProvider;
37
79
  export declare function resolveGeoProviders(providers?: (string | GeoProvider)[], geoEndpoint?: string): GeoProvider[];
80
+ /**
81
+ * The key a resolved location is stored under.
82
+ *
83
+ * A lookup of an explicit `ip`, a lookup through a worker and a lookup of the
84
+ * caller's own address are different questions, so each keeps its own answer
85
+ * rather than serving one for another.
86
+ */
87
+ export declare function locationCacheKey(geoEndpoint?: string, ip?: string): string;
38
88
  /**
39
89
  * Resolve the caller's location from a deployed geo worker or from one of the
40
90
  * public IP lookups.
41
91
  *
42
- * Each provider is retried with a growing delay, then the chain moves on; only
43
- * if all of them fail (and no `fallbackLocation` was given) does an error
44
- * reach the caller, listing what every provider said.
92
+ * A location resolved in the last 12 hours is reused as-is and costs no
93
+ * request at all -- see the note at the top of this file for why that is the
94
+ * difference between a widget that works and one that answers `429` from
95
+ * mid-afternoon onwards. Pass `cache: false` to force a fresh lookup.
96
+ *
97
+ * Otherwise each provider is retried with a growing delay, then the chain
98
+ * moves on; only if all of them fail (and no `fallbackLocation` was given)
99
+ * does an error reach the caller, listing what every provider said.
45
100
  *
46
101
  * @param geoEndpoint URL of a deployed geo worker; it is tried before the public providers.
47
102
  * @param ip Look up this IP instead of the caller's own.
48
- * @param options Retry, timeout, provider chain and fallback settings.
103
+ * @param options Cache, retry, timeout, provider chain and fallback settings.
49
104
  */
50
105
  export declare function getClientLocation(geoEndpoint?: string, ip?: string, options?: GeolocationOptions): Promise<WeatherLocation>;
@@ -39,14 +39,27 @@ export type GrabJsonOptions = {
39
39
  * malformed only wastes the upstream's rate limit.
40
40
  */
41
41
  retryClientErrors?: boolean;
42
+ /**
43
+ * default=true Repeat a request the upstream answered with a rate limit.
44
+ *
45
+ * Worth turning off wherever another upstream can answer the same question:
46
+ * a free IP lookup's limit is a daily quota, so the retry is spent on the
47
+ * one provider that has already said no, and spends quota to be told so
48
+ * again. The geolocation chain turns it off for every provider but the last
49
+ * one, which has nowhere left to move on to.
50
+ */
51
+ retryRateLimits?: boolean;
42
52
  };
43
53
  /** A failed upstream call, carrying the HTTP status when the failure had one. */
44
54
  export declare class HttpRequestError extends Error {
45
55
  readonly status?: number;
46
56
  readonly retryable: boolean;
57
+ /** The upstream refused because a quota or rate limit is spent, not because it broke. */
58
+ readonly rateLimited: boolean;
47
59
  constructor(message: string, options?: {
48
60
  status?: number;
49
61
  retryable?: boolean;
62
+ rateLimited?: boolean;
50
63
  });
51
64
  }
52
65
  /** No status at all means the request never reached the server -- worth a repeat. */
package/dist/index.cjs CHANGED
@@ -33,9 +33,12 @@ __export(index_exports, {
33
33
  DEFAULT_GEO_PROVIDERS: () => DEFAULT_GEO_PROVIDERS,
34
34
  DEFAULT_WEATHER_PROVIDERS: () => DEFAULT_WEATHER_PROVIDERS,
35
35
  HttpRequestError: () => HttpRequestError,
36
+ LOCATION_TTL_MS: () => LOCATION_TTL_MS,
36
37
  WeatherForecast: () => WeatherForecast,
37
38
  WeatherIcon: () => WeatherIcon,
39
+ browserProvider: () => browserProvider,
38
40
  buildForecastRequest: () => buildForecastRequest,
41
+ clearCachedLocations: () => clearCachedLocations,
39
42
  clearWeatherForecastCache: () => clearWeatherForecastCache,
40
43
  createOpenMeteoProvider: () => createOpenMeteoProvider,
41
44
  createWorkerGeoProvider: () => createWorkerGeoProvider,
@@ -51,15 +54,18 @@ __export(index_exports, {
51
54
  isRetryableStatus: () => isRetryableStatus,
52
55
  isValidLatitude: () => isValidLatitude,
53
56
  isValidLongitude: () => isValidLongitude,
57
+ locationCacheKey: () => locationCacheKey,
54
58
  metNoProvider: () => metNoProvider,
55
59
  normalizeCoordinates: () => normalizeCoordinates,
56
60
  normalizeTimezone: () => normalizeTimezone,
57
61
  openMeteoGfsProvider: () => openMeteoGfsProvider,
58
62
  openMeteoProvider: () => openMeteoProvider,
63
+ readCachedLocation: () => readCachedLocation,
59
64
  resolveGeoProviders: () => resolveGeoProviders,
60
65
  resolveWeatherProviders: () => resolveWeatherProviders,
61
66
  symbolToWmoCode: () => symbolToWmoCode,
62
67
  useWeatherForecast: () => useWeatherForecast,
68
+ writeCachedLocation: () => writeCachedLocation,
63
69
  wttrProvider: () => wttrProvider,
64
70
  wwoToWmoCode: () => wwoToWmoCode
65
71
  });
@@ -84,6 +90,8 @@ function getWeatherIcon(code, isDay = 1) {
84
90
  var CACHE_PREFIX = "weather-forecast-cache:";
85
91
  var CACHE_TTL_MS = 30 * 60 * 1e3;
86
92
  var STALE_TTL_MS = 24 * 60 * 60 * 1e3;
93
+ var LOCATION_PREFIX = "location:";
94
+ var LOCATION_TTL_MS = 12 * 60 * 60 * 1e3;
87
95
  function getStorage() {
88
96
  if (typeof window === "undefined" || !window.localStorage) return null;
89
97
  return window.localStorage;
@@ -116,6 +124,9 @@ function readStaleForecast(key, maxAgeMs = STALE_TTL_MS) {
116
124
  return entry.data;
117
125
  }
118
126
  function writeCachedForecast(key, data) {
127
+ writeEntry(key, data);
128
+ }
129
+ function writeEntry(key, data) {
119
130
  const storage = getStorage();
120
131
  if (!storage) return;
121
132
  try {
@@ -124,12 +135,31 @@ function writeCachedForecast(key, data) {
124
135
  } catch {
125
136
  }
126
137
  }
138
+ function readCachedLocation(scope, maxAgeMs = LOCATION_TTL_MS) {
139
+ const key = LOCATION_PREFIX + scope;
140
+ const entry = readEntry(key);
141
+ if (!entry) return null;
142
+ if (maxAgeMs <= 0 || Date.now() - entry.timestamp > maxAgeMs) {
143
+ getStorage()?.removeItem(CACHE_PREFIX + key);
144
+ return null;
145
+ }
146
+ return entry.data;
147
+ }
148
+ function writeCachedLocation(scope, location) {
149
+ writeEntry(LOCATION_PREFIX + scope, location);
150
+ }
151
+ function clearCachedLocations() {
152
+ clearByPrefix(CACHE_PREFIX + LOCATION_PREFIX);
153
+ }
127
154
  function clearWeatherForecastCache() {
155
+ clearByPrefix(CACHE_PREFIX);
156
+ }
157
+ function clearByPrefix(prefix) {
128
158
  const storage = getStorage();
129
159
  if (!storage) return;
130
160
  for (let i = storage.length - 1; i >= 0; i--) {
131
161
  const key = storage.key(i);
132
- if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);
162
+ if (key?.startsWith(prefix)) storage.removeItem(key);
133
163
  }
134
164
  }
135
165
 
@@ -142,8 +172,10 @@ var HttpRequestError = class extends Error {
142
172
  this.name = "HttpRequestError";
143
173
  this.status = options.status;
144
174
  this.retryable = options.retryable ?? isRetryableStatus(options.status);
175
+ this.rateLimited = options.rateLimited ?? options.status === 429;
145
176
  }
146
177
  };
178
+ var RATE_LIMIT_REASON = /rate|limit|quota|too many/i;
147
179
  function isRetryableStatus(status) {
148
180
  if (status === void 0) return true;
149
181
  return RETRYABLE_STATUS.has(status) || status >= 500;
@@ -239,7 +271,8 @@ async function grabJsonOnce(url, label, options) {
239
271
  if (data?.error === true) {
240
272
  const reason = data.reason ?? "unknown error";
241
273
  throw new HttpRequestError(`${label} failed: ${reason}`, {
242
- retryable: /rate|limit|busy|timeout|try again/i.test(reason)
274
+ retryable: /rate|limit|busy|timeout|try again/i.test(reason),
275
+ rateLimited: RATE_LIMIT_REASON.test(reason)
243
276
  });
244
277
  }
245
278
  if (!data) throw new HttpRequestError(`${label} failed: empty response`);
@@ -260,8 +293,9 @@ async function grabJson(url, label, options = {}) {
260
293
  }
261
294
  }
262
295
  lastError = failure;
296
+ const spentQuota = failure instanceof HttpRequestError && failure.rateLimited && options.retryRateLimits === false;
263
297
  const retryable = !(failure instanceof HttpRequestError) || failure.retryable || options.retryClientErrors === true;
264
- if (!retryable || attempt === attempts) break;
298
+ if (spentQuota || !retryable || attempt === attempts) break;
265
299
  if (retryDelay > 0) await wait(retryDelay * 2 ** (attempt - 1));
266
300
  }
267
301
  throw lastError instanceof Error ? lastError : new HttpRequestError(String(lastError));
@@ -755,23 +789,10 @@ function resolveWeatherProviders(providers) {
755
789
  var DEFAULT_ATTEMPTS = 2;
756
790
  var DEFAULT_RETRY_DELAY_MS = 500;
757
791
  var DEFAULT_TIMEOUT_SECONDS = 8;
758
- var ipapiProvider = {
759
- id: "ipapi",
760
- label: "ipapi.co lookup",
761
- url: (ip) => `https://ipapi.co/${ip ? `${encodeURIComponent(ip)}/` : ""}json/`,
762
- parse: (data) => ({
763
- city: data.city,
764
- region: data.region,
765
- country: data.country_name,
766
- timezone: data.timezone,
767
- latitude: data.latitude,
768
- longitude: data.longitude
769
- })
770
- };
771
792
  var ipwhoProvider = {
772
793
  id: "ipwho",
773
794
  label: "ipwho.is lookup",
774
- url: (ip) => `https://ipwho.is/${ip ? encodeURIComponent(ip) : ""}`,
795
+ url: (ip) => `https://ipwho.is/${ip ? encodeURIComponent(ip) : ""}?rate=1`,
775
796
  parse: (data) => {
776
797
  if (data.success === false) return null;
777
798
  return {
@@ -784,6 +805,19 @@ var ipwhoProvider = {
784
805
  };
785
806
  }
786
807
  };
808
+ var ipapiProvider = {
809
+ id: "ipapi",
810
+ label: "ipapi.co lookup",
811
+ url: (ip) => `https://ipapi.co/${ip ? `${encodeURIComponent(ip)}/` : ""}json/`,
812
+ parse: (data) => ({
813
+ city: data.city,
814
+ region: data.region,
815
+ country: data.country_name,
816
+ timezone: data.timezone,
817
+ latitude: data.latitude,
818
+ longitude: data.longitude
819
+ })
820
+ };
787
821
  var geojsProvider = {
788
822
  id: "geojs",
789
823
  label: "geojs.io lookup",
@@ -814,13 +848,36 @@ var freeipapiProvider = {
814
848
  longitude: data.longitude
815
849
  })
816
850
  };
851
+ var browserProvider = {
852
+ id: "browser",
853
+ label: "Browser geolocation",
854
+ resolve: (ip, { timeout }) => new Promise((resolve, reject) => {
855
+ if (ip) return reject(new Error("Browser geolocation cannot look up another IP"));
856
+ if (typeof navigator === "undefined" || !navigator.geolocation) {
857
+ return reject(new Error("Browser geolocation is unavailable"));
858
+ }
859
+ navigator.geolocation.getCurrentPosition(
860
+ (position) => resolve({
861
+ latitude: position.coords.latitude,
862
+ longitude: position.coords.longitude,
863
+ // The device knows where it is, not what that place is called;
864
+ // the zone it is in is the one the runtime is set to.
865
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
866
+ }),
867
+ (error) => reject(new Error(error.message || "Browser geolocation was refused")),
868
+ { timeout: timeout * 1e3, maximumAge: LOCATION_TTL_MS }
869
+ );
870
+ })
871
+ };
817
872
  var DEFAULT_GEO_PROVIDERS = [
818
- ipapiProvider,
819
873
  ipwhoProvider,
874
+ ipapiProvider,
820
875
  geojsProvider,
821
876
  freeipapiProvider
822
877
  ];
823
- var BY_ID2 = new Map(DEFAULT_GEO_PROVIDERS.map((provider) => [provider.id, provider]));
878
+ var BY_ID2 = new Map(
879
+ [...DEFAULT_GEO_PROVIDERS, browserProvider].map((provider) => [provider.id, provider])
880
+ );
824
881
  function createWorkerGeoProvider(geoEndpoint) {
825
882
  return {
826
883
  id: "worker",
@@ -841,8 +898,7 @@ function resolveGeoProviders(providers, geoEndpoint) {
841
898
  const chain = configured && configured.length > 0 ? configured : DEFAULT_GEO_PROVIDERS;
842
899
  return geoEndpoint ? [createWorkerGeoProvider(geoEndpoint), ...chain] : chain;
843
900
  }
844
- function toLocation(provider, body) {
845
- const parsed = provider.parse(body);
901
+ function toLocation(parsed) {
846
902
  if (!parsed) return null;
847
903
  const coordinates = normalizeCoordinates(parsed.latitude, parsed.longitude);
848
904
  if (!coordinates) return null;
@@ -854,19 +910,46 @@ function toLocation(provider, body) {
854
910
  ...coordinates
855
911
  };
856
912
  }
913
+ function locationCacheKey(geoEndpoint, ip) {
914
+ return `v1|${geoEndpoint ?? "public"}|${ip ?? "self"}`;
915
+ }
916
+ async function resolveOverHttp(provider, ip, transport) {
917
+ if (!provider.url || !provider.parse) {
918
+ throw new Error(`${provider.label} failed: provider has no url or resolve`);
919
+ }
920
+ const body = await grabJson(provider.url(ip), provider.label, transport);
921
+ return provider.parse(body);
922
+ }
857
923
  async function getClientLocation(geoEndpoint, ip, options = {}) {
924
+ const cacheKey = locationCacheKey(geoEndpoint, ip);
925
+ const useCache = options.cache !== false;
926
+ if (useCache) {
927
+ const cached = toLocation(readCachedLocation(cacheKey, options.cacheTtl));
928
+ if (cached) return cached;
929
+ }
858
930
  const providers = resolveGeoProviders(options.providers, geoEndpoint);
931
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
859
932
  const transport = {
860
933
  attempts: Math.max(1, Math.trunc(options.attempts ?? DEFAULT_ATTEMPTS)),
861
934
  retryDelay: Math.max(0, options.retryDelay ?? DEFAULT_RETRY_DELAY_MS),
862
- timeout: options.timeout ?? DEFAULT_TIMEOUT_SECONDS
935
+ timeout
863
936
  };
864
937
  const failures = [];
865
- for (const provider of providers) {
938
+ for (const [index, provider] of providers.entries()) {
939
+ const isLast = index === providers.length - 1;
866
940
  try {
867
- const body = await grabJson(provider.url(ip), provider.label, transport);
868
- const location = toLocation(provider, body);
869
- if (location) return location;
941
+ const parsed = provider.resolve ? await provider.resolve(ip, { timeout }) : await resolveOverHttp(provider, ip, {
942
+ ...transport,
943
+ // A provider that has spent its daily quota will spend the retry
944
+ // saying so again. Move on to the next operator instead -- unless
945
+ // this is the last one, which has nowhere to move on to.
946
+ retryRateLimits: isLast
947
+ });
948
+ const location = toLocation(parsed);
949
+ if (location) {
950
+ if (useCache) writeCachedLocation(cacheKey, location);
951
+ return location;
952
+ }
870
953
  throw new Error(`${provider.label} failed: no coordinates in the response`);
871
954
  } catch (error) {
872
955
  const failure = error instanceof Error ? error : new Error(String(error));
@@ -912,6 +995,12 @@ async function resolveLocation(options) {
912
995
  attempts: options.retryAttempts,
913
996
  retryDelay: options.retryDelay,
914
997
  providers: options.geoProviders,
998
+ // The forecast cache is keyed by coordinates, so it can only be consulted
999
+ // once the location is known -- which means a location that is not cached
1000
+ // costs an IP lookup on every refresh, cached forecast or not. This is the
1001
+ // cache that stops that.
1002
+ cache: options.cacheLocation,
1003
+ cacheTtl: options.locationCacheTtl,
915
1004
  fallbackLocation: options.fallbackLocation,
916
1005
  onProviderError: options.onProviderError ? ({ provider, error }) => options.onProviderError?.({ provider, error, stage: "geolocation" }) : void 0
917
1006
  });
@@ -989,6 +1078,8 @@ function requestKey(options) {
989
1078
  options.retryDelay,
990
1079
  options.timeout,
991
1080
  options.allowStaleCache,
1081
+ options.cacheLocation,
1082
+ options.locationCacheTtl,
992
1083
  options.reloadAttempts,
993
1084
  options.reloadDelay,
994
1085
  options.fallbackLocation?.latitude,
@@ -1417,9 +1508,12 @@ function WeatherForecast(props) {
1417
1508
  DEFAULT_GEO_PROVIDERS,
1418
1509
  DEFAULT_WEATHER_PROVIDERS,
1419
1510
  HttpRequestError,
1511
+ LOCATION_TTL_MS,
1420
1512
  WeatherForecast,
1421
1513
  WeatherIcon,
1514
+ browserProvider,
1422
1515
  buildForecastRequest,
1516
+ clearCachedLocations,
1423
1517
  clearWeatherForecastCache,
1424
1518
  createOpenMeteoProvider,
1425
1519
  createWorkerGeoProvider,
@@ -1435,15 +1529,18 @@ function WeatherForecast(props) {
1435
1529
  isRetryableStatus,
1436
1530
  isValidLatitude,
1437
1531
  isValidLongitude,
1532
+ locationCacheKey,
1438
1533
  metNoProvider,
1439
1534
  normalizeCoordinates,
1440
1535
  normalizeTimezone,
1441
1536
  openMeteoGfsProvider,
1442
1537
  openMeteoProvider,
1538
+ readCachedLocation,
1443
1539
  resolveGeoProviders,
1444
1540
  resolveWeatherProviders,
1445
1541
  symbolToWmoCode,
1446
1542
  useWeatherForecast,
1543
+ writeCachedLocation,
1447
1544
  wttrProvider,
1448
1545
  wwoToWmoCode
1449
1546
  });