use-weather-forecast 0.1.182 → 0.1.184
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 +59 -17
- package/dist/api/geolocation.d.ts +64 -9
- package/dist/api/http.d.ts +13 -0
- package/dist/index.cjs +123 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +117 -26
- package/dist/index.js.map +1 -1
- package/dist/lib/cache.d.ts +25 -1
- package/dist/types.d.ts +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
[](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/
|
|
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 | `
|
|
119
|
-
| 3 | `met-no` -- met.no, a different operator and model | `
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 (
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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`,
|
|
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. `
|
|
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
|
|
8
|
+
url?: (ip?: string) => string;
|
|
9
9
|
/** Map the body onto a location, or return `null` if it carries none. */
|
|
10
|
-
parse
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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>;
|
package/dist/api/http.d.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
868
|
-
|
|
869
|
-
|
|
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
|
});
|