@chaosity/location-client 0.11.0 → 0.13.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 (72) hide show
  1. package/README.md +207 -40
  2. package/dist/adapters/GeoPlaces.d.ts +7 -1
  3. package/dist/adapters/GeoPlaces.js +64 -22
  4. package/dist/auth/TokenProvider.d.ts +24 -1
  5. package/dist/auth/TokenProvider.js +81 -12
  6. package/dist/auth/tokenHold.d.ts +81 -0
  7. package/dist/auth/tokenHold.js +174 -0
  8. package/dist/aws.d.ts +4 -0
  9. package/dist/aws.js +2 -0
  10. package/dist/cjs/adapters/GeoPlaces.d.ts +7 -1
  11. package/dist/cjs/adapters/GeoPlaces.js +63 -21
  12. package/dist/cjs/auth/TokenProvider.d.ts +24 -1
  13. package/dist/cjs/auth/TokenProvider.js +81 -12
  14. package/dist/cjs/auth/tokenHold.d.ts +81 -0
  15. package/dist/cjs/auth/tokenHold.js +181 -0
  16. package/dist/cjs/aws.d.ts +4 -0
  17. package/dist/cjs/aws.js +155 -0
  18. package/dist/cjs/client/GeoPlacesClient.d.ts +23 -3
  19. package/dist/cjs/client/GeoPlacesClient.js +50 -32
  20. package/dist/cjs/client/commands.d.ts +2 -3
  21. package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
  22. package/dist/cjs/errors/LocationServiceException.js +53 -1
  23. package/dist/cjs/index.d.ts +5 -4
  24. package/dist/cjs/index.js +13 -8
  25. package/dist/cjs/maps/createTransformRequest.d.ts +25 -0
  26. package/dist/cjs/maps/createTransformRequest.js +1 -0
  27. package/dist/cjs/maps/mapPoi.d.ts +10 -5
  28. package/dist/cjs/maps/mapPoi.js +14 -5
  29. package/dist/cjs/maps/mapStyle.d.ts +9 -2
  30. package/dist/cjs/maps/mapStyle.js +16 -12
  31. package/dist/cjs/maps/mapToken.d.ts +84 -0
  32. package/dist/cjs/maps/mapToken.js +126 -0
  33. package/dist/cjs/maps/staticMap.d.ts +7 -3
  34. package/dist/cjs/maps/staticMap.js +13 -12
  35. package/dist/cjs/server/LocationServiceConnector.d.ts +20 -3
  36. package/dist/cjs/server/LocationServiceConnector.js +20 -22
  37. package/dist/cjs/server/getClientConfig.d.ts +10 -5
  38. package/dist/cjs/server/getClientConfig.js +43 -19
  39. package/dist/cjs/server/index.d.ts +1 -1
  40. package/dist/cjs/transport/errors.d.ts +14 -8
  41. package/dist/cjs/transport/errors.js +26 -18
  42. package/dist/cjs/transport/http.d.ts +18 -0
  43. package/dist/cjs/transport/http.js +39 -1
  44. package/dist/cjs/types/index.d.ts +26 -0
  45. package/dist/client/GeoPlacesClient.d.ts +23 -3
  46. package/dist/client/GeoPlacesClient.js +52 -34
  47. package/dist/client/commands.d.ts +2 -3
  48. package/dist/errors/LocationServiceException.d.ts +30 -2
  49. package/dist/errors/LocationServiceException.js +52 -0
  50. package/dist/index.d.ts +5 -4
  51. package/dist/index.js +11 -7
  52. package/dist/maps/createTransformRequest.d.ts +25 -0
  53. package/dist/maps/createTransformRequest.js +1 -1
  54. package/dist/maps/mapPoi.d.ts +10 -5
  55. package/dist/maps/mapPoi.js +14 -5
  56. package/dist/maps/mapStyle.d.ts +9 -2
  57. package/dist/maps/mapStyle.js +17 -13
  58. package/dist/maps/mapToken.d.ts +84 -0
  59. package/dist/maps/mapToken.js +122 -0
  60. package/dist/maps/staticMap.d.ts +7 -3
  61. package/dist/maps/staticMap.js +14 -13
  62. package/dist/server/LocationServiceConnector.d.ts +20 -3
  63. package/dist/server/LocationServiceConnector.js +22 -24
  64. package/dist/server/getClientConfig.d.ts +10 -5
  65. package/dist/server/getClientConfig.js +43 -19
  66. package/dist/server/index.d.ts +1 -1
  67. package/dist/transport/errors.d.ts +14 -8
  68. package/dist/transport/errors.js +27 -19
  69. package/dist/transport/http.d.ts +18 -0
  70. package/dist/transport/http.js +37 -1
  71. package/dist/types/index.d.ts +26 -0
  72. package/package.json +3 -3
