@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.
@@ -3,15 +3,34 @@ 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.DEFAULT_MAX_ATTEMPTS = exports.DEFAULT_TIMEOUT_MS = void 0;
6
+ exports.DEFAULT_MAX_ATTEMPTS = exports.DEFAULT_OVERALL_TIMEOUT_MS = exports.DEFAULT_TIMEOUT_MS = void 0;
7
7
  exports.backoffMs = backoffMs;
8
8
  exports.requestJson = requestJson;
9
+ exports.requestBlob = requestBlob;
9
10
  const debug_1 = __importDefault(require("debug"));
10
11
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
11
12
  const errors_js_1 = require("./errors.js");
12
13
  const log = (0, debug_1.default)('location-client:transport');
13
14
  /** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
14
15
  exports.DEFAULT_TIMEOUT_MS = 10000;
16
+ /**
17
+ * Ceiling for the WHOLE call — every attempt plus every wait between them.
18
+ *
19
+ * `timeoutMs` bounds an attempt, not a call, and the gap between those two is
20
+ * where the caller's own deadline disappears. The API answers a spent quota
21
+ * with `Retry-After: 60`, which the retry loop honoured literally: two waits of
22
+ * a minute each, so one call could sit for ~120 s — past any Lambda budget,
23
+ * past any HTTP gateway, and until now uncancellable (#37).
24
+ *
25
+ * 30 s is picked to sit just under the old worst case: three default attempts
26
+ * that all time out, plus their backoff, came to ~30.75 s. The overlap is not
27
+ * quite nothing — when both earlier attempts burn their full 10 s, the third is
28
+ * clamped to the ~9.25 s that remain, so a response arriving in its final
29
+ * ~0.75 s used to succeed and now times out. That window is why this ships in a
30
+ * MINOR rather than a patch. What it buys is that no call can be made to sit
31
+ * out a retry hint longer than the caller has.
32
+ */
33
+ exports.DEFAULT_OVERALL_TIMEOUT_MS = 30000;
15
34
  exports.DEFAULT_MAX_ATTEMPTS = 3;
16
35
  const BACKOFF_BASE_MS = 250;
17
36
  const BACKOFF_CAP_MS = 4000;
@@ -48,38 +67,86 @@ function backoffMs(attempt, random = Math.random) {
48
67
  const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
49
68
  return Math.floor(random() * ceiling);
50
69
  }
51
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
52
70
  /**
53
- * One JSON request, with timeout, cancellation and retry.
71
+ * Sleep, unless the caller aborts first.
54
72
  *
55
- * Every failure leaves as a LocationServiceException — a fetch rejection
56
- * becomes `NetworkException` with the original as `cause`, an abort becomes
57
- * `AbortedException`, a timeout becomes `TimeoutException` with
58
- * `details.source = 'client'` so it is distinguishable from the API's own 504.
73
+ * The timer used to be uncancellable, so an `abort()` during backoff was
74
+ * ignored until it elapsed — up to a whole `Retry-After` — and the loop only
75
+ * noticed at the top of the next attempt (#37). Resolving early is all that is
76
+ * needed: the loop's own pre-attempt check is what raises `AbortedException`,
77
+ * so exactly one place decides what an abort means.
59
78
  */
