use-weather-forecast 0.1.166 → 0.1.167

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
@@ -68,18 +68,37 @@ the package does when an upstream misbehaves.
68
68
 
69
69
  ### `Weather request failed: 400 Bad Request`
70
70
 
71
- Open-Meteo answers `400` for a query it cannot parse, and the usual way to
72
- build one is not a typo in your props: an IP geolocation upstream answers
73
- `200` with no coordinates in the body, `Number(undefined)` becomes `NaN`,
74
- `latitude=NaN` goes out on the wire. Everything that goes into a request is
75
- validated first now:
71
+ Two different things produced this, and both are fixed.
72
+
73
+ **A malformed URL.** Requests go through
74
+ [`grab-url`](https://www.npmjs.com/package/grab-url), which turns every option
75
+ it does not recognise into the GET query string and concatenates it onto the
76
+ path. Passing it a URL that already had a query gave the request *two* `?`, and
77
+ the last real parameter value absorbed the leftover option:
78
+
79
+ ```text
80
+ ...&daily=temperature_2m_max%2Cwind_speed_10m_max?retryAttempts=0
81
+ ```
82
+
83
+ Open-Meteo read that as an unknown `daily` variable and answered `400`. It hit
84
+ every weather provider on every call, since all four build a query -- while the
85
+ geolocation lookups kept working, because their URLs carry no query of their own
86
+ for the stray `?` to collide with. The query is now handed over as grab-url's
87
+ own parameters, so grab-url writes the single `?` itself and there is no query
88
+ string of ours left to corrupt.
89
+
90
+ **A malformed query.** Open-Meteo also answers `400` for a query it cannot
91
+ parse, and the usual way to build one is not a typo in your props: an IP
92
+ geolocation upstream answers `200` with no coordinates in the body,
93
+ `Number(undefined)` becomes `NaN`, `latitude=NaN` goes out on the wire.
94
+ Everything that goes into a request is validated first:
76
95
 
77
96
  | Input | What happens to it |
78
97
  | --- | --- |
79
- | `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. |
80
- | `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. |
98
+ | `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. Coordinates on `location` are honoured like top-level ones, without an IP lookup. |
99
+ | `location.timezone` | Canonicalized (`US/Pacific` becomes `America/Los_Angeles`); a zone the runtime cannot resolve is dropped rather than sent. With no zone given, the one IP geolocation reported is used, and Open-Meteo resolves it from the coordinates if there is none. |
81
100
  | `forecastDays` / `forecastHours` | Clamped to the 1-16 days and 1-384 hours the API accepts. |
82
- | Variable list | A `400` from an endpoint that refused one of the optional variables is retried once with the minimal set the widget needs. |
101
+ | Variable list | A `400` from an endpoint that refused part of the query is asked again in a smaller shape: first only the variables every model supports, then that without `forecast_hours` and with `timezone=auto`. The units are never dropped -- a Celsius body read as Fahrenheit would render a plausible, wrong temperature. |
83
102
 
84
103
  ### Retries
85
104
 
@@ -104,9 +123,20 @@ upstream's rate limit.
104
123
  The fallbacks normalize their own condition codes, units and timestamps into
105
124
  the same shape Open-Meteo returns, so a failover is invisible in the rendered
106
125
  widget (wttr.in is 3-hourly rather than hourly). If every provider fails, a
107
- cached forecast up to a day old is served instead of throwing; only if there is
108
- no cache either does an error reach the widget, listing what each provider
109
- said.
126
+ cached forecast up to a day old is served instead of throwing.
127
+
128
+ ### Reloading
129
+
130
+ If even that produces nothing, the component reloads the whole forecast
131
+ `reloadAttempts` more times (2 by default) with a growing pause -- 3s, then 6s.
132
+ A failure that reaches this point means every provider failed at once, which is
133
+ usually something that clears on its own: a flaky network on first paint, or
134
+ every free upstream rate-limited in the same second. The component stays in its
135
+ loading state while a reload is pending, so it renders nothing for a few
136
+ seconds rather than flashing an error it is about to recover from.
137
+
138
+ Only once the reloads are spent does an error reach the widget, listing what
139
+ each provider said. `reloadAttempts={0}` shows it immediately instead.
110
140
 
111
141
  ```tsx
112
142
  <WeatherForecast
@@ -115,6 +145,8 @@ said.
115
145
  retryAttempts={3}
116
146
  retryDelay={400}
117
147
  timeout={15}
148
+ reloadAttempts={2}
149
+ reloadDelay={3000}
118
150
  fallbackLocation={{ city: 'Austin', latitude: 30.27, longitude: -97.74 }}
119
151
  allowStaleCache
120
152
  onProviderError={({ provider, stage, error }) => console.warn(stage, provider, error.message)}
@@ -4,12 +4,20 @@ import { type ForecastRequest } from './providers';
4
4
  * Everything that goes on the wire is validated first, then the providers are
5
5
  * tried in order until one answers.
6
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.
7
+ * Two different things produced the `400 Bad Request` this used to render, and
8
+ * both are handled, in different places:
9
+ *
10
+ * - The **request** could be malformed. A geolocation upstream answering 200
11
+ * with no coordinates put `latitude=NaN` in the URL; an unknown timezone
12
+ * string or an out-of-range `forecastDays` did the same. Those are caught in
13
+ * {@link buildForecastRequest}, before a request is sent.
14
+ * - The **URL** could be malformed after it left here, by the transport rather
15
+ * than by anything in this file -- see `splitUrl` in `./http`. That one broke
16
+ * every provider on every call, which is why the fallbacks below could not
17
+ * rescue it.
18
+ *
19
+ * Anything that still fails falls through to the next provider and finally to a
20
+ * stale cache entry.
13
21
  */
14
22
  /** Cache key for a request, so a fallback provider's answer is reused like the primary one's. */
15
23
  export declare function forecastCacheKey(request: ForecastRequest): string;
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * On top of that this module adds what the upstreams actually need to stay up:
12
12
  * a classified retry loop (repeat what can recover, give up immediately on a
13
- * request the server called invalid) with exponential backoff.
13
+ * request the server called invalid) with exponential backoff, and it hands
14
+ * grab-url a query string it cannot corrupt -- see {@link splitUrl}.
14
15
  */
