@chaosity/location-client 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +118 -36
  2. package/dist/adapters/GeoPlaces.d.ts +7 -1
  3. package/dist/adapters/GeoPlaces.js +11 -5
  4. package/dist/auth/TokenProvider.d.ts +22 -1
  5. package/dist/auth/TokenProvider.js +42 -2
  6. package/dist/auth/tokenHold.d.ts +14 -5
  7. package/dist/auth/tokenHold.js +70 -5
  8. package/dist/aws.d.ts +4 -0
  9. package/dist/aws.js +2 -0
  10. package/dist/cjs/adapters/GeoPlaces.d.ts +7 -1
  11. package/dist/cjs/adapters/GeoPlaces.js +10 -4
  12. package/dist/cjs/auth/TokenProvider.d.ts +22 -1
  13. package/dist/cjs/auth/TokenProvider.js +42 -2
  14. package/dist/cjs/auth/tokenHold.d.ts +14 -5
  15. package/dist/cjs/auth/tokenHold.js +71 -5
  16. package/dist/cjs/aws.d.ts +4 -0
  17. package/dist/cjs/aws.js +155 -0
  18. package/dist/cjs/client/GeoPlacesClient.d.ts +17 -1
  19. package/dist/cjs/client/GeoPlacesClient.js +18 -62
  20. package/dist/cjs/index.d.ts +4 -3
  21. package/dist/cjs/index.js +13 -8
  22. package/dist/cjs/maps/createTransformRequest.d.ts +25 -0
  23. package/dist/cjs/maps/createTransformRequest.js +1 -0
  24. package/dist/cjs/maps/mapPoi.d.ts +10 -5
  25. package/dist/cjs/maps/mapPoi.js +14 -5
  26. package/dist/cjs/maps/mapStyle.d.ts +9 -2
  27. package/dist/cjs/maps/mapStyle.js +16 -12
  28. package/dist/cjs/maps/mapToken.d.ts +84 -0
  29. package/dist/cjs/maps/mapToken.js +126 -0
  30. package/dist/cjs/maps/staticMap.d.ts +7 -3
  31. package/dist/cjs/maps/staticMap.js +13 -12
  32. package/dist/cjs/server/LocationServiceConnector.d.ts +18 -3
  33. package/dist/cjs/server/LocationServiceConnector.js +17 -58
  34. package/dist/cjs/server/getClientConfig.d.ts +7 -3
  35. package/dist/cjs/server/getClientConfig.js +9 -12
  36. package/dist/cjs/server/index.d.ts +1 -1
  37. package/dist/cjs/transport/http.d.ts +18 -0
  38. package/dist/cjs/transport/http.js +39 -1
  39. package/dist/cjs/types/index.d.ts +26 -0
  40. package/dist/client/GeoPlacesClient.d.ts +17 -1
  41. package/dist/client/GeoPlacesClient.js +21 -65
  42. package/dist/index.d.ts +4 -3
  43. package/dist/index.js +11 -7
  44. package/dist/maps/createTransformRequest.d.ts +25 -0
  45. package/dist/maps/createTransformRequest.js +1 -1
  46. package/dist/maps/mapPoi.d.ts +10 -5
  47. package/dist/maps/mapPoi.js +14 -5
  48. package/dist/maps/mapStyle.d.ts +9 -2
  49. package/dist/maps/mapStyle.js +17 -13
  50. package/dist/maps/mapToken.d.ts +84 -0
  51. package/dist/maps/mapToken.js +122 -0
  52. package/dist/maps/staticMap.d.ts +7 -3
  53. package/dist/maps/staticMap.js +14 -13
  54. package/dist/server/LocationServiceConnector.d.ts +18 -3
  55. package/dist/server/LocationServiceConnector.js +20 -61
  56. package/dist/server/getClientConfig.d.ts +7 -3
  57. package/dist/server/getClientConfig.js +9 -12
  58. package/dist/server/index.d.ts +1 -1
  59. package/dist/transport/http.d.ts +18 -0
  60. package/dist/transport/http.js +37 -1
  61. package/dist/types/index.d.ts +26 -0
  62. package/package.json +3 -3
@@ -1,10 +1,10 @@
1
1
  import debug from 'debug';