60
- async function requestJson(url, init, options = {}) {
79
+ function sleep(ms, signal) {
80
+ if (ms <= 0 || signal?.aborted)
81
+ return Promise.resolve();
82
+ return new Promise((resolve) => {
83
+ const done = () => {
84
+ clearTimeout(timer);
85
+ signal?.removeEventListener('abort', done);
86
+ resolve();
87
+ };
88
+ const timer = setTimeout(done, ms);
89
+ signal?.addEventListener('abort', done, { once: true });
90
+ });
91
+ }
92
+ /**
93
+ * One request, with timeout, cancellation and retry.
94
+ *
95
+ * The body is read INSIDE the attempt loop deliberately: a truncated or
96
+ * malformed body is a failed attempt like any other and earns the same
97
+ * wrapping and the same retry as a dropped socket. Handing the `Response` back
98
+ * for the caller to read would move that outside the loop, where a bare
99
+ * `SyntaxError` escapes as itself.
100
+ */
101
+ async function request(url, init, options, read) {
61
102
  const timeoutMs = options.timeoutMs ?? exports.DEFAULT_TIMEOUT_MS;
103
+ const overallTimeoutMs = options.overallTimeoutMs ?? exports.DEFAULT_OVERALL_TIMEOUT_MS;
62
104
  const maxAttempts = options.retry === false
63
105
  ? 1
64
106
  : (options.retry?.maxAttempts ?? exports.DEFAULT_MAX_ATTEMPTS);
107
+ // A request budget of zero attempts is a caller mistake, not a policy. It
108
+ // used to fall straight through the loop and raise `InternalException` for a
109
+ // request that was never made — an error about our own internals, for their
110
+ // typo (#37).
111
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
112
+ throw new LocationServiceException_js_1.LocationServiceException({
113
+ code: 'ValidationException',
114
+ message: `retry.maxAttempts must be a whole number of at least 1; received ${maxAttempts}`,
115
+ details: { source: 'client' },
116
+ });
117
+ }
118
+ const deadline = Date.now() + overallTimeoutMs;
65
119
  let lastError;
66
120
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
67
121
  // Checked before every attempt: a signal aborted during backoff must not fire one more.
68
122
  if (options.signal?.aborted)
69
123
  throw abortedException(options.signal);
70
- const { signal, cleanup } = attemptSignal(options.signal, timeoutMs);
124
+ const budget = deadline - Date.now();
125
+ if (budget <= 0)
126
+ throw lastError ?? overallTimeoutException(overallTimeoutMs);
127
+ // Never longer than what is left of the call, so the per-attempt timeout
128
+ // cannot overrun the budget it sits inside.
129
+ const { signal, cleanup } = attemptSignal(options.signal, Math.min(timeoutMs, budget));
71
130
  try {
72
131
  const response = await fetch(url, { ...init, signal });
73
132
  if (response.ok)
74
- return (await response.json());
133
+ return await read(response);
75
134
  const error = (0, errors_js_1.parseErrorResponse)(response.status, response.statusText, await response.text(), response.headers);
76
135
  lastError = error;
77
136
  if (!error.isRetryable || attempt === maxAttempts - 1)
78
137
  throw error;
79
- log('attempt %d failed (%s), retrying', attempt + 1, error.code);
80
- await sleep(error.retryAfterMs ??
138
+ const wait = error.retryAfterMs ??
81
139
  (0, errors_js_1.parseRetryAfter)(response.headers.get('retry-after')) ??
82
- backoffMs(attempt));
140
+ backoffMs(attempt);
141
+ // Sitting out a 60 s `Retry-After` inside a 30 s budget only delivers the
142
+ // same failure after the caller has already given up. Stop now and hand
143
+ // back what the API said, `retryAfterMs` and all.
144
+ if (wait >= deadline - Date.now()) {
145
+ log('not retrying: a %d ms wait outlasts the remaining budget', wait);
146
+ break;
147
+ }
148
+ log('attempt %d failed (%s), retrying', attempt + 1, error.code);
149
+ await sleep(wait, options.signal);
83
150
  }
84
151
  catch (err) {
85
152
  if (err instanceof LocationServiceException_js_1.LocationServiceException) {
@@ -93,20 +160,48 @@ async function requestJson(url, init, options = {}) {
93
160
  lastError = wrapped;
94
161
  if (!wrapped.isRetryable || attempt === maxAttempts - 1)
95
162
  throw wrapped;
163
+ const wait = backoffMs(attempt);
164
+ if (wait >= deadline - Date.now()) {
165
+ log('not retrying: a %d ms wait outlasts the remaining budget', wait);
166
+ break;
167
+ }
96
168
  log('attempt %d failed (%s), retrying', attempt + 1, wrapped.code);
97
- await sleep(backoffMs(attempt));
169
+ await sleep(wait, options.signal);
98
170
  }
99
171
  finally {
100
172
  cleanup();
101
173
  }
102
174
  }
103
- /* c8 ignore next */
175
+ // Reached when the budget ran out before another attempt could be made, so
176
+ // `lastError` is the API's own answer and is the useful thing to throw.
104
177
  throw (lastError ??
105
178
  new LocationServiceException_js_1.LocationServiceException({
106
179
  code: 'InternalException',
107
180
  message: 'Request failed',
108
181
  }));
109
182
  }
183
+ /**
184
+ * One JSON request, with timeout, cancellation and retry.
185
+ *
186
+ * Every failure leaves as a LocationServiceException — a fetch rejection
187
+ * becomes `NetworkException` with the original as `cause`, an abort becomes
188
+ * `AbortedException`, a timeout becomes `TimeoutException` with
189
+ * `details.source = 'client'` so it is distinguishable from the API's own 504.
190
+ */
191
+ function requestJson(url, init, options = {}) {
192
+ return request(url, init, options, (response) => response.json());
193
+ }
194
+ /**
195
+ * One request answered as a Blob — the static map path.
196
+ *
197
+ * Exists so the two map fetches are not the only calls in the package without
198
+ * a timeout, a retry or a signal: they used their own bare `fetch`, so a
199
+ * network fault there escaped as a raw `TypeError` while the identical fault on
200
+ * any other call arrived as `NetworkException` (#37).
201
+ */
202
+ function requestBlob(url, init, options = {}) {
203
+ return request(url, init, options, (response) => response.blob());
204
+ }
110
205
  function abortedException(signal) {
111
206
  return new LocationServiceException_js_1.LocationServiceException({
112
207
  code: 'AbortedException',
@@ -115,6 +210,14 @@ function abortedException(signal) {
115
210
  cause: signal?.reason,
116
211
  });
117
212
  }
213
+ /** The call's own budget elapsed, rather than one attempt's timeout. */
214
+ function overallTimeoutException(overallTimeoutMs) {
215
+ return new LocationServiceException_js_1.LocationServiceException({
216
+ code: 'TimeoutException',
217
+ message: `Request exceeded its overall timeout of ${overallTimeoutMs} ms`,
218
+ details: { source: 'client' },
219
+ });
220
+ }
118
221
  /**
119
222
  * fetch rejects with a raw `TypeError` for a network fault and a `DOMException`
120
223
  * named AbortError for both cancellation and timeout — indistinguishable from
@@ -1,12 +1,29 @@
1
+ /**
2
+ * At least one of `token`, `getToken` or `refreshToken` must supply a token, or
3
+ * `send` refuses locally with `InvalidCredentialsException` rather than putting
4
+ * `Bearer undefined` on the wire (#37).
5
+ *
6
+ * `token` is optional because a client driven purely by a provider — the shape
7
+ * `@chaosity/location-client-react` uses — has nothing to put there at
8
+ * construction time, and was previously forced to invent a placeholder. It is
9
+ * still required on `ServerClientConfig`, which is a RESULT rather than a
10
+ * configuration: `getClientConfig` always resolves one.
11
+ */
1
12
  export interface ClientConfig {
2
13
  apiUrl: string;
3
- token: string;
14
+ token?: string;
4
15
  /** Optional callback to get the current token dynamically. When provided,
5
16
  * called on every request so token updates are reflected without recreating the client. */
6
17
  getToken?: () => string | undefined;
7
18
  /**
8
19
  * Asked for a replacement AFTER the API has rejected the current token with a
9
- * 401, so the request can be retried once instead of failing.
20
+ * 401, so the request can be retried once instead of failing — and, since
21
+ * #37, asked once BEFORE the first send when neither `getToken` nor `token`
22
+ * yields anything, so a client whose token has not arrived yet does not spend
23
+ * a round trip on `Bearer undefined` to learn that. Both calls mean the same
24
+ * thing to an implementor — "give me a usable token" — so a `refreshToken`
25
+ * that mints or awaits one needs no change; only one that assumes every
26
+ * invocation follows a 401 does.
10
27
  *
11
28
  * Separate from `getToken` because that one is synchronous by contract — it
12
29
  * is read while a request is being built and cannot await anything, so it can
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
2
  import { resolveEndpoint } from '../transport/endpoints.js';
3
- import { isTokenRejected } from '../transport/errors.js';
3
+ import { isTokenRejected, noTokenAvailable } from '../transport/errors.js';
4
4
  import { requestJson } from '../transport/http.js';
5
5
  import { roundPositionFields } from '../utils/roundPosition.js';
6
6
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
@@ -40,13 +40,39 @@ export class GeoPlacesClient {
40
40
  return this.clientConfig.getToken?.() ?? this.clientConfig.token;
41
41
  }
42
42
  /**
43
- * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
44
- * to disable the retry loop. Every failure throws LocationServiceException.
43
+ * A token to send, or a refusal — never `undefined`.
44
+ *
45
+ * `refreshToken` is asked only when there is nothing at all in hand, so a
46
+ * client configured the ordinary way pays nothing for this.
47
+ */
48
+ async ensureToken() {
49
+ // `||`, not `??`: an empty string is a token source with nothing to give,
50
+ // not a decision to send an empty one. With `??` it survived the coalesce,
51
+ // skipped `refreshToken`, and then failed the check two lines below — so
52
+ // `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
53
+ // did not, which is a distinction no caller means to draw.
54
+ const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
55
+ if (!token) {
56
+ throw noTokenAvailable('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
57
+ }
58
+ return token;
59
+ }
60
+ /**
61
+ * @param options `signal` to cancel, `timeoutMs` per attempt,
62
+ * `overallTimeoutMs` for the whole call, `retry: false` to disable the
63
+ * retry loop. Every failure throws LocationServiceException.
45
64
  */
46
65
  async send(command, options) {
47
66
  const cmd = command;
48
67
  const url = `${this.clientConfig.apiUrl}${resolveEndpoint(cmd)}`;
49
- const token = this.currentToken();
68
+ // The fifth and last place in this package that turns a token into an
69
+ // `Authorization` header, and the last one that would send `Bearer
70
+ // undefined` (#37). The 401 self-heal below cannot cover this case — it
71
+ // needs a request to have been rejected first — so a client whose token
72
+ // source has not produced one yet spent a whole round trip to learn
73
+ // something it already knew. Ask the refresh source instead, and refuse if
74
+ // there is still nothing.
75
+ const token = await this.ensureToken();
50
76
  try {
51
77
  return await this.dispatch(url, token, cmd, options);
52
78
  }
@@ -59,8 +85,8 @@ export class GeoPlacesClient {
59
85
  // refreshes in the background may have landed a new one while this
60
86
  // request was in flight.
61
87
  const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
62
- // Nothing new to send. Repeating the request would fail identically, and
63
- // be billed identically.
88
+ // Nothing new to send. Repeating the request would fail identically — a
89
+ // second round trip for the same 401.
64
90
  if (!fresh || fresh === token)
65
91
  throw err;
66
92
  log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
package/dist/index.d.ts CHANGED
@@ -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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Client (Custom - uses our auth instead of AWS SigV4)
2
2
  export { GeoPlacesClient } from './client/GeoPlacesClient.js';
3
- // Transport options — cancellation, per-attempt timeout, retry policy
4
- export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http.js';
3
+ // Transport options — cancellation, per-attempt timeout, overall budget, retry
4
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_OVERALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, } from './transport/http.js';
5
5
  // Token refresh policy — shared by the server provider and the React provider
6
6
  export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh.js';
7
7
  // Errors
@@ -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>;
@@ -1,4 +1,5 @@
1
- import { parseErrorResponse } from '../transport/errors.js';
1
+ import { noTokenAvailable } from '../transport/errors.js';
2
+ import { requestJson } from '../transport/http.js';
2
3
  import { labelsByName, languageExpression } from './mapLanguage.js';
3
4
  /**
4
5
  * Build a map style descriptor URL for the Location Service API.
@@ -46,38 +47,42 @@ export function buildMapStyleUrl(apiUrl, mapStyle, options = {}) {
46
47
  * @param mapStyle - Map style name (e.g. 'Standard', 'Monochrome', 'Satellite', 'Hybrid')
47
48
  * @param getToken - Callback returning the current auth token
48
49
  * @param options - Style options; `language` is applied to the descriptor, all others become URL params
50
+ * @param request - Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
49
51
  * @returns Modified MapLibre StyleSpecification object
50
52
  *
51
53
  * @example
52
54
  * const style = await fetchMapStyle(API_URL, 'Standard', getToken, { colorScheme: 'Dark', language: 'fr' })
53
55
  * const map = new maplibregl.Map({ style, transformRequest: createTransformRequest(API_URL, getToken) })
54
56
  */
55
- export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}) {
57
+ export async function fetchMapStyle(apiUrl, mapStyle, getToken, options = {}, request = {}) {
56
58
  const { language, ...styleOptions } = options;
57
59
  const url = buildMapStyleUrl(apiUrl, mapStyle, styleOptions);
58
60
  const token = getToken();
59
- const response = await fetch(url, {
61
+ // `Bearer undefined` used to go out here, and came back as a 401 the caller
62
+ // had to work backwards from — a whole round trip for a request that was
63
+ // never going to succeed (#37).
64
+ if (!token) {
65
+ throw noTokenAvailable('getToken() returned nothing, so no style request was sent. Check the token provider has finished initialising.');
66
+ }
67
+ // Through the shared transport, not a bare fetch: this gets the same
68
+ // per-attempt timeout, overall budget, cancellation and retry as every other
69
+ // call in the package, and the same error type on the way out. It also keeps
70
+ // the API's own message, which is the whole point of reading the body — for
71
+ // a style request that sentence is Amazon's, forwarded verbatim by
72
+ // 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.
80
+ const style = await requestJson(url, {
60
81
  headers: {
61
82
  Authorization: `Bearer ${token}`,
62
83
  Accept: 'application/json',
63
84
  },
64
- });
65
- if (!response.ok) {
66
- // Read the body. The API sends `{message, code, requestId}` and the message
67
- // is the whole point of it — for a style request it is Amazon's own
68
- // sentence, forwarded verbatim by location-service-api#89:
69
- //
70
- // 400 "Traffic is not supported for style."
71
- // 400 "light is not a supported color scheme for style Standard."
72
- //
73
- // This used to throw `Failed to fetch map style: 400`, discarding all of it
74
- // two lines before anyone could read it — the same defect #89 fixed in the
75
- // API, one layer up. Reuses parseErrorResponse so a style failure arrives as
76
- // the same LocationServiceException as every other call in this package,
77
- // with `code`, `statusCode` and `requestId` intact.
78
- throw parseErrorResponse(response.status, response.statusText, await response.text(), response.headers);
79
- }
80
- const style = (await response.json());
85
+ }, request);
81
86
  if (language) {
82
87
  applyLanguageToDescriptor(style, language);
83
88
  }
@@ -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>;
@@ -1,4 +1,5 @@
1
- import { parseErrorResponse } from '../transport/errors.js';
1
+ import { noTokenAvailable } from '../transport/errors.js';
2
+ import { requestBlob } from '../transport/http.js';
2
3
  /**
3
4
  * The Accept header this request must send.
4
5
  *
@@ -50,6 +51,7 @@ export function buildStaticMapUrl(apiUrl, options) {
50
51
  * @param apiUrl Base URL of the Location Service API
51
52
  * @param options Render options; exactly one of center / boundingBox / boundedPositions
52
53
  * @param getToken Callback returning the current auth token
54
+ * @param request Transport options: `signal` to cancel, `timeoutMs`, `overallTimeoutMs`, `retry`
53
55
  *
54
56
  * @example
55
57
  * const blob = await fetchStaticMap(API_URL, {
@@ -58,19 +60,23 @@ export function buildStaticMapUrl(apiUrl, options) {
58
60
  * }, getToken)
59
61
  * const url = URL.createObjectURL(blob) // remember to revokeObjectURL
60
62
  */
61
- export async function fetchStaticMap(apiUrl, options, getToken) {
62
- const response = await fetch(buildStaticMapUrl(apiUrl, options), {
63
+ export async function fetchStaticMap(apiUrl, options, getToken, request = {}) {
64
+ const token = getToken();
65
+ // The same guard as fetchMapStyle and the server connector: a render is not
66
+ // worth requesting without a token to send (#37).
67
+ if (!token) {
68
+ throw noTokenAvailable('getToken() returned nothing, so no static map was requested. Check the token provider has finished initialising.');
69
+ }
70
+ // Through the shared transport, so a static map gets the timeout, budget,
71
+ // cancellation and retry every other call has -- and its failures arrive as
72
+ // LocationServiceException rather than as a raw TypeError. The API's own
73
+ // {message, code, requestId} survives, which matters here: the messages are
74
+ // specific and actionable -- "'width' and 'height' are required", "Only one
75
+ // of center, bounding-box or bounded-positions may be set".
76
+ return requestBlob(buildStaticMapUrl(apiUrl, options), {
63
77
  headers: {
64
- Authorization: `Bearer ${getToken()}`,
78
+ Authorization: `Bearer ${token}`,
65
79
  Accept: staticMapAccept(options.style),
66
80
  },
67
- });
68
- if (!response.ok) {
69
- // Same treatment as fetchMapStyle: the API's {message, code, requestId} is
70
- // the useful part, and a bare "failed: 400" throws it away. The messages
71
- // here are specific and actionable -- "'width' and 'height' are required",
72
- // "Only one of center, bounding-box or bounded-positions may be set".
73
- throw parseErrorResponse(response.status, response.statusText, await response.text());
74
- }
75
- return response.blob();
81
+ }, request);
76
82
  }
@@ -1,7 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException.js';
3
3
  import { resolveEndpoint } from '../transport/endpoints.js';
4
- import { isTokenRejected } from '../transport/errors.js';
4
+ import { isTokenRejected, noTokenAvailable } from '../transport/errors.js';
5
5
  import { requestJson } from '../transport/http.js';
6
6
  import { roundPositionFields } from '../utils/roundPosition.js';
7
7
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
@@ -27,6 +27,12 @@ function headerValue(headers, name) {
27
27
  * spelling of it survives the merge.
28
28
  */
29
29
  const SYSTEM_HEADERS = ['origin', 'content-type', 'authorization'];
30
+ /**
31
+ * What to check when the token source comes up empty. The guard itself is
32
+ * shared with the map fetches (`noTokenAvailable`); only the advice differs,
33
+ * and on this path the answer is always the credentials.
34
+ */
35
+ const NO_TOKEN_ADVICE = 'check clientId/clientSecret configuration';
30
36
  /** The caller's headers minus `names`, however they capitalised them. */
31
37
  function withoutHeaders(headers, names) {
32
38
  if (!headers)
@@ -187,7 +193,7 @@ export class LocationServiceConnector {
187
193
  async dispatchWithRetry(source, url, cmd, options) {
188
194
  const token = await source.get();
189
195
  if (!token)
190
- throw noTokenAvailable();
196
+ throw noTokenAvailable(NO_TOKEN_ADVICE);
191
197
  try {
192
198
  return await this.dispatch(url, token, cmd, options);
193
199
  }
@@ -198,8 +204,8 @@ export class LocationServiceConnector {
198
204
  // token. That single comparison covers every source: a fixed `token`
199
205
  // string, a caller `getToken` that ignores `forceRefresh`, and a cached
200
206
  // token the API has revoked before its `exp` all hand back what we
201
- // already sent — and re-sending it would be a second doomed request, and
202
- // a second billed one.
207
+ // already sent — and re-sending it would be a second doomed request for
208
+ // the same answer.
203
209
  const fresh = await source.get(true);
204
210
  if (!fresh || fresh === token)
205
211
  throw err;
@@ -254,10 +260,3 @@ function requireApiUrl(explicit) {
254
260
  }
255
261
  return apiUrl;
256
262
  }
257
- function noTokenAvailable() {
258
- return new LocationServiceException({
259
- code: 'InvalidCredentialsException',
260
- message: 'No token available — check clientId/clientSecret configuration',
261
- details: { source: 'client' },
262
- });
263
- }
@@ -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,9 +3,32 @@ import { createHash } from 'node:crypto';
3
3
  import { TokenProvider } from '../auth/TokenProvider.js';
4
4
  import { LocationServiceException } from '../errors/LocationServiceException.js';
5
5
  const log = debug('location-client:clientConfig');
6
- // Singleton instance to prevent race conditions
7
- let tokenProviderInstance = null;
8
- let currentConfig = null;
6
+ /**
7
+ * How many applications one process keeps token providers for.
8
+ *
9
+ * A provider holds a URL, a client id, a secret and one cached JWT, so the
10
+ * ceiling is about memory containment rather than a tuned working set — 64 is
11
+ * far above what a single-tenant service needs and far below anything worth
12
+ * worrying about. An agency past it pays a re-mint for the least recently used
13
+ * application, which is exactly the behaviour this replaced, but only for the
14
+ * coldest one instead of for every alternation.
15
+ */
16
+ export const MAX_CACHED_PROVIDERS = 64;
17
+ /**
18
+ * One provider per configuration, most-recently-used last.
19
+ *
20
+ * This used to be TWO module-level variables holding a single provider, and a
21
+ * process serving more than one application therefore evicted the cache on
22
+ * every alternation: A→B→A→B took a full `/auth/token` round trip per call,
23
+ * each writing a jti row, all against the one shared token-endpoint throttle.
24
+ * The hit rate under alternating load was 0% (#39). Nothing leaked between
25
+ * tenants — each caller closes over the provider it asked for — so what this
26
+ * fixes is availability and cost, not confidentiality.
27
+ *
28
+ * A `Map` iterates in insertion order, so re-inserting on a hit makes the first
29
+ * key the least recently used, and an LRU needs no other bookkeeping.
30
+ */
31
+ const tokenProviders = new Map();
9
32
  function getTokenProvider(apiUrl, clientId, clientSecret) {
10
33
  // The SECRET is part of the key. Without it, rotating a client secret while
11
34
  // keeping the same clientId left this process reusing a provider built on the
@@ -13,16 +36,26 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
13
36
  // rather than concatenated so the key is never a secret in its own right, and
14
37
  // never ends up in a log line (#5).
15
38
  const configKey = `${apiUrl}:${clientId}:${createHash('sha256').update(clientSecret).digest('hex').slice(0, 16)}`;
16
- // Reuse existing instance if config matches
17
- if (tokenProviderInstance && currentConfig === configKey) {
39
+ const cached = tokenProviders.get(configKey);
40
+ if (cached) {
18
41
  log('[getTokenProvider] Reusing existing TokenProvider instance');
19
- return tokenProviderInstance;
42
+ // Re-insert to mark it most recently used.
43
+ tokenProviders.delete(configKey);
44
+ tokenProviders.set(configKey, cached);
45
+ return cached;
20
46
  }
21
- // Create new instance if config changed
22
47
  log('[getTokenProvider] Creating new TokenProvider instance');
23
- tokenProviderInstance = new TokenProvider({ apiUrl, clientId, clientSecret });
24
- currentConfig = configKey;
25
- return tokenProviderInstance;
48
+ const provider = new TokenProvider({ apiUrl, clientId, clientSecret });
49
+ tokenProviders.set(configKey, provider);
50
+ if (tokenProviders.size > MAX_CACHED_PROVIDERS) {
51
+ const leastRecentlyUsed = tokenProviders.keys().next().value;
52
+ /* c8 ignore next — size > 0 here, so the iterator always yields */
53
+ if (leastRecentlyUsed !== undefined) {
54
+ log('[getTokenProvider] Evicting the least recently used TokenProvider');
55
+ tokenProviders.delete(leastRecentlyUsed);
56
+ }
57
+ }
58
+ return provider;
26
59
  }
27
60
  /**
28
61
  * Where the API lives, from the argument or the environment.