@chaosity/location-client 0.1.3 → 0.1.5

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
@@ -2,6 +2,20 @@
2
2
 
3
3
  AWS Location Service compatible client with custom Bearer token authentication.
4
4
 
5
+ ## ⚠️ Security Warning
6
+
7
+ **This package contains server-side authentication utilities that require client credentials.**
8
+
9
+ - `TokenProvider` and `getClientConfig()` are **SERVER-SIDE ONLY**
10
+ - They require `clientId` and `clientSecret` which must **NEVER** be exposed to browsers
11
+ - Only use these in:
12
+ - Node.js servers
13
+ - Next.js Server Actions (`'use server'`)
14
+ - Next.js API routes
15
+ - Backend services
16
+
17
+ **For React applications**, use [`@chaosity/location-client-react`](https://www.npmjs.com/package/@chaosity/location-client-react) which handles authentication safely.
18
+
5
19
  ## Installation
6
20
 
7
21
  ```bash
@@ -148,6 +162,33 @@ import {
148
162
  } from '@chaosity/location-client'
149
163
  ```
150
164
 
165
+ ## Logging
166
+
167
+ The library uses the `debug` package for optional verbose logging. Enable it via the `DEBUG` environment variable:
168
+
169
+ ```bash
170
+ # Enable all location-client logs
171
+ DEBUG=location-client:* npm run dev
172
+
173
+ # Enable only authentication logs
174
+ DEBUG=location-client:auth npm run dev
175
+
176
+ # Enable only API request logs
177
+ DEBUG=location-client:api npm run dev
178
+
179
+ # Enable multiple namespaces
180
+ DEBUG=location-client:*,express:* npm run dev
181
+ ```
182
+
183
+ Example output:
184
+ ```
185
+ location-client:auth Initializing TokenProvider for https://api.example.com +0ms
186
+ location-client:auth Fetching new token from https://api.example.com +2ms
187
+ location-client:auth Token acquired successfully (expires in 3600s) +145ms
188
+ location-client:api Sending SuggestCommand request to /address/suggestion +0ms
189
+ location-client:api Request successful: 200 (89ms) +89ms
190
+ ```
191
+
151
192
  ## Security Best Practices
152
193
 
153
194
  ⚠️ **NEVER expose client credentials in browser code!**
@@ -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,147 @@
1
- import { placeToFeatureCollection } from '@aws/amazon-location-utilities-datatypes';
2
- import { GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SuggestCommand } from '@aws-sdk/client-geo-places';
1
+ import { GeocodeAdditionalFeature, GeocodeCommand, GetPlaceAdditionalFeature, GetPlaceCommand, ReverseGeocodeCommand } from '@aws-sdk/client-geo-places';
2
+ import { geocodeResponseToFeatureCollection, getPlaceResponseToFeatureCollection, reverseGeocodeResponseToFeatureCollection } from '@aws/amazon-location-utilities-datatypes';
3
3
  /**
4
4
  * GeoPlaces - MapLibre adapter for AWS Location Service
5
5
  *
6
- * Uses AWS Location Client commands and utilities for data conversion.
7
- * Only auth is custom (Bearer token instead of SigV4).
6
+ * Implements MaplibreGeocoderApi interface for compatibility with @maplibre/maplibre-gl-geocoder
8
7
  */
9
8
  export class GeoPlaces {
10
9
  constructor(client, map) {
11
10
  this.client = client;
12
11
  this.map = map;
13
12
  }
13
+ normalizeLanguage(language) {
14
+ if (Array.isArray(language))
15
+ return language[0] || 'en';
16
+ if (typeof language === 'string')
17
+ return language;
18
+ return 'en';
19
+ }
14
20
  async forwardGeocode(config) {
21
+ console.log('[GeoPlaces] forwardGeocode called with:', config);
15
22
  const center = this.map.getCenter();
16
- const command = new SuggestCommand({
23
+ const biasPosition = config.proximity && config.proximity.length >= 2
24
+ ? [config.proximity[0], config.proximity[1]]
25
+ : [center.lng, center.lat];
26
+ const commandInput = {
17
27
  QueryText: config.query,
18
- BiasPosition: [center.lng, center.lat],
28
+ BiasPosition: biasPosition,
19
29
  MaxResults: config.limit || 5,
20
- Language: 'en'
21
- });
30
+ Language: this.normalizeLanguage(config.language),
31
+ };
32
+ if (config.countries || config.bbox) {
33
+ commandInput.Filter = {};
34
+ if (config.countries)
35
+ commandInput.Filter.IncludeCountries = config.countries;
36
+ if (config.bbox)
37
+ commandInput.Filter.BoundingBox = config.bbox;
38
+ }
39
+ console.log('[GeoPlaces] Sending SearchTextCommand with:', commandInput);
40
+ const command = new GeocodeCommand(commandInput);
22
41
  const response = await this.client.send(command);
23
- return placeToFeatureCollection(response, { flattenProperties: true });
42
+ console.log('[GeoPlaces] SearchTextCommand response:', response);
43
+ const result = geocodeResponseToFeatureCollection(response, { flattenProperties: true });
44
+ console.log('[GeoPlaces] Converted to FeatureCollection:', result);
45
+ return result;
24
46
  }
25
47
  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
- });
48
+ console.log('[GeoPlaces] reverseGeocode called with:', config);
49
+ const queryPosition = Array.isArray(config.query) && config.query.length >= 2
50
+ ? [config.query[0], config.query[1]]
51
+ : [0, 0];
52
+ const commandInput = {
53
+ QueryPosition: queryPosition,
54
+ MaxResults: config.limit || 1,
55
+ Language: this.normalizeLanguage(config.language)
56
+ };
57
+ if (config.countries) {
58
+ commandInput.Filter = { IncludeCountries: config.countries };
59
+ }
60
+ const command = new ReverseGeocodeCommand(commandInput);
37
61
  const response = await this.client.send(command);
