@chaosity/location-client 0.7.0 → 0.9.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 (44) hide show
  1. package/README.md +73 -12
  2. package/dist/cjs/client/GeoPlacesClient.d.ts +11 -3
  3. package/dist/cjs/client/GeoPlacesClient.js +38 -12
  4. package/dist/cjs/index.d.ts +1 -1
  5. package/dist/cjs/index.js +3 -2
  6. package/dist/cjs/maps/mapStyle.d.ts +3 -1
  7. package/dist/cjs/maps/mapStyle.js +24 -19
  8. package/dist/cjs/maps/staticMap.d.ts +3 -1
  9. package/dist/cjs/maps/staticMap.js +18 -12
  10. package/dist/cjs/server/LocationServiceConnector.d.ts +1 -1
  11. package/dist/cjs/server/LocationServiceConnector.js +17 -20
  12. package/dist/cjs/server/getClientConfig.d.ts +19 -0
  13. package/dist/cjs/server/getClientConfig.js +44 -10
  14. package/dist/cjs/transport/errors.d.ts +18 -1
  15. package/dist/cjs/transport/errors.js +25 -1
  16. package/dist/cjs/transport/http.d.ts +36 -0
  17. package/dist/cjs/transport/http.js +118 -15
  18. package/dist/cjs/types/index.d.ts +19 -2
  19. package/dist/cjs/utils/tokenClaims.d.ts +11 -13
  20. package/dist/cjs/utils/tokenClaims.js +11 -14
  21. package/dist/client/GeoPlacesClient.d.ts +11 -3
  22. package/dist/client/GeoPlacesClient.js +39 -13
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +2 -2
  25. package/dist/maps/mapStyle.d.ts +3 -1
  26. package/dist/maps/mapStyle.js +25 -20
  27. package/dist/maps/staticMap.d.ts +3 -1
  28. package/dist/maps/staticMap.js +19 -13
  29. package/dist/server/LocationServiceConnector.d.ts +1 -1
  30. package/dist/server/LocationServiceConnector.js +18 -21
  31. package/dist/server/getClientConfig.d.ts +19 -0
  32. package/dist/server/getClientConfig.js +43 -10
  33. package/dist/transport/errors.d.ts +18 -1
  34. package/dist/transport/errors.js +24 -1
  35. package/dist/transport/http.d.ts +36 -0
  36. package/dist/transport/http.js +116 -14
  37. package/dist/types/index.d.ts +19 -2
  38. package/dist/utils/tokenClaims.d.ts +11 -13
  39. package/dist/utils/tokenClaims.js +11 -14
  40. package/package.json +1 -1
  41. package/dist/cjs/utils/roundPosition.d.ts +0 -66
  42. package/dist/cjs/utils/roundPosition.js +0 -109
  43. package/dist/utils/roundPosition.d.ts +0 -66
  44. package/dist/utils/roundPosition.js +0 -104
package/README.md CHANGED
@@ -226,9 +226,57 @@ token already in hand, and a token the API stops accepting **before** its `exp`
226
226
  the refresh buffer elapses. `refreshToken` is awaited after a 401, and the
227
227
  request is retried **once** with what it returns. Return the same token, or
228
228
  nothing, and no retry is sent — a request that is going to fail again is not
229
- worth being billed for twice. A 403 is never retried: a new token cannot fix an
229
+ worth a second round trip. A 403 is never retried: a new token cannot fix an
230
230
  `Origin` the application does not allow.
231
231
 
232
+ #### Request options
233
+
234
+ Every call in this package takes the same options object — `client.send`,
235
+ `connector.send`, `fetchMapStyle` and `fetchStaticMap` — and every failure
236
+ arrives as a `LocationServiceException`.
237
+
238
+ ```typescript
239
+ await client.send(command, {
240
+ signal, // AbortSignal — cancels mid-flight AND mid-backoff
241
+ timeoutMs: 10_000, // per ATTEMPT
242
+ overallTimeoutMs: 30_000, // the whole call, waits between attempts included
243
+ retry: { maxAttempts: 3 }, // or `false` for none
244
+ })
245
+ ```
246
+
247
+ `overallTimeoutMs` is the one worth setting deliberately, and it defaults to
248
+ 30 s. `timeoutMs` bounds an attempt, not a call: the API answers a spent quota
249
+ with `Retry-After: 60`, and honouring that literally across two retries blocked
250
+ the caller for about two minutes — past any Lambda budget. Now no attempt gets
251
+ more time than the call has left, and a retry that would have to wait longer
252
+ than the remaining budget is not made at all. You get the API's own error back
253
+ instead, `retryAfterMs` intact, so you can queue the work rather than guess.
254
+
255
+ The map helpers take them as a trailing argument:
256
+
257
+ ```typescript
258
+ const style = await fetchMapStyle(
259
+ apiUrl,
260
+ 'Standard',
261
+ getToken,
262
+ { language: 'fr' },
263
+ { signal },
264
+ )
265
+ const blob = await fetchStaticMap(
266
+ apiUrl,
267
+ { width: 640, height: 400, center },
268
+ getToken,
269
+ { signal },
270
+ )
271
+ ```
272
+
273
+ **A call with no token is refused locally rather than sent.** Every path that
274
+ builds an `Authorization` header checks first, so a token source that has not
275
+ produced one yet raises `InvalidCredentialsException` instead of putting
276
+ `Bearer undefined` on the wire — which could only ever come back a 401, a round
277
+ trip spent to be told what you already know. `GeoPlacesClient` asks `refreshToken` first, so a
278
+ client whose token simply has not arrived yet still works.
279
+
232
280
  #### GeoPlaces Adapter
