@chaosity/location-client 0.1.10 → 0.1.13

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 CHANGED
@@ -28,6 +28,9 @@ npm install @chaosity/location-client
28
28
  - **AWS SDK Commands**: Full access to all AWS Location Service commands
29
29
  - **Data Type Utilities**: Built-in GeoJSON conversion utilities
30
30
  - **MapLibre Integration**: Adapter for MapLibre GL Geocoder and `createTransformRequest` helper
31
+ - **Map Style Control**: Fetch and customize map style descriptors with terrain, 3D buildings, traffic, and more
32
+ - **Map Language**: Switch map label language client-side with zero API calls
33
+ - **POI Layer Control**: Toggle point-of-interest categories on/off by layer
31
34
  - **Server Utilities**: `getClientConfig()` with auto-env detection and token caching
32
35
 
33
36
  ## Quick Start
@@ -78,28 +81,74 @@ const client = new GeoPlacesClient({
78
81
  })
79
82
 
80
83
  const response = await client.send(
81
- new SuggestCommand({ QueryText: 'Vancouver', MaxResults: 5 })
84
+ new SuggestCommand({ QueryText: 'Vancouver', MaxResults: 5 }),
82
85
  )
83
86
  ```
84
87
 
85
88
  ### MapLibre Map Integration
86
89
 
87
- Use `createTransformRequest` to automatically attach Bearer tokens to map tile requests:
90
+ Use `fetchMapStyle` to fetch a style descriptor with authentication and optional customization, and `createTransformRequest` to attach Bearer tokens to all subsequent tile/glyph/sprite requests:
88
91
 
89
92
  ```typescript
90
- import { createTransformRequest } from '@chaosity/location-client'
93
+ import {
94
+ fetchMapStyle,
95
+ createTransformRequest,
96
+ } from '@chaosity/location-client'
91
97
  import maplibregl from 'maplibre-gl'
92
98
 
99
+ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
100
+ colorScheme: 'Dark',
101
+ terrain: 'Terrain3D',
102
+ buildings: 'Buildings3D',
103
+ language: 'fr',
104
+ })
105
+
93
106
  const map = new maplibregl.Map({
94
107
  container: 'map',
95
- style: `${apiUrl}/maps/Standard/descriptor`,
108
+ style,
96
109
  center: [-123.12, 49.28],
97
110
  zoom: 10,
111
+ maxPitch: 85,
98
112
  transformRequest: createTransformRequest(apiUrl, getToken),
99
113
  })
100
114
  ```
101
115
 
102
- `createTransformRequest` handles setting the correct `Accept` headers for tiles (protobuf), glyphs, sprites, and style descriptors.
116
+ ### Switching Map Language
117
+
118
+ Change map label language instantly on the client side — no API calls needed:
119
+
120
+ ```typescript
121
+ import { applyMapLanguage } from '@chaosity/location-client'
122
+
123
+ // Apply after the style is loaded
124
+ map.once('style.load', () => applyMapLanguage(map, 'fr'))
125
+
126
+ // Or reapply whenever the user changes language
127
+ applyMapLanguage(map, 'ja')
128
+ ```
129
+
130
+ ### Controlling POI Layers
131
+
132
+ Toggle point-of-interest categories on or off:
133
+
134
+ ```typescript
135
+ import {
136
+ setPoiVisibility,
137
+ setAllPoiVisibility,
138
+ POI_CATEGORIES,
139
+ } from '@chaosity/location-client'
140
+
141
+ // Hide transit POIs
142
+ setPoiVisibility(map, 'transit', false)
143
+
144
+ // Hide multiple categories at once
145
+ setPoiVisibility(map, ['shopping', 'business'], false)
146
+
147
+ // Hide all POIs
148
+ setAllPoiVisibility(map, false)
149
+ ```
150
+
151
+ Available categories: `food_drink`, `entertainment`, `sights`, `transit`, `accommodations`, `leisure`, `shopping`, `business`, `facilities`, `areas`, `parks`.
103
152
 
104
153
  ### MapLibre Geocoder Integration
105
154
 
@@ -172,6 +221,80 @@ import { createTransformRequest } from '@chaosity/location-client'
172
221
  const transformRequest = createTransformRequest(apiUrl, () => currentToken)
173
222
  ```
174
223
 
