@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.
- package/README.md +207 -40
- package/dist/adapters/GeoPlaces.d.ts +7 -1
- package/dist/adapters/GeoPlaces.js +64 -22
- package/dist/auth/TokenProvider.d.ts +24 -1
- package/dist/auth/TokenProvider.js +81 -12
- package/dist/auth/tokenHold.d.ts +81 -0
- package/dist/auth/tokenHold.js +174 -0
- package/dist/aws.d.ts +4 -0
- package/dist/aws.js +2 -0
- package/dist/cjs/adapters/GeoPlaces.d.ts +7 -1
- package/dist/cjs/adapters/GeoPlaces.js +63 -21
- package/dist/cjs/auth/TokenProvider.d.ts +24 -1
- package/dist/cjs/auth/TokenProvider.js +81 -12
- package/dist/cjs/auth/tokenHold.d.ts +81 -0
- package/dist/cjs/auth/tokenHold.js +181 -0
- package/dist/cjs/aws.d.ts +4 -0
- package/dist/cjs/aws.js +155 -0
- package/dist/cjs/client/GeoPlacesClient.d.ts +23 -3
- package/dist/cjs/client/GeoPlacesClient.js +50 -32
- package/dist/cjs/client/commands.d.ts +2 -3
- package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
- package/dist/cjs/errors/LocationServiceException.js +53 -1
- package/dist/cjs/index.d.ts +5 -4
- package/dist/cjs/index.js +13 -8
- package/dist/cjs/maps/createTransformRequest.d.ts +25 -0
- package/dist/cjs/maps/createTransformRequest.js +1 -0
- package/dist/cjs/maps/mapPoi.d.ts +10 -5
- package/dist/cjs/maps/mapPoi.js +14 -5
- package/dist/cjs/maps/mapStyle.d.ts +9 -2
- package/dist/cjs/maps/mapStyle.js +16 -12
- package/dist/cjs/maps/mapToken.d.ts +84 -0
- package/dist/cjs/maps/mapToken.js +126 -0
- package/dist/cjs/maps/staticMap.d.ts +7 -3
- package/dist/cjs/maps/staticMap.js +13 -12
- package/dist/cjs/server/LocationServiceConnector.d.ts +20 -3
- package/dist/cjs/server/LocationServiceConnector.js +20 -22
- package/dist/cjs/server/getClientConfig.d.ts +10 -5
- package/dist/cjs/server/getClientConfig.js +43 -19
- package/dist/cjs/server/index.d.ts +1 -1
- package/dist/cjs/transport/errors.d.ts +14 -8
- package/dist/cjs/transport/errors.js +26 -18
- package/dist/cjs/transport/http.d.ts +18 -0
- package/dist/cjs/transport/http.js +39 -1
- package/dist/cjs/types/index.d.ts +26 -0
- package/dist/client/GeoPlacesClient.d.ts +23 -3
- package/dist/client/GeoPlacesClient.js +52 -34
- package/dist/client/commands.d.ts +2 -3
- package/dist/errors/LocationServiceException.d.ts +30 -2
- package/dist/errors/LocationServiceException.js +52 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +11 -7
- package/dist/maps/createTransformRequest.d.ts +25 -0
- package/dist/maps/createTransformRequest.js +1 -1
- package/dist/maps/mapPoi.d.ts +10 -5
- package/dist/maps/mapPoi.js +14 -5
- package/dist/maps/mapStyle.d.ts +9 -2
- package/dist/maps/mapStyle.js +17 -13
- package/dist/maps/mapToken.d.ts +84 -0
- package/dist/maps/mapToken.js +122 -0
- package/dist/maps/staticMap.d.ts +7 -3
- package/dist/maps/staticMap.js +14 -13
- package/dist/server/LocationServiceConnector.d.ts +20 -3
- package/dist/server/LocationServiceConnector.js +22 -24
- package/dist/server/getClientConfig.d.ts +10 -5
- package/dist/server/getClientConfig.js +43 -19
- package/dist/server/index.d.ts +1 -1
- package/dist/transport/errors.d.ts +14 -8
- package/dist/transport/errors.js +27 -19
- package/dist/transport/http.d.ts +18 -0
- package/dist/transport/http.js +37 -1
- package/dist/types/index.d.ts +26 -0
- 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
|
-
- **
|
|
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**:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
663
|
-
|
|
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.
|
|
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.
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
136
|
-
?
|
|
137
|
-
:
|
|
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.
|
|
147
|
-
// `Highlights
|
|
148
|
-
//
|
|
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
|
-
...(
|
|
203
|
+
...(bbox || countries
|
|
156
204
|
? {
|
|
157
205
|
Filter: {
|
|
158
|
-
...(
|
|
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 =
|
|
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 =
|
|
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
|
}
|