@chaosity/location-client 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.
@@ -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;
@@ -46,13 +46,39 @@ class GeoPlacesClient {
46
46
  return this.clientConfig.getToken?.() ?? this.clientConfig.token;
47
47
  }
48
48
  /**
49
- * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
50
- * to disable the retry loop. Every failure throws LocationServiceException.
49
+ * A token to send, or a refusal — never `undefined`.
50
+ *
51
+ * `refreshToken` is asked only when there is nothing at all in hand, so a
52
+ * client configured the ordinary way pays nothing for this.
53
+ */
54
+ async ensureToken() {
55
+ // `||`, not `??`: an empty string is a token source with nothing to give,
56
+ // not a decision to send an empty one. With `??` it survived the coalesce,
57
+ // skipped `refreshToken`, and then failed the check two lines below — so
58
+ // `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
59
+ // did not, which is a distinction no caller means to draw.
60
+ const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
61
+ if (!token) {
62
+ throw (0, errors_js_1.noTokenAvailable)('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
63
+ }
64
+ return token;
65
+ }
66
+ /**
67
+ * @param options `signal` to cancel, `timeoutMs` per attempt,
68
+ * `overallTimeoutMs` for the whole call, `retry: false` to disable the
69
+ * retry loop. Every failure throws LocationServiceException.
51
70
  */
52
71
  async send(command, options) {
53
72
  const cmd = command;
54
73
  const url = `${this.clientConfig.apiUrl}${(0, endpoints_js_1.resolveEndpoint)(cmd)}`;
55
- const token = this.currentToken();
74
+ // The fifth and last place in this package that turns a token into an
75
+ // `Authorization` header, and the last one that would send `Bearer
76
+ // undefined` (#37). The 401 self-heal below cannot cover this case — it
77
+ // needs a request to have been rejected first — so a client whose token
78
+ // source has not produced one yet spent a whole round trip to learn
79
+ // something it already knew. Ask the refresh source instead, and refuse if
80
+ // there is still nothing.
81
+ const token = await this.ensureToken();
56
82
  try {
57
83
  return await this.dispatch(url, token, cmd, options);
58
84
  }
@@ -65,8 +91,8 @@ class GeoPlacesClient {
65
91
  // refreshes in the background may have landed a new one while this
66
92
  // request was in flight.
67
93
  const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
68
- // Nothing new to send. Repeating the request would fail identically, and
69
- // be billed identically.
94
+ // Nothing new to send. Repeating the request would fail identically — a
95
+ // second round trip for the same 401.
70
96
  if (!fresh || fresh === token)
71
97
  throw err;
72
98
  log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
@@ -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
  }
@@ -33,6 +33,12 @@ function headerValue(headers, name) {
33
33
  * spelling of it survives the merge.
34
34
  */
35
35
  const SYSTEM_HEADERS = ['origin', 'content-type', 'authorization'];
36
+ /**
37
+ * What to check when the token source comes up empty. The guard itself is
38
+ * shared with the map fetches (`noTokenAvailable`); only the advice differs,
39
+ * and on this path the answer is always the credentials.
40
+ */
41
+ const NO_TOKEN_ADVICE = 'check clientId/clientSecret configuration';
36
42
  /** The caller's headers minus `names`, however they capitalised them. */
37
43
  function withoutHeaders(headers, names) {
38
44
  if (!headers)
@@ -193,7 +199,7 @@ class LocationServiceConnector {
193
199
  async dispatchWithRetry(source, url, cmd, options) {
194
200
  const token = await source.get();
195
201
  if (!token)
196
- throw noTokenAvailable();
202
+ throw (0, errors_js_1.noTokenAvailable)(NO_TOKEN_ADVICE);
197
203
  try {
198
204
  return await this.dispatch(url, token, cmd, options);
199
205
  }
@@ -204,8 +210,8 @@ class LocationServiceConnector {
204
210
  // token. That single comparison covers every source: a fixed `token`
205
211
  // string, a caller `getToken` that ignores `forceRefresh`, and a cached
206
212
  // 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.
213
+ // already sent — and re-sending it would be a second doomed request for
214
+ // the same answer.
209
215
  const fresh = await source.get(true);
210
216
  if (!fresh || fresh === token)
211
217
  throw err;
@@ -261,10 +267,3 @@ function requireApiUrl(explicit) {
261
267
  }
262
268
  return apiUrl;
263
269
  }
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.
@@ -19,7 +19,9 @@ export declare function parseErrorResponse(status: number, statusText: string, b
19
19
  * expired, and API Gateway turns that into a 401. Its other refusals — no
20
20
  * domain configured for the application, an Origin the application does not
21
21
  * allow — are a Deny policy or a service 403, and a fresh token changes
22
- * neither. Retrying those would send, and bill, the same doomed request twice.
22
+ * neither. Retrying those sends the same doomed request twice, for the same
23
+ * answer — which is the whole cost, since the service meters successful
24
+ * requests and no error response is billed whatever its status.
23
25
  *
24
26
  * Shared by both send paths so the browser client and the server connector
25
27
  * cannot come to different conclusions about the same response.
@@ -30,3 +32,18 @@ export declare function isTokenRejected(err: unknown): boolean;
30
32
  * API sends the first; a date is handled so a proxy or gateway cannot surprise us.
31
33
  */
32
34
  export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
35
+ /**
36
+ * No token to send, so nothing is sent.
37
+ *
38
+ * Every send path resolves a token before it builds a request, and every one of
39
+ * them can come up empty — a provider that has not initialised, a server action
40
+ * that returned nothing, credentials that are not configured. Sending anyway
41
+ * puts the literal string `Bearer undefined` on the wire, which the API answers
42
+ * with a 401 the caller then has to work backwards from — a whole round trip,
43
+ * paid for out of the caller's own deadline, to be told what it already knew.
44
+ * The map fetches did exactly that until #37.
45
+ *
46
+ * `advice` says what to check, because that differs by path: a server connector
47
+ * wants its client credentials looked at, a browser map wants its token source.
48
+ */
49
+ export declare function noTokenAvailable(advice: string): LocationServiceException;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseErrorResponse = parseErrorResponse;
4
4
  exports.isTokenRejected = isTokenRejected;
5
5
  exports.parseRetryAfter = parseRetryAfter;
6
+ exports.noTokenAvailable = noTokenAvailable;
6
7
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
7
8
  /**
8
9
  * Turn a non-2xx response into a LocationServiceException.
@@ -95,7 +96,9 @@ function statusCode(status) {
95
96
  * expired, and API Gateway turns that into a 401. Its other refusals — no
96
97
  * domain configured for the application, an Origin the application does not
97
98
  * allow — are a Deny policy or a service 403, and a fresh token changes
98
- * neither. Retrying those would send, and bill, the same doomed request twice.
99
+ * neither. Retrying those sends the same doomed request twice, for the same
100
+ * answer — which is the whole cost, since the service meters successful
101
+ * requests and no error response is billed whatever its status.
99
102
  *
100
103
  * Shared by both send paths so the browser client and the server connector
101
104
  * cannot come to different conclusions about the same response.
@@ -118,3 +121,24 @@ function parseRetryAfter(value) {
118
121
  return Math.max(0, date - Date.now());
119
122
  return undefined;
120
123
  }
124
+ /**
125
+ * No token to send, so nothing is sent.
126
+ *
127
+ * Every send path resolves a token before it builds a request, and every one of
128
+ * them can come up empty — a provider that has not initialised, a server action
129
+ * that returned nothing, credentials that are not configured. Sending anyway
130
+ * puts the literal string `Bearer undefined` on the wire, which the API answers
131
+ * with a 401 the caller then has to work backwards from — a whole round trip,
132
+ * paid for out of the caller's own deadline, to be told what it already knew.
133
+ * The map fetches did exactly that until #37.
134
+ *
135
+ * `advice` says what to check, because that differs by path: a server connector
136
+ * wants its client credentials looked at, a browser map wants its token source.
137
+ */
138
+ function noTokenAvailable(advice) {
139
+ return new LocationServiceException_js_1.LocationServiceException({
140
+ code: 'InvalidCredentialsException',
141
+ message: `No token available — ${advice}`,
142
+ details: { source: 'client' },
143
+ });
144
+ }
@@ -1,11 +1,38 @@
1
1
  /** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
2
2
  export declare const DEFAULT_TIMEOUT_MS = 10000;
3
+ /**
4
+ * Ceiling for the WHOLE call — every attempt plus every wait between them.
5
+ *
6
+ * `timeoutMs` bounds an attempt, not a call, and the gap between those two is
7
+ * where the caller's own deadline disappears. The API answers a spent quota
8
+ * with `Retry-After: 60`, which the retry loop honoured literally: two waits of
9
+ * a minute each, so one call could sit for ~120 s — past any Lambda budget,
10
+ * past any HTTP gateway, and until now uncancellable (#37).
11
+ *
12
+ * 30 s is picked to sit just under the old worst case: three default attempts
13
+ * that all time out, plus their backoff, came to ~30.75 s. The overlap is not
14
+ * quite nothing — when both earlier attempts burn their full 10 s, the third is
15
+ * clamped to the ~9.25 s that remain, so a response arriving in its final
16
+ * ~0.75 s used to succeed and now times out. That window is why this ships in a
17
+ * MINOR rather than a patch. What it buys is that no call can be made to sit
18
+ * out a retry hint longer than the caller has.
19
+ */
20
+ export declare const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
3
21
  export declare const DEFAULT_MAX_ATTEMPTS = 3;
4
22
  export interface RequestOptions {
5
23
  /** Caller cancellation. Aborting rejects with code `AbortedException`. */
6
24
  signal?: AbortSignal;
7
25
  /** Per ATTEMPT, not for the whole call. Default 10 s. */
8
26
  timeoutMs?: number;
27
+ /**
28
+ * The whole call — attempts and the waits between them. Default 30 s.
29
+ *
30
+ * No attempt is given more than what is left of it, and a retry that would
31
+ * have to wait longer than what is left is not made at all: the API's own
32
+ * error comes back instead, `retryAfterMs` intact, so the caller can decide
33
+ * whether to queue the work or drop it.
34
+ */
35
+ overallTimeoutMs?: number;
9
36
  /** `false` disables retries entirely. Default 3 attempts = 2 retries. */
10
37
  retry?: false | {
11
38
  maxAttempts?: number;
@@ -22,3 +49,12 @@ export declare function backoffMs(attempt: number, random?: () => number): numbe
22
49
  * `details.source = 'client'` so it is distinguishable from the API's own 504.
23
50
  */
24
51
  export declare function requestJson<T>(url: string, init: RequestInit, options?: RequestOptions): Promise<T>;
52
+ /**
53
+ * One request answered as a Blob — the static map path.
54
+ *
55
+ * Exists so the two map fetches are not the only calls in the package without
56
+ * a timeout, a retry or a signal: they used their own bare `fetch`, so a
57
+ * network fault there escaped as a raw `TypeError` while the identical fault on
58
+ * any other call arrived as `NetworkException` (#37).
59
+ */
60
+ export declare function requestBlob(url: string, init: RequestInit, options?: RequestOptions): Promise<Blob>;