38
- return placeToFeatureCollection(response, { flattenProperties: true });
62
+ const maplibreGeocoderFeatureResults = reverseGeocodeResponseToFeatureCollection(response, { flattenProperties: true });
63
+ maplibreGeocoderFeatureResults.features.forEach(feature => {
64
+ });
65
+ console.log('[GeoPlaces] Converted to FeatureCollection:', maplibreGeocoderFeatureResults);
66
+ return maplibreGeocoderFeatureResults;
39
67
  }
40
68
  async getSuggestions(config) {
69
+ // const center = this.map.getCenter()
70
+ // const biasPosition = config.proximity && config.proximity.length >= 2
71
+ // ? [config.proximity[0], config.proximity[1]]
72
+ // : [center.lng, center.lat]
73
+ // const commandInput: any = {
74
+ // QueryText: config.query as string,
75
+ // BiasPosition: biasPosition,
76
+ // MaxResults: config.limit || 5,
77
+ // Language: this.normalizeLanguage(config.language),
78
+ // AdditionalFeatures: [SuggestAdditionalFeature.CORE],
79
+ // }
80
+ // if (config.countries || config.bbox) {
81
+ // commandInput.Filter = {}
82
+ // if (config.countries) commandInput.Filter.IncludeCountries = config.countries
83
+ // if (config.bbox) commandInput.Filter.BoundingBox = config.bbox
84
+ // }
85
+ // const command = new SuggestCommand(commandInput)
86
+ // const response = await this.client.send(command) as SuggestResponse
41
87
  const center = this.map.getCenter();
42
- const command = new SuggestCommand({
88
+ const biasPosition = config.proximity && config.proximity.length >= 2
89
+ ? [config.proximity[0], config.proximity[1]]
90
+ : [center.lng, center.lat];
91
+ const commandInput = {
43
92
  QueryText: config.query,
44
- BiasPosition: [center.lng, center.lat],
45
- Language: 'en'
46
- });
93
+ BiasPosition: biasPosition,
94
+ MaxResults: config.limit || 5,
95
+ Language: this.normalizeLanguage(config.language),
96
+ AdditionalFeatures: [GeocodeAdditionalFeature.SECONDARY_ADDRESSES],
97
+ };
98
+ if (config.countries || config.bbox) {
99
+ commandInput.Filter = {};
100
+ if (config.countries)
101
+ commandInput.Filter.IncludeCountries = Array.isArray(config.countries) ? config.countries : config.countries.split(',');
102
+ // if (config.types) commandInput.Filter.IncludePlaceTypes = config.types.split(',').map(type => new GeocodeFilterPlaceType)
103
+ }
104
+ console.log('[GeoPlaces] Sending SearchTextCommand with:', commandInput);
105
+ const command = new GeocodeCommand(commandInput);
47
106
  const response = await this.client.send(command);
48
- return placeToFeatureCollection(response, { flattenProperties: true });
107
+ console.log('[GeoPlaces] SearchTextCommand response:', response);
108
+ const maplibreGeocoderSuggestionResults = { suggestions: [] };
109
+ response?.ResultItems?.forEach(item => {
110
+ const text = item?.Title || item?.Address?.Label;
111
+ const placeId = item?.PlaceId;
112
+ if (text) {
113
+ maplibreGeocoderSuggestionResults.suggestions.push({
114
+ text,
115
+ placeId,
116
+ });
117
+ }
118
+ });
119
+ console.log('[GeoPlaces] Converted to MaplibreGeocoderSuggestionResults:', maplibreGeocoderSuggestionResults);
120
+ return maplibreGeocoderSuggestionResults;
49
121
  }
