@chaosity/location-client-react 0.9.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
|
@@ -28,9 +28,16 @@ in `@chaosity/location-client`'s README has the copy script and the Vite form.
|
|
|
28
28
|
|
|
29
29
|
import { getClientConfig } from '@chaosity/location-client/server'
|
|
30
30
|
|
|
31
|
-
export async function getLocationConfig() {
|
|
31
|
+
export async function getLocationConfig(request?: { refusedToken?: string }) {
|
|
32
32
|
// Auto-reads LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET
|
|
33
|
-
|
|
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
|
|
34
41
|
}
|
|
35
42
|
```
|
|
36
43
|
|
|
@@ -88,6 +95,11 @@ function SearchComponent() {
|
|
|
88
95
|
}
|
|
89
96
|
```
|
|
90
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
|
+
|
|
91
103
|
## Map Utilities
|
|
92
104
|
|
|
93
105
|
### useMapLanguage
|
|
@@ -141,7 +153,7 @@ Provides the location client and automatic token refresh to all child components
|
|
|
141
153
|
|
|
142
154
|
**Props:**
|
|
143
155
|
|
|
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)).
|
|
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.
|
|
145
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.
|
|
146
158
|
- `children` — Child components.
|
|
147
159
|
|
|
@@ -215,8 +227,22 @@ The provider owns the token lifecycle. There is nothing to manage manually.
|
|
|
215
227
|
until the timer next comes around: for a token with 14 minutes left, 14
|
|
216
228
|
minutes of a broken page. The core added this in 0.7.0, so every core this
|
|
217
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
|
+
|
|
218
241
|
6. Concurrent refreshes are deduplicated — everything waiting shares one call to
|
|
219
|
-
`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.
|
|
220
246
|
7. A failed `getConfig` is retried on the provider's own timer, backing off
|
|
221
247
|
exponentially from 1 to 30 seconds, with jitter. When `getConfig` rejects
|
|
222
248
|
with an error that has `retryAfterMs` (milliseconds), it waits that long
|
|
@@ -230,8 +256,13 @@ The provider owns the token lifecycle. There is nothing to manage manually.
|
|
|
230
256
|
there:
|
|
231
257
|
|
|
232
258
|
```ts
|
|
233
|
-
async function getConfig() {
|
|
234
|
-
|
|
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
|
+
})
|
|
235
266
|
if (!res.ok) {
|
|
236
267
|
const retryAfter = Number(res.headers.get('retry-after'))
|
|
237
268
|
throw Object.assign(new Error(`token route answered ${res.status}`), {
|
|
@@ -319,6 +350,8 @@ export default function MapComponent() {
|
|
|
319
350
|
'top-right',
|
|
320
351
|
)
|
|
321
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.
|
|
322
355
|
const geoPlaces = new GeoPlaces(client, instance)
|
|
323
356
|
const geocoder = new MaplibreGeocoder(geoPlaces, {
|
|
324
357
|
maplibregl,
|
|
@@ -494,6 +527,11 @@ const response: SuggestCommandOutput = await client!.send(
|
|
|
494
527
|
)
|
|
495
528
|
```
|
|
496
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
|
+
|
|
497
535
|
## License
|
|
498
536
|
|
|
499
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@chaosity/location-client": "^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",
|