233
281
 
234
282
  Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
@@ -437,17 +485,30 @@ const connector = new LocationServiceConnector({
437
485
  })
438
486
  ```
439
487
 
440
- ## Cache-Friendly Position Rounding
441
-
442
- `BiasPosition` coordinates are automatically rounded before each API request, to
443
- whatever precision your application is entitled to — a `biasDecimals` claim on
444
- the access token, defaulting to **3 decimal places** (~110 m) when the token
445
- carries none. This maximizes cache hits across nearby users without affecting
446
- result quality — bias is approximate by nature.
447
-
448
- `QueryPosition` (reverse geocode) retains full precision since it represents an exact point the user selected.
449
-
450
- This is handled transparently in both `GeoPlacesClient` and `LocationServiceConnector` — no action needed in application code.
488
+ ## Coordinates Are Sent As You Supply Them
489
+
490
+ Both `GeoPlacesClient` and `LocationServiceConnector` put your command input on
491
+ the wire unchanged. `BiasPosition` and `QueryPosition` arrive at the service at
492
+ the precision you passed.
493
+
494
+ Earlier versions rounded `BiasPosition` to a grid — 3 decimal places by
495
+ default — so that nearby callers could share a cached upstream answer. Requests
496
+ are no longer cached, so the rounding had nothing left to share and only
497
+ lowered the precision the geocoder worked from.
498
+
499
+ That is not a coarser result, it is a different one. A 3 dp grid moves a
500
+ coordinate by up to ~70 m, depending where in its cell the coordinate falls,
501
+ and the places a search returns change well inside that distance: measured
502
+ against this service, a bias moved ~70 m returned a different set of nearby
503
+ places, not the same set in a different order. If you were relying on the
504
+ rounding to group nearby requests, round before you call.
505
+
506
+ One coordinate is normalised: a static map's `center`, `bounding-box` and
507
+ `bounded-positions` are rounded to six decimals — ~10 cm, below one pixel of a
508
+ raster render. That is this library's choice, well inside what the service
509
+ accepts: at most fourteen decimals per number, and at most 36 characters for
510
+ the pair. A value straight from `map.getCenter()` carries fifteen or sixteen
511
+ decimals and fails the first of those. A format rule, not a precision policy.
451
512
 
452
513
  ## Logging
453
514
 
@@ -18,7 +18,7 @@ export declare class GeoPlacesClient {
18
18
  constructor(config: ClientConfig);
19
19
  /**
20
20
  * This application's own configuration, as carried on the access token
21
- * (api#65) — bias precision, and the countries it is scoped to.
21
+ * (api#65) — today, the countries it is scoped to.
22
22
  *
23
23
  * Provided so an application can SHOW its own settings: populate a country
24
24
  * selector with the markets it actually serves, label a settings screen,
@@ -34,8 +34,16 @@ export declare class GeoPlacesClient {
34
34
  /** Prefer the getToken callback (live ref) over a static token string. */
35
35
  private currentToken;
36
36
  /**
37
- * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
38
- * to disable the retry loop. Every failure throws LocationServiceException.
37
+ * A token to send, or a refusal — never `undefined`.
38
+ *
39
+ * `refreshToken` is asked only when there is nothing at all in hand, so a
40
+ * client configured the ordinary way pays nothing for this.
41
+ */
42
+ private ensureToken;
43
+ /**
44
+ * @param options `signal` to cancel, `timeoutMs` per attempt,
45
+ * `overallTimeoutMs` for the whole call, `retry: false` to disable the
46
+ * retry loop. Every failure throws LocationServiceException.
39
47
  */
40
48
  send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
41
49
  private dispatch;
@@ -8,7 +8,6 @@ const debug_1 = __importDefault(require("debug"));
8
8
  const endpoints_js_1 = require("../transport/endpoints.js");
9
9
  const errors_js_1 = require("../transport/errors.js");
10
10
  const http_js_1 = require("../transport/http.js");
11
- const roundPosition_js_1 = require("../utils/roundPosition.js");
12
11
  const tokenClaims_js_1 = require("../utils/tokenClaims.js");
13
12
  const log = (0, debug_1.default)('location-client:api');
14
13
  /**
@@ -26,7 +25,7 @@ class GeoPlacesClient {
26
25
  }
27
26
  /**
28
27
  * This application's own configuration, as carried on the access token
29
- * (api#65) — bias precision, and the countries it is scoped to.
28
+ * (api#65) — today, the countries it is scoped to.
30
29
  *
31
30
  * Provided so an application can SHOW its own settings: populate a country
32
31
  * selector with the markets it actually serves, label a settings screen,
@@ -46,13 +45,39 @@ class GeoPlacesClient {
46
45
  return this.clientConfig.getToken?.() ?? this.clientConfig.token;
47
46
  }
48
47
  /**
49
- * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
50
- * to disable the retry loop. Every failure throws LocationServiceException.
48
+ * A token to send, or a refusal — never `undefined`.
49
+ *
50
+ * `refreshToken` is asked only when there is nothing at all in hand, so a
51
+ * client configured the ordinary way pays nothing for this.
52
+ */
53
+ async ensureToken() {
54
+ // `||`, not `??`: an empty string is a token source with nothing to give,
55
+ // not a decision to send an empty one. With `??` it survived the coalesce,
56
+ // skipped `refreshToken`, and then failed the check two lines below — so
57
+ // `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
58
+ // did not, which is a distinction no caller means to draw.
59
+ const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
60
+ if (!token) {
61
+ throw (0, errors_js_1.noTokenAvailable)('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
62
+ }
63
+ return token;
64
+ }
65
+ /**
66
+ * @param options `signal` to cancel, `timeoutMs` per attempt,
67
+ * `overallTimeoutMs` for the whole call, `retry: false` to disable the
68
+ * retry loop. Every failure throws LocationServiceException.
51
69
  */
