use-weather-forecast 0.1.128 → 0.1.129

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
@@ -2,14 +2,16 @@
2
2
 
3
3
  [![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)
4
4
 
5
- React weather forecast component using Open-Meteo for current, hourly, and daily forecasts and Cloudflare/ipapi.co for IP geolocation.
5
+ 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.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - Current weather.
10
10
  - Next hours forecast.
11
11
  - Next days forecast.
12
- - IP geolocation fallback.
12
+ - Four weather upstreams tried in order, four IP geolocation upstreams behind that.
13
+ - Every request validated before it is sent, retried with backoff when it can recover.
14
+ - Stale-cache fallback so a total outage still renders a widget.
13
15
  - Latitude/longitude override.
14
16
  - Split SVG weather icon components.
15
17
  - TypeScript + tsup library scaffold.
@@ -37,6 +39,66 @@ export default function App() {
37
39
  }
38
40
  ```
39
41
 
42
+ ## Reliability
43
+
44
+ Nothing here needs configuring -- these are the defaults -- but this is what
45
+ the package does when an upstream misbehaves.
46
+
47
+ ### `Weather request failed: 400 Bad Request`
48
+
49
+ Open-Meteo answers `400` for a query it cannot parse, and the usual way to
50
+ build one is not a typo in your props: an IP geolocation upstream answers
51
+ `200` with no coordinates in the body, `Number(undefined)` becomes `NaN`,
52
+ `latitude=NaN` goes out on the wire. Everything that goes into a request is
53
+ validated first now:
54
+
55
+ | Input | What happens to it |
56
+ | --- | --- |
57
+ | `latitude` / `longitude` | Must be finite and on the globe, and are rounded to 4 decimals. A geolocation response without them counts as a failed lookup, so the next provider gets a turn. Unusable props fall back to IP geolocation. |
58
+ | `location.timezone` | Canonicalized (`US/Pacific` becomes `America/Los_Angeles`); a zone the runtime cannot resolve is dropped rather than sent, and Open-Meteo resolves the zone from the coordinates instead. |
59
+ | `forecastDays` / `forecastHours` | Clamped to the 1-16 days and 1-384 hours the API accepts. |
60
+ | Variable list | A `400` from an endpoint that refused one of the optional variables is retried once with the minimal set the widget needs. |
61
+
62
+ ### Retries
63
+
64
+ Requests go through [`grab-url`](https://www.npmjs.com/package/grab-url) rather
65
+ than `fetch`. Each one is tried up to `retryAttempts` times (3 by default for a
66
+ forecast, 2 per geolocation provider since that chain is longer) with
67
+ exponential backoff from `retryDelay` (400ms, then 800ms, ...). A `429`, a
68
+ `5xx`, a timeout or a dropped connection is repeated; a request the server
69
+ called invalid (`400`, `404`) is not, since replaying it only burns the
70
+ upstream's rate limit.
71
+
72
+ ### Fallback APIs
73
+
74
+ | Order | Weather | IP geolocation |
75
+ | --- | --- | --- |
76
+ | 1 | `open-meteo` -- `api.open-meteo.com/v1/forecast` | `geoEndpoint` (the bundled worker), when given |
77
+ | 2 | `open-meteo-gfs` -- the GFS model endpoint | `ipapi` -- ipapi.co |
78
+ | 3 | `met-no` -- met.no, a different operator and model | `ipwho` -- ipwho.is |
79
+ | 4 | `wttr` -- wttr.in, no key required | `geojs` -- get.geojs.io |
80
+ | 5 | | `freeipapi` -- freeipapi.com |
81
+
82
+ The fallbacks normalize their own condition codes, units and timestamps into
83
+ the same shape Open-Meteo returns, so a failover is invisible in the rendered
84
+ widget (wttr.in is 3-hourly rather than hourly). If every provider fails, a
85
+ cached forecast up to a day old is served instead of throwing; only if there is
86
+ no cache either does an error reach the widget, listing what each provider
87
+ said.
88
+
89
+ ```tsx
90
+ <WeatherForecast
91
+ weatherProviders={['open-meteo', 'met-no']}
92
+ geoProviders={['ipwho', 'geojs']}
93
+ retryAttempts={3}
94
+ retryDelay={400}
95
+ timeout={15}
96
+ fallbackLocation={{ city: 'Austin', latitude: 30.27, longitude: -97.74 }}
97
+ allowStaleCache
98
+ onProviderError={({ provider, stage, error }) => console.warn(stage, provider, error.message)}
99
+ />
100
+ ```
101
+
40
102
  ## Direct API usage
41
103
 
42
104
  ```ts
@@ -72,10 +134,17 @@ This package no longer uses ipinfo.io. Instead:
72
134
  no mixed-content issues, though ipapi.co's free tier is rate-limited (1,000
73
135
  requests/day) — deploy the worker and pass `geoEndpoint` for higher-volume or
