@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,19 +1,28 @@
1
1
  /**
2
2
  * Mapping of POI category names to their MapLibre layer IDs in AWS GeoMaps tiles.
3
- * Layer IDs are stable across Standard, Monochrome, and Hybrid map styles.
3
+ *
4
+ * Standard and Hybrid carry the same sixteen `poi*` layers; Monochrome carries
5
+ * only the three park layers, and Satellite none. A category whose layers a
6
+ * style does not carry is skipped. AWS publishes no list of these ids, so
7
+ * `test/map-poi-layers.test.ts` holds this map to each style's layers as the
8
+ * service served them: every `poi*` layer in exactly one category (#33).
4
9
  */
5
10
  export const POI_CATEGORIES = {
6
11
  food_drink: ['poi_100_food_drink'],
7
12
  entertainment: ['poi_200_going_out_entertainment'],
8
13
  sights: ['poi_300_sights_museums'],
9
- transit: ['poi_400_transit'],
14
+ transit: ['poi_400_transit', 'poi_400_transit_small'],
10
15
  accommodations: ['poi_500_accommodations'],
11
16
  leisure: ['poi_550_leisure_outdoor'],
12
17
  shopping: ['poi_600_shopping'],
13
- business: ['poi_700_business_services'],
14
- facilities: ['poi_800_facilities'],
18
+ business: ['poi_700_business_services', 'poi_700_business_services_generic'],
19
+ facilities: ['poi_800_facilities', 'poi_800_facilities_generic'],
15
20
  areas: ['poi_900_areas_buildings'],
16
- parks: ['poi_landuse_park', 'poi_landuse_public_complex'],
21
+ parks: [
22
+ 'poi_landuse_park',
23
+ 'poi_landuse_park_lowzoom',
24
+ 'poi_landuse_public_complex',
25
+ ],
17
26
  };
18
27
  /**
19
28
  * Set the visibility of one or more POI categories on the map.
@@ -1,6 +1,7 @@
1
1
  import type { StyleSpecification } from 'maplibre-gl';
2
2
  import type { RequestOptions } from '../transport/http.js';
3
3
  import type { Buildings, ColorScheme, ContourDensity, MapStyle, PoiDensity, StylePoiCategory, Terrain, TrafficMode, TravelMode } from './mapEnums.js';
4
+ import type { MapTokenSource } from './mapToken.js';
4
5
  /**
5
6
  * Options for building an AWS Location Service map style URL.
6
7
  * All parameters map directly to query parameters supported by the style descriptor endpoint.
@@ -137,10 +138,16 @@ export declare function buildMapStyleUrl(apiUrl: string, mapStyle: MapStyle, opt
137
138
  * `event.error.status` is 403, and `event.error.body` is a `Blob` holding the
138
139
  * same `{ message, code }` JSON.
139
140
  *
141
+ * A REFUSED TOKEN. Given `{ getToken, refreshToken }` in place of a bare
142
+ * `getToken`, a 401 asks `refreshToken` once and sends again when it brings a
143
+ * different token, within the same `signal` and `overallTimeoutMs` (#72). A
144
+ * 403 is never retried: a new token cannot change it.
145
+ *
140
146
  * @param apiUrl - Base URL of the Location Service API
141
147
  * @param mapStyle - Map style name: 'Standard' or 'Monochrome', or 'Satellite'
142
148
  * or 'Hybrid', which need the `satellite` plan feature (see `MAP_STYLES`)
143
- * @param getToken - Callback returning the current auth token
149
+ * @param tokens - Callback returning the current auth token, or
150
+ * `{ getToken, refreshToken }` to recover from a refused one
144
151
  * @param options - Style options; `language` is applied to the descriptor, all
145
152
  * others become URL params. Those tagged `@planFeature` need that feature of
146
153
  * the application's plan
@@ -151,6 +158,6 @@ export declare function buildMapStyleUrl(apiUrl: string, mapStyle: MapStyle, opt
151
158
  * const style = await fetchMapStyle(API_URL, 'Standard', getToken, { colorScheme: 'Dark', language: 'fr' })
152
159
  * const map = new maplibregl.Map({ style, transformRequest: createTransformRequest(API_URL, getToken) })
153
160
  */