15
16
  export type GrabJsonOptions = {
16
17
  headers?: Record<string, string>;
@@ -21,9 +22,15 @@ export type GrabJsonOptions = {
21
22
  /** default=15 Seconds before a single try is aborted. */
22
23
  timeout?: number;
23
24
  /**
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.
25
+ * default=0 Extra tries inside each of our `attempts`, run back-to-back with
26
+ * no backoff between them.
27
+ *
28
+ * This used to be forwarded to grab-url's own option of the same name. It no
29
+ * longer is -- grab-url turns any option it does not recognise into a query
30
+ * parameter, and `retryAttempts` is one it does not recognise, so forwarding
31
+ * it appended `?retryAttempts=0` to every URL (see {@link splitUrl}). The
32
+ * repeat is done here instead, which keeps the same try count without
33
+ * touching the URL.
27
34
  */
28
35
  retryAttempts?: number;
29
36
  /**
@@ -44,6 +51,35 @@ export declare class HttpRequestError extends Error {
44
51
  }
45
52
  /** No status at all means the request never reached the server -- worth a repeat. */
46
53
  export declare function isRetryableStatus(status?: number): boolean;
54
+ /**
55
+ * Split a full URL into the path grab-url should request and the query it
56
+ * should build, because grab-url will not leave a query string we wrote alone.
57
+ *
58
+ * grab-url destructures the options it knows, then turns **every remaining
59
+ * option** into the GET query string and concatenates it onto the path. So a
60
+ * URL that already carries a `?` comes out with two of them, and the last real
61
+ * value silently absorbs the leftover option:
62
+ *
63
+ * ```text
64
+ * ...&daily=temperature_2m_max%2Cwind_speed_10m_max?retryAttempts=0
65
+ * ```
66
+ *
67
+ * Open-Meteo reads that trailing segment as part of the last `daily` variable,
68
+ * does not recognise it, and answers `400 Bad Request` -- which is exactly the
69
+ * `Weather request failed: 400 Bad Request` the widget used to render, on every
70
+ * weather provider (they all build a query), while the geolocation lookups kept
71
+ * working because their URLs have no query of their own for the stray `?` to
72
+ * collide with.
73
+ *
74
+ * Handing the query over as grab-url's own params instead means grab-url writes
75
+ * the single `?` itself, so there is no query string of ours left to corrupt --
76
+ * and an option it fails to recognise lands as one more harmless parameter
77
+ * rather than inside the value of a real one.
78
+ */
79
+ export declare function splitUrl(url: string): {
80
+ path: string;
81
+ params: Record<string, string>;
82
+ };
47
83
  /**
48
84
  * Request a JSON body, repeating what can recover and throwing a labelled
49
85
  * {@link HttpRequestError} once it cannot.
@@ -6,7 +6,20 @@ declare const FULL_VARIABLES: {
6
6
  daily: string[];
7
7
  };
8
8
  type Variables = typeof FULL_VARIABLES;
9
- export declare function buildOpenMeteoUrl(endpoint: string, request: ForecastRequest, variables?: Variables): string;
9
+ export type OpenMeteoQuery = {
10
+ /** default=FULL_VARIABLES Which variables to ask for. */
11
+ variables?: Variables;
12
+ /**
13
+ * default=false Leave out the range and zone parameters an endpoint can
14
+ * refuse on their own: `forecast_hours`, and a `timezone` other than `auto`.
15
+ *
16
+ * Only parameters that cannot change what the numbers *mean* are dropped --
17
+ * the units stay, since a Celsius body read as Fahrenheit would render a
18
+ * plausible, wrong temperature rather than an error.
19
+ */
20
+ omitOptional?: boolean;
21
+ };
22
+ export declare function buildOpenMeteoUrl(endpoint: string, request: ForecastRequest, query?: OpenMeteoQuery | Variables): string;
10
23
  /**
11
24
  * Open-Meteo, the package's primary source.
12
25
  *
@@ -4,7 +4,11 @@ import type { WeatherForecastData, WeatherForecastOptions } from '../types';
4
4
  *
5
5
  * The last good forecast is kept while a refetch is in flight *and* if that
6
6
  * refetch fails, so a rate-limited upstream leaves the widget showing the
7
- * previous data rather than replacing it with an error.
7
+ * previous data rather than replacing it with an error. A first load that
8
+ * fails outright is retried `reloadAttempts` times with a growing pause, and
9
+ * stays in the loading state while a retry is pending -- the widget renders
10
+ * nothing for a few seconds rather than flashing an error it is about to
11
+ * recover from.
8
12
  */
9
13
  export declare function useWeatherForecast(options?: WeatherForecastOptions): {
10
14
  data: WeatherForecastData | null;
package/dist/index.cjs CHANGED
@@ -165,11 +165,63 @@ function unwrapBody(payload) {
165
165
  }
166
166
  return payload;
167
167
  }
168
+ function splitUrl(url) {
169
+ const separator = url.indexOf("?");
170
+ if (separator === -1) return { path: url, params: {} };
171
+ const params = {};
172
+ for (const [key, value] of new URLSearchParams(url.slice(separator + 1))) params[key] = value;
173
+ return { path: url.slice(0, separator), params };
174
+ }
175
+ var GRAB_RESERVED_OPTIONS = /* @__PURE__ */ new Set([
176
+ "headers",
177
+ "response",
178
+ "method",
179
+ "cache",
180
+ "timeout",
181
+ "baseURL",
182
+ "cancelOngoingIfNew",
183
+ "cancelNewIfOngoing",
184
+ "rateLimit",
185
+ "debug",
186
+ "infiniteScroll",
187
+ "logger",
188
+ "onRequest",
189
+ "onResponse",
190
+ "onError",
191
+ "onStream",
192
+ "unzip",
193
+ "dom",
194
+ "body",
195
+ "post",
196
+ "put",
197
+ "patch",
198
+ "debounce",
199
+ "repeat",
200
+ "repeatEvery",
201
+ "setDefaults",
202
+ "regrabOnStale",
203
+ "regrabOnFocus",
204
+ "regrabOnNetwork",
205
+ "cacheForTime",
206
+ "retryAttempts"
207
+ ]);
168
208
  async function grabJsonOnce(url, label, options) {
169
- const { headers, retryAttempts = 0, timeout = 15 } = options;
170
- const data = await (0, import_grab_url.default)(url, {
209
+ const { headers, timeout = 15 } = options;
210
+ const { path, params } = splitUrl(url);
211
+ const reserved = Object.keys(params).filter((key) => GRAB_RESERVED_OPTIONS.has(key));
212
+ if (reserved.length > 0) {
213
+ throw new HttpRequestError(
214
+ `${label} failed: ${reserved.join(", ")} cannot be sent as a query parameter`,
215
+ { retryable: false }
216
+ );
217
+ }
218
+ const data = await (0, import_grab_url.default)(path, {
219
+ // grab-url builds the query from the options it does not recognise, so the
220
+ // request's own parameters are passed as exactly that -- never as a query
221
+ // string on `path`. Anything added here has to be an option grab-url knows,
222
+ // or it goes out on the wire as a parameter.
223
+ ...params,
171
224
  headers,
172
- retryAttempts,
173
225
  timeout,
174
226
  // Several widgets (or several locations in one widget) hit the same path
175
227
  // at once, and grab-url aborts the earlier call of a duplicate path by
@@ -196,16 +248,21 @@ async function grabJsonOnce(url, label, options) {
196
248
  async function grabJson(url, label, options = {}) {
197
249
  const attempts = Math.max(1, Math.trunc(options.attempts ?? 3));
198
250
  const retryDelay = Math.max(0, options.retryDelay ?? 400);
251
+ const immediate = Math.max(0, Math.trunc(options.retryAttempts ?? 0));
199
252
  let lastError;
200
253
  for (let attempt = 1; attempt <= attempts; attempt++) {
201
- try {
202
- return await grabJsonOnce(url, label, options);
203
- } catch (error) {
204
- lastError = error;
205
- const retryable = !(error instanceof HttpRequestError) || error.retryable || options.retryClientErrors === true;
206
- if (!retryable || attempt === attempts) break;
207
- if (retryDelay > 0) await wait(retryDelay * 2 ** (attempt - 1));
254
+ let failure;
255
+ for (let immediateTry = 0; immediateTry <= immediate; immediateTry++) {
256
+ try {
257
+ return await grabJsonOnce(url, label, options);
258
+ } catch (error) {
259
+ failure = error;
260
+ }
208
261
  }
262
+ lastError = failure;
263
+ const retryable = !(failure instanceof HttpRequestError) || failure.retryable || options.retryClientErrors === true;
264
+ if (!retryable || attempt === attempts) break;
265
+ if (retryDelay > 0) await wait(retryDelay * 2 ** (attempt - 1));
209
266
  }
210
267
  throw lastError instanceof Error ? lastError : new HttpRequestError(String(lastError));
211
268
  }
@@ -440,15 +497,16 @@ var MINIMAL_VARIABLES = {
440
497
  hourly: ["temperature_2m", "weather_code"],
441
498
  daily: ["temperature_2m_max", "temperature_2m_min", "weather_code"]
442
499
  };
443
- function buildOpenMeteoUrl(endpoint, request, variables = FULL_VARIABLES) {
500
+ function buildOpenMeteoUrl(endpoint, request, query = {}) {
501
+ const { variables = FULL_VARIABLES, omitOptional = false } = "current" in query ? { variables: query } : query;
444
502
  const params = new URLSearchParams({
445
503
  latitude: String(request.location.latitude),
446
504
  longitude: String(request.location.longitude),
447
- timezone: request.timezone || "auto",
505
+ timezone: omitOptional ? "auto" : request.timezone || "auto",
448
506
  temperature_unit: request.temperatureUnit,
449
507
  wind_speed_unit: request.windSpeedUnit,
450
508
  forecast_days: String(request.forecastDays),
451
- forecast_hours: String(request.forecastHours),
509
+ ...omitOptional ? {} : { forecast_hours: String(request.forecastHours) },
452
510
  current: variables.current.join(","),
453
511
  hourly: variables.hourly.join(","),
454
512
  daily: variables.daily.join(",")
@@ -505,24 +563,28 @@ function createOpenMeteoProvider(id, label, endpoint) {
505
563
  id,
506
564
  label,
507
565
  async fetchForecast(request) {
508
- try {
509
- const data = await grabJson(
510
- buildOpenMeteoUrl(endpoint, request),
511
- "Weather request",
512
- request.transport
513
- );
514
- return parse(data, request);
515
- } catch (error) {
516
- if (!(error instanceof HttpRequestError)) throw error;
517
- const invalidQuery = error.status === 400 || /cannot initialize|invalid|not supported/i.test(error.message);
518
- if (!invalidQuery) throw error;
519
- const data = await grabJson(
520
- buildOpenMeteoUrl(endpoint, request, MINIMAL_VARIABLES),
521
- "Weather request",
522
- request.transport
523
- );
524
- return parse(data, request);
566
+ const shapes = [
567
+ {},
568
+ { variables: MINIMAL_VARIABLES },
569
+ { variables: MINIMAL_VARIABLES, omitOptional: true }
570
+ ];
571
+ let lastError;
572
+ for (const [index, shape] of shapes.entries()) {
573
+ try {
574
+ const data = await grabJson(
575
+ buildOpenMeteoUrl(endpoint, request, shape),
576
+ "Weather request",
577
+ request.transport
578
+ );
579
+ return parse(data, request);
580
+ } catch (error) {
581
+ lastError = error;
582
+ if (!(error instanceof HttpRequestError)) throw error;
583
+ const invalidQuery = error.status === 400 || /cannot initialize|invalid|not supported/i.test(error.message);
584
+ if (!invalidQuery || index === shapes.length - 1) throw error;
585
+ }
525
586
  }
587
+ throw lastError;
526
588
  }
527
589
  };
528
590
  }
@@ -838,7 +900,7 @@ function forecastCacheKey(request) {
838
900
  ].join("|");
839
901
  }
840
902
  async function resolveLocation(options) {
841
- const explicit = normalizeCoordinates(options.latitude, options.longitude);
903
+ const explicit = normalizeCoordinates(options.latitude, options.longitude) ?? normalizeCoordinates(options.location?.latitude, options.location?.longitude);
842
904
  if (explicit) {
843
905
  return {
844
906
  ...options.location,
@@ -870,7 +932,10 @@ async function buildForecastRequest(options = {}) {
870
932
  // `undefined` means "let the provider resolve the zone from the
871
933
  // coordinates" (Open-Meteo's `timezone=auto`); an unrecognised zone string
872
934
  // is dropped here rather than sent, since Open-Meteo answers 400 for one.
873
- timezone: normalizeTimezone(options.location?.timezone),
935
+ // A zone the caller gave wins over the one geolocation reported, but the
936
+ // geolocated zone is still used rather than discarded -- it is what makes
937
+ // the clock read in the location's own time on the first render.
938
+ timezone: normalizeTimezone(options.location?.timezone) ?? location.timezone,
874
939
  transport
875
940
  };
876
941
  }
@@ -901,31 +966,18 @@ async function getWeatherForecast(options = {}) {
901
966
 
902
967
  // src/hooks/useWeatherForecast.ts
903
968
  var import_react = require("react");
969
+ var DEFAULT_RELOAD_ATTEMPTS = 2;
970
+ var DEFAULT_RELOAD_DELAY_MS = 3e3;
904
971
  function providerKey(options) {
905
972
  const ids = (list) => (list ?? []).map((provider) => typeof provider === "string" ? provider : provider.id).join(",");
906
973
  return `${ids(options.weatherProviders)}|${ids(options.geoProviders)}`;
907
974
  }
908
- function useWeatherForecast(options = {}) {
909
- const [data, setData] = (0, import_react.useState)(null);
910
- const [error, setError] = (0, import_react.useState)(null);
911
- const [loading, setLoading] = (0, import_react.useState)(true);
912
- (0, import_react.useEffect)(() => {
913
- let active = true;
914
- setLoading(true);
915
- setError(null);
916
- getWeatherForecast(options).then((result) => {
917
- if (active) setData(result);
918
- }).catch((err) => {
919
- if (active) setError(err instanceof Error ? err : new Error("Unknown error"));
920
- }).finally(() => {
921
- if (active) setLoading(false);
922
- });
923
- return () => {
924
- active = false;
925
- };
926
- }, [
975
+ function requestKey(options) {
976
+ return [
927
977
  options.latitude,
928
978
  options.longitude,
979
+ options.location?.latitude,
980
+ options.location?.longitude,
929
981
  options.geoEndpoint,
930
982
  options.ip,
931
983
  options.forecastDays,
@@ -937,10 +989,48 @@ function useWeatherForecast(options = {}) {
937
989
  options.retryDelay,
938
990
  options.timeout,
939
991
  options.allowStaleCache,
992
+ options.reloadAttempts,
993
+ options.reloadDelay,
940
994
  options.fallbackLocation?.latitude,
941
995
  options.fallbackLocation?.longitude,
942
996
  providerKey(options)
943
- ]);
997
+ ].join("|");
998
+ }
999
+ function useWeatherForecast(options = {}) {
1000
+ const [data, setData] = (0, import_react.useState)(null);
1001
+ const [error, setError] = (0, import_react.useState)(null);
1002
+ const [loading, setLoading] = (0, import_react.useState)(true);
1003
+ const reloadAttempts = Math.max(0, Math.trunc(options.reloadAttempts ?? DEFAULT_RELOAD_ATTEMPTS));
1004
+ const reloadDelay = Math.max(0, options.reloadDelay ?? DEFAULT_RELOAD_DELAY_MS);
1005
+ const key = requestKey(options);
1006
+ const [reload, setReload] = (0, import_react.useState)({ key, count: 0 });
1007
+ if (reload.key !== key) setReload({ key, count: 0 });
1008
+ (0, import_react.useEffect)(() => {
1009
+ let active = true;
1010
+ let timer;
1011
+ setLoading(true);
1012
+ setError(null);
1013
+ getWeatherForecast(options).then((result) => {
1014
+ if (!active) return;
1015
+ setData(result);
1016
+ setLoading(false);
1017
+ }).catch((err) => {
1018
+ if (!active) return;
1019
+ setError(err instanceof Error ? err : new Error("Unknown error"));
1020
+ if (reload.count >= reloadAttempts) {
1021
+ setLoading(false);
1022
+ return;
1023
+ }
1024
+ timer = setTimeout(
1025
+ () => setReload((current) => ({ key: current.key, count: current.count + 1 })),
1026
+ reloadDelay * 2 ** reload.count
1027
+ );
1028
+ });
1029
+ return () => {
1030
+ active = false;
1031
+ if (timer !== void 0) clearTimeout(timer);
1032
+ };
1033
+ }, [key, reload.count]);
944
1034
  return { data, error, loading };
945
1035
  }
946
1036