@chaosity/location-client 0.10.0 → 0.12.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.
Files changed (36) hide show
  1. package/README.md +176 -30
  2. package/dist/adapters/GeoPlaces.js +53 -17
  3. package/dist/auth/TokenProvider.d.ts +2 -0
  4. package/dist/auth/TokenProvider.js +39 -10
  5. package/dist/auth/tokenHold.d.ts +72 -0
  6. package/dist/auth/tokenHold.js +109 -0
  7. package/dist/cjs/adapters/GeoPlaces.js +53 -17
  8. package/dist/cjs/auth/TokenProvider.d.ts +2 -0
  9. package/dist/cjs/auth/TokenProvider.js +39 -10
  10. package/dist/cjs/auth/tokenHold.d.ts +72 -0
  11. package/dist/cjs/auth/tokenHold.js +115 -0
  12. package/dist/cjs/client/GeoPlacesClient.d.ts +6 -2
  13. package/dist/cjs/client/GeoPlacesClient.js +83 -21
  14. package/dist/cjs/client/commands.d.ts +2 -3
  15. package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
  16. package/dist/cjs/errors/LocationServiceException.js +53 -1
  17. package/dist/cjs/index.d.ts +1 -1
  18. package/dist/cjs/server/LocationServiceConnector.d.ts +2 -0
  19. package/dist/cjs/server/LocationServiceConnector.js +53 -14
  20. package/dist/cjs/server/getClientConfig.d.ts +3 -2
  21. package/dist/cjs/server/getClientConfig.js +44 -17
  22. package/dist/cjs/transport/errors.d.ts +14 -8
  23. package/dist/cjs/transport/errors.js +26 -18
  24. package/dist/client/GeoPlacesClient.d.ts +6 -2
  25. package/dist/client/GeoPlacesClient.js +83 -21
  26. package/dist/client/commands.d.ts +2 -3
  27. package/dist/errors/LocationServiceException.d.ts +30 -2
  28. package/dist/errors/LocationServiceException.js +52 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/server/LocationServiceConnector.d.ts +2 -0
  31. package/dist/server/LocationServiceConnector.js +53 -14
  32. package/dist/server/getClientConfig.d.ts +3 -2
  33. package/dist/server/getClientConfig.js +44 -17
  34. package/dist/transport/errors.d.ts +14 -8
  35. package/dist/transport/errors.js +27 -19
  36. package/package.json +3 -3
package/README.md CHANGED
@@ -34,17 +34,24 @@ npm install maplibre-gl
34
34
  npm install maplibre-gl @maplibre/maplibre-gl-geocoder
35
35
  ```
36
36
 
37
+ The map helpers need `maplibre-gl` 6.4.1 or a later 6.x release. Earlier
38
+ releases carry
39
+ [GHSA-jrc7-96c5-q579](https://github.com/advisories/GHSA-jrc7-96c5-q579), an
40
+ XSS in the attribution control, and none of them has a fix. MapLibre 6 needs
41
+ its worker set up once under a bundler: see [The MapLibre worker](#the-maplibre-worker).
42
+
37
43
  ## Key Features
38
44
 
39
45
  - **Custom Authentication**: Uses Bearer tokens instead of AWS SigV4
40
- - **AWS SDK Commands**: Full access to all AWS Location Service commands
46
+ - **Places Commands**: the seven Amazon Location Places commands from `@aws-sdk/client-geo-places`, plus `VerifyAddressCommand`
41
47
  - **Address Verification**: `verifyAddress(placeId)` returns the one Places result you may store — see [Verifying an address](#verifying-an-address)
42
- - **Data Type Utilities**: Built-in GeoJSON conversion utilities
48
+ - **Data Type Utilities**: GeoJSON converters for the Places responses
43
49
  - **MapLibre Integration**: Adapter for MapLibre GL Geocoder and `createTransformRequest` helper
44
50
  - **Map Style Control**: Fetch and customize map style descriptors — color scheme, points of interest, label language, and terrain, 3D buildings, traffic and more as [plan features](#plan-features)
45
51
  - **Map Language**: Switch map label language client-side with zero API calls
46
52
  - **POI Layer Control**: Toggle point-of-interest categories on/off by layer
47
53
  - **Server Utilities**: `getClientConfig()` with auto-env detection and token caching
54
+ - **Typed Errors**: every failure is a `LocationServiceException` whose `code` is a `LocationServiceErrorCode`
48
55
 
49
56
  ## Quick Start
50
57
 
@@ -67,7 +74,8 @@ export async function getLocationConfig() {
67
74
  Set these environment variables:
68
75
 
69
76
  ```bash