52
70
  async send(command, options) {
53
71
  const cmd = command;
54
72
  const url = `${this.clientConfig.apiUrl}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
55
- const token = this.currentToken();
73
+ // The fifth and last place in this package that turns a token into an
74
+ // `Authorization` header, and the last one that would send `Bearer
75
+ // undefined` (#37). The 401 self-heal below cannot cover this case — it
76
+ // needs a request to have been rejected first — so a client whose token
77
+ // source has not produced one yet spent a whole round trip to learn
78
+ // something it already knew. Ask the refresh source instead, and refuse if
79
+ // there is still nothing.
80
+ const token = await this.ensureToken();
56
81
  try {
57
82
  return await this.dispatch(url, token, cmd, options);
58
83
  }
@@ -65,8 +90,8 @@ class GeoPlacesClient {
65
90
  // refreshes in the background may have landed a new one while this
66
91
  // request was in flight.
67
92
  const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
68
- // Nothing new to send. Repeating the request would fail identically, and
69
- // be billed identically.
93
+ // Nothing new to send. Repeating the request would fail identically — a
94
+ // second round trip for the same 401.
70
95
  if (!fresh || fresh === token)
71
96
  throw err;
72
97
  log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
@@ -74,10 +99,11 @@ class GeoPlacesClient {
74
99
  }
75
100
  }
76
101
  dispatch(url, token, cmd, options) {
77
- // Resolve the token BEFORE rounding: the precision this application is
78
- // entitled to is a claim on it (api#65). Absent claim -> the 3 dp floor.
79
- const { biasDecimals } = (0, tokenClaims_js_1.readAppConfigClaims)(token);
80
- const input = (0, roundPosition_js_1.roundPositionFields)(cmd.input, biasDecimals);
102
+ // The caller's input goes out as the caller wrote it. `BiasPosition` used
103
+ // to be rounded here to a grid sized by a token claim, so nearby callers
104
+ // shared a server cache entry; with no cache the rounding only lowered the
105
+ // precision the upstream geocoder had to work with, which moves the
106
+ // results rather than coarsening them (#51).
81
107
  log('Sending %s to %s', cmd.constructor?.name, url);
82
108
  return (0, http_js_1.requestJson)(url, {
83
109
  method: 'POST',
@@ -85,7 +111,7 @@ class GeoPlacesClient {
85
111
  'Content-Type': 'application/json',
86
112
  Authorization: `Bearer ${token}`,
87
113
  },
88
- body: JSON.stringify(input),
114
+ body: JSON.stringify(cmd.input),
89
115
  }, options);
90
116
  }
91
117
  }
@@ -1,6 +1,6 @@
1
1
  export { GeoPlacesClient } from './client/GeoPlacesClient.js';
2
2
  export type { SendOptions } from './client/GeoPlacesClient.js';
