@chaosity/location-client 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +207 -40
  2. package/dist/adapters/GeoPlaces.d.ts +7 -1
  3. package/dist/adapters/GeoPlaces.js +64 -22
  4. package/dist/auth/TokenProvider.d.ts +24 -1
  5. package/dist/auth/TokenProvider.js +81 -12
  6. package/dist/auth/tokenHold.d.ts +81 -0
  7. package/dist/auth/tokenHold.js +174 -0
  8. package/dist/aws.d.ts +4 -0
  9. package/dist/aws.js +2 -0
  10. package/dist/cjs/adapters/GeoPlaces.d.ts +7 -1
  11. package/dist/cjs/adapters/GeoPlaces.js +63 -21
  12. package/dist/cjs/auth/TokenProvider.d.ts +24 -1
  13. package/dist/cjs/auth/TokenProvider.js +81 -12
  14. package/dist/cjs/auth/tokenHold.d.ts +81 -0
  15. package/dist/cjs/auth/tokenHold.js +181 -0
  16. package/dist/cjs/aws.d.ts +4 -0
  17. package/dist/cjs/aws.js +155 -0
  18. package/dist/cjs/client/GeoPlacesClient.d.ts +23 -3
  19. package/dist/cjs/client/GeoPlacesClient.js +50 -32
  20. package/dist/cjs/client/commands.d.ts +2 -3
  21. package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
  22. package/dist/cjs/errors/LocationServiceException.js +53 -1
  23. package/dist/cjs/index.d.ts +5 -4
  24. package/dist/cjs/index.js +13 -8
  25. package/dist/cjs/maps/createTransformRequest.d.ts +25 -0
  26. package/dist/cjs/maps/createTransformRequest.js +1 -0
  27. package/dist/cjs/maps/mapPoi.d.ts +10 -5
  28. package/dist/cjs/maps/mapPoi.js +14 -5
  29. package/dist/cjs/maps/mapStyle.d.ts +9 -2
  30. package/dist/cjs/maps/mapStyle.js +16 -12
  31. package/dist/cjs/maps/mapToken.d.ts +84 -0
  32. package/dist/cjs/maps/mapToken.js +126 -0
  33. package/dist/cjs/maps/staticMap.d.ts +7 -3
  34. package/dist/cjs/maps/staticMap.js +13 -12
  35. package/dist/cjs/server/LocationServiceConnector.d.ts +20 -3
  36. package/dist/cjs/server/LocationServiceConnector.js +20 -22
  37. package/dist/cjs/server/getClientConfig.d.ts +10 -5
  38. package/dist/cjs/server/getClientConfig.js +43 -19
  39. package/dist/cjs/server/index.d.ts +1 -1
  40. package/dist/cjs/transport/errors.d.ts +14 -8
  41. package/dist/cjs/transport/errors.js +26 -18
  42. package/dist/cjs/transport/http.d.ts +18 -0
  43. package/dist/cjs/transport/http.js +39 -1
  44. package/dist/cjs/types/index.d.ts +26 -0
  45. package/dist/client/GeoPlacesClient.d.ts +23 -3
  46. package/dist/client/GeoPlacesClient.js +52 -34
  47. package/dist/client/commands.d.ts +2 -3
  48. package/dist/errors/LocationServiceException.d.ts +30 -2
  49. package/dist/errors/LocationServiceException.js +52 -0
  50. package/dist/index.d.ts +5 -4
  51. package/dist/index.js +11 -7
  52. package/dist/maps/createTransformRequest.d.ts +25 -0
  53. package/dist/maps/createTransformRequest.js +1 -1
  54. package/dist/maps/mapPoi.d.ts +10 -5
  55. package/dist/maps/mapPoi.js +14 -5
  56. package/dist/maps/mapStyle.d.ts +9 -2
  57. package/dist/maps/mapStyle.js +17 -13
  58. package/dist/maps/mapToken.d.ts +84 -0
  59. package/dist/maps/mapToken.js +122 -0
  60. package/dist/maps/staticMap.d.ts +7 -3
  61. package/dist/maps/staticMap.js +14 -13
  62. package/dist/server/LocationServiceConnector.d.ts +20 -3
  63. package/dist/server/LocationServiceConnector.js +22 -24
  64. package/dist/server/getClientConfig.d.ts +10 -5
  65. package/dist/server/getClientConfig.js +43 -19
  66. package/dist/server/index.d.ts +1 -1
  67. package/dist/transport/errors.d.ts +14 -8
  68. package/dist/transport/errors.js +27 -19
  69. package/dist/transport/http.d.ts +18 -0
  70. package/dist/transport/http.js +37 -1
  71. package/dist/types/index.d.ts +26 -0
  72. package/package.json +3 -3
@@ -1,8 +1,14 @@
1
1
  import debug from 'debug';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException.js';
3
3
  import { requestJson } from '../transport/http.js';
4
+ import { TokenHold, isTokenRefusal } from './tokenHold.js';
4
5
  import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './tokenRefresh.js';
5
6
  const log = debug('location-client:auth');