2
- import { TokenHold, holdFor } from '../auth/tokenHold.js';
2
+ import { TokenHold, sendRetryingOnce } from '../auth/tokenHold.js';
3
3
  import { VerifyAddressCommand } from '../client/commands.js';
4
4
  import { LocationServiceException } from '../errors/LocationServiceException.js';
5
5
  import { resolveEndpoint } from '../transport/endpoints.js';
6
- import { isTokenRejected, noTokenAvailable } from '../transport/errors.js';
7
- import { requestJson } from '../transport/http.js';
6
+ import { noTokenAvailable } from '../transport/errors.js';
7
+ import { requestJson, startCall, withinCall } from '../transport/http.js';
8
8
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
9
9
  import { resolveApiUrl, serverTokenSource } from './getClientConfig.js';
10
10
  const log = debug('location-client:connector');
@@ -134,7 +134,10 @@ export class LocationServiceConnector {
134
134
  return {
135
135
  apiUrl: () => requireApiUrl(apiUrl),
136
136
  get: async (forceRefresh) => {
137
- const result = await getToken(forceRefresh);
137
+ // As the environment source below asks its provider (#63).
138
+ const result = await getToken(forceRefresh, {
139
+ cachedUntilExpiry: true,
140
+ });
138
141
  if (!result)
139
142
  return undefined;
140
143
  return typeof result === 'string' ? result : result.token;
@@ -156,7 +159,9 @@ export class LocationServiceConnector {
156
159
  // Already validated by serverTokenSource, which cannot resolve
157
160
  // credentials without it.
158
161
  apiUrl: () => env.apiUrl,
159
- get: async (forceRefresh) => (await env.getToken(forceRefresh)).token,
162
+ // A throttled refresh keeps the cached token in use until its own exp
163
+ // (#63): this is a server dispatch, never a token handed to a browser.
164
+ get: async (forceRefresh) => (await env.getToken(forceRefresh, { cachedUntilExpiry: true })).token,
160
165
  };
161
166
  }
162
167
  /**
@@ -194,8 +199,11 @@ export class LocationServiceConnector {
194
199
  const source = this.source();
195
200
  const cmd = command;
196
201
  const url = `${source.apiUrl()}${resolveEndpoint(cmd)}`;
202
+ // The call's deadline starts here, before the token is waited for, so the
203
+ // caller's `overallTimeoutMs` and `signal` bound the whole call (#62).
204
+ const call = startCall(options);
197
205
  try {
198
- return await this.dispatchWithRetry(source, url, cmd, options);
206
+ return await this.dispatchWithRetry(source, url, cmd, call);
199
207
  }
200
208
  catch (err) {
201
209
  throw explainMissingOrigin(err, this.effectiveOrigin(options));
@@ -213,63 +221,14 @@ export class LocationServiceConnector {
213
221
  return this.send(new VerifyAddressCommand({ PlaceId: placeId }), options);
214
222
  }
215
223
  async dispatchWithRetry(source, url, cmd, options) {
216
- const token = await source.get();
224
+ // Raced against the caller's signal and deadline, not given them: the
225
+ // token fetch may be shared with other calls (#62).
226
+ const token = await withinCall(source.get(), options);
217
227
  if (!token)
218
228
  throw noTokenAvailable(NO_TOKEN_ADVICE);
219
- // This token was refused a moment ago and nothing has replaced it: answer
220
- // with that refusal rather than send it, and force the source, again (#38).
221
- // A different token from the source ends the hold. When the refresh that
222
- // followed said nothing about when to ask again, it is asked now — but the
223
- // refused token is still not sent.
224
- const held = this.refused.check(token);
225
- if (held && !held.askAgain)
226
- throw held.error;
227
- let rejected = held?.error;
228
- if (!held) {
229
- try {
230
- return await this.dispatch(url, token, cmd, options);
231
- }
232
- catch (err) {
233
- if (!isTokenRejected(err))
234
- throw err;
235
- rejected = err;
236
- }
237
- }
238
- // One retry, and only when the replacement is genuinely a different token.
239
- // That single comparison covers every source: a fixed `token` string, a
240
- // caller `getToken` that ignores `forceRefresh`, and a cached token the API
241
- // has revoked before its `exp` all hand back what we already sent — and
242
- // re-sending it would be a second doomed request for the same answer.
243
- let fresh;
244
- try {
245
- fresh = await source.get(true);
246
- }
247
- catch (refusal) {
248
- // A suspended application's /auth/token refuses it as its data routes
249
- // refuse its token. Without this, every send asked for another. A
250
- // refusal that says nothing — a network fault — leaves the source to be
251
- // asked again, but not the token sent.
252
- if (holdFor(refusal) > 0)
253
- this.refused.remember(refusal, token);
254
- // Only when no hold stands: one already standing keeps its own end, so
255
- // the refused token is tried again once per hold rather than never.
256
- else if (!held)
257
- this.refused.remember(rejected, token, { askAgain: true });
258
- throw refusal;
259
- }
260
- if (!fresh || fresh === token) {
261
- this.refused.remember(rejected, token);
262
- throw rejected;
263
- }
264
- log('401 on a token the API no longer accepts — retrying once, refreshed');
265
- try {
266
- return await this.dispatch(url, fresh, cmd, options);
267
- }
268
- catch (again) {
269
- if (isTokenRejected(again))
270
- this.refused.remember(again, fresh);
271
- throw again;
272
- }
229
+ // Through `sendRetryingOnce`, like every 401 retry here (#38). The forced
230
+ // refresh is raced against the caller as the first ask was (#62).
231
+ return sendRetryingOnce(this.refused, token, (t) => this.dispatch(url, t, cmd, options), () => withinCall(source.get(true), options), () => log('401 on a token the API no longer accepts — retrying once, refreshed'));
273
232
  }
274
233
  dispatch(url, token, cmd, options) {
275
234
  // The caller's input goes out as the caller wrote it — nothing in the body
@@ -1,4 +1,4 @@
1
- import type { TokenResponse } from '../auth/TokenProvider.js';
1
+ import type { GetTokenOptions, TokenResponse } from '../auth/TokenProvider.js';
2
2
  import type { ClientConfig } from '../types/index.js';
3
3
  export interface ServerAuthConfig {
4
4
  apiUrl?: string;
@@ -60,8 +60,12 @@ export declare function resolveApiUrl(explicit?: string): string | undefined;
60
60
  */
61
61
  export interface ServerTokenSource {
62
62
  apiUrl: string;
63
- /** Resolves with a token or rejects; it never resolves tokenless. */
64
- getToken(forceRefresh?: boolean): Promise<TokenResponse & {
63
+ /**
64
+ * Resolves with a token or rejects; it never resolves tokenless. The options
65
+ * are `TokenProvider.getToken`'s: the connector asks for `cachedUntilExpiry`
66
+ * (#63), and `getClientConfig` never does.
67
+ */
68
+ getToken(forceRefresh?: boolean, options?: GetTokenOptions): Promise<TokenResponse & {
65
69
  token: string;
66
70
  }>;
67
71
  }
@@ -75,10 +75,10 @@ export function resolveApiUrl(explicit) {
75
75
  *
76
76
  * Two answers qualify. `/auth/token`'s own `Invalid credentials` is a secret
77
77
  * that matched nothing. The gateway's 401 (`UnauthorizedException`) is the
78
- * authorizer refusing the Basic pair before `/auth/token` runs, and it gives
79
- * the same answer for a wrong secret and for an application that is not
80
- * active, so the advice for it names both. `Application is not active`, and
81
- * every 403, is left as the API wrote it.
78
+ * authorizer refusing the Basic pair before `/auth/token` runs. An application
79
+ * that is not active is a 403 (`ApplicationNotActiveException`) on either
80
+ * path, whose sentence names its cause, and it is left as the API wrote it,
81
+ * like every 403.
82
82
  */
83
83
  function isCredentialsRefusal(error) {
84
84
  if (!(error instanceof LocationServiceException))
@@ -90,11 +90,8 @@ function isCredentialsRefusal(error) {
90
90
  }
91
91
  /** The API's words as a sentence, so the advice can follow them. */
92
92
  const sentence = (text) => (/[.!?]$/.test(text) ? text : `${text}.`);
93
- function credentialsAdvice(clientId, error) {
94
- const check = `Check that LOCATION_CLIENT_ID ("${clientId}") and LOCATION_CLIENT_SECRET match your application in the developer portal`;
95
- return error.code === 'UnauthorizedException'
96
- ? `${check}, and that the application is active there.`
97
- : `${check}.`;
93
+ function credentialsAdvice(clientId) {
94
+ return `Check that LOCATION_CLIENT_ID ("${clientId}") and LOCATION_CLIENT_SECRET match your application in the developer portal.`;
98
95
  }
99
96
  export function serverTokenSource(config = {}) {
100
97
  log('[serverTokenSource] Starting with config:', {
@@ -130,11 +127,11 @@ export function serverTokenSource(config = {}) {
130
127
  const provider = getTokenProvider(apiUrl, clientId, clientSecret);
131
128
  return {
132
129
  apiUrl,
133
- async getToken(forceRefresh = false) {
130
+ async getToken(forceRefresh = false, options) {
134
131
  log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
135
132
  let result;
136
133
  try {
137
- result = await provider.getToken(forceRefresh);
134
+ result = await provider.getToken(forceRefresh, options);
138
135
  }
139
136
  catch (error) {
140
137
  // The API's code and sentence, passed through (#38). Every 401 and 403
@@ -144,7 +141,7 @@ export function serverTokenSource(config = {}) {
144
141
  if (isCredentialsRefusal(error)) {
145
142
  throw new LocationServiceException({
146
143
  code: error.code,
147
- message: `${sentence(error.message)} ${credentialsAdvice(clientId, error)}`,
144
+ message: `${sentence(error.message)} ${credentialsAdvice(clientId)}`,
148
145
  statusCode: error.statusCode,
149
146
  requestId: error.requestId,
150
147
  details: error.details,
@@ -1,5 +1,5 @@
1
1
  export { TokenProvider } from '../auth/TokenProvider.js';
2
- export type { TokenProviderConfig, TokenResponse, } from '../auth/TokenProvider.js';
2
+ export type { GetTokenOptions, TokenProviderConfig, TokenResponse, } from '../auth/TokenProvider.js';
3
3
  export { getClientConfig } from './getClientConfig.js';
4
4
  export type { ServerAuthConfig, ServerClientConfig } from './getClientConfig.js';
5
5
  export { LocationServiceConnector } from './LocationServiceConnector.js';
@@ -38,6 +38,24 @@ export interface RequestOptions {
38
38
  maxAttempts?: number;
39
39
  };
40
40
  }
41
+ /**
42
+ * A call's options once it has begun: `deadline` is when `overallTimeoutMs`
43
+ * runs out, fixed at the call's entry, so that a wait before the request — a
44
+ * token — spends the same budget the request then gets the rest of (#62).
45
+ */
46
+ export interface CallOptions extends RequestOptions {
47
+ deadline: number;
48
+ }
49
+ /** Fix a call's deadline at its entry (#62). */
50
+ export declare function startCall<T extends RequestOptions>(options?: T): T & CallOptions;
51
+ /**
52
+ * Wait for `work` within the call: reject with `AbortedException` when the
53
+ * caller's signal aborts, and with `TimeoutException` when its deadline
54
+ * passes, whichever comes first (#62). `work` itself is left running, because
55
+ * it may be shared: one caller's abort must not fail another waiting on the
56
+ * same token fetch.
57
+ */
58
+ export declare function withinCall<T>(work: Promise<T>, call: CallOptions): Promise<T>;
41
59
  /** Exponential backoff with FULL jitter, so retries never march in lockstep. */
42
60
  export declare function backoffMs(attempt: number, random?: () => number): number;
43
61
  /**
@@ -25,6 +25,40 @@ export const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
25
25
  export const DEFAULT_MAX_ATTEMPTS = 3;
26
26
  const BACKOFF_BASE_MS = 250;
27
27
  const BACKOFF_CAP_MS = 4000;
28
+ /** Fix a call's deadline at its entry (#62). */
29
+ export function startCall(options = {}) {
30
+ return {
31
+ ...options,
32
+ deadline: Date.now() + (options.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS),
33
+ };
34
+ }
35
+ /**
36
+ * Wait for `work` within the call: reject with `AbortedException` when the
37
+ * caller's signal aborts, and with `TimeoutException` when its deadline
38
+ * passes, whichever comes first (#62). `work` itself is left running, because
39
+ * it may be shared: one caller's abort must not fail another waiting on the
40
+ * same token fetch.
41
+ */
42
+ export function withinCall(work, call) {
43
+ const { signal, deadline } = call;
44
+ const overallTimeoutMs = call.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
45
+ if (signal?.aborted)
46
+ return Promise.reject(abortedException(signal));
47
+ const left = deadline - Date.now();
48
+ if (left <= 0)
49
+ return Promise.reject(overallTimeoutException(overallTimeoutMs));
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => finish(() => reject(overallTimeoutException(overallTimeoutMs))), left);
52
+ const onAbort = () => finish(() => reject(abortedException(signal)));
53
+ signal?.addEventListener('abort', onAbort, { once: true });
54
+ function finish(settle) {
55
+ clearTimeout(timer);
56
+ signal?.removeEventListener('abort', onAbort);
57
+ settle();
58
+ }
59
+ work.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
60
+ });
61
+ }
28
62
  /**
29
63
  * Combine the caller's signal with a per-attempt timeout.
30
64
  *
@@ -106,7 +140,9 @@ async function request(url, init, options, read) {
106
140
  details: { source: 'client' },
107
141
  });
108
142
  }
109
- const deadline = Date.now() + overallTimeoutMs;
143
+ // A call that began before this request (#62) brings its own deadline, so
144
+ // the request has only what the wait before it left.
145
+ const deadline = options.deadline ?? Date.now() + overallTimeoutMs;
110
146
  let lastError;
111
147
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
112
148
  // Checked before every attempt: a signal aborted during backoff must not fire one more.
@@ -1,3 +1,4 @@
1
+ import type { VerifyAddressCommand, VerifyAddressResponse } from '../client/commands.js';
1
2
  /**
2
3
  * At least one of `token`, `getToken` or `refreshToken` must supply a token, or
3
4
  * `send` refuses locally with `InvalidCredentialsException` rather than putting
@@ -52,6 +53,30 @@ export interface ClientConfig {
52
53
  export interface GeoPlacesCommand {
53
54
  readonly input: object;
54
55
  }
56
+ /**
57
+ * The part of an AWS SDK command that carries its output type: the request
58
+ * handler its `resolveMiddleware` builds resolves with `{ output }`. The SDK's
59
+ * own `send` reads the output from the command the same way.
60
+ */
61
+ interface SdkCommandOutputs<O extends object = object> {
62
+ resolveMiddleware(...args: never[]): (...args: never[]) => Promise<{
63
+ output: O;
64
+ }>;
65
+ }
66
+ /**
67
+ * A command whose output `send` can name (#68): every SDK command, this
68
+ * package's narrowed Places commands among them, and `VerifyAddressCommand`.
69
+ */
70
+ export type CommandWithOutput = SdkCommandOutputs | VerifyAddressCommand;
71
+ /**
72
+ * What `send` resolves with for a command (#68): an SDK command's own
73
+ * `…CommandOutput`, and `VerifyAddressResponse` for `VerifyAddressCommand`,
74
+ * which is this package's and has no handler.
75
+ *
76
+ * @example
77
+ * type Out = CommandOutput<AutocompleteCommand> // AutocompleteCommandOutput
78
+ */
79
+ export type CommandOutput<C extends CommandWithOutput> = C extends SdkCommandOutputs<infer O> ? O : VerifyAddressResponse;
55
80
  /**
56
81
  * Minimal interface for a MapLibre Map instance.
57
82
  * Using a structural type avoids hard coupling to a specific maplibre-gl version.
@@ -69,3 +94,4 @@ export interface MapLike {
69
94
  getLayoutProperty(layerId: string, name: string): unknown;
70
95
  setLayoutProperty(layerId: string, name: string, value: unknown): void;
71
96
  }
97
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -58,8 +58,8 @@
58
58
  "url": "https://github.com/chaosity-io/location-service-client/issues"
59
59
  },
60
60
  "dependencies": {
61
- "@aws-sdk/client-geo-places": "^3.0.0",
62
- "@aws/amazon-location-utilities-datatypes": "^1.0.0",
61
+ "@aws-sdk/client-geo-places": "^3.1116.0",
62
+ "@aws/amazon-location-utilities-datatypes": "^1.2.4",
63
63
  "debug": "^4.4.3"
64
64
  },
65
65
  "peerDependencies": {