224
+ #### fetchMapStyle
225
+
226
+ Fetches the map style descriptor with Bearer auth and applies optional language to the descriptor JSON before MapLibre processes it (eliminates the visual flash that occurs when modifying layers post-load).
227
+
228
+ ```typescript
229
+ import { fetchMapStyle } from '@chaosity/location-client'
230
+
231
+ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
232
+ colorScheme: 'Dark',
233
+ terrain: 'Terrain3D',
234
+ buildings: 'Buildings3D',
235
+ contourDensity: 'Medium',
236
+ traffic: 'All',
237
+ travelModes: ['Truck', 'Transit'],
238
+ language: 'fr',
239
+ })
240
+
241
+ const map = new maplibregl.Map({
242
+ style,
243
+ transformRequest: createTransformRequest(apiUrl, getToken),
244
+ })
245
+ ```
246
+
247
+ #### buildMapStyleUrl
248
+
249
+ Builds the style descriptor URL without fetching. Useful when you want to pass the URL directly to MapLibre (e.g. without pre-applying language).
250
+
251
+ ```typescript
252
+ import { buildMapStyleUrl } from '@chaosity/location-client'
253
+
254
+ const url = buildMapStyleUrl(apiUrl, 'Standard', {
255
+ colorScheme: 'Dark',
256
+ terrain: 'Hillshade',
257
+ })
258
+ ```
259
+
260
+ #### MapStyleOptions
261
+
262
+ ```typescript
263
+ interface MapStyleOptions {
264
+ colorScheme?: 'Light' | 'Dark'
265
+ politicalView?: string // ISO 3166-1 alpha-3 (e.g. 'IND', 'TUR')
266
+ terrain?: 'Hillshade' | 'Terrain3D'
267
+ buildings?: 'Buildings3D'
268
+ contourDensity?: 'Medium' // Only 'Medium' is supported by the AWS SDK
269
+ traffic?: 'All'
270
+ travelModes?: Array<'Truck' | 'Transit'>
271
+ }
272
+ ```
273
+
274
+ #### applyMapLanguage
275
+
276
+ Modifies symbol layer `text-field` expressions on an existing map to display labels in the specified language. No API call — operates entirely on the client.
277
+
278
+ ```typescript
279
+ import { applyMapLanguage } from '@chaosity/location-client'
280
+
281
+ applyMapLanguage(map, 'fr')
282
+ ```
283
+
284
+ #### POI Layer Control
285
+
286
+ ```typescript
287
+ import {
288
+ setPoiVisibility,
289
+ setAllPoiVisibility,
290
+ POI_CATEGORIES,
291
+ } from '@chaosity/location-client'
292
+ import type { PoiCategory } from '@chaosity/location-client'
293
+
294
+ setPoiVisibility(map, 'transit', false)
295
+ setAllPoiVisibility(map, false)
296
+ ```
297
+
175
298
  #### Available Commands
176
299
 
177
300
  All AWS Location Service commands from `@aws-sdk/client-geo-places`:
@@ -240,9 +363,19 @@ const connector = new LocationServiceConnector({
240
363
  token: config.token,
241
364
  })
242
365
 
243
- const result = await connector.send(new SuggestCommand({ QueryText: 'Vancouver' }))
366
+ const result = await connector.send(
367
+ new SuggestCommand({ QueryText: 'Vancouver' }),
368
+ )
244
369
  ```
245
370
 
371
+ ## Cache-Friendly Position Rounding
372
+
373
+ `BiasPosition` coordinates are automatically rounded to 2 decimal places (~1.1 km grid) before each API request. This maximizes cache hits across nearby users without affecting result quality — bias is approximate by nature.
374
+
375
+ `QueryPosition` (reverse geocode) retains full precision since it represents an exact point the user selected.
376
+
377
+ This is handled transparently in both `GeoPlacesClient` and `LocationServiceConnector` — no action needed in application code.
378
+
246
379
  ## Logging
247
380
 
248
381
  The library uses the `debug` package for optional verbose logging:
@@ -266,7 +399,7 @@ Full TypeScript support with types from AWS SDK:
266
399
  import type { SuggestCommandOutput } from '@aws-sdk/client-geo-places'
267
400
 
268
401
  const response: SuggestCommandOutput = await client.send(
269
- new SuggestCommand({ QueryText: 'Vancouver' })
402
+ new SuggestCommand({ QueryText: 'Vancouver' }),
270
403
  )
271
404
  ```
272
405
 