3
- export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http.js';
3
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_OVERALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, } from './transport/http.js';
4
4
  export type { RequestOptions } from './transport/http.js';
5
5
  export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh.js';
6
6
  export { LocationServiceException } from './errors/LocationServiceException.js';
package/dist/cjs/index.js CHANGED
@@ -14,13 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.transformRequest = exports.TRAVEL_MODES = exports.TRAFFIC_MODES = exports.TERRAINS = exports.STATIC_MAP_STYLES = exports.SPRITE_VARIANTS = exports.SCALE_BAR_UNITS = exports.MAP_STYLES = exports.MAP_FEATURE_MODES = exports.LABEL_SIZES = exports.CONTOUR_DENSITIES = exports.COLOR_SCHEMES = exports.BUILDINGS = exports.staticMapAccept = exports.fetchStaticMap = exports.buildStaticMapUrl = exports.fetchMapStyle = exports.buildMapStyleUrl = exports.setPoiVisibility = exports.setAllPoiVisibility = exports.POI_CATEGORIES = exports.applyMapLanguage = exports.createTransformRequest = exports.GeoPlaces = exports.LocationServiceException = exports.readTokenExpiry = exports.TOKEN_REFRESH_BUFFER_SECONDS = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.GeoPlacesClient = void 0;
17
+ exports.transformRequest = exports.TRAVEL_MODES = exports.TRAFFIC_MODES = exports.TERRAINS = exports.STATIC_MAP_STYLES = exports.SPRITE_VARIANTS = exports.SCALE_BAR_UNITS = exports.MAP_STYLES = exports.MAP_FEATURE_MODES = exports.LABEL_SIZES = exports.CONTOUR_DENSITIES = exports.COLOR_SCHEMES = exports.BUILDINGS = exports.staticMapAccept = exports.fetchStaticMap = exports.buildStaticMapUrl = exports.fetchMapStyle = exports.buildMapStyleUrl = exports.setPoiVisibility = exports.setAllPoiVisibility = exports.POI_CATEGORIES = exports.applyMapLanguage = exports.createTransformRequest = exports.GeoPlaces = exports.LocationServiceException = exports.readTokenExpiry = exports.TOKEN_REFRESH_BUFFER_SECONDS = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_OVERALL_TIMEOUT_MS = exports.DEFAULT_MAX_ATTEMPTS = exports.GeoPlacesClient = void 0;
18
18
  // Client (Custom - uses our auth instead of AWS SigV4)
19
19
  var GeoPlacesClient_js_1 = require("./client/GeoPlacesClient.js");
20
20
  Object.defineProperty(exports, "GeoPlacesClient", { enumerable: true, get: function () { return GeoPlacesClient_js_1.GeoPlacesClient; } });
21
- // Transport options — cancellation, per-attempt timeout, retry policy
21
+ // Transport options — cancellation, per-attempt timeout, overall budget, retry
22
22
  var http_js_1 = require("./transport/http.js");
23
23
  Object.defineProperty(exports, "DEFAULT_MAX_ATTEMPTS", { enumerable: true, get: function () { return http_js_1.DEFAULT_MAX_ATTEMPTS; } });
24
+ Object.defineProperty(exports, "DEFAULT_OVERALL_TIMEOUT_MS", { enumerable: true, get: function () { return http_js_1.DEFAULT_OVERALL_TIMEOUT_MS; } });
24
25
  Object.defineProperty(exports, "DEFAULT_TIMEOUT_MS", { enumerable: true, get: function () { return http_js_1.DEFAULT_TIMEOUT_MS; } });
25
26
  // Token refresh policy — shared by the server provider and the React provider
26
27
  var tokenRefresh_js_1 = require("./auth/tokenRefresh.js");
@@ -1,4 +1,5 @@
1
1
  import type { StyleSpecification } from 'maplibre-gl';
2
+ import type { RequestOptions } from '../transport/http.js';
2
3
  import type { Buildings, ColorScheme, ContourDensity, MapStyle, Terrain, TrafficMode, TravelMode } from './mapEnums.js';
3
4
  /**
4
5
  * Options for building an AWS Location Service map style URL.
@@ -66,6 +67,7 @@ export declare function buildMapStyleUrl(apiUrl: string, mapStyle: MapStyle, opt
66
67
  * @param mapStyle - Map style name (e.g. 'Standard', 'Monochrome', 'Satellite', 'Hybrid')
67
68
  * @param getToken - Callback returning the current auth token
68
69
  * @param options - Style options; `language` is applied to the descriptor, all others become URL params
70
+ * @param request - Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
69
71
  * @returns Modified MapLibre StyleSpecification object
70
72
  *
71
73
  * @example
@@ -74,4 +76,4 @@ export declare function buildMapStyleUrl(apiUrl: string, mapStyle: MapStyle, opt
74
76
  */
