@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.
- package/README.md +207 -40
- package/dist/adapters/GeoPlaces.d.ts +7 -1
- package/dist/adapters/GeoPlaces.js +64 -22
- package/dist/auth/TokenProvider.d.ts +24 -1
- package/dist/auth/TokenProvider.js +81 -12
- package/dist/auth/tokenHold.d.ts +81 -0
- package/dist/auth/tokenHold.js +174 -0
- package/dist/aws.d.ts +4 -0
- package/dist/aws.js +2 -0
- package/dist/cjs/adapters/GeoPlaces.d.ts +7 -1
- package/dist/cjs/adapters/GeoPlaces.js +63 -21
- package/dist/cjs/auth/TokenProvider.d.ts +24 -1
- package/dist/cjs/auth/TokenProvider.js +81 -12
- package/dist/cjs/auth/tokenHold.d.ts +81 -0
- package/dist/cjs/auth/tokenHold.js +181 -0
- package/dist/cjs/aws.d.ts +4 -0
- package/dist/cjs/aws.js +155 -0
- package/dist/cjs/client/GeoPlacesClient.d.ts +23 -3
- package/dist/cjs/client/GeoPlacesClient.js +50 -32
- package/dist/cjs/client/commands.d.ts +2 -3
- package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
- package/dist/cjs/errors/LocationServiceException.js +53 -1
- package/dist/cjs/index.d.ts +5 -4
- package/dist/cjs/index.js +13 -8
- package/dist/cjs/maps/createTransformRequest.d.ts +25 -0
- package/dist/cjs/maps/createTransformRequest.js +1 -0
- package/dist/cjs/maps/mapPoi.d.ts +10 -5
- package/dist/cjs/maps/mapPoi.js +14 -5
- package/dist/cjs/maps/mapStyle.d.ts +9 -2
- package/dist/cjs/maps/mapStyle.js +16 -12
- package/dist/cjs/maps/mapToken.d.ts +84 -0
- package/dist/cjs/maps/mapToken.js +126 -0
- package/dist/cjs/maps/staticMap.d.ts +7 -3
- package/dist/cjs/maps/staticMap.js +13 -12
- package/dist/cjs/server/LocationServiceConnector.d.ts +20 -3
- package/dist/cjs/server/LocationServiceConnector.js +20 -22
- package/dist/cjs/server/getClientConfig.d.ts +10 -5
- package/dist/cjs/server/getClientConfig.js +43 -19
- package/dist/cjs/server/index.d.ts +1 -1
- package/dist/cjs/transport/errors.d.ts +14 -8
- package/dist/cjs/transport/errors.js +26 -18
- package/dist/cjs/transport/http.d.ts +18 -0
- package/dist/cjs/transport/http.js +39 -1
- package/dist/cjs/types/index.d.ts +26 -0
- package/dist/client/GeoPlacesClient.d.ts +23 -3
- package/dist/client/GeoPlacesClient.js +52 -34
- package/dist/client/commands.d.ts +2 -3
- package/dist/errors/LocationServiceException.d.ts +30 -2
- package/dist/errors/LocationServiceException.js +52 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +11 -7
- package/dist/maps/createTransformRequest.d.ts +25 -0
- package/dist/maps/createTransformRequest.js +1 -1
- package/dist/maps/mapPoi.d.ts +10 -5
- package/dist/maps/mapPoi.js +14 -5
- package/dist/maps/mapStyle.d.ts +9 -2
- package/dist/maps/mapStyle.js +17 -13
- package/dist/maps/mapToken.d.ts +84 -0
- package/dist/maps/mapToken.js +122 -0
- package/dist/maps/staticMap.d.ts +7 -3
- package/dist/maps/staticMap.js +14 -13
- package/dist/server/LocationServiceConnector.d.ts +20 -3
- package/dist/server/LocationServiceConnector.js +22 -24
- package/dist/server/getClientConfig.d.ts +10 -5
- package/dist/server/getClientConfig.js +43 -19
- package/dist/server/index.d.ts +1 -1
- package/dist/transport/errors.d.ts +14 -8
- package/dist/transport/errors.js +27 -19
- package/dist/transport/http.d.ts +18 -0
- package/dist/transport/http.js +37 -1
- package/dist/types/index.d.ts +26 -0
- 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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
139
|
-
?
|
|
140
|
-
:
|
|
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.
|
|
150
|
-
// `Highlights
|
|
151
|
-
//
|
|
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
|
-
...(
|
|
206
|
+
...(bbox || countries
|
|
159
207
|
? {
|
|
160
208
|
Filter: {
|
|
161
|
-
...(
|
|
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 =
|
|
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 =
|
|
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
|
}
|