@@ -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 { GeoPlacesClient } from '../client/GeoPlacesClient';
3
+ import type { GeoPlacesClient } from '../client/GeoPlacesClient';
4
4
  /**
5
5
  * GeoPlaces - MapLibre adapter for AWS Location Service
6
6
  *
@@ -33,11 +33,15 @@ export class GeoPlaces {
33
33
  };
34
34
  if (config.countries) {
35
35
  commandInput.Filter = {
36
- IncludeCountries: Array.isArray(config.countries) ? config.countries : config.countries.split(','),
36
+ IncludeCountries: Array.isArray(config.countries)
37
+ ? config.countries
38
+ : config.countries.split(','),
37
39
  };
38
40
  }
39
- const response = await this.client.send(new GeocodeCommand(commandInput));
40
- const result = geocodeResponseToFeatureCollection(response, { flattenProperties: true });
41
+ const response = (await this.client.send(new GeocodeCommand(commandInput)));
42
+ const result = geocodeResponseToFeatureCollection(response, {
43
+ flattenProperties: true,
44
+ });
41
45
  log('forwardGeocode returned %d results', result.features?.length ?? 0);
42
46
  return result;
43
47
  }
@@ -51,8 +55,10 @@ export class GeoPlaces {
51
55
  MaxResults: config.limit || 1,
52
56
  Language: this.normalizeLanguage(config.language),
53
57
  };
54
- const response = await this.client.send(new ReverseGeocodeCommand(commandInput));
55
- const result = reverseGeocodeResponseToFeatureCollection(response, { flattenProperties: true });
58
+ const response = (await this.client.send(new ReverseGeocodeCommand(commandInput)));
59
+ const result = reverseGeocodeResponseToFeatureCollection(response, {
60
+ flattenProperties: true,
61
+ });
56
62
  log('reverseGeocode returned %d results', result.features?.length ?? 0);
57
63
  return result;
58
64
  }
@@ -68,14 +74,22 @@ export class GeoPlaces {
68
74
  MaxResults: config.limit || 5,
69
75
  Language: this.normalizeLanguage(config.language),
70
76
  AdditionalFeatures: [SuggestAdditionalFeature.CORE],
71
- ...(config.countries || config.bbox ? {
72
- Filter: {
73
- ...(config.countries ? { IncludeCountries: Array.isArray(config.countries) ? config.countries : config.countries.split(',') } : {}),
74
- ...(config.bbox ? { BoundingBox: config.bbox } : {}),
75
- },
76
- } : {}),
77
+ ...(config.countries || config.bbox
78
+ ? {
79
+ Filter: {
80
+ ...(config.countries
81
+ ? {
82
+ IncludeCountries: Array.isArray(config.countries)
83
+ ? config.countries
84
+ : config.countries.split(','),
85
+ }
86
+ : {}),
87
+ ...(config.bbox ? { BoundingBox: config.bbox } : {}),
88
+ },
89
+ }
90
+ : {}),
77
91
  };
78
- const response = await this.client.send(new SuggestCommand(commandInput));
92
+ const response = (await this.client.send(new SuggestCommand(commandInput)));
79
93
  const suggestions = { suggestions: [] };
80
94
  for (const item of response.ResultItems ?? []) {
81
95
  const text = item.Title;
@@ -99,9 +113,11 @@ export class GeoPlaces {
99
113
  GetPlaceAdditionalFeature.TIME_ZONE,
100
114
  ],
101
115
  });
102
- const response = await this.client.send(command);
103
- const result = getPlaceResponseToFeatureCollection(response, { flattenProperties: true });
104
- const carmenGeojsonFeatures = result.features.map(feature => ({
116
+ const response = (await this.client.send(command));
117
+ const result = getPlaceResponseToFeatureCollection(response, {
118
+ flattenProperties: true,
119
+ });
120
+ const carmenGeojsonFeatures = result.features.map((feature) => ({
105
121
  ...feature,
106
122
  text: response.Title || '',
107
123
  place_name: response.Address?.Label || '',
@@ -41,12 +41,14 @@ export class TokenProvider {
41
41
  }
42
42
  async getToken(forceRefresh = false) {
43
43
  if (!forceRefresh && this.cachedToken && !this.isExpired()) {
44
- const expiresIn = this.cachedExpiresAt ? Math.floor((this.cachedExpiresAt - Date.now()) / 1000) : 'unknown';
44
+ const expiresIn = this.cachedExpiresAt
45
+ ? Math.floor((this.cachedExpiresAt - Date.now()) / 1000)
46
+ : 'unknown';
45
47
  log('Using cached token (expires in %ds)', expiresIn);
46
48
  return {
47
49
  success: true,
48
50
  token: this.cachedToken,
49
- expiresAt: this.cachedExpiresAt
51
+ expiresAt: this.cachedExpiresAt,
50
52
  };
51
53
  }
52
54
  // If token fetch is already in progress, wait for it
@@ -55,7 +57,11 @@ export class TokenProvider {
55
57
  return this.tokenPromise;
56
58
  }
57
59
  // Start new token fetch
58
- const reason = forceRefresh ? 'forced refresh' : (this.cachedToken ? 'token expired' : 'no cached token');
60
+ const reason = forceRefresh
61
+ ? 'forced refresh'
62
+ : this.cachedToken
63
+ ? 'token expired'
64
+ : 'no cached token';
59
65
  log('Refreshing token (%s) from %s', reason, this.config.apiUrl);
60
66
  this.tokenPromise = this.fetchToken();
61
67
  try {
@@ -74,10 +80,12 @@ export class TokenProvider {
74
80
  const response = await fetch(`${apiUrl}/auth/token`, {
75
81
  method: 'POST',
76
82
  headers: {
77
- 'Authorization': `Basic ${credentials}`,
83
+ Authorization: `Basic ${credentials}`,
78
84
  'Content-Type': 'application/x-www-form-urlencoded',
79
85
  },
80
- body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(),
86
+ body: new URLSearchParams({
87
+ grant_type: 'client_credentials',
88
+ }).toString(),
81
89
  });
82
90
  if (!response.ok) {
83
91
  const errorText = await response.text();
@@ -89,13 +97,16 @@ export class TokenProvider {
89
97
  else if (errorData.error)
90
98
  errorMessage = errorData.error;
91
99
  }
92
- catch { /* use statusText */ }
100
+ catch {
101
+ /* use statusText */
102
+ }
93
103
  throw new Error(errorMessage);
94
104
  }
95
105
  const data = await response.json();
96
106
  this.cachedToken = data.access_token;
97
107
  // Prefer absolute expires_at (ms) from response, fall back to expires_in (seconds)
98
- this.cachedExpiresAt = data.expires_at ?? (Date.now() + ((data.expires_in ?? 900) * 1000));
108
+ this.cachedExpiresAt =
109
+ data.expires_at ?? Date.now() + (data.expires_in ?? 900) * 1000;
99
110
  const expiresInSec = Math.floor((this.cachedExpiresAt - Date.now()) / 1000);
100
111
  log('Token acquired successfully (expires in %ds)', expiresInSec);
