@chaosity/location-client 0.11.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 +108 -23
- 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 +1 -1
package/README.md
CHANGED
|
@@ -43,14 +43,15 @@ its worker set up once under a bundler: see [The MapLibre worker](#the-maplibre-
|
|
|
43
43
|
## Key Features
|
|
44
44
|
|
|
45
45
|
- **Custom Authentication**: Uses Bearer tokens instead of AWS SigV4
|
|
46
|
-
- **
|
|
46
|
+
- **Places Commands**: the seven Amazon Location Places commands from `@aws-sdk/client-geo-places`, plus `VerifyAddressCommand`
|
|
47
47
|
- **Address Verification**: `verifyAddress(placeId)` returns the one Places result you may store — see [Verifying an address](#verifying-an-address)
|
|
48
|
-
- **Data Type Utilities**:
|
|
48
|
+
- **Data Type Utilities**: GeoJSON converters for the Places responses
|
|
49
49
|
- **MapLibre Integration**: Adapter for MapLibre GL Geocoder and `createTransformRequest` helper
|
|
50
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)
|
|
51
51
|
- **Map Language**: Switch map label language client-side with zero API calls
|
|
52
52
|
- **POI Layer Control**: Toggle point-of-interest categories on/off by layer
|
|
53
53
|
- **Server Utilities**: `getClientConfig()` with auto-env detection and token caching
|
|
54
|
+
- **Typed Errors**: every failure is a `LocationServiceException` whose `code` is a `LocationServiceErrorCode`
|
|
54
55
|
|
|
55
56
|
## Quick Start
|
|
56
57
|
|
|
@@ -73,7 +74,8 @@ export async function getLocationConfig() {
|
|
|
73
74
|
Set these environment variables:
|
|
74
75
|
|
|
75
76
|
```bash
|
|
76
|
-
|
|
77
|
+
# The API URL on your application's page in the developer portal
|
|
78
|
+
LOCATION_API_URL=https://your-api-url.example
|
|
77
79
|
LOCATION_CLIENT_ID=your-client-id
|
|
78
80
|
LOCATION_CLIENT_SECRET=your-client-secret
|
|
79
81
|
|
|
@@ -86,7 +88,7 @@ Or pass credentials explicitly:
|
|
|
86
88
|
|
|
87
89
|
```typescript
|
|
88
90
|
const config = await getClientConfig({
|
|
89
|
-
apiUrl:
|
|
91
|
+
apiUrl: process.env.MY_API_URL!,
|
|
90
92
|
clientId: process.env.MY_CLIENT_ID!,
|
|
91
93
|
clientSecret: process.env.MY_SECRET!,
|
|
92
94
|
})
|
|
@@ -102,10 +104,9 @@ import {
|
|
|
102
104
|
type SuggestCommandOutput,
|
|
103
105
|
} from '@chaosity/location-client'
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
107
|
+
// apiUrl and token as getLocationConfig() above returns them
|
|
108
|
+
const { apiUrl, token } = await getLocationConfig()
|
|
109
|
+
const client = new GeoPlacesClient({ apiUrl, token })
|
|
109
110
|
|
|
110
111
|
const response: SuggestCommandOutput = await client.send(
|
|
111
112
|
new SuggestCommand({
|
|
@@ -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
|
|
@@ -399,10 +400,11 @@ const geocoder = new MaplibreGeocoder(geoPlaces, {
|
|
|
399
400
|
|
|
400
401
|
map.addControl(geocoder, 'top-left')
|
|
401
402
|
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
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])
|
|
406
408
|
})
|
|
407
409
|
```
|
|
408
410
|
|
|
@@ -417,7 +419,7 @@ Client for executing AWS Location Service commands with Bearer token auth.
|
|
|
417
419
|
```typescript
|
|
418
420
|
const client = new GeoPlacesClient({
|
|
419
421
|
apiUrl: string,
|
|
420
|
-
token
|
|
422
|
+
token?: string, // at least one of token, getToken and refreshToken
|
|
421
423
|
getToken?: () => string | undefined, // Optional: dynamic token getter
|
|
422
424
|
refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
|
|
423
425
|
})
|
|
@@ -453,6 +455,18 @@ nothing, and no retry is sent — a request that is going to fail again is not
|
|
|
453
455
|
worth a second round trip. A 403 is never retried: a new token cannot fix an
|
|
454
456
|
`Origin` the application does not allow.
|
|
455
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
|
+
|
|
456
470
|
#### Request options
|
|
457
471
|
|
|
458
472
|
Every call in this package takes the same options object — `client.send`,
|
|
@@ -502,6 +516,39 @@ produced one yet raises `InvalidCredentialsException` instead of putting
|
|
|
502
516
|
trip spent to be told what you already know. `GeoPlacesClient` asks `refreshToken` first, so a
|
|
503
517
|
client whose token simply has not arrived yet still works.
|
|
504
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
|
+
|
|
505
552
|
#### GeoPlaces Adapter
|
|
506
553
|
|
|
507
554
|
Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
|
|
@@ -655,21 +702,45 @@ import { VerifyAddressCommand } from '@chaosity/location-client'
|
|
|
655
702
|
|
|
656
703
|
#### Data Type Utilities
|
|
657
704
|
|
|
658
|
-
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` |
|
|
659
717
|
|
|
660
718
|
```typescript
|
|
661
719
|
import {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
720
|
+
SearchTextCommand,
|
|
721
|
+
searchTextResponseToFeatureCollection,
|
|
722
|
+
type SearchTextCommandOutput,
|
|
665
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)
|
|
666
733
|
```
|
|
667
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
|
+
|
|
668
739
|
### Server Exports (`@chaosity/location-client/server`)
|
|
669
740
|
|
|
670
741
|
#### getClientConfig
|
|
671
742
|
|
|
672
|
-
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.
|
|
673
744
|
|
|
674
745
|
```typescript
|
|
675
746
|
import { getClientConfig } from '@chaosity/location-client/server'
|
|
@@ -682,6 +753,15 @@ const config = await getClientConfig()
|
|
|
682
753
|
const replacement = await getClientConfig({ forceRefresh: true })
|
|
683
754
|
```
|
|
684
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
|
+
|
|
685
765
|
The return value is **plain data** — no methods, no closures — so it can be
|
|
686
766
|
returned straight out of a Next.js Server Action to a Client Component. It is
|
|
687
767
|
therefore a snapshot: the token in it stops working at its `expiresAt`, and the
|
|
@@ -738,8 +818,13 @@ which wins over both). `/auth/token` is the one endpoint exempt.
|
|
|
738
818
|
|
|
739
819
|
A connector configured this way keeps working indefinitely: it holds a live
|
|
740
820
|
token source, refreshes before expiry, and retries once with a new token if the
|
|
741
|
-
API rejects the one it sent.
|
|
742
|
-
|
|
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`:
|
|
743
828
|
|
|
744
829
|
```typescript
|
|
745
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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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 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 function isTokenRefusal(err) {
|
|
28
|
+
return (err instanceof LocationServiceException &&
|
|
29
|
+
(err.statusCode === 401 || err.statusCode === 403));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* How long `err` says asking again cannot help, in milliseconds; 0 when it
|
|
33
|
+
* says nothing, and the next call may ask at once.
|
|
34
|
+
*
|
|
35
|
+
* Only the server's own word counts: a refusal, or a `Retry-After`. A network
|
|
36
|
+
* fault, a timeout or a 500 carries neither, so it is not remembered, and the
|
|
37
|
+
* next call tries again as it always has — the transport has already retried
|
|
38
|
+
* it with backoff inside the call that failed.
|
|
39
|
+
*/
|
|
40
|
+
export function holdFor(err) {
|
|
41
|
+
if (!(err instanceof LocationServiceException))
|
|
42
|
+
return 0;
|
|
43
|
+
return Math.max(isTokenRefusal(err) ? TOKEN_REFUSAL_HOLD_MS : 0, err.retryAfterMs ?? 0);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One remembered failure, re-thrown instead of asking again until it lapses.
|
|
47
|
+
*
|
|
48
|
+
* Shared by every place that used to ask again on every call: the server
|
|
49
|
+
* `TokenProvider` (a refused or throttled token request), and the two send
|
|
50
|
+
* paths (a token the API refused, and the refresh that could not replace it,
|
|
51
|
+
* or — in `GeoPlacesClient` — could not supply a first one). A send path
|
|
52
|
+
* remembers the failure against the token it concerns, because a DIFFERENT
|
|
53
|
+
* token is a new situation — a background refresh that landed, or a caller's
|
|
54
|
+
* own source that moved on — and ends the hold at once.
|
|
55
|
+
*
|
|
56
|
+
* `askAgain` is the one case where the source may still be asked: the API
|
|
57
|
+
* refused the token, and the refresh that followed failed with nothing to say
|
|
58
|
+
* about when to try again — a network fault, or a rejection that lost its
|
|
59
|
+
* fields crossing a Server Action boundary, as `@chaosity/location-client-react`
|
|
60
|
+
* delivers one. The token is still refused, so it is not sent again; the
|
|
61
|
+
* source is asked on the next send, as it always was.
|
|
62
|
+
*/
|
|
63
|
+
export class TokenHold {
|
|
64
|
+
/** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
|
|
65
|
+
remember(err, token, { askAgain = false } = {}) {
|
|
66
|
+
const ms = holdFor(err);
|
|
67
|
+
if (ms > 0) {
|
|
68
|
+
this.held = {
|
|
69
|
+
error: err,
|
|
70
|
+
until: Date.now() + ms,
|
|
71
|
+
token,
|
|
72
|
+
askAgain,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The remembered failure while it stands, as a new exception to throw — for
|
|
78
|
+
* `token`, if one was remembered with it — and whether the source may still
|
|
79
|
+
* be asked. Anything else ends the hold.
|
|
80
|
+
*/
|
|
81
|
+
check(token) {
|
|
82
|
+
const held = this.held;
|
|
83
|
+
if (!held)
|
|
84
|
+
return undefined;
|
|
85
|
+
const remaining = held.until - Date.now();
|
|
86
|
+
if (remaining <= 0 || (held.token !== undefined && held.token !== token)) {
|
|
87
|
+
this.held = undefined;
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const { error, askAgain } = held;
|
|
91
|
+
return {
|
|
92
|
+
askAgain,
|
|
93
|
+
error: new LocationServiceException({
|
|
94
|
+
code: error.code,
|
|
95
|
+
message: error.message,
|
|
96
|
+
statusCode: error.statusCode,
|
|
97
|
+
requestId: error.requestId,
|
|
98
|
+
details: error.details,
|
|
99
|
+
// What is left of a Retry-After, so a caller that schedules on it waits
|
|
100
|
+
// the right amount. A refusal carries none, and gains none here.
|
|
101
|
+
retryAfterMs: error.retryAfterMs === undefined ? undefined : remaining,
|
|
102
|
+
cause: error,
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
forget() {
|
|
107
|
+
this.held = undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|