@chaosity/location-client-react 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,16 @@ React bindings for [@chaosity/location-client](https://www.npmjs.com/package/@ch
8
8
  npm install @chaosity/location-client-react @chaosity/location-client
9
9
  ```
10
10
 
11
+ `maplibre-gl` is an optional peer, needed by `useMapLanguage` and the map
12
+ examples, at 6.4.1 or a later 6.x release. Earlier releases carry
13
+ [GHSA-jrc7-96c5-q579](https://github.com/advisories/GHSA-jrc7-96c5-q579), an
14
+ XSS in the attribution control, and `@chaosity/location-client` 0.11.0 is the
15
+ first core whose own peer admits 6. Under a bundler, MapLibre 6 runs its worker
16
+ from a file your application serves, so a map needs `setWorkerUrl` once before
17
+ it is built. The examples below serve it from `public/maplibre/`, and
18
+ [The MapLibre worker](https://github.com/chaosity-io/location-service-client#the-maplibre-worker)
19
+ in `@chaosity/location-client`'s README has the copy script and the Vite form.
20
+
11
21
  ## Quick Start
12
22
 
13
23
  ### 1. Create a Server Action to fetch config
@@ -86,6 +96,9 @@ React hook that keeps map label language in sync. Automatically reapplies after
86
96
 
87
97
  ```tsx
88
98
  import { useMapLanguage } from '@chaosity/location-client-react'
99
+ import * as maplibregl from 'maplibre-gl'
100
+
101
+ maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')
89
102
 
90
103
  function MapComponent() {
91
104
  const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null)
@@ -128,7 +141,8 @@ Provides the location client and automatic token refresh to all child components
128
141
 
129
142
  **Props:**
130
143
 
131
- - `getConfig` — Async function that returns `{ apiUrl: string, token: string, expiresAt?: number }`. Called on init and whenever the token needs refreshing.
144
+ - `getConfig` — Async function that returns `{ apiUrl: string, token: string, expiresAt?: number }`. Called on mount, whenever the token needs refreshing, and to retry a call that failed (see [Token Refresh](#token-refresh)).
145
+ - `configKey` — Optional. What `getConfig` answers for, such as an organisation or application id. When it changes, the provider drops the old client, token and `apiUrl`, calls `getConfig` again and hands out a new client, without remounting its children.
132
146
  - `children` — Child components.
133
147
 
134
148
  There is no `refreshBuffer` prop. It was removed in `0.3.0` — a value shorter
@@ -137,20 +151,47 @@ the server would not yet replace, and the two spun against each other. Both
137
151
  sides now apply the same buffer to the token's own `exp`. Passing it does
138
152
  nothing.
139
153
 
154
+ #### Switching organisation or application
155
+
156
+ Either of these is correct:
157
+
158
+ ```tsx
159
+ // Keeps the children mounted: only the client is replaced.
160
+ <LocationClientProvider configKey={orgId} getConfig={getLocationConfig}>
161
+
162
+ // Remounts everything below the provider, and its state with it.
163
+ <LocationClientProvider key={orgId} getConfig={getLocationConfig}>
164
+ ```
165
+
166
+ Without either, the provider goes on using the old configuration until its
167
+ next token refresh, up to 14 minutes later. A new `getConfig` function is not
168
+ a signal on its own, because it is usually a new function on every render.
169
+
170
+ After a switch, a client or `getToken` kept from before it refuses: `send()`
171
+ and `verifyAddress()` reject, `getToken()` returns `undefined`, and
172
+ `getAppConfig()` answers `{}`. So one configuration's token never reaches
173
+ another's URL. Build anything that holds
174
+ on to them, such as a MapLibre map or a geocoder, from the context's current
175
+ values, and rebuild it when `client`, `getToken` or `apiUrl` changes, as the
176
+ [complete example](#complete-example-with-maplibre) does. The same happens
177
+ without a `configKey` when `getConfig` starts answering with a different
178
+ `apiUrl`.
179
+
140
180
  ### useLocationClient
141
181
 
142
182
  Hook to access the location client in any component.
143
183
 
144
184
  ```tsx
145
- const { client, getToken, loading, error } = useLocationClient()
185
+ const { client, getToken, apiUrl, loading, error } = useLocationClient()
146
186
  ```
147
187
 
148
188
  **Returns:**
149
189
 
150
190
  - `client` (`LocationClient | null`) — The location client. Not a bare `GeoPlacesClient`: the provider wraps it so `send()` and `verifyAddress()` refresh the token first when they need to, and retry once if the API rejects it.
151
- - `getToken` (`() => string | undefined`) — Returns the current token. Useful for direct API calls (e.g., map style fetch).
152
- - `loading` (`boolean`) — Whether the client is initializing.
153
- - `error` (`string | null`) — Error message if initialization or token refresh failed.
191
+ - `getToken` (`() => string | undefined`) — Returns the current token, for requests the client does not make itself, such as a map's style, tiles and glyphs. It is available before the first token arrives and stays the same function afterwards, so a map built early reads the token once it lands. It returns `undefined` once `configKey` changes.
192
+ - `apiUrl` (`string | null`) — The API `client` talks to, from the same `getConfig` answer as the token. Build map URLs from it rather than restating the URL. `null` until a configuration has loaded.
193
+ - `loading` (`boolean`) — Whether the client is initializing. `true` again while a new `configKey` loads.
194
+ - `error` (`string | null`) — Error message if initialization or a token refresh failed. The provider keeps retrying, and `error` returns to `null` on the first success (see [Token Refresh](#token-refresh)).
154
195
 
155
196
  **Throws:** Error if used outside `LocationClientProvider`.
156
197
 
@@ -176,11 +217,46 @@ The provider owns the token lifecycle. There is nothing to manage manually.
176
217
  package's peer range admits has it.
177
218
  6. Concurrent refreshes are deduplicated — everything waiting shares one call to
178
219
  `getConfig`.
179
-
180
- A refresh that fails is reported as `error` from `useLocationClient()`, and
181
- rejects the `send()` or `verifyAddress()` that triggered it — with the refresh error rather than a
182
- 401, so the cause reads as the token endpoint being unreachable and not as the
183
- API refusing you.
220
+ 7. A failed `getConfig` is retried on the provider's own timer, backing off
221
+ exponentially from 1 to 30 seconds, with jitter. When `getConfig` rejects
222
+ with an error that has `retryAfterMs` (milliseconds), it waits that long
223
+ instead. Until then nothing else asks, neither a map reading its token for
224
+ every tile nor a `send()`. The tab coming back into view, or the browser
225
+ coming back online, retries at once, unless the wait was a `retryAfterMs`.
226
+
227
+ A Server Action, as in the Quick Start, cannot pass `retryAfterMs` on:
228
+ React sends a thrown error to the browser without its own fields. To honour
229
+ a token route's `Retry-After`, call the route from the browser and throw it
230
+ there:
231
+
232
+ ```ts
233
+ async function getConfig() {
234
+ const res = await fetch('/api/location-token')
235
+ if (!res.ok) {
236
+ const retryAfter = Number(res.headers.get('retry-after'))
237
+ throw Object.assign(new Error(`token route answered ${res.status}`), {
238
+ retryAfterMs: retryAfter > 0 ? retryAfter * 1000 : undefined,
239
+ })
240
+ }
241
+ return res.json()
242
+ }
243
+ ```
244
+
245
+ 8. That includes the first call. A page whose first `getConfig` fails gets its
246
+ client when a retry succeeds, without a reload.
247
+ 9. A token that arrives already stale by the browser's clock, because that
248
+ clock runs ahead of the server's, is not asked for again at once: the
249
+ server would hand back the same one. The provider waits, longer each time,
250
+ and sends with the token it has. A 401 still replaces it straight away.
251
+
252
+ A refresh that fails is reported as `error` from `useLocationClient()` until a
253
+ retry succeeds. Meanwhile every request goes out with the token in hand for as
254
+ long as that token is before its own expiry: `send()`, `verifyAddress()` and a
255
+ map's tiles alike, because the API still accepts it. Once it has expired,
256
+ `send()` and `verifyAddress()` reject with the refresh error rather than a 401,
257
+ so the cause reads as the token endpoint being unreachable, not as the API
258
+ refusing you. A token the API refuses (a 401) is never sent again: that
259
+ request rejects with the refresh error too.
184
260
 
185
261
  ## Complete Example with MapLibre
186
262
 
@@ -197,10 +273,14 @@ import {
197
273
  fetchMapStyle,
198
274
  createTransformRequest,
199
275
  } from '@chaosity/location-client'
200
- import maplibregl from 'maplibre-gl'
276
+ import * as maplibregl from 'maplibre-gl'
277
+ import 'maplibre-gl/dist/maplibre-gl.css'
201
278
  import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
279
+ import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css'
202
280
 
203
- const API_URL = process.env.NEXT_PUBLIC_LOCATION_API_URL!
281
+ // Once, before the first map: the worker file the application serves (see
282
+ // Installation).
283
+ maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')
204
284
 
205
285
  export default function MapComponent() {
206
286
  const mapContainer = useRef<HTMLDivElement>(null)
@@ -208,16 +288,18 @@ export default function MapComponent() {
208
288
  const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null)
209
289
  const [mapError, setMapError] = useState<string | null>(null)
210
290
  const [language, setLanguage] = useState('en')
211
- const { client, getToken, loading, error } = useLocationClient()
291
+ const { client, getToken, apiUrl, loading, error } = useLocationClient()
212
292
 
213
293
  // Keeps map labels in sync with language — reapplies after every setStyle() call
214
294
  useMapLanguage(mapInstance, language)
215
295
 
216
296
  useEffect(() => {
217
- if (!mapContainer.current || map.current || loading || !client) return
297
+ if (!mapContainer.current || map.current || loading || !client || !apiUrl)
298
+ return
218
299
  ;(async () => {
219
- // Fetch the style with the language baked into the descriptor
220
- const style = await fetchMapStyle(API_URL, 'Standard', getToken, {
300
+ // Fetch the style with the language baked into the descriptor. The URL
301
+ // comes from the context with the token, so the two always belong together.
302
+ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
221
303
  colorScheme: 'Light',
222
304
  language,
223
305
  })
@@ -227,7 +309,7 @@ export default function MapComponent() {
227
309
  style,
228
310
  center: [-123.12, 49.28],
229
311
  zoom: 10,
230
- transformRequest: createTransformRequest(API_URL, getToken),
312
+ transformRequest: createTransformRequest(apiUrl, getToken),
231
313
  })
232
314
  // Held at once, so the catch and the cleanup below can remove it
233
315
  map.current = instance
@@ -262,7 +344,7 @@ export default function MapComponent() {
262
344
  setMapInstance(null)
263
345
  }
264
346
  }
265
- }, [client, getToken, loading])
347
+ }, [client, getToken, apiUrl, loading])
266
348
 
267
349
  if (error) return <div>Error: {error}</div>
268
350
  if (mapError) return <div>Map unavailable: {mapError}</div>
@@ -277,7 +359,7 @@ one refuses the style request with 403 `FeatureNotEntitledException`, which the
277
359
  `catch` above puts on screen. 3D terrain and buildings need the `terrain` and `buildings` plan features:
278
360
 
279
361
  ```tsx
280
- const style = await fetchMapStyle(API_URL, 'Standard', getToken, {
362
+ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
281
363
  colorScheme: 'Light',
282
364
  terrain: 'Terrain3D',
283
365
  buildings: 'Buildings3D',
@@ -73,6 +73,14 @@ export interface LocationClient {
73
73
  interface LocationClientContextValue {
74
74
  client: LocationClient | null;
75
75
  getToken: () => string | undefined;
76
+ /**
77
+ * The API `client` talks to, from the same `getConfig` answer as the token
78
+ * `getToken` returns (#14). Build a map's style and tile URLs from this
79
+ * rather than restating the URL, so a map and its token cannot come from two
80
+ * different configurations. `null` until a configuration has loaded, and
81
+ * again while a new `configKey` loads.
82
+ */
83
+ apiUrl: string | null;
76
84
  loading: boolean;
77
85
  error: string | null;
78
86
  }
@@ -81,7 +89,20 @@ export interface LocationClientProviderProps {
81
89
  getConfig: () => Promise<ClientConfig & {
82
90
  expiresAt?: number;
83
91
  }>;
92
+ /**
93
+ * What `getConfig` answers for: an organisation or application id.
94
+ *
95
+ * Changing it drops the old configuration's token, client and `apiUrl` in
96
+ * the same render, and asks `getConfig` again, without remounting the
97
+ * children (#14). A client or a `getToken` kept from before the change
98
+ * refuses from then on, rather than pairing one configuration's token with
99
+ * the other's URL.
100
+ *
101
+ * `getConfig`'s own identity is deliberately not such a signal: it is
102
+ * usually an inline function, new on every render.
103
+ */
104
+ configKey?: string | number;
84
105
  }
85
- export declare function LocationClientProvider({ children, getConfig, }: LocationClientProviderProps): import("react").JSX.Element;
106
+ export declare function LocationClientProvider({ children, getConfig, configKey, }: LocationClientProviderProps): import("react").JSX.Element;
86
107
  export declare function useLocationClient(): LocationClientContextValue;
87
108
  export {};