package/README.md CHANGED
@@ -22,6 +22,9 @@ AWS Location Service compatible client with custom Bearer token authentication.
22
22
  npm install @chaosity/location-client
23
23
  ```
24
24
 
25
+ TypeScript users need TypeScript 5.0 or later: the declarations use
26
+ `export type *`.
27
+
25
28
  The Places commands, the transport and the static-map and style-URL helpers need
26
29
  nothing else. The **map** features are optional peer dependencies, so install
27
30
  them only if you use them:
@@ -43,14 +46,15 @@ its worker set up once under a bundler: see [The MapLibre worker](#the-maplibre-
43
46
  ## Key Features
44
47
 
45
48
  - **Custom Authentication**: Uses Bearer tokens instead of AWS SigV4
46
- - **AWS SDK Commands**: Full access to all AWS Location Service commands
49
+ - **Places Commands**: the seven Amazon Location Places commands from `@aws-sdk/client-geo-places`, plus `VerifyAddressCommand`
47
50
  - **Address Verification**: `verifyAddress(placeId)` returns the one Places result you may store — see [Verifying an address](#verifying-an-address)
48
- - **Data Type Utilities**: Built-in GeoJSON conversion utilities
51
+ - **Data Type Utilities**: GeoJSON converters for the Places responses
49
52
  - **MapLibre Integration**: Adapter for MapLibre GL Geocoder and `createTransformRequest` helper
50
53
  - **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)
51
54
  - **Map Language**: Switch map label language client-side with zero API calls
52
55
  - **POI Layer Control**: Toggle point-of-interest categories on/off by layer
53
56
  - **Server Utilities**: `getClientConfig()` with auto-env detection and token caching
57
+ - **Typed Errors**: every failure is a `LocationServiceException` whose `code` is a `LocationServiceErrorCode`
54
58
 
55
59
  ## Quick Start
56
60
 
@@ -73,7 +77,8 @@ export async function getLocationConfig() {
73
77
  Set these environment variables:
74
78
 
75
79
  ```bash
76
- LOCATION_API_URL=https://api.chaosity.cloud
80
+ # The API URL on your application's page in the developer portal
81
+ LOCATION_API_URL=https://your-api-url.example
77
82
  LOCATION_CLIENT_ID=your-client-id
78
83
  LOCATION_CLIENT_SECRET=your-client-secret
79
84
 
@@ -86,7 +91,7 @@ Or pass credentials explicitly:
86
91
 
87
92
  ```typescript
88
93
  const config = await getClientConfig({
89
- apiUrl: 'https://api.chaosity.cloud',
94
+ apiUrl: process.env.MY_API_URL!,
90
95
  clientId: process.env.MY_CLIENT_ID!,
91
96
  clientSecret: process.env.MY_SECRET!,
92
97
  })
@@ -96,18 +101,13 @@ const config = await getClientConfig({
96
101
  ### Client-Side: Using the GeoPlacesClient
97
102
 
98
103
  ```typescript
99
- import {
100
- GeoPlacesClient,
101
- SuggestCommand,
102
- type SuggestCommandOutput,
103
- } from '@chaosity/location-client'
104
+ import { GeoPlacesClient, SuggestCommand } from '@chaosity/location-client'
104
105
 
105
- const client = new GeoPlacesClient({
106
- apiUrl: 'https://api.chaosity.cloud',
107
- token: 'your-bearer-token',
108
- })
106
+ // apiUrl and token as getLocationConfig() above returns them
107
+ const { apiUrl, token } = await getLocationConfig()
108
+ const client = new GeoPlacesClient({ apiUrl, token })
109
109
 