101
112
  return {
@@ -115,7 +126,7 @@ export class TokenProvider {
115
126
  isExpired(bufferSeconds = 60) {
116
127
  if (!this.cachedExpiresAt)
117
128
  return true;
118
- return Date.now() >= (this.cachedExpiresAt - bufferSeconds * 1000);
129
+ return Date.now() >= this.cachedExpiresAt - bufferSeconds * 1000;
119
130
  }
120
131
  clearCache() {
121
132
  this.cachedToken = undefined;
@@ -1,4 +1,4 @@
1
- import { ClientConfig } from '../types';
1
+ import type { ClientConfig, GeoPlacesCommand } from '../types';
2
2
  /**
3
3
  * GeoPlacesClient - AWS Location Service compatible client with custom auth
4
4
  *
@@ -13,6 +13,6 @@ export declare class GeoPlacesClient {
13
13
  serviceId: string;
14
14
  };
15
15
  constructor(config: ClientConfig);
16
- send<TInput, TOutput>(command: TInput): Promise<TOutput>;
16
+ send<TOutput>(command: GeoPlacesCommand): Promise<TOutput>;
17
17
  private getEndpoint;
18
18
  }
@@ -1,5 +1,7 @@
1
- import debug from 'debug';
2
1
  import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
+ import debug from 'debug';
3
+ import { LocationServiceException } from '../errors/LocationServiceException';
4
+ import { roundPositionFields } from '../utils/roundPosition';
3
5
  const log = debug('location-client:api');
4
6
  const logError = debug('location-client:api:error');
5
7
  // Minification-safe endpoint map — uses constructor identity, not class name strings
@@ -29,7 +31,7 @@ export class GeoPlacesClient {
29
31
  const endpoint = this.getEndpoint(command);
30
32
  const url = `${this.clientConfig.apiUrl}${endpoint}`;
31
33
  const commandName = command.constructor.name;
32
- const input = command.input;
34
+ const input = roundPositionFields(command.input);
33
35
  // Prefer getToken callback (live ref) over static token string
34
36
  const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
35
37
  log('Sending %s to %s', commandName, endpoint);
@@ -38,7 +40,7 @@ export class GeoPlacesClient {
38
40
  method: 'POST',
39
41
  headers: {
40
42
  'Content-Type': 'application/json',
41
- 'Authorization': `Bearer ${token}`,
43
+ Authorization: `Bearer ${token}`,
42
44
  },
43
45
  body: JSON.stringify(input),
44
46
  });
@@ -47,16 +49,27 @@ export class GeoPlacesClient {
47
49
  const errorText = await response.text();
48
50
  logError('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
49
51
  let errorMessage = `API request failed: ${response.statusText}`;
52
+ let errorCode = 'ServiceException';
53
+ let requestId;
50
54
  try {
51
55
  const errorData = JSON.parse(errorText);
52
56
  if (errorData.message)
53
57
  errorMessage = errorData.message;
58
+ if (errorData.code)
59
+ errorCode = errorData.code;
60
+ if (errorData.requestId)
61
+ requestId = errorData.requestId;
54
62
  }
55
63
  catch {
56
64
  if (errorText)
57
65
  errorMessage = errorText;
58
66
  }
59
- throw new Error(errorMessage);
67
+ throw new LocationServiceException({
68
+ message: errorMessage,
69
+ code: errorCode,
70
+ statusCode: response.status,
71
+ requestId,
72
+ });
60
73
  }
61
74
  const result = await response.json();
62
75
  log('Request successful: %s (%dms)', response.status, duration);
@@ -0,0 +1,16 @@
1
+ export interface LocationServiceExceptionOptions {
2
+ message: string;
3
+ code: string;
4
+ statusCode: number;
5
+ requestId?: string;
6
+ }
7
+ export declare class LocationServiceException extends Error {
8
+ readonly code: string;
9
+ readonly statusCode: number;
10
+ readonly requestId?: string;
11
+ constructor(options: LocationServiceExceptionOptions);
12
+ get isRetryable(): boolean;
13
+ get isThrottling(): boolean;
14
+ get isValidation(): boolean;
15
+ toString(): string;
16
+ }
@@ -0,0 +1,24 @@
1
+ export class LocationServiceException extends Error {
2
+ constructor(options) {
3
+ super(options.message);
4
+ this.name = 'LocationServiceException';
5
+ this.code = options.code;
6
+ this.statusCode = options.statusCode;
7
+ this.requestId = options.requestId;
8
+ }
9
+ get isRetryable() {
10
+ return this.statusCode === 429 || this.statusCode >= 500;
11
+ }
12
+ get isThrottling() {
13
+ return this.code === 'ThrottlingException' || this.statusCode === 429;
14
+ }
15
+ get isValidation() {
16
+ return this.code === 'ValidationException' || this.statusCode === 400;
17
+ }
18
+ toString() {
19
+ const parts = [`LocationServiceException: [${this.code}] ${this.message}`];
20
+ if (this.requestId)
21
+ parts.push(`(requestId: ${this.requestId})`);
22
+ return parts.join(' ');
23
+ }
24
+ }
package/dist/index.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export { GeoPlacesClient } from './client/GeoPlacesClient';
2
+ export { LocationServiceException } from './errors/LocationServiceException';
3
+ export type { LocationServiceExceptionOptions } from './errors/LocationServiceException';
2
4
  export * from '@aws-sdk/client-geo-places';
3
5
  export * from '@aws/amazon-location-utilities-datatypes';
4
6
  export { GeoPlaces } from './adapters/GeoPlaces';
5
7
  export { createTransformRequest } from './maps/createTransformRequest';
6
- export { transformRequest } from './maps/Utils';
7
- export { buildMapStyleUrl, fetchMapStyle } from './maps/mapStyle';
8
- export type { MapStyleOptions } from './maps/mapStyle';
9
8
  export { applyMapLanguage } from './maps/mapLanguage';
10
- export { setPoiVisibility, setAllPoiVisibility, POI_CATEGORIES } from './maps/mapPoi';
9
+ export { POI_CATEGORIES, setAllPoiVisibility, setPoiVisibility, } from './maps/mapPoi';
11
10
  export type { PoiCategory } from './maps/mapPoi';
12
- export type { ClientConfig } from './types';
11
+ export { buildMapStyleUrl, fetchMapStyle } from './maps/mapStyle';
12
+ export type { MapStyleOptions } from './maps/mapStyle';
13
+ export { transformRequest } from './maps/Utils';
14
+ export type { ClientConfig, GeoPlacesCommand, MapLike } from './types';
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Client (Custom - uses our auth instead of AWS SigV4)
2
2
  export { GeoPlacesClient } from './client/GeoPlacesClient';
3
+ // Errors
4
+ export { LocationServiceException } from './errors/LocationServiceException';
3
5
  // Re-export AWS SDK commands and types
4
6
  export * from '@aws-sdk/client-geo-places';
5
7
  // Re-export AWS Location Utilities (data type conversions)
@@ -8,8 +10,8 @@ export * from '@aws/amazon-location-utilities-datatypes';
8
10
  export { GeoPlaces } from './adapters/GeoPlaces';
9
11
  // Maps utilities
10
12
  export { createTransformRequest } from './maps/createTransformRequest';
11
- export { transformRequest } from './maps/Utils';
12
- export { buildMapStyleUrl, fetchMapStyle } from './maps/mapStyle';
13
13
  export { applyMapLanguage } from './maps/mapLanguage';
14
- export { setPoiVisibility, setAllPoiVisibility, POI_CATEGORIES } from './maps/mapPoi';
14
+ export { POI_CATEGORIES, setAllPoiVisibility, setPoiVisibility, } from './maps/mapPoi';
15
+ export { buildMapStyleUrl, fetchMapStyle } from './maps/mapStyle';
16
+ export { transformRequest } from './maps/Utils';
15
17
  // Server-only utilities are available via '@chaosity/location-client/server'
@@ -1,2 +1,2 @@
1
- import { ClientConfig } from "../types";
2
- export declare function transformRequest(url: string, config: ClientConfig): import("maplibre-gl").RequestParameters | undefined;
1
+ import type { ClientConfig } from '../types';
2
+ export declare function transformRequest(url: string, config: ClientConfig): import("maplibre-gl").RequestParameters | Promise<import("maplibre-gl").RequestParameters> | undefined;
@@ -1,4 +1,4 @@
1
- import { createTransformRequest } from "./createTransformRequest";
1
+ import { createTransformRequest } from './createTransformRequest';
2
2
  export function transformRequest(url, config) {
3
3
  const token = config.getToken?.() ?? config.token;
4
4
  return createTransformRequest(config.apiUrl, () => token)(url);
@@ -15,7 +15,7 @@ export function createTransformRequest(apiUrl, getToken) {
15
15
  return { url };
16
16
  }
17
17
  const headers = {
18
- 'Authorization': `Bearer ${token}`,
18
+ Authorization: `Bearer ${token}`,
19
19
  };
20
20
  // Set appropriate Accept headers based on resource type
21
21
  if (url.includes('/tiles/')) {
@@ -1,4 +1,4 @@
1
- import type { Map } from 'maplibre-gl';
1
+ import type { MapLike } from '../types';
2
2
  /**
3
3
  * Apply a preferred display language to all symbol layers on a MapLibre map.
4
4
  *
@@ -10,10 +10,10 @@ import type { Map } from 'maplibre-gl';
10
10
  * Based on the approach documented at:
11
11
  * https://docs.aws.amazon.com/location/latest/developerguide/how-to-set-preferred-language-map.html
12
12
  *
13
- * @param map - MapLibre Map instance
13
+ * @param map - MapLibre Map instance (or any object matching the MapLike interface)
14
14
  * @param language - ISO 639-1 language code (e.g. 'en', 'fr', 'de', 'ja', 'zh', 'ar')
15
15
  *
16
16
  * @example
17
17
  * map.once('style.load', () => applyMapLanguage(map, 'fr'))
18
18
  */
19
- export declare function applyMapLanguage(map: Map, language: string): void;
19
+ export declare function applyMapLanguage(map: MapLike, language: string): void;
@@ -9,7 +9,7 @@
9
9
  * Based on the approach documented at:
10
10
  * https://docs.aws.amazon.com/location/latest/developerguide/how-to-set-preferred-language-map.html
11
11
  *
12
- * @param map - MapLibre Map instance
12
+ * @param map - MapLibre Map instance (or any object matching the MapLike interface)
13
13
  * @param language - ISO 639-1 language code (e.g. 'en', 'fr', 'de', 'ja', 'zh', 'ar')
14
14
  *
15
15
  * @example
@@ -19,8 +19,13 @@ export function applyMapLanguage(map, language) {
19
19
  try {
20
20
  const expression = language === 'en'
21
21
  ? ['coalesce', ['get', 'name:en'], ['get', 'name']]
22
- : ['coalesce', ['get', `name:${language}`], ['get', 'name:en'], ['get', 'name']];
23
- map.getStyle().layers.forEach(layer => {
22
+ : [
23
+ 'coalesce',
24
+ ['get', `name:${language}`],
25
+ ['get', 'name:en'],
26
+ ['get', 'name'],
27
+ ];
28
+ map.getStyle().layers.forEach((layer) => {
24
29
  if (layer.type === 'symbol') {
25
30
  const textField = map.getLayoutProperty(layer.id, 'text-field');
26
31
  if (textField !== undefined && textField !== null) {
@@ -56,14 +56,14 @@ export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}) {
56
56
  const token = getToken();
57
57
  const response = await fetch(url, {
58
58
  headers: {
59
- 'Authorization': `Bearer ${token}`,
60
- 'Accept': 'application/json',
61
- }
59
+ Authorization: `Bearer ${token}`,
60
+ Accept: 'application/json',
61
+ },
62
62
  });
63
63
  if (!response.ok) {
64
64
  throw new Error(`Failed to fetch map style: ${response.status} ${response.statusText}`);
65
65
  }
66
- const style = await response.json();
66
+ const style = (await response.json());
67
67
  if (language) {
68
68
  applyLanguageToDescriptor(style, language);
69
69
  }
@@ -76,7 +76,12 @@ export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}) {
76
76
  function applyLanguageToDescriptor(style, language) {
77
77
  const expression = language === 'en'
78
78
  ? ['coalesce', ['get', 'name:en'], ['get', 'name']]
79
- : ['coalesce', ['get', `name:${language}`], ['get', 'name:en'], ['get', 'name']];
79
+ : [
80
+ 'coalesce',
81
+ ['get', `name:${language}`],
82
+ ['get', 'name:en'],
83
+ ['get', 'name'],
84
+ ];
80
85
  for (const layer of style.layers) {
81
86
  if (layer.type === 'symbol') {
82
87
  const layout = layer.layout;
@@ -1,3 +1,4 @@
1
+ import type { GeoPlacesCommand } from '../types';
1
2
  export interface ConnectorConfig {
2
3
  apiUrl?: string;
3
4
  token?: string;
@@ -41,6 +42,6 @@ export declare class LocationServiceConnector {
41
42
  private configPromise;
42
43
  readonly serviceId: string;
43
44
  constructor(config?: ConnectorConfig);
44
- send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
45
+ send<TOutput>(command: GeoPlacesCommand, options?: SendOptions): Promise<TOutput>;
45
46
  private getEndpoint;
46
47
  }
@@ -1,5 +1,7 @@
1
1
  import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
2
  import debug from 'debug';
3
+ import { LocationServiceException } from '../errors/LocationServiceException';
4
+ import { roundPositionFields } from '../utils/roundPosition';
3
5
  import { getClientConfig } from './getClientConfig';
4
6
  const log = debug('location-client:connector');
5
7
  /**
@@ -34,9 +36,7 @@ const log = debug('location-client:connector');
34
36
  export class LocationServiceConnector {
35
37
  constructor(config) {
36
38
  this.serviceId = 'Geo Places';
37
- this.configPromise = config
38
- ? Promise.resolve(config)
39
- : getClientConfig();
39
+ this.configPromise = config ? Promise.resolve(config) : getClientConfig();
40
40
  }
41
41
  async send(command, options) {
42
42
  const config = await this.configPromise;
@@ -44,7 +44,11 @@ export class LocationServiceConnector {
44
44
  let token;
45
45
  if ('getToken' in config && typeof config.getToken === 'function') {
46
46
  const result = await config.getToken();
47
- token = result ? (typeof result === 'string' ? result : result.token) : undefined;
47
+ token = result
48
+ ? typeof result === 'string'
49
+ ? result
50
+ : result.token
51
+ : undefined;
48
52
  }
49
53
  else {
50
54
  token = config.token;
@@ -55,38 +59,46 @@ export class LocationServiceConnector {
55
59
  const endpoint = this.getEndpoint(command);
56
60
  const url = `${config.apiUrl}${endpoint}`;
57
61
  const commandName = command.constructor.name;
58
- const input = command.input;
62
+ const input = roundPositionFields(command.input);
59
63
  log('Sending %s request to %s', commandName, endpoint);
60
64
  const startTime = Date.now();
61
65
  // Merge headers: user headers, then system headers override
62
66
  const headers = {
63
67
  ...(options?.headers || {}),
64
68
  'Content-Type': 'application/json',
65
- 'Authorization': `Bearer ${token}`
69
+ Authorization: `Bearer ${token}`,
66
70
  };
67
71
  const response = await fetch(url, {
68
72
  method: 'POST',
69
73
  headers,
70
- body: JSON.stringify(input)
74
+ body: JSON.stringify(input),
71
75
  });
72
76
  const duration = Date.now() - startTime;
73
77
  if (!response.ok) {
74
78
  const errorText = await response.text();
75
79
  log('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
76
- // Try to parse error message from response
77
80
  let errorMessage = `API request failed: ${response.statusText}`;
81
+ let errorCode = 'ServiceException';
82
+ let requestId;
78
83
  try {
79
84
  const errorData = JSON.parse(errorText);
80
- if (errorData.message) {
85
+ if (errorData.message)
81
86
  errorMessage = errorData.message;
82
- }
87
+ if (errorData.code)
88
+ errorCode = errorData.code;
89
+ if (errorData.requestId)
90
+ requestId = errorData.requestId;
83
91
  }
84
92
  catch {
85
- // If not JSON, use raw text if available
86
93
  if (errorText)
87
94
  errorMessage = errorText;
88
95
  }
89
- throw new Error(errorMessage);
96
+ throw new LocationServiceException({
97
+ message: errorMessage,
98
+ code: errorCode,
99
+ statusCode: response.status,
100
+ requestId,
101
+ });
90
102
  }
91
103
  const result = await response.json();
92
104
  log('Request successful: %s (%dms)', response.status, duration);
@@ -1,4 +1,4 @@
1
- import { ClientConfig } from '../types';
1
+ import type { ClientConfig } from '../types';
2
2
  export interface ServerAuthConfig {
3
3
  apiUrl?: string;
4
4
  clientId?: string;
@@ -48,7 +48,10 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
48
48
  * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
49
49
  */
50
50
  export async function getClientConfig(config = {}) {
51
- log('[getClientConfig] Starting with config:', { hasApiUrl: !!config.apiUrl, hasClientId: !!config.clientId });
51
+ log('[getClientConfig] Starting with config:', {
52
+ hasApiUrl: !!config.apiUrl,
53
+ hasClientId: !!config.clientId,
54
+ });
52
55
  // Auto-detect from environment with fallbacks
53
56
  const apiUrl = config.apiUrl ||
54
57
  process.env.LOCATION_API_URL ||
@@ -59,7 +62,10 @@ export async function getClientConfig(config = {}) {
59
62
  const clientSecret = config.clientSecret ||
60
63
  process.env.LOCATION_CLIENT_SECRET ||
61
64
  process.env.LOCATION_SERVICE_CLIENT_SECRET;
62
- log('[getClientConfig] Resolved config:', { apiUrl, clientId: clientId?.substring(0, 10) + '...' });
65
+ log('[getClientConfig] Resolved config:', {
66
+ apiUrl,
67
+ clientId: clientId?.substring(0, 10) + '...',
68
+ });
63
69
  // Validate required values
64
70
  if (!apiUrl || !clientId || !clientSecret) {
65
71
  console.error('[getClientConfig] Missing required configuration');
@@ -77,12 +83,12 @@ export async function getClientConfig(config = {}) {
77
83
  throw new Error(isAuthError
78
84
  ? `Authentication failed for client ID "${clientId}". ` +
79
85
  `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`
80
- : (result.error || 'Failed to get token'));
86
+ : result.error || 'Failed to get token');
81
87
  }
82
88
  log('[getClientConfig] Token fetched successfully, length:', result.token.length);
83
89
  return {
84
90
  apiUrl,
85
91
  token: result.token,
86
- expiresAt: result.expiresAt
92
+ expiresAt: result.expiresAt,
87
93
  };
88
94
  }
@@ -1,6 +1,6 @@
1
+ export { TokenProvider } from '../auth/TokenProvider';
2
+ export type { TokenProviderConfig, TokenResponse } from '../auth/TokenProvider';
1
3
  export { getClientConfig } from './getClientConfig';
2
4
  export type { ServerAuthConfig, ServerClientConfig } from './getClientConfig';
3
5
  export { LocationServiceConnector } from './LocationServiceConnector';
4
6
  export type { ConnectorConfig, SendOptions } from './LocationServiceConnector';
5
- export { TokenProvider } from '../auth/TokenProvider';
6
- export type { TokenProviderConfig, TokenResponse } from '../auth/TokenProvider';
@@ -1,3 +1,3 @@
1
+ export { TokenProvider } from '../auth/TokenProvider';
1
2
  export { getClientConfig } from './getClientConfig';
2
3
  export { LocationServiceConnector } from './LocationServiceConnector';
3
- export { TokenProvider } from '../auth/TokenProvider';
@@ -5,3 +5,30 @@ export interface ClientConfig {
5
5
  * called on every request so token updates are reflected without recreating the client. */
6
6
  getToken?: () => string | undefined;
7
7
  }
8
+ /**
9
+ * Minimal interface for AWS SDK command objects.
10
+ * All AWS SDK commands (AutocompleteCommand, SearchTextCommand, etc.) extend
11
+ * Smithy's Command base class which has an `input` property containing the
12
+ * request parameters. This interface captures what we actually need from
13
+ * commands without coupling to Smithy internals.
14
+ */
15
+ export interface GeoPlacesCommand {
16
+ readonly input: object;
17
+ }
18
+ /**
19
+ * Minimal interface for a MapLibre Map instance.
20
+ * Using a structural type avoids hard coupling to a specific maplibre-gl version.
21
+ */
22
+ export interface MapLike {
23
+ isStyleLoaded(): boolean | void;
24
+ on(event: string, listener: (...args: unknown[]) => void): void;
25
+ off(event: string, listener: (...args: unknown[]) => void): void;
26
+ getStyle(): {
27
+ layers: Array<{
28
+ id: string;
29
+ type: string;
30
+ }>;
31
+ };
32
+ getLayoutProperty(layerId: string, name: string): unknown;
33
+ setLayoutProperty(layerId: string, name: string, value: unknown): void;
34
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Round coordinate arrays in command inputs for better API cache hits.
3
+ *
4
+ * BiasPosition is rounded to 2 decimal places (~1.1 km precision) —
5
+ * sufficient for spatial biasing while maximizing cache reuse across
6
+ * nearby users.
7
+ *
8
+ * QueryPosition (reverse geocode) keeps full precision since it
9
+ * represents an exact point the user clicked or their device reported.
10
+ */
11
+ /**
12
+ * Shallow-clone the input and round position arrays that benefit from caching.
13
+ * Returns the original object if no position fields are present.
14
+ */
15
+ export declare function roundPositionFields<T extends object>(input: T): T;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Round coordinate arrays in command inputs for better API cache hits.
3
+ *
4
+ * BiasPosition is rounded to 2 decimal places (~1.1 km precision) —
5
+ * sufficient for spatial biasing while maximizing cache reuse across
6
+ * nearby users.
7
+ *
8
+ * QueryPosition (reverse geocode) keeps full precision since it
9
+ * represents an exact point the user clicked or their device reported.
10
+ */
11
+ const BIAS_DECIMALS = 2; // ~1.1 km grid
12
+ const BIAS_FACTOR = 10 ** BIAS_DECIMALS;
13
+ /** Known position field names and whether they should be rounded */
14
+ const POSITION_FIELDS = {
15
+ BiasPosition: true, // geocode, autocomplete, search — round for cache
16
+ QueryPosition: false, // reverse geocode — keep full precision
17
+ };
18
+ function roundCoord(value) {
19
+ return Math.round(value * BIAS_FACTOR) / BIAS_FACTOR;
20
+ }
21
+ /**
22
+ * Shallow-clone the input and round position arrays that benefit from caching.
23
+ * Returns the original object if no position fields are present.
24
+ */
25
+ export function roundPositionFields(input) {
26
+ if (!input || typeof input !== 'object')
27
+ return input;
28
+ let modified = false;
29
+ const result = { ...input };
30
+ for (const [field, shouldRound] of Object.entries(POSITION_FIELDS)) {
31
+ const value = result[field];
32
+ if (!shouldRound || !Array.isArray(value) || value.length < 2)
33
+ continue;
34
+ result[field] = value.map(roundCoord);
35
+ modified = true;
36
+ }
37
+ return modified ? result : input;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,9 +18,14 @@
18
18
  "scripts": {
19
19
  "build": "tsc",
20
20
  "dev": "tsc --watch",
21
- "test": "vitest run",
21
+ "lint": "eslint . && prettier --check .",
22
+ "lint:fix": "eslint --fix . && prettier --write .",
23
+ "format": "prettier --write .",
24
+ "test": "vitest run --passWithNoTests",
22
25
  "test:watch": "vitest",
23
- "prepublishOnly": "npm run build"
26
+ "prepublishOnly": "npm run build",
27
+ "prepare": "husky",
28
+ "postversion": "git push --follow-tags"
24
29
  },
25
30
  "keywords": [
26
31
  "aws",
@@ -55,10 +60,17 @@
55
60
  }
56
61
  },
57
62
  "devDependencies": {
63
+ "@eslint/js": "^10.0.1",
58
64
  "@types/debug": "^4.1.12",
59
65
  "@types/node": "^25.0.3",
66
+ "@vitest/coverage-v8": "^3.0.0",
67
+ "eslint": "^10.1.0",
68
+ "husky": "^9.0.0",
60
69
  "maplibre-gl": "^5.0.0",
70
+ "prettier": "^3.8.1",
71
+ "prettier-plugin-organize-imports": "^4.3.0",
61
72
  "typescript": "^5.0.0",
73
+ "typescript-eslint": "^8.57.1",
62
74
  "vitest": "^3.0.0"
63
75
  },
64
76
  "publishConfig": {
@@ -1,37 +0,0 @@
1
- import { LocationServiceConnector } from './LocationServiceConnector';
2
- export interface ServerAuthConfig {
3
- apiUrl?: string;
4
- clientId?: string;
5
- clientSecret?: string;
6
- }
7
- /**
8
- * Get LocationServiceConnector with automatic token management.
9
- *
10
- * Automatically reads from environment variables:
11
- * - LOCATION_API_URL or LOCATION_SERVICE_API_URL
12
- * - LOCATION_CLIENT_ID or LOCATION_SERVICE_CLIENT_ID
13
- * - LOCATION_CLIENT_SECRET or LOCATION_SERVICE_CLIENT_SECRET
14
- *
15
- * You can override any value by passing it explicitly.
16
- *
17
- * WARNING: This function uses client credentials (clientId/clientSecret).
18
- * Only call this from:
19
- * - Next.js Server Components/Actions
20
- * - Node.js backend servers
21
- * - API routes
22
- *
23
- * NEVER call from browser/client code as it exposes credentials.
24
- *
25
- * @example
26
- * // Server Action for React provider
27
- * 'use server'
28
- * export async function getLocationConnector() {
29
- * return getConnector()
30
- * }
31
- *
32
- * // Or with custom config
33
- * export async function getLocationConnector() {
34
- * return getConnector({ apiUrl: 'https://custom.api.com' })
35
- * }
36
- */
37
- export declare function getConnector(config?: ServerAuthConfig): LocationServiceConnector;
@@ -1,34 +0,0 @@
1
- import { LocationServiceConnector } from './LocationServiceConnector';
2
- /**
3
- * Get LocationServiceConnector with automatic token management.
4
- *
5
- * Automatically reads from environment variables:
6
- * - LOCATION_API_URL or LOCATION_SERVICE_API_URL
7
- * - LOCATION_CLIENT_ID or LOCATION_SERVICE_CLIENT_ID
8
- * - LOCATION_CLIENT_SECRET or LOCATION_SERVICE_CLIENT_SECRET
9
- *
10
- * You can override any value by passing it explicitly.
11
- *
12
- * WARNING: This function uses client credentials (clientId/clientSecret).
13
- * Only call this from:
14
- * - Next.js Server Components/Actions
15
- * - Node.js backend servers
16
- * - API routes
17
- *
18
- * NEVER call from browser/client code as it exposes credentials.
19
- *
20
- * @example
21
- * // Server Action for React provider
22
- * 'use server'
23
- * export async function getLocationConnector() {
24
- * return getConnector()
25
- * }
26
- *
27
- * // Or with custom config
28
- * export async function getLocationConnector() {
29
- * return getConnector({ apiUrl: 'https://custom.api.com' })
30
- * }
31
- */
32
- export function getConnector(config) {
33
- return new LocationServiceConnector(config);
34
- }