@chaosity/location-client 0.5.1 → 0.6.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 +14 -0
- 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 +39 -0
- package/dist/cjs/client/GeoPlacesClient.js +68 -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 +10 -0
- package/dist/cjs/maps/createTransformRequest.js +43 -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 +55 -0
- package/dist/cjs/server/LocationServiceConnector.js +91 -0
- package/dist/cjs/server/getClientConfig.d.ts +40 -0
- package/dist/cjs/server/getClientConfig.js +130 -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 +19 -0
- package/dist/cjs/transport/errors.js +104 -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 +34 -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 +3 -3
- package/dist/client/GeoPlacesClient.js +4 -4
- 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/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 +2 -2
- package/dist/server/LocationServiceConnector.js +6 -6
- package/dist/server/getClientConfig.d.ts +1 -1
- package/dist/server/getClientConfig.js +2 -2
- 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 +1 -1
- package/dist/transport/errors.js +1 -1
- package/dist/transport/http.js +2 -2
- 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
|
|
@@ -156,6 +168,8 @@ Available categories: `food_drink`, `entertainment`, `sights`, `transit`, `accom
|
|
|
156
168
|
|
|
157
169
|
### MapLibre Geocoder Integration
|
|
158
170
|
|
|
171
|
+
Requires the optional peers: `npm install maplibre-gl @maplibre/maplibre-gl-geocoder`
|
|
172
|
+
|
|
159
173
|
```typescript
|
|
160
174
|
import { GeoPlacesClient, GeoPlaces } from '@chaosity/location-client'
|
|
161
175
|
import MaplibreGeocoder from '@maplibre/maplibre-gl-geocoder'
|
|
@@ -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;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long before expiry a token is treated as needing replacement.
|
|
3
|
+
*
|
|
4
|
+
* ONE number, used by both sides: the server-side `TokenProvider` deciding
|
|
5
|
+
* whether its cached token is still good, and the React provider deciding
|
|
6
|
+
* whether to ask for a new one. Both apply it to the same `exp` claim, so they
|
|
7
|
+
* cannot reach different answers about the same token.
|
|
8
|
+
*
|
|
9
|
+
* It used to be two separate `60`s — a private literal in `isExpired()` and a
|
|
10
|
+
* public `refreshBuffer` prop default — with nothing tying them together. On
|
|
11
|
+
* 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
|
|
12
|
+
* client judged it stale after 100 s, the server still considered it fresh for
|
|
13
|
+
* another 840 s and returned the same one, and the client asked again
|
|
14
|
+
* immediately — roughly 110 requests per second from an idle page.
|
|
15
|
+
*
|
|
16
|
+
* Not configurable per consumer, deliberately. A settable client-side buffer is
|
|
17
|
+
* precisely what allowed that disagreement, and no amount of capping or backing
|
|
18
|
+
* off fixes it as cleanly as the two sides simply sharing the number.
|
|
19
|
+
*/
|
|
20
|
+
export declare const TOKEN_REFRESH_BUFFER_SECONDS = 60;
|
|
21
|
+
/**
|
|
22
|
+
* Read the `exp` claim out of a JWT, in milliseconds.
|
|
23
|
+
*
|
|
24
|
+
* The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
|
|
25
|
+
* take it on trust from elsewhere — the server from the response body's
|
|
26
|
+
* `expires_at`, the React provider from whatever `getConfig` returned, falling
|
|
27
|
+
* back to inventing `Date.now() + 900_000` when that was absent. An invented
|
|
28
|
+
* expiry is how the two ended up disagreeing about the same token.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT verified. This is only used to decide *when to refresh*;
|
|
31
|
+
* nothing is authorised on the strength of it, and the API verifies the
|
|
32
|
+
* signature on every request regardless. Trusting `exp` for scheduling is safe
|
|
33
|
+
* in a way that trusting it for access would not be.
|
|
34
|
+
*
|
|
35
|
+
* Returns undefined for anything unparseable, so callers keep their fallback.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readTokenExpiry(token: string | undefined): number | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TOKEN_REFRESH_BUFFER_SECONDS = void 0;
|
|
4
|
+
exports.readTokenExpiry = readTokenExpiry;
|
|
5
|
+
/**
|
|
6
|
+
* How long before expiry a token is treated as needing replacement.
|
|
7
|
+
*
|
|
8
|
+
* ONE number, used by both sides: the server-side `TokenProvider` deciding
|
|
9
|
+
* whether its cached token is still good, and the React provider deciding
|
|
10
|
+
* whether to ask for a new one. Both apply it to the same `exp` claim, so they
|
|
11
|
+
* cannot reach different answers about the same token.
|
|
12
|
+
*
|
|
13
|
+
* It used to be two separate `60`s — a private literal in `isExpired()` and a
|
|
14
|
+
* public `refreshBuffer` prop default — with nothing tying them together. On
|
|
15
|
+
* 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
|
|
16
|
+
* client judged it stale after 100 s, the server still considered it fresh for
|
|
17
|
+
* another 840 s and returned the same one, and the client asked again
|
|
18
|
+
* immediately — roughly 110 requests per second from an idle page.
|
|
19
|
+
*
|
|
20
|
+
* Not configurable per consumer, deliberately. A settable client-side buffer is
|
|
21
|
+
* precisely what allowed that disagreement, and no amount of capping or backing
|
|
22
|
+
* off fixes it as cleanly as the two sides simply sharing the number.
|
|
23
|
+
*/
|
|
24
|
+
exports.TOKEN_REFRESH_BUFFER_SECONDS = 60;
|
|
25
|
+
/**
|
|
26
|
+
* Read the `exp` claim out of a JWT, in milliseconds.
|
|
27
|
+
*
|
|
28
|
+
* The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
|
|
29
|
+
* take it on trust from elsewhere — the server from the response body's
|
|
30
|
+
* `expires_at`, the React provider from whatever `getConfig` returned, falling
|
|
31
|
+
* back to inventing `Date.now() + 900_000` when that was absent. An invented
|
|
32
|
+
* expiry is how the two ended up disagreeing about the same token.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately NOT verified. This is only used to decide *when to refresh*;
|
|
35
|
+
* nothing is authorised on the strength of it, and the API verifies the
|
|
36
|
+
* signature on every request regardless. Trusting `exp` for scheduling is safe
|
|
37
|
+
* in a way that trusting it for access would not be.
|
|
38
|
+
*
|
|
39
|
+
* Returns undefined for anything unparseable, so callers keep their fallback.
|
|
40
|
+
*/
|
|
41
|
+
function readTokenExpiry(token) {
|
|
42
|
+
if (!token)
|
|
43
|
+
return undefined;
|
|
44
|
+
const payload = token.split('.')[1];
|
|
45
|
+
if (!payload)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
49
|
+
const json = typeof atob === 'function'
|
|
50
|
+
? atob(base64)
|
|
51
|
+
: Buffer.from(base64, 'base64').toString('utf8');
|
|
52
|
+
const exp = JSON.parse(json)?.exp;
|
|
53
|
+
return typeof exp === 'number' ? exp * 1000 : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RequestOptions } from '../transport/http.js';
|
|
2
|
+
import type { ClientConfig } from '../types/index.js';
|
|
3
|
+
import type { AppConfigClaims } from '../utils/tokenClaims.js';
|
|
4
|
+
export type SendOptions = RequestOptions;
|
|
5
|
+
/**
|
|
6
|
+
* GeoPlacesClient — AWS Location Service compatible client with custom auth.
|
|
7
|
+
*
|
|
8
|
+
* Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
|
|
9
|
+
* request and response types are identical to AWS Location Service.
|
|
10
|
+
*
|
|
11
|
+
* Pass `getToken` in config for live refresh without recreating the client.
|
|
12
|
+
*/
|
|
13
|
+
export declare class GeoPlacesClient {
|
|
14
|
+
private clientConfig;
|
|
15
|
+
readonly config: {
|
|
16
|
+
serviceId: string;
|
|
17
|
+
};
|
|
18
|
+
constructor(config: ClientConfig);
|
|
19
|
+
/**
|
|
20
|
+
* This application's own configuration, as carried on the access token
|
|
21
|
+
* (api#65) — bias precision, and the countries it is scoped to.
|
|
22
|
+
*
|
|
23
|
+
* Provided so an application can SHOW its own settings: populate a country
|
|
24
|
+
* selector with the markets it actually serves, label a settings screen,
|
|
25
|
+
* and so on. Being a few minutes stale is cosmetic for that.
|
|
26
|
+
*
|
|
27
|
+
* It is not an entitlement check. See AppConfigClaims for why acting on
|
|
28
|
+
* `countries` client-side makes requests fail that would otherwise succeed.
|
|
29
|
+
*
|
|
30
|
+
* Returns `{}` when the token carries no application config, which is the
|
|
31
|
+
* case until one is configured in the portal.
|
|
32
|
+
*/
|
|
33
|
+
getAppConfig(): AppConfigClaims;
|
|
34
|
+
/**
|
|
35
|
+
* @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
|
|
36
|
+
* to disable the retry loop. Every failure throws LocationServiceException.
|
|
37
|
+
*/
|
|
38
|
+
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
39
|
+
}
|