@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
package/README.md CHANGED
@@ -22,6 +22,9 @@ AWS Location Service compatible client with custom Bearer token authentication.
22
22
  npm install @chaosity/location-client
23
23
  ```
24
24
 
25
+ TypeScript users need TypeScript 5.0 or later: the declarations use
26
+ `export type *`.
27
+
25
28
  The Places commands, the transport and the static-map and style-URL helpers need
26
29
  nothing else. The **map** features are optional peer dependencies, so install
27
30
  them only if you use them:
@@ -98,17 +101,13 @@ const config = await getClientConfig({
98
101
  ### Client-Side: Using the GeoPlacesClient
99
102
 
100
103
  ```typescript
101
- import {
102
- GeoPlacesClient,
103
- SuggestCommand,
104
- type SuggestCommandOutput,
105
- } from '@chaosity/location-client'
104
+ import { GeoPlacesClient, SuggestCommand } from '@chaosity/location-client'
106
105
 
107
106
  // apiUrl and token as getLocationConfig() above returns them
108
107
  const { apiUrl, token } = await getLocationConfig()
109
108
  const client = new GeoPlacesClient({ apiUrl, token })
110
109
 
111
- const response: SuggestCommandOutput = await client.send(
110
+ const response = await client.send(
112
111
  new SuggestCommand({
113
112
  QueryText: 'Vancouver',
114
113
  MaxResults: 5,
@@ -116,8 +115,14 @@ const response: SuggestCommandOutput = await client.send(
116
115
  BiasPosition: [-123.1207, 49.2827],
117
116
  }),
118
117
  )
118
+ response.ResultItems
119
119
  ```
120
120
 
121
+ `send` resolves with the command's own output type — `SuggestCommandOutput`
122
+ here — so there is nothing to annotate, on `GeoPlacesClient` and
123
+ `LocationServiceConnector` alike (since 0.13.0). `CommandOutput<C>` names it
124
+ when you need it.
125
+
121
126
  ### Verifying an address
122
127
 
123
128
  `POST /address/verify` resolves one PlaceId to the full place record plus
@@ -129,15 +134,10 @@ Japan, which may not be stored at all.
129
134
  The flow is suggest, then an optional unit pick, then verify at submit:
130
135
 
131
136
  ```typescript
132
- import {
133
- GetPlaceCommand,
134
- SuggestCommand,
135
- type GetPlaceCommandOutput,
136
- type SuggestCommandOutput,
137
- } from '@chaosity/location-client'
137
+ import { GetPlaceCommand, SuggestCommand } from '@chaosity/location-client'
138
138
 
139
139
  // 1. While the person types: suggestions, for display.
140
- const suggestions: SuggestCommandOutput = await client.send(
140
+ const suggestions = await client.send(
141
141
  // Suggest takes exactly one of BiasPosition, Filter.BoundingBox or Filter.Circle.
142
142
  new SuggestCommand({
143
143
  QueryText: '100 George St, Sydney',
@@ -147,7 +147,7 @@ const suggestions: SuggestCommandOutput = await client.send(
147
147
  const picked = suggestions.ResultItems?.[0]?.Place?.PlaceId
148
148
 
149
149
  // 2. Optionally, offer the building's units. Each has its own PlaceId.
150
- const building: GetPlaceCommandOutput = await client.send(
150
+ const building = await client.send(
151
151
  new GetPlaceCommand({
152
152
  PlaceId: picked!,
153
153
  AdditionalFeatures: ['SecondaryAddresses'],
@@ -221,6 +221,41 @@ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
221
221
  // then `maxPitch: 85` on the map, so the camera can tilt to see them
222
222
  ```
223
223
 
224
+ #### When the API refuses the map's token
225
+
226
+ `getToken` is synchronous, so it can only hand over the token already in hand.
227
+ For when the API refuses that token before its `exp` — after the application's
228
+ secret is rotated, for one — pass `{ getToken, refreshToken }` where the map
229
+ helpers take `getToken`, and add `refreshTokenOnUnauthorized` for the tiles
230
+ MapLibre fetches itself:
231
+
232
+ ```typescript
233
+ import {
234
+ fetchMapStyle,
235
+ refreshTokenOnUnauthorized,
236
+ } from '@chaosity/location-client'
237
+
238
+ // refreshToken obtains a new token, and makes getToken return it from then on.
239
+ const tokens = { getToken, refreshToken }
240
+
241
+ const style = await fetchMapStyle(apiUrl, 'Standard', tokens)
242
+ // ...the map as above, with createTransformRequest(apiUrl, getToken)...
243
+ const stop = refreshTokenOnUnauthorized(map, apiUrl, tokens)
244
+ ```
245
+
246
+ `fetchMapStyle` and `fetchStaticMap` then answer a 401 by asking `refreshToken`
247
+ once and sending again with the new token, within the same `signal` and
248
+ `overallTimeoutMs`. `refreshTokenOnUnauthorized` listens for the map's `error`
249
+ events: on a 401 from your API it asks `refreshToken` once for the burst, and
250
+ reloads the refused tiles when `getToken` returns a different token. The
251
+ tiles receive the new token no other way — they read `getToken` through
252
+ `createTransformRequest` — so a `refreshToken` that leaves `getToken` unchanged
253
+ reloads nothing. A 403 is never retried: a new token cannot change it. A token
254
+ `refreshToken` could not replace is not asked about again for 30 seconds, by
255
+ any helper handed the same `tokens` object. `stop()` removes the listener.
256
+
257
+ A bare `getToken` works as it always has: one request, and its 401 to you.
258
+
224
259
  ### The MapLibre worker
225
260
 
226
261
  MapLibre 6 loads and parses its tiles in a Web Worker, and it finds the
@@ -458,10 +493,13 @@ worth a second round trip. A 403 is never retried: a new token cannot fix an
458
493
  **A refused token is not sent again for 30 seconds.** When the API refuses a
459
494
  token and `refreshToken` cannot replace it — it rejects with a 401 or 403, or
460
495
  returns the same token — the next sends with that token reject with the same
461
- refusal without a request, and without asking `refreshToken`. A suspended
462
- application is refused on every route and by its token route alike, so each
463
- send used to cost a refused request and a refused refresh. A different token
464
- from `getToken` ends the wait at once. A `refreshToken` that fails with a
496
+ refusal without a request, and without asking `refreshToken`. A rotated secret
497
+ is refused by the data route and, through a `refreshToken` that holds the old
498
+ secret, by the token route too, so each send used to cost a refused request
499
+ and a refused refresh. A suspended application is refused 403
500
+ `ApplicationNotActiveException`, which is never retried, so it is not held
501
+ here: each send has the API's answer. A different token from `getToken` ends
502
+ the wait at once. A `refreshToken` that fails with a
465
503
  `Retry-After` is held for that long instead. One that fails with nothing to
466
504
  say about when to try again — a network fault, or a Server Action's error,
467
505
  which reaches the browser without its fields — is asked again on the next
@@ -477,7 +515,7 @@ Every call in this package takes the same options object — `client.send`,
477
515
  await client.send(command, {
478
516
  signal, // AbortSignal — cancels mid-flight AND mid-backoff
479
517
  timeoutMs: 10_000, // per ATTEMPT
480
- overallTimeoutMs: 30_000, // the whole call, waits between attempts included
518
+ overallTimeoutMs: 30_000, // the whole call: the wait for a token and between attempts
481
519
  retry: { maxAttempts: 3 }, // or `false` for none
482
520
  })
483
521
  ```
@@ -545,9 +583,9 @@ try {
545
583
  }
546
584
  ```
547
585
 
548
- `message` is the API's own sentence. On `/auth/token` that is its
549
- `error_description`, so a suspended application's refusal reads
550
- `Application is not active`.
586
+ `message` is the API's own sentence — on `/auth/token`, its
587
+ `error_description` — so a suspended application's refusal names that cause
588
+ and points to the portal.
551
589
 
552
590
  #### GeoPlaces Adapter
553
591
 
@@ -574,6 +612,13 @@ import { createTransformRequest } from '@chaosity/location-client'
574
612
  const transformRequest = createTransformRequest(apiUrl, () => currentToken)
575
613
  ```
576
614
 
615
+ #### refreshTokenOnUnauthorized
616
+
617
+ `refreshTokenOnUnauthorized(map, apiUrl, { getToken, refreshToken })` reloads
618
+ the tiles the API refused once `refreshToken` has given `getToken` a new token,
619
+ and returns a function that stops listening. See
620
+ [When the API refuses the map's token](#when-the-api-refuses-the-maps-token).
621
+
577
622
  #### fetchMapStyle
578
623
 
579
624
  Fetches the map style descriptor with Bearer auth and applies optional language to the descriptor JSON before MapLibre processes it (eliminates the visual flash that occurs when modifying layers post-load).
@@ -589,6 +634,9 @@ const style = await fetchMapStyle(apiUrl, 'Standard', getToken, {
589
634
  ```
590
635
 
591
636
  Hand `style` to `new maplibregl.Map`, with the worker set, as in [MapLibre Map Integration](#maplibre-map-integration).
637
+ Pass `{ getToken, refreshToken }` in place of `getToken` to recover from a
638
+ refused token, as [When the API refuses the map's token](#when-the-api-refuses-the-maps-token)
639
+ shows; `fetchStaticMap` takes the same.
592
640
 
593
641
  The overlays need the `terrain`, `buildings`, `contours`, `traffic` and `travel-modes` plan features — see [Plan features](#plan-features):
594
642
 
@@ -719,10 +767,9 @@ feature:
719
767
  import {
720
768
  SearchTextCommand,
721
769
  searchTextResponseToFeatureCollection,
722
- type SearchTextCommandOutput,
723
770
  } from '@chaosity/location-client'
724
771
 
725
- const response: SearchTextCommandOutput = await client.send(
772
+ const response = await client.send(
726
773
  new SearchTextCommand({
727
774
  QueryText: 'coffee',
728
775
  // SearchText takes exactly one of BiasPosition, Filter.BoundingBox or Filter.Circle.
@@ -754,14 +801,35 @@ const replacement = await getClientConfig({ forceRefresh: true })
754
801
  ```
755
802
 
756
803
  A refusal arrives with the API's code and sentence. Where the API refused the
757
- credentials themselves, the message goes on to say which variables to check,
758
- and names the client ID. An application that is not active reads
759
- `Application is not active`, although once the API's authorizer has
760
- refused it the sentence is the same as for a wrong secret, and the advice then
761
- says to check both. A refusal is remembered for 30 seconds, and a
804
+ credentials themselves, a 401, the message goes on to say which variables to
805
+ check, and names the client ID. An application that is not active is refused
806
+ 403 `ApplicationNotActiveException`, in a sentence that names that cause, and
807
+ gets no advice added. A refusal is remembered for 30 seconds, and a
762
808
  `Retry-After` for as long as it asks: calls in that time reject at once,
763
809
  without asking `/auth/token` again.
764
810
 
811
+ When the token goes to a browser, the browser is what sees it refused, and
812
+ only it can say so. `@chaosity/location-client-react`'s provider asks its
813
+ `getConfig` again with `{ refusedToken }` after a 401. Answer it by replacing
814
+ the cached token only when it is the one refused:
815
+
816
+ ```typescript
817
+ 'use server'
818
+ import { getClientConfig } from '@chaosity/location-client/server'
819
+
820
+ export async function getLocationConfig(request?: { refusedToken?: string }) {
821
+ const config = await getClientConfig()
822
+ return request?.refusedToken === config.token
823
+ ? getClientConfig({ forceRefresh: true })
824
+ : config
825
+ }
826
+ ```
827
+
828
+ Asked again without it, `getClientConfig()` hands back the token it caches:
829
+ the one just refused. The check mints once per refused token, and nothing for a
830
+ report of any other. It does not stop a caller echoing the token it was just
831
+ given to make the server mint again; rate-limit the endpoint if that matters.
832
+
765
833
  The return value is **plain data** — no methods, no closures — so it can be
766
834
  returned straight out of a Next.js Server Action to a Client Component. It is
767
835
  therefore a snapshot: the token in it stops working at its `expiresAt`, and the
@@ -818,11 +886,23 @@ which wins over both). `/auth/token` is the one endpoint exempt.
818
886
 
819
887
  A connector configured this way keeps working indefinitely: it holds a live
820
888
  token source, refreshes before expiry, and retries once with a new token if the
821
- API rejects the one it sent. If the new token is refused too, as a suspended
822
- application's is, the refusal is remembered for 30 seconds: sends in that time
823
- reject with it at once, without a data request or a token request. A refresh
824
- that fails with nothing to say about when to try again, a network fault for
825
- one, is asked again on the next send, without the refused token first. Pass an
889
+ API rejects the one it sent. When that refresh before expiry fails with anything
890
+ but a refusal — a 429 or 503 from the token route, or a network fault — it keeps
891
+ sending the token it has until 5 seconds before that token's own `exp`, and asks
892
+ the token route nothing more while a `Retry-After` stands; past that it rejects
893
+ with the API's error, `retryAfterMs` intact. A `getToken` you supply is asked
894
+ for this with `{ cachedUntilExpiry: true }`, which `TokenProvider.getToken`
895
+ understands. `getClientConfig` never does it: a token that close to its expiry
896
+ is one a browser provider would ask to replace at once.
897
+
898
+ After the API rejects a token, if no new token can be had, or the new one is
899
+ refused too — a rotated secret's refresh is refused, for one — the refusal is
900
+ remembered for 30 seconds: sends in that time reject with it at once, without a
901
+ data request or a token request. A suspended application is refused 403 on
902
+ every route, which no new token can change: its data requests are not retried,
903
+ and a token request's refusal is remembered the same way. A refresh that fails
904
+ with nothing to say about when to try again, a network fault for one, is asked
905
+ again on the next send, without the refused token first. Pass an
826
906
  explicit `token` instead and you opt out of all of that — it is a fixed string,
827
907
  and it dies at its own `exp`:
828
908
 
@@ -832,7 +912,7 @@ and it dies at its own `exp`:
832
912
  const connector = new LocationServiceConnector({
833
913
  apiUrl,
834
914
  origin: 'https://your-allowed-domain.example',
835
- getToken: (forceRefresh) => provider.getToken(forceRefresh),
915
+ getToken: (forceRefresh, options) => provider.getToken(forceRefresh, options),
836
916
  })
837
917
  ```
838
918
 
@@ -878,7 +958,9 @@ DEBUG=location-client:api npm run dev
878
958
 
879
959
  ## TypeScript Support
880
960
 
881
- Full TypeScript support with types from AWS SDK:
961
+ The request and response types are the AWS SDK's, and `send` resolves with the
962
+ command's own output type. To name it — for a variable declared before the
963
+ call, say — use the SDK's `…CommandOutput` or `CommandOutput<C>`:
882
964
 
883
965
  ```typescript
884
966
  import type { SuggestCommandOutput } from '@chaosity/location-client'
@@ -74,7 +74,13 @@ export declare class GeoPlaces implements MaplibreGeocoderApi {
74
74
  private client;
75
75
  private map;
76
76
  private details;
77
- constructor(client: GeoPlacesClient, map: Map, options?: GeoPlacesOptions);
77
+ /**
78
+ * @param client Anything with the client's `send` — the adapter calls
79
+ * nothing else (#65). `@chaosity/location-client-react`'s
80
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
81
+ * and a class with private fields admits no other object.
82
+ */
83
+ constructor(client: Pick<GeoPlacesClient, 'send'>, map: Map, options?: GeoPlacesOptions);
78
84
  /**
79
85
  * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
80
86
  *
@@ -1,4 +1,4 @@
1
- import { GetPlaceAdditionalFeature, } from '@aws-sdk/client-geo-places';
1
+ import { GetPlaceAdditionalFeature } from '@aws-sdk/client-geo-places';
2
2
  import { geocodeResponseToFeatureCollection, getPlaceResponseToFeatureCollection, reverseGeocodeResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
3
3
  import debug from 'debug';
4
4
  import {
@@ -68,6 +68,12 @@ function insideBox([x, y], bbox) {
68
68
  return minX <= maxX ? x >= minX && x <= maxX : x >= minX || x <= maxX;
69
69
  }
70
70
  export class GeoPlaces {
71
+ /**
72
+ * @param client Anything with the client's `send` — the adapter calls
73
+ * nothing else (#65). `@chaosity/location-client-react`'s
74
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
75
+ * and a class with private fields admits no other object.
76
+ */
71
77
  constructor(client, map, options = {}) {
72
78
  this.client = client;
73
79
  this.map = map;
@@ -117,7 +123,7 @@ export class GeoPlaces {
117
123
  : config.countries.split(','),
118
124
  };
119
125
  }
120
- const response = (await this.client.send(new GeocodeCommand(commandInput)));
126
+ const response = await this.client.send(new GeocodeCommand(commandInput));
121
127
  const converted = geocodeResponseToFeatureCollection(response, {
122
128
  flattenProperties: true,
123
129
  });
@@ -145,7 +151,7 @@ export class GeoPlaces {
145
151
  MaxResults: config.limit || 1,
146
152
  Language: this.normalizeLanguage(config.language),
147
153
  };
148
- const response = (await this.client.send(new ReverseGeocodeCommand(commandInput)));
154
+ const response = await this.client.send(new ReverseGeocodeCommand(commandInput));
149
155
  const converted = reverseGeocodeResponseToFeatureCollection(response, {
150
156
  flattenProperties: true,
151
157
  });
@@ -203,7 +209,7 @@ export class GeoPlaces {
203
209
  }
204
210
  : {}),
205
211
  };
206
- const response = (await this.client.send(new SuggestCommand(commandInput)));
212
+ const response = await this.client.send(new SuggestCommand(commandInput));
207
213
  const suggestions = { suggestions: [] };
208
214
  for (const item of response.ResultItems ?? []) {
209
215
  const text = item.Title;
@@ -226,7 +232,7 @@ export class GeoPlaces {
226
232
  Language: this.normalizeLanguage(config.language),
227
233
  ...(additionalFeatures ? { AdditionalFeatures: additionalFeatures } : {}),
228
234
  });
229
- const response = (await this.client.send(command));
235
+ const response = await this.client.send(command);
230
236
  const result = getPlaceResponseToFeatureCollection(response, {
231
237
  flattenProperties: true,
232
238
  });
@@ -4,6 +4,21 @@ export interface TokenResponse {
4
4
  expiresAt?: number;
5
5
  error?: string;
6
6
  }
7
+ export interface GetTokenOptions {
8
+ /**
9
+ * When a refresh fails with an error that waiting can fix — a 429 or 503
10
+ * from `/auth/token`, a network fault, or the `Retry-After` such a failure
11
+ * left standing — return the cached token instead, while it is before its
12
+ * own `exp` (#63). A refusal (401 or 403) still clears the cache and throws,
13
+ * and a forced refresh never falls back: it is asked for because the API
14
+ * refused the cached token.
15
+ *
16
+ * For server-side dispatch only. Never set it where the token is handed to
17
+ * a browser: a token inside `TOKEN_REFRESH_BUFFER_SECONDS` is one the React
18
+ * provider asks to replace at once, so `getClientConfig` does not.
19
+ */
20
+ cachedUntilExpiry?: boolean;
21
+ }
7
22
  export interface TokenProviderConfig {
8
23
  apiUrl: string;
9
24
  clientId: string;
@@ -45,7 +60,7 @@ export declare class TokenProvider {
45
60
  /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
46
61
  private readonly hold;
47
62
  constructor(config: TokenProviderConfig);
48
- getToken(forceRefresh?: boolean): Promise<TokenResponse>;
63
+ getToken(forceRefresh?: boolean, options?: GetTokenOptions): Promise<TokenResponse>;
49
64
  /**
50
65
  * Fetch a token, distinguishing transient failure from terminal (#9).
51
66
  *
@@ -61,6 +76,12 @@ export declare class TokenProvider {
61
76
  * with `success: false`, so a stale token can never be used by accident.
62
77
  */
63
78
  private fetchToken;
79
+ /**
80
+ * #63: the cached token, when the caller asked for it and the failure is one
81
+ * that waiting fixes. A refusal has already cleared the cache in fetchToken,
82
+ * so it can never be the cached token handed back here.
83
+ */
84
+ private fallback;
64
85
  private isExpired;
65
86
  clearCache(): void;
66
87
  }
@@ -4,6 +4,11 @@ import { requestJson } from '../transport/http.js';
4
4
  import { TokenHold, isTokenRefusal } from './tokenHold.js';
5
5
  import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './tokenRefresh.js';
6
6
  const log = debug('location-client:auth');
7
+ /**
8
+ * #63: how close to its own `exp` a cached token may still be sent when a
9
+ * refresh fails. A margin for the clocks of this host and the API to disagree.
10
+ */
11
+ const EXPIRY_SKEW_MS = 5000;
7
12
  /**
8
13
  * TokenProvider - SERVER-SIDE ONLY
9
14
  *
@@ -45,7 +50,7 @@ export class TokenProvider {
45
50
  log('Initializing TokenProvider for %s', config.apiUrl);
46
51
  this.config = config;
47
52
  }
48
- async getToken(forceRefresh = false) {
53
+ async getToken(forceRefresh = false, options = {}) {
49
54
  if (!forceRefresh && this.cachedToken && !this.isExpired()) {
50
55
  const expiresIn = this.cachedExpiresAt
51
56
  ? Math.floor((this.cachedExpiresAt - Date.now()) / 1000)
@@ -64,12 +69,20 @@ export class TokenProvider {
64
69
  const held = this.hold.check()?.error;
65
70
  if (held) {
66
71
  log('Token request held: %s', held.message);
72
+ const fallback = this.fallback(forceRefresh, options, held);
73
+ if (fallback)
74
+ return fallback;
67
75
  throw held;
68
76
  }
69
77
  // If token fetch is already in progress, wait for it
70
78
  if (this.tokenPromise) {
71
79
  log('Token fetch in progress, waiting for existing request...');
72
- return this.tokenPromise;
80
+ return this.tokenPromise.catch((error) => {
81
+ const fallback = this.fallback(forceRefresh, options, error);
82
+ if (fallback)
83
+ return fallback;
84
+ throw error;
85
+ });
73
86
  }
74
87
  // Start new token fetch
75
88
  const reason = forceRefresh
@@ -83,6 +96,12 @@ export class TokenProvider {
83
96
  const result = await this.tokenPromise;
84
97
  return result;
85
98
  }
99
+ catch (error) {
100
+ const fallback = this.fallback(forceRefresh, options, error);
101
+ if (fallback)
102
+ return fallback;
103
+ throw error;
104
+ }
86
105
  finally {
87
106
  // Clear promise after completion (success or failure)
88
107
  this.tokenPromise = undefined;
@@ -154,6 +173,27 @@ export class TokenProvider {
154
173
  expiresAt: this.cachedExpiresAt,
155
174
  };
156
175
  }
176
+ /**
177
+ * #63: the cached token, when the caller asked for it and the failure is one
178
+ * that waiting fixes. A refusal has already cleared the cache in fetchToken,
179
+ * so it can never be the cached token handed back here.
180
+ */
181
+ fallback(forceRefresh, options, error) {
182
+ if (forceRefresh || !options.cachedUntilExpiry)
183
+ return undefined;
184
+ if (isTokenRefusal(error))
185
+ return undefined;
186
+ if (!this.cachedToken || !this.cachedExpiresAt)
187
+ return undefined;
188
+ if (Date.now() >= this.cachedExpiresAt - EXPIRY_SKEW_MS)
189
+ return undefined;
190
+ log('Refresh failed (%s); the cached token is valid for %ds more, sending it', error instanceof Error ? error.message : String(error), Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
191
+ return {
192
+ success: true,
193
+ token: this.cachedToken,
194
+ expiresAt: this.cachedExpiresAt,
195
+ };
196
+ }
157
197
  isExpired(bufferSeconds = TOKEN_REFRESH_BUFFER_SECONDS) {
158
198
  if (!this.cachedExpiresAt)
159
199
  return true;
@@ -9,11 +9,11 @@ import { LocationServiceException } from '../errors/LocationServiceException.js'
9
9
  * served paid for a doomed data request and a doomed token request, all
10
10
  * against the application's own token-route throttle.
11
11
  *
12
- * Thirty seconds bounds how long an application that has just been made
13
- * active again waits for this side to notice: long enough to turn a request
14
- * rate into a trickle, short enough to be a pause rather than an outage. It is
15
- * also about how long a newly created application is refused while it goes
16
- * live, which is the one refusal that clears itself.
12
+ * Thirty seconds is short beside the API's own waits: it accepts a
13
+ * reactivated application again within five minutes, and refuses a newly
14
+ * created or reactivated one for up to about thirty seconds while its key goes
15
+ * live. So this side adds at most thirty seconds to either, and turns a
16
+ * request rate into a trickle meanwhile.
17
17
  */
18
18
  export declare const TOKEN_REFUSAL_HOLD_MS = 30000;
19
19
  /**
@@ -70,3 +70,12 @@ export declare class TokenHold {
70
70
  } | undefined;
71
71
  forget(): void;
72
72
  }
73
+ /**
74
+ * Send with `token`, and after a 401 once more with `refresh()`'s token when it
75
+ * is a DIFFERENT one, remembering on `hold` what the API and the source said,
76
+ * against the token it concerns (#38).
77
+ *
78
+ * The one copy of the 401 retry: both send paths and the two map fetches (#72)
79
+ * call it, so when to ask again is decided here and nowhere else.
80
+ */
81
+ export declare function sendRetryingOnce<T>(hold: TokenHold, token: string, send: (token: string) => Promise<T>, refresh: () => Promise<string | undefined>, onRetry?: () => void): Promise<T>;
@@ -1,4 +1,5 @@
1
1
  import { LocationServiceException } from '../errors/LocationServiceException.js';
2
+ import { isTokenRejected } from '../transport/errors.js';
2
3
  /**
3
4
  * How long a refusal is remembered: a token the API refused, or a token
4
5
  * request `/auth/token` refused (#38).
@@ -9,11 +10,11 @@ import { LocationServiceException } from '../errors/LocationServiceException.js'
9
10
  * served paid for a doomed data request and a doomed token request, all
10
11
  * against the application's own token-route throttle.
11
12
  *
12
- * Thirty seconds bounds how long an application that has just been made
13
- * active again waits for this side to notice: long enough to turn a request
14
- * rate into a trickle, short enough to be a pause rather than an outage. It is
15
- * also about how long a newly created application is refused while it goes
16
- * live, which is the one refusal that clears itself.
13
+ * Thirty seconds is short beside the API's own waits: it accepts a
14
+ * reactivated application again within five minutes, and refuses a newly
15
+ * created or reactivated one for up to about thirty seconds while its key goes
16
+ * live. So this side adds at most thirty seconds to either, and turns a
17
+ * request rate into a trickle meanwhile.
17
18
  */
18
19
  export const TOKEN_REFUSAL_HOLD_MS = 30000;
19
20
  /**
@@ -107,3 +108,67 @@ export class TokenHold {
107
108
  this.held = undefined;
108
109
  }
109
110
  }
111
+ /**
112
+ * Send with `token`, and after a 401 once more with `refresh()`'s token when it
113
+ * is a DIFFERENT one, remembering on `hold` what the API and the source said,
114
+ * against the token it concerns (#38).
115
+ *
116
+ * The one copy of the 401 retry: both send paths and the two map fetches (#72)
117
+ * call it, so when to ask again is decided here and nowhere else.
118
+ */
119
+ export async function sendRetryingOnce(hold, token, send, refresh, onRetry) {
120
+ // This token was refused a moment ago and nothing has replaced it: answer
121
+ // with that refusal rather than send it, and ask the source again. A
122
+ // different token ends the hold. When the refresh that followed said nothing
123
+ // about when to ask again, it is asked now — but the refused token is still
124
+ // not sent.
125
+ const held = hold.check(token);
126
+ if (held && !held.askAgain)
127
+ throw held.error;
128
+ let rejected = held?.error;
129
+ if (!held) {
130
+ try {
131
+ return await send(token);
132
+ }
133
+ catch (err) {
134
+ if (!isTokenRejected(err))
135
+ throw err;
136
+ rejected = err;
137
+ }
138
+ }
139
+ // One retry, and only when the replacement is genuinely a different token.
140
+ // That single comparison covers every source: a fixed `token` string, a
141
+ // `getToken` that ignores `forceRefresh`, a `refreshToken` that hands back
142
+ // what it had, and a cached token the API has revoked before its `exp` —
143
+ // re-sending any of them is a second doomed request for the same answer.
144
+ let fresh;
145
+ try {
146
+ fresh = await refresh();
147
+ }
148
+ catch (refusal) {
149
+ // A rotated secret's token route refuses the refresh as the data route
150
+ // refused its token. Without this, every send asked again. A refusal that
151
+ // says nothing — a network fault, or a Server Action's error without its
152
+ // fields — leaves the source to be asked again, but not the token sent.
153
+ if (holdFor(refusal) > 0)
154
+ hold.remember(refusal, token);
155
+ // Only when no hold stands: one already standing keeps its own end, so
156
+ // the refused token is tried again once per hold rather than never.
157
+ else if (!held)
158
+ hold.remember(rejected, token, { askAgain: true });
159
+ throw refusal;
160
+ }
161
+ if (!fresh || fresh === token) {
162
+ hold.remember(rejected, token);
163
+ throw rejected;
164
+ }
165
+ onRetry?.();
166
+ try {
167
+ return await send(fresh);
168
+ }
169
+ catch (again) {
170
+ if (isTokenRejected(again))
171
+ hold.remember(again, fresh);
172
+ throw again;
173
+ }
174
+ }
package/dist/aws.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type * from '@aws-sdk/client-geo-places';
2
+ export { $Command, AccessDeniedException, AccessDeniedException$, AccessPoint$, AccessPointType, AccessRestriction$, Address$, AddressComponentMatchScores$, AddressComponentPhonemes$, AddressTranslationComponent, AdminNames$, AdminNamesPreference, Autocomplete$, AutocompleteAdditionalFeature, AutocompleteAddressHighlights$, AutocompleteFilter$, AutocompleteFilterPlaceType, AutocompleteHighlights$, AutocompleteIntendedUse, AutocompleteRequest$, AutocompleteResponse$, AutocompleteResultItem$, BusinessChain$, Category$, ComponentMatchScores$, ContactDetails$, Contacts$, Country$, CountryHighlights$, CrossReference$, FilterCircle$, FoodType$, GeoPlacesServiceException, GeoPlacesServiceException$, Geocode$, GeocodeAdditionalFeature, GeocodeAddressNamesMode, GeocodeFilter$, GeocodeFilterPlaceType, GeocodeIntendedUse, GeocodeParsedQuery$, GeocodeParsedQueryAddressComponents$, GeocodeQueryComponents$, GeocodeRequest$, GeocodeResponse$, GeocodeResultItem$, GetPlace$, GetPlaceAdditionalFeature, GetPlaceAddressNamesMode, GetPlaceIntendedUse, GetPlaceRequest$, GetPlaceResponse$, Highlight$, InternalServerException, InternalServerException$, Intersection$, MatchScoreDetails$, OpeningHours$, OpeningHoursComponents$, ParsedQueryComponent$, ParsedQuerySecondaryAddressComponent$, PhonemeDetails$, PhonemeTranscription$, PlaceAttribute, PlaceType, PostalAuthority, PostalCodeDetails$, PostalCodeMode, PostalCodeType, QueryRefinement$, QueryType, RecordTypeCode, Region$, RegionHighlights$, RelatedPlace$, ReverseGeocode$, ReverseGeocodeAdditionalFeature, ReverseGeocodeAddressNamesMode, ReverseGeocodeFilter$, ReverseGeocodeFilterPlaceType, ReverseGeocodeIntendedUse, ReverseGeocodeRequest$, ReverseGeocodeResponse$, ReverseGeocodeResultItem$, SearchNearby$, SearchNearbyAdditionalFeature, SearchNearbyFilter$, SearchNearbyIntendedUse, SearchNearbyRequest$, SearchNearbyResponse$, SearchNearbyResultItem$, SearchText$, SearchTextAdditionalFeature, SearchTextFilter$, SearchTextIntendedUse, SearchTextRequest$, SearchTextResponse$, SearchTextResultItem$, SearchTextTravelMode, SecondaryAddressComponent$, SecondaryAddressComponentMatchScore$, StreetComponents$, SubRegion$, SubRegionHighlights$, Suggest$, SuggestAdditionalFeature, SuggestAddressHighlights$, SuggestFilter$, SuggestHighlights$, SuggestIntendedUse, SuggestPlaceResult$, SuggestQueryResult$, SuggestRequest$, SuggestResponse$, SuggestResultItem$, SuggestResultItemType, SuggestTravelMode, ThrottlingException, ThrottlingException$, TimeZone$, TranslationDetails$, TranslationName$, TranslationNameType, TypePlacement, UspsZip$, UspsZipPlus4$, ValidationException, ValidationException$, ValidationExceptionField$, ValidationExceptionReason, ZipClassificationCode, __Client, errorTypeRegistries, } from '@aws-sdk/client-geo-places';
3
+ export type * from '@aws/amazon-location-utilities-datatypes';
4
+ export { calculateIsolinesResponseToFeatureCollection, calculateRoutesResponseToFeatureCollections, devicePositionsToFeatureCollection, featureCollectionToGeofence, geocodeResponseToFeatureCollection, geofencesToFeatureCollection, getPlaceResponseToFeatureCollection, optimizeWaypointsResponseToFeatureCollection, placeToFeatureCollection, reverseGeocodeResponseToFeatureCollection, routeToFeatureCollection, searchNearbyResponseToFeatureCollection, searchTextResponseToFeatureCollection, snapToRoadsResponseToFeatureCollection, suggestResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
package/dist/aws.js ADDED
@@ -0,0 +1,2 @@
1
+ export { $Command, AccessDeniedException, AccessDeniedException$, AccessPoint$, AccessPointType, AccessRestriction$, Address$, AddressComponentMatchScores$, AddressComponentPhonemes$, AddressTranslationComponent, AdminNames$, AdminNamesPreference, Autocomplete$, AutocompleteAdditionalFeature, AutocompleteAddressHighlights$, AutocompleteFilter$, AutocompleteFilterPlaceType, AutocompleteHighlights$, AutocompleteIntendedUse, AutocompleteRequest$, AutocompleteResponse$, AutocompleteResultItem$, BusinessChain$, Category$, ComponentMatchScores$, ContactDetails$, Contacts$, Country$, CountryHighlights$, CrossReference$, FilterCircle$, FoodType$, GeoPlacesServiceException, GeoPlacesServiceException$, Geocode$, GeocodeAdditionalFeature, GeocodeAddressNamesMode, GeocodeFilter$, GeocodeFilterPlaceType, GeocodeIntendedUse, GeocodeParsedQuery$, GeocodeParsedQueryAddressComponents$, GeocodeQueryComponents$, GeocodeRequest$, GeocodeResponse$, GeocodeResultItem$, GetPlace$, GetPlaceAdditionalFeature, GetPlaceAddressNamesMode, GetPlaceIntendedUse, GetPlaceRequest$, GetPlaceResponse$, Highlight$, InternalServerException, InternalServerException$, Intersection$, MatchScoreDetails$, OpeningHours$, OpeningHoursComponents$, ParsedQueryComponent$, ParsedQuerySecondaryAddressComponent$, PhonemeDetails$, PhonemeTranscription$, PlaceAttribute, PlaceType, PostalAuthority, PostalCodeDetails$, PostalCodeMode, PostalCodeType, QueryRefinement$, QueryType, RecordTypeCode, Region$, RegionHighlights$, RelatedPlace$, ReverseGeocode$, ReverseGeocodeAdditionalFeature, ReverseGeocodeAddressNamesMode, ReverseGeocodeFilter$, ReverseGeocodeFilterPlaceType, ReverseGeocodeIntendedUse, ReverseGeocodeRequest$, ReverseGeocodeResponse$, ReverseGeocodeResultItem$, SearchNearby$, SearchNearbyAdditionalFeature, SearchNearbyFilter$, SearchNearbyIntendedUse, SearchNearbyRequest$, SearchNearbyResponse$, SearchNearbyResultItem$, SearchText$, SearchTextAdditionalFeature, SearchTextFilter$, SearchTextIntendedUse, SearchTextRequest$, SearchTextResponse$, SearchTextResultItem$, SearchTextTravelMode, SecondaryAddressComponent$, SecondaryAddressComponentMatchScore$, StreetComponents$, SubRegion$, SubRegionHighlights$, Suggest$, SuggestAdditionalFeature, SuggestAddressHighlights$, SuggestFilter$, SuggestHighlights$, SuggestIntendedUse, SuggestPlaceResult$, SuggestQueryResult$, SuggestRequest$, SuggestResponse$, SuggestResultItem$, SuggestResultItemType, SuggestTravelMode, ThrottlingException, ThrottlingException$, TimeZone$, TranslationDetails$, TranslationName$, TranslationNameType, TypePlacement, UspsZip$, UspsZipPlus4$, ValidationException, ValidationException$, ValidationExceptionField$, ValidationExceptionReason, ZipClassificationCode, __Client, errorTypeRegistries, } from '@aws-sdk/client-geo-places';
2
+ export { calculateIsolinesResponseToFeatureCollection, calculateRoutesResponseToFeatureCollections, devicePositionsToFeatureCollection, featureCollectionToGeofence, geocodeResponseToFeatureCollection, geofencesToFeatureCollection, getPlaceResponseToFeatureCollection, optimizeWaypointsResponseToFeatureCollection, placeToFeatureCollection, reverseGeocodeResponseToFeatureCollection, routeToFeatureCollection, searchNearbyResponseToFeatureCollection, searchTextResponseToFeatureCollection, snapToRoadsResponseToFeatureCollection, suggestResponseToFeatureCollection, } from '@aws/amazon-location-utilities-datatypes';
@@ -74,7 +74,13 @@ export declare class GeoPlaces implements MaplibreGeocoderApi {
74
74
  private client;
75
75
  private map;
76
76
  private details;
77
- constructor(client: GeoPlacesClient, map: Map, options?: GeoPlacesOptions);
77
+ /**
78
+ * @param client Anything with the client's `send` — the adapter calls
79
+ * nothing else (#65). `@chaosity/location-client-react`'s
80
+ * `useLocationClient()` hands out an interface, not a `GeoPlacesClient`,
81
+ * and a class with private fields admits no other object.
82
+ */
83
+ constructor(client: Pick<GeoPlacesClient, 'send'>, map: Map, options?: GeoPlacesOptions);
78
84
  /**
79
85
  * Build the AdditionalFeatures list from the opt-ins, or omit it entirely.
80
86
  *