@chaosity/location-client-react 0.8.0 → 0.10.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
@@ -18,9 +28,16 @@ npm install @chaosity/location-client-react @chaosity/location-client
18
28
 
19
29
  import { getClientConfig } from '@chaosity/location-client/server'
20
30
 
21
- export async function getLocationConfig() {
31
+ export async function getLocationConfig(request?: { refusedToken?: string }) {
22
32
  // Auto-reads LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET
23
- return await getClientConfig()
33
+ const config = await getClientConfig()
34
+ // The API refused this token before its expiry (revoked, or its secret
35
+ // rotated). getClientConfig() keeps one token per application, so replace
36
+ // it, but only when it is the one refused: see Token Refresh, step 5.
37
+ // `request` is optional: it arrives from the browser, and only after a 401.
38
+ return request?.refusedToken === config.token
39
+ ? getClientConfig({ forceRefresh: true })
40
+ : config
24
41
  }
25
42
  ```
26
43
 
@@ -78,6 +95,11 @@ function SearchComponent() {
78
95
  }
79
96
  ```
80
97
 
98
+ With `@chaosity/location-client` 0.13.0 or later, `client.send` resolves with
99
+ the command's own output type, so the `SuggestCommandOutput` annotation above
100
+ is optional. On an older core, `send` answers `unknown` unless the output type
101
+ is named, as it is here.
102
+
81
103
  ## Map Utilities
82
104
 
83
105
  ### useMapLanguage
@@ -86,6 +108,9 @@ React hook that keeps map label language in sync. Automatically reapplies after
86
108
 
87
109
  ```tsx
88
110
  import { useMapLanguage } from '@chaosity/location-client-react'
111
+ import * as maplibregl from 'maplibre-gl'
112
+
113
+ maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')
89
114
 
90
115
  function MapComponent() {
91
116
  const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null)
@@ -128,7 +153,7 @@ Provides the location client and automatic token refresh to all child components
128
153
 
129
154
  **Props:**
130
155
 
131
- - `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)).
156
+ - `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)). After the API refuses a token it is called with `{ refusedToken }`, the token refused; every other call passes nothing. An answer without a non-empty `token` and `apiUrl` is treated as a failure, as a rejection is.
132
157
  - `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.
133
158
  - `children` — Child components.
134
159
 
@@ -202,8 +227,22 @@ The provider owns the token lifecycle. There is nothing to manage manually.
202
227
  until the timer next comes around: for a token with 14 minutes left, 14
203
228
  minutes of a broken page. The core added this in 0.7.0, so every core this
204
229
  package's peer range admits has it.
230
+
231
+ Since 0.10.0 that refresh calls `getConfig({ refusedToken })`. Your server
232
+ has to act on it: `getClientConfig()` keeps one token per application and,
233
+ asked again, hands back the one just refused, so nothing is retried until
234
+ its cache comes round on its own (measured: almost ten minutes). Replace the
235
+ cached token only when it is the refused one, as the Quick Start does: that
236
+ mints once per refused token, however many visitors report it, and nothing
237
+ for a report of any other token. It does not stop a visitor echoing the
238
+ token it was just handed to make your server mint again; rate-limit the
239
+ action if that matters.
240
+
205
241
  6. Concurrent refreshes are deduplicated — everything waiting shares one call to
206
- `getConfig`.
242
+ `getConfig`. A 401 that lands while a scheduled refresh is in flight shares
243
+ that call too, which names no token, so the refused one is named on the
244
+ next 401: at the next `send()` on a core below 0.12.0, and after the core's
245
+ 30-second hold from 0.12.0.
207
246
  7. A failed `getConfig` is retried on the provider's own timer, backing off
208
247
  exponentially from 1 to 30 seconds, with jitter. When `getConfig` rejects
209
248
  with an error that has `retryAfterMs` (milliseconds), it waits that long
@@ -217,8 +256,13 @@ The provider owns the token lifecycle. There is nothing to manage manually.
217
256
  there:
