@chaosity/location-client 0.12.0 → 0.13.1
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 +125 -36
- package/dist/adapters/GeoPlaces.d.ts +7 -1
- package/dist/adapters/GeoPlaces.js +11 -5
- package/dist/auth/TokenProvider.d.ts +22 -1
- package/dist/auth/TokenProvider.js +42 -2
- package/dist/auth/tokenHold.d.ts +14 -5
- package/dist/auth/tokenHold.js +70 -5
- 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 +10 -4
- package/dist/cjs/auth/TokenProvider.d.ts +22 -1
- package/dist/cjs/auth/TokenProvider.js +42 -2
- package/dist/cjs/auth/tokenHold.d.ts +14 -5
- package/dist/cjs/auth/tokenHold.js +71 -5
- package/dist/cjs/aws.d.ts +4 -0
- package/dist/cjs/aws.js +155 -0
- package/dist/cjs/client/GeoPlacesClient.d.ts +17 -1
- package/dist/cjs/client/GeoPlacesClient.js +18 -62
- package/dist/cjs/index.d.ts +4 -3
- 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 +87 -0
- package/dist/cjs/maps/mapToken.js +181 -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 +18 -3
- package/dist/cjs/server/LocationServiceConnector.js +17 -58
- package/dist/cjs/server/getClientConfig.d.ts +7 -3
- package/dist/cjs/server/getClientConfig.js +9 -12
- package/dist/cjs/server/index.d.ts +1 -1
- 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 +17 -1
- package/dist/client/GeoPlacesClient.js +21 -65
- package/dist/index.d.ts +4 -3
- 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 +87 -0
- package/dist/maps/mapToken.js +177 -0
- package/dist/maps/staticMap.d.ts +7 -3
- package/dist/maps/staticMap.js +14 -13
- package/dist/server/LocationServiceConnector.d.ts +18 -3
- package/dist/server/LocationServiceConnector.js +20 -61
- package/dist/server/getClientConfig.d.ts +7 -3
- package/dist/server/getClientConfig.js +9 -12
- package/dist/server/index.d.ts +1 -1
- 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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { TOKEN_REFUSAL_HOLD_MS, TokenHold, holdFor, sendRetryingOnce, } from '../auth/tokenHold.js';
|
|
2
|
+
import { LocationServiceException } from '../errors/LocationServiceException.js';
|
|
3
|
+
import { noTokenAvailable } from '../transport/errors.js';
|
|
4
|
+
import { withinCall } from '../transport/http.js';
|
|
5
|
+
import { isOurApi } from './createTransformRequest.js';
|
|
6
|
+
/**
|
|
7
|
+
* One hold per `MapTokens` object, so the helpers handed the same one share
|
|
8
|
+
* what the API and `refreshToken` said (#38): a refusal one of them met is not
|
|
9
|
+
* asked again by the next. A bare `getToken` never asks again, and needs none.
|
|
10
|
+
*/
|
|
11
|
+
const holds = new WeakMap();
|
|
12
|
+
function holdOf(tokens) {
|
|
13
|
+
let hold = holds.get(tokens);
|
|
14
|
+
if (!hold)
|
|
15
|
+
holds.set(tokens, (hold = new TokenHold()));
|
|
16
|
+
return hold;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The token each `MapTokens` object's refresh last brought, and until when a
|
|
20
|
+
* 401 while it is in hand is not news (#72). One per object, beside its hold:
|
|
21
|
+
* a note per helper had each of two helpers handed one object take the
|
|
22
|
+
* other's token as news, and ask again for every refusal.
|
|
23
|
+
*/
|
|
24
|
+
const broughtBy = new WeakMap();
|
|
25
|
+
/**
|
|
26
|
+
* Send with the token in hand, and after a 401 once more with a different one
|
|
27
|
+
* from `refreshToken`, all within the call's signal and deadline (#62). A 403
|
|
28
|
+
* is never retried, and neither is the token the API refused.
|
|
29
|
+
*/
|
|
30
|
+
export async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
|
|
31
|
+
const tokens = typeof source === 'function' ? { getToken: source } : source;
|
|
32
|
+
const token = tokens.getToken();
|
|
33
|
+
if (!token)
|
|
34
|
+
throw noTokenAvailable(noTokenAdvice);
|
|
35
|
+
const { refreshToken } = tokens;
|
|
36
|
+
if (!refreshToken)
|
|
37
|
+
return send(token);
|
|
38
|
+
return sendRetryingOnce(holdOf(tokens), token, send, async () => (await withinCall(refreshToken(), call)) ?? tokens.getToken());
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Recover the tiles MapLibre fetches itself when the API refuses the token
|
|
42
|
+
* (#72).
|
|
43
|
+
*
|
|
44
|
+
* `createTransformRequest` builds each request synchronously and never sees
|
|
45
|
+
* the answer, so a refused tile used to leave a hole until the page reloaded.
|
|
46
|
+
* This listens for MapLibre's `error` events. On a 401 from a URL of our API it
|
|
47
|
+
* asks `refreshToken` once for the whole burst. When `getToken` then returns a
|
|
48
|
+
* different token, it reloads each refused tile with `map.refreshTiles`, whose
|
|
49
|
+
* requests carry that token. So `refreshToken` must make `getToken` return
|
|
50
|
+
* what it obtained: the tiles have no other way to receive it. A refused
|
|
51
|
+
* request that is not a tile (a glyph, a sprite) has the token replaced for
|
|
52
|
+
* the next one, and nothing reloaded.
|
|
53
|
+
*
|
|
54
|
+
* Tiles are reloaded by id, never a whole source: MapLibre 6 reloads a source's
|
|
55
|
+
* errored tiles as still loading, and they wait for a load that never comes.
|
|
56
|
+
*
|
|
57
|
+
* What it learns is held as the fetch helpers hold it, and shared with them
|
|
58
|
+
* when they are handed the same `tokens` object (#38): a token `refreshToken`
|
|
59
|
+
* could not replace, a token it brought that the API refused on a tile
|
|
60
|
+
* reloaded with it, and a failure that says when to ask again, are not asked
|
|
61
|
+
* about again until they lapse or the token in hand changes. While the token
|
|
62
|
+
* a refresh brought is in hand, any other refused tile is reloaded once with
|
|
63
|
+
* it, without asking.
|
|
64
|
+
*
|
|
65
|
+
* Returns a function that stops listening.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* const tokens = { getToken, refreshToken }
|
|
69
|
+
* const map = new Map({
|
|
70
|
+
* container: 'map',
|
|
71
|
+
* style: await fetchMapStyle(API_URL, 'Standard', tokens),
|
|
72
|
+
* transformRequest: createTransformRequest(API_URL, getToken),
|
|
73
|
+
* })
|
|
74
|
+
* refreshTokenOnUnauthorized(map, API_URL, tokens)
|
|
75
|
+
*/
|
|
76
|
+
export function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
|
|
77
|
+
const hold = holdOf(tokens);
|
|
78
|
+
// Each source's refused tiles, keyed `z/x/y` so a tile refused twice is
|
|
79
|
+
// reloaded once. Tiles refused while a hold stands are kept, so the next ask
|
|
80
|
+
// reloads them; MapLibre ignores ids no longer in view.
|
|
81
|
+
const refused = new Map();
|
|
82
|
+
// The token each tile was reloaded with here, keyed `source:z/x/y`.
|
|
83
|
+
const reloadedWith = new Map();
|
|
84
|
+
let asking = false;
|
|
85
|
+
const reload = (sourceId, tiles, token) => {
|
|
86
|
+
for (const { x, y, z } of tiles)
|
|
87
|
+
reloadedWith.set(`${sourceId}:${z}/${x}/${y}`, token);
|
|
88
|
+
map.refreshTiles(sourceId, tiles);
|
|
89
|
+
};
|
|
90
|
+
const onError = ({ error, sourceId, tile }) => {
|
|
91
|
+
if (error?.status !== 401 || !error.url || !isOurApi(error.url, apiUrl))
|
|
92
|
+
return;
|
|
93
|
+
let refusedTile;
|
|
94
|
+
if (sourceId && tile) {
|
|
95
|
+
const { x, y, z } = tile.tileID.canonical;
|
|
96
|
+
const tiles = refused.get(sourceId) ?? new Map();
|
|
97
|
+
refused.set(sourceId, tiles.set(`${z}/${x}/${y}`, { x, y, z }));
|
|
98
|
+
refusedTile = [sourceId, { x, y, z }];
|
|
99
|
+
}
|
|
100
|
+
if (asking)
|
|
101
|
+
return;
|
|
102
|
+
const inHand = tokens.getToken();
|
|
103
|
+
const held = hold.check(inHand);
|
|
104
|
+
if (held && !held.askAgain)
|
|
105
|
+
return;
|
|
106
|
+
// A 401 while the token a refresh brought is in hand. The event does not
|
|
107
|
+
// say which token the refused request carried, and a tile requested with
|
|
108
|
+
// the token before, answered late, looks like the new token refused. So
|
|
109
|
+
// only a tile reloaded here with the token in hand is that token refused:
|
|
110
|
+
// the API refuses every token, as it does a map pointed at an API its
|
|
111
|
+
// tokens are not for, and asking again minted a token and reloaded the
|
|
112
|
+
// tiles several times a second. That is remembered, as a send path
|
|
113
|
+
// remembers its retry refused, and the next ask is after the hold. Any
|
|
114
|
+
// other tile is reloaded once with the token in hand; anything else (a
|
|
115
|
+
// glyph, a sprite) asks nothing.
|
|
116
|
+
const brought = broughtBy.get(tokens);
|
|
117
|
+
if (inHand && inHand === brought?.token && Date.now() < brought.until) {
|
|
118
|
+
if (!refusedTile)
|
|
119
|
+
return;
|
|
120
|
+
const [id, coordinates] = refusedTile;
|
|
121
|
+
const key = `${coordinates.z}/${coordinates.x}/${coordinates.y}`;
|
|
122
|
+
if (reloadedWith.get(`${id}:${key}`) !== inHand) {
|
|
123
|
+
refused.get(id)?.delete(key);
|
|
124
|
+
reload(id, [coordinates], inHand);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Kept in `refused`, as every tile refused during the hold is.
|
|
128
|
+
broughtBy.delete(tokens);
|
|
129
|
+
hold.remember(unauthorized('The API refused a tile reloaded with the token refreshToken brought.'), inHand);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
asking = true;
|
|
133
|
+
// Asked inside an executor, so a `refreshToken` that throws instead of
|
|
134
|
+
// rejecting lands in the catch below rather than in MapLibre's emitter.
|
|
135
|
+
new Promise((resolve) => resolve(tokens.refreshToken()))
|
|
136
|
+
.then(() => {
|
|
137
|
+
// A reloaded tile takes its token from `getToken`, as every tile does,
|
|
138
|
+
// so that is the token that must have changed. Reloading on what
|
|
139
|
+
// `refreshToken` returned alone would send the refused one again, and
|
|
140
|
+
// its 401 would ask again: a loop of refreshes and reloads.
|
|
141
|
+
const now = tokens.getToken();
|
|
142
|
+
if (!now || now === inHand) {
|
|
143
|
+
// MapLibre's error carries none of the API's fields, so the refusal
|
|
144
|
+
// is remembered as the 401 it was.
|
|
145
|
+
hold.remember(unauthorized('The API refused the token, and getToken has no other since refreshToken settled.'), inHand);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
broughtBy.set(tokens, {
|
|
149
|
+
token: now,
|
|
150
|
+
until: Date.now() + TOKEN_REFUSAL_HOLD_MS,
|
|
151
|
+
});
|
|
152
|
+
// What was reloaded with an earlier token can no longer match.
|
|
153
|
+
reloadedWith.clear();
|
|
154
|
+
for (const [id, tiles] of refused)
|
|
155
|
+
if (tiles.size)
|
|
156
|
+
reload(id, [...tiles.values()], now);
|
|
157
|
+
})
|
|
158
|
+
.catch((refusal) => {
|
|
159
|
+
// Only a failure that says when to ask again is remembered; any other
|
|
160
|
+
// leaves the next refused tile to ask, as the send paths do.
|
|
161
|
+
if (holdFor(refusal) > 0)
|
|
162
|
+
hold.remember(refusal, inHand);
|
|
163
|
+
})
|
|
164
|
+
.finally(() => {
|
|
165
|
+
refused.clear();
|
|
166
|
+
asking = false;
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
map.on('error', onError);
|
|
170
|
+
return () => map.off('error', onError);
|
|
171
|
+
}
|
|
172
|
+
/** A refusal MapLibre reported, remembered as the 401 it was. */
|
|
173
|
+
const unauthorized = (message) => new LocationServiceException({
|
|
174
|
+
code: 'UnauthorizedException',
|
|
175
|
+
message,
|
|
176
|
+
statusCode: 401,
|
|
177
|
+
});
|
package/dist/maps/staticMap.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RequestOptions } from '../transport/http.js';
|
|
2
2
|
import type { ColorScheme, LabelSize, MapFeatureMode, ScaleBarUnit, StaticMapStyle } from './mapEnums.js';
|
|
3
|
+
import type { MapTokenSource } from './mapToken.js';
|
|
3
4
|
/**
|
|
4
5
|
* Static maps: build the URL, send the right headers, get a Blob.
|
|
5
6
|
*
|
|
@@ -110,11 +111,14 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
|
|
|
110
111
|
*
|
|
111
112
|
* A refused plan feature — a Satellite `style`, the default when none is
|
|
112
113
|
* given, or a `politicalView` — rejects with a `LocationServiceException`
|
|
113
|
-
* whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does.
|
|
114
|
+
* whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does. Given
|
|
115
|
+
* `{ getToken, refreshToken }`, a refused token is replaced once, as
|
|
116
|
+
* `fetchMapStyle` does it (#72).
|
|
114
117
|
*
|
|
115
118
|
* @param apiUrl Base URL of the Location Service API
|
|
116
119
|
* @param options Render options; exactly one of center / boundingBox / boundedPositions
|
|
117
|
-
* @param
|
|
120
|
+
* @param tokens Callback returning the current auth token, or
|
|
121
|
+
* `{ getToken, refreshToken }` to recover from a refused one
|
|
118
122
|
* @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
119
123
|
*
|
|
120
124
|
* @example
|
|
@@ -124,4 +128,4 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
|
|
|
124
128
|
* }, getToken)
|
|
125
129
|
* const url = URL.createObjectURL(blob) // remember to revokeObjectURL
|
|
126
130
|
*/
|
|
127
|
-
export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions,
|
|
131
|
+
export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, tokens: MapTokenSource, request?: RequestOptions): Promise<Blob>;
|
package/dist/maps/staticMap.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { requestBlob, startCall } from '../transport/http.js';
|
|
2
|
+
import { sendWithTokenRefresh } from './mapToken.js';
|
|
3
3
|
/**
|
|
4
4
|
* The Accept header this request must send.
|
|
5
5
|
*
|
|
@@ -50,11 +50,14 @@ export function buildStaticMapUrl(apiUrl, options) {
|
|
|
50
50
|
*
|
|
51
51
|
* A refused plan feature — a Satellite `style`, the default when none is
|
|
52
52
|
* given, or a `politicalView` — rejects with a `LocationServiceException`
|
|
53
|
-
* whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does.
|
|
53
|
+
* whose `isFeatureNotEntitled` is true, as `fetchMapStyle` does. Given
|
|
54
|
+
* `{ getToken, refreshToken }`, a refused token is replaced once, as
|
|
55
|
+
* `fetchMapStyle` does it (#72).
|
|
54
56
|
*
|
|
55
57
|
* @param apiUrl Base URL of the Location Service API
|
|
56
58
|
* @param options Render options; exactly one of center / boundingBox / boundedPositions
|
|
57
|
-
* @param
|
|
59
|
+
* @param tokens Callback returning the current auth token, or
|
|
60
|
+
* `{ getToken, refreshToken }` to recover from a refused one
|
|
58
61
|
* @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
|
|
59
62
|
*
|
|
60
63
|
* @example
|
|
@@ -64,23 +67,21 @@ export function buildStaticMapUrl(apiUrl, options) {
|
|
|
64
67
|
* }, getToken)
|
|
65
68
|
* const url = URL.createObjectURL(blob) // remember to revokeObjectURL
|
|
66
69
|
*/
|
|
67
|
-
export async function fetchStaticMap(apiUrl, options,
|
|
68
|
-
const
|
|
69
|
-
// The same guard as fetchMapStyle and the server connector: a render is not
|
|
70
|
-
// worth requesting without a token to send (#37).
|
|
71
|
-
if (!token) {
|
|
72
|
-
throw noTokenAvailable('getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.');
|
|
73
|
-
}
|
|
70
|
+
export async function fetchStaticMap(apiUrl, options, tokens, request = {}) {
|
|
71
|
+
const call = startCall(request);
|
|
74
72
|
// Through the shared transport, so a static map gets the timeout, budget,
|
|
75
73
|
// cancellation and retry every other call has -- and its failures arrive as
|
|
76
74
|
// LocationServiceException rather than as a raw TypeError. The API's own
|
|
77
75
|
// {message, code, requestId} survives, which matters here: the messages are
|
|
78
76
|
// specific and actionable -- "'width' and 'height' are required", "Only one
|
|
79
77
|
// of center, bounding-box or bounded-positions may be set".
|
|
80
|
-
|
|
78
|
+
//
|
|
79
|
+
// The same guard as fetchMapStyle and the server connector: a render is not
|
|
80
|
+
// worth requesting without a token to send (#37).
|
|
81
|
+
return sendWithTokenRefresh(tokens, 'getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.', call, (token) => requestBlob(buildStaticMapUrl(apiUrl, options), {
|
|
81
82
|
headers: {
|
|
82
83
|
Authorization: `Bearer ${token}`,
|
|
83
84
|
Accept: staticMapAccept(options.style),
|
|
84
85
|
},
|
|
85
|
-
},
|
|
86
|
+
}, call));
|
|
86
87
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { GetTokenOptions } from '../auth/TokenProvider.js';
|
|
1
2
|
import type { VerifyAddressResponse } from '../client/commands.js';
|
|
2
3
|
import type { RequestOptions } from '../transport/http.js';
|
|
4
|
+
import type { CommandOutput, CommandWithOutput } from '../types/index.js';
|
|
3
5
|
import type { AppConfigClaims } from '../utils/tokenClaims.js';
|
|
4
6
|
export interface ConnectorConfig {
|
|
5
7
|
/** Falls back to `LOCATION_API_URL` / `LOCATION_SERVICE_API_URL`. */
|
|
@@ -14,10 +16,13 @@ export interface ConnectorConfig {
|
|
|
14
16
|
* a long-lived connector survive expiry.
|
|
15
17
|
*
|
|
16
18
|
* `forceRefresh` is passed as `true` when the API has just rejected the token
|
|
17
|
-
* this returned
|
|
18
|
-
*
|
|
19
|
+
* this returned, and `options` always asks for `cachedUntilExpiry` (#63): a
|
|
20
|
+
* source that can keep sending its cached token through a throttled refresh
|
|
21
|
+
* should, since a connector's token never reaches a browser. The signature
|
|
22
|
+
* is `TokenProvider.getToken`'s, so
|
|
23
|
+
* `getToken: (f, o) => provider.getToken(f, o)` is a complete implementation.
|
|
19
24
|
*/
|
|
20
|
-
getToken?: (forceRefresh?: boolean) => Promise<string | {
|
|
25
|
+
getToken?: (forceRefresh?: boolean, options?: GetTokenOptions) => Promise<string | {
|
|
21
26
|
token?: string;
|
|
22
27
|
} | undefined>;
|
|
23
28
|
/** Falls back to `LOCATION_CLIENT_ID` / `LOCATION_SERVICE_CLIENT_ID`. */
|
|
@@ -109,6 +114,16 @@ export declare class LocationServiceConnector {
|
|
|
109
114
|
* header beats the connector default, whatever the caller capitalised.
|
|
110
115
|
*/
|
|
111
116
|
private effectiveOrigin;
|
|
117
|
+
/**
|
|
118
|
+
* Send a command, and resolve with its output:
|
|
119
|
+
* `await connector.send(new SearchTextCommand(…))` is a
|
|
120
|
+
* `SearchTextCommandOutput`, with nothing to annotate (#68).
|
|
121
|
+
*/
|
|
122
|
+
send<C extends CommandWithOutput>(command: C, options?: SendOptions): Promise<CommandOutput<C>>;
|
|
123
|
+
/**
|
|
124
|
+
* The signature `send` had before it inferred its output (#68), kept for
|
|
125
|
+
* calls naming both type arguments, as `GeoPlacesClient.send` keeps it.
|
|
126
|
+
*/
|
|
112
127
|
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
113
128
|
/**
|
|
114
129
|
* Verify a PlaceId: `send(new VerifyAddressCommand({ PlaceId }))`, typed
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
|
-
import { TokenHold,
|
|
2
|
+
import { TokenHold, sendRetryingOnce } from '../auth/tokenHold.js';
|
|
3
3
|
import { VerifyAddressCommand } from '../client/commands.js';
|
|
4
4
|
import { LocationServiceException } from '../errors/LocationServiceException.js';
|
|
5
5
|
import { resolveEndpoint } from '../transport/endpoints.js';
|
|
6
|
-
import {
|
|
7
|
-
import { requestJson } from '../transport/http.js';
|
|
6
|
+
import { noTokenAvailable } from '../transport/errors.js';
|
|
7
|
+
import { requestJson, startCall, withinCall } from '../transport/http.js';
|
|
8
8
|
import { readAppConfigClaims } from '../utils/tokenClaims.js';
|
|
9
9
|
import { resolveApiUrl, serverTokenSource } from './getClientConfig.js';
|
|
10
10
|
const log = debug('location-client:connector');
|
|
@@ -134,7 +134,10 @@ export class LocationServiceConnector {
|
|
|
134
134
|
return {
|
|
135
135
|
apiUrl: () => requireApiUrl(apiUrl),
|
|
136
136
|
get: async (forceRefresh) => {
|
|
137
|
-
|
|
137
|
+
// As the environment source below asks its provider (#63).
|
|
138
|
+
const result = await getToken(forceRefresh, {
|
|
139
|
+
cachedUntilExpiry: true,
|
|
140
|
+
});
|
|
138
141
|
if (!result)
|
|
139
142
|
return undefined;
|
|
140
143
|
return typeof result === 'string' ? result : result.token;
|
|
@@ -156,7 +159,9 @@ export class LocationServiceConnector {
|
|
|
156
159
|
// Already validated by serverTokenSource, which cannot resolve
|
|
157
160
|
// credentials without it.
|
|
158
161
|
apiUrl: () => env.apiUrl,
|
|
159
|
-
|
|
162
|
+
// A throttled refresh keeps the cached token in use until its own exp
|
|
163
|
+
// (#63): this is a server dispatch, never a token handed to a browser.
|
|
164
|
+
get: async (forceRefresh) => (await env.getToken(forceRefresh, { cachedUntilExpiry: true })).token,
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
/**
|
|
@@ -194,8 +199,11 @@ export class LocationServiceConnector {
|
|
|
194
199
|
const source = this.source();
|
|
195
200
|
const cmd = command;
|
|
196
201
|
const url = `${source.apiUrl()}${resolveEndpoint(cmd)}`;
|
|
202
|
+
// The call's deadline starts here, before the token is waited for, so the
|
|
203
|
+
// caller's `overallTimeoutMs` and `signal` bound the whole call (#62).
|
|
204
|
+
const call = startCall(options);
|
|
197
205
|
try {
|
|
198
|
-
return await this.dispatchWithRetry(source, url, cmd,
|
|
206
|
+
return await this.dispatchWithRetry(source, url, cmd, call);
|
|
199
207
|
}
|
|
200
208
|
catch (err) {
|
|
201
209
|
throw explainMissingOrigin(err, this.effectiveOrigin(options));
|
|
@@ -213,63 +221,14 @@ export class LocationServiceConnector {
|
|
|
213
221
|
return this.send(new VerifyAddressCommand({ PlaceId: placeId }), options);
|
|
214
222
|
}
|
|
215
223
|
async dispatchWithRetry(source, url, cmd, options) {
|
|
216
|
-
|
|
224
|
+
// Raced against the caller's signal and deadline, not given them: the
|
|
225
|
+
// token fetch may be shared with other calls (#62).
|
|
226
|
+
const token = await withinCall(source.get(), options);
|
|
217
227
|
if (!token)
|
|
218
228
|
throw noTokenAvailable(NO_TOKEN_ADVICE);
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
// followed said nothing about when to ask again, it is asked now — but the
|
|
223
|
-
// refused token is still not sent.
|
|
224
|
-
const held = this.refused.check(token);
|
|
225
|
-
if (held && !held.askAgain)
|
|
226
|
-
throw held.error;
|
|
227
|
-
let rejected = held?.error;
|
|
228
|
-
if (!held) {
|
|
229
|
-
try {
|
|
230
|
-
return await this.dispatch(url, token, cmd, options);
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
if (!isTokenRejected(err))
|
|
234
|
-
throw err;
|
|
235
|
-
rejected = err;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
// One retry, and only when the replacement is genuinely a different token.
|
|
239
|
-
// That single comparison covers every source: a fixed `token` string, a
|
|
240
|
-
// caller `getToken` that ignores `forceRefresh`, and a cached token the API
|
|
241
|
-
// has revoked before its `exp` all hand back what we already sent — and
|
|
242
|
-
// re-sending it would be a second doomed request for the same answer.
|
|
243
|
-
let fresh;
|
|
244
|
-
try {
|
|
245
|
-
fresh = await source.get(true);
|
|
246
|
-
}
|
|
247
|
-
catch (refusal) {
|
|
248
|
-
// A suspended application's /auth/token refuses it as its data routes
|
|
249
|
-
// refuse its token. Without this, every send asked for another. A
|
|
250
|
-
// refusal that says nothing — a network fault — leaves the source to be
|
|
251
|
-
// asked again, but not the token sent.
|
|
252
|
-
if (holdFor(refusal) > 0)
|
|
253
|
-
this.refused.remember(refusal, token);
|
|
254
|
-
// Only when no hold stands: one already standing keeps its own end, so
|
|
255
|
-
// the refused token is tried again once per hold rather than never.
|
|
256
|
-
else if (!held)
|
|
257
|
-
this.refused.remember(rejected, token, { askAgain: true });
|
|
258
|
-
throw refusal;
|
|
259
|
-
}
|
|
260
|
-
if (!fresh || fresh === token) {
|
|
261
|
-
this.refused.remember(rejected, token);
|
|
262
|
-
throw rejected;
|
|
263
|
-
}
|
|
264
|
-
log('401 on a token the API no longer accepts — retrying once, refreshed');
|
|
265
|
-
try {
|
|
266
|
-
return await this.dispatch(url, fresh, cmd, options);
|
|
267
|
-
}
|
|
268
|
-
catch (again) {
|
|
269
|
-
if (isTokenRejected(again))
|
|
270
|
-
this.refused.remember(again, fresh);
|
|
271
|
-
throw again;
|
|
272
|
-
}
|
|
229
|
+
// Through `sendRetryingOnce`, like every 401 retry here (#38). The forced
|
|
230
|
+
// refresh is raced against the caller as the first ask was (#62).
|
|
231
|
+
return sendRetryingOnce(this.refused, token, (t) => this.dispatch(url, t, cmd, options), () => withinCall(source.get(true), options), () => log('401 on a token the API no longer accepts — retrying once, refreshed'));
|
|
273
232
|
}
|
|
274
233
|
dispatch(url, token, cmd, options) {
|
|
275
234
|
// The caller's input goes out as the caller wrote it — nothing in the body
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TokenResponse } from '../auth/TokenProvider.js';
|
|
1
|
+
import type { GetTokenOptions, TokenResponse } from '../auth/TokenProvider.js';
|
|
2
2
|
import type { ClientConfig } from '../types/index.js';
|
|
3
3
|
export interface ServerAuthConfig {
|
|
4
4
|
apiUrl?: string;
|
|
@@ -60,8 +60,12 @@ export declare function resolveApiUrl(explicit?: string): string | undefined;
|
|
|
60
60
|
*/
|
|
61
61
|
export interface ServerTokenSource {
|
|
62
62
|
apiUrl: string;
|
|
63
|
-
/**
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Resolves with a token or rejects; it never resolves tokenless. The options
|
|
65
|
+
* are `TokenProvider.getToken`'s: the connector asks for `cachedUntilExpiry`
|
|
66
|
+
* (#63), and `getClientConfig` never does.
|
|
67
|
+
*/
|
|
68
|
+
getToken(forceRefresh?: boolean, options?: GetTokenOptions): Promise<TokenResponse & {
|
|
65
69
|
token: string;
|
|
66
70
|
}>;
|
|
67
71
|
}
|
|
@@ -75,10 +75,10 @@ export function resolveApiUrl(explicit) {
|
|
|
75
75
|
*
|
|
76
76
|
* Two answers qualify. `/auth/token`'s own `Invalid credentials` is a secret
|
|
77
77
|
* that matched nothing. The gateway's 401 (`UnauthorizedException`) is the
|
|
78
|
-
* authorizer refusing the Basic pair before `/auth/token` runs
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* every 403
|
|
78
|
+
* authorizer refusing the Basic pair before `/auth/token` runs. An application
|
|
79
|
+
* that is not active is a 403 (`ApplicationNotActiveException`) on either
|
|
80
|
+
* path, whose sentence names its cause, and it is left as the API wrote it,
|
|
81
|
+
* like every 403.
|
|
82
82
|
*/
|
|
83
83
|
function isCredentialsRefusal(error) {
|
|
84
84
|
if (!(error instanceof LocationServiceException))
|
|
@@ -90,11 +90,8 @@ function isCredentialsRefusal(error) {
|
|
|
90
90
|
}
|
|
91
91
|
/** The API's words as a sentence, so the advice can follow them. */
|
|
92
92
|
const sentence = (text) => (/[.!?]$/.test(text) ? text : `${text}.`);
|
|
93
|
-
function credentialsAdvice(clientId
|
|
94
|
-
|
|
95
|
-
return error.code === 'UnauthorizedException'
|
|
96
|
-
? `${check}, and that the application is active there.`
|
|
97
|
-
: `${check}.`;
|
|
93
|
+
function credentialsAdvice(clientId) {
|
|
94
|
+
return `Check that LOCATION_CLIENT_ID ("${clientId}") and LOCATION_CLIENT_SECRET match your application in the developer portal.`;
|
|
98
95
|
}
|
|
99
96
|
export function serverTokenSource(config = {}) {
|
|
100
97
|
log('[serverTokenSource] Starting with config:', {
|
|
@@ -130,11 +127,11 @@ export function serverTokenSource(config = {}) {
|
|
|
130
127
|
const provider = getTokenProvider(apiUrl, clientId, clientSecret);
|
|
131
128
|
return {
|
|
132
129
|
apiUrl,
|
|
133
|
-
async getToken(forceRefresh = false) {
|
|
130
|
+
async getToken(forceRefresh = false, options) {
|
|
134
131
|
log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
|
|
135
132
|
let result;
|
|
136
133
|
try {
|
|
137
|
-
result = await provider.getToken(forceRefresh);
|
|
134
|
+
result = await provider.getToken(forceRefresh, options);
|
|
138
135
|
}
|
|
139
136
|
catch (error) {
|
|
140
137
|
// The API's code and sentence, passed through (#38). Every 401 and 403
|
|
@@ -144,7 +141,7 @@ export function serverTokenSource(config = {}) {
|
|
|
144
141
|
if (isCredentialsRefusal(error)) {
|
|
145
142
|
throw new LocationServiceException({
|
|
146
143
|
code: error.code,
|
|
147
|
-
message: `${sentence(error.message)} ${credentialsAdvice(clientId
|
|
144
|
+
message: `${sentence(error.message)} ${credentialsAdvice(clientId)}`,
|
|
148
145
|
statusCode: error.statusCode,
|
|
149
146
|
requestId: error.requestId,
|
|
150
147
|
details: error.details,
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { TokenProvider } from '../auth/TokenProvider.js';
|
|
2
|
-
export type { TokenProviderConfig, TokenResponse, } from '../auth/TokenProvider.js';
|
|
2
|
+
export type { GetTokenOptions, TokenProviderConfig, TokenResponse, } from '../auth/TokenProvider.js';
|
|
3
3
|
export { getClientConfig } from './getClientConfig.js';
|
|
4
4
|
export type { ServerAuthConfig, ServerClientConfig } from './getClientConfig.js';
|
|
5
5
|
export { LocationServiceConnector } from './LocationServiceConnector.js';
|
package/dist/transport/http.d.ts
CHANGED
|
@@ -38,6 +38,24 @@ export interface RequestOptions {
|
|
|
38
38
|
maxAttempts?: number;
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* A call's options once it has begun: `deadline` is when `overallTimeoutMs`
|
|
43
|
+
* runs out, fixed at the call's entry, so that a wait before the request — a
|
|
44
|
+
* token — spends the same budget the request then gets the rest of (#62).
|
|
45
|
+
*/
|
|
46
|
+
export interface CallOptions extends RequestOptions {
|
|
47
|
+
deadline: number;
|
|
48
|
+
}
|
|
49
|
+
/** Fix a call's deadline at its entry (#62). */
|
|
50
|
+
export declare function startCall<T extends RequestOptions>(options?: T): T & CallOptions;
|
|
51
|
+
/**
|
|
52
|
+
* Wait for `work` within the call: reject with `AbortedException` when the
|
|
53
|
+
* caller's signal aborts, and with `TimeoutException` when its deadline
|
|
54
|
+
* passes, whichever comes first (#62). `work` itself is left running, because
|
|
55
|
+
* it may be shared: one caller's abort must not fail another waiting on the
|
|
56
|
+
* same token fetch.
|
|
57
|
+
*/
|
|
58
|
+
export declare function withinCall<T>(work: Promise<T>, call: CallOptions): Promise<T>;
|
|
41
59
|
/** Exponential backoff with FULL jitter, so retries never march in lockstep. */
|
|
42
60
|
export declare function backoffMs(attempt: number, random?: () => number): number;
|
|
43
61
|
/**
|
package/dist/transport/http.js
CHANGED
|
@@ -25,6 +25,40 @@ export const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
|
|
|
25
25
|
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
26
26
|
const BACKOFF_BASE_MS = 250;
|
|
27
27
|
const BACKOFF_CAP_MS = 4000;
|
|
28
|
+
/** Fix a call's deadline at its entry (#62). */
|
|
29
|
+
export function startCall(options = {}) {
|
|
30
|
+
return {
|
|
31
|
+
...options,
|
|
32
|
+
deadline: Date.now() + (options.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Wait for `work` within the call: reject with `AbortedException` when the
|
|
37
|
+
* caller's signal aborts, and with `TimeoutException` when its deadline
|
|
38
|
+
* passes, whichever comes first (#62). `work` itself is left running, because
|
|
39
|
+
* it may be shared: one caller's abort must not fail another waiting on the
|
|
40
|
+
* same token fetch.
|
|
41
|
+
*/
|
|
42
|
+
export function withinCall(work, call) {
|
|
43
|
+
const { signal, deadline } = call;
|
|
44
|
+
const overallTimeoutMs = call.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
|
|
45
|
+
if (signal?.aborted)
|
|
46
|
+
return Promise.reject(abortedException(signal));
|
|
47
|
+
const left = deadline - Date.now();
|
|
48
|
+
if (left <= 0)
|
|
49
|
+
return Promise.reject(overallTimeoutException(overallTimeoutMs));
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const timer = setTimeout(() => finish(() => reject(overallTimeoutException(overallTimeoutMs))), left);
|
|
52
|
+
const onAbort = () => finish(() => reject(abortedException(signal)));
|
|
53
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
54
|
+
function finish(settle) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
signal?.removeEventListener('abort', onAbort);
|
|
57
|
+
settle();
|
|
58
|
+
}
|
|
59
|
+
work.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
28
62
|
/**
|
|
29
63
|
* Combine the caller's signal with a per-attempt timeout.
|
|
30
64
|
*
|
|
@@ -106,7 +140,9 @@ async function request(url, init, options, read) {
|
|
|
106
140
|
details: { source: 'client' },
|
|
107
141
|
});
|
|
108
142
|
}
|
|
109
|
-
|
|
143
|
+
// A call that began before this request (#62) brings its own deadline, so
|
|
144
|
+
// the request has only what the wait before it left.
|
|
145
|
+
const deadline = options.deadline ?? Date.now() + overallTimeoutMs;
|
|
110
146
|
let lastError;
|
|
111
147
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
112
148
|
// Checked before every attempt: a signal aborted during backoff must not fire one more.
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VerifyAddressCommand, VerifyAddressResponse } from '../client/commands.js';
|
|
1
2
|
/**
|
|
2
3
|
* At least one of `token`, `getToken` or `refreshToken` must supply a token, or
|
|
3
4
|
* `send` refuses locally with `InvalidCredentialsException` rather than putting
|
|
@@ -52,6 +53,30 @@ export interface ClientConfig {
|
|
|
52
53
|
export interface GeoPlacesCommand {
|
|
53
54
|
readonly input: object;
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The part of an AWS SDK command that carries its output type: the request
|
|
58
|
+
* handler its `resolveMiddleware` builds resolves with `{ output }`. The SDK's
|
|
59
|
+
* own `send` reads the output from the command the same way.
|
|
60
|
+
*/
|
|
61
|
+
interface SdkCommandOutputs<O extends object = object> {
|
|
62
|
+
resolveMiddleware(...args: never[]): (...args: never[]) => Promise<{
|
|
63
|
+
output: O;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A command whose output `send` can name (#68): every SDK command, this
|
|
68
|
+
* package's narrowed Places commands among them, and `VerifyAddressCommand`.
|
|
69
|
+
*/
|
|
70
|
+
export type CommandWithOutput = SdkCommandOutputs | VerifyAddressCommand;
|
|
71
|
+
/**
|
|
72
|
+
* What `send` resolves with for a command (#68): an SDK command's own
|
|
73
|
+
* `…CommandOutput`, and `VerifyAddressResponse` for `VerifyAddressCommand`,
|
|
74
|
+
* which is this package's and has no handler.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* type Out = CommandOutput<AutocompleteCommand> // AutocompleteCommandOutput
|
|
78
|
+
*/
|
|
79
|
+
export type CommandOutput<C extends CommandWithOutput> = C extends SdkCommandOutputs<infer O> ? O : VerifyAddressResponse;
|
|
55
80
|
/**
|
|
56
81
|
* Minimal interface for a MapLibre Map instance.
|
|
57
82
|
* Using a structural type avoids hard coupling to a specific maplibre-gl version.
|
|
@@ -69,3 +94,4 @@ export interface MapLike {
|
|
|
69
94
|
getLayoutProperty(layerId: string, name: string): unknown;
|
|
70
95
|
setLayoutProperty(layerId: string, name: string, value: unknown): void;
|
|
71
96
|
}
|
|
97
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chaosity/location-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cjs/index.js",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"url": "https://github.com/chaosity-io/location-service-client/issues"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@aws-sdk/client-geo-places": "^3.
|
|
62
|
-
"@aws/amazon-location-utilities-datatypes": "^1.
|
|
61
|
+
"@aws-sdk/client-geo-places": "^3.1116.0",
|
|
62
|
+
"@aws/amazon-location-utilities-datatypes": "^1.2.4",
|
|
63
63
|
"debug": "^4.4.3"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|