50
- async searchByPlaceId(placeId) {
122
+ async searchByPlaceId(config) {
123
+ console.log('[GeoPlaces] searchByPlaceId called with:', config);
124
+ const placeId = config.query;
51
125
  const command = new GetPlaceCommand({
52
126
  PlaceId: placeId,
53
- Language: 'en'
127
+ Language: this.normalizeLanguage(config.language),
128
+ AdditionalFeatures: [GetPlaceAdditionalFeature.ACCESS, GetPlaceAdditionalFeature.SECONDARY_ADDRESSES, GetPlaceAdditionalFeature.CONTACT, GetPlaceAdditionalFeature.TIME_ZONE],
54
129
  });
55
130
  const response = await this.client.send(command);
56
- return placeToFeatureCollection(response, { flattenProperties: true });
131
+ const result = getPlaceResponseToFeatureCollection(response, { flattenProperties: true });
132
+ console.log('[GeoPlaces] GetPlaceCommand response converted to FeatureCollection:', result);
133
+ const carmenGeojsonFeatures = result.features.map(feature => ({
134
+ ...feature,
135
+ text: response.Title || '',
136
+ place_name: response.Address?.Label || '',
137
+ place_type: response.PlaceType ? [response.PlaceType] : [],
138
+ bbox: response.MapView
139
+ }));
140
+ console.log('[GeoPlaces] Converted to CarmenGeojsonFeatures:', carmenGeojsonFeatures);
141
+ return { place: carmenGeojsonFeatures };
142
+ }
143
+ async localGeocode(config) {
144
+ console.log('[GeoPlaces] localGeocode called with:', config);
145
+ return this.forwardGeocode(config);
57
146
  }
58
147
  }
@@ -1,8 +1,6 @@
1
1
  export interface TokenResponse {
2
2
  success: boolean;
3
3
  token?: string;
4
- expiresIn?: number;
5
- expiresAt?: number;
6
4
  error?: string;
7
5
  }