218
257
 
219
258
  ```ts
220
- async function getConfig() {
221
- const res = await fetch('/api/location-token')
259
+ async function getConfig(request?: { refusedToken: string }) {
260
+ // In the body, not the URL, so the token stays out of access logs.
261
+ const res = await fetch('/api/location-token', {
262
+ method: 'POST',
263
+ headers: { 'content-type': 'application/json' },
264
+ body: JSON.stringify(request ?? {}),
265
+ })
222
266
  if (!res.ok) {
223
267
  const retryAfter = Number(res.headers.get('retry-after'))
224
268
  throw Object.assign(new Error(`token route answered ${res.status}`), {
@@ -260,8 +304,14 @@ import {
260
304
  fetchMapStyle,
261
305
  createTransformRequest,
262
306
  } from '@chaosity/location-client'
263
- import maplibregl from 'maplibre-gl'
307
+ import * as maplibregl from 'maplibre-gl'
308
+ import 'maplibre-gl/dist/maplibre-gl.css'
264
309
  import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
310
+ import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css'
311
+
312
+ // Once, before the first map: the worker file the application serves (see
313
+ // Installation).
314
+ maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')
265
315
 
266
316
  export default function MapComponent() {
267
317
  const mapContainer = useRef<HTMLDivElement>(null)
@@ -300,6 +350,8 @@ export default function MapComponent() {
300
350
  'top-right',
301
351
  )
302
352
 
353
+ // Type-checks with @chaosity/location-client 0.13.0 or later. An older
354
+ // core types GeoPlaces's client as GeoPlacesClient, and reports TS2345.
303
355
  const geoPlaces = new GeoPlaces(client, instance)
304
356
  const geocoder = new MaplibreGeocoder(geoPlaces, {
305
357
  maplibregl,
@@ -475,6 +527,11 @@ const response: SuggestCommandOutput = await client!.send(
475
527
  )
476
528
  ```
477
529
 
530
+ Every `send` example in this README names the output type, so it compiles on
531
+ every core the peer range admits. From `@chaosity/location-client` 0.13.0 the
532
+ annotation is optional: `send` resolves with the command's own output type,
533
+ and the core's `CommandOutput<C>` names it when you need it.
534
+
478
535
  ## License
479
536
 
480
537
  MIT
@@ -1,5 +1,5 @@
1
1
  import type { ClientConfig, VerifyAddressResponse } from '@chaosity/location-client';
2
- import { type AppConfigClaims } from '@chaosity/location-client';
2
+ import { type AppConfigClaims, GeoPlacesClient } from '@chaosity/location-client';
3
3
  import type { ReactNode } from 'react';
4
4
  /**
5
5
  * Per-request transport options.
@@ -36,7 +36,16 @@ export interface LocationClient {
36
36
  readonly config: {
37
37
  serviceId: string;
38
38
  };
39
- send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
39
+ /**
40
+ * The core's own `send`, overloads and all, not a copy of it: from core
41
+ * 0.13.0, `await client.send(new AutocompleteCommand(…))` is an
42
+ * `AutocompleteCommandOutput` with nothing to annotate (core #68). A copy
43
+ * of the old `send<TInput, TOutput>` kept answering `unknown`, and nothing
44
+ * caught it — that one signature and the core's pair of overloads are
45
+ * assignable to each other both ways. Below 0.13.0 it is the core's old
46
+ * signature, as before.
47
+ */
48
+ send: GeoPlacesClient['send'];
40
49
  /**
41
50
  * Verify a PlaceId through `POST /address/verify`: the full place record plus
42
51
  * `verified`, the one Places result an integrator may store (#26). A
@@ -86,7 +95,32 @@ interface LocationClientContextValue {
86
95
  }
87
96
  export interface LocationClientProviderProps {
88
97
  children: ReactNode;
89
- getConfig: () => Promise<ClientConfig & {
98
+ /**
99
+ * Where the token and `apiUrl` come from: usually a server action returning
100
+ * `getClientConfig()`.
101
+ *
102
+ * After the API refuses a token before its `exp` (a revoked token, or a
103
+ * rotated secret), it is asked with `{ refusedToken }`, the token refused
104
+ * (#43). `getClientConfig()` caches one token per application, so a server
105
+ * that ignores this hands the refused token back, and the page stays broken
106
+ * until that cache comes round on its own. Ask for a new one only when the
107
+ * cached token IS the refused one:
108
+ *
109
+ * return request?.refusedToken === config.token
110
+ * ? getClientConfig({ forceRefresh: true })
111
+ * : config
112
+ *
113
+ * That mints once per refused token, and nothing for a report of any other.
114
+ * It does not stop a caller echoing the token it was just handed, so a
115
+ * server worried about that rate-limits the call. Every other call passes
116
+ * nothing, and a `getConfig` that ignores the argument works as before.
117
+ *
118
+ * An answer without a non-empty `token` and `apiUrl` is taken as a failure
119
+ * (#39), as a rejection is.
120
+ */
121
+ getConfig: (request?: {
122
+ refusedToken: string;
123
+ }) => Promise<ClientConfig & {
90
124
  expiresAt?: number;
91
125
  }>;