7
+ /**
8
+ * #63: how close to its own `exp` a cached token may still be sent when a
9
+ * refresh fails. A margin for the clocks of this host and the API to disagree.
10
+ */
11
+ const EXPIRY_SKEW_MS = 5000;
6
12
  /**
7
13
  * TokenProvider - SERVER-SIDE ONLY
8
14
  *
@@ -33,6 +39,8 @@ const log = debug('location-client:auth');
33
39
  */
34
40
  export class TokenProvider {
35
41
  constructor(config) {
42
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
43
+ this.hold = new TokenHold();
36
44
  // Runtime check: prevent usage in browser
37
45
  if (typeof window !== 'undefined') {
38
46
  throw new Error('TokenProvider cannot be used in browser environments. ' +
@@ -42,7 +50,7 @@ export class TokenProvider {
42
50
  log('Initializing TokenProvider for %s', config.apiUrl);
43
51
  this.config = config;
44
52
  }
45
- async getToken(forceRefresh = false) {
53
+ async getToken(forceRefresh = false, options = {}) {
46
54
  if (!forceRefresh && this.cachedToken && !this.isExpired()) {
47
55
  const expiresIn = this.cachedExpiresAt
48
56
  ? Math.floor((this.cachedExpiresAt - Date.now()) / 1000)
@@ -54,10 +62,27 @@ export class TokenProvider {
54
62
  expiresAt: this.cachedExpiresAt,
55
63
  };
56
64
  }
65
+ // The endpoint refused these credentials, or asked us to wait, a moment
66
+ // ago: answer with that rather than ask again (#38). Forced or not — a
67
+ // forced refresh asks for a different token, and these credentials will
68
+ // not get one until the hold lapses.
69
+ const held = this.hold.check()?.error;
70
+ if (held) {
71
+ log('Token request held: %s', held.message);
72
+ const fallback = this.fallback(forceRefresh, options, held);
73
+ if (fallback)
74
+ return fallback;
75
+ throw held;
76
+ }
57
77
  // If token fetch is already in progress, wait for it
58
78
  if (this.tokenPromise) {
59
79
  log('Token fetch in progress, waiting for existing request...');
60
- return this.tokenPromise;
80
+ return this.tokenPromise.catch((error) => {
81
+ const fallback = this.fallback(forceRefresh, options, error);
82
+ if (fallback)
83
+ return fallback;
84
+ throw error;
85
+ });
61
86
  }
62
87
  // Start new token fetch
63
88
  const reason = forceRefresh
@@ -71,6 +96,12 @@ export class TokenProvider {
71
96
  const result = await this.tokenPromise;
72
97
  return result;
73
98
  }
99
+ catch (error) {
100
+ const fallback = this.fallback(forceRefresh, options, error);
101
+ if (fallback)
102
+ return fallback;
103
+ throw error;
104
+ }
74
105
  finally {
75
106
  // Clear promise after completion (success or failure)
76
107
  this.tokenPromise = undefined;
@@ -93,16 +124,32 @@ export class TokenProvider {
93
124
  async fetchToken() {
94
125
  const { clientId, clientSecret, apiUrl } = this.config;
95
126
  const credentials = btoa(`${clientId}:${clientSecret}`);
96
- const data = await requestJson(`${apiUrl}/auth/token`, {
97
- method: 'POST',
98
- headers: {
99
- Authorization: `Basic ${credentials}`,
100
- 'Content-Type': 'application/x-www-form-urlencoded',
101
- },
102
- body: new URLSearchParams({
103
- grant_type: 'client_credentials',
104
- }).toString(),
105
- }, { retry: { maxAttempts: 3 } });
127
+ let data;
128
+ try {
129
+ data = await requestJson(`${apiUrl}/auth/token`, {
130
+ method: 'POST',
131
+ headers: {
132
+ Authorization: `Basic ${credentials}`,
133
+ 'Content-Type': 'application/x-www-form-urlencoded',
134
+ },
135
+ body: new URLSearchParams({
136
+ grant_type: 'client_credentials',
137
+ }).toString(),
138
+ }, { retry: { maxAttempts: 3 } });
139
+ }
140
+ catch (error) {
141
+ // Remembered, so the next call is answered without a request (#38), and
142
+ // a Retry-After is honoured across calls rather than only within this
143
+ // one (#63).
144
+ this.hold.remember(error);
145
+ // A refusal is about the credentials, so every token they minted is
146
+ // refused too — a suspended application's on its next use, a rotated
147
+ // secret's at once. Never hand the cached one out again. A Retry-After
148
+ // says nothing about it, and keeps it.
149
+ if (isTokenRefusal(error))
150
+ this.clearCache();
151
+ throw error;
152
+ }
106
153
  if (!data.access_token) {
107
154
  throw new LocationServiceException({
108
155
  code: 'InvalidCredentialsException',
@@ -110,6 +157,7 @@ export class TokenProvider {
110
157
  details: { source: 'client' },
111
158
  });
112
159
  }
160
+ this.hold.forget();
113
161
  this.cachedToken = data.access_token;
114
162
  // The token's own `exp` claim first — it is the only value that cannot
115
163
  // disagree with what the API will actually accept. `expires_at` and
@@ -125,6 +173,27 @@ export class TokenProvider {
125
173
  expiresAt: this.cachedExpiresAt,
126
174
  };
127
175
  }
176
+ /**
177
+ * #63: the cached token, when the caller asked for it and the failure is one
178
+ * that waiting fixes. A refusal has already cleared the cache in fetchToken,
179
+ * so it can never be the cached token handed back here.
180
+ */
181
+ fallback(forceRefresh, options, error) {
182
+ if (forceRefresh || !options.cachedUntilExpiry)
183
+ return undefined;
184
+ if (isTokenRefusal(error))
185
+ return undefined;
186
+ if (!this.cachedToken || !this.cachedExpiresAt)
187
+ return undefined;
188
+ if (Date.now() >= this.cachedExpiresAt - EXPIRY_SKEW_MS)
189
+ return undefined;
190
+ log('Refresh failed (%s); the cached token is valid for %ds more, sending it', error instanceof Error ? error.message : String(error), Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
191
+ return {
192
+ success: true,
193
+ token: this.cachedToken,
194
+ expiresAt: this.cachedExpiresAt,
195
+ };
196
+ }
128
197
  isExpired(bufferSeconds = TOKEN_REFRESH_BUFFER_SECONDS) {
129
198
  if (!this.cachedExpiresAt)
130
199
  return true;
@@ -0,0 +1,81 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException.js';
2
+ /**
3
+ * How long a refusal is remembered: a token the API refused, or a token
4
+ * request `/auth/token` refused (#38).
5
+ *
6
+ * A refusal — a 401 or a 403 — is not something asking again can change. A
7
+ * suspended application is refused on every data route and on `/auth/token`
8
+ * alike, and nothing used to remember that, so every request a busy server
9
+ * served paid for a doomed data request and a doomed token request, all
10
+ * against the application's own token-route throttle.
11
+ *
12
+ * Thirty seconds is short beside the API's own waits: it accepts a
13
+ * reactivated application again within five minutes, and refuses a newly
14
+ * created or reactivated one for up to about thirty seconds while its key goes
15
+ * live. So this side adds at most thirty seconds to either, and turns a
16
+ * request rate into a trickle meanwhile.
17
+ */
18
+ export declare const TOKEN_REFUSAL_HOLD_MS = 30000;
19
+ /**
20
+ * A 401 or a 403: the server refused, and a retry gets the same answer.
21
+ *
22
+ * Wider than `isTokenRejected`, which is the 401 a new token can fix. This is
23
+ * about the token SOURCE: `/auth/token` refusing the credentials (401), or
24
+ * the application's key not live on its plan (403), and neither changes by
25
+ * asking again.
26
+ */
27
+ export declare function isTokenRefusal(err: unknown): boolean;
28
+ /**
29
+ * How long `err` says asking again cannot help, in milliseconds; 0 when it
30
+ * says nothing, and the next call may ask at once.
31
+ *
32
+ * Only the server's own word counts: a refusal, or a `Retry-After`. A network
33
+ * fault, a timeout or a 500 carries neither, so it is not remembered, and the
34
+ * next call tries again as it always has — the transport has already retried
35
+ * it with backoff inside the call that failed.
36
+ */
37
+ export declare function holdFor(err: unknown): number;
38
+ /**
39
+ * One remembered failure, re-thrown instead of asking again until it lapses.
40
+ *
41
+ * Shared by every place that used to ask again on every call: the server
42
+ * `TokenProvider` (a refused or throttled token request), and the two send
43
+ * paths (a token the API refused, and the refresh that could not replace it,
44
+ * or — in `GeoPlacesClient` — could not supply a first one). A send path
45
+ * remembers the failure against the token it concerns, because a DIFFERENT
46
+ * token is a new situation — a background refresh that landed, or a caller's
47
+ * own source that moved on — and ends the hold at once.
48
+ *
49
+ * `askAgain` is the one case where the source may still be asked: the API
50
+ * refused the token, and the refresh that followed failed with nothing to say
51
+ * about when to try again — a network fault, or a rejection that lost its
52
+ * fields crossing a Server Action boundary, as `@chaosity/location-client-react`
53
+ * delivers one. The token is still refused, so it is not sent again; the
54
+ * source is asked on the next send, as it always was.
55
+ */
56
+ export declare class TokenHold {
57
+ private held?;
58
+ /** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
59
+ remember(err: unknown, token?: string, { askAgain }?: {
60
+ askAgain?: boolean | undefined;
61
+ }): void;
62
+ /**
63
+ * The remembered failure while it stands, as a new exception to throw — for
64
+ * `token`, if one was remembered with it — and whether the source may still
65
+ * be asked. Anything else ends the hold.
66
+ */
67
+ check(token?: string): {
68
+ error: LocationServiceException;
69
+ askAgain: boolean;
70
+ } | undefined;
71
+ forget(): void;
72
+ }
73
+ /**
74
+ * Send with `token`, and after a 401 once more with `refresh()`'s token when it
75
+ * is a DIFFERENT one, remembering on `hold` what the API and the source said,
76
+ * against the token it concerns (#38).
77
+ *
78
+ * The one copy of the 401 retry: both send paths and the two map fetches (#72)
79
+ * call it, so when to ask again is decided here and nowhere else.
80
+ */
81
+ export declare function sendRetryingOnce<T>(hold: TokenHold, token: string, send: (token: string) => Promise<T>, refresh: () => Promise<string | undefined>, onRetry?: () => void): Promise<T>;
@@ -0,0 +1,174 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException.js';
2
+ import { isTokenRejected } from '../transport/errors.js';
3
+ /**
4
+ * How long a refusal is remembered: a token the API refused, or a token
5
+ * request `/auth/token` refused (#38).
6
+ *
7
+ * A refusal — a 401 or a 403 — is not something asking again can change. A
8
+ * suspended application is refused on every data route and on `/auth/token`
9
+ * alike, and nothing used to remember that, so every request a busy server
10
+ * served paid for a doomed data request and a doomed token request, all
11
+ * against the application's own token-route throttle.
12
+ *
13
+ * Thirty seconds is short beside the API's own waits: it accepts a
14
+ * reactivated application again within five minutes, and refuses a newly
15
+ * created or reactivated one for up to about thirty seconds while its key goes
16
+ * live. So this side adds at most thirty seconds to either, and turns a
17
+ * request rate into a trickle meanwhile.
18
+ */
19
+ export const TOKEN_REFUSAL_HOLD_MS = 30000;
20
+ /**
21
+ * A 401 or a 403: the server refused, and a retry gets the same answer.
22
+ *
23
+ * Wider than `isTokenRejected`, which is the 401 a new token can fix. This is
24
+ * about the token SOURCE: `/auth/token` refusing the credentials (401), or
25
+ * the application's key not live on its plan (403), and neither changes by
26
+ * asking again.
27
+ */
28
+ export function isTokenRefusal(err) {
29
+ return (err instanceof LocationServiceException &&
30
+ (err.statusCode === 401 || err.statusCode === 403));
31
+ }
32
+ /**
33
+ * How long `err` says asking again cannot help, in milliseconds; 0 when it
34
+ * says nothing, and the next call may ask at once.
35
+ *
36
+ * Only the server's own word counts: a refusal, or a `Retry-After`. A network
37
+ * fault, a timeout or a 500 carries neither, so it is not remembered, and the
38
+ * next call tries again as it always has — the transport has already retried
39
+ * it with backoff inside the call that failed.
40
+ */
41
+ export function holdFor(err) {
42
+ if (!(err instanceof LocationServiceException))
43
+ return 0;
44
+ return Math.max(isTokenRefusal(err) ? TOKEN_REFUSAL_HOLD_MS : 0, err.retryAfterMs ?? 0);
45
+ }
46
+ /**
47
+ * One remembered failure, re-thrown instead of asking again until it lapses.
48
+ *
49
+ * Shared by every place that used to ask again on every call: the server
50
+ * `TokenProvider` (a refused or throttled token request), and the two send
51
+ * paths (a token the API refused, and the refresh that could not replace it,
52
+ * or — in `GeoPlacesClient` — could not supply a first one). A send path
53
+ * remembers the failure against the token it concerns, because a DIFFERENT
54
+ * token is a new situation — a background refresh that landed, or a caller's
55
+ * own source that moved on — and ends the hold at once.
56
+ *
57
+ * `askAgain` is the one case where the source may still be asked: the API
58
+ * refused the token, and the refresh that followed failed with nothing to say
59
+ * about when to try again — a network fault, or a rejection that lost its
60
+ * fields crossing a Server Action boundary, as `@chaosity/location-client-react`
61
+ * delivers one. The token is still refused, so it is not sent again; the
62
+ * source is asked on the next send, as it always was.
63
+ */
64
+ export class TokenHold {
65
+ /** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
66
+ remember(err, token, { askAgain = false } = {}) {
67
+ const ms = holdFor(err);
68
+ if (ms > 0) {
69
+ this.held = {
70
+ error: err,
71
+ until: Date.now() + ms,
72
+ token,
73
+ askAgain,
74
+ };
75
+ }
76
+ }
77
+ /**
78
+ * The remembered failure while it stands, as a new exception to throw — for
79
+ * `token`, if one was remembered with it — and whether the source may still
80
+ * be asked. Anything else ends the hold.
81
+ */
82
+ check(token) {
83
+ const held = this.held;
84
+ if (!held)
85
+ return undefined;
86
+ const remaining = held.until - Date.now();
87
+ if (remaining <= 0 || (held.token !== undefined && held.token !== token)) {
88
+ this.held = undefined;
89
+ return undefined;
90
+ }
91
+ const { error, askAgain } = held;
92
+ return {
93
+ askAgain,
94
+ error: new LocationServiceException({
95
+ code: error.code,
96
+ message: error.message,
97
+ statusCode: error.statusCode,
98
+ requestId: error.requestId,
99
+ details: error.details,
100
+ // What is left of a Retry-After, so a caller that schedules on it waits
101
+ // the right amount. A refusal carries none, and gains none here.
102
+ retryAfterMs: error.retryAfterMs === undefined ? undefined : remaining,
103
+ cause: error,
104
+ }),
105
+ };
106
+ }
107
+ forget() {
108
+ this.held = undefined;
109
+ }
110
+ }
111
+ /**
112
+ * Send with `token`, and after a 401 once more with `refresh()`'s token when it
113
+ * is a DIFFERENT one, remembering on `hold` what the API and the source said,
114
+ * against the token it concerns (#38).
115
+ *
116
+ * The one copy of the 401 retry: both send paths and the two map fetches (#72)
117
+ * call it, so when to ask again is decided here and nowhere else.
118
+ */
119
+ export async function sendRetryingOnce(hold, token, send, refresh, onRetry) {
120
+ // This token was refused a moment ago and nothing has replaced it: answer
121
+ // with that refusal rather than send it, and ask the source again. A
122
+ // different token ends the hold. When the refresh that followed said nothing
123
+ // about when to ask again, it is asked now — but the refused token is still
124
+ // not sent.
125
+ const held = hold.check(token);
126
+ if (held && !held.askAgain)
127
+ throw held.error;
128
+ let rejected = held?.error;
129
+ if (!held) {
130
+ try {
131
+ return await send(token);
132
+ }
133
+ catch (err) {
134
+ if (!isTokenRejected(err))
135
+ throw err;
136
+ rejected = err;
137
+ }
138
+ }
139
+ // One retry, and only when the replacement is genuinely a different token.
140
+ // That single comparison covers every source: a fixed `token` string, a
141
+ // `getToken` that ignores `forceRefresh`, a `refreshToken` that hands back
142
+ // what it had, and a cached token the API has revoked before its `exp` —
143
+ // re-sending any of them is a second doomed request for the same answer.
144
+ let fresh;
145
+ try {
146
+ fresh = await refresh();
147
+ }
148
+ catch (refusal) {
149
+ // A rotated secret's token route refuses the refresh as the data route
150
+ // refused its token. Without this, every send asked again. A refusal that
151
+ // says nothing — a network fault, or a Server Action's error without its
152
+ // fields — leaves the source to be asked again, but not the token sent.
153
+ if (holdFor(refusal) > 0)
154
+ hold.remember(refusal, token);
155
+ // Only when no hold stands: one already standing keeps its own end, so
156
+ // the refused token is tried again once per hold rather than never.
157
+ else if (!held)
158
+ hold.remember(rejected, token, { askAgain: true });
159
+ throw refusal;
160
+ }
161
+ if (!fresh || fresh === token) {
162
+ hold.remember(rejected, token);
163
+ throw rejected;
164
+ }
165
+ onRetry?.();
166
+ try {
167
+ return await send(fresh);
168
+ }
169
+ catch (again) {
170
+ if (isTokenRejected(again))
171
+ hold.remember(again, fresh);
172
+ throw again;
173
+ }
174
+ }
package/dist/aws.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type * from '@aws-sdk/client-geo-places';
2
+ export { $Command, AccessDeniedException, AccessDeniedException$, AccessPoint$, AccessPointType, AccessRestriction$, Address$, AddressComponentMatchScores$, AddressComponentPhonemes$, AddressTranslationComponent, AdminNames$, AdminNamesPreference, Autocomplete$, AutocompleteAdditionalFeature, AutocompleteAddressHighlights$, AutocompleteFilter$, AutocompleteFilterPlaceType, AutocompleteHighlights$, AutocompleteIntendedUse, AutocompleteRequest$, AutocompleteResponse$, AutocompleteResultItem$, BusinessChain$, Category$, ComponentMatchScores$, ContactDetails$, Contacts$, Country$, CountryHighlights$, CrossReference$, FilterCircle$, FoodType$, GeoPlacesServiceException, GeoPlacesServiceException$, Geocode$, GeocodeAdditionalFeature, GeocodeAddressNamesMode, GeocodeFilter$, GeocodeFilterPlaceType, GeocodeIntendedUse, GeocodeParsedQuery$, GeocodeParsedQueryAddressComponents$, GeocodeQueryComponents$, GeocodeRequest$, GeocodeResponse$, GeocodeResultItem$, GetPlace$, GetPlaceAdditionalFeature, GetPlaceAddressNamesMode, GetPlaceIntendedUse, GetPlaceRequest$, GetPlaceResponse$, Highlight$, InternalServerException, InternalServerException$, Intersection$, MatchScoreDetails$, OpeningHours$, OpeningHoursComponents$, ParsedQueryComponent$, ParsedQuerySecondaryAddressComponent$, PhonemeDetails$, PhonemeTranscription$, PlaceAttribute, PlaceType, PostalAuthority, PostalCodeDetails$, PostalCodeMode, PostalCodeType, QueryRefinement$, QueryType, RecordTypeCode, Region$, RegionHighlights$, RelatedPlace$, ReverseGeocode$, ReverseGeocodeAdditionalFeature, ReverseGeocodeAddressNamesMode, ReverseGeocodeFilter$, ReverseGeocodeFilterPlaceType, ReverseGeocodeIntendedUse, ReverseGeocodeRequest$, ReverseGeocodeResponse$, ReverseGeocodeResultItem$, SearchNearby$, SearchNearbyAdditionalFeature, SearchNearbyFilter$, SearchNearbyIntendedUse, SearchNearbyRequest$, SearchNearbyResponse$, SearchNearbyResultItem$, SearchText$, SearchTextAdditionalFeature, SearchTextFilter$, SearchTextIntendedUse, SearchTextRequest$, SearchTextResponse$, SearchTextResultItem$, SearchTextTravelMode, SecondaryAddressComponent$, SecondaryAddressComponentMatchScore$, StreetComponents$, SubRegion$, SubRegionHighlights$, Suggest$, SuggestAdditionalFeature, SuggestAddressHighlights$, SuggestFilter$, SuggestHighlights$, SuggestIntendedUse, SuggestPlaceResult$, SuggestQueryResult$, SuggestRequest$, SuggestResponse$, SuggestResultItem$, SuggestResultItemType, SuggestTravelMode, ThrottlingException, ThrottlingException$, TimeZone$, TranslationDetails$, TranslationName$, TranslationNameType, TypePlacement, UspsZip$, UspsZipPlus4$, ValidationException, ValidationException$, ValidationExceptionField$, ValidationExceptionReason, ZipClassificationCode, __Client, errorTypeRegistries, } from '@aws-sdk/client-geo-places';
3
+ export type * from '@aws/amazon-location-utilities-datatypes';
4
+ export { calculateIsolinesResponseToFeatureCollection, calculateRoutesResponseToFeatureCollections, devicePositionsToFeatureCollection, featureCollectionToGeofence, geocodeResponseToFeatureCollection, geofencesToFeatureCollection, getPlaceResponseToFeatureCollection, optimizeWaypointsResponseToFeatureCollection, placeToFeatureCollection, reverseGeocodeResponseToFeatureCollection, routeToFeatureCollection, searchNearbyResponseToFeatureCollection, searchTextResponseToFeatureCollection, snapToRoadsResponseToFeatureCollection, suggestResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
package/dist/aws.js ADDED
@@ -0,0 +1,2 @@
1
+ export { $Command, AccessDeniedException, AccessDeniedException$, AccessPoint$, AccessPointType, AccessRestriction$, Address$, AddressComponentMatchScores$, AddressComponentPhonemes$, AddressTranslationComponent, AdminNames$, AdminNamesPreference, Autocomplete$, AutocompleteAdditionalFeature, AutocompleteAddressHighlights$, AutocompleteFilter$, AutocompleteFilterPlaceType, AutocompleteHighlights$, AutocompleteIntendedUse, AutocompleteRequest$, AutocompleteResponse$, AutocompleteResultItem$, BusinessChain$, Category$, ComponentMatchScores$, ContactDetails$, Contacts$, Country$, CountryHighlights$, CrossReference$, FilterCircle$, FoodType$, GeoPlacesServiceException, GeoPlacesServiceException$, Geocode$, GeocodeAdditionalFeature, GeocodeAddressNamesMode, GeocodeFilter$, GeocodeFilterPlaceType, GeocodeIntendedUse, GeocodeParsedQuery$, GeocodeParsedQueryAddressComponents$, GeocodeQueryComponents$, GeocodeRequest$, GeocodeResponse$, GeocodeResultItem$, GetPlace$, GetPlaceAdditionalFeature, GetPlaceAddressNamesMode, GetPlaceIntendedUse, GetPlaceRequest$, GetPlaceResponse$, Highlight$, InternalServerException, InternalServerException$, Intersection$, MatchScoreDetails$, OpeningHours$, OpeningHoursComponents$, ParsedQueryComponent$, ParsedQuerySecondaryAddressComponent$, PhonemeDetails$, PhonemeTranscription$, PlaceAttribute, PlaceType, PostalAuthority, PostalCodeDetails$, PostalCodeMode, PostalCodeType, QueryRefinement$, QueryType, RecordTypeCode, Region$, RegionHighlights$, RelatedPlace$, ReverseGeocode$, ReverseGeocodeAdditionalFeature, ReverseGeocodeAddressNamesMode, ReverseGeocodeFilter$, ReverseGeocodeFilterPlaceType, ReverseGeocodeIntendedUse, ReverseGeocodeRequest$, ReverseGeocodeResponse$, ReverseGeocodeResultItem$, SearchNearby$, SearchNearbyAdditionalFeature, SearchNearbyFilter$, SearchNearbyIntendedUse, SearchNearbyRequest$, SearchNearbyResponse$, SearchNearbyResultItem$, SearchText$, SearchTextAdditionalFeature, SearchTextFilter$, SearchTextIntendedUse, SearchTextRequest$, SearchTextResponse$, SearchTextResultItem$, SearchTextTravelMode, SecondaryAddressComponent$, SecondaryAddressComponentMatchScore$, StreetComponents$, SubRegion$, SubRegionHighlights$, Suggest$, SuggestAdditionalFeature, SuggestAddressHighlights$, SuggestFilter$, SuggestHighlights$, SuggestIntendedUse, SuggestPlaceResult$, SuggestQueryResult$, SuggestRequest$, SuggestResponse$, SuggestResultItem$, SuggestResultItemType, SuggestTravelMode, ThrottlingException, ThrottlingException$, TimeZone$, TranslationDetails$, TranslationName$, TranslationNameType, TypePlacement, UspsZip$, UspsZipPlus4$, ValidationException, ValidationException$, ValidationExceptionField$, ValidationExceptionReason, ZipClassificationCode, __Client, errorTypeRegistries, } from '@aws-sdk/client-geo-places';
2
+ export { calculateIsolinesResponseToFeatureCollection, calculateRoutesResponseToFeatureCollections, devicePositionsToFeatureCollection, featureCollectionToGeofence, geocodeResponseToFeatureCollection, geofencesToFeatureCollection, getPlaceResponseToFeatureCollection, optimizeWaypointsResponseToFeatureCollection, placeToFeatureCollection, reverseGeocodeResponseToFeatureCollection, routeToFeatureCollection, searchNearbyResponseToFeatureCollection, searchTextResponseToFeatureCollection, snapToRoadsResponseToFeatureCollection, suggestResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
@@ -74,7 +74,13 @@ export declare class GeoPlaces implements MaplibreGeocoderApi {
74
74
  private client;
75
75
  private map;
76
76
  private details;
77
- constructor(client: GeoPlacesClient, map: Map, options?: GeoPlacesOptions);
77
+ /**
78
+ * @param client Anything with the client's `send` — the adapter calls
79
+ * nothing else (#65). `@chaosity/location-client-react`'s
80
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
81
+ * and a class with private fields admits no other object.
82
+ */
83
+ constructor(client: Pick<GeoPlacesClient, 'send'>, map: Map, options?: GeoPlacesOptions);
78
84
  /**
79
85
  * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
80
86
  *
@@ -50,7 +50,33 @@ function toCarmenFeatures(features) {
50
50
  };
51
51
  });
52
52
  }
53
+ /**
54
+ * The geocoder's `bbox` — `[minX, minY, maxX, maxY]`, the order Amazon's
55
+ * `BoundingBox` takes too — or undefined when it has none, or not four
56
+ * numbers.
57
+ */
58
+ function boundingBox(bbox) {
59
+ return bbox?.length === 4 && bbox.every((v) => typeof v === 'number')
60
+ ? bbox
61
+ : undefined;
62
+ }
63
+ /**
64
+ * Whether `[x, y]` lies in `bbox`, edges included. A box whose `minX` is
65
+ * greater than its `maxX` crosses the antimeridian.
66
+ */
67
+ function insideBox([x, y], bbox) {
68
+ const [minX, minY, maxX, maxY] = bbox;
69
+ if (y < minY || y > maxY)
70
+ return false;
71
+ return minX <= maxX ? x >= minX && x <= maxX : x >= minX || x <= maxX;
72
+ }
53
73
  class GeoPlaces {
74
+ /**
75
+ * @param client Anything with the client's `send` — the adapter calls
76
+ * nothing else (#65). `@chaosity/location-client-react`'s
77
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
78
+ * and a class with private fields admits no other object.
79
+ */
54
80
  constructor(client, map, options = {}) {
55
81
  this.client = client;
56
82
  this.map = map;
@@ -100,13 +126,20 @@ class GeoPlaces {
100
126
  : config.countries.split(','),
101
127
  };
102
128
  }
103
- const response = (await this.client.send(new commands_js_1.GeocodeCommand(commandInput)));
129
+ const response = await this.client.send(new commands_js_1.GeocodeCommand(commandInput));
104
130
  const converted = (0, amazon_location_utilities_datatypes_1.geocodeResponseToFeatureCollection)(response, {
105
131
  flattenProperties: true,
106
132
  });
133
+ // Geocode takes no box, so the geocoder's `bbox` is applied here, to what
134
+ // comes back (#59). It used to be ignored, and a result picked with Enter
135
+ // could land outside the box the integrator set.
136
+ const bbox = boundingBox(config.bbox);
137
+ const features = bbox
138
+ ? converted.features.filter((f) => insideBox(f.geometry.coordinates, bbox))
139
+ : converted.features;
107
140
  const result = {
108
141
  type: 'FeatureCollection',
109
- features: toCarmenFeatures(converted.features),
142
+ features: toCarmenFeatures(features),
110
143
  };
111
144
  log('forwardGeocode returned %d results', result.features.length);
112
145
  return result;
@@ -121,7 +154,7 @@ class GeoPlaces {
121
154
  MaxResults: config.limit || 1,
122
155
  Language: this.normalizeLanguage(config.language),
123
156
  };
124
- const response = (await this.client.send(new commands_js_1.ReverseGeocodeCommand(commandInput)));
157
+ const response = await this.client.send(new commands_js_1.ReverseGeocodeCommand(commandInput));
125
158
  const converted = (0, amazon_location_utilities_datatypes_1.reverseGeocodeResponseToFeatureCollection)(response, {
126
159
  flattenProperties: true,
127
160
  });
@@ -134,43 +167,52 @@ class GeoPlaces {
134
167
  }
135
168
  async getSuggestions(config) {
136
169
  log('getSuggestions query=%s', config.query);
170
+ // Suggest takes exactly ONE of BiasPosition, Filter.BoundingBox and
171
+ // Filter.Circle, and refuses a request with two: 400 "Exactly one of the
172
+ // following fields must be set". This sent a bias always, and the box
173
+ // beside it whenever the geocoder carried one, so a `bbox` made every
174
+ // suggestion fail (#59). The box, when there is one, is the bias.
175
+ const bbox = boundingBox(config.bbox);
137
176
  const center = this.map.getCenter();
138
- const biasPosition = config.proximity && config.proximity.length >= 2
139
- ? [config.proximity[0], config.proximity[1]]
140
- : [center.lng, center.lat];
177
+ const biasPosition = bbox
178
+ ? undefined
179
+ : config.proximity && config.proximity.length >= 2
180
+ ? [config.proximity[0], config.proximity[1]]
181
+ : [center.lng, center.lat];
182
+ const countries = config.countries
183
+ ? Array.isArray(config.countries)
184
+ ? config.countries
185
+ : config.countries.split(',')
186
+ : undefined;
141
187
  const commandInput = {
142
188
  QueryText: config.query,
143
- BiasPosition: biasPosition,
189
+ ...(biasPosition ? { BiasPosition: biasPosition } : {}),
144
190
  MaxResults: config.limit || 5,
145
191
  Language: this.normalizeLanguage(config.language),
146
192
  // No AdditionalFeatures (#3 / T19).
147
193
  //
148
194
  // This used to send `[Core]`, which put every keystroke in the Core
149
- // bucket at $0.50/1k. The only thing Core adds to a Suggest response is
150
- // `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
151
- // nothing else. Verified against Amazon Location on 2026-08-25:
195
+ // bucket at $0.50/1k. What Core adds to a Suggest response is
196
+ // `Highlights` and the place's `Position` (without it,
197
+ // `suggestResponseToFeatureCollection` finds no feature: measured
198
+ // 2026-09-29), and this adapter reads neither — only `Title` and
199
+ // `Place.PlaceId`. Verified against Amazon Location on 2026-08-25:
152
200
  //
153
201
  // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
154
202
  // without -> bucket Label keys: Title, ..., Place
155
203
  //
156
204
  // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
157
205
  // keystroke, so it is the highest-volume call the library makes.
158
- ...(config.countries || config.bbox
206
+ ...(bbox || countries
159
207
  ? {
160
208
  Filter: {
161
- ...(config.countries
162
- ? {
163
- IncludeCountries: Array.isArray(config.countries)
164
- ? config.countries
165
- : config.countries.split(','),
166
- }
167
- : {}),
168
- ...(config.bbox ? { BoundingBox: config.bbox } : {}),
209
+ ...(bbox ? { BoundingBox: bbox } : {}),
210
+ ...(countries ? { IncludeCountries: countries } : {}),
169
211
  },
170
212
  }
171
213
  : {}),
172
214
  };
173
- const response = (await this.client.send(new commands_js_1.SuggestCommand(commandInput)));
215
+ const response = await this.client.send(new commands_js_1.SuggestCommand(commandInput));
174
216
  const suggestions = { suggestions: [] };
175
217
  for (const item of response.ResultItems ?? []) {
176
218
  const text = item.Title;
@@ -193,7 +235,7 @@ class GeoPlaces {
193
235
  Language: this.normalizeLanguage(config.language),
194
236
  ...(additionalFeatures ? { AdditionalFeatures: additionalFeatures } : {}),
195
237
  });
196
- const response = (await this.client.send(command));
238
+ const response = await this.client.send(command);
197
239
  const result = (0, amazon_location_utilities_datatypes_1.getPlaceResponseToFeatureCollection)(response, {
198
240
  flattenProperties: true,
199
241
  });
@@ -4,6 +4,21 @@ export interface TokenResponse {
4
4
  expiresAt?: number;
5
5
  error?: string;
6
6
  }
7
+ export interface GetTokenOptions {
8
+ /**
9
+ * When a refresh fails with an error that waiting can fix — a 429 or 503
10
+ * from `/auth/token`, a network fault, or the `Retry-After` such a failure
11
+ * left standing — return the cached token instead, while it is before its
12
+ * own `exp` (#63). A refusal (401 or 403) still clears the cache and throws,
13
+ * and a forced refresh never falls back: it is asked for because the API
14
+ * refused the cached token.
15
+ *
16
+ * For server-side dispatch only. Never set it where the token is handed to
17
+ * a browser: a token inside `TOKEN_REFRESH_BUFFER_SECONDS` is one the React
18
+ * provider asks to replace at once, so `getClientConfig` does not.
19
+ */
20
+ cachedUntilExpiry?: boolean;
21
+ }
7
22
  export interface TokenProviderConfig {
8
23
  apiUrl: string;
9
24
  clientId: string;
@@ -42,8 +57,10 @@ export declare class TokenProvider {
42
57
  private cachedToken?;
43
58
  private cachedExpiresAt?;
44
59
  private tokenPromise?;
60
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
61
+ private readonly hold;
45
62
  constructor(config: TokenProviderConfig);
46
- getToken(forceRefresh?: boolean): Promise<TokenResponse>;
63
+ getToken(forceRefresh?: boolean, options?: GetTokenOptions): Promise<TokenResponse>;
47
64
  /**
48
65
  * Fetch a token, distinguishing transient failure from terminal (#9).
49
66
  *
@@ -59,6 +76,12 @@ export declare class TokenProvider {
59
76
  * with `success: false`, so a stale token can never be used by accident.
60
77
  */
61
78
  private fetchToken;
79
+ /**
80
+ * #63: the cached token, when the caller asked for it and the failure is one
81
+ * that waiting fixes. A refusal has already cleared the cache in fetchToken,
82
+ * so it can never be the cached token handed back here.
83
+ */
84
+ private fallback;
62
85
  private isExpired;
63
86
  clearCache(): void;
64
87
  }