70
- LOCATION_API_URL=https://api.chaosity.cloud
77
+ # The API URL on your application's page in the developer portal
78
+ LOCATION_API_URL=https://your-api-url.example
71
79
  LOCATION_CLIENT_ID=your-client-id
72
80
  LOCATION_CLIENT_SECRET=your-client-secret
73
81
 
@@ -80,7 +88,7 @@ Or pass credentials explicitly:
80
88
 
81
89
  ```typescript
82
90
  const config = await getClientConfig({
83
- apiUrl: 'https://api.chaosity.cloud',
91
+ apiUrl: process.env.MY_API_URL!,
84
92
  clientId: process.env.MY_CLIENT_ID!,
85
93
  clientSecret: process.env.MY_SECRET!,
86
94
  })
@@ -96,10 +104,9 @@ import {
96
104
  type SuggestCommandOutput,
97
105
  } from '@chaosity/location-client'
98
106
 
99
- const client = new GeoPlacesClient({
100
- apiUrl: 'https://api.chaosity.cloud',
101
- token: 'your-bearer-token',
102
- })
107
+ // apiUrl and token as getLocationConfig() above returns them
108
+ const { apiUrl, token } = await getLocationConfig()
109
+ const client = new GeoPlacesClient({ apiUrl, token })
103
110
 
104
111
  const response: SuggestCommandOutput = await client.send(
105
112
  new SuggestCommand({
@@ -166,9 +173,9 @@ if (answer.verified) {
166
173
  because the service would drop `Language`, `PoliticalView` and
167
174
  `AdditionalFeatures`. A repeat verify of the same PlaceId may be answered
168
175
  from the service's own store, and is billed all the same.
169
- - Keep the PlaceId you sent beside the answer. The answer's own `PlaceId` can
170
- differ, and for a unit it does. The service does not accept that one back,
171
- while the one you sent verifies again.
176
+ - The answer's `PlaceId` is the one you sent, for a unit as for a building,
177
+ so a stored verification can be verified, or looked up with
178
+ `GetPlaceCommand`, again by its own `PlaceId`.
172
179
  - `client.verifyAddress(placeId)` is
173
180
  `client.send(new VerifyAddressCommand({ PlaceId: placeId }))`, typed as
174
181
  `VerifyAddressResponse`. `connector.verifyAddress` does the same on the
@@ -183,7 +190,12 @@ import {
183
190
  fetchMapStyle,
184
191
  createTransformRequest,
185
192
  } from '@chaosity/location-client'
186
- import maplibregl from 'maplibre-gl'
193
+ import * as maplibregl from 'maplibre-gl'
194
+ import 'maplibre-gl/dist/maplibre-gl.css'
195
+ // Vite. For other bundlers, see "The MapLibre worker" below.
196
+ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
197
+
198
+ maplibregl.setWorkerUrl(workerUrl)
187
199
 
188
200
  const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
189
201
  colorScheme: 'Dark',
@@ -209,6 +221,58 @@ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
209
221
  // then `maxPitch: 85` on the map, so the camera can tilt to see them
210
222
  ```
211
223
 
224
+ ### The MapLibre worker
225
+
226
+ MapLibre 6 loads and parses its tiles in a Web Worker, and it finds the
227
+ worker's file from its own module URL. A bundler rewrites that URL, so an
228
+ application built with one sets the worker's URL once, before the first map.
229
+ Without it the map mounts, draws no tile, and logs "Worker failed to load".
230
+
231
+ With Vite, import the worker's URL, as in the example above:
232
+
233
+ ```typescript
234
+ import * as maplibregl from 'maplibre-gl'
235
+ import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
236
+
237
+ maplibregl.setWorkerUrl(workerUrl)
238
+ ```
239
+
240
+ With Next.js, serve the worker and the chunk it imports from `public/`. Copy
241
+ them before every build and dev run:
242
+
243
+ ```js
244
+ // scripts/copy-maplibre-worker.mjs
245
+ import { copyFileSync, mkdirSync } from 'node:fs'
246
+ import { createRequire } from 'node:module'
247
+ import path from 'node:path'
248
+
249
+ const pkg = createRequire(import.meta.url).resolve('maplibre-gl/package.json')
250
+ const dist = path.join(path.dirname(pkg), 'dist')
251
+ const dest = path.join(process.cwd(), 'public', 'maplibre')
252
+ mkdirSync(dest, { recursive: true })
253
+ for (const file of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs']) {
254
+ copyFileSync(path.join(dist, file), path.join(dest, file))
255
+ }
256
+ ```
257
+
258
+ ```json
259
+ "scripts": {
260
+ "predev": "node scripts/copy-maplibre-worker.mjs",
261
+ "prebuild": "node scripts/copy-maplibre-worker.mjs"
262
+ }
263
+ ```
264
+
265
+ Then, in the client component that builds the map:
266
+
267
+ ```typescript
268
+ import * as maplibregl from 'maplibre-gl'
269
+
270
+ maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')
271
+ ```
272
+
273
+ Other bundlers, and loading MapLibre from a CDN, are covered in MapLibre's own
274
+ [installation guide](https://maplibre.org/maplibre-gl-js/docs/#installation).
275
+
212
276
  ### Switching Map Language
213
277
 
214
278
  Change map label language instantly on the client side — no API calls needed:
@@ -321,7 +385,8 @@ Requires the optional peers: `npm install maplibre-gl @maplibre/maplibre-gl-geoc
321
385
  ```typescript
322
386
  import { GeoPlacesClient, GeoPlaces } from '@chaosity/location-client'
323
387
  import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
324
- import maplibregl from 'maplibre-gl'
388
+ import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css'
389
+ import * as maplibregl from 'maplibre-gl'
325
390
 
326
391
  // GeoPlaces adapter takes a GeoPlacesClient instance and the map
327
392
  const client = new GeoPlacesClient({ apiUrl, token })
@@ -335,10 +400,11 @@ const geocoder = new MaplibreGeocoder(geoPlaces, {
335
400
 
336
401
  map.addControl(geocoder, 'top-left')
337
402
 
338
- // The geocoder calls getSuggestions → searchByPlaceId internally.
339
- // The 'result' event fires with the resolved place feature.
340
- geocoder.on('result', (event) => {
341
- console.log('Selected place:', event.result)
403
+ // Picking a suggestion calls searchByPlaceId, and the geocoder emits
404
+ // 'results' with the resolved place on `place`. It emits 'result' only when a
405
+ // typed query is geocoded (forwardGeocode) and one of its features is chosen.
406
+ geocoder.on('results', (event) => {
407
+ if (event.place) console.log('Selected place:', event.place[0])
342
408
  })
343
409
  ```
344
410
 
@@ -353,7 +419,7 @@ Client for executing AWS Location Service commands with Bearer token auth.
353
419
  ```typescript
354
420
  const client = new GeoPlacesClient({
355
421
  apiUrl: string,
356
- token: string,
422
+ token?: string, // at least one of token, getToken and refreshToken
357
423
  getToken?: () => string | undefined, // Optional: dynamic token getter
358
424
  refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
359
425
  })
@@ -389,6 +455,18 @@ nothing, and no retry is sent — a request that is going to fail again is not
389
455
  worth a second round trip. A 403 is never retried: a new token cannot fix an
390
456
  `Origin` the application does not allow.
391
457
 
458
+ **A refused token is not sent again for 30 seconds.** When the API refuses a
459
+ token and `refreshToken` cannot replace it — it rejects with a 401 or 403, or
460
+ returns the same token — the next sends with that token reject with the same
461
+ refusal without a request, and without asking `refreshToken`. A suspended
462
+ application is refused on every route and by its token route alike, so each
463
+ send used to cost a refused request and a refused refresh. A different token
464
+ from `getToken` ends the wait at once. A `refreshToken` that fails with a
465
+ `Retry-After` is held for that long instead. One that fails with nothing to
466
+ say about when to try again — a network fault, or a Server Action's error,
467
+ which reaches the browser without its fields — is asked again on the next
468
+ send, and the refused token is not sent before it.
469
+
392
470
  #### Request options
393
471
 
394
472
  Every call in this package takes the same options object — `client.send`,
@@ -438,6 +516,39 @@ produced one yet raises `InvalidCredentialsException` instead of putting
438
516
  trip spent to be told what you already know. `GeoPlacesClient` asks `refreshToken` first, so a
439
517
  client whose token simply has not arrived yet still works.
440
518
 
519
+ #### Errors
520
+
521
+ Every failure is a `LocationServiceException`. Its `code` is typed
522
+ `LocationServiceErrorCode`: every code the API documents at
523
+ [docs.chaosity.cloud/api/errors](https://docs.chaosity.cloud/api/errors), the
524
+ Amazon Location names it passes through, and the four this package raises for
525
+ a failure that never reached the API (`AbortedException`, `NetworkException`,
526
+ `ServiceException`, `UnknownCommandException`). A code the API adds later
527
+ arrives all the same, so treat an unknown one as you would its status.
528
+
529
+ ```typescript
530
+ import {
531
+ LocationServiceException,
532
+ type LocationServiceErrorCode,
533
+ } from '@chaosity/location-client'
534
+
535
+ try {
536
+ await client.send(command)
537
+ } catch (err) {
538
+ if (!(err instanceof LocationServiceException)) throw err
539
+ const code: LocationServiceErrorCode | (string & {}) = err.code
540
+ if (code === 'RateLimitExceededException') {
541
+ // this application's own rate: wait `err.retryAfterMs`
542
+ } else if (code === 'ApplicationNotActiveException') {
543
+ // new, suspended, or off its plan: see the application in the portal
544
+ }
545
+ }
546
+ ```
547
+
548
+ `message` is the API's own sentence. On `/auth/token` that is its
549
+ `error_description`, so a suspended application's refusal reads
550
+ `Application is not active`.
551
+
441
552
  #### GeoPlaces Adapter
442
553
 
443
554
  Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
@@ -475,13 +586,10 @@ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
475
586
  poiDensity: 'Sparse',
476
587
  language: 'fr',
477
588
  })
478
-
479
- const map = new maplibregl.Map({
480
- style,
481
- transformRequest: createTransformRequest(apiUrl, getToken),
482
- })
483
589
  ```
484
590
 
591
+ Hand `style` to `new maplibregl.Map`, with the worker set, as in [MapLibre Map Integration](#maplibre-map-integration).
592
+
485
593
  The overlays need the `terrain`, `buildings`, `contours`, `traffic` and `travel-modes` plan features — see [Plan features](#plan-features):
486
594
 
487
595
  ```typescript
@@ -594,21 +702,45 @@ import { VerifyAddressCommand } from '@chaosity/location-client'
594
702
 
595
703
  #### Data Type Utilities
596
704
 
597
- GeoJSON conversion utilities from `@aws/amazon-location-utilities-datatypes`:
705
+ GeoJSON converters from `@aws/amazon-location-utilities-datatypes`, one per
706
+ Places response that carries positions. A result with no position becomes no
707
+ feature:
708
+
709
+ | Converter | Takes the response of |
710
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
711
+ | `geocodeResponseToFeatureCollection` | `GeocodeCommand` |
712
+ | `reverseGeocodeResponseToFeatureCollection` | `ReverseGeocodeCommand` |
713
+ | `getPlaceResponseToFeatureCollection` | `GetPlaceCommand` |
714
+ | `suggestResponseToFeatureCollection` | `SuggestCommand` with `AdditionalFeatures: ['Core']`. Without it, Suggest results carry no position, and it returns none |
715
+ | `searchTextResponseToFeatureCollection` | `SearchTextCommand` |
716
+ | `searchNearbyResponseToFeatureCollection` | `SearchNearbyCommand` |
598
717
 
599
718
  ```typescript
600
719
  import {
601
- placeToFeatureCollection,
602
- routeToFeatureCollection,
603
- devicePositionsToFeatureCollection,
720
+ SearchTextCommand,
721
+ searchTextResponseToFeatureCollection,
722
+ type SearchTextCommandOutput,
604
723
  } from '@chaosity/location-client'
724
+
725
+ const response: SearchTextCommandOutput = await client.send(
726
+ new SearchTextCommand({
727
+ QueryText: 'coffee',
728
+ // SearchText takes exactly one of BiasPosition, Filter.BoundingBox or Filter.Circle.
729
+ BiasPosition: [-123.1207, 49.2827],
730
+ }),
731
+ )
732
+ const geojson = searchTextResponseToFeatureCollection(response)
605
733
  ```
606
734
 
735
+ Autocomplete has no converter: its results carry no position. The package
736
+ re-exports the rest of that package's converters too, but they take responses
737
+ from other Amazon Location APIs, which this service does not serve.
738
+
607
739
  ### Server Exports (`@chaosity/location-client/server`)
608
740
 
609
741
  #### getClientConfig
610
742
 
611
- Gets a client config with a fresh token. Uses a singleton `TokenProvider` internally — safe to call repeatedly (tokens are cached and refreshed automatically).
743
+ Gets a client config with a fresh token. It keeps one `TokenProvider` per application, for the applications used most recently, so it is safe to call repeatedly: tokens are cached and refreshed automatically.
612
744
 
613
745
  ```typescript
614
746
  import { getClientConfig } from '@chaosity/location-client/server'
@@ -621,6 +753,15 @@ const config = await getClientConfig()
621
753
  const replacement = await getClientConfig({ forceRefresh: true })
622
754
  ```
623
755
 
756
+ A refusal arrives with the API's code and sentence. Where the API refused the
757
+ credentials themselves, the message goes on to say which variables to check,
758
+ and names the client ID. An application that is not active reads
759
+ `Application is not active`, although once the API's authorizer has
760
+ refused it the sentence is the same as for a wrong secret, and the advice then
761
+ says to check both. A refusal is remembered for 30 seconds, and a
762
+ `Retry-After` for as long as it asks: calls in that time reject at once,
763
+ without asking `/auth/token` again.
764
+
624
765
  The return value is **plain data** — no methods, no closures — so it can be
625
766
  returned straight out of a Next.js Server Action to a Client Component. It is
626
767
  therefore a snapshot: the token in it stops working at its `expiresAt`, and the
@@ -677,8 +818,13 @@ which wins over both). `/auth/token` is the one endpoint exempt.
677
818
 
678
819
  A connector configured this way keeps working indefinitely: it holds a live
679
820
  token source, refreshes before expiry, and retries once with a new token if the
680
- API rejects the one it sent. Pass an explicit `token` instead and you opt out of
681
- all of that — it is a fixed string, and it dies at its own `exp`:
821
+ API rejects the one it sent. If the new token is refused too, as a suspended
822
+ application's is, the refusal is remembered for 30 seconds: sends in that time
823
+ reject with it at once, without a data request or a token request. A refresh
824
+ that fails with nothing to say about when to try again, a network fault for
825
+ one, is asked again on the next send, without the refused token first. Pass an
826
+ explicit `token` instead and you opt out of all of that — it is a fixed string,
827
+ and it dies at its own `exp`:
682
828
 
683
829
  ```typescript
684
830
  // Managing credentials yourself: an explicit token source wins outright, and
@@ -47,6 +47,26 @@ function toCarmenFeatures(features) {
47
47
  };
48
48
  });
49
49
  }
50
+ /**
51
+ * The geocoder's `bbox` — `[minX, minY, maxX, maxY]`, the order Amazon's
52
+ * `BoundingBox` takes too — or undefined when it has none, or not four
53
+ * numbers.
54
+ */
55
+ function boundingBox(bbox) {
56
+ return bbox?.length === 4 && bbox.every((v) => typeof v === 'number')
57
+ ? bbox
58
+ : undefined;
59
+ }
60
+ /**
61
+ * Whether `[x, y]` lies in `bbox`, edges included. A box whose `minX` is
62
+ * greater than its `maxX` crosses the antimeridian.
63
+ */
64
+ function insideBox([x, y], bbox) {
65
+ const [minX, minY, maxX, maxY] = bbox;
66
+ if (y < minY || y > maxY)
67
+ return false;
68
+ return minX <= maxX ? x >= minX && x <= maxX : x >= minX || x <= maxX;
69
+ }
50
70
  export class GeoPlaces {
51
71
  constructor(client, map, options = {}) {
52
72
  this.client = client;
@@ -101,9 +121,16 @@ export class GeoPlaces {
101
121
  const converted = geocodeResponseToFeatureCollection(response, {
102
122
  flattenProperties: true,
103
123
  });
124
+ // Geocode takes no box, so the geocoder's `bbox` is applied here, to what
125
+ // comes back (#59). It used to be ignored, and a result picked with Enter
126
+ // could land outside the box the integrator set.
127
+ const bbox = boundingBox(config.bbox);
128
+ const features = bbox
129
+ ? converted.features.filter((f) => insideBox(f.geometry.coordinates, bbox))
130
+ : converted.features;
104
131
  const result = {
105
132
  type: 'FeatureCollection',
106
- features: toCarmenFeatures(converted.features),
133
+ features: toCarmenFeatures(features),
107
134
  };
108
135
  log('forwardGeocode returned %d results', result.features.length);
109
136
  return result;
@@ -131,38 +158,47 @@ export class GeoPlaces {
131
158
  }
132
159
  async getSuggestions(config) {
133
160
  log('getSuggestions query=%s', config.query);
161
+ // Suggest takes exactly ONE of BiasPosition, Filter.BoundingBox and
162
+ // Filter.Circle, and refuses a request with two: 400 "Exactly one of the
163
+ // following fields must be set". This sent a bias always, and the box
164
+ // beside it whenever the geocoder carried one, so a `bbox` made every
165
+ // suggestion fail (#59). The box, when there is one, is the bias.
166
+ const bbox = boundingBox(config.bbox);
134
167
  const center = this.map.getCenter();
135
- const biasPosition = config.proximity && config.proximity.length >= 2
136
- ? [config.proximity[0], config.proximity[1]]
137
- : [center.lng, center.lat];
168
+ const biasPosition = bbox
169
+ ? undefined
170
+ : config.proximity && config.proximity.length >= 2
171
+ ? [config.proximity[0], config.proximity[1]]
172
+ : [center.lng, center.lat];
173
+ const countries = config.countries
174
+ ? Array.isArray(config.countries)
175
+ ? config.countries
176
+ : config.countries.split(',')
177
+ : undefined;
138
178
  const commandInput = {
139
179
  QueryText: config.query,
140
- BiasPosition: biasPosition,
180
+ ...(biasPosition ? { BiasPosition: biasPosition } : {}),
141
181
  MaxResults: config.limit || 5,
142
182
  Language: this.normalizeLanguage(config.language),
143
183
  // No AdditionalFeatures (#3 / T19).
144
184
  //
145
185
  // This used to send `[Core]`, which put every keystroke in the Core
146
- // bucket at $0.50/1k. The only thing Core adds to a Suggest response is
147
- // `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
148
- // nothing else. Verified against Amazon Location on 2026-08-25:
186
+ // bucket at $0.50/1k. What Core adds to a Suggest response is
187
+ // `Highlights` and the place's `Position` (without it,
188
+ // `suggestResponseToFeatureCollection` finds no feature: measured
189
+ // 2026-09-29), and this adapter reads neither — only `Title` and
190
+ // `Place.PlaceId`. Verified against Amazon Location on 2026-08-25:
149
191
  //
150
192
  // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
151
193
  // without -> bucket Label keys: Title, ..., Place
152
194
  //
153
195
  // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
154
196
  // keystroke, so it is the highest-volume call the library makes.
155
- ...(config.countries || config.bbox
197
+ ...(bbox || countries
156
198
  ? {
157
199
  Filter: {
158
- ...(config.countries
159
- ? {
160
- IncludeCountries: Array.isArray(config.countries)
161
- ? config.countries
162
- : config.countries.split(','),
163
- }
164
- : {}),
165
- ...(config.bbox ? { BoundingBox: config.bbox } : {}),
200
+ ...(bbox ? { BoundingBox: bbox } : {}),
201
+ ...(countries ? { IncludeCountries: countries } : {}),
166
202
  },
167
203
  }
168
204
  : {}),
@@ -42,6 +42,8 @@ export declare class TokenProvider {
42
42
  private cachedToken?;
43
43
  private cachedExpiresAt?;
44
44
  private tokenPromise?;
45
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
46
+ private readonly hold;
45
47
  constructor(config: TokenProviderConfig);
46
48
  getToken(forceRefresh?: boolean): Promise<TokenResponse>;
47
49
  /**
@@ -1,6 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException.js';
3
3
  import { requestJson } from '../transport/http.js';
4
+ import { TokenHold, isTokenRefusal } from './tokenHold.js';
4
5
  import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './tokenRefresh.js';
5
6
  const log = debug('location-client:auth');
6
7
  /**
@@ -33,6 +34,8 @@ const log = debug('location-client:auth');
33
34
  */
34
35
  export class TokenProvider {
35
36
  constructor(config) {
37
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
38
+ this.hold = new TokenHold();
36
39
  // Runtime check: prevent usage in browser
37
40
  if (typeof window !== 'undefined') {
38
41
  throw new Error('TokenProvider cannot be used in browser environments. ' +
@@ -54,6 +57,15 @@ export class TokenProvider {
54
57
  expiresAt: this.cachedExpiresAt,
55
58
  };
56
59
  }
60
+ // The endpoint refused these credentials, or asked us to wait, a moment
61
+ // ago: answer with that rather than ask again (#38). Forced or not — a
62
+ // forced refresh asks for a different token, and these credentials will
63
+ // not get one until the hold lapses.
64
+ const held = this.hold.check()?.error;
65
+ if (held) {
66
+ log('Token request held: %s', held.message);
67
+ throw held;
68
+ }
57
69
  // If token fetch is already in progress, wait for it
58
70
  if (this.tokenPromise) {
59
71
  log('Token fetch in progress, waiting for existing request...');
@@ -93,16 +105,32 @@ export class TokenProvider {
93
105
  async fetchToken() {
94
106
  const { clientId, clientSecret, apiUrl } = this.config;
95
107
  const credentials = btoa(`${clientId}:${clientSecret}`);
96
- const data = await requestJson(`${apiUrl}/auth/token`, {
97
- method: 'POST',
98
- headers: {
99
- Authorization: `Basic ${credentials}`,
100
- 'Content-Type': 'application/x-www-form-urlencoded',
101
- },
102
- body: new URLSearchParams({
103
- grant_type: 'client_credentials',
104
- }).toString(),
105
- }, { retry: { maxAttempts: 3 } });
108
+ let data;
109
+ try {
110
+ data = await requestJson(`${apiUrl}/auth/token`, {
111
+ method: 'POST',
112
+ headers: {
113
+ Authorization: `Basic ${credentials}`,
114
+ 'Content-Type': 'application/x-www-form-urlencoded',
115
+ },
116
+ body: new URLSearchParams({
117
+ grant_type: 'client_credentials',
118
+ }).toString(),
119
+ }, { retry: { maxAttempts: 3 } });
120
+ }
121
+ catch (error) {
122
+ // Remembered, so the next call is answered without a request (#38), and
123
+ // a Retry-After is honoured across calls rather than only within this
124
+ // one (#63).
125
+ this.hold.remember(error);
126
+ // A refusal is about the credentials, so every token they minted is
127
+ // refused too — a suspended application's on its next use, a rotated
128
+ // secret's at once. Never hand the cached one out again. A Retry-After
129
+ // says nothing about it, and keeps it.
130
+ if (isTokenRefusal(error))
131
+ this.clearCache();
132
+ throw error;
133
+ }
106
134
  if (!data.access_token) {
107
135
  throw new LocationServiceException({
108
136
  code: 'InvalidCredentialsException',
@@ -110,6 +138,7 @@ export class TokenProvider {
110
138
  details: { source: 'client' },
111
139
  });
112
140
  }
141
+ this.hold.forget();
113
142
  this.cachedToken = data.access_token;
114
143
  // The token's own `exp` claim first — it is the only value that cannot
115
144
  // disagree with what the API will actually accept. `expires_at` and
@@ -0,0 +1,72 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException.js';
2
+ /**
3
+ * How long a refusal is remembered: a token the API refused, or a token
4
+ * request `/auth/token` refused (#38).
5
+ *
6
+ * A refusal — a 401 or a 403 — is not something asking again can change. A
7
+ * suspended application is refused on every data route and on `/auth/token`
8
+ * alike, and nothing used to remember that, so every request a busy server
9
+ * served paid for a doomed data request and a doomed token request, all
10
+ * against the application's own token-route throttle.
11
+ *
12
+ * Thirty seconds bounds how long an application that has just been made
13
+ * active again waits for this side to notice: long enough to turn a request
14
+ * rate into a trickle, short enough to be a pause rather than an outage. It is
15
+ * also about how long a newly created application is refused while it goes
16
+ * live, which is the one refusal that clears itself.
17
+ */
18
+ export declare const TOKEN_REFUSAL_HOLD_MS = 30000;
19
+ /**
20
+ * A 401 or a 403: the server refused, and a retry gets the same answer.
21
+ *
22
+ * Wider than `isTokenRejected`, which is the 401 a new token can fix. This is
23
+ * about the token SOURCE: `/auth/token` refusing the credentials (401), or
24
+ * the application's key not live on its plan (403), and neither changes by
25
+ * asking again.
26
+ */
27
+ export declare function isTokenRefusal(err: unknown): boolean;
28
+ /**
29
+ * How long `err` says asking again cannot help, in milliseconds; 0 when it
30
+ * says nothing, and the next call may ask at once.
31
+ *
32
+ * Only the server's own word counts: a refusal, or a `Retry-After`. A network
33
+ * fault, a timeout or a 500 carries neither, so it is not remembered, and the
34
+ * next call tries again as it always has — the transport has already retried
35
+ * it with backoff inside the call that failed.
36
+ */
37
+ export declare function holdFor(err: unknown): number;
38
+ /**
39
+ * One remembered failure, re-thrown instead of asking again until it lapses.
40
+ *
41
+ * Shared by every place that used to ask again on every call: the server
42
+ * `TokenProvider` (a refused or throttled token request), and the two send
43
+ * paths (a token the API refused, and the refresh that could not replace it,
44
+ * or — in `GeoPlacesClient` — could not supply a first one). A send path
45
+ * remembers the failure against the token it concerns, because a DIFFERENT
46
+ * token is a new situation — a background refresh that landed, or a caller's
47
+ * own source that moved on — and ends the hold at once.
48
+ *
49
+ * `askAgain` is the one case where the source may still be asked: the API
50
+ * refused the token, and the refresh that followed failed with nothing to say
51
+ * about when to try again — a network fault, or a rejection that lost its
52
+ * fields crossing a Server Action boundary, as `@chaosity/location-client-react`
53
+ * delivers one. The token is still refused, so it is not sent again; the
54
+ * source is asked on the next send, as it always was.
55
+ */
56
+ export declare class TokenHold {
57
+ private held?;
58
+ /** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
59
+ remember(err: unknown, token?: string, { askAgain }?: {
60
+ askAgain?: boolean | undefined;
61
+ }): void;
62
+ /**
63
+ * The remembered failure while it stands, as a new exception to throw — for
64
+ * `token`, if one was remembered with it — and whether the source may still
65
+ * be asked. Anything else ends the hold.
66
+ */
67
+ check(token?: string): {
68
+ error: LocationServiceException;
69
+ askAgain: boolean;
70
+ } | undefined;
71
+ forget(): void;
72
+ }