92
126
  /**
@@ -48,6 +48,7 @@ function retryAfterOf(err) {
48
48
  ?.retryAfterMs;
49
49
  return typeof ms === 'number' && ms > 0 ? ms : undefined;
50
50
  }
51
+ const isFilled = (value) => typeof value === 'string' && value.length > 0;
51
52
  function overrides(trigger, hold) {
52
53
  if (trigger === 'scheduled')
53
54
  return true;
@@ -204,7 +205,9 @@ function LocationClientProvider({ children, getConfig, configKey, }) {
204
205
  *
205
206
  * The client awaits this after a 401 and retries the request once with
206
207
  * what it returns; the same token, or nothing, means no retry, so a
207
- * doomed request is never sent — or billed — twice.
208
+ * doomed request is never sent — or billed — twice. The `rejected`
209
+ * trigger names the refused token to `getConfig` (#43): a server that
210
+ * caches one token returns it again unless told which one was refused.
208
211
  *
209
212
  * @chaosity/location-client 0.8.0 and later also calls it BEFORE the
210
213
  * first send when it holds no token at all. Here that means this client
@@ -232,6 +235,9 @@ function LocationClientProvider({ children, getConfig, configKey, }) {
232
235
  // this provider would have been silently discarded.
233
236
  const client = {
234
237
  config: baseClient.config,
238
+ // Typed by `LocationClient` above, so callers see the core's overloads
239
+ // and never this signature: a literal has no overload syntax, and one
240
+ // generic body with the cast satisfies both (core #68).
235
241
  async send(command, options) {
236
242
  await ready();
237
243
  return baseClient.send(command, options);
@@ -284,10 +290,31 @@ function LocationClientProvider({ children, getConfig, configKey, }) {
284
290
  : Promise.reject(hold.error);
285
291
  }
286
292
  log('Asking getConfig (%s)', trigger);
293
+ // The token the API refused, so a server that caches one can replace
294
+ // exactly that one (#43). Named on no other trigger, where the call
295
+ // stays argument-less. A `rejected` call that finds an attempt in
296
+ // flight joins it above and names nothing, and the next 401 names it:
297
+ // at the next send below core 0.12.0, and after that core's 30 s hold
298
+ // on the refused token from 0.12.0. The core's `refreshToken` takes no
299
+ // argument, so `state.token` stands for the token it sent.
300
+ const refused = trigger === 'rejected' ? state.token : undefined;
301
+ const request = refused
302
+ ? [{ refusedToken: refused }]
303
+ : [];
287
304
  const attempt = (async () => {
288
305
  let cfg;
289
306
  try {
290
- cfg = await getConfigRef.current();
307
+ cfg = await getConfigRef.current(...request);
308
+ // A token route's error body passed through `res.json()` resolves
309
+ // too (#39). Installing it published a client for no URL, and an
310
+ // `error` of null, so it fails here as a rejection does.
311
+ const missing = !isFilled(cfg?.token)
312
+ ? 'a token'
313
+ : !isFilled(cfg?.apiUrl)
314
+ ? 'an apiUrl'
315
+ : null;
316
+ if (missing)
317
+ throw new Error(`getConfig resolved without ${missing}`);
291
318
  }
292
319
  catch (err) {
293
320
  if (configRef.current !== state)
@@ -1,5 +1,5 @@
1
1
  import type { ClientConfig, VerifyAddressResponse } from '@chaosity/location-client';
2
- import { type AppConfigClaims } from '@chaosity/location-client';
2
+ import { type AppConfigClaims, GeoPlacesClient } from '@chaosity/location-client';
3
3
  import type { ReactNode } from 'react';
4
4
  /**
5
5
  * Per-request transport options.
@@ -36,7 +36,16 @@ export interface LocationClient {
36
36
  readonly config: {
37
37
  serviceId: string;
38
38
  };
39
- send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
39
+ /**
40
+ * The core's own `send`, overloads and all, not a copy of it: from core
41
+ * 0.13.0, `await client.send(new AutocompleteCommand(…))` is an
42
+ * `AutocompleteCommandOutput` with nothing to annotate (core #68). A copy
43
+ * of the old `send<TInput, TOutput>` kept answering `unknown`, and nothing
44
+ * caught it — that one signature and the core's pair of overloads are
45
+ * assignable to each other both ways. Below 0.13.0 it is the core's old
46
+ * signature, as before.
47
+ */
48
+ send: GeoPlacesClient['send'];
40
49
  /**
41
50
  * Verify a PlaceId through `POST /address/verify`: the full place record plus
42
51
  * `verified`, the one Places result an integrator may store (#26). A
@@ -86,7 +95,32 @@ interface LocationClientContextValue {
86
95
  }
87
96
  export interface LocationClientProviderProps {
88
97
  children: ReactNode;
89
- getConfig: () => Promise<ClientConfig & {
98
+ /**
99
+ * Where the token and `apiUrl` come from: usually a server action returning
100
+ * `getClientConfig()`.
101
+ *
102
+ * After the API refuses a token before its `exp` (a revoked token, or a
103
+ * rotated secret), it is asked with `{ refusedToken }`, the token refused
104
+ * (#43). `getClientConfig()` caches one token per application, so a server
105
+ * that ignores this hands the refused token back, and the page stays broken
106
+ * until that cache comes round on its own. Ask for a new one only when the
107
+ * cached token IS the refused one:
108
+ *
109
+ * return request?.refusedToken === config.token
110
+ * ? getClientConfig({ forceRefresh: true })
111
+ * : config
112
+ *
113
+ * That mints once per refused token, and nothing for a report of any other.
114
+ * It does not stop a caller echoing the token it was just handed, so a
115
+ * server worried about that rate-limits the call. Every other call passes
116
+ * nothing, and a `getConfig` that ignores the argument works as before.
117
+ *
118
+ * An answer without a non-empty `token` and `apiUrl` is taken as a failure
119
+ * (#39), as a rejection is.
120
+ */
121
+ getConfig: (request?: {
122
+ refusedToken: string;
123
+ }) => Promise<ClientConfig & {
90
124
  expiresAt?: number;
91
125
  }>;
