@chaosity/location-client 0.5.1 → 0.7.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 +73 -4
- package/dist/adapters/GeoPlaces.d.ts +1 -1
- package/dist/auth/TokenProvider.js +3 -3
- package/dist/cjs/adapters/GeoPlaces.d.ts +64 -0
- package/dist/cjs/adapters/GeoPlaces.js +213 -0
- package/dist/cjs/auth/TokenProvider.d.ts +64 -0
- package/dist/cjs/auth/TokenProvider.js +144 -0
- package/dist/cjs/auth/tokenRefresh.d.ts +37 -0
- package/dist/cjs/auth/tokenRefresh.js +58 -0
- package/dist/cjs/client/GeoPlacesClient.d.ts +42 -0
- package/dist/cjs/client/GeoPlacesClient.js +92 -0
- package/dist/cjs/errors/LocationServiceException.d.ts +44 -0
- package/dist/cjs/errors/LocationServiceException.js +60 -0
- package/dist/cjs/index.d.ts +24 -0
- package/dist/cjs/index.js +72 -0
- package/dist/cjs/maps/Utils.d.ts +2 -0
- package/dist/cjs/maps/Utils.js +8 -0
- package/dist/cjs/maps/createTransformRequest.d.ts +12 -0
- package/dist/cjs/maps/createTransformRequest.js +87 -0
- package/dist/cjs/maps/mapEnums.d.ts +102 -0
- package/dist/cjs/maps/mapEnums.js +103 -0
- package/dist/cjs/maps/mapLanguage.d.ts +43 -0
- package/dist/cjs/maps/mapLanguage.js +75 -0
- package/dist/cjs/maps/mapPoi.d.ts +44 -0
- package/dist/cjs/maps/mapPoi.js +64 -0
- package/dist/cjs/maps/mapStyle.d.ts +77 -0
- package/dist/cjs/maps/mapStyle.js +111 -0
- package/dist/cjs/maps/staticMap.d.ts +85 -0
- package/dist/cjs/maps/staticMap.js +81 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/server/LocationServiceConnector.d.ts +104 -0
- package/dist/cjs/server/LocationServiceConnector.js +270 -0
- package/dist/cjs/server/getClientConfig.d.ts +94 -0
- package/dist/cjs/server/getClientConfig.js +174 -0
- package/dist/cjs/server/index.d.ts +6 -0
- package/dist/cjs/server/index.js +9 -0
- package/dist/cjs/transport/endpoints.d.ts +2 -0
- package/dist/cjs/transport/endpoints.js +34 -0
- package/dist/cjs/transport/errors.d.ts +32 -0
- package/dist/cjs/transport/errors.js +120 -0
- package/dist/cjs/transport/http.d.ts +24 -0
- package/dist/cjs/transport/http.js +142 -0
- package/dist/cjs/types/index.d.ts +53 -0
- package/dist/cjs/types/index.js +3 -0
- package/dist/cjs/utils/roundPosition.d.ts +66 -0
- package/dist/cjs/utils/roundPosition.js +109 -0
- package/dist/cjs/utils/tokenClaims.d.ts +55 -0
- package/dist/cjs/utils/tokenClaims.js +61 -0
- package/dist/client/GeoPlacesClient.d.ts +6 -3
- package/dist/client/GeoPlacesClient.js +35 -11
- package/dist/index.d.ts +22 -22
- package/dist/index.js +12 -12
- package/dist/maps/Utils.d.ts +1 -1
- package/dist/maps/Utils.js +1 -1
- package/dist/maps/createTransformRequest.d.ts +2 -0
- package/dist/maps/createTransformRequest.js +45 -1
- package/dist/maps/mapLanguage.d.ts +1 -1
- package/dist/maps/mapStyle.d.ts +1 -1
- package/dist/maps/mapStyle.js +2 -2
- package/dist/maps/staticMap.d.ts +1 -1
- package/dist/maps/staticMap.js +1 -1
- package/dist/server/LocationServiceConnector.d.ts +58 -9
- package/dist/server/LocationServiceConnector.js +217 -38
- package/dist/server/getClientConfig.d.ts +58 -4
- package/dist/server/getClientConfig.js +108 -66
- package/dist/server/index.d.ts +6 -6
- package/dist/server/index.js +3 -3
- package/dist/transport/endpoints.d.ts +1 -1
- package/dist/transport/endpoints.js +1 -1
- package/dist/transport/errors.d.ts +14 -1
- package/dist/transport/errors.js +16 -1
- package/dist/transport/http.js +2 -2
- package/dist/types/index.d.ts +19 -0
- package/package.json +33 -11
package/README.md
CHANGED
|
@@ -22,6 +22,18 @@ AWS Location Service compatible client with custom Bearer token authentication.
|
|
|
22
22
|
npm install @chaosity/location-client
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
The Places commands, the transport and the static-map and style-URL helpers need
|
|
26
|
+
nothing else. The **map** features are optional peer dependencies, so install
|
|
27
|
+
them only if you use them:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# interactive map helpers (createTransformRequest, applyMapLanguage, POI toggles)
|
|
31
|
+
npm install maplibre-gl
|
|
32
|
+
|
|
33
|
+
# the MapLibre geocoder control (the GeoPlaces adapter)
|
|
34
|
+
npm install maplibre-gl @maplibre/maplibre-gl-geocoder
|
|
35
|
+
```
|
|
36
|
+
|
|
25
37
|
## Key Features
|
|
26
38
|
|
|
27
39
|
- **Custom Authentication**: Uses Bearer tokens instead of AWS SigV4
|
|
@@ -57,6 +69,10 @@ Set these environment variables:
|
|
|
57
69
|
LOCATION_API_URL=https://api.chaosity.cloud
|
|
58
70
|
LOCATION_CLIENT_ID=your-client-id
|
|
59
71
|
LOCATION_CLIENT_SECRET=your-client-secret
|
|
72
|
+
|
|
73
|
+
# Only for LocationServiceConnector: the Origin sent on every data request.
|
|
74
|
+
# The API answers 403 without one it recognises — see below.
|
|
75
|
+
LOCATION_ORIGIN=https://your-allowed-domain.example
|
|
60
76
|
```
|
|
61
77
|
|
|
62
78
|
Or pass credentials explicitly:
|
|
@@ -156,6 +172,8 @@ Available categories: `food_drink`, `entertainment`, `sights`, `transit`, `accom
|
|
|
156
172
|
|
|
157
173
|
### MapLibre Geocoder Integration
|
|
158
174
|
|
|
175
|
+
Requires the optional peers: `npm install maplibre-gl @maplibre/maplibre-gl-geocoder`
|
|
176
|
+
|
|
159
177
|
```typescript
|
|
160
178
|
import { GeoPlacesClient, GeoPlaces } from '@chaosity/location-client'
|
|
161
179
|
import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
|
|
@@ -193,6 +211,7 @@ const client = new GeoPlacesClient({
|
|
|
193
211
|
apiUrl: string,
|
|
194
212
|
token: string,
|
|
195
213
|
getToken?: () => string | undefined, // Optional: dynamic token getter
|
|
214
|
+
refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
|
|
196
215
|
})
|
|
197
216
|
|
|
198
217
|
await client.send(command)
|
|
@@ -200,6 +219,16 @@ await client.send(command)
|
|
|
200
219
|
|
|
201
220
|
When `getToken` is provided, it is called on every request so token updates are reflected without recreating the client.
|
|
202
221
|
|
|
222
|
+
`refreshToken` covers the case `getToken` cannot. `getToken` is synchronous —
|
|
223
|
+
MapLibre's `transformRequest` requires that — so it can only ever return the
|
|
224
|
+
token already in hand, and a token the API stops accepting **before** its `exp`
|
|
225
|
+
(revoked, or issued against a since-rotated secret) fails every request until
|
|
226
|
+
the refresh buffer elapses. `refreshToken` is awaited after a 401, and the
|
|
227
|
+
request is retried **once** with what it returns. Return the same token, or
|
|
228
|
+
nothing, and no retry is sent — a request that is going to fail again is not
|
|
229
|
+
worth being billed for twice. A 403 is never retried: a new token cannot fix an
|
|
230
|
+
`Origin` the application does not allow.
|
|
231
|
+
|
|
203
232
|
#### GeoPlaces Adapter
|
|
204
233
|
|
|
205
234
|
Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
|
|
@@ -337,8 +366,19 @@ import { getClientConfig } from '@chaosity/location-client/server'
|
|
|
337
366
|
|
|
338
367
|
const config = await getClientConfig()
|
|
339
368
|
// { apiUrl: string, token: string, expiresAt?: number }
|
|
369
|
+
|
|
370
|
+
// The API has rejected a token that has not reached its exp — revoked in the
|
|
371
|
+
// portal, or issued against a client secret that has since been rotated.
|
|
372
|
+
const replacement = await getClientConfig({ forceRefresh: true })
|
|
340
373
|
```
|
|
341
374
|
|
|
375
|
+
The return value is **plain data** — no methods, no closures — so it can be
|
|
376
|
+
returned straight out of a Next.js Server Action to a Client Component. It is
|
|
377
|
+
therefore a snapshot: the token in it stops working at its `expiresAt`, and the
|
|
378
|
+
caller asks for another. For a long-lived server process that should just keep
|
|
379
|
+
working, use `LocationServiceConnector`, which holds a live token source and
|
|
380
|
+
refreshes for you.
|
|
381
|
+
|
|
342
382
|
#### TokenProvider
|
|
343
383
|
|
|
344
384
|
Lower-level token management with caching and deduplication.
|
|
@@ -357,14 +397,18 @@ const { success, token, expiresAt } = await provider.getToken()
|
|
|
357
397
|
|
|
358
398
|
#### LocationServiceConnector
|
|
359
399
|
|
|
360
|
-
Server-side connector for backend-to-backend API calls.
|
|
400
|
+
Server-side connector for backend-to-backend API calls. It **completes** its
|
|
401
|
+
configuration from the environment rather than replacing it, so you supply only
|
|
402
|
+
the parts the environment does not have:
|
|
361
403
|
|
|
362
404
|
```typescript
|
|
363
405
|
import { LocationServiceConnector } from '@chaosity/location-client/server'
|
|
364
406
|
|
|
407
|
+
// apiUrl + credentials from LOCATION_API_URL / LOCATION_CLIENT_ID /
|
|
408
|
+
// LOCATION_CLIENT_SECRET; Origin from here. Set LOCATION_ORIGIN as well and
|
|
409
|
+
// `new LocationServiceConnector()` needs no arguments at all.
|
|
365
410
|
const connector = new LocationServiceConnector({
|
|
366
|
-
|
|
367
|
-
token: config.token,
|
|
411
|
+
origin: 'https://your-allowed-domain.example',
|
|
368
412
|
})
|
|
369
413
|
|
|
370
414
|
const result = await connector.send(
|
|
@@ -372,9 +416,34 @@ const result = await connector.send(
|
|
|
372
416
|
)
|
|
373
417
|
```
|
|
374
418
|
|
|
419
|
+
**Every data request needs an `Origin` the service recognises, and the API
|
|
420
|
+
answers 403 without one** — server-to-server calls included, not just browsers.
|
|
421
|
+
In a browser the browser sets it; here you do, with `origin` above,
|
|
422
|
+
`LOCATION_ORIGIN`, or a per-call header (`send(cmd, { headers: { Origin } })`,
|
|
423
|
+
which wins over both). `/auth/token` is the one endpoint exempt.
|
|
424
|
+
|
|
425
|
+
A connector configured this way keeps working indefinitely: it holds a live
|
|
426
|
+
token source, refreshes before expiry, and retries once with a new token if the
|
|
427
|
+
API rejects the one it sent. Pass an explicit `token` instead and you opt out of
|
|
428
|
+
all of that — it is a fixed string, and it dies at its own `exp`:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
// Managing credentials yourself: an explicit token source wins outright, and
|
|
432
|
+
// the environment is not consulted.
|
|
433
|
+
const connector = new LocationServiceConnector({
|
|
434
|
+
apiUrl,
|
|
435
|
+
origin: 'https://your-allowed-domain.example',
|
|
436
|
+
getToken: (forceRefresh) => provider.getToken(forceRefresh),
|
|
437
|
+
})
|
|
438
|
+
```
|
|
439
|
+
|
|
375
440
|
## Cache-Friendly Position Rounding
|
|
376
441
|
|
|
377
|
-
`BiasPosition` coordinates are automatically rounded
|
|
442
|
+
`BiasPosition` coordinates are automatically rounded before each API request, to
|
|
443
|
+
whatever precision your application is entitled to — a `biasDecimals` claim on
|
|
444
|
+
the access token, defaulting to **3 decimal places** (~110 m) when the token
|
|
445
|
+
carries none. This maximizes cache hits across nearby users without affecting
|
|
446
|
+
result quality — bias is approximate by nature.
|
|
378
447
|
|
|
379
448
|
`QueryPosition` (reverse geocode) retains full precision since it represents an exact point the user selected.
|
|
380
449
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MaplibreGeocoderApi, MaplibreGeocoderApiConfig, MaplibreGeocoderFeatureResults, MaplibreGeocoderPlaceResults, MaplibreGeocoderSuggestionResults } from '@maplibre/maplibre-gl-geocoder';
|
|
2
2
|
import type { Map } from 'maplibre-gl';
|
|
3
|
-
import type { GeoPlacesClient } from '../client/GeoPlacesClient';
|
|
3
|
+
import type { GeoPlacesClient } from '../client/GeoPlacesClient.js';
|
|
4
4
|
/**
|
|
5
5
|
* GeoPlaces - MapLibre adapter for AWS Location Service
|
|
6
6
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
|
-
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
3
|
-
import { requestJson } from '../transport/http';
|
|
4
|
-
import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry } from './tokenRefresh';
|
|
2
|
+
import { LocationServiceException } from '../errors/LocationServiceException.js';
|
|
3
|
+
import { requestJson } from '../transport/http.js';
|
|
4
|
+
import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './tokenRefresh.js';
|
|
5
5
|
const log = debug('location-client:auth');
|
|
6
6
|
/**
|
|
7
7
|
* TokenProvider - SERVER-SIDE ONLY
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { MaplibreGeocoderApi, MaplibreGeocoderApiConfig, MaplibreGeocoderFeatureResults, MaplibreGeocoderPlaceResults, MaplibreGeocoderSuggestionResults } from '@maplibre/maplibre-gl-geocoder';
|
|
2
|
+
import type { Map } from 'maplibre-gl';
|
|
3
|
+
import type { GeoPlacesClient } from '../client/GeoPlacesClient.js';
|
|
4
|
+
/**
|
|
5
|
+
* GeoPlaces - MapLibre adapter for AWS Location Service
|
|
6
|
+
*
|
|
7
|
+
* Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Extra GetPlace detail, and what each one costs (#3 / T19).
|
|
11
|
+
*
|
|
12
|
+
* Measured against Amazon Location on 2026-08-25 by requesting each feature
|
|
13
|
+
* alone and reading the pricing bucket back:
|
|
14
|
+
*
|
|
15
|
+
* (none) -> Core $0.50/1k
|
|
16
|
+
* SecondaryAddresses -> Core $0.50/1k
|
|
17
|
+
* Access -> Advanced $1.50/1k
|
|
18
|
+
* TimeZone -> Advanced $1.50/1k
|
|
19
|
+
* Contact -> Advanced $1.50/1k
|
|
20
|
+
*
|
|
21
|
+
* `secondaryAddresses` is therefore free in bucket terms and the others triple
|
|
22
|
+
* the price of every lookup. They are opt-in for that reason, not because the
|
|
23
|
+
* data is unwelcome.
|
|
24
|
+
*
|
|
25
|
+
* Worth knowing before enabling `contact`: for a street address it costs
|
|
26
|
+
* Advanced and returns no contact field at all — only points of interest carry
|
|
27
|
+
* one. On an address-completion flow that is 3x the price for nothing.
|
|
28
|
+
*/
|
|
29
|
+
export interface GeoPlacesDetailOptions {
|
|
30
|
+
/** Entrance/exit points. Moves the request to the Advanced bucket. */
|
|
31
|
+
access?: boolean;
|
|
32
|
+
/** Unit and sub-address detail. Stays in the Core bucket — free to enable. */
|
|
33
|
+
secondaryAddresses?: boolean;
|
|
34
|
+
/** Phone/website, POIs only. Moves the request to the Advanced bucket. */
|
|
35
|
+
contact?: boolean;
|
|
36
|
+
/** IANA zone and offset. Moves the request to the Advanced bucket. */
|
|
37
|
+
timeZone?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface GeoPlacesOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Extra detail on `searchByPlaceId`. Default: none, which keeps every
|
|
42
|
+
* lookup in the Core bucket.
|
|
43
|
+
*/
|
|
44
|
+
details?: GeoPlacesDetailOptions;
|
|
45
|
+
}
|
|
46
|
+
export declare class GeoPlaces implements MaplibreGeocoderApi {
|
|
47
|
+
private client;
|
|
48
|
+
private map;
|
|
49
|
+
private details;
|
|
50
|
+
constructor(client: GeoPlacesClient, map: Map, options?: GeoPlacesOptions);
|
|
51
|
+
/**
|
|
52
|
+
* Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
|
|
53
|
+
*
|
|
54
|
+
* Returning `undefined` rather than `[]` matters: an empty array is still a
|
|
55
|
+
* field on the request, and the point is to send nothing.
|
|
56
|
+
*/
|
|
57
|
+
private detailFeatures;
|
|
58
|
+
private normalizeLanguage;
|
|
59
|
+
forwardGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
|
|
60
|
+
reverseGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
|
|
61
|
+
getSuggestions(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderSuggestionResults>;
|
|
62
|
+
searchByPlaceId(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderPlaceResults>;
|
|
63
|
+
localGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GeoPlaces = void 0;
|
|
7
|
+
const client_geo_places_1 = require("@aws-sdk/client-geo-places");
|
|
8
|
+
const amazon_location_utilities_datatypes_1 = require("@aws/amazon-location-utilities-datatypes");
|
|
9
|
+
const debug_1 = __importDefault(require("debug"));
|
|
10
|
+
const log = (0, debug_1.default)('location-client:geocoder');
|
|
11
|
+
/**
|
|
12
|
+
* Give the control the fields it renders, not just the ones GeoJSON needs.
|
|
13
|
+
*
|
|
14
|
+
* `@maplibre/maplibre-gl-geocoder` is a Carmen-shaped consumer: its result list
|
|
15
|
+
* calls `item.place_name.split(',')`, the input takes `result.place_name` on
|
|
16
|
+
* select, and fly-to reads `center` / `bbox`. The AWS converters produce plain
|
|
17
|
+
* GeoJSON — `properties` and `geometry` only — so a forward geocode used to
|
|
18
|
+
* hand the control features with no `place_name`. The list renderer threw, the
|
|
19
|
+
* control caught it and emitted `error`, and with no listener that surfaced as
|
|
20
|
+
* `Unhandled error. (undefined)` in the console: the Enter key silently did
|
|
21
|
+
* nothing. Suggestions were unaffected because that path renders `text`.
|
|
22
|
+
*
|
|
23
|
+
* The `as MaplibreGeocoderFeatureResults` cast this replaces is what hid it —
|
|
24
|
+
* `place_name` and `text` are required on the type.
|
|
25
|
+
*/
|
|
26
|
+
function toCarmenFeatures(features) {
|
|
27
|
+
return features.map((feature) => {
|
|
28
|
+
const p = (feature.properties ?? {});
|
|
29
|
+
const title = typeof p.Title === 'string' ? p.Title : '';
|
|
30
|
+
const label = typeof p['Address.Label'] === 'string' ? p['Address.Label'] : '';
|
|
31
|
+
// `flattenProperties` flattens nested OBJECTS (Address.Label) but leaves an
|
|
32
|
+
// array of numbers intact, so MapView is still [minx, miny, maxx, maxy].
|
|
33
|
+
const view = p.MapView;
|
|
34
|
+
const bbox = Array.isArray(view) &&
|
|
35
|
+
view.length === 4 &&
|
|
36
|
+
view.every((v) => typeof v === 'number')
|
|
37
|
+
? view
|
|
38
|
+
: undefined;
|
|
39
|
+
const center = feature.geometry?.type === 'Point'
|
|
40
|
+
? feature.geometry.coordinates
|
|
41
|
+
: undefined;
|
|
42
|
+
return {
|
|
43
|
+
...feature,
|
|
44
|
+
text: title || label,
|
|
45
|
+
place_name: label || title,
|
|
46
|
+
place_type: typeof p.PlaceType === 'string' ? [p.PlaceType] : [],
|
|
47
|
+
...(bbox ? { bbox } : {}),
|
|
48
|
+
...(center ? { center } : {}),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
class GeoPlaces {
|
|
53
|
+
constructor(client, map, options = {}) {
|
|
54
|
+
this.client = client;
|
|
55
|
+
this.map = map;
|
|
56
|
+
this.details = options.details ?? {};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
|
|
60
|
+
*
|
|
61
|
+
* Returning `undefined` rather than `[]` matters: an empty array is still a
|
|
62
|
+
* field on the request, and the point is to send nothing.
|
|
63
|
+
*/
|
|
64
|
+
detailFeatures() {
|
|
65
|
+
const features = [];
|
|
66
|
+
if (this.details.access)
|
|
67
|
+
features.push(client_geo_places_1.GetPlaceAdditionalFeature.ACCESS);
|
|
68
|
+
if (this.details.secondaryAddresses)
|
|
69
|
+
features.push(client_geo_places_1.GetPlaceAdditionalFeature.SECONDARY_ADDRESSES);
|
|
70
|
+
if (this.details.contact)
|
|
71
|
+
features.push(client_geo_places_1.GetPlaceAdditionalFeature.CONTACT);
|
|
72
|
+
if (this.details.timeZone)
|
|
73
|
+
features.push(client_geo_places_1.GetPlaceAdditionalFeature.TIME_ZONE);
|
|
74
|
+
return features.length ? features : undefined;
|
|
75
|
+
}
|
|
76
|
+
normalizeLanguage(language) {
|
|
77
|
+
if (Array.isArray(language))
|
|
78
|
+
return language[0] || 'en';
|
|
79
|
+
if (typeof language === 'string')
|
|
80
|
+
return language;
|
|
81
|
+
return 'en';
|
|
82
|
+
}
|
|
83
|
+
async forwardGeocode(config) {
|
|
84
|
+
log('forwardGeocode query=%s', config.query);
|
|
85
|
+
const center = this.map.getCenter();
|
|
86
|
+
const biasPosition = config.proximity && config.proximity.length >= 2
|
|
87
|
+
? [config.proximity[0], config.proximity[1]]
|
|
88
|
+
: [center.lng, center.lat];
|
|
89
|
+
const commandInput = {
|
|
90
|
+
QueryText: config.query,
|
|
91
|
+
BiasPosition: biasPosition,
|
|
92
|
+
MaxResults: config.limit || 5,
|
|
93
|
+
Language: this.normalizeLanguage(config.language),
|
|
94
|
+
};
|
|
95
|
+
if (config.countries) {
|
|
96
|
+
commandInput.Filter = {
|
|
97
|
+
IncludeCountries: Array.isArray(config.countries)
|
|
98
|
+
? config.countries
|
|
99
|
+
: config.countries.split(','),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const response = (await this.client.send(new client_geo_places_1.GeocodeCommand(commandInput)));
|
|
103
|
+
const converted = (0, amazon_location_utilities_datatypes_1.geocodeResponseToFeatureCollection)(response, {
|
|
104
|
+
flattenProperties: true,
|
|
105
|
+
});
|
|
106
|
+
const result = {
|
|
107
|
+
type: 'FeatureCollection',
|
|
108
|
+
features: toCarmenFeatures(converted.features),
|
|
109
|
+
};
|
|
110
|
+
log('forwardGeocode returned %d results', result.features.length);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
async reverseGeocode(config) {
|
|
114
|
+
log('reverseGeocode query=%o', config.query);
|
|
115
|
+
const queryPosition = Array.isArray(config.query) && config.query.length >= 2
|
|
116
|
+
? [config.query[0], config.query[1]]
|
|
117
|
+
: [0, 0];
|
|
118
|
+
const commandInput = {
|
|
119
|
+
QueryPosition: queryPosition,
|
|
120
|
+
MaxResults: config.limit || 1,
|
|
121
|
+
Language: this.normalizeLanguage(config.language),
|
|
122
|
+
};
|
|
123
|
+
const response = (await this.client.send(new client_geo_places_1.ReverseGeocodeCommand(commandInput)));
|
|
124
|
+
const converted = (0, amazon_location_utilities_datatypes_1.reverseGeocodeResponseToFeatureCollection)(response, {
|
|
125
|
+
flattenProperties: true,
|
|
126
|
+
});
|
|
127
|
+
const result = {
|
|
128
|
+
type: 'FeatureCollection',
|
|
129
|
+
features: toCarmenFeatures(converted.features),
|
|
130
|
+
};
|
|
131
|
+
log('reverseGeocode returned %d results', result.features.length);
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
async getSuggestions(config) {
|
|
135
|
+
log('getSuggestions query=%s', config.query);
|
|
136
|
+
const center = this.map.getCenter();
|
|
137
|
+
const biasPosition = config.proximity && config.proximity.length >= 2
|
|
138
|
+
? [config.proximity[0], config.proximity[1]]
|
|
139
|
+
: [center.lng, center.lat];
|
|
140
|
+
const commandInput = {
|
|
141
|
+
QueryText: config.query,
|
|
142
|
+
BiasPosition: biasPosition,
|
|
143
|
+
MaxResults: config.limit || 5,
|
|
144
|
+
Language: this.normalizeLanguage(config.language),
|
|
145
|
+
// No AdditionalFeatures (#3 / T19).
|
|
146
|
+
//
|
|
147
|
+
// This used to send `[Core]`, which put every keystroke in the Core
|
|
148
|
+
// bucket at $0.50/1k. The only thing Core adds to a Suggest response is
|
|
149
|
+
// `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
|
|
150
|
+
// nothing else. Verified against Amazon Location on 2026-08-25:
|
|
151
|
+
//
|
|
152
|
+
// with [Core] -> bucket Core keys: Title, ..., Place, Highlights
|
|
153
|
+
// without -> bucket Label keys: Title, ..., Place
|
|
154
|
+
//
|
|
155
|
+
// Same two fields, $0.20/1k instead of $0.50. Suggest fires per
|
|
156
|
+
// keystroke, so it is the highest-volume call the library makes.
|
|
157
|
+
...(config.countries || config.bbox
|
|
158
|
+
? {
|
|
159
|
+
Filter: {
|
|
160
|
+
...(config.countries
|
|
161
|
+
? {
|
|
162
|
+
IncludeCountries: Array.isArray(config.countries)
|
|
163
|
+
? config.countries
|
|
164
|
+
: config.countries.split(','),
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
...(config.bbox ? { BoundingBox: config.bbox } : {}),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
171
|
+
};
|
|
172
|
+
const response = (await this.client.send(new client_geo_places_1.SuggestCommand(commandInput)));
|
|
173
|
+
const suggestions = { suggestions: [] };
|
|
174
|
+
for (const item of response.ResultItems ?? []) {
|
|
175
|
+
const text = item.Title;
|
|
176
|
+
if (!text)
|
|
177
|
+
continue;
|
|
178
|
+
const placeId = item.Place?.PlaceId;
|
|
179
|
+
suggestions.suggestions.push({ text, placeId });
|
|
180
|
+
}
|
|
181
|
+
log('getSuggestions returned %d suggestions', suggestions.suggestions.length);
|
|
182
|
+
return suggestions;
|
|
183
|
+
}
|
|
184
|
+
async searchByPlaceId(config) {
|
|
185
|
+
log('searchByPlaceId placeId=%s', config.query);
|
|
186
|
+
// Opt-in rather than always-on (#3 / T19). Requesting all four put every
|
|
187
|
+
// lookup in the Advanced bucket at $1.50/1k; the default now sends none
|
|
188
|
+
// and stays in Core at $0.50. Callers that want the detail ask for it.
|
|
189
|
+
const additionalFeatures = this.detailFeatures();
|
|
190
|
+
const command = new client_geo_places_1.GetPlaceCommand({
|
|
191
|
+
PlaceId: config.query,
|
|
192
|
+
Language: this.normalizeLanguage(config.language),
|
|
193
|
+
...(additionalFeatures ? { AdditionalFeatures: additionalFeatures } : {}),
|
|
194
|
+
});
|
|
195
|
+
const response = (await this.client.send(command));
|
|
196
|
+
const result = (0, amazon_location_utilities_datatypes_1.getPlaceResponseToFeatureCollection)(response, {
|
|
197
|
+
flattenProperties: true,
|
|
198
|
+
});
|
|
199
|
+
const carmenGeojsonFeatures = result.features.map((feature) => ({
|
|
200
|
+
...feature,
|
|
201
|
+
text: response.Title || '',
|
|
202
|
+
place_name: response.Address?.Label || '',
|
|
203
|
+
place_type: response.PlaceType ? [response.PlaceType] : [],
|
|
204
|
+
bbox: response.MapView,
|
|
205
|
+
}));
|
|
206
|
+
log('searchByPlaceId returned %d features', carmenGeojsonFeatures.length);
|
|
207
|
+
return { place: carmenGeojsonFeatures };
|
|
208
|
+
}
|
|
209
|
+
async localGeocode(config) {
|
|
210
|
+
return this.forwardGeocode(config);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
exports.GeoPlaces = GeoPlaces;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface TokenResponse {
|
|
2
|
+
success: boolean;
|
|
3
|
+
token?: string;
|
|
4
|
+
expiresAt?: number;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TokenProviderConfig {
|
|
8
|
+
apiUrl: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
clientSecret: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* TokenProvider - SERVER-SIDE ONLY
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ WARNING: This class requires client credentials (clientId and clientSecret)
|
|
16
|
+
* and must NEVER be used in browser/client-side code.
|
|
17
|
+
*
|
|
18
|
+
* Use this only in:
|
|
19
|
+
* - Node.js server environments
|
|
20
|
+
* - Next.js Server Actions (marked with 'use server')
|
|
21
|
+
* - Next.js API routes
|
|
22
|
+
* - Backend services
|
|
23
|
+
*
|
|
24
|
+
* For browser usage, use the React provider which receives tokens from server-side code.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // ✓ Correct: Server-side usage
|
|
28
|
+
* import { TokenProvider } from '@chaosity/location-client/server'
|
|
29
|
+
*
|
|
30
|
+
* const provider = new TokenProvider({
|
|
31
|
+
* apiUrl: process.env.API_URL!,
|
|
32
|
+
* clientId: process.env.CLIENT_ID!,
|
|
33
|
+
* clientSecret: process.env.CLIENT_SECRET!,
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // ✗ Wrong: Never use in browser code
|
|
38
|
+
* // This would expose your credentials!
|
|
39
|
+
*/
|
|
40
|
+
export declare class TokenProvider {
|
|
41
|
+
private config;
|
|
42
|
+
private cachedToken?;
|
|
43
|
+
private cachedExpiresAt?;
|
|
44
|
+
private tokenPromise?;
|
|
45
|
+
constructor(config: TokenProviderConfig);
|
|
46
|
+
getToken(forceRefresh?: boolean): Promise<TokenResponse>;
|
|
47
|
+
/**
|
|
48
|
+
* Fetch a token, distinguishing transient failure from terminal (#9).
|
|
49
|
+
*
|
|
50
|
+
* This used to collapse every non-200 into `new Error(message)`: no status,
|
|
51
|
+
* no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
|
|
52
|
+
* the API returns when its config store is unreachable — was indistinguishable
|
|
53
|
+
* from `401 unauthorized`, and a customer whose credentials were perfectly
|
|
54
|
+
* good was told to go and check them.
|
|
55
|
+
*
|
|
56
|
+
* The shared transport now does the classifying: it retries 503/429/network
|
|
57
|
+
* honouring `Retry-After`, never retries 401/400, and throws a typed
|
|
58
|
+
* LocationServiceException either way. A failure REJECTS rather than resolving
|
|
59
|
+
* with `success: false`, so a stale token can never be used by accident.
|
|
60
|
+
*/
|
|
61
|
+
private fetchToken;
|
|
62
|
+
private isExpired;
|
|
63
|
+
clearCache(): void;
|
|
64
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TokenProvider = void 0;
|
|
7
|
+
const debug_1 = __importDefault(require("debug"));
|
|
8
|
+
const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
|
|
9
|
+
const http_js_1 = require("../transport/http.js");
|
|
10
|
+
const tokenRefresh_js_1 = require("./tokenRefresh.js");
|
|
11
|
+
const log = (0, debug_1.default)('location-client:auth');
|
|
12
|
+
/**
|
|
13
|
+
* TokenProvider - SERVER-SIDE ONLY
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ WARNING: This class requires client credentials (clientId and clientSecret)
|
|
16
|
+
* and must NEVER be used in browser/client-side code.
|
|
17
|
+
*
|
|
18
|
+
* Use this only in:
|
|
19
|
+
* - Node.js server environments
|
|
20
|
+
* - Next.js Server Actions (marked with 'use server')
|
|
21
|
+
* - Next.js API routes
|
|
22
|
+
* - Backend services
|
|
23
|
+
*
|
|
24
|
+
* For browser usage, use the React provider which receives tokens from server-side code.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // ✓ Correct: Server-side usage
|
|
28
|
+
* import { TokenProvider } from '@chaosity/location-client/server'
|
|
29
|
+
*
|
|
30
|
+
* const provider = new TokenProvider({
|
|
31
|
+
* apiUrl: process.env.API_URL!,
|
|
32
|
+
* clientId: process.env.CLIENT_ID!,
|
|
33
|
+
* clientSecret: process.env.CLIENT_SECRET!,
|
|
34
|
+
* })
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* // ✗ Wrong: Never use in browser code
|
|
38
|
+
* // This would expose your credentials!
|
|
39
|
+
*/
|
|
40
|
+
class TokenProvider {
|
|
41
|
+
constructor(config) {
|
|
42
|
+
// Runtime check: prevent usage in browser
|
|
43
|
+
if (typeof window !== 'undefined') {
|
|
44
|
+
throw new Error('TokenProvider cannot be used in browser environments. ' +
|
|
45
|
+
'It requires client credentials that must never be exposed to browsers. ' +
|
|
46
|
+
'Use @chaosity/location-client-react for browser usage.');
|
|
47
|
+
}
|
|
48
|
+
log('Initializing TokenProvider for %s', config.apiUrl);
|
|
49
|
+
this.config = config;
|
|
50
|
+
}
|
|
51
|
+
async getToken(forceRefresh = false) {
|
|
52
|
+
if (!forceRefresh && this.cachedToken && !this.isExpired()) {
|
|
53
|
+
const expiresIn = this.cachedExpiresAt
|
|
54
|
+
? Math.floor((this.cachedExpiresAt - Date.now()) / 1000)
|
|
55
|
+
: 'unknown';
|
|
56
|
+
log('Using cached token (expires in %ds)', expiresIn);
|
|
57
|
+
return {
|
|
58
|
+
success: true,
|
|
59
|
+
token: this.cachedToken,
|
|
60
|
+
expiresAt: this.cachedExpiresAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// If token fetch is already in progress, wait for it
|
|
64
|
+
if (this.tokenPromise) {
|
|
65
|
+
log('Token fetch in progress, waiting for existing request...');
|
|
66
|
+
return this.tokenPromise;
|
|
67
|
+
}
|
|
68
|
+
// Start new token fetch
|
|
69
|
+
const reason = forceRefresh
|
|
70
|
+
? 'forced refresh'
|
|
71
|
+
: this.cachedToken
|
|
72
|
+
? 'token expired'
|
|
73
|
+
: 'no cached token';
|
|
74
|
+
log('Refreshing token (%s) from %s', reason, this.config.apiUrl);
|
|
75
|
+
this.tokenPromise = this.fetchToken();
|
|
76
|
+
try {
|
|
77
|
+
const result = await this.tokenPromise;
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
// Clear promise after completion (success or failure)
|
|
82
|
+
this.tokenPromise = undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Fetch a token, distinguishing transient failure from terminal (#9).
|
|
87
|
+
*
|
|
88
|
+
* This used to collapse every non-200 into `new Error(message)`: no status,
|
|
89
|
+
* no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
|
|
90
|
+
* the API returns when its config store is unreachable — was indistinguishable
|
|
91
|
+
* from `401 unauthorized`, and a customer whose credentials were perfectly
|
|
92
|
+
* good was told to go and check them.
|
|
93
|
+
*
|
|
94
|
+
* The shared transport now does the classifying: it retries 503/429/network
|
|
95
|
+
* honouring `Retry-After`, never retries 401/400, and throws a typed
|
|
96
|
+
* LocationServiceException either way. A failure REJECTS rather than resolving
|
|
97
|
+
* with `success: false`, so a stale token can never be used by accident.
|
|
98
|
+
*/
|
|
99
|
+
async fetchToken() {
|
|
100
|
+
const { clientId, clientSecret, apiUrl } = this.config;
|
|
101
|
+
const credentials = btoa(`${clientId}:${clientSecret}`);
|
|
102
|
+
const data = await (0, http_js_1.requestJson)(`${apiUrl}/auth/token`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Basic ${credentials}`,
|
|
106
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
107
|
+
},
|
|
108
|
+
body: new URLSearchParams({
|
|
109
|
+
grant_type: 'client_credentials',
|
|
110
|
+
}).toString(),
|
|
111
|
+
}, { retry: { maxAttempts: 3 } });
|
|
112
|
+
if (!data.access_token) {
|
|
113
|
+
throw new LocationServiceException_js_1.LocationServiceException({
|
|
114
|
+
code: 'InvalidCredentialsException',
|
|
115
|
+
message: 'Token endpoint returned no access_token',
|
|
116
|
+
details: { source: 'client' },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
this.cachedToken = data.access_token;
|
|
120
|
+
// The token's own `exp` claim first — it is the only value that cannot
|
|
121
|
+
// disagree with what the API will actually accept. `expires_at` and
|
|
122
|
+
// `expires_in` are what the response CLAIMS, and are kept as fallbacks.
|
|
123
|
+
this.cachedExpiresAt =
|
|
124
|
+
(0, tokenRefresh_js_1.readTokenExpiry)(data.access_token) ??
|
|
125
|
+
data.expires_at ??
|
|
126
|
+
Date.now() + (data.expires_in ?? 900) * 1000;
|
|
127
|
+
log('Token acquired successfully (expires in %ds)', Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
|
|
128
|
+
return {
|
|
129
|
+
success: true,
|
|
130
|
+
token: this.cachedToken,
|
|
131
|
+
expiresAt: this.cachedExpiresAt,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
isExpired(bufferSeconds = tokenRefresh_js_1.TOKEN_REFRESH_BUFFER_SECONDS) {
|
|
135
|
+
if (!this.cachedExpiresAt)
|
|
136
|
+
return true;
|
|
137
|
+
return Date.now() >= this.cachedExpiresAt - bufferSeconds * 1000;
|
|
138
|
+
}
|
|
139
|
+
clearCache() {
|
|
140
|
+
this.cachedToken = undefined;
|
|
141
|
+
this.cachedExpiresAt = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
exports.TokenProvider = TokenProvider;
|