74
136
  production use.
75
- - A failed lookup is **repeated** before it throws: `getClientLocation` tries three
76
- times by default, waiting 500ms, then 1s, so a single rate-limited or cold-start
77
- response doesn't take the whole forecast down. Tune it with the third argument:
78
- `getClientLocation(geoEndpoint, ip, { attempts: 5, retryDelay: 250 })`.
137
+ - A failed lookup is **repeated** before the chain moves on: `getClientLocation`
138
+ tries each provider twice by default, waiting 500ms, then walks the rest of the
139
+ chain (ipapi.co, ipwho.is, get.geojs.io, freeipapi.com) so a single rate-limited
140
+ or cold-start response doesn't take the whole forecast down. Tune it with the
141
+ third argument: `getClientLocation(geoEndpoint, ip, { attempts: 5, retryDelay: 250, providers: ['ipwho'] })`.
142
+ - A response **without usable coordinates counts as a failure** rather than being
143
+ passed on as `NaN`, and `fallbackLocation` covers the case where every provider
144
+ is down.
145
+ - The bundled worker does the same on its side: it falls back to the IP lookups
146
+ when Cloudflare's own geolocation carries no coordinates, and answers `502` with
147
+ a reason instead of a body the caller cannot use.
79
148
 
80
149
  ### Deploying the geo worker
81
150
 