8
6
  export interface TokenProviderConfig {
@@ -10,12 +8,43 @@ export interface TokenProviderConfig {
10
8
  clientId: string;
11
9
  clientSecret: string;
12
10
  }
11
+ /**
12
+ * TokenProvider - SERVER-SIDE ONLY
13
+ *
14
+ * ⚠️ WARNING: This class requires client credentials (clientId and clientSecret)
15
+ * and must NEVER be used in browser/client-side code.
16
+ *
17
+ * Use this only in:
18
+ * - Node.js server environments
19
+ * - Next.js Server Actions (marked with 'use server')
20
+ * - Next.js API routes
21
+ * - Backend services
22
+ *
23
+ * For browser usage, use the React provider which receives tokens from server-side code.
24
+ *
25
+ * @example
26
+ * // ✓ Correct: Server-side usage
27
+ * import { TokenProvider } from '@chaosity/location-client'
28
+ *
29
+ * const provider = new TokenProvider({
30
+ * apiUrl: process.env.API_URL!,
31
+ * clientId: process.env.CLIENT_ID!,
32
+ * clientSecret: process.env.CLIENT_SECRET!,
33
+ * })
34
+ *
35
+ * @example
36
+ * // ✗ Wrong: Never use in browser code
37
+ * // This would expose your credentials!
38
+ */
13
39
  export declare class TokenProvider {
14
40
  private config;
15
41
  private cachedToken?;
16
- private expiresAt?;
42
+ private oauth2Client;
43
+ private tokenPromise?;
17
44
  constructor(config: TokenProviderConfig);
18
45
  getToken(forceRefresh?: boolean): Promise<TokenResponse>;
46
+ private fetchToken;
47
+ private getTokenExpiry;
19
48
  private isExpired;
20
49
  clearCache(): void;
21
50
  }
@@ -1,52 +1,115 @@
1
+ import ClientOAuth2 from 'client-oauth2';
2
+ import { decodeJwt } from 'jose';
3
+ import debug from 'debug';
4
+ const log = debug('location-client:auth');
5
+ /**
6
+ * TokenProvider - SERVER-SIDE ONLY
7
+ *
8
+ * ⚠️ WARNING: This class requires client credentials (clientId and clientSecret)
9
+ * and must NEVER be used in browser/client-side code.
10
+ *
11
+ * Use this only in:
12
+ * - Node.js server environments
13
+ * - Next.js Server Actions (marked with 'use server')
14
+ * - Next.js API routes
15
+ * - Backend services
16
+ *
17
+ * For browser usage, use the React provider which receives tokens from server-side code.
18
+ *
19
+ * @example
20
+ * // ✓ Correct: Server-side usage
21
+ * import { TokenProvider } from '@chaosity/location-client'
22
+ *
23
+ * const provider = new TokenProvider({
24
+ * apiUrl: process.env.API_URL!,
25
+ * clientId: process.env.CLIENT_ID!,
26
+ * clientSecret: process.env.CLIENT_SECRET!,
27
+ * })
28
+ *
29
+ * @example
30
+ * // ✗ Wrong: Never use in browser code
31
+ * // This would expose your credentials!
32
+ */
1
33
  export class TokenProvider {
2
34
  constructor(config) {
35
+ // Runtime check: prevent usage in browser
36
+ if (typeof window !== 'undefined') {
37
+ throw new Error('TokenProvider cannot be used in browser environments. ' +
38
+ 'It requires client credentials that must never be exposed to browsers. ' +
39
+ 'Use @chaosity/location-client-react for browser usage.');
40
+ }
41
+ log('Initializing TokenProvider for %s', config.apiUrl);
3
42
  this.config = config;
43
+ this.oauth2Client = new ClientOAuth2({
44
+ clientId: config.clientId,
45
+ clientSecret: config.clientSecret,
46
+ accessTokenUri: `${config.apiUrl}/auth/token`,
47
+ });
4
48
  }
5
49
  async getToken(forceRefresh = false) {
6
- if (!forceRefresh && this.cachedToken && this.expiresAt && !this.isExpired()) {
50
+ if (!forceRefresh && this.cachedToken && !this.isExpired()) {
51
+ const exp = this.getTokenExpiry();
52
+ log('Using cached token (expires in %ds)', exp ? Math.floor(exp - Date.now() / 1000) : 'unknown');
7
53
  return {
8
54
  success: true,
9
- token: this.cachedToken,
10
- expiresAt: this.expiresAt
55
+ token: this.cachedToken
11
56
  };
12
57
  }
58
+ // If token fetch is already in progress, wait for it
59
+ if (this.tokenPromise) {
60
+ log('Token fetch in progress, waiting for existing request...');
61
+ return this.tokenPromise;
62
+ }
63
+ // Start new token fetch
64
+ const reason = forceRefresh ? 'forced refresh' : (this.cachedToken ? 'token expired' : 'no cached token');
65
+ log('Refreshing token (%s) from %s', reason, this.config.apiUrl);
66
+ this.tokenPromise = this.fetchToken();
67
+ try {
68
+ const result = await this.tokenPromise;
69
+ return result;
70
+ }
71
+ finally {
72
+ // Clear promise after completion (success or failure)
73
+ this.tokenPromise = undefined;
74
+ }
75
+ }
76
+ async fetchToken() {
13
77
  try {
14
- const response = await fetch(`${this.config.apiUrl}/auth/token`, {
15
- method: 'POST',
16
- headers: { 'Content-Type': 'application/json' },
17
- body: JSON.stringify({
18
- client_id: this.config.clientId,
19
- client_secret: this.config.clientSecret,
20
- grant_type: 'client_credentials',
21
- }),
22
- });
23
- if (!response.ok) {
24
- throw new Error(`Failed to get token: ${response.statusText}`);
25
- }
26
- const data = await response.json();
27
- this.cachedToken = data.access_token;
28
- this.expiresAt = Date.now() + (data.expires_in * 1000);
78
+ const token = await this.oauth2Client.credentials.getToken();
79
+ this.cachedToken = token.accessToken;
80
+ const exp = this.getTokenExpiry();
81
+ log('Token acquired successfully (expires in %ds)', exp ? Math.floor(exp - Date.now() / 1000) : 'unknown');
29
82
  return {
30
83
  success: true,
31
- token: this.cachedToken,
32
- expiresIn: data.expires_in,
33
- expiresAt: this.expiresAt,
84
+ token: this.cachedToken
34
85
  };
35
86
  }
36
87
  catch (error) {
88
+ log('Token acquisition failed: %s', error instanceof Error ? error.message : 'Unknown error');
37
89
  return {
38
90
  success: false,
39
91
  error: error instanceof Error ? error.message : 'Failed to get token',
40
92
  };
41
93
  }
42
94
  }
95
+ getTokenExpiry() {
96
+ if (!this.cachedToken)
97
+ return null;
98
+ try {
99
+ const decoded = decodeJwt(this.cachedToken);
100
+ return decoded.exp || null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
43
106
  isExpired(bufferSeconds = 60) {
44
- if (!this.expiresAt)
107
+ const exp = this.getTokenExpiry();
108
+ if (!exp)
45
109
  return true;
46
- return Date.now() >= (this.expiresAt - bufferSeconds * 1000);
110
+ return Date.now() / 1000 >= (exp - bufferSeconds);
47
111
  }
48
112
  clearCache() {
49
113
  this.cachedToken = undefined;
50
- this.expiresAt = undefined;
51
114
  }
52
115
  }
@@ -1,4 +1,6 @@
1
- import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
1
+ import debug from 'debug';
2
+ const log = debug('location-client:api');
3
+ const logError = debug('location-client:api:error');
2
4
  /**
3
5
  * GeoPlacesClient - AWS Location Service compatible client with custom auth
4
6
  *
@@ -12,41 +14,65 @@ export class GeoPlacesClient {
12
14
  }
13
15
  async send(command) {
14
16
  const endpoint = this.getEndpoint(command);
15
- const response = await fetch(`${this.clientConfig.apiUrl}${endpoint}`, {
17
+ const url = `${this.clientConfig.apiUrl}${endpoint}`;
18
+ const commandName = command.constructor.name;
19
+ const input = command.input;
20
+ log(`[GeoPlacesClient] Sending ${commandName} to ${endpoint}`);
21
+ log('[GeoPlacesClient] Request body:', JSON.stringify(input, null, 2));
22
+ log('Sending %s request to %s', commandName, endpoint);
23
+ const startTime = Date.now();
24
+ const response = await fetch(url, {
16
25
  method: 'POST',
17
26
  headers: {
18
27
  'Content-Type': 'application/json',
19
28
  'Authorization': `Bearer ${this.clientConfig.token}`
20
29
  },
21
- body: JSON.stringify(command)
30
+ body: JSON.stringify(input)
22
31
  });
32
+ const duration = Date.now() - startTime;
23
33
  if (!response.ok) {
24
- throw new Error(`API request failed: ${response.statusText}`);
34
+ const errorText = await response.text();
35
+ log(`[GeoPlacesClient] Request failed: ${response.status} ${response.statusText}`, errorText);
36
+ logError('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
37
+ // Try to parse error message from response
38
+ let errorMessage = `API request failed: ${response.statusText}`;
39
+ try {
40
+ const errorData = JSON.parse(errorText);
41
+ if (errorData.message) {
42
+ errorMessage = errorData.message;
43
+ }
44
+ }
45
+ catch {
46
+ // If not JSON, use raw text if available
47
+ if (errorText)
48
+ errorMessage = errorText;
49
+ }
50
+ throw new Error(errorMessage);
25
51
  }
26
- return response.json();
52
+ const result = await response.json();
53
+ log(`[GeoPlacesClient] Response (${duration}ms):`, JSON.stringify(result, null, 2));
54
+ log('Request successful: %s (%dms)', response.status, duration);
55
+ return result;
27
56
  }
28
57
  getEndpoint(command) {
29
- if (command instanceof AutocompleteCommand) {
30
- return '/address/autocomplete';
58
+ const commandName = command.constructor.name;
59
+ switch (commandName) {
60
+ case 'AutocompleteCommand':
61
+ return '/address/autocomplete';
62
+ case 'GeocodeCommand':
63
+ return '/address/geocode';
64
+ case 'GetPlaceCommand':
65
+ return '/address/place';
66
+ case 'ReverseGeocodeCommand':
67
+ return '/address/search/reverse-geocode';
68
+ case 'SearchNearbyCommand':
69
+ return '/address/search/nearby';
70
+ case 'SearchTextCommand':
71
+ return '/address/search/text';
72
+ case 'SuggestCommand':
73
+ return '/address/suggestion';
74
+ default:
75
+ throw new Error(`Unknown command type: ${commandName}`);
31
76
  }
32
- else if (command instanceof GeocodeCommand) {
33
- return '/address/geocode';
34
- }
35
- else if (command instanceof GetPlaceCommand) {
36
- return '/address/place';
37
- }
38
- else if (command instanceof ReverseGeocodeCommand) {
39
- return '/address/search/reverse-geocode';
40
- }
41
- else if (command instanceof SearchNearbyCommand) {
42
- return '/address/search/nearby';
43
- }
44
- else if (command instanceof SearchTextCommand) {
45
- return '/address/search/text';
46
- }
47
- else if (command instanceof SuggestCommand) {
48
- return '/address/suggestion';
49
- }
50
- throw new Error('Unknown command type');
51
77
  }
52
78
  }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,10 @@ export { GeoPlacesClient } from './client/GeoPlacesClient';
4
4
  export * from '@aws-sdk/client-geo-places';
5
5
  export * from '@aws/amazon-location-utilities-datatypes';
6
6
  export { GeoPlaces } from './adapters/GeoPlaces';
7
+ export { createTransformRequest } from './maps/createTransformRequest';
8
+ export { transformRequest } from './maps/Utils';
7
9
  export type { ClientConfig } from './types';
8
10
  export { getClientConfig } from './server/getClientConfig';
9
11
  export type { ServerAuthConfig } from './server/getClientConfig';
12
+ export { LocationServiceConnector } from './server/LocationServiceConnector';
13
+ export type { ConnectorConfig } from './server/LocationServiceConnector';
package/dist/index.js CHANGED
@@ -8,5 +8,9 @@ export * from '@aws-sdk/client-geo-places';
8
8
  export * from '@aws/amazon-location-utilities-datatypes';
9
9
  // Adapters (Custom - for MapLibre integration)
10
10
  export { GeoPlaces } from './adapters/GeoPlaces';
11
+ // Maps utilities
12
+ export { createTransformRequest } from './maps/createTransformRequest';
13
+ export { transformRequest } from './maps/Utils';
11
14
  // Server-only utilities
12
15
  export { getClientConfig } from './server/getClientConfig';
16
+ export { LocationServiceConnector } from './server/LocationServiceConnector';
@@ -0,0 +1,8 @@
1
+ import { ClientConfig } from "../types";
2
+ export declare function transformRequest(url: string, config: ClientConfig): {
3
+ url: string;
4
+ headers: Record<string, string>;
5
+ } | {
6
+ url: string;
7
+ headers?: undefined;
8
+ };
@@ -0,0 +1,24 @@
1
+ export function transformRequest(url, config) {
2
+ if (url.startsWith(config.apiUrl)) {
3
+ const headers = {
4
+ 'Authorization': `Bearer ${config.token}`,
5
+ };
6
+ if (url.includes('/tiles/')) {
7
+ headers['Accept'] = 'application/x-protobuf';
8
+ }
9
+ else if (url.includes('/glyphs/')) {
10
+ headers['Accept'] = 'application/x-protobuf';
11
+ }
12
+ else if (url.includes('/sprites/') && url.endsWith('.png')) {
13
+ headers['Accept'] = 'image/png';
14
+ }
15
+ else if (url.includes('/sprites/') && url.endsWith('.json')) {
16
+ headers['Accept'] = 'application/json';
17
+ }
18
+ else if (url.includes('/descriptor')) {
19
+ headers['Accept'] = 'application/json';
20
+ }
21
+ return { url, headers };
22
+ }
23
+ return { url };
24
+ }
@@ -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,110 @@
1
+ import debug from 'debug';
2
+ import { getClientConfig } from './getClientConfig';
3
+ const log = debug('location-client:connector');
4
+ /**
5
+ * LocationServiceConnector - Server-side connector for Location Service API
6
+ *
7
+ * Optimized for backend-to-backend communication with:
8
+ * - Automatic configuration from environment variables
9
+ * - Automatic Origin header handling
10
+ * - Server-side token management
11
+ * - Enhanced error handling
12
+ *
13
+ * Uses AWS SDK command classes with Bearer token authentication.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // Auto-detect from environment
18
+ * const connector = new LocationServiceConnector()
19
+ *
20
+ * // Or provide explicit config
21
+ * const connector = new LocationServiceConnector({
22
+ * apiUrl: 'https://api.example.com',
23
+ * token: 'your-token'
24
+ * })
25
+ *
26
+ * // Send with custom headers (including Origin)
27
+ * const result = await connector.send(
28
+ * new SearchTextCommand({ QueryText: 'Space Needle' }),
29
+ * { headers: { 'Origin': req.headers.origin } }
30
+ * )
31
+ * ```
32
+ */
33
+ export class LocationServiceConnector {
34
+ constructor(config) {
35
+ this.serviceId = 'Geo Places';
36
+ this.configPromise = config
37
+ ? Promise.resolve(config)
38
+ : getClientConfig();
39
+ }
40
+ async send(command, options) {
41
+ const config = await this.configPromise;
42
+ // Get token - ConnectorConfig may have getToken, ServerClientConfig has static token
43
+ const token = 'getToken' in config && config.getToken
44
+ ? (await config.getToken()).token
45
+ : config.token;
46
+ if (!token) {
47
+ throw new Error('No token available');
48
+ }
49
+ const endpoint = this.getEndpoint(command);
50
+ const url = `${config.apiUrl}${endpoint}`;
51
+ const commandName = command.constructor.name;
52
+ const input = command.input;
53
+ log('Sending %s request to %s', commandName, endpoint);
54
+ const startTime = Date.now();
55
+ // Merge headers: user headers, then system headers override
56
+ const headers = {
57
+ ...(options?.headers || {}),
58
+ 'Content-Type': 'application/json',
59
+ 'Authorization': `Bearer ${token}`
60
+ };
61
+ const response = await fetch(url, {
62
+ method: 'POST',
63
+ headers,
64
+ body: JSON.stringify(input)
65
+ });
66
+ const duration = Date.now() - startTime;
67
+ if (!response.ok) {
68
+ const errorText = await response.text();
69
+ log('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
70
+ // Try to parse error message from response
71
+ let errorMessage = `API request failed: ${response.statusText}`;
72
+ try {
73
+ const errorData = JSON.parse(errorText);
74
+ if (errorData.message) {
75
+ errorMessage = errorData.message;
76
+ }
77
+ }
78
+ catch {
79
+ // If not JSON, use raw text if available
80
+ if (errorText)
81
+ errorMessage = errorText;
82
+ }
83
+ throw new Error(errorMessage);
84
+ }
85
+ const result = await response.json();
86
+ log('Request successful: %s (%dms)', response.status, duration);
87
+ return result;
88
+ }
89
+ getEndpoint(command) {
90
+ const commandName = command.constructor.name;
91
+ switch (commandName) {
92
+ case 'AutocompleteCommand':
93
+ return '/address/autocomplete';
94
+ case 'GeocodeCommand':
95
+ return '/address/geocode';
96
+ case 'GetPlaceCommand':
97
+ return '/address/place';
98
+ case 'ReverseGeocodeCommand':
99
+ return '/address/search/reverse-geocode';
100
+ case 'SearchNearbyCommand':
101
+ return '/address/search/nearby';
102
+ case 'SearchTextCommand':
103
+ return '/address/search/text';
104
+ case 'SuggestCommand':
105
+ return '/address/suggestion';
106
+ default:
107
+ throw new Error(`Unknown command type: ${commandName}`);
108
+ }
109
+ }
110
+ }
@@ -1,9 +1,21 @@
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
+ }
4
9
  /**
5
10
  * Get client configuration with OAuth2 authentication.
6
11
  *
12
+ * Automatically reads from environment variables:
13
+ * - LOCATION_API_URL or LOCATION_SERVICE_API_URL
14
+ * - LOCATION_CLIENT_ID or LOCATION_SERVICE_CLIENT_ID
15
+ * - LOCATION_CLIENT_SECRET or LOCATION_SERVICE_CLIENT_SECRET
16
+ *
17
+ * You can override any value by passing it explicitly.
18
+ *
7
19
  * WARNING: This function uses client credentials (clientId/clientSecret).
8
20
  * Only call this from:
9
21
  * - Next.js Server Components/Actions
@@ -12,5 +24,16 @@ export type ServerAuthConfig = TokenProviderConfig;
12
24
  *
13
25
  * NEVER call from browser/client code as it exposes credentials.
14
26
  * For SPA projects, create your own backend endpoint that calls this.
27
+ *
28
+ * @example
29
+ * // Auto-detect from environment
30
+ * const config = await getClientConfig()
31
+ *
32
+ * // Or override specific values
33
+ * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
34
+ *
35
+ * // Use getToken() for automatic caching and refresh
36
+ * const { token } = await config.getToken()
37
+ * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
15
38
  */
16
- export declare function getClientConfig(config: ServerAuthConfig): Promise<ClientConfig>;
39
+ export declare function getClientConfig(config?: ServerAuthConfig): Promise<ServerClientConfig>;
@@ -1,7 +1,16 @@
1
+ import debug from 'debug';
1
2
  import { TokenProvider } from '../auth/TokenProvider';
3
+ const log = debug('location-client:clientConfig');
2
4
  /**
3
5
  * Get client configuration with OAuth2 authentication.
4
6
  *
7
+ * Automatically reads from environment variables:
8
+ * - LOCATION_API_URL or LOCATION_SERVICE_API_URL
9
+ * - LOCATION_CLIENT_ID or LOCATION_SERVICE_CLIENT_ID
10
+ * - LOCATION_CLIENT_SECRET or LOCATION_SERVICE_CLIENT_SECRET
11
+ *
12
+ * You can override any value by passing it explicitly.
13
+ *
5
14
  * WARNING: This function uses client credentials (clientId/clientSecret).
6
15
  * Only call this from:
7
16
  * - Next.js Server Components/Actions
@@ -10,15 +19,48 @@ import { TokenProvider } from '../auth/TokenProvider';
10
19
  *
11
20
  * NEVER call from browser/client code as it exposes credentials.
12
21
  * For SPA projects, create your own backend endpoint that calls this.
22
+ *
23
+ * @example
24
+ * // Auto-detect from environment
25
+ * const config = await getClientConfig()
26
+ *
27
+ * // Or override specific values
28
+ * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
29
+ *
30
+ * // Use getToken() for automatic caching and refresh
31
+ * const { token } = await config.getToken()
32
+ * const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
13
33
  */
14
- export async function getClientConfig(config) {
15
- const provider = new TokenProvider(config);
34
+ export async function getClientConfig(config = {}) {
35
+ log('[getClientConfig] Starting with config:', { hasApiUrl: !!config.apiUrl, hasClientId: !!config.clientId });
36
+ // Auto-detect from environment with fallbacks
37
+ const apiUrl = config.apiUrl ||
38
+ process.env.LOCATION_API_URL ||
39
+ process.env.LOCATION_SERVICE_API_URL;
40
+ const clientId = config.clientId ||
41
+ process.env.LOCATION_CLIENT_ID ||
42
+ process.env.LOCATION_SERVICE_CLIENT_ID;
43
+ const clientSecret = config.clientSecret ||
44
+ process.env.LOCATION_CLIENT_SECRET ||
45
+ process.env.LOCATION_SERVICE_CLIENT_SECRET;
46
+ log('[getClientConfig] Resolved config:', { apiUrl, clientId: clientId?.substring(0, 10) + '...' });
47
+ // Validate required values
48
+ if (!apiUrl || !clientId || !clientSecret) {
49
+ console.error('[getClientConfig] Missing required configuration');
50
+ throw new Error('Missing required configuration. Set environment variables: ' +
51
+ 'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET');
52
+ }
53
+ log('[getClientConfig] Creating TokenProvider');
54
+ const provider = new TokenProvider({ apiUrl, clientId, clientSecret });
55
+ log('[getClientConfig] Fetching token');
16
56
  const result = await provider.getToken();
17
57
  if (!result.success || !result.token) {
58
+ console.error('[getClientConfig] Token fetch failed:', result.error);
18
59
  throw new Error(result.error || 'Failed to get token');
19
60
  }
61
+ log('[getClientConfig] Token fetched successfully, length:', result.token.length);
20
62
  return {
21
- apiUrl: config.apiUrl,
22
- token: result.token,
63
+ apiUrl,
64
+ token: result.token
23
65
  };
24
66
  }
@@ -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,4 @@
1
+ export { getClientConfig } from './getClientConfig';
2
+ export type { ServerAuthConfig, ServerClientConfig } from './getClientConfig';
3
+ export { LocationServiceConnector } from './LocationServiceConnector';
4
+ export type { ConnectorConfig, SendOptions } from './LocationServiceConnector';
@@ -0,0 +1,2 @@
1
+ export { getClientConfig } from './getClientConfig';
2
+ export { LocationServiceConnector } from './LocationServiceConnector';
package/package.json CHANGED
@@ -1,9 +1,20 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -30,10 +41,14 @@
30
41
  },
31
42
  "dependencies": {
32
43
  "@aws-sdk/client-geo-places": "^3.0.0",
33
- "@aws/amazon-location-utilities-datatypes": "^1.0.0"
44
+ "@aws/amazon-location-utilities-datatypes": "^1.0.0",
45
+ "@maplibre/maplibre-gl-geocoder": "^1.9.4",
46
+ "client-oauth2": "^4.3.3",
47
+ "debug": "^4.4.3",
48
+ "jose": "^5.0.0"
34
49
  },
35
50
  "peerDependencies": {
36
- "maplibre-gl": "^4.0.0"
51
+ "maplibre-gl": "^5.0.0"
37
52
  },
38
53
  "peerDependenciesMeta": {
39
54
  "maplibre-gl": {
@@ -41,6 +56,9 @@
41
56
  }
42
57
  },
43
58
  "devDependencies": {
59
+ "@types/debug": "^4.1.12",
60
+ "@types/node": "^25.0.3",
61
+ "maplibre-gl": "^5.0.0",
44
62
  "typescript": "^5.0.0"
45
63
  },
46
64
  "files": [