@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.
- package/README.md +176 -30
- package/dist/adapters/GeoPlaces.js +53 -17
- package/dist/auth/TokenProvider.d.ts +2 -0
- package/dist/auth/TokenProvider.js +39 -10
- package/dist/auth/tokenHold.d.ts +72 -0
- package/dist/auth/tokenHold.js +109 -0
- package/dist/cjs/adapters/GeoPlaces.js +53 -17
- package/dist/cjs/auth/TokenProvider.d.ts +2 -0
- package/dist/cjs/auth/TokenProvider.js +39 -10
- package/dist/cjs/auth/tokenHold.d.ts +72 -0
- package/dist/cjs/auth/tokenHold.js +115 -0
- package/dist/cjs/client/GeoPlacesClient.d.ts +6 -2
- package/dist/cjs/client/GeoPlacesClient.js +83 -21
- 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 +1 -1
- package/dist/cjs/server/LocationServiceConnector.d.ts +2 -0
- package/dist/cjs/server/LocationServiceConnector.js +53 -14
- package/dist/cjs/server/getClientConfig.d.ts +3 -2
- package/dist/cjs/server/getClientConfig.js +44 -17
- package/dist/cjs/transport/errors.d.ts +14 -8
- package/dist/cjs/transport/errors.js +26 -18
- package/dist/client/GeoPlacesClient.d.ts +6 -2
- package/dist/client/GeoPlacesClient.js +83 -21
- 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 +1 -1
- package/dist/server/LocationServiceConnector.d.ts +2 -0
- package/dist/server/LocationServiceConnector.js +53 -14
- package/dist/server/getClientConfig.d.ts +3 -2
- package/dist/server/getClientConfig.js +44 -17
- package/dist/transport/errors.d.ts +14 -8
- package/dist/transport/errors.js +27 -19
- 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
|
-
- **
|
|
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**:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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.
|
|
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.
|
|
681
|
-
|
|
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(
|
|
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 =
|
|
136
|
-
?
|
|
137
|
-
:
|
|
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.
|
|
147
|
-
// `Highlights
|
|
148
|
-
//
|
|
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
|
-
...(
|
|
197
|
+
...(bbox || countries
|
|
156
198
|
? {
|
|
157
199
|
Filter: {
|
|
158
|
-
...(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|