154
- export declare function fetchMapStyle(apiUrl: string, mapStyle: MapStyle, getToken: () => string | undefined, options?: MapStyleOptions & {
161
+ export declare function fetchMapStyle(apiUrl: string, mapStyle: MapStyle, tokens: MapTokenSource, options?: MapStyleOptions & {
155
162
  language?: string;
156
163
  }, request?: RequestOptions): Promise<StyleSpecification>;
@@ -1,6 +1,6 @@
1
- import { noTokenAvailable } from '../transport/errors.js';
2
- import { requestJson } from '../transport/http.js';
1
+ import { requestJson, startCall } from '../transport/http.js';
3
2
  import { labelsByName, languageExpression } from './mapLanguage.js';
3
+ import { sendWithTokenRefresh } from './mapToken.js';
4
4
  /**
5
5
  * Build a map style descriptor URL for the Location Service API.
6
6
  *
@@ -63,10 +63,16 @@ export function buildMapStyleUrl(apiUrl, mapStyle, options = {}) {
63
63
  * `event.error.status` is 403, and `event.error.body` is a `Blob` holding the
64
64
  * same `{ message, code }` JSON.
65
65
  *
66
+ * A REFUSED TOKEN. Given `{ getToken, refreshToken }` in place of a bare
67
+ * `getToken`, a 401 asks `refreshToken` once and sends again when it brings a
68
+ * different token, within the same `signal` and `overallTimeoutMs` (#72). A
69
+ * 403 is never retried: a new token cannot change it.
70
+ *
66
71
  * @param apiUrl - Base URL of the Location Service API
67
72
  * @param mapStyle - Map style name: 'Standard' or 'Monochrome', or 'Satellite'
68
73
  * or 'Hybrid', which need the `satellite` plan feature (see `MAP_STYLES`)
69
- * @param getToken - Callback returning the current auth token
74
+ * @param tokens - Callback returning the current auth token, or
75
+ * `{ getToken, refreshToken }` to recover from a refused one
70
76
  * @param options - Style options; `language` is applied to the descriptor, all
71
77
  * others become URL params. Those tagged `@planFeature` need that feature of
72
78
  * the application's plan
@@ -77,16 +83,10 @@ export function buildMapStyleUrl(apiUrl, mapStyle, options = {}) {
77
83
  * const style = await fetchMapStyle(API_URL, 'Standard', getToken, { colorScheme: 'Dark', language: 'fr' })
78
84
  * const map = new maplibregl.Map({ style, transformRequest: createTransformRequest(API_URL, getToken) })
79
85
  */
80
- export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}, request = {}) {
86
+ export async function fetchMapStyle(apiUrl, mapStyle, tokens, options = {}, request = {}) {
81
87
  const { language, ...styleOptions } = options;
82
88
  const url = buildMapStyleUrl(apiUrl, mapStyle, styleOptions);
83
- const token = getToken();
84
- // `Bearer undefined` used to go out here, and came back as a 401 the caller
85
- // had to work backwards from — a whole round trip for a request that was
86
- // never going to succeed (#37).
87
- if (!token) {
88
- throw noTokenAvailable('getToken() returned nothing, so no style request was sent. Check the token provider has finished initialising.');
89
- }
89
+ const call = startCall(request);
90
90
  // Through the shared transport, not a bare fetch: this gets the same
91
91
  // per-attempt timeout, overall budget, cancellation and retry as every other
92
92
  // call in the package, and the same error type on the way out. It also keeps
@@ -100,12 +100,16 @@ export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}, re
100
100
  // This used to throw `Failed to fetch map style: 400`, discarding all of it
101
101
  // two lines before anyone could read it — the same defect #89 fixed in the
102
102
  // API, one layer up.
103
- const style = await requestJson(url, {
103
+ //
104
+ // `Bearer undefined` used to go out here, and came back as a 401 the caller
105
+ // had to work backwards from — a whole round trip for a request that was
106
+ // never going to succeed (#37). `sendWithTokenRefresh` refuses first.
107
+ const style = await sendWithTokenRefresh(tokens, 'getToken() returned nothing, so no style request was sent. Check the token provider has finished initialising.', call, (token) => requestJson(url, {
104
108
  headers: {
105
109
  Authorization: `Bearer ${token}`,
106
110
  Accept: 'application/json',
107
111
  },
108
- }, request);
112
+ }, call));
109
113
  if (language) {
110
114
  applyLanguageToDescriptor(style, language);
111
115
  }
@@ -0,0 +1,84 @@
1
+ import type { CallOptions } from '../transport/http.js';
2
+ /**
3
+ * Where a map helper gets its token, and how it gets a new one (#72).
4
+ *
5
+ * `getToken` is synchronous by contract: MapLibre's `transformRequest` calls
6
+ * it for every tile and cannot wait. `refreshToken` is how a map recovers when
7
+ * the API refuses the token in hand before its `exp` — a rotated secret, for
8
+ * one — which nothing else in the map path can do. It is the escape hatch
9
+ * `GeoPlacesClient` takes, under the same name.
10
+ */
11
+ export interface MapTokens {
12
+ /** The token in hand, now. */
13
+ getToken: () => string | undefined;
14
+ /** Obtain a new token after the API refused the one in hand. */
15
+ refreshToken?: () => Promise<string | undefined>;
16
+ }
17
+ /** A bare `getToken`, which behaves as it always has, or `MapTokens`. */
18
+ export type MapTokenSource = (() => string | undefined) | MapTokens;
19
+ /**
20
+ * Send with the token in hand, and after a 401 once more with a different one
21
+ * from `refreshToken`, all within the call's signal and deadline (#62). A 403
22
+ * is never retried, and neither is the token the API refused.
23
+ */
24
+ export declare function sendWithTokenRefresh<T>(source: MapTokenSource, noTokenAdvice: string, call: CallOptions, send: (token: string) => Promise<T>): Promise<T>;
25
+ /** A tile's coordinates, as `map.refreshTiles` takes them. */
26
+ interface TileCoordinates {
27
+ x: number;
28
+ y: number;
29
+ z: number;
30
+ }
31
+ /** What MapLibre's `error` event carries for a refused request. */
32
+ interface MapErrorEvent {
33
+ error?: {
34
+ status?: number;
35
+ url?: string;
36
+ };
37
+ sourceId?: string;
38
+ tile?: {
39
+ tileID: {
40
+ canonical: TileCoordinates;
41
+ };
42
+ };
43
+ }
44
+ /** The part of a MapLibre `Map` that `refreshTokenOnUnauthorized` uses. */
45
+ export interface TokenRefreshMap {
46
+ on(type: 'error', listener: (event: MapErrorEvent) => void): unknown;
47
+ off(type: 'error', listener: (event: MapErrorEvent) => void): unknown;
48
+ refreshTiles(sourceId: string, tileIds?: TileCoordinates[]): void;
49
+ }
50
+ /**
51
+ * Recover the tiles MapLibre fetches itself when the API refuses the token
52
+ * (#72).
53
+ *
54
+ * `createTransformRequest` builds each request synchronously and never sees
55
+ * the answer, so a refused tile used to leave a hole until the page reloaded.
56
+ * This listens for MapLibre's `error` events. On a 401 from a URL of our API it
57
+ * asks `refreshToken` once for the whole burst. When `getToken` then returns a
58
+ * different token, it reloads each refused tile with `map.refreshTiles`, whose
59
+ * requests carry that token. So `refreshToken` must make `getToken` return
60
+ * what it obtained: the tiles have no other way to receive it. A refused
61
+ * request that is not a tile (a glyph, a sprite) has the token replaced for
62
+ * the next one, and nothing reloaded.
63
+ *
64
+ * Tiles are reloaded by id, never a whole source: MapLibre 6 reloads a source's
65
+ * errored tiles as still loading, and they wait for a load that never comes.
66
+ *
67
+ * What it learns is held as the fetch helpers hold it, and shared with them
68
+ * when they are handed the same `tokens` object (#38): a token `refreshToken`
69
+ * could not replace, and a failure that says when to ask again, are not asked
70
+ * about again until they lapse or the token in hand changes.
71
+ *
72
+ * Returns a function that stops listening.
73
+ *
74
+ * @example
75
+ * const tokens = { getToken, refreshToken }
76
+ * const map = new Map({
77
+ * container: 'map',
78
+ * style: await fetchMapStyle(API_URL, 'Standard', tokens),
79
+ * transformRequest: createTransformRequest(API_URL, getToken),
80
+ * })
81
+ * refreshTokenOnUnauthorized(map, API_URL, tokens)
82
+ */
83
+ export declare function refreshTokenOnUnauthorized(map: TokenRefreshMap, apiUrl: string, tokens: Required<MapTokens>): () => void;
84
+ export {};
@@ -0,0 +1,122 @@
1
+ import { 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
+ * Send with the token in hand, and after a 401 once more with a different one
20
+ * from `refreshToken`, all within the call's signal and deadline (#62). A 403
21
+ * is never retried, and neither is the token the API refused.
22
+ */
23
+ export async function sendWithTokenRefresh(source, noTokenAdvice, call, send) {
24
+ const tokens = typeof source === 'function' ? { getToken: source } : source;
25
+ const token = tokens.getToken();
26
+ if (!token)
27
+ throw noTokenAvailable(noTokenAdvice);
28
+ const { refreshToken } = tokens;
29
+ if (!refreshToken)
30
+ return send(token);
31
+ return sendRetryingOnce(holdOf(tokens), token, send, async () => (await withinCall(refreshToken(), call)) ?? tokens.getToken());
32
+ }
33
+ /**
34
+ * Recover the tiles MapLibre fetches itself when the API refuses the token
35
+ * (#72).
36
+ *
37
+ * `createTransformRequest` builds each request synchronously and never sees
38
+ * the answer, so a refused tile used to leave a hole until the page reloaded.
39
+ * This listens for MapLibre's `error` events. On a 401 from a URL of our API it
40
+ * asks `refreshToken` once for the whole burst. When `getToken` then returns a
41
+ * different token, it reloads each refused tile with `map.refreshTiles`, whose
42
+ * requests carry that token. So `refreshToken` must make `getToken` return
43
+ * what it obtained: the tiles have no other way to receive it. A refused
44
+ * request that is not a tile (a glyph, a sprite) has the token replaced for
45
+ * the next one, and nothing reloaded.
46
+ *
47
+ * Tiles are reloaded by id, never a whole source: MapLibre 6 reloads a source's
48
+ * errored tiles as still loading, and they wait for a load that never comes.
49
+ *
50
+ * What it learns is held as the fetch helpers hold it, and shared with them
51
+ * when they are handed the same `tokens` object (#38): a token `refreshToken`
52
+ * could not replace, and a failure that says when to ask again, are not asked
53
+ * about again until they lapse or the token in hand changes.
54
+ *
55
+ * Returns a function that stops listening.
56
+ *
57
+ * @example
58
+ * const tokens = { getToken, refreshToken }
59
+ * const map = new Map({
60
+ * container: 'map',
61
+ * style: await fetchMapStyle(API_URL, 'Standard', tokens),
62
+ * transformRequest: createTransformRequest(API_URL, getToken),
63
+ * })
64
+ * refreshTokenOnUnauthorized(map, API_URL, tokens)
65
+ */
66
+ export function refreshTokenOnUnauthorized(map, apiUrl, tokens) {
67
+ const hold = holdOf(tokens);
68
+ // Each source's refused tiles, keyed `z/x/y` so a tile refused twice is
69
+ // reloaded once.
70
+ const refused = new Map();
71
+ let asking = false;
72
+ const onError = ({ error, sourceId, tile }) => {
73
+ if (error?.status !== 401 || !error.url || !isOurApi(error.url, apiUrl))
74
+ return;
75
+ if (sourceId && tile) {
76
+ const { x, y, z } = tile.tileID.canonical;
77
+ const tiles = refused.get(sourceId) ?? new Map();
78
+ refused.set(sourceId, tiles.set(`${z}/${x}/${y}`, { x, y, z }));
79
+ }
80
+ if (asking)
81
+ return;
82
+ const inHand = tokens.getToken();
83
+ const held = hold.check(inHand);
84
+ if (held && !held.askAgain)
85
+ return;
86
+ asking = true;
87
+ // Asked inside an executor, so a `refreshToken` that throws instead of
88
+ // rejecting lands in the catch below rather than in MapLibre's emitter.
89
+ new Promise((resolve) => resolve(tokens.refreshToken()))
90
+ .then(() => {
91
+ // A reloaded tile takes its token from `getToken`, as every tile does,
92
+ // so that is the token that must have changed. Reloading on what
93
+ // `refreshToken` returned alone would send the refused one again, and
94
+ // its 401 would ask again: a loop of refreshes and reloads.
95
+ const now = tokens.getToken();
96
+ if (!now || now === inHand) {
97
+ // MapLibre's error carries none of the API's fields, so the refusal
98
+ // is remembered as the 401 it was.
99
+ hold.remember(new LocationServiceException({
100
+ code: 'UnauthorizedException',
101
+ message: 'The API refused the token, and getToken has no other since refreshToken settled.',
102
+ statusCode: 401,
103
+ }), inHand);
104
+ return;
105
+ }
106
+ for (const [id, tiles] of refused)
107
+ map.refreshTiles(id, [...tiles.values()]);
108
+ })
109
+ .catch((refusal) => {
110
+ // Only a failure that says when to ask again is remembered; any other
111
+ // leaves the next refused tile to ask, as the send paths do.
112
+ if (holdFor(refusal) > 0)
113
+ hold.remember(refusal, inHand);
114
+ })
115
+ .finally(() => {
116
+ refused.clear();
117
+ asking = false;
118
+ });
119
+ };
120
+ map.on('error', onError);
121
+ return () => map.off('error', onError);
122
+ }
@@ -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 getToken Callback returning the current auth token
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, getToken: () => string | undefined, request?: RequestOptions): Promise<Blob>;
131
+ export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, tokens: MapTokenSource, request?: RequestOptions): Promise<Blob>;
@@ -1,5 +1,5 @@
1
- import { noTokenAvailable } from '../transport/errors.js';
2
- import { requestBlob } from '../transport/http.js';
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 getToken Callback returning the current auth token
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, getToken, request = {}) {
68
- const token = getToken();
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
- return requestBlob(buildStaticMapUrl(apiUrl, options), {
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
- }, request);
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 — the signature is `TokenProvider.getToken`'s exactly, so
18
- * `getToken: (f) => provider.getToken(f)` is a complete implementation.
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`. */
@@ -70,6 +75,8 @@ export declare class LocationServiceConnector {
70
75
  private readonly config;
71
76
  private tokenSource?;
72
77
  private readonly origin?;
78
+ /** A token the API refused, and why, until the hold lapses (#38). */
79
+ private readonly refused;
73
80
  readonly serviceId: string;
74
81
  constructor(config?: ConnectorConfig);
75
82
  /**
@@ -107,6 +114,16 @@ export declare class LocationServiceConnector {
107
114
  * header beats the connector default, whatever the caller capitalised.
108
115
  */
109
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
+ */
110
127
  send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
111
128
  /**
112
129
  * Verify a PlaceId: `send(new VerifyAddressCommand({ PlaceId }))`, typed
@@ -1,9 +1,10 @@
1
1
  import debug from 'debug';
2
+ import { TokenHold, sendRetryingOnce } from '../auth/tokenHold.js';
2
3
  import { VerifyAddressCommand } from '../client/commands.js';
3
4
  import { LocationServiceException } from '../errors/LocationServiceException.js';
4
5
  import { resolveEndpoint } from '../transport/endpoints.js';
5
- import { isTokenRejected, noTokenAvailable } from '../transport/errors.js';
6
- import { requestJson } from '../transport/http.js';
6
+ import { noTokenAvailable } from '../transport/errors.js';
7
+ import { requestJson, startCall, withinCall } from '../transport/http.js';
7
8
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
8
9
  import { resolveApiUrl, serverTokenSource } from './getClientConfig.js';
9
10
  const log = debug('location-client:connector');
@@ -100,6 +101,8 @@ function explainMissingOrigin(err, sentOrigin) {
100
101
  */
101
102
  export class LocationServiceConnector {
102
103
  constructor(config = {}) {
104
+ /** A token the API refused, and why, until the hold lapses (#38). */
105
+ this.refused = new TokenHold();
103
106
  this.serviceId = 'Geo Places';
104
107
  this.config = config;
105
108
  // `||`, not `??`, to match the other four env-completed fields: an empty
@@ -131,7 +134,10 @@ export class LocationServiceConnector {
131
134
  return {
132
135
  apiUrl: () => requireApiUrl(apiUrl),
133
136
  get: async (forceRefresh) => {
134
- const result = await getToken(forceRefresh);
137
+ // As the environment source below asks its provider (#63).
138
+ const result = await getToken(forceRefresh, {
139
+ cachedUntilExpiry: true,
140
+ });
135
141
  if (!result)
136
142
  return undefined;
137
143
  return typeof result === 'string' ? result : result.token;
@@ -153,7 +159,9 @@ export class LocationServiceConnector {
153
159
  // Already validated by serverTokenSource, which cannot resolve
154
160
  // credentials without it.
155
161
  apiUrl: () => env.apiUrl,
156
- get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
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,
157
165
  };
158
166
  }
159
167
  /**
@@ -191,8 +199,11 @@ export class LocationServiceConnector {
191
199
  const source = this.source();
192
200
  const cmd = command;
193
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);
194
205
  try {
195
- return await this.dispatchWithRetry(source, url, cmd, options);
206
+ return await this.dispatchWithRetry(source, url, cmd, call);
196
207
  }
197
208
  catch (err) {
198
209
  throw explainMissingOrigin(err, this.effectiveOrigin(options));
@@ -210,27 +221,14 @@ export class LocationServiceConnector {
210
221
  return this.send(new VerifyAddressCommand({ PlaceId: placeId }), options);
211
222
  }
212
223
  async dispatchWithRetry(source, url, cmd, options) {
213
- const token = await source.get();
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);
214
227
  if (!token)
215
228
  throw noTokenAvailable(NO_TOKEN_ADVICE);
216
- try {
217
- return await this.dispatch(url, token, cmd, options);
218
- }
219
- catch (err) {
220
- if (!isTokenRejected(err))
221
- throw err;
222
- // One retry, and only when the replacement is genuinely a different
223
- // token. That single comparison covers every source: a fixed `token`
224
- // string, a caller `getToken` that ignores `forceRefresh`, and a cached
225
- // token the API has revoked before its `exp` all hand back what we
226
- // already sent — and re-sending it would be a second doomed request for
227
- // the same answer.
228
- const fresh = await source.get(true);
229
- if (!fresh || fresh === token)
230
- throw err;
231
- log('401 on a token the API no longer accepts — retrying once, refreshed');
232
- return await this.dispatch(url, fresh, cmd, options);
233
- }
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'));
234
232
  }
235
233
  dispatch(url, token, cmd, options) {
236
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
- /** Resolves with a token or rejects; it never resolves tokenless. */
64
- getToken(forceRefresh?: boolean): Promise<TokenResponse & {
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
  }
@@ -104,8 +108,9 @@ export declare function serverTokenSource(config?: ServerAuthConfig): ServerToke
104
108
  * // Auto-detect from environment
105
109
  * const config = await getClientConfig()
106
110
  *
107
- * // Or override specific values
108
- * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
111
+ * // Or override specific values — the API URL is the one on the
112
+ * // application's page in the developer portal
113
+ * const config = await getClientConfig({ apiUrl: 'https://your-api-url.example' })
109
114
  *
110
115
  * // The API rejected the token before its exp — revoked, or secret rotated
111
116
  * const fresh = await getClientConfig({ forceRefresh: true })