75
77
  export declare function fetchMapStyle(apiUrl: string, mapStyle: MapStyle, getToken: () => string | undefined, options?: MapStyleOptions & {
76
78
  language?: string;
77
- }): Promise<StyleSpecification>;
79
+ }, request?: RequestOptions): Promise<StyleSpecification>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildMapStyleUrl = buildMapStyleUrl;
4
4
  exports.fetchMapStyle = fetchMapStyle;
5
5
  const errors_js_1 = require("../transport/errors.js");
6
+ const http_js_1 = require("../transport/http.js");
6
7
  const mapLanguage_js_1 = require("./mapLanguage.js");
7
8
  /**
8
9
  * Build a map style descriptor URL for the Location Service API.
@@ -50,38 +51,42 @@ function buildMapStyleUrl(apiUrl, mapStyle, options = {}) {
50
51
  * @param mapStyle - Map style name (e.g. 'Standard', 'Monochrome', 'Satellite', 'Hybrid')
51
52
  * @param getToken - Callback returning the current auth token
52
53
  * @param options - Style options; `language` is applied to the descriptor, all others become URL params
54
+ * @param request - Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
53
55
  * @returns Modified MapLibre StyleSpecification object
54
56
  *
55
57
  * @example
56
58
  * const style = await fetchMapStyle(API_URL, 'Standard', getToken, { colorScheme: 'Dark', language: 'fr' })
57
59
  * const map = new maplibregl.Map({ style, transformRequest: createTransformRequest(API_URL, getToken) })
58
60
  */
59
- async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}) {
61
+ async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}, request = {}) {
60
62
  const { language, ...styleOptions } = options;
61
63
  const url = buildMapStyleUrl(apiUrl, mapStyle, styleOptions);
62
64
  const token = getToken();
63
- const response = await fetch(url, {
65
+ // `Bearer undefined` used to go out here, and came back as a 401 the caller
66
+ // had to work backwards from — a whole round trip for a request that was
67
+ // never going to succeed (#37).
68
+ if (!token) {
69
+ throw (0, errors_js_1.noTokenAvailable)('getToken() returned nothing, so no style request was sent. Check the token provider has finished initialising.');
70
+ }
71
+ // Through the shared transport, not a bare fetch: this gets the same
72
+ // per-attempt timeout, overall budget, cancellation and retry as every other
73
+ // call in the package, and the same error type on the way out. It also keeps
74
+ // the API's own message, which is the whole point of reading the body — for
75
+ // a style request that sentence is Amazon's, forwarded verbatim by
76
+ // location-service-api#89:
77
+ //
78
+ // 400 "Traffic is not supported for style."
79
+ // 400 "light is not a supported color scheme for style Standard."
80
+ //
81
+ // This used to throw `Failed to fetch map style: 400`, discarding all of it
82
+ // two lines before anyone could read it — the same defect #89 fixed in the
83
+ // API, one layer up.
84
+ const style = await (0, http_js_1.requestJson)(url, {
64
85
  headers: {
65
86
  Authorization: `Bearer ${token}`,
66
87
  Accept: 'application/json',
67
88
  },
68
- });
69
- if (!response.ok) {
70
- // Read the body. The API sends `{message, code, requestId}` and the message
71
- // is the whole point of it — for a style request it is Amazon's own
72
- // sentence, forwarded verbatim by location-service-api#89:
73
- //
74
- // 400 "Traffic is not supported for style."
75
- // 400 "light is not a supported color scheme for style Standard."
76
- //
77
- // This used to throw `Failed to fetch map style: 400`, discarding all of it
78
- // two lines before anyone could read it — the same defect #89 fixed in the
79
- // API, one layer up. Reuses parseErrorResponse so a style failure arrives as
80
- // the same LocationServiceException as every other call in this package,
81
- // with `code`, `statusCode` and `requestId` intact.
82
- throw (0, errors_js_1.parseErrorResponse)(response.status, response.statusText, await response.text(), response.headers);
83
- }
84
- const style = (await response.json());
89
+ }, request);
85
90
  if (language) {
86
91
  applyLanguageToDescriptor(style, language);
87
92
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestOptions } from '../transport/http.js';
1
2
  import type { ColorScheme, LabelSize, MapFeatureMode, ScaleBarUnit, StaticMapStyle } from './mapEnums.js';
2
3
  /**
3
4
  * Static maps: build the URL, send the right headers, get a Blob.
@@ -74,6 +75,7 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
74
75
  * @param apiUrl Base URL of the Location Service API
75
76
  * @param options Render options; exactly one of center / boundingBox / boundedPositions
76
77
  * @param getToken Callback returning the current auth token
78
+ * @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
77
79
  *
78
80
  * @example
79
81
  * const blob = await fetchStaticMap(API_URL, {
@@ -82,4 +84,4 @@ export declare function buildStaticMapUrl(apiUrl: string, options: StaticMapOpti
82
84
  * }, getToken)
83
85
  * const url = URL.createObjectURL(blob) // remember to revokeObjectURL
84
86
  */
85
- export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, getToken: () => string | undefined): Promise<Blob>;
87
+ export declare function fetchStaticMap(apiUrl: string, options: StaticMapOptions, getToken: () => string | undefined, request?: RequestOptions): Promise<Blob>;
@@ -4,6 +4,7 @@ exports.staticMapAccept = staticMapAccept;
4
4
  exports.buildStaticMapUrl = buildStaticMapUrl;