@@ -90,16 +159,20 @@ This deploys `worker/geo-worker.ts` via Wrangler. Use the resulting `*.workers.d
90
159
  ## Caching
91
160
 
92
161
  `getWeatherForecast` caches each response in `localStorage` for 30 minutes, keyed by
93
- the exact request URL (location + units + forecast range). Repeated calls for the same
162
+ the request itself (location + units + forecast range + timezone), so a fallback
163
+ provider's answer is reused exactly like the primary one's. Repeated calls for the same
94
164
  location/options within that window are served from the cache instead of hitting
95
- Open-Meteo again, which keeps the widget well under Open-Meteo's rate limits. Call
96
- `clearWeatherForecastCache()` to evict everything (e.g. in tests). The cache is a no-op
97
- in non-browser environments (SSR) or when `localStorage` is unavailable/full.
165
+ Open-Meteo again, which keeps the widget well under Open-Meteo's rate limits. Past
166
+ the 30 minutes an entry is kept for a day as the last-resort fallback described
167
+ above, and evicted after that. Call `clearWeatherForecastCache()` to evict
168
+ everything (e.g. in tests). The cache is a no-op in non-browser environments (SSR)
169
+ or when `localStorage` is unavailable/full.
98
170
 
99
171
  ## Notes
100
172
 
101
- - Open-Meteo powers the forecast data.
102
- - Cloudflare's `request.cf` and ipapi.co power IP geolocation (see above).
173
+ - Open-Meteo powers the forecast data, with met.no and wttr.in behind it.
174
+ - Cloudflare's `request.cf`, ipapi.co, ipwho.is, get.geojs.io and freeipapi.com
175
+ power IP geolocation (see above).
103
176
  - HTTP requests go through [`grab-url`](https://www.npmjs.com/package/grab-url), the
104
177
  repo-wide client, rather than raw `fetch`. It is the package's only runtime
105
178
  dependency.
@@ -1,2 +1,26 @@
1
1
  import type { WeatherForecastData, WeatherForecastOptions } from '../types';
2
+ import { type ForecastRequest } from './providers';
3
+ /**
4
+ * Everything that goes on the wire is validated first, then the providers are
5
+ * tried in order until one answers.
6
+ *
7
+ * The `400 Bad Request` this used to render came from an unvalidated request:
8
+ * a geolocation upstream answering 200 with no coordinates put `latitude=NaN`
9
+ * in the URL, and an unknown timezone string or an out-of-range `forecastDays`
10
+ * did the same. Those are caught in {@link buildForecastRequest} now, before a
11
+ * request is sent, and anything that still fails falls through to the next
12
+ * provider and finally to a stale cache entry.
13
+ */
14
+ /** Cache key for a request, so a fallback provider's answer is reused like the primary one's. */
15
+ export declare function forecastCacheKey(request: ForecastRequest): string;
16
+ /** Validate and clamp every option into a request that no upstream can reject as malformed. */
17
+ export declare function buildForecastRequest(options?: WeatherForecastOptions): Promise<ForecastRequest>;
18
+ /**
19
+ * Current, hourly and daily forecast for a location.
20
+ *
21
+ * @param options Location, units, range, provider chain and retry settings.
22
+ * @returns The first provider's answer, a cached one, or -- if every provider
23
+ * failed and `allowStaleCache` is on -- the last good answer for this exact
24
+ * request.
25
+ */
2
26
  export declare function getWeatherForecast(options?: WeatherForecastOptions): Promise<WeatherForecastData>;
@@ -1,20 +1,50 @@
1
1
  import type { WeatherLocation } from '../types';
2
+ /** One IP geolocation upstream. */
3
+ export type GeoProvider = {
4
+ id: string;
5
+ /** Used in the thrown message, e.g. `ipapi.co lookup`. */
6
+ label: string;
7
+ /** Full URL for the lookup; `ip` is the address to look up, if any. */
8
+ url: (ip?: string) => string;
9
+ /** Map the body onto a location, or return `null` if it carries none. */
10
+ parse: (body: Record<string, any>) => Partial<WeatherLocation> | null;
11
+ };
2
12
  export type GeolocationOptions = {
3
- /** default=3 How many times the lookup is tried before the error is thrown. */
13
+ /** default=2 How many times each provider is tried before the next one is used. */
4
14
  attempts?: number;
5
- /** default=500 Milliseconds to wait before the second try; each further wait grows by that much. */
15
+ /** default=500 Milliseconds before a retry; each further wait doubles. */
6
16
  retryDelay?: number;
17
+ /** default=8 Seconds before a single lookup is aborted. */
18
+ timeout?: number;
19
+ /** Providers to try, by id or as objects. Omit for {@link DEFAULT_GEO_PROVIDERS}. */
20
+ providers?: (string | GeoProvider)[];
21
+ /** Used when every provider failed, instead of throwing. */
22
+ fallbackLocation?: Partial<WeatherLocation> | null;
23
+ /** Called for each provider that failed, before the next one is tried. */
24
+ onProviderError?: (info: {
25
+ provider: string;
26
+ error: Error;
27
+ }) => void;
7
28
  };
29
+ export declare const ipapiProvider: GeoProvider;
30
+ export declare const ipwhoProvider: GeoProvider;
31
+ export declare const geojsProvider: GeoProvider;
32
+ 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. */
34
+ export declare const DEFAULT_GEO_PROVIDERS: GeoProvider[];
35
+ /** The bundled Cloudflare worker, wrapped as a provider so it joins the same chain. */
36
+ export declare function createWorkerGeoProvider(geoEndpoint: string): GeoProvider;
37
+ export declare function resolveGeoProviders(providers?: (string | GeoProvider)[], geoEndpoint?: string): GeoProvider[];
8
38
  /**
9
- * Resolve the caller's location, either from a deployed geo worker or from
10
- * ipapi.co directly.
39
+ * Resolve the caller's location from a deployed geo worker or from one of the
40
+ * public IP lookups.
11
41
  *
12
- * A failed lookup is repeated with a growing delay and only the last error is
13
- * thrown, so a single rate-limited or cold-started response no longer takes
14
- * the whole forecast down with it.
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.
15
45
  *
16
- * @param geoEndpoint URL of a deployed geo worker; omit to use ipapi.co.
46
+ * @param geoEndpoint URL of a deployed geo worker; it is tried before the public providers.
17
47
  * @param ip Look up this IP instead of the caller's own.
18
- * @param options How often to repeat the lookup, and how long to wait between tries.
48
+ * @param options Retry, timeout, provider chain and fallback settings.
19
49
  */
20
50
  export declare function getClientLocation(geoEndpoint?: string, ip?: string, options?: GeolocationOptions): Promise<WeatherLocation>;
@@ -1,25 +1,55 @@
1
1
  /**
2
- * Shared JSON transport for the package's upstreams (the geo worker, ipapi.co
3
- * and Open-Meteo).
2
+ * Shared JSON transport for every upstream this package talks to (the geo
3
+ * worker, the IP geolocation providers and the weather providers).
4
4
  *
5
- * `grab-url` is the HTTP client used across this repo: it parses the JSON,
6
- * applies a timeout and can repeat a failed request itself. Unlike `fetch` it
7
- * resolves rather than rejects when a request fails, handing back the message
8
- * on `.error`, so every call site has to inspect the payload -- that check
9
- * lives here so the callers can just `await` a typed body.
5
+ * `grab-url` is the HTTP client used across this repo rather than `fetch`: it
6
+ * parses the JSON, applies a timeout and can repeat a failed request itself.
7
+ * Unlike `fetch` it resolves rather than rejects when a request fails, handing
8
+ * back the message on `.error`, so every call site has to inspect the payload
9
+ * -- that check lives here so the callers can just `await` a typed body.
10
+ *
11
+ * On top of that this module adds what the upstreams actually need to stay up:
12
+ * a classified retry loop (repeat what can recover, give up immediately on a
13
+ * request the server called invalid) with exponential backoff.
10
14
  */
11
15
  export type GrabJsonOptions = {
12
16
  headers?: Record<string, string>;
13
- /** default=0 Times grab-url repeats a request that failed at the transport level. */
14
- retryAttempts?: number;
15
- /** default=15 Seconds before the request is aborted. */
17
+ /** default=3 Total tries for this request, including the first one. */
18
+ attempts?: number;
19
+ /** default=400 Milliseconds before the second try; each further wait doubles. */
20
+ retryDelay?: number;
21
+ /** default=15 Seconds before a single try is aborted. */
16
22
  timeout?: number;
23
+ /**
24
+ * default=0 Times grab-url itself repeats a try that failed at the transport
25
+ * level, inside each of our `attempts`. Left at 0 so the backoff and the
26
+ * status classification below stay in charge of every repeat.
27
+ */
28
+ retryAttempts?: number;
29
+ /**
30
+ * default=false Repeat even when the server rejected the request as invalid
31
+ * (a 4xx other than 408/429). Off by default: replaying a request that is
32
+ * malformed only wastes the upstream's rate limit.
33
+ */
34
+ retryClientErrors?: boolean;
17
35
  };
36
+ /** A failed upstream call, carrying the HTTP status when the failure had one. */
37
+ export declare class HttpRequestError extends Error {
38
+ readonly status?: number;
39
+ readonly retryable: boolean;
40
+ constructor(message: string, options?: {
41
+ status?: number;
42
+ retryable?: boolean;
43
+ });
44
+ }
45
+ /** No status at all means the request never reached the server -- worth a repeat. */
46
+ export declare function isRetryableStatus(status?: number): boolean;
18
47
  /**
19
- * Request a JSON body, throwing a labelled error for any failure.
48
+ * Request a JSON body, repeating what can recover and throwing a labelled
49
+ * {@link HttpRequestError} once it cannot.
20
50
  *
21
51
  * @param url Full URL to request.
22
52
  * @param label Prefix for the thrown message, e.g. `ipapi.co lookup`.
23
- * @param options Transport options passed through to grab-url.
53
+ * @param options Transport and retry options.
24
54
  */
25
55
  export declare function grabJson<T>(url: string, label: string, options?: GrabJsonOptions): Promise<T>;
@@ -0,0 +1,21 @@
1
+ import type { WeatherProvider } from './types';
2
+ export * from './types';
3
+ export { createOpenMeteoProvider, openMeteoProvider, openMeteoGfsProvider } from './open-meteo';
4
+ export { metNoProvider, symbolToWmoCode } from './met-no';
5
+ export { wttrProvider, wwoToWmoCode } from './wttr';
6
+ /**
7
+ * Tried in order until one answers.
8
+ *
9
+ * Open-Meteo stays the primary source; its GFS endpoint covers a query the
10
+ * default model refused or rate-limited, and met.no and wttr.in are run by
11
+ * different operators entirely, so they still answer when Open-Meteo itself is
12
+ * unreachable.
13
+ */
14
+ export declare const DEFAULT_WEATHER_PROVIDERS: WeatherProvider[];
15
+ /**
16
+ * Turn the `weatherProviders` option into the list to try.
17
+ *
18
+ * @param providers Ids (`'open-meteo'`, `'met-no'`, `'wttr'`, `'open-meteo-gfs'`)
19
+ * or provider objects of your own. Omit for {@link DEFAULT_WEATHER_PROVIDERS}.
20
+ */
21
+ export declare function resolveWeatherProviders(providers?: (string | WeatherProvider)[]): WeatherProvider[];
@@ -0,0 +1,11 @@
1
+ import type { WeatherProvider } from './types';
2
+ export declare function symbolToWmoCode(symbol: string | undefined): number;
3
+ /**
4
+ * met.no is an independent upstream (a different operator, a different model),
5
+ * so it stays useful exactly when Open-Meteo is the thing that is down.
6
+ *
7
+ * It reports instants in UTC with Celsius and m/s; everything is converted to
8
+ * the request's units and rewritten into the naive local timestamps the widget
9
+ * renders, so a failover is invisible.
10
+ */
11
+ export declare const metNoProvider: WeatherProvider;
@@ -0,0 +1,26 @@
1
+ import type { ForecastRequest, WeatherProvider } from './types';
2
+ /** Every variable the widget can render. */
3
+ declare const FULL_VARIABLES: {
4
+ current: string[];
5
+ hourly: string[];
6
+ daily: string[];
7
+ };
8
+ type Variables = typeof FULL_VARIABLES;
9
+ export declare function buildOpenMeteoUrl(endpoint: string, request: ForecastRequest, variables?: Variables): string;
10
+ /**
11
+ * Open-Meteo, the package's primary source.
12
+ *
13
+ * @param id Provider id, e.g. `open-meteo`.
14
+ * @param label Name used in the aggregated error message.
15
+ * @param endpoint Full endpoint URL; the model endpoints (`/v1/gfs` and
16
+ * friends) take the same query as `/v1/forecast`.
17
+ */
18
+ export declare function createOpenMeteoProvider(id: string, label: string, endpoint: string): WeatherProvider;
19
+ /** `https://api.open-meteo.com/v1/forecast` -- the default, best-model source. */
20
+ export declare const openMeteoProvider: WeatherProvider;
21
+ /**
22
+ * The GFS model endpoint on the same host. It answers when `/v1/forecast` is
23
+ * rate-limited or refuses the query, and speaks exactly the same dialect.
24
+ */
25
+ export declare const openMeteoGfsProvider: WeatherProvider;
26
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { GrabJsonOptions } from '../http';
2
+ import type { TemperatureUnit, WeatherForecastData, WeatherLocation, WindSpeedUnit } from '../../types';
3
+ /** A forecast request after validation: every field is safe to put in a URL. */
4
+ export type ForecastRequest = {
5
+ location: WeatherLocation;
6
+ temperatureUnit: TemperatureUnit;
7
+ windSpeedUnit: WindSpeedUnit;
8
+ /** 1-16. */
9
+ forecastDays: number;
10
+ /** 1-384. */
11
+ forecastHours: number;
12
+ /** A validated IANA zone, or `undefined` to let the provider resolve it. */
13
+ timezone?: string;
14
+ /** Transport and retry settings passed through to grab-url. */
15
+ transport: GrabJsonOptions;
16
+ };
17
+ /**
18
+ * One upstream that can answer a {@link ForecastRequest}.
19
+ *
20
+ * Providers are tried in order and the first one to answer wins, so each has
21
+ * to return the same normalized shape whatever units or codes it speaks
22
+ * natively.
23
+ */
24
+ export type WeatherProvider = {
25
+ /** Stable id, e.g. `open-meteo`. Used in options and in the error message. */
26
+ id: string;
27
+ /** Human-readable name for the aggregated error message. */
28
+ label: string;
29
+ fetchForecast: (request: ForecastRequest) => Promise<WeatherForecastData>;
30
+ };
@@ -0,0 +1,11 @@
1
+ import type { WeatherProvider } from './types';
2
+ export declare function wwoToWmoCode(code: unknown): number;
3
+ /**
4
+ * wttr.in, the second independent fallback.
5
+ *
6
+ * Its free endpoint needs no key and answers with the location's own local
7
+ * times, in both Celsius and Fahrenheit, so only the wind speed has to be
8
+ * converted. It is 3-hourly rather than hourly -- a coarser forecast, but a
9
+ * rendered widget rather than an error message.
10
+ */
11
+ export declare const wttrProvider: WeatherProvider;
@@ -1,4 +1,11 @@
1
1
  import type { WeatherForecastData, WeatherForecastOptions } from '../types';
2
+ /**
3
+ * Fetch a forecast for `options`, refetching whenever they change.
4
+ *
5
+ * The last good forecast is kept while a refetch is in flight *and* if that
6
+ * refetch fails, so a rate-limited upstream leaves the widget showing the
7
+ * previous data rather than replacing it with an error.
8
+ */
2
9
  export declare function useWeatherForecast(options?: WeatherForecastOptions): {
3
10
  data: WeatherForecastData | null;
4
11
  error: Error | null;