@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
@@ -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>;
@@ -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
@@ -1,29 +1,27 @@
1
1
  /**
2
2
  * Read advisory application config out of the access token (api#65).
3
3
  *
4
- * The API puts an application's own settings — `biasDecimals`, `countries` —
5
- * into the JWT alongside `allowedDomain` and `allowedResources`, so this
6
- * library can stop hard-coding values it has no other way of knowing.
4
+ * The API puts an application's own settings — today just `countries` — into
5
+ * the JWT alongside `allowedDomain` and `allowedResources`, so this library can
6
+ * stop hard-coding values it has no other way of knowing.
7
+ *
8
+ * `biasDecimals` used to be here too. It sized the grid this library rounded
9
+ * `BiasPosition` onto, so that nearby callers shared a server cache entry; the
10
+ * cache is gone, the API issues no such claim, and rounding a coordinate with
11
+ * nothing to share it with only lowered the precision the upstream geocoder
12
+ * received (#51).
7
13
  *
8
14
  * DELIBERATELY UNVERIFIED, and that is safe. This library has no signing key
9
15
  * and does not need one: every claim here is re-read from the application row
10
16
  * by the API on each request, and the API's answer is the one that counts. A
11
17
  * forged token would fail at the authorizer long before any of this mattered.
12
- * What is read here only decides how the request is SHAPED — a hint, never a
13
- * permission.
18
+ * Nothing read here reaches a request at all now — it is displayed, never
19
+ * acted on, so a forged value misinforms only the caller who forged it.
14
20
  *
15
21
  * A JWT is signed, not encrypted, so the payload is plain base64url. Nothing
16
22
  * secret is in it; these are the caller's own settings.
17
23
  */