5
5
  exports.fetchStaticMap = fetchStaticMap;
6
6
  const errors_js_1 = require("../transport/errors.js");
7
+ const http_js_1 = require("../transport/http.js");
7
8
  /**
8
9
  * The Accept header this request must send.
9
10
  *
@@ -55,6 +56,7 @@ function buildStaticMapUrl(apiUrl, options) {
55
56
  * @param apiUrl Base URL of the Location Service API
56
57
  * @param options Render options; exactly one of center / boundingBox / boundedPositions
57
58
  * @param getToken Callback returning the current auth token
59
+ * @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
58
60
  *
59
61
  * @example
60
62
  * const blob = await fetchStaticMap(API_URL, {
@@ -63,19 +65,23 @@ function buildStaticMapUrl(apiUrl, options) {
63
65
  * }, getToken)
64
66
  * const url = URL.createObjectURL(blob) // remember to revokeObjectURL
65
67
  */
66
- async function fetchStaticMap(apiUrl, options, getToken) {
67
- const response = await fetch(buildStaticMapUrl(apiUrl, options), {
68
+ async function fetchStaticMap(apiUrl, options, getToken, request = {}) {
69
+ const token = getToken();
70
+ // The same guard as fetchMapStyle and the server connector: a render is not
71
+ // worth requesting without a token to send (#37).
72
+ if (!token) {
73
+ throw (0, errors_js_1.noTokenAvailable)('getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.');
74
+ }
75
+ // Through the shared transport, so a static map gets the timeout, budget,
76
+ // cancellation and retry every other call has -- and its failures arrive as
77
+ // LocationServiceException rather than as a raw TypeError. The API's own
78
+ // {message, code, requestId} survives, which matters here: the messages are
79
+ // specific and actionable -- "'width' and 'height' are required", "Only one
80
+ // of center, bounding-box or bounded-positions may be set".
81
+ return (0, http_js_1.requestBlob)(buildStaticMapUrl(apiUrl, options), {
68
82
  headers: {
69
- Authorization: `Bearer ${getToken()}`,
83
+ Authorization: `Bearer ${token}`,
70
84
  Accept: staticMapAccept(options.style),
71
85
  },
72
- });
73
- if (!response.ok) {
74
- // Same treatment as fetchMapStyle: the API's {message, code, requestId} is
75
- // the useful part, and a bare "failed: 400" throws it away. The messages
76
- // here are specific and actionable -- "'width' and 'height' are required",
77
- // "Only one of center, bounding-box or bounded-positions may be set".
78
- throw (0, errors_js_1.parseErrorResponse)(response.status, response.statusText, await response.text());
79
- }
80
- return response.blob();
86
+ }, request);
81
87
  }
@@ -77,7 +77,7 @@ export declare class LocationServiceConnector {
77
77
  private buildSource;
78
78
  /**
79
79
  * This application's own configuration, as carried on the access token
80
- * (api#65) — bias precision, and the countries it is scoped to.
80
+ * (api#65) — today, the countries it is scoped to.
81
81
  *
82
82
  * Provided so an application can SHOW its own settings: populate a country
83
83
  * selector with the markets it actually serves, label a settings screen, and
@@ -9,7 +9,6 @@ const LocationServiceException_js_1 = require("../errors/LocationServiceExceptio
9
9
  const endpoints_js_1 = require("../transport/endpoints.js");
10
10
  const errors_js_1 = require("../transport/errors.js");
11
11
  const http_js_1 = require("../transport/http.js");
12
- const roundPosition_js_1 = require("../utils/roundPosition.js");
13
12
  const tokenClaims_js_1 = require("../utils/tokenClaims.js");
14
13
  const getClientConfig_js_1 = require("./getClientConfig.js");
15
14
  const log = (0, debug_1.default)('location-client:connector');
@@ -33,6 +32,12 @@ function headerValue(headers, name) {
33
32
  * spelling of it survives the merge.
34
33
  */
35
34
  const SYSTEM_HEADERS = ['origin', 'content-type', 'authorization'];
35
+ /**
36
+ * What to check when the token source comes up empty. The guard itself is
37
+ * shared with the map fetches (`noTokenAvailable`); only the advice differs,
38
+ * and on this path the answer is always the credentials.
39
+ */
40
+ const NO_TOKEN_ADVICE = 'check clientId/clientSecret configuration';
36
41
  /** The caller's headers minus `names`, however they capitalised them. */
37
42
  function withoutHeaders(headers, names) {
38
43
  if (!headers)
@@ -152,7 +157,7 @@ class LocationServiceConnector {
152
157
  }
153
158
  /**
154
159
  * This application's own configuration, as carried on the access token
155
- * (api#65) — bias precision, and the countries it is scoped to.
160
+ * (api#65) — today, the countries it is scoped to.
156
161
  *
157
162
  * Provided so an application can SHOW its own settings: populate a country
158
163
  * selector with the markets it actually serves, label a settings screen, and
@@ -193,7 +198,7 @@ class LocationServiceConnector {
193
198
  async dispatchWithRetry(source, url, cmd, options) {
194
199
  const token = await source.get();
195
200
  if (!token)
196
- throw noTokenAvailable();
201
+ throw (0, errors_js_1.noTokenAvailable)(NO_TOKEN_ADVICE);
197
202
  try {
198
203
  return await this.dispatch(url, token, cmd, options);
199
204
  }
@@ -204,8 +209,8 @@ class LocationServiceConnector {
204
209
  // token. That single comparison covers every source: a fixed `token`
205
210
  // string, a caller `getToken` that ignores `forceRefresh`, and a cached
206
211
  // token the API has revoked before its `exp` all hand back what we
207
- // already sent — and re-sending it would be a second doomed request, and
208
- // a second billed one.
212
+ // already sent — and re-sending it would be a second doomed request for
213
+ // the same answer.
209
214
  const fresh = await source.get(true);
210
215
  if (!fresh || fresh === token)
211
216
  throw err;
@@ -214,13 +219,12 @@ class LocationServiceConnector {
214
219
  }
215
220
  }
216
221
  dispatch(url, token, cmd, options) {
217
- // The token is resolved before the request is shaped, so the precision this
218
- // application is entitled to is available (api#65). Absent claim -> the
219
- // 3 dp floor, which is what every application gets until one is configured
220
- // otherwise. Recomputed per attempt because a refreshed token may carry
221
- // different claims.
222
- const { biasDecimals } = (0, tokenClaims_js_1.readAppConfigClaims)(token);
223
- const input = (0, roundPosition_js_1.roundPositionFields)(cmd.input, biasDecimals);
222
+ // The caller's input goes out as the caller wrote it — nothing in the body
223
+ // is derived from the token any more. `BiasPosition` used to be rounded
224
+ // here to a grid sized by a token claim, so nearby callers shared a server
225
+ // cache entry; with no cache the rounding only lowered the precision the
226
+ // upstream geocoder had to work with, which moves the results rather than
227
+ // coarsening them (#51).
224
228
  // Every system header is set exactly ONCE, and the caller's own spelling of
225
229
  // each is dropped first.
226
230
  //
@@ -245,7 +249,7 @@ class LocationServiceConnector {
245
249
  Authorization: `Bearer ${token}`,
246
250
  };
247
251
  log('Sending %s request to %s', cmd.constructor?.name, url);
248
- return (0, http_js_1.requestJson)(url, { method: 'POST', headers, body: JSON.stringify(input) }, options);
252
+ return (0, http_js_1.requestJson)(url, { method: 'POST', headers, body: JSON.stringify(cmd.input) }, options);
249
253
  }
250
254
  }
251
255
  exports.LocationServiceConnector = LocationServiceConnector;
@@ -261,10 +265,3 @@ function requireApiUrl(explicit) {
261
265
  }
262
266
  return apiUrl;
263
267
  }
264
- function noTokenAvailable() {
265
- return new LocationServiceException_js_1.LocationServiceException({
266
- code: 'InvalidCredentialsException',
267
- message: 'No token available — check clientId/clientSecret configuration',
268
- details: { source: 'client' },
269
- });
270
- }
@@ -14,7 +14,26 @@ export interface ServerAuthConfig {
14
14
  */
15
15
  forceRefresh?: boolean;
16
16
  }
17
+ /**
18
+ * How many applications one process keeps token providers for.
19
+ *
20
+ * A provider holds a URL, a client id, a secret and one cached JWT, so the
21
+ * ceiling is about memory containment rather than a tuned working set — 64 is
22
+ * far above what a single-tenant service needs and far below anything worth
23
+ * worrying about. An agency past it pays a re-mint for the least recently used
24
+ * application, which is exactly the behaviour this replaced, but only for the
25
+ * coldest one instead of for every alternation.
26
+ */
27
+ export declare const MAX_CACHED_PROVIDERS = 64;
17
28
  export interface ServerClientConfig extends ClientConfig {
29
+ /**
30
+ * Narrowed back to required. `ClientConfig.token` is optional because a
31
+ * client can be driven by `getToken`/`refreshToken` alone, but this type is
32
+ * what `getClientConfig` RESOLVES — it either has a token or it threw — and
33
+ * widening it would push a needless `string | undefined` onto every consumer
34
+ * that reads `config.token`.
35
+ */
36
+ token: string;
18
37
  expiresAt?: number;
19
38
  }
20
39
  /**
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_CACHED_PROVIDERS = void 0;
6
7
  exports.resolveApiUrl = resolveApiUrl;
7
8
  exports.serverTokenSource = serverTokenSource;
8
9
  exports.getClientConfig = getClientConfig;
@@ -11,9 +12,32 @@ const node_crypto_1 = require("node:crypto");
11
12
  const TokenProvider_js_1 = require("../auth/TokenProvider.js");
12
13
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
13
14
  const log = (0, debug_1.default)('location-client:clientConfig');
14
- // Singleton instance to prevent race conditions
15
- let tokenProviderInstance = null;
16
- let currentConfig = null;
15
+ /**
16
+ * How many applications one process keeps token providers for.
17
+ *
18
+ * A provider holds a URL, a client id, a secret and one cached JWT, so the
19
+ * ceiling is about memory containment rather than a tuned working set — 64 is
20
+ * far above what a single-tenant service needs and far below anything worth
21
+ * worrying about. An agency past it pays a re-mint for the least recently used
22
+ * application, which is exactly the behaviour this replaced, but only for the
23
+ * coldest one instead of for every alternation.
24
+ */
25
+ exports.MAX_CACHED_PROVIDERS = 64;
26
+ /**
27
+ * One provider per configuration, most-recently-used last.
28
+ *
29
+ * This used to be TWO module-level variables holding a single provider, and a
30
+ * process serving more than one application therefore evicted the cache on
31
+ * every alternation: A→B→A→B took a full `/auth/token` round trip per call,
32
+ * each writing a jti row, all against the one shared token-endpoint throttle.
33
+ * The hit rate under alternating load was 0% (#39). Nothing leaked between
34
+ * tenants — each caller closes over the provider it asked for — so what this
35
+ * fixes is availability and cost, not confidentiality.
36
+ *
37
+ * A `Map` iterates in insertion order, so re-inserting on a hit makes the first
38
+ * key the least recently used, and an LRU needs no other bookkeeping.
39
+ */
40
+ const tokenProviders = new Map();
17
41
  function getTokenProvider(apiUrl, clientId, clientSecret) {
18
42
  // The SECRET is part of the key. Without it, rotating a client secret while
19
43
  // keeping the same clientId left this process reusing a provider built on the
@@ -21,16 +45,26 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
21
45
  // rather than concatenated so the key is never a secret in its own right, and
22
46
  // never ends up in a log line (#5).
23
47
  const configKey = `${apiUrl}:${clientId}:${(0, node_crypto_1.createHash)('sha256').update(clientSecret).digest('hex').slice(0, 16)}`;
24
- // Reuse existing instance if config matches
25
- if (tokenProviderInstance && currentConfig === configKey) {
48
+ const cached = tokenProviders.get(configKey);
49
+ if (cached) {
26
50
  log('[getTokenProvider] Reusing existing TokenProvider instance');
27
- return tokenProviderInstance;
51
+ // Re-insert to mark it most recently used.
52
+ tokenProviders.delete(configKey);
53
+ tokenProviders.set(configKey, cached);
54
+ return cached;
28
55
  }
29
- // Create new instance if config changed
30
56
  log('[getTokenProvider] Creating new TokenProvider instance');
31
- tokenProviderInstance = new TokenProvider_js_1.TokenProvider({ apiUrl, clientId, clientSecret });
32
- currentConfig = configKey;
33
- return tokenProviderInstance;
57
+ const provider = new TokenProvider_js_1.TokenProvider({ apiUrl, clientId, clientSecret });
58
+ tokenProviders.set(configKey, provider);
59
+ if (tokenProviders.size > exports.MAX_CACHED_PROVIDERS) {
60
+ const leastRecentlyUsed = tokenProviders.keys().next().value;
61
+ /* c8 ignore next — size > 0 here, so the iterator always yields */
62
+ if (leastRecentlyUsed !== undefined) {
63
+ log('[getTokenProvider] Evicting the least recently used TokenProvider');
64
+ tokenProviders.delete(leastRecentlyUsed);
65
+ }
66
+ }
67
+ return provider;
34
68
  }
35
69
  /**
36
70
  * Where the API lives, from the argument or the environment.