@chaosity/location-client 0.1.4 → 0.1.6

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.
@@ -1,27 +1,19 @@
1
- import type { FeatureCollection } from 'geojson';
1
+ import type { MaplibreGeocoderApi, MaplibreGeocoderApiConfig, MaplibreGeocoderFeatureResults, MaplibreGeocoderPlaceResults, MaplibreGeocoderSuggestionResults } from '@maplibre/maplibre-gl-geocoder';
2
2
  import type { Map } from 'maplibre-gl';
3
3
  import { GeoPlacesClient } from '../client/GeoPlacesClient';
4
4
  /**
5
5
  * GeoPlaces - MapLibre adapter for AWS Location Service
6
6
  *
7
- * Uses AWS Location Client commands and utilities for data conversion.
8
- * Only auth is custom (Bearer token instead of SigV4).
7
+ * Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
9
8
  */
10
- export declare class GeoPlaces {
9
+ export declare class GeoPlaces implements MaplibreGeocoderApi {
11
10
  private client;
12
11
  private map;
13
12
  constructor(client: GeoPlacesClient, map: Map);
14
- forwardGeocode(config: {
15
- query: string;
16
- limit?: number;
17
- }): Promise<FeatureCollection>;
18
- reverseGeocode(config: {
19
- query: [number, number];
20
- limit?: number;
21
- click?: boolean;
22
- }): Promise<FeatureCollection>;
23
- getSuggestions(config: {
24
- query: string;
25
- }): Promise<FeatureCollection>;
26
- searchByPlaceId(placeId: string): Promise<FeatureCollection>;
13
+ private normalizeLanguage;
14
+ forwardGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
15
+ reverseGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
16
+ getSuggestions(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderSuggestionResults>;
17
+ searchByPlaceId(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderPlaceResults>;
18
+ localGeocode(config: MaplibreGeocoderApiConfig): Promise<MaplibreGeocoderFeatureResults>;
27
19
  }
@@ -1,58 +1,117 @@
1
- import { placeToFeatureCollection } from '@aws/amazon-location-utilities-datatypes';
2
- import { GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SuggestCommand } from '@aws-sdk/client-geo-places';
1
+ import { GeocodeCommand, GetPlaceAdditionalFeature, GetPlaceCommand, ReverseGeocodeCommand, SuggestAdditionalFeature, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
+ import { geocodeResponseToFeatureCollection, getPlaceResponseToFeatureCollection, reverseGeocodeResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
3
+ import debug from 'debug';
4
+ const log = debug('location-client:geocoder');
3
5
  /**
4
6
  * GeoPlaces - MapLibre adapter for AWS Location Service
5
7
  *
6
- * Uses AWS Location Client commands and utilities for data conversion.
7
- * Only auth is custom (Bearer token instead of SigV4).
8
+ * Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
8
9
  */
9
10
  export class GeoPlaces {
10
11
  constructor(client, map) {
11
12
  this.client = client;
12
13
  this.map = map;
13
14
  }
15
+ normalizeLanguage(language) {
16
+ if (Array.isArray(language))
17
+ return language[0] || 'en';
18
+ if (typeof language === 'string')
19
+ return language;
20
+ return 'en';
21
+ }
14
22
  async forwardGeocode(config) {
23
+ log('forwardGeocode query=%s', config.query);
15
24
  const center = this.map.getCenter();
16
- const command = new SuggestCommand({
25
+ const biasPosition = config.proximity && config.proximity.length >= 2
26
+ ? [config.proximity[0], config.proximity[1]]
27
+ : [center.lng, center.lat];
28
+ const commandInput = {
17
29
  QueryText: config.query,
18
- BiasPosition: [center.lng, center.lat],
30
+ BiasPosition: biasPosition,
19
31
  MaxResults: config.limit || 5,
20
- Language: 'en'
21
- });
22
- const response = await this.client.send(command);
23
- return placeToFeatureCollection(response, { flattenProperties: true });
32
+ Language: this.normalizeLanguage(config.language),
33
+ };
34
+ if (config.countries) {
35
+ commandInput.Filter = {
36
+ IncludeCountries: Array.isArray(config.countries) ? config.countries : config.countries.split(','),
37
+ };
38
+ }
39
+ const response = await this.client.send(new GeocodeCommand(commandInput));
40
+ const result = geocodeResponseToFeatureCollection(response, { flattenProperties: true });
41
+ log('forwardGeocode returned %d results', result.features?.length ?? 0);
42
+ return result;
24
43
  }
25
44
  async reverseGeocode(config) {
26
- const command = config.click
27
- ? new ReverseGeocodeCommand({
28
- QueryPosition: config.query,
29
- MaxResults: config.limit || 1,
30
- Language: 'en'
31
- })
32
- : new SearchNearbyCommand({
33
- QueryPosition: config.query,
34
- MaxResults: config.limit || 15,
35
- Language: 'en'
36
- });
37
- const response = await this.client.send(command);
38
- return placeToFeatureCollection(response, { flattenProperties: true });
45
+ log('reverseGeocode query=%o', config.query);
46
+ const queryPosition = Array.isArray(config.query) && config.query.length >= 2
47
+ ? [config.query[0], config.query[1]]
48
+ : [0, 0];
49
+ const commandInput = {
50
+ QueryPosition: queryPosition,
51
+ MaxResults: config.limit || 1,
52
+ Language: this.normalizeLanguage(config.language),
53
+ };
54
+ const response = await this.client.send(new ReverseGeocodeCommand(commandInput));
55
+ const result = reverseGeocodeResponseToFeatureCollection(response, { flattenProperties: true });
56
+ log('reverseGeocode returned %d results', result.features?.length ?? 0);
57
+ return result;
39
58
  }
40
59
  async getSuggestions(config) {
60
+ log('getSuggestions query=%s', config.query);
41
61
  const center = this.map.getCenter();
42
- const command = new SuggestCommand({
62
+ const biasPosition = config.proximity && config.proximity.length >= 2
63
+ ? [config.proximity[0], config.proximity[1]]
64
+ : [center.lng, center.lat];
65
+ const commandInput = {
43
66
  QueryText: config.query,
44
- BiasPosition: [center.lng, center.lat],
45
- Language: 'en'
46
- });
47
- const response = await this.client.send(command);
48
- return placeToFeatureCollection(response, { flattenProperties: true });
67
+ BiasPosition: biasPosition,
68
+ MaxResults: config.limit || 5,
69
+ Language: this.normalizeLanguage(config.language),
70
+ 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
+ };
78
+ const response = await this.client.send(new SuggestCommand(commandInput));
79
+ const suggestions = { suggestions: [] };
80
+ for (const item of response.ResultItems ?? []) {
81
+ const text = item.Title;
82
+ if (!text)
83
+ continue;
84
+ const placeId = item.Place?.PlaceId;
85
+ suggestions.suggestions.push({ text, placeId });
86
+ }
87
+ log('getSuggestions returned %d suggestions', suggestions.suggestions.length);
88
+ return suggestions;
49
89
  }
50
- async searchByPlaceId(placeId) {
90
+ async searchByPlaceId(config) {
91
+ log('searchByPlaceId placeId=%s', config.query);
51
92
  const command = new GetPlaceCommand({
52
- PlaceId: placeId,
53
- Language: 'en'
93
+ PlaceId: config.query,
94
+ Language: this.normalizeLanguage(config.language),
95
+ AdditionalFeatures: [
96
+ GetPlaceAdditionalFeature.ACCESS,
97
+ GetPlaceAdditionalFeature.SECONDARY_ADDRESSES,
98
+ GetPlaceAdditionalFeature.CONTACT,
99
+ GetPlaceAdditionalFeature.TIME_ZONE,
100
+ ],
54
101
  });
55
102
  const response = await this.client.send(command);
56
- return placeToFeatureCollection(response, { flattenProperties: true });
103
+ const result = getPlaceResponseToFeatureCollection(response, { flattenProperties: true });
104
+ const carmenGeojsonFeatures = result.features.map(feature => ({
105
+ ...feature,
106
+ text: response.Title || '',
107
+ place_name: response.Address?.Label || '',
108
+ place_type: response.PlaceType ? [response.PlaceType] : [],
109
+ bbox: response.MapView,
110
+ }));
111
+ log('searchByPlaceId returned %d features', carmenGeojsonFeatures.length);
112
+ return { place: carmenGeojsonFeatures };
113
+ }
114
+ async localGeocode(config) {
115
+ return this.forwardGeocode(config);
57
116
  }
58
117
  }
@@ -1,7 +1,6 @@
1
1
  export interface TokenResponse {
2
2
  success: boolean;
3
3
  token?: string;
4
- expiresIn?: number;
5
4
  expiresAt?: number;
6
5
  error?: string;
7
6
  }
@@ -26,7 +25,7 @@ export interface TokenProviderConfig {
26
25
  *
27
26
  * @example
28
27
  * // ✓ Correct: Server-side usage
29
- * import { TokenProvider } from '@chaosity/location-client'
28
+ * import { TokenProvider } from '@chaosity/location-client/server'
30
29
  *
31
30
  * const provider = new TokenProvider({
32
31
  * apiUrl: process.env.API_URL!,
@@ -41,10 +40,11 @@ export interface TokenProviderConfig {
41
40
  export declare class TokenProvider {
42
41
  private config;
43
42
  private cachedToken?;
44
- private expiresAt?;
45
- private oauth2Client;
43
+ private cachedExpiresAt?;
44
+ private tokenPromise?;
46
45
  constructor(config: TokenProviderConfig);
47
46
  getToken(forceRefresh?: boolean): Promise<TokenResponse>;
47
+ private fetchToken;
48
48
  private isExpired;
49
49
  clearCache(): void;
50
50
  }
@@ -1,4 +1,3 @@
1
- import ClientOAuth2 from 'client-oauth2';
2
1
  import debug from 'debug';
3
2
  const log = debug('location-client:auth');
4
3
  /**
@@ -17,7 +16,7 @@ const log = debug('location-client:auth');
17
16
  *
18
17
  * @example
19
18
  * // ✓ Correct: Server-side usage
20
- * import { TokenProvider } from '@chaosity/location-client'
19
+ * import { TokenProvider } from '@chaosity/location-client/server'
21
20
  *
22
21
  * const provider = new TokenProvider({
23
22
  * apiUrl: process.env.API_URL!,
@@ -39,36 +38,70 @@ export class TokenProvider {
39
38
  }
40
39
  log('Initializing TokenProvider for %s', config.apiUrl);
41
40
  this.config = config;
42
- this.oauth2Client = new ClientOAuth2({
43
- clientId: config.clientId,
44
- clientSecret: config.clientSecret,
45
- accessTokenUri: `${config.apiUrl}/auth/token`,
46
- });
47
41
  }
48
42
  async getToken(forceRefresh = false) {
49
- if (!forceRefresh && this.cachedToken && this.expiresAt && !this.isExpired()) {
50
- log('Using cached token (expires in %ds)', Math.floor((this.expiresAt - Date.now()) / 1000));
43
+ if (!forceRefresh && this.cachedToken && !this.isExpired()) {
44
+ const expiresIn = this.cachedExpiresAt ? Math.floor((this.cachedExpiresAt - Date.now()) / 1000) : 'unknown';
45
+ log('Using cached token (expires in %ds)', expiresIn);
51
46
  return {
52
47
  success: true,
53
48
  token: this.cachedToken,
54
- expiresAt: this.expiresAt
49
+ expiresAt: this.cachedExpiresAt
55
50
  };
56
51
  }
52
+ // If token fetch is already in progress, wait for it
53
+ if (this.tokenPromise) {
54
+ log('Token fetch in progress, waiting for existing request...');
55
+ return this.tokenPromise;
56
+ }
57
+ // Start new token fetch
57
58
  const reason = forceRefresh ? 'forced refresh' : (this.cachedToken ? 'token expired' : 'no cached token');
58
59
  log('Refreshing token (%s) from %s', reason, this.config.apiUrl);
60
+ this.tokenPromise = this.fetchToken();
61
+ try {
62
+ const result = await this.tokenPromise;
63
+ return result;
64
+ }
65
+ finally {
66
+ // Clear promise after completion (success or failure)
67
+ this.tokenPromise = undefined;
68
+ }
69
+ }
70
+ async fetchToken() {
59
71
  try {
60
- const token = await this.oauth2Client.credentials.getToken();
61
- const expiresIn = typeof token.data?.expires_in === 'number'
62
- ? token.data.expires_in
63
- : parseInt(token.data?.expires_in || '3600');
64
- this.cachedToken = token.accessToken;
65
- this.expiresAt = Date.now() + (expiresIn * 1000);
72
+ const { clientId, clientSecret, apiUrl } = this.config;
73
+ const credentials = btoa(`${clientId}:${clientSecret}`);
74
+ const response = await fetch(`${apiUrl}/auth/token`, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Authorization': `Basic ${credentials}`,
78
+ 'Content-Type': 'application/x-www-form-urlencoded',
79
+ },
80
+ body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(),
81
+ });
82
+ if (!response.ok) {
83
+ const errorText = await response.text();
84
+ let errorMessage = `Token request failed: ${response.statusText}`;
85
+ try {
86
+ const errorData = JSON.parse(errorText);
87
+ if (errorData.error_description)
88
+ errorMessage = errorData.error_description;
89
+ else if (errorData.error)
90
+ errorMessage = errorData.error;
91
+ }
92
+ catch { /* use statusText */ }
93
+ throw new Error(errorMessage);
94
+ }
95
+ const data = await response.json();
96
+ this.cachedToken = data.access_token;
97
+ // Use expires_in from OAuth2 response; null-coalesce so 0 is not replaced
98
+ const expiresIn = data.expires_in ?? 900;
99
+ this.cachedExpiresAt = Date.now() + (expiresIn * 1000);
66
100
  log('Token acquired successfully (expires in %ds)', expiresIn);
67
101
  return {
68
102
  success: true,
69
103
  token: this.cachedToken,
70
- expiresIn,
71
- expiresAt: this.expiresAt,
104
+ expiresAt: this.cachedExpiresAt,
72
105
  };
73
106
  }
74
107
  catch (error) {
@@ -80,12 +113,12 @@ export class TokenProvider {
80
113
  }
81
114
  }
82
115
  isExpired(bufferSeconds = 60) {
83
- if (!this.expiresAt)
116
+ if (!this.cachedExpiresAt)
84
117
  return true;
85
- return Date.now() >= (this.expiresAt - bufferSeconds * 1000);
118
+ return Date.now() >= (this.cachedExpiresAt - bufferSeconds * 1000);
86
119
  }
87
120
  clearCache() {
88
121
  this.cachedToken = undefined;
89
- this.expiresAt = undefined;
122
+ this.cachedExpiresAt = undefined;
90
123
  }
91
124
  }
@@ -2,8 +2,10 @@ import { ClientConfig } from '../types';
2
2
  /**
3
3
  * GeoPlacesClient - AWS Location Service compatible client with custom auth
4
4
  *
5
- * Uses AWS Location Client command classes but replaces SigV4 auth with Bearer token.
5
+ * Uses AWS SDK command classes but replaces SigV4 auth with Bearer token.
6
6
  * All request/response types are identical to AWS Location Service.
7
+ *
8
+ * Pass `getToken` in config to support live token refresh without recreating the client.
7
9
  */
8
10
  export declare class GeoPlacesClient {
9
11
  private clientConfig;
@@ -1,11 +1,24 @@
1
- import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
1
  import debug from 'debug';
2
+ import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
3
3
  const log = debug('location-client:api');
4
+ const logError = debug('location-client:api:error');
5
+ // Minification-safe endpoint map — uses constructor identity, not class name strings
6
+ const ENDPOINT_MAP = new Map([
7
+ [AutocompleteCommand, '/address/autocomplete'],
8
+ [GeocodeCommand, '/address/geocode'],
9
+ [GetPlaceCommand, '/address/place'],
10
+ [ReverseGeocodeCommand, '/address/search/reverse-geocode'],
11
+ [SearchNearbyCommand, '/address/search/nearby'],
12
+ [SearchTextCommand, '/address/search/text'],
13
+ [SuggestCommand, '/address/suggestion'],
14
+ ]);
4
15
  /**
5
16
  * GeoPlacesClient - AWS Location Service compatible client with custom auth
6
17
  *
7
- * Uses AWS Location Client command classes but replaces SigV4 auth with Bearer token.
18
+ * Uses AWS SDK command classes but replaces SigV4 auth with Bearer token.
8
19
  * All request/response types are identical to AWS Location Service.
20
+ *
21
+ * Pass `getToken` in config to support live token refresh without recreating the client.
9
22
  */
10
23
  export class GeoPlacesClient {
11
24
  constructor(config) {
@@ -15,46 +28,45 @@ export class GeoPlacesClient {
15
28
  async send(command) {
16
29
  const endpoint = this.getEndpoint(command);
17
30
  const url = `${this.clientConfig.apiUrl}${endpoint}`;
18
- log('Sending %s request to %s', command.constructor.name, endpoint);
31
+ const commandName = command.constructor.name;
32
+ const input = command.input;
33
+ // Prefer getToken callback (live ref) over static token string
34
+ const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
35
+ log('Sending %s to %s', commandName, endpoint);
19
36
  const startTime = Date.now();
20
37
  const response = await fetch(url, {
21
38
  method: 'POST',
22
39
  headers: {
23
40
  'Content-Type': 'application/json',
24
- 'Authorization': `Bearer ${this.clientConfig.token}`
41
+ 'Authorization': `Bearer ${token}`,
25
42
  },
26
- body: JSON.stringify(command)
43
+ body: JSON.stringify(input),
27
44
  });
28
45
  const duration = Date.now() - startTime;
29
46
  if (!response.ok) {
30
- log('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
31
- throw new Error(`API request failed: ${response.statusText}`);
47
+ const errorText = await response.text();
48
+ logError('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
49
+ let errorMessage = `API request failed: ${response.statusText}`;
50
+ try {
51
+ const errorData = JSON.parse(errorText);
52
+ if (errorData.message)
53
+ errorMessage = errorData.message;
54
+ }
55
+ catch {
56
+ if (errorText)
57
+ errorMessage = errorText;
58
+ }
59
+ throw new Error(errorMessage);
32
60
  }
61
+ const result = await response.json();
33
62
  log('Request successful: %s (%dms)', response.status, duration);
34
- return response.json();
63
+ return result;
35
64
  }
36
65
  getEndpoint(command) {
37
- if (command instanceof AutocompleteCommand) {
38
- return '/address/autocomplete';
39
- }
40
- else if (command instanceof GeocodeCommand) {
41
- return '/address/geocode';
42
- }
43
- else if (command instanceof GetPlaceCommand) {
44
- return '/address/place';
45
- }
46
- else if (command instanceof ReverseGeocodeCommand) {
47
- return '/address/search/reverse-geocode';
48
- }
49
- else if (command instanceof SearchNearbyCommand) {
50
- return '/address/search/nearby';
51
- }
52
- else if (command instanceof SearchTextCommand) {
53
- return '/address/search/text';
54
- }
55
- else if (command instanceof SuggestCommand) {
56
- return '/address/suggestion';
66
+ for (const [CommandClass, endpoint] of ENDPOINT_MAP) {
67
+ if (command instanceof CommandClass)
68
+ return endpoint;
57
69
  }
58
- throw new Error('Unknown command type');
70
+ throw new Error(`Unknown command type: ${command.constructor?.name ?? typeof command}`);
59
71
  }
60
72
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,7 @@
1
- export { TokenProvider } from './auth/TokenProvider';
2
- export type { TokenResponse, TokenProviderConfig } from './auth/TokenProvider';
3
1
  export { GeoPlacesClient } from './client/GeoPlacesClient';
4
2
  export * from '@aws-sdk/client-geo-places';
5
3
  export * from '@aws/amazon-location-utilities-datatypes';
6
4
  export { GeoPlaces } from './adapters/GeoPlaces';
5
+ export { createTransformRequest } from './maps/createTransformRequest';
6
+ export { transformRequest } from './maps/Utils';
7
7
  export type { ClientConfig } from './types';
8
- export { getClientConfig } from './server/getClientConfig';
9
- export type { ServerAuthConfig } from './server/getClientConfig';
package/dist/index.js CHANGED
@@ -1,5 +1,3 @@
1
- // Auth (Custom - only difference from AWS SDK)
2
- export { TokenProvider } from './auth/TokenProvider';
3
1
  // Client (Custom - uses our auth instead of AWS SigV4)
4
2
  export { GeoPlacesClient } from './client/GeoPlacesClient';
5
3
  // Re-export AWS SDK commands and types
@@ -8,5 +6,7 @@ export * from '@aws-sdk/client-geo-places';
8
6
  export * from '@aws/amazon-location-utilities-datatypes';
9
7
  // Adapters (Custom - for MapLibre integration)
10
8
  export { GeoPlaces } from './adapters/GeoPlaces';
11
- // Server-only utilities
12
- export { getClientConfig } from './server/getClientConfig';
9
+ // Maps utilities
10
+ export { createTransformRequest } from './maps/createTransformRequest';
11
+ export { transformRequest } from './maps/Utils';
12
+ // Server-only utilities are available via '@chaosity/location-client/server'
@@ -0,0 +1,2 @@
1
+ import { ClientConfig } from "../types";
2
+ export declare function transformRequest(url: string, config: ClientConfig): import("maplibre-gl").RequestParameters | undefined;
@@ -0,0 +1,5 @@
1
+ import { createTransformRequest } from "./createTransformRequest";
2
+ export function transformRequest(url, config) {
3
+ const token = config.getToken?.() ?? config.token;
4
+ return createTransformRequest(config.apiUrl, () => token)(url);
5
+ }
@@ -0,0 +1,10 @@
1
+ import type { RequestTransformFunction } from 'maplibre-gl';
2
+ /**
3
+ * Creates a transformRequest function for MapLibre that adds authentication
4
+ * and proper Accept headers for AWS Location Service API requests.
5
+ *
6
+ * @param apiUrl - Base URL of the Location Service API
7
+ * @param getToken - Callback function that returns the current auth token
8
+ * @returns MapLibre transformRequest function
9
+ */
10
+ export declare function createTransformRequest(apiUrl: string, getToken: () => string | undefined): RequestTransformFunction;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Creates a transformRequest function for MapLibre that adds authentication
3
+ * and proper Accept headers for AWS Location Service API requests.
4
+ *
5
+ * @param apiUrl - Base URL of the Location Service API
6
+ * @param getToken - Callback function that returns the current auth token
7
+ * @returns MapLibre transformRequest function
8
+ */
9
+ export function createTransformRequest(apiUrl, getToken) {
10
+ return (url, resourceType) => {
11
+ if (url.startsWith(apiUrl)) {
12
+ const token = getToken();
13
+ if (!token) {
14
+ console.warn('[createTransformRequest] No token available');
15
+ return { url };
16
+ }
17
+ const headers = {
18
+ 'Authorization': `Bearer ${token}`,
19
+ };
20
+ // Set appropriate Accept headers based on resource type
21
+ if (url.includes('/tiles/')) {
22
+ headers['Accept'] = 'application/x-protobuf';
23
+ }
24
+ else if (url.includes('/glyphs/')) {
25
+ headers['Accept'] = 'application/x-protobuf';
26
+ }
27
+ else if (url.includes('/sprites/') && url.endsWith('.png')) {
28
+ headers['Accept'] = 'image/png';
29
+ }
30
+ else if (url.includes('/sprites/') && url.endsWith('.json')) {
31
+ headers['Accept'] = 'application/json';
32
+ }
33
+ else if (url.includes('/descriptor')) {
34
+ headers['Accept'] = 'application/json';
35
+ }
36
+ return { url, headers };
37
+ }
38
+ return { url };
39
+ };
40
+ }
@@ -0,0 +1,46 @@
1
+ export interface ConnectorConfig {
2
+ apiUrl?: string;
3
+ token?: string;
4
+ getToken?: () => Promise<{
5
+ token: string;
6
+ }>;
7
+ }
8
+ export interface SendOptions {
9
+ headers?: Record<string, string>;
10
+ }
11
+ /**
12
+ * LocationServiceConnector - Server-side connector for Location Service API
13
+ *
14
+ * Optimized for backend-to-backend communication with:
15
+ * - Automatic configuration from environment variables
16
+ * - Automatic Origin header handling
17
+ * - Server-side token management
18
+ * - Enhanced error handling
19
+ *
20
+ * Uses AWS SDK command classes with Bearer token authentication.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // Auto-detect from environment
25
+ * const connector = new LocationServiceConnector()
26
+ *
27
+ * // Or provide explicit config
28
+ * const connector = new LocationServiceConnector({
29
+ * apiUrl: 'https://api.example.com',
30
+ * token: 'your-token'
31
+ * })
32
+ *
33
+ * // Send with custom headers (including Origin)
34
+ * const result = await connector.send(
35
+ * new SearchTextCommand({ QueryText: 'Space Needle' }),
36
+ * { headers: { 'Origin': req.headers.origin } }
37
+ * )
38
+ * ```
39
+ */
40
+ export declare class LocationServiceConnector {
41
+ private configPromise;
42
+ readonly serviceId: string;
43
+ constructor(config?: ConnectorConfig);
44
+ send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
45
+ private getEndpoint;
46
+ }
@@ -0,0 +1,112 @@
1
+ import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
+ import debug from 'debug';
3
+ import { getClientConfig } from './getClientConfig';
4
+ const log = debug('location-client:connector');
5
+ /**
6
+ * LocationServiceConnector - Server-side connector for Location Service API
7
+ *
8
+ * Optimized for backend-to-backend communication with:
9
+ * - Automatic configuration from environment variables
10
+ * - Automatic Origin header handling
11
+ * - Server-side token management
12
+ * - Enhanced error handling
13
+ *
14
+ * Uses AWS SDK command classes with Bearer token authentication.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * // Auto-detect from environment
19
+ * const connector = new LocationServiceConnector()
20
+ *
21
+ * // Or provide explicit config
22
+ * const connector = new LocationServiceConnector({
23
+ * apiUrl: 'https://api.example.com',
24
+ * token: 'your-token'
25
+ * })
26
+ *
27
+ * // Send with custom headers (including Origin)
28
+ * const result = await connector.send(
29
+ * new SearchTextCommand({ QueryText: 'Space Needle' }),
30
+ * { headers: { 'Origin': req.headers.origin } }
31
+ * )
32
+ * ```
33
+ */
34
+ export class LocationServiceConnector {
35
+ constructor(config) {
36
+ this.serviceId = 'Geo Places';
37
+ this.configPromise = config
38
+ ? Promise.resolve(config)
39
+ : getClientConfig();
40
+ }
41
+ async send(command, options) {
42
+ const config = await this.configPromise;
43
+ // Get token - ConnectorConfig may have getToken, ServerClientConfig has static token
44
+ let token;
45
+ if ('getToken' in config && typeof config.getToken === 'function') {
46
+ const result = await config.getToken();
47
+ token = result ? (typeof result === 'string' ? result : result.token) : undefined;
48
+ }
49
+ else {
50
+ token = config.token;
51
+ }
52
+ if (!token) {
53
+ throw new Error('No token available');
54
+ }
55
+ const endpoint = this.getEndpoint(command);
56
+ const url = `${config.apiUrl}${endpoint}`;
57
+ const commandName = command.constructor.name;
58
+ const input = command.input;
59
+ log('Sending %s request to %s', commandName, endpoint);
60
+ const startTime = Date.now();
61
+ // Merge headers: user headers, then system headers override
62
+ const headers = {
63
+ ...(options?.headers || {}),
64
+ 'Content-Type': 'application/json',
65
+ 'Authorization': `Bearer ${token}`
66
+ };
67
+ const response = await fetch(url, {
68
+ method: 'POST',
69
+ headers,
70
+ body: JSON.stringify(input)
71
+ });
72
+ const duration = Date.now() - startTime;
73
+ if (!response.ok) {
74
+ const errorText = await response.text();
75
+ log('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
76
+ // Try to parse error message from response
77
+ let errorMessage = `API request failed: ${response.statusText}`;
78
+ try {
79
+ const errorData = JSON.parse(errorText);
80
+ if (errorData.message) {
81
+ errorMessage = errorData.message;
82
+ }
83
+ }
84
+ catch {
85
+ // If not JSON, use raw text if available
86
+ if (errorText)
87
+ errorMessage = errorText;
88
+ }
89
+ throw new Error(errorMessage);
90
+ }
91
+ const result = await response.json();
92
+ log('Request successful: %s (%dms)', response.status, duration);
93
+ return result;
94
+ }
95
+ getEndpoint(command) {
96
+ if (command instanceof AutocompleteCommand)
97
+ return '/address/autocomplete';
98
+ if (command instanceof GeocodeCommand)
99
+ return '/address/geocode';
100
+ if (command instanceof GetPlaceCommand)
101
+ return '/address/place';
102
+ if (command instanceof ReverseGeocodeCommand)
103
+ return '/address/search/reverse-geocode';
104
+ if (command instanceof SearchNearbyCommand)
105
+ return '/address/search/nearby';
106
+ if (command instanceof SearchTextCommand)
107
+ return '/address/search/text';
108
+ if (command instanceof SuggestCommand)
109
+ return '/address/suggestion';
110
+ throw new Error(`Unknown command type: ${command.constructor?.name ?? typeof command}`);
111
+ }
112
+ }
@@ -1,9 +1,22 @@
1
1
  import { ClientConfig } from '../types';
2
- import { TokenProviderConfig } from '../auth/TokenProvider';
3
- export type ServerAuthConfig = TokenProviderConfig;
2
+ export interface ServerAuthConfig {
3
+ apiUrl?: string;
4
+ clientId?: string;
5
+ clientSecret?: string;
6
+ }
7
+ export interface ServerClientConfig extends ClientConfig {
8
+ expiresAt?: number;
9
+ }
4
10
  /**
5
11
  * Get client configuration with OAuth2 authentication.
6
12
  *
13
+ * Automatically reads from environment variables:
14
+ * - LOCATION_API_URL or LOCATION_SERVICE_API_URL
15
+ * - LOCATION_CLIENT_ID or LOCATION_SERVICE_CLIENT_ID
16
+ * - LOCATION_CLIENT_SECRET or LOCATION_SERVICE_CLIENT_SECRET
17
+ *
18
+ * You can override any value by passing it explicitly.
19
+ *
7
20
  * WARNING: This function uses client credentials (clientId/clientSecret).
8
21
  * Only call this from:
9
22
  * - Next.js Server Components/Actions
@@ -12,7 +25,16 @@ export type ServerAuthConfig = TokenProviderConfig;
12
25
  *
13
26
  * NEVER call from browser/client code as it exposes credentials.
14
27
  * For SPA projects, create your own backend endpoint that calls this.
28
+ *
29
+ * @example
30
+ * // Auto-detect from environment
31
+ * const config = await getClientConfig()
32
+ *
33
+ * // Or override specific values
34
+ * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
35
+ *
36
+ * // Use getToken() for automatic caching and refresh
37
+ * const { token } = await config.getToken()
38
+ * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
15
39
  */
16
- export declare function getClientConfig(config: ServerAuthConfig): Promise<ClientConfig & {
17
- expiresAt?: number;
18
- }>;
40
+ export declare function getClientConfig(config?: ServerAuthConfig): Promise<ServerClientConfig>;
@@ -1,7 +1,32 @@
1
+ import debug from 'debug';
1
2
  import { TokenProvider } from '../auth/TokenProvider';
3
+ const log = debug('location-client:clientConfig');
4
+ // Singleton instance to prevent race conditions
5
+ let tokenProviderInstance = null;
6
+ let currentConfig = null;
7
+ function getTokenProvider(apiUrl, clientId, clientSecret) {
8
+ const configKey = `${apiUrl}:${clientId}`;
9
+ // Reuse existing instance if config matches
10
+ if (tokenProviderInstance && currentConfig === configKey) {
11
+ log('[getTokenProvider] Reusing existing TokenProvider instance');
12
+ return tokenProviderInstance;
13
+ }
14
+ // Create new instance if config changed
15
+ log('[getTokenProvider] Creating new TokenProvider instance');
16
+ tokenProviderInstance = new TokenProvider({ apiUrl, clientId, clientSecret });
17
+ currentConfig = configKey;
18
+ return tokenProviderInstance;
19
+ }
2
20
  /**
3
21
  * Get client configuration with OAuth2 authentication.
4
22
  *
23
+ * Automatically reads from environment variables:
24
+ * - LOCATION_API_URL or LOCATION_SERVICE_API_URL
25
+ * - LOCATION_CLIENT_ID or LOCATION_SERVICE_CLIENT_ID
26
+ * - LOCATION_CLIENT_SECRET or LOCATION_SERVICE_CLIENT_SECRET
27
+ *
28
+ * You can override any value by passing it explicitly.
29
+ *
5
30
  * WARNING: This function uses client credentials (clientId/clientSecret).
6
31
  * Only call this from:
7
32
  * - Next.js Server Components/Actions
@@ -10,16 +35,49 @@ import { TokenProvider } from '../auth/TokenProvider';
10
35
  *
11
36
  * NEVER call from browser/client code as it exposes credentials.
12
37
  * For SPA projects, create your own backend endpoint that calls this.
38
+ *
39
+ * @example
40
+ * // Auto-detect from environment
41
+ * const config = await getClientConfig()
42
+ *
43
+ * // Or override specific values
44
+ * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
45
+ *
46
+ * // Use getToken() for automatic caching and refresh
47
+ * const { token } = await config.getToken()
48
+ * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
13
49
  */
14
- export async function getClientConfig(config) {
15
- const provider = new TokenProvider(config);
50
+ export async function getClientConfig(config = {}) {
51
+ log('[getClientConfig] Starting with config:', { hasApiUrl: !!config.apiUrl, hasClientId: !!config.clientId });
52
+ // Auto-detect from environment with fallbacks
53
+ const apiUrl = config.apiUrl ||
54
+ process.env.LOCATION_API_URL ||
55
+ process.env.LOCATION_SERVICE_API_URL;
56
+ const clientId = config.clientId ||
57
+ process.env.LOCATION_CLIENT_ID ||
58
+ process.env.LOCATION_SERVICE_CLIENT_ID;
59
+ const clientSecret = config.clientSecret ||
60
+ process.env.LOCATION_CLIENT_SECRET ||
61
+ process.env.LOCATION_SERVICE_CLIENT_SECRET;
62
+ log('[getClientConfig] Resolved config:', { apiUrl, clientId: clientId?.substring(0, 10) + '...' });
63
+ // Validate required values
64
+ if (!apiUrl || !clientId || !clientSecret) {
65
+ console.error('[getClientConfig] Missing required configuration');
66
+ throw new Error('Missing required configuration. Set environment variables: ' +
67
+ 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET');
68
+ }
69
+ log('[getClientConfig] Getting TokenProvider instance');
70
+ const provider = getTokenProvider(apiUrl, clientId, clientSecret);
71
+ log('[getClientConfig] Fetching token');
16
72
  const result = await provider.getToken();
17
73
  if (!result.success || !result.token) {
74
+ console.error('[getClientConfig] Token fetch failed:', result.error);
18
75
  throw new Error(result.error || 'Failed to get token');
19
76
  }
77
+ log('[getClientConfig] Token fetched successfully, length:', result.token.length);
20
78
  return {
21
- apiUrl: config.apiUrl,
79
+ apiUrl,
22
80
  token: result.token,
23
- expiresAt: result.expiresAt,
81
+ expiresAt: result.expiresAt
24
82
  };
25
83
  }
@@ -0,0 +1,37 @@
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;
@@ -0,0 +1,34 @@
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
+ }
@@ -0,0 +1,6 @@
1
+ export { getClientConfig } from './getClientConfig';
2
+ export type { ServerAuthConfig, ServerClientConfig } from './getClientConfig';
3
+ export { LocationServiceConnector } from './LocationServiceConnector';
4
+ export type { ConnectorConfig, SendOptions } from './LocationServiceConnector';
5
+ export { TokenProvider } from '../auth/TokenProvider';
6
+ export type { TokenProviderConfig, TokenResponse } from '../auth/TokenProvider';
@@ -0,0 +1,3 @@
1
+ export { getClientConfig } from './getClientConfig';
2
+ export { LocationServiceConnector } from './LocationServiceConnector';
3
+ export { TokenProvider } from '../auth/TokenProvider';
@@ -1,4 +1,7 @@
1
1
  export interface ClientConfig {
2
2
  apiUrl: string;
3
3
  token: string;
4
+ /** Optional callback to get the current token dynamically. When provided,
5
+ * called on every request so token updates are reflected without recreating the client. */
6
+ getToken?: () => string | undefined;
4
7
  }
package/package.json CHANGED
@@ -1,13 +1,25 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
+ "type": "module",
5
6
  "main": "dist/index.js",
6
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./server": {
14
+ "types": "./dist/server/index.d.ts",
15
+ "default": "./dist/server/index.js"
16
+ }
17
+ },
7
18
  "scripts": {
8
19
  "build": "tsc",
9
20
  "dev": "tsc --watch",
10
- "test": "jest",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
11
23
  "prepublishOnly": "npm run build"
12
24
  },
13
25
  "keywords": [
@@ -31,11 +43,11 @@
31
43
  "dependencies": {
32
44
  "@aws-sdk/client-geo-places": "^3.0.0",
33
45
  "@aws/amazon-location-utilities-datatypes": "^1.0.0",
34
- "client-oauth2": "^4.3.3",
35
- "debug": "^4.4.3"
46
+ "@maplibre/maplibre-gl-geocoder": "^1.9.4",
47
+ "debug": "^4.4.3"
36
48
  },
37
49
  "peerDependencies": {
38
- "maplibre-gl": "^4.0.0"
50
+ "maplibre-gl": "^5.0.0"
39
51
  },
40
52
  "peerDependenciesMeta": {
41
53
  "maplibre-gl": {
@@ -44,7 +56,13 @@
44
56
  },
45
57
  "devDependencies": {
46
58
  "@types/debug": "^4.1.12",
47
- "typescript": "^5.0.0"
59
+ "@types/node": "^25.0.3",
60
+ "maplibre-gl": "^5.0.0",
61
+ "typescript": "^5.0.0",
62
+ "vitest": "^3.0.0"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
48
66
  },
49
67
  "files": [
50
68
  "dist",