18
24
  export interface AppConfigClaims {
19
- /**
20
- * Bias precision this application is entitled to.
21
- *
22
- * Safe to act on: it only changes how a coordinate is rounded before
23
- * sending, and the server re-rounds to its own configured value anyway. A
24
- * stale value here costs precision, never correctness.
25
- */
26
- biasDecimals?: number;
27
25
  /**
28
26
  * Countries this application may search, ISO 3166-1 alpha-2.
29
27
  *
@@ -2,16 +2,22 @@
2
2
  /**
3
3
  * Read advisory application config out of the access token (api#65).
4
4
  *
5
- * The API puts an application's own settings — `biasDecimals`, `countries` —
6
- * into the JWT alongside `allowedDomain` and `allowedResources`, so this
7
- * library can stop hard-coding values it has no other way of knowing.
5
+ * The API puts an application's own settings — today just `countries` — into
6
+ * the JWT alongside `allowedDomain` and `allowedResources`, so this library can
7
+ * stop hard-coding values it has no other way of knowing.
8
+ *
9
+ * `biasDecimals` used to be here too. It sized the grid this library rounded
10
+ * `BiasPosition` onto, so that nearby callers shared a server cache entry; the
11
+ * cache is gone, the API issues no such claim, and rounding a coordinate with
12
+ * nothing to share it with only lowered the precision the upstream geocoder
13
+ * received (#51).
8
14
  *
9
15
  * DELIBERATELY UNVERIFIED, and that is safe. This library has no signing key
10
16
  * and does not need one: every claim here is re-read from the application row
11
17
  * by the API on each request, and the API's answer is the one that counts. A
12
18
  * forged token would fail at the authorizer long before any of this mattered.
13
- * What is read here only decides how the request is SHAPED — a hint, never a
14
- * permission.
19
+ * Nothing read here reaches a request at all now — it is displayed, never
20
+ * acted on, so a forged value misinforms only the caller who forged it.
15
21
  *
16
22
  * A JWT is signed, not encrypted, so the payload is plain base64url. Nothing
17
23
  * secret is in it; these are the caller's own settings.
@@ -39,15 +45,6 @@ function readAppConfigClaims(token) {
39
45
  const json = decodeURIComponent(Array.from(atob(padded), (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''));
40
46
  const payload = JSON.parse(json);
41
47
  const claims = {};
42
- if (typeof payload.biasDecimals === 'number') {
43
- claims.biasDecimals = payload.biasDecimals;
44
- }
45
- else if (typeof payload.biasDecimals === 'string' &&
46
- payload.biasDecimals.trim() !== '') {
47
- const n = Number(payload.biasDecimals);
48
- if (Number.isFinite(n))
49
- claims.biasDecimals = n;
50
- }
51
48
  if (Array.isArray(payload.countries)) {
52
49
  const list = payload.countries.filter((c) => typeof c === 'string');
53
50
  if (list.length)
@@ -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;
@@ -1,8 +1,7 @@
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
- import { roundPositionFields } from '../utils/roundPosition.js';
6
5
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
7
6
  const log = debug('location-client:api');
8
7
  /**
@@ -20,7 +19,7 @@ export class GeoPlacesClient {
20
19
  }
21
20
  /**
22
21
  * This application's own configuration, as carried on the access token
23
- * (api#65) — bias precision, and the countries it is scoped to.
22
+ * (api#65) — today, the countries it is scoped to.
24
23
  *
25
24
  * Provided so an application can SHOW its own settings: populate a country
26
25
  * selector with the markets it actually serves, label a settings screen,
@@ -40,13 +39,39 @@ export class GeoPlacesClient {
40
39
  return this.clientConfig.getToken?.() ?? this.clientConfig.token;
41
40
  }
42
41
  /**
43
- * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
44
- * to disable the retry loop. Every failure throws LocationServiceException.
42
+ * A token to send, or a refusal — never `undefined`.
43
+ *
44
+ * `refreshToken` is asked only when there is nothing at all in hand, so a
45
+ * client configured the ordinary way pays nothing for this.
46
+ */
47
+ async ensureToken() {
48
+ // `||`, not `??`: an empty string is a token source with nothing to give,
49
+ // not a decision to send an empty one. With `??` it survived the coalesce,
50
+ // skipped `refreshToken`, and then failed the check two lines below — so
51
+ // `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
52
+ // did not, which is a distinction no caller means to draw.
53
+ const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
54
+ if (!token) {
55
+ throw noTokenAvailable('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
56
+ }
57
+ return token;
58
+ }
59
+ /**
60
+ * @param options `signal` to cancel, `timeoutMs` per attempt,
61
+ * `overallTimeoutMs` for the whole call, `retry: false` to disable the
62
+ * retry loop. Every failure throws LocationServiceException.
45
63
  */
46
64
  async send(command, options) {
47
65
  const cmd = command;
48
66
  const url = `${this.clientConfig.apiUrl}${resolveEndpoint(cmd)}`;
49
- const token = this.currentToken();
67
+ // The fifth and last place in this package that turns a token into an
68
+ // `Authorization` header, and the last one that would send `Bearer
69
+ // undefined` (#37). The 401 self-heal below cannot cover this case — it
70
+ // needs a request to have been rejected first — so a client whose token
71
+ // source has not produced one yet spent a whole round trip to learn
72
+ // something it already knew. Ask the refresh source instead, and refuse if
73
+ // there is still nothing.
74
+ const token = await this.ensureToken();
50
75
  try {
51
76
  return await this.dispatch(url, token, cmd, options);
52
77
  }
@@ -59,8 +84,8 @@ export class GeoPlacesClient {
59
84
  // refreshes in the background may have landed a new one while this
60
85
  // request was in flight.
61
86
  const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
62
- // Nothing new to send. Repeating the request would fail identically, and
63
- // be billed identically.
87
+ // Nothing new to send. Repeating the request would fail identically — a
88
+ // second round trip for the same 401.
64
89
  if (!fresh || fresh === token)
65
90
  throw err;
66
91
  log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
@@ -68,10 +93,11 @@ export class GeoPlacesClient {
68
93
  }
69
94
  }
70
95
  dispatch(url, token, cmd, options) {
71
- // Resolve the token BEFORE rounding: the precision this application is
72
- // entitled to is a claim on it (api#65). Absent claim -> the 3 dp floor.
73
- const { biasDecimals } = readAppConfigClaims(token);
74
- const input = roundPositionFields(cmd.input, biasDecimals);
96
+ // The caller's input goes out as the caller wrote it. `BiasPosition` used
97
+ // to be rounded here to a grid sized by a token claim, so nearby callers
98
+ // shared a server cache entry; with no cache the rounding only lowered the
99
+ // precision the upstream geocoder had to work with, which moves the
100
+ // results rather than coarsening them (#51).
75
101
  log('Sending %s to %s', cmd.constructor?.name, url);
76
102
  return requestJson(url, {
77
103
  method: 'POST',
@@ -79,7 +105,7 @@ export class GeoPlacesClient {
79
105
  'Content-Type': 'application/json',
80
106
  Authorization: `Bearer ${token}`,
81
107
  },
82
- body: JSON.stringify(input),
108
+ body: JSON.stringify(cmd.input),
83
109
  }, options);
84
110
  }
85
111
  }
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>;