92
126
  /**
@@ -41,6 +41,7 @@ function retryAfterOf(err) {
41
41
  ?.retryAfterMs;
42
42
  return typeof ms === 'number' && ms > 0 ? ms : undefined;
43
43
  }
44
+ const isFilled = (value) => typeof value === 'string' && value.length > 0;
44
45
  function overrides(trigger, hold) {
45
46
  if (trigger === 'scheduled')
46
47
  return true;
@@ -197,7 +198,9 @@ export function LocationClientProvider({ children, getConfig, configKey, }) {
197
198
  *
198
199
  * The client awaits this after a 401 and retries the request once with
199
200
  * what it returns; the same token, or nothing, means no retry, so a
200
- * doomed request is never sent — or billed — twice.
201
+ * doomed request is never sent — or billed — twice. The `rejected`
202
+ * trigger names the refused token to `getConfig` (#43): a server that
203
+ * caches one token returns it again unless told which one was refused.
201
204
  *
202
205
  * @chaosity/location-client 0.8.0 and later also calls it BEFORE the
203
206
  * first send when it holds no token at all. Here that means this client
@@ -225,6 +228,9 @@ export function LocationClientProvider({ children, getConfig, configKey, }) {
225
228
  // this provider would have been silently discarded.
226
229
  const client = {
227
230
  config: baseClient.config,
231
+ // Typed by `LocationClient` above, so callers see the core's overloads
232
+ // and never this signature: a literal has no overload syntax, and one
233
+ // generic body with the cast satisfies both (core #68).
228
234
  async send(command, options) {
229
235
  await ready();
230
236
  return baseClient.send(command, options);
@@ -277,10 +283,31 @@ export function LocationClientProvider({ children, getConfig, configKey, }) {
277
283
  : Promise.reject(hold.error);
278
284
  }
279
285
  log('Asking getConfig (%s)', trigger);
286
+ // The token the API refused, so a server that caches one can replace
287
+ // exactly that one (#43). Named on no other trigger, where the call
288
+ // stays argument-less. A `rejected` call that finds an attempt in
289
+ // flight joins it above and names nothing, and the next 401 names it:
290
+ // at the next send below core 0.12.0, and after that core's 30 s hold
291
+ // on the refused token from 0.12.0. The core's `refreshToken` takes no
292
+ // argument, so `state.token` stands for the token it sent.
293
+ const refused = trigger === 'rejected' ? state.token : undefined;
294
+ const request = refused
295
+ ? [{ refusedToken: refused }]
296
+ : [];
280
297
  const attempt = (async () => {
281
298
  let cfg;
282
299
  try {
283
- cfg = await getConfigRef.current();
300
+ cfg = await getConfigRef.current(...request);
301
+ // A token route's error body passed through `res.json()` resolves
302
+ // too (#39). Installing it published a client for no URL, and an
303
+ // `error` of null, so it fails here as a rejection does.
304
+ const missing = !isFilled(cfg?.token)
305
+ ? 'a token'
306
+ : !isFilled(cfg?.apiUrl)
307
+ ? 'an apiUrl'
308
+ : null;
309
+ if (missing)
310
+ throw new Error(`getConfig resolved without ${missing}`);
284
311
  }
285
312
  catch (err) {
286
313
  if (configRef.current !== state)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client-react",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "React bindings for Chaosity Location Service client",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@chaosity/location-client": ">=0.10.0",
42
- "maplibre-gl": "^5.0.0",
42
+ "maplibre-gl": "^6.4.1",
43
43
  "react": "^19.0.0"
44
44
  },
45
45
  "peerDependenciesMeta": {
@@ -48,7 +48,7 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@chaosity/location-client": "^0.10.0",
51
+ "@chaosity/location-client": "^0.13.0",
52
52
  "@eslint/js": "^10.0.1",
53
53
  "@testing-library/react": "^16.0.0",
54
54
  "@testing-library/user-event": "^14.0.0",
@@ -58,7 +58,7 @@
58
58
  "eslint": "^10.1.0",
59
59
  "happy-dom": "^20.11.6",
60
60
  "husky": "^9.0.0",
61
- "maplibre-gl": "^5.0.0",
61
+ "maplibre-gl": "^6.4.1",
62
62
  "prettier": "^3.8.1",
63
63
  "prettier-plugin-organize-imports": "^4.3.0",
64
64
  "react": "^19.0.0",