110
- const response: SuggestCommandOutput = await client.send(
110
+ const response = await client.send(
111
111
  new SuggestCommand({
112
112
  QueryText: 'Vancouver',
113
113
  MaxResults: 5,
@@ -115,8 +115,14 @@ const response: SuggestCommandOutput = await client.send(
115
115
  BiasPosition: [-123.1207, 49.2827],
116
116
  }),
117
117
  )
118
+ response.ResultItems
118
119
  ```
119
120
 
121
+ `send` resolves with the command's own output type — `SuggestCommandOutput`
122
+ here — so there is nothing to annotate, on `GeoPlacesClient` and
123
+ `LocationServiceConnector` alike (since 0.13.0). `CommandOutput<C>` names it
124
+ when you need it.
125
+
120
126
  ### Verifying an address
121
127
 
122
128
  `POST /address/verify` resolves one PlaceId to the full place record plus
@@ -128,15 +134,10 @@ Japan, which may not be stored at all.
128
134
  The flow is suggest, then an optional unit pick, then verify at submit:
129
135
 
130
136
  ```typescript
131
- import {
132
- GetPlaceCommand,
133
- SuggestCommand,
134
- type GetPlaceCommandOutput,
135
- type SuggestCommandOutput,
136
- } from '@chaosity/location-client'
137
+ import { GetPlaceCommand, SuggestCommand } from '@chaosity/location-client'
137
138
 
138
139
  // 1. While the person types: suggestions, for display.
139
- const suggestions: SuggestCommandOutput = await client.send(
140
+ const suggestions = await client.send(
140
141
  // Suggest takes exactly one of BiasPosition, Filter.BoundingBox or Filter.Circle.
141
142
  new SuggestCommand({
142
143
  QueryText: '100 George St, Sydney',
@@ -146,7 +147,7 @@ const suggestions: SuggestCommandOutput = await client.send(
146
147
  const picked = suggestions.ResultItems?.[0]?.Place?.PlaceId
147
148
 
148
149
  // 2. Optionally, offer the building's units. Each has its own PlaceId.
149
- const building: GetPlaceCommandOutput = await client.send(
150
+ const building = await client.send(
150
151
  new GetPlaceCommand({
151
152
  PlaceId: picked!,
152
153
  AdditionalFeatures: ['SecondaryAddresses'],
@@ -172,9 +173,9 @@ if (answer.verified) {
172
173
  because the service would drop `Language`, `PoliticalView` and
173
174
  `AdditionalFeatures`. A repeat verify of the same PlaceId may be answered
174
175
  from the service's own store, and is billed all the same.
175
- - Keep the PlaceId you sent beside the answer. The answer's own `PlaceId` can
176
- differ, and for a unit it does. The service does not accept that one back,
177
- 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`.
178
179
  - `client.verifyAddress(placeId)` is
179
180
  `client.send(new VerifyAddressCommand({ PlaceId: placeId }))`, typed as
180
181
  `VerifyAddressResponse`. `connector.verifyAddress` does the same on the
@@ -220,6 +221,41 @@ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
220
221
  // then `maxPitch: 85` on the map, so the camera can tilt to see them
221
222
  ```
222
223
 
224
+ #### When the API refuses the map's token
225
+
226
+ `getToken` is synchronous, so it can only hand over the token already in hand.
227
+ For when the API refuses that token before its `exp` — after the application's
228
+ secret is rotated, for one — pass `{ getToken, refreshToken }` where the map
229
+ helpers take `getToken`, and add `refreshTokenOnUnauthorized` for the tiles
230
+ MapLibre fetches itself:
231
+
232
+ ```typescript
233
+ import {
234
+ fetchMapStyle,
235
+ refreshTokenOnUnauthorized,
236
+ } from '@chaosity/location-client'
237
+
238
+ // refreshToken obtains a new token, and makes getToken return it from then on.
239
+ const tokens = { getToken, refreshToken }
240
+
241
+ const style = await fetchMapStyle(apiUrl, 'Standard', tokens)
242
+ // ...the map as above, with createTransformRequest(apiUrl, getToken)...
243
+ const stop = refreshTokenOnUnauthorized(map, apiUrl, tokens)
244
+ ```
245
+
246
+ `fetchMapStyle` and `fetchStaticMap` then answer a 401 by asking `refreshToken`
247
+ once and sending again with the new token, within the same `signal` and
248
+ `overallTimeoutMs`. `refreshTokenOnUnauthorized` listens for the map's `error`
249
+ events: on a 401 from your API it asks `refreshToken` once for the burst, and
250
+ reloads the refused tiles when `getToken` returns a different token. The
251
+ tiles receive the new token no other way — they read `getToken` through
252
+ `createTransformRequest` — so a `refreshToken` that leaves `getToken` unchanged
253
+ reloads nothing. A 403 is never retried: a new token cannot change it. A token
254
+ `refreshToken` could not replace is not asked about again for 30 seconds, by
255
+ any helper handed the same `tokens` object. `stop()` removes the listener.
256
+
257
+ A bare `getToken` works as it always has: one request, and its 401 to you.
258
+
223
259
  ### The MapLibre worker
224
260
 
225
261
  MapLibre 6 loads and parses its tiles in a Web Worker, and it finds the
@@ -399,10 +435,11 @@ const geocoder = new MaplibreGeocoder(geoPlaces, {
399
435
 
400
436
  map.addControl(geocoder, 'top-left')
401
437
 
402
- // The geocoder calls getSuggestions → searchByPlaceId internally.
403
- // The 'result' event fires with the resolved place feature.
404
- geocoder.on('result', (event) => {
405
- console.log('Selected place:', event.result)
438
+ // Picking a suggestion calls searchByPlaceId, and the geocoder emits
439
+ // 'results' with the resolved place on `place`. It emits 'result' only when a
440
+ // typed query is geocoded (forwardGeocode) and one of its features is chosen.
441
+ geocoder.on('results', (event) => {
442
+ if (event.place) console.log('Selected place:', event.place[0])
406
443
  })
407
444
  ```
408
445
 
@@ -417,7 +454,7 @@ Client for executing AWS Location Service commands with Bearer token auth.
417
454
  ```typescript
418
455
  const client = new GeoPlacesClient({
419
456
  apiUrl: string,
420
- token: string,
457
+ token?: string, // at least one of token, getToken and refreshToken
421
458
  getToken?: () => string | undefined, // Optional: dynamic token getter
422
459
  refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
423
460
  })
@@ -453,6 +490,21 @@ nothing, and no retry is sent — a request that is going to fail again is not
453
490
  worth a second round trip. A 403 is never retried: a new token cannot fix an
454
491
  `Origin` the application does not allow.
455
492
 
493
+ **A refused token is not sent again for 30 seconds.** When the API refuses a
494
+ token and `refreshToken` cannot replace it — it rejects with a 401 or 403, or
495
+ returns the same token — the next sends with that token reject with the same
496
+ refusal without a request, and without asking `refreshToken`. A rotated secret
497
+ is refused by the data route and, through a `refreshToken` that holds the old
498
+ secret, by the token route too, so each send used to cost a refused request
499
+ and a refused refresh. A suspended application is refused 403
500
+ `ApplicationNotActiveException`, which is never retried, so it is not held
501
+ here: each send has the API's answer. A different token from `getToken` ends
502
+ the wait at once. A `refreshToken` that fails with a
503
+ `Retry-After` is held for that long instead. One that fails with nothing to
504
+ say about when to try again — a network fault, or a Server Action's error,
505
+ which reaches the browser without its fields — is asked again on the next
506
+ send, and the refused token is not sent before it.
507
+
456
508
  #### Request options
457
509
 
458
510
  Every call in this package takes the same options object — `client.send`,
@@ -463,7 +515,7 @@ Every call in this package takes the same options object — `client.send`,
463
515
  await client.send(command, {
464
516
  signal, // AbortSignal — cancels mid-flight AND mid-backoff
465
517
  timeoutMs: 10_000, // per ATTEMPT
466
- overallTimeoutMs: 30_000, // the whole call, waits between attempts included
518
+ overallTimeoutMs: 30_000, // the whole call: the wait for a token and between attempts
467
519
  retry: { maxAttempts: 3 }, // or `false` for none
468
520
  })
469
521
  ```
@@ -502,6 +554,39 @@ produced one yet raises `InvalidCredentialsException` instead of putting
502
554
  trip spent to be told what you already know. `GeoPlacesClient` asks `refreshToken` first, so a
503
555
  client whose token simply has not arrived yet still works.
504
556
 
557
+ #### Errors
558
+
559
+ Every failure is a `LocationServiceException`. Its `code` is typed
560
+ `LocationServiceErrorCode`: every code the API documents at
561
+ [docs.chaosity.cloud/api/errors](https://docs.chaosity.cloud/api/errors), the
562
+ Amazon Location names it passes through, and the four this package raises for
563
+ a failure that never reached the API (`AbortedException`, `NetworkException`,
564
+ `ServiceException`, `UnknownCommandException`). A code the API adds later
565
+ arrives all the same, so treat an unknown one as you would its status.
566
+
567
+ ```typescript
568
+ import {
569
+ LocationServiceException,
570
+ type LocationServiceErrorCode,
571
+ } from '@chaosity/location-client'
572
+
573
+ try {
574
+ await client.send(command)
575
+ } catch (err) {
576
+ if (!(err instanceof LocationServiceException)) throw err
577
+ const code: LocationServiceErrorCode | (string & {}) = err.code
578
+ if (code === 'RateLimitExceededException') {
579
+ // this application's own rate: wait `err.retryAfterMs`
580
+ } else if (code === 'ApplicationNotActiveException') {
581
+ // new, suspended, or off its plan: see the application in the portal
582
+ }
583
+ }
584
+ ```
585
+
586
+ `message` is the API's own sentence — on `/auth/token`, its
587
+ `error_description` — so a suspended application's refusal names that cause
588
+ and points to the portal.
589
+
505
590
  #### GeoPlaces Adapter
506
591
 
507
592
  Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
@@ -527,6 +612,13 @@ import { createTransformRequest } from '@chaosity/location-client'
527
612
  const transformRequest = createTransformRequest(apiUrl, () => currentToken)
528
613
  ```
529
614
 
615
+ #### refreshTokenOnUnauthorized
616
+
617
+ `refreshTokenOnUnauthorized(map, apiUrl, { getToken, refreshToken })` reloads
618
+ the tiles the API refused once `refreshToken` has given `getToken` a new token,
619
+ and returns a function that stops listening. See
620
+ [When the API refuses the map's token](#when-the-api-refuses-the-maps-token).
621
+
530
622
  #### fetchMapStyle
531
623
 
532
624
  Fetches the map style descriptor with Bearer auth and applies optional language to the descriptor JSON before MapLibre processes it (eliminates the visual flash that occurs when modifying layers post-load).
@@ -542,6 +634,9 @@ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
542
634
  ```
543
635
 
544
636
  Hand `style` to `new maplibregl.Map`, with the worker set, as in [MapLibre Map Integration](#maplibre-map-integration).
637
+ Pass `{ getToken, refreshToken }` in place of `getToken` to recover from a
638
+ refused token, as [When the API refuses the map's token](#when-the-api-refuses-the-maps-token)
639
+ shows; `fetchStaticMap` takes the same.
545
640
 
546
641
  The overlays need the `terrain`, `buildings`, `contours`, `traffic` and `travel-modes` plan features — see [Plan features](#plan-features):
547
642
 
@@ -655,21 +750,44 @@ import { VerifyAddressCommand } from '@chaosity/location-client'
655
750
 
656
751
  #### Data Type Utilities
657
752
 
658
- GeoJSON conversion utilities from `@aws/amazon-location-utilities-datatypes`:
753
+ GeoJSON converters from `@aws/amazon-location-utilities-datatypes`, one per
754
+ Places response that carries positions. A result with no position becomes no
755
+ feature:
756
+
757
+ | Converter | Takes the response of |
758
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
759
+ | `geocodeResponseToFeatureCollection` | `GeocodeCommand` |
760
+ | `reverseGeocodeResponseToFeatureCollection` | `ReverseGeocodeCommand` |
761
+ | `getPlaceResponseToFeatureCollection` | `GetPlaceCommand` |
762
+ | `suggestResponseToFeatureCollection` | `SuggestCommand` with `AdditionalFeatures: ['Core']`. Without it, Suggest results carry no position, and it returns none |
763
+ | `searchTextResponseToFeatureCollection` | `SearchTextCommand` |
764
+ | `searchNearbyResponseToFeatureCollection` | `SearchNearbyCommand` |
659
765
 
660
766
  ```typescript
661
767
  import {
662
- placeToFeatureCollection,
663
- routeToFeatureCollection,
664
- devicePositionsToFeatureCollection,
768
+ SearchTextCommand,
769
+ searchTextResponseToFeatureCollection,
665
770
  } from '@chaosity/location-client'
771
+
772
+ const response = await client.send(
773
+ new SearchTextCommand({
774
+ QueryText: 'coffee',
775
+ // SearchText takes exactly one of BiasPosition, Filter.BoundingBox or Filter.Circle.
776
+ BiasPosition: [-123.1207, 49.2827],
777
+ }),
778
+ )
779
+ const geojson = searchTextResponseToFeatureCollection(response)
666
780
  ```
667
781
 
782
+ Autocomplete has no converter: its results carry no position. The package
783
+ re-exports the rest of that package's converters too, but they take responses
784
+ from other Amazon Location APIs, which this service does not serve.
785
+
668
786
  ### Server Exports (`@chaosity/location-client/server`)
669
787
 
670
788
  #### getClientConfig
671
789
 
672
- Gets a client config with a fresh token. Uses a singleton `TokenProvider` internally — safe to call repeatedly (tokens are cached and refreshed automatically).
790
+ 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.
673
791
 
674
792
  ```typescript
675
793
  import { getClientConfig } from '@chaosity/location-client/server'
@@ -682,6 +800,36 @@ const config = await getClientConfig()
682
800
  const replacement = await getClientConfig({ forceRefresh: true })
683
801
  ```
684
802
 
803
+ A refusal arrives with the API's code and sentence. Where the API refused the
804
+ credentials themselves, a 401, the message goes on to say which variables to
805
+ check, and names the client ID. An application that is not active is refused
806
+ 403 `ApplicationNotActiveException`, in a sentence that names that cause, and
807
+ gets no advice added. A refusal is remembered for 30 seconds, and a
808
+ `Retry-After` for as long as it asks: calls in that time reject at once,
809
+ without asking `/auth/token` again.
810
+
811
+ When the token goes to a browser, the browser is what sees it refused, and
812
+ only it can say so. `@chaosity/location-client-react`'s provider asks its
813
+ `getConfig` again with `{ refusedToken }` after a 401. Answer it by replacing
814
+ the cached token only when it is the one refused:
815
+
816
+ ```typescript
817
+ 'use server'
818
+ import { getClientConfig } from '@chaosity/location-client/server'
819
+
820
+ export async function getLocationConfig(request?: { refusedToken?: string }) {
821
+ const config = await getClientConfig()
822
+ return request?.refusedToken === config.token
823
+ ? getClientConfig({ forceRefresh: true })
824
+ : config
825
+ }
826
+ ```
827
+
828
+ Asked again without it, `getClientConfig()` hands back the token it caches:
829
+ the one just refused. The check mints once per refused token, and nothing for a
830
+ report of any other. It does not stop a caller echoing the token it was just
831
+ given to make the server mint again; rate-limit the endpoint if that matters.
832
+
685
833
  The return value is **plain data** — no methods, no closures — so it can be
686
834
  returned straight out of a Next.js Server Action to a Client Component. It is
687
835
  therefore a snapshot: the token in it stops working at its `expiresAt`, and the
@@ -738,8 +886,25 @@ which wins over both). `/auth/token` is the one endpoint exempt.
738
886
 
739
887
  A connector configured this way keeps working indefinitely: it holds a live
740
888
  token source, refreshes before expiry, and retries once with a new token if the
741
- API rejects the one it sent. Pass an explicit `token` instead and you opt out of
742
- all of that — it is a fixed string, and it dies at its own `exp`:
889
+ API rejects the one it sent. When that refresh before expiry fails with anything
890
+ but a refusal — a 429 or 503 from the token route, or a network fault — it keeps
891
+ sending the token it has until 5 seconds before that token's own `exp`, and asks
892
+ the token route nothing more while a `Retry-After` stands; past that it rejects
893
+ with the API's error, `retryAfterMs` intact. A `getToken` you supply is asked
894
+ for this with `{ cachedUntilExpiry: true }`, which `TokenProvider.getToken`
895
+ understands. `getClientConfig` never does it: a token that close to its expiry
896
+ is one a browser provider would ask to replace at once.
897
+
898
+ After the API rejects a token, if no new token can be had, or the new one is
899
+ refused too — a rotated secret's refresh is refused, for one — the refusal is
900
+ remembered for 30 seconds: sends in that time reject with it at once, without a
901
+ data request or a token request. A suspended application is refused 403 on
902
+ every route, which no new token can change: its data requests are not retried,
903
+ and a token request's refusal is remembered the same way. A refresh that fails
904
+ with nothing to say about when to try again, a network fault for one, is asked
905
+ again on the next send, without the refused token first. Pass an
906
+ explicit `token` instead and you opt out of all of that — it is a fixed string,
907
+ and it dies at its own `exp`:
743
908
 
744
909
  ```typescript
745
910
  // Managing credentials yourself: an explicit token source wins outright, and
@@ -747,7 +912,7 @@ all of that — it is a fixed string, and it dies at its own `exp`:
747
912
  const connector = new LocationServiceConnector({
748
913
  apiUrl,
749
914
  origin: 'https://your-allowed-domain.example',
750
- getToken: (forceRefresh) => provider.getToken(forceRefresh),
915
+ getToken: (forceRefresh, options) => provider.getToken(forceRefresh, options),
751
916
  })
752
917
  ```
753
918
 
@@ -793,7 +958,9 @@ DEBUG=location-client:api npm run dev
793
958
 
794
959
  ## TypeScript Support
795
960
 
796
- Full TypeScript support with types from AWS SDK:
961
+ The request and response types are the AWS SDK's, and `send` resolves with the
962
+ command's own output type. To name it — for a variable declared before the
963
+ call, say — use the SDK's `…CommandOutput` or `CommandOutput<C>`:
797
964
 
798
965
  ```typescript
799
966
  import type { SuggestCommandOutput } from '@chaosity/location-client'
@@ -74,7 +74,13 @@ export declare class GeoPlaces implements MaplibreGeocoderApi {
74
74
  private client;
75
75
  private map;
76
76
  private details;
77
- constructor(client: GeoPlacesClient, map: Map, options?: GeoPlacesOptions);
77
+ /**
78
+ * @param client Anything with the client's `send` — the adapter calls
79
+ * nothing else (#65). `@chaosity/location-client-react`'s
80
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
81
+ * and a class with private fields admits no other object.
82
+ */
83
+ constructor(client: Pick<GeoPlacesClient, 'send'>, map: Map, options?: GeoPlacesOptions);
78
84
  /**
79
85
  * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
80
86
  *
@@ -1,4 +1,4 @@
1
- import { GetPlaceAdditionalFeature, } from '@aws-sdk/client-geo-places';
1
+ import { GetPlaceAdditionalFeature } from '@aws-sdk/client-geo-places';
2
2
  import { geocodeResponseToFeatureCollection, getPlaceResponseToFeatureCollection, reverseGeocodeResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
3
3
  import debug from 'debug';
4
4
  import {
@@ -47,7 +47,33 @@ 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 {
71
+ /**
72
+ * @param client Anything with the client's `send` — the adapter calls
73
+ * nothing else (#65). `@chaosity/location-client-react`'s
74
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
75
+ * and a class with private fields admits no other object.
76
+ */
51
77
  constructor(client, map, options = {}) {
52
78
  this.client = client;
53
79
  this.map = map;
@@ -97,13 +123,20 @@ export class GeoPlaces {
97
123
  : config.countries.split(','),
98
124
  };
99
125
  }
100
- const response = (await this.client.send(new GeocodeCommand(commandInput)));
126
+ const response = await this.client.send(new GeocodeCommand(commandInput));
101
127
  const converted = geocodeResponseToFeatureCollection(response, {
102
128
  flattenProperties: true,
103
129
  });
130
+ // Geocode takes no box, so the geocoder's `bbox` is applied here, to what
131
+ // comes back (#59). It used to be ignored, and a result picked with Enter
132
+ // could land outside the box the integrator set.
133
+ const bbox = boundingBox(config.bbox);
134
+ const features = bbox
135
+ ? converted.features.filter((f) => insideBox(f.geometry.coordinates, bbox))
136
+ : converted.features;
104
137
  const result = {
105
138
  type: 'FeatureCollection',
106
- features: toCarmenFeatures(converted.features),
139
+ features: toCarmenFeatures(features),
107
140
  };
108
141
  log('forwardGeocode returned %d results', result.features.length);
109
142
  return result;
@@ -118,7 +151,7 @@ export class GeoPlaces {
118
151
  MaxResults: config.limit || 1,
119
152
  Language: this.normalizeLanguage(config.language),
120
153
  };
121
- const response = (await this.client.send(new ReverseGeocodeCommand(commandInput)));
154
+ const response = await this.client.send(new ReverseGeocodeCommand(commandInput));
122
155
  const converted = reverseGeocodeResponseToFeatureCollection(response, {
123
156
  flattenProperties: true,
124
157
  });
@@ -131,43 +164,52 @@ export class GeoPlaces {
131
164
  }
132
165
  async getSuggestions(config) {
133
166
  log('getSuggestions query=%s', config.query);
167
+ // Suggest takes exactly ONE of BiasPosition, Filter.BoundingBox and
168
+ // Filter.Circle, and refuses a request with two: 400 "Exactly one of the
169
+ // following fields must be set". This sent a bias always, and the box
170
+ // beside it whenever the geocoder carried one, so a `bbox` made every
171
+ // suggestion fail (#59). The box, when there is one, is the bias.
172
+ const bbox = boundingBox(config.bbox);
134
173
  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];
174
+ const biasPosition = bbox
175
+ ? undefined
176
+ : config.proximity && config.proximity.length >= 2
177
+ ? [config.proximity[0], config.proximity[1]]
178
+ : [center.lng, center.lat];
179
+ const countries = config.countries
180
+ ? Array.isArray(config.countries)
181
+ ? config.countries
182
+ : config.countries.split(',')
183
+ : undefined;
138
184
  const commandInput = {
139
185
  QueryText: config.query,
140
- BiasPosition: biasPosition,
186
+ ...(biasPosition ? { BiasPosition: biasPosition } : {}),
141
187
  MaxResults: config.limit || 5,
142
188
  Language: this.normalizeLanguage(config.language),
143
189
  // No AdditionalFeatures (#3 / T19).
144
190
  //
145
191
  // 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:
192
+ // bucket at $0.50/1k. What Core adds to a Suggest response is
193
+ // `Highlights` and the place's `Position` (without it,
194
+ // `suggestResponseToFeatureCollection` finds no feature: measured
195
+ // 2026-09-29), and this adapter reads neither — only `Title` and
196
+ // `Place.PlaceId`. Verified against Amazon Location on 2026-08-25:
149
197
  //
150
198
  // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
151
199
  // without -> bucket Label keys: Title, ..., Place
152
200
  //
153
201
  // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
154
202
  // keystroke, so it is the highest-volume call the library makes.
155
- ...(config.countries || config.bbox
203
+ ...(bbox || countries
156
204
  ? {
157
205
  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 } : {}),
206
+ ...(bbox ? { BoundingBox: bbox } : {}),
207
+ ...(countries ? { IncludeCountries: countries } : {}),
166
208
  },
167
209
  }
168
210
  : {}),
169
211
  };
170
- const response = (await this.client.send(new SuggestCommand(commandInput)));
212
+ const response = await this.client.send(new SuggestCommand(commandInput));
171
213
  const suggestions = { suggestions: [] };
172
214
  for (const item of response.ResultItems ?? []) {
173
215
  const text = item.Title;
@@ -190,7 +232,7 @@ export class GeoPlaces {
190
232
  Language: this.normalizeLanguage(config.language),
191
233
  ...(additionalFeatures ? { AdditionalFeatures: additionalFeatures } : {}),
192
234
  });
193
- const response = (await this.client.send(command));
235
+ const response = await this.client.send(command);
194
236
  const result = getPlaceResponseToFeatureCollection(response, {
195
237
  flattenProperties: true,
196
238
  });
@@ -4,6 +4,21 @@ export interface TokenResponse {
4
4
  expiresAt?: number;
5
5
  error?: string;
6
6
  }
7
+ export interface GetTokenOptions {
8
+ /**
9
+ * When a refresh fails with an error that waiting can fix — a 429 or 503
10
+ * from `/auth/token`, a network fault, or the `Retry-After` such a failure
11
+ * left standing — return the cached token instead, while it is before its
12
+ * own `exp` (#63). A refusal (401 or 403) still clears the cache and throws,
13
+ * and a forced refresh never falls back: it is asked for because the API
14
+ * refused the cached token.
15
+ *
16
+ * For server-side dispatch only. Never set it where the token is handed to
17
+ * a browser: a token inside `TOKEN_REFRESH_BUFFER_SECONDS` is one the React
18
+ * provider asks to replace at once, so `getClientConfig` does not.
19
+ */
20
+ cachedUntilExpiry?: boolean;
21
+ }
7
22
  export interface TokenProviderConfig {
8
23
  apiUrl: string;
9
24
  clientId: string;
@@ -42,8 +57,10 @@ export declare class TokenProvider {
42
57
  private cachedToken?;
43
58
  private cachedExpiresAt?;
44
59
  private tokenPromise?;
60
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
61
+ private readonly hold;
45
62
  constructor(config: TokenProviderConfig);
46
- getToken(forceRefresh?: boolean): Promise<TokenResponse>;
63
+ getToken(forceRefresh?: boolean, options?: GetTokenOptions): Promise<TokenResponse>;
47
64
  /**
48
65
  * Fetch a token, distinguishing transient failure from terminal (#9).
49
66
  *
@@ -59,6 +76,12 @@ export declare class TokenProvider {
59
76
  * with `success: false`, so a stale token can never be used by accident.
60
77
  */
61
78
  private fetchToken;
79
+ /**
80
+ * #63: the cached token, when the caller asked for it and the failure is one
81
+ * that waiting fixes. A refusal has already cleared the cache in fetchToken,
82
+ * so it can never be the cached token handed back here.
83
+ */
84
+ private fallback;
62
85
  private isExpired;
63
86
  clearCache(): void;
64
87
  }