@chaosity/location-client 0.1.14 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,14 +73,18 @@ const config = await getClientConfig({
73
73
  ### Client-Side: Using the GeoPlacesClient
74
74
 
75
75
  ```typescript
76
- import { GeoPlacesClient, SuggestCommand } from '@chaosity/location-client'
76
+ import {
77
+ GeoPlacesClient,
78
+ SuggestCommand,
79
+ type SuggestCommandOutput,
80
+ } from '@chaosity/location-client'
77
81
 
78
82
  const client = new GeoPlacesClient({
79
83
  apiUrl: 'https://api.chaosity.cloud',
80
84
  token: 'your-bearer-token',
81
85
  })
82
86
 
83
- const response = await client.send(
87
+ const response: SuggestCommandOutput = await client.send(
84
88
  new SuggestCommand({ QueryText: 'Vancouver', MaxResults: 5 }),
85
89
  )
86
90
  ```
@@ -396,7 +400,7 @@ DEBUG=location-client:api npm run dev
396
400
  Full TypeScript support with types from AWS SDK:
397
401
 
398
402
  ```typescript
399
- import type { SuggestCommandOutput } from '@aws-sdk/client-geo-places'
403
+ import type { SuggestCommandOutput } from '@chaosity/location-client'
400
404
 
401
405
  const response: SuggestCommandOutput = await client.send(
402
406
  new SuggestCommand({ QueryText: 'Vancouver' }),
@@ -44,6 +44,20 @@ export declare class TokenProvider {
44
44
  private tokenPromise?;
45
45
  constructor(config: TokenProviderConfig);
46
46
  getToken(forceRefresh?: boolean): Promise<TokenResponse>;
47
+ /**
48
+ * Fetch a token, distinguishing transient failure from terminal (#9).
49
+ *
50
+ * This used to collapse every non-200 into `new Error(message)`: no status,
51
+ * no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
52
+ * the API returns when its config store is unreachable — was indistinguishable
53
+ * from `401 unauthorized`, and a customer whose credentials were perfectly
54
+ * good was told to go and check them.
55
+ *
56
+ * The shared transport now does the classifying: it retries 503/429/network
57
+ * honouring `Retry-After`, never retries 401/400, and throws a typed
58
+ * LocationServiceException either way. A failure REJECTS rather than resolving
59
+ * with `success: false`, so a stale token can never be used by accident.
60
+ */
47
61
  private fetchToken;
48
62
  private isExpired;
49
63
  clearCache(): void;
@@ -1,4 +1,7 @@
1
1
  import debug from 'debug';
2
+ import { LocationServiceException } from '../errors/LocationServiceException';
3
+ import { requestJson } from '../transport/http';
4
+ import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry } from './tokenRefresh';
2
5
  const log = debug('location-client:auth');
3
6
  /**
4
7
  * TokenProvider - SERVER-SIDE ONLY
@@ -73,57 +76,56 @@ export class TokenProvider {
73
76
  this.tokenPromise = undefined;
74
77
  }
75
78
  }
79
+ /**
80
+ * Fetch a token, distinguishing transient failure from terminal (#9).
81
+ *
82
+ * This used to collapse every non-200 into `new Error(message)`: no status,
83
+ * no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
84
+ * the API returns when its config store is unreachable — was indistinguishable
85
+ * from `401 unauthorized`, and a customer whose credentials were perfectly
86
+ * good was told to go and check them.
87
+ *
88
+ * The shared transport now does the classifying: it retries 503/429/network
89
+ * honouring `Retry-After`, never retries 401/400, and throws a typed
90
+ * LocationServiceException either way. A failure REJECTS rather than resolving
91
+ * with `success: false`, so a stale token can never be used by accident.
92
+ */
76
93
  async fetchToken() {
77
- try {
78
- const { clientId, clientSecret, apiUrl } = this.config;
79
- const credentials = btoa(`${clientId}:${clientSecret}`);
80
- const response = await fetch(`${apiUrl}/auth/token`, {
81
- method: 'POST',
82
- headers: {
83
- Authorization: `Basic ${credentials}`,
84
- 'Content-Type': 'application/x-www-form-urlencoded',
85
- },
86
- body: new URLSearchParams({
87
- grant_type: 'client_credentials',
88
- }).toString(),
94
+ const { clientId, clientSecret, apiUrl } = this.config;
95
+ const credentials = btoa(`${clientId}:${clientSecret}`);
96
+ const data = await requestJson(`${apiUrl}/auth/token`, {
97
+ method: 'POST',
98
+ headers: {
99
+ Authorization: `Basic ${credentials}`,
100
+ 'Content-Type': 'application/x-www-form-urlencoded',
101
+ },
102
+ body: new URLSearchParams({
103
+ grant_type: 'client_credentials',
104
+ }).toString(),
105
+ }, { retry: { maxAttempts: 3 } });
106
+ if (!data.access_token) {
107
+ throw new LocationServiceException({
108
+ code: 'InvalidCredentialsException',
109
+ message: 'Token endpoint returned no access_token',
110
+ details: { source: 'client' },
89
111
  });
90
- if (!response.ok) {
91
- const errorText = await response.text();
92
- let errorMessage = `Token request failed: ${response.statusText}`;
93
- try {
94
- const errorData = JSON.parse(errorText);
95
- if (errorData.error_description)
96
- errorMessage = errorData.error_description;
97
- else if (errorData.error)
98
- errorMessage = errorData.error;
99
- }
100
- catch {
101
- /* use statusText */
102
- }
103
- throw new Error(errorMessage);
104
- }
105
- const data = await response.json();
106
- this.cachedToken = data.access_token;
107
- // Prefer absolute expires_at (ms) from response, fall back to expires_in (seconds)
108
- this.cachedExpiresAt =
109
- data.expires_at ?? Date.now() + (data.expires_in ?? 900) * 1000;
110
- const expiresInSec = Math.floor((this.cachedExpiresAt - Date.now()) / 1000);
111
- log('Token acquired successfully (expires in %ds)', expiresInSec);
112
- return {
113
- success: true,
114
- token: this.cachedToken,
115
- expiresAt: this.cachedExpiresAt,
116
- };
117
- }
118
- catch (error) {
119
- log('Token acquisition failed: %s', error instanceof Error ? error.message : 'Unknown error');
120
- return {
121
- success: false,
122
- error: error instanceof Error ? error.message : 'Failed to get token',
123
- };
124
112
  }
113
+ this.cachedToken = data.access_token;
114
+ // The token's own `exp` claim first — it is the only value that cannot
115
+ // disagree with what the API will actually accept. `expires_at` and
116
+ // `expires_in` are what the response CLAIMS, and are kept as fallbacks.
117
+ this.cachedExpiresAt =
118
+ readTokenExpiry(data.access_token) ??
119
+ data.expires_at ??
120
+ Date.now() + (data.expires_in ?? 900) * 1000;
121
+ log('Token acquired successfully (expires in %ds)', Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
122
+ return {
123
+ success: true,
124
+ token: this.cachedToken,
125
+ expiresAt: this.cachedExpiresAt,
126
+ };
125
127
  }
126
- isExpired(bufferSeconds = 60) {
128
+ isExpired(bufferSeconds = TOKEN_REFRESH_BUFFER_SECONDS) {
127
129
  if (!this.cachedExpiresAt)
128
130
  return true;
129
131
  return Date.now() >= this.cachedExpiresAt - bufferSeconds * 1000;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * How long before expiry a token is treated as needing replacement.
3
+ *
4
+ * ONE number, used by both sides: the server-side `TokenProvider` deciding
5
+ * whether its cached token is still good, and the React provider deciding
6
+ * whether to ask for a new one. Both apply it to the same `exp` claim, so they
7
+ * cannot reach different answers about the same token.
8
+ *
9
+ * It used to be two separate `60`s — a private literal in `isExpired()` and a
10
+ * public `refreshBuffer` prop default — with nothing tying them together. On
11
+ * 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
12
+ * client judged it stale after 100 s, the server still considered it fresh for
13
+ * another 840 s and returned the same one, and the client asked again
14
+ * immediately — roughly 110 requests per second from an idle page.
15
+ *
16
+ * Not configurable per consumer, deliberately. A settable client-side buffer is
17
+ * precisely what allowed that disagreement, and no amount of capping or backing
18
+ * off fixes it as cleanly as the two sides simply sharing the number.
19
+ */
20
+ export declare const TOKEN_REFRESH_BUFFER_SECONDS = 60;
21
+ /**
22
+ * Read the `exp` claim out of a JWT, in milliseconds.
23
+ *
24
+ * The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
25
+ * take it on trust from elsewhere — the server from the response body's
26
+ * `expires_at`, the React provider from whatever `getConfig` returned, falling
27
+ * back to inventing `Date.now() + 900_000` when that was absent. An invented
28
+ * expiry is how the two ended up disagreeing about the same token.
29
+ *
30
+ * Deliberately NOT verified. This is only used to decide *when to refresh*;
31
+ * nothing is authorised on the strength of it, and the API verifies the
32
+ * signature on every request regardless. Trusting `exp` for scheduling is safe
33
+ * in a way that trusting it for access would not be.
34
+ *
35
+ * Returns undefined for anything unparseable, so callers keep their fallback.
36
+ */
37
+ export declare function readTokenExpiry(token: string | undefined): number | undefined;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * How long before expiry a token is treated as needing replacement.
3
+ *
4
+ * ONE number, used by both sides: the server-side `TokenProvider` deciding
5
+ * whether its cached token is still good, and the React provider deciding
6
+ * whether to ask for a new one. Both apply it to the same `exp` claim, so they
7
+ * cannot reach different answers about the same token.
8
+ *
9
+ * It used to be two separate `60`s — a private literal in `isExpired()` and a
10
+ * public `refreshBuffer` prop default — with nothing tying them together. On
11
+ * 2026-08-23 a consumer passed `refreshBuffer={800}` against a 900 s token: the
12
+ * client judged it stale after 100 s, the server still considered it fresh for
13
+ * another 840 s and returned the same one, and the client asked again
14
+ * immediately — roughly 110 requests per second from an idle page.
15
+ *
16
+ * Not configurable per consumer, deliberately. A settable client-side buffer is
17
+ * precisely what allowed that disagreement, and no amount of capping or backing
18
+ * off fixes it as cleanly as the two sides simply sharing the number.
19
+ */
20
+ export const TOKEN_REFRESH_BUFFER_SECONDS = 60;
21
+ /**
22
+ * Read the `exp` claim out of a JWT, in milliseconds.
23
+ *
24
+ * The expiry is IN THE TOKEN, so neither side needs to be told it. Both used to
25
+ * take it on trust from elsewhere — the server from the response body's
26
+ * `expires_at`, the React provider from whatever `getConfig` returned, falling
27
+ * back to inventing `Date.now() + 900_000` when that was absent. An invented
28
+ * expiry is how the two ended up disagreeing about the same token.
29
+ *
30
+ * Deliberately NOT verified. This is only used to decide *when to refresh*;
31
+ * nothing is authorised on the strength of it, and the API verifies the
32
+ * signature on every request regardless. Trusting `exp` for scheduling is safe
33
+ * in a way that trusting it for access would not be.
34
+ *
35
+ * Returns undefined for anything unparseable, so callers keep their fallback.
36
+ */
37
+ export function readTokenExpiry(token) {
38
+ if (!token)
39
+ return undefined;
40
+ const payload = token.split('.')[1];
41
+ if (!payload)
42
+ return undefined;
43
+ try {
44
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
45
+ const json = typeof atob === 'function'
46
+ ? atob(base64)
47
+ : Buffer.from(base64, 'base64').toString('utf8');
48
+ const exp = JSON.parse(json)?.exp;
49
+ return typeof exp === 'number' ? exp * 1000 : undefined;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
@@ -1,11 +1,13 @@
1
+ import type { RequestOptions } from '../transport/http';
1
2
  import type { ClientConfig } from '../types';
3
+ export type SendOptions = RequestOptions;
2
4
  /**
3
- * GeoPlacesClient - AWS Location Service compatible client with custom auth
5
+ * GeoPlacesClient — AWS Location Service compatible client with custom auth.
4
6
  *
5
- * Uses AWS SDK command classes but replaces SigV4 auth with Bearer token.
6
- * All request/response types are identical to AWS Location Service.
7
+ * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
8
+ * request and response types are identical to AWS Location Service.
7
9
  *
8
- * Pass `getToken` in config to support live token refresh without recreating the client.
10
+ * Pass `getToken` in config for live refresh without recreating the client.
9
11
  */
10
12
  export declare class GeoPlacesClient {
11
13
  private clientConfig;
@@ -13,6 +15,9 @@ export declare class GeoPlacesClient {
13
15
  serviceId: string;
14
16
  };
15
17
  constructor(config: ClientConfig);
16
- send<TInput, TOutput>(command: TInput): Promise<TOutput>;
17
- private getEndpoint;
18
+ /**
19
+ * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
20
+ * to disable the retry loop. Every failure throws LocationServiceException.
21
+ */
22
+ send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
18
23
  }
@@ -1,86 +1,39 @@
1
- import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
1
  import debug from 'debug';
3
- import { LocationServiceException } from '../errors/LocationServiceException';
2
+ import { resolveEndpoint } from '../transport/endpoints';
3
+ import { requestJson } from '../transport/http';
4
4
  import { roundPositionFields } from '../utils/roundPosition';
5
5
  const log = debug('location-client:api');
6
- const logError = debug('location-client:api:error');
7
- // Minification-safe endpoint map — uses constructor identity, not class name strings
8
- const ENDPOINT_MAP = new Map([
9
- [AutocompleteCommand, '/address/autocomplete'],
10
- [GeocodeCommand, '/address/geocode'],
11
- [GetPlaceCommand, '/address/place'],
12
- [ReverseGeocodeCommand, '/address/search/reverse-geocode'],
13
- [SearchNearbyCommand, '/address/search/nearby'],
14
- [SearchTextCommand, '/address/search/text'],
15
- [SuggestCommand, '/address/suggestion'],
16
- ]);
17
6
  /**
18
- * GeoPlacesClient - AWS Location Service compatible client with custom auth
7
+ * GeoPlacesClient — AWS Location Service compatible client with custom auth.
19
8
  *
20
- * Uses AWS SDK command classes but replaces SigV4 auth with Bearer token.
21
- * All request/response types are identical to AWS Location Service.
9
+ * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
10
+ * request and response types are identical to AWS Location Service.
22
11
  *
23
- * Pass `getToken` in config to support live token refresh without recreating the client.
12
+ * Pass `getToken` in config for live refresh without recreating the client.
24
13
  */
25
14
  export class GeoPlacesClient {
26
15
  constructor(config) {
27
16
  this.clientConfig = config;
28
17
  this.config = { serviceId: 'Geo Places' };
29
18
  }
30
- async send(command) {
19
+ /**
20
+ * @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
21
+ * to disable the retry loop. Every failure throws LocationServiceException.
22
+ */
23
+ async send(command, options) {
31
24
  const cmd = command;
32
- const endpoint = this.getEndpoint(cmd);
33
- const url = `${this.clientConfig.apiUrl}${endpoint}`;
34
- const commandName = cmd.constructor.name;
25
+ const endpoint = resolveEndpoint(cmd);
35
26
  const input = roundPositionFields(cmd.input);
36
- // Prefer getToken callback (live ref) over static token string
27
+ // Prefer the getToken callback (live ref) over a static token string.
37
28
  const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
38
- log('Sending %s to %s', commandName, endpoint);
39
- const startTime = Date.now();
40
- const response = await fetch(url, {
29
+ log('Sending %s to %s', cmd.constructor?.name, endpoint);
30
+ return requestJson(`${this.clientConfig.apiUrl}${endpoint}`, {
41
31
  method: 'POST',
42
32
  headers: {
43
33
  'Content-Type': 'application/json',
44
34
  Authorization: `Bearer ${token}`,
45
35
  },
46
36
  body: JSON.stringify(input),
47
- });
48
- const duration = Date.now() - startTime;
49
- if (!response.ok) {
50
- const errorText = await response.text();
51
- logError('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
52
- let errorMessage = `API request failed: ${response.statusText}`;
53
- let errorCode = 'ServiceException';
54
- let requestId;
55
- try {
56
- const errorData = JSON.parse(errorText);
57
- if (errorData.message)
58
- errorMessage = errorData.message;
59
- if (errorData.code)
60
- errorCode = errorData.code;
61
- if (errorData.requestId)
62
- requestId = errorData.requestId;
63
- }
64
- catch {
65
- if (errorText)
66
- errorMessage = errorText;
67
- }
68
- throw new LocationServiceException({
69
- message: errorMessage,
70
- code: errorCode,
71
- statusCode: response.status,
72
- requestId,
73
- });
74
- }
75
- const result = await response.json();
76
- log('Request successful: %s (%dms)', response.status, duration);
77
- return result;
78
- }
79
- getEndpoint(command) {
80
- for (const [CommandClass, endpoint] of ENDPOINT_MAP) {
81
- if (command instanceof CommandClass)
82
- return endpoint;
83
- }
84
- throw new Error(`Unknown command type: ${command.constructor?.name ?? typeof command}`);
37
+ }, options);
85
38
  }
86
39
  }
@@ -1,16 +1,44 @@
1
1
  export interface LocationServiceExceptionOptions {
2
2
  message: string;
3
3
  code: string;
4
- statusCode: number;
4
+ /** Absent for failures that never reached the server: network, timeout, abort. */
5
+ statusCode?: number;
5
6
  requestId?: string;
7
+ /** Structured extras — `source: 'client'` marks a locally-raised failure. */
8
+ details?: Record<string, unknown>;
9
+ cause?: unknown;
10
+ /** Parsed from the `Retry-After` header, in milliseconds. */
11
+ retryAfterMs?: number;
6
12
  }
13
+ /**
14
+ * The single error type this package throws.
15
+ *
16
+ * Every failure — an API error envelope, a network fault, a timeout, an abort —
17
+ * arrives as one of these, so a caller writes one `catch` and asks the getters
18
+ * rather than sniffing at `TypeError` vs `DOMException` vs a bare `Error`.
19
+ */
7
20
  export declare class LocationServiceException extends Error {
8
21
  readonly code: string;
9
- readonly statusCode: number;
22
+ readonly statusCode?: number;
10
23
  readonly requestId?: string;
24
+ readonly details?: Record<string, unknown>;
25
+ readonly retryAfterMs?: number;
11
26
  constructor(options: LocationServiceExceptionOptions);
27
+ /**
28
+ * Worth another attempt.
29
+ *
30
+ * Deliberately a explicit list rather than `statusCode >= 500`: a 500 or 501
31
+ * means the server broke on this request and will break again, while 502/503/
32
+ * 504 mean it never got there or gave up waiting. Retrying the first kind just
33
+ * multiplies the damage. Network failures and timeouts are retryable because
34
+ * nothing was necessarily processed.
35
+ */
12
36
  get isRetryable(): boolean;
13
37
  get isThrottling(): boolean;
14
38
  get isValidation(): boolean;
39
+ /** Credentials or entitlements — the caller must act, retrying will not help. */
40
+ get isAuth(): boolean;
41
+ get isAborted(): boolean;
42
+ get isTimeout(): boolean;
15
43
  toString(): string;
16
44
  }
@@ -1,13 +1,35 @@
1
+ /**
2
+ * The single error type this package throws.
3
+ *
4
+ * Every failure — an API error envelope, a network fault, a timeout, an abort —
5
+ * arrives as one of these, so a caller writes one `catch` and asks the getters
6
+ * rather than sniffing at `TypeError` vs `DOMException` vs a bare `Error`.
7
+ */
1
8
  export class LocationServiceException extends Error {
2
9
  constructor(options) {
3
- super(options.message);
10
+ super(options.message, { cause: options.cause });
4
11
  this.name = 'LocationServiceException';
5
12
  this.code = options.code;
6
13
  this.statusCode = options.statusCode;
7
14
  this.requestId = options.requestId;
15
+ this.details = options.details;
16
+ this.retryAfterMs = options.retryAfterMs;
8
17
  }
18
+ /**
19
+ * Worth another attempt.
20
+ *
21
+ * Deliberately a explicit list rather than `statusCode >= 500`: a 500 or 501
22
+ * means the server broke on this request and will break again, while 502/503/
23
+ * 504 mean it never got there or gave up waiting. Retrying the first kind just
24
+ * multiplies the damage. Network failures and timeouts are retryable because
25
+ * nothing was necessarily processed.
26
+ */
9
27
  get isRetryable() {
10
- return this.statusCode === 429 || this.statusCode >= 500;
28
+ if (this.code === 'AbortedException')
29
+ return false;
30
+ if (this.code === 'NetworkException' || this.code === 'TimeoutException')
31
+ return true;
32
+ return (this.statusCode === 429 || [502, 503, 504].includes(this.statusCode ?? 0));
11
33
  }
12
34
  get isThrottling() {
13
35
  return this.code === 'ThrottlingException' || this.statusCode === 429;
@@ -15,6 +37,16 @@ export class LocationServiceException extends Error {
15
37
  get isValidation() {
16
38
  return this.code === 'ValidationException' || this.statusCode === 400;
17
39
  }
40
+ /** Credentials or entitlements — the caller must act, retrying will not help. */
41
+ get isAuth() {
42
+ return this.statusCode === 401 || this.statusCode === 403;
43
+ }
44
+ get isAborted() {
45
+ return this.code === 'AbortedException';
46
+ }
47
+ get isTimeout() {
48
+ return this.code === 'TimeoutException';
49
+ }
18
50
  toString() {
19
51
  const parts = [`LocationServiceException: [${this.code}] ${this.message}`];
20
52
  if (this.requestId)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { GeoPlacesClient } from './client/GeoPlacesClient';
2
+ export type { SendOptions } from './client/GeoPlacesClient';
3
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http';
4
+ export type { RequestOptions } from './transport/http';
5
+ export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh';
2
6
  export { LocationServiceException } from './errors/LocationServiceException';
3
7
  export type { LocationServiceExceptionOptions } from './errors/LocationServiceException';
4
8
  export * from '@aws-sdk/client-geo-places';
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // Client (Custom - uses our auth instead of AWS SigV4)
2
2
  export { GeoPlacesClient } from './client/GeoPlacesClient';
3
+ // Transport options — cancellation, per-attempt timeout, retry policy
4
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http';
5
+ // Token refresh policy — shared by the server provider and the React provider
6
+ export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh';
3
7
  // Errors
4
8
  export { LocationServiceException } from './errors/LocationServiceException';
5
9
  // Re-export AWS SDK commands and types
@@ -1,46 +1,37 @@
1
+ import type { RequestOptions } from '../transport/http';
1
2
  export interface ConnectorConfig {
2
3
  apiUrl?: string;
3
4
  token?: string;
4
5
  getToken?: () => Promise<{
5
6
  token: string;
6
7
  }>;
8
+ /**
9
+ * Sent as the `Origin` header on every request. The API requires an Origin it
10
+ * recognises on every data request and answers 403 without one, so every
11
+ * caller was setting it by hand on each `send`; this does it once.
12
+ */
13
+ origin?: string;
7
14
  }
8
- export interface SendOptions {
15
+ export interface SendOptions extends RequestOptions {
9
16
  headers?: Record<string, string>;
10
17
  }
11
18
  /**
12
- * LocationServiceConnector - Server-side connector for Location Service API
19
+ * LocationServiceConnector — server-side connector for the Location Service API.
13
20
  *
14
- * Optimized for backend-to-backend communication with:
15
- * - Automatic configuration from environment variables
16
- * - Automatic Origin header handling
17
- * - Server-side token management
18
- * - Enhanced error handling
19
- *
20
- * Uses AWS SDK command classes with Bearer token authentication.
21
+ * Backend-to-backend: automatic configuration from the environment, server-side
22
+ * token management, and the same transport (timeout, cancellation, retry) as the
23
+ * browser client.
21
24
  *
22
25
  * @example
23
26
  * ```typescript
24
- * // Auto-detect from environment
25
- * const connector = new LocationServiceConnector()
26
- *
27
- * // Or provide explicit config
28
- * const connector = new LocationServiceConnector({
29
- * apiUrl: 'https://api.example.com',
30
- * token: 'your-token'
31
- * })
32
- *
33
- * // Send with custom headers (including Origin)
34
- * const result = await connector.send(
35
- * new SearchTextCommand({ QueryText: 'Space Needle' }),
36
- * { headers: { 'Origin': req.headers.origin } }
37
- * )
27
+ * const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
28
+ * const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
38
29
  * ```
39
30
  */
40
31
  export declare class LocationServiceConnector {
41
32
  private configPromise;
33
+ private origin?;
42
34
  readonly serviceId: string;
43
35
  constructor(config?: ConnectorConfig);
44
36
  send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
45
- private getEndpoint;
46
37
  }
@@ -1,46 +1,31 @@
1
- import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
1
  import debug from 'debug';
3
2
  import { LocationServiceException } from '../errors/LocationServiceException';
3
+ import { resolveEndpoint } from '../transport/endpoints';
4
+ import { requestJson } from '../transport/http';
4
5
  import { roundPositionFields } from '../utils/roundPosition';
5
6
  import { getClientConfig } from './getClientConfig';
6
7
  const log = debug('location-client:connector');
7
8
  /**
8
- * LocationServiceConnector - Server-side connector for Location Service API
9
+ * LocationServiceConnector — server-side connector for the Location Service API.
9
10
  *
10
- * Optimized for backend-to-backend communication with:
11
- * - Automatic configuration from environment variables
12
- * - Automatic Origin header handling
13
- * - Server-side token management
14
- * - Enhanced error handling
15
- *
16
- * Uses AWS SDK command classes with Bearer token authentication.
11
+ * Backend-to-backend: automatic configuration from the environment, server-side
12
+ * token management, and the same transport (timeout, cancellation, retry) as the
13
+ * browser client.
17
14
  *
18
15
  * @example
19
16
  * ```typescript
20
- * // Auto-detect from environment
21
- * const connector = new LocationServiceConnector()
22
- *
23
- * // Or provide explicit config
24
- * const connector = new LocationServiceConnector({
25
- * apiUrl: 'https://api.example.com',
26
- * token: 'your-token'
27
- * })
28
- *
29
- * // Send with custom headers (including Origin)
30
- * const result = await connector.send(
31
- * new SearchTextCommand({ QueryText: 'Space Needle' }),
32
- * { headers: { 'Origin': req.headers.origin } }
33
- * )
17
+ * const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
18
+ * const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
34
19
  * ```
35
20
  */
36
21
  export class LocationServiceConnector {
37
22
  constructor(config) {
38
23
  this.serviceId = 'Geo Places';
39
24
  this.configPromise = config ? Promise.resolve(config) : getClientConfig();
25
+ this.origin = config?.origin;
40
26
  }
41
27
  async send(command, options) {
42
28
  const config = await this.configPromise;
43
- // Get token - ConnectorConfig may have getToken, ServerClientConfig has static token
44
29
  let token;
45
30
  if ('getToken' in config && typeof config.getToken === 'function') {
46
31
  const result = await config.getToken();
@@ -54,72 +39,24 @@ export class LocationServiceConnector {
54
39
  token = config.token;
55
40
  }
56
41
  if (!token) {
57
- throw new Error('No token available');
42
+ throw new LocationServiceException({
43
+ code: 'InvalidCredentialsException',
44
+ message: 'No token available — check clientId/clientSecret configuration',
45
+ details: { source: 'client' },
46
+ });
58
47
  }
59
48
  const cmd = command;
60
- const endpoint = this.getEndpoint(cmd);
61
- const url = `${config.apiUrl}${endpoint}`;
62
- const commandName = cmd.constructor.name;
49
+ const endpoint = resolveEndpoint(cmd);
63
50
  const input = roundPositionFields(cmd.input);
64
- log('Sending %s request to %s', commandName, endpoint);
65
- const startTime = Date.now();
66
- // Merge headers: user headers, then system headers override
51
+ // Caller headers first so the system ones below cannot be overridden, but an
52
+ // explicit per-call Origin still beats the connector-wide default.
67
53
  const headers = {
68
- ...(options?.headers || {}),
54
+ ...(this.origin ? { Origin: this.origin } : {}),
55
+ ...(options?.headers ?? {}),
69
56
  'Content-Type': 'application/json',
70
57
  Authorization: `Bearer ${token}`,
71
58
  };
72
- const response = await fetch(url, {
73
- method: 'POST',
74
- headers,
75
- body: JSON.stringify(input),
76
- });
77
- const duration = Date.now() - startTime;
78
- if (!response.ok) {
79
- const errorText = await response.text();
80
- log('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
81
- let errorMessage = `API request failed: ${response.statusText}`;
82
- let errorCode = 'ServiceException';
83
- let requestId;
84
- try {
85
- const errorData = JSON.parse(errorText);
86
- if (errorData.message)
87
- errorMessage = errorData.message;
88
- if (errorData.code)
89
- errorCode = errorData.code;
90
- if (errorData.requestId)
91
- requestId = errorData.requestId;
92
- }
93
- catch {
94
- if (errorText)
95
- errorMessage = errorText;
96
- }
97
- throw new LocationServiceException({
98
- message: errorMessage,
99
- code: errorCode,
100
- statusCode: response.status,
101
- requestId,
102
- });
103
- }
104
- const result = await response.json();
105
- log('Request successful: %s (%dms)', response.status, duration);
106
- return result;
107
- }
108
- getEndpoint(command) {
109
- if (command instanceof AutocompleteCommand)
110
- return '/address/autocomplete';
111
- if (command instanceof GeocodeCommand)
112
- return '/address/geocode';
113
- if (command instanceof GetPlaceCommand)
114
- return '/address/place';
115
- if (command instanceof ReverseGeocodeCommand)
116
- return '/address/search/reverse-geocode';
117
- if (command instanceof SearchNearbyCommand)
118
- return '/address/search/nearby';
119
- if (command instanceof SearchTextCommand)
120
- return '/address/search/text';
121
- if (command instanceof SuggestCommand)
122
- return '/address/suggestion';
123
- throw new Error(`Unknown command type: ${command.constructor?.name ?? typeof command}`);
59
+ log('Sending %s request to %s', cmd.constructor?.name, endpoint);
60
+ return requestJson(`${config.apiUrl}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(input) }, options);
124
61
  }
125
62
  }
@@ -1,11 +1,18 @@
1
1
  import debug from 'debug';
2
+ import { createHash } from 'node:crypto';
2
3
  import { TokenProvider } from '../auth/TokenProvider';
4
+ import { LocationServiceException } from '../errors/LocationServiceException';
3
5
  const log = debug('location-client:clientConfig');
4
6
  // Singleton instance to prevent race conditions
5
7
  let tokenProviderInstance = null;
6
8
  let currentConfig = null;
7
9
  function getTokenProvider(apiUrl, clientId, clientSecret) {
8
- const configKey = `${apiUrl}:${clientId}`;
10
+ // The SECRET is part of the key. Without it, rotating a client secret while
11
+ // keeping the same clientId left this process reusing a provider built on the
12
+ // old secret — so the rotation appeared to do nothing until a restart. Hashed
13
+ // rather than concatenated so the key is never a secret in its own right, and
14
+ // never ends up in a log line (#5).
15
+ const configKey = `${apiUrl}:${clientId}:${createHash('sha256').update(clientSecret).digest('hex').slice(0, 16)}`;
9
16
  // Reuse existing instance if config matches
10
17
  if (tokenProviderInstance && currentConfig === configKey) {
11
18
  log('[getTokenProvider] Reusing existing TokenProvider instance');
@@ -75,15 +82,38 @@ export async function getClientConfig(config = {}) {
75
82
  log('[getClientConfig] Getting TokenProvider instance');
76
83
  const provider = getTokenProvider(apiUrl, clientId, clientSecret);
77
84
  log('[getClientConfig] Fetching token');
78
- const result = await provider.getToken();
79
- if (!result.success || !result.token) {
80
- console.error('[getClientConfig] Token fetch failed:', result.error);
81
- const isAuthError = result.error === 'Invalid credentials' ||
82
- result.error?.toLowerCase().includes('unauthorized');
83
- throw new Error(isAuthError
84
- ? `Authentication failed for client ID "${clientId}". ` +
85
- `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`
86
- : result.error || 'Failed to get token');
85
+ let result;
86
+ try {
87
+ result = await provider.getToken();
88
+ }
89
+ catch (error) {
90
+ // The provider now rejects rather than resolving with success:false, and the
91
+ // rejection is typed — so a store outage (503) can be reported as a store
92
+ // outage instead of as bad credentials.
93
+ if (error instanceof LocationServiceException) {
94
+ if (error.isAuth) {
95
+ throw new LocationServiceException({
96
+ code: 'InvalidCredentialsException',
97
+ message: `Authentication failed for client ID "${clientId}". ` +
98
+ `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
99
+ statusCode: error.statusCode,
100
+ requestId: error.requestId,
101
+ cause: error,
102
+ });
103
+ }
104
+ throw error;
105
+ }
106
+ throw error;
107
+ }
108
+ // TokenProvider rejects rather than returning a tokenless success, so this is
109
+ // unreachable in practice — it is here to keep the contract explicit at the
110
+ // type level rather than asserting non-null.
111
+ if (!result.token) {
112
+ throw new LocationServiceException({
113
+ code: 'InvalidCredentialsException',
114
+ message: 'Token provider returned no token',
115
+ details: { source: 'client' },
116
+ });
87
117
  }
88
118
  log('[getClientConfig] Token fetched successfully, length:', result.token.length);
89
119
  return {
@@ -0,0 +1,2 @@
1
+ import type { GeoPlacesCommand } from '../types';
2
+ export declare function resolveEndpoint(command: GeoPlacesCommand): string;
@@ -0,0 +1,31 @@
1
+ import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
2
+ import { LocationServiceException } from '../errors/LocationServiceException';
3
+ /**
4
+ * Command class -> API path.
5
+ *
6
+ * Keyed on constructor IDENTITY, never on `constructor.name`: a minifier
7
+ * rewrites class names and a string-keyed map silently stops matching in a
8
+ * production bundle while working perfectly in dev. The server connector used
9
+ * to carry its own `if (cmd instanceof X)` chain saying the same thing in a
10
+ * different order — one of them was always going to drift.
11
+ */
12
+ const ENDPOINTS = new Map([
13
+ [AutocompleteCommand, '/address/autocomplete'],
14
+ [GeocodeCommand, '/address/geocode'],
15
+ [GetPlaceCommand, '/address/place'],
16
+ [ReverseGeocodeCommand, '/address/search/reverse-geocode'],
17
+ [SearchNearbyCommand, '/address/search/nearby'],
18
+ [SearchTextCommand, '/address/search/text'],
19
+ [SuggestCommand, '/address/suggestion'],
20
+ ]);
21
+ export function resolveEndpoint(command) {
22
+ for (const [CommandClass, endpoint] of ENDPOINTS) {
23
+ if (command instanceof CommandClass)
24
+ return endpoint;
25
+ }
26
+ throw new LocationServiceException({
27
+ code: 'UnknownCommandException',
28
+ message: `Unknown command type: ${command?.constructor?.name ?? typeof command}`,
29
+ details: { source: 'client' },
30
+ });
31
+ }
@@ -0,0 +1,19 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException';
2
+ /**
3
+ * Turn a non-2xx response into a LocationServiceException.
4
+ *
5
+ * The API does not yet speak one error shape — that is api#29 (T23) — so this
6
+ * tolerates the three it currently emits and synthesises a `code` for each.
7
+ * RFC-0002 calls this legacy tolerance, and it is what lets the client ship
8
+ * before the API contract lands. Delete the fallbacks once T23 is deployed.
9
+ *
10
+ * { message, code, requestId } service Lambdas — already correct
11
+ * { error, error_description } /auth/token, OAuth shape
12
+ * { message: "Unauthorized" } API Gateway's own responses
13
+ */
14
+ export declare function parseErrorResponse(status: number, statusText: string, body: string, headers?: Headers): LocationServiceException;
15
+ /**
16
+ * `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
17
+ * API sends the first; a date is handled so a proxy or gateway cannot surprise us.
18
+ */
19
+ export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
@@ -0,0 +1,100 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException';
2
+ /**
3
+ * Turn a non-2xx response into a LocationServiceException.
4
+ *
5
+ * The API does not yet speak one error shape — that is api#29 (T23) — so this
6
+ * tolerates the three it currently emits and synthesises a `code` for each.
7
+ * RFC-0002 calls this legacy tolerance, and it is what lets the client ship
8
+ * before the API contract lands. Delete the fallbacks once T23 is deployed.
9
+ *
10
+ * { message, code, requestId } service Lambdas — already correct
11
+ * { error, error_description } /auth/token, OAuth shape
12
+ * { message: "Unauthorized" } API Gateway's own responses
13
+ */
14
+ export function parseErrorResponse(status, statusText, body, headers) {
15
+ let message = `Request failed: ${statusText || status}`;
16
+ let code;
17
+ let requestId;
18
+ let details;
19
+ try {
20
+ const data = JSON.parse(body);
21
+ if (typeof data.message === 'string')
22
+ message = data.message;
23
+ if (typeof data.code === 'string')
24
+ code = data.code;
25
+ if (typeof data.requestId === 'string')
26
+ requestId = data.requestId;
27
+ // OAuth envelope from /auth/token
28
+ if (!code && typeof data.error === 'string') {
29
+ message = data.error_description ?? data.error;
30
+ code = oauthCode(data.error, status);
31
+ details = { oauthError: data.error };
32
+ }
33
+ }
34
+ catch {
35
+ if (body)
36
+ message = body;
37
+ }
38
+ return new LocationServiceException({
39
+ message,
40
+ code: code ?? statusCode(status),
41
+ statusCode: status,
42
+ requestId,
43
+ details,
44
+ retryAfterMs: parseRetryAfter(headers?.get('retry-after')),
45
+ });
46
+ }
47
+ /** OAuth `error` values the token endpoint emits, mapped to our codes. */
48
+ function oauthCode(error, status) {
49
+ switch (error) {
50
+ case 'temporarily_unavailable':
51
+ return 'ServiceUnavailableException';
52
+ case 'invalid_client':
53
+ case 'unauthorized':
54
+ return 'InvalidCredentialsException';
55
+ case 'invalid_request':
56
+ return 'ValidationException';
57
+ case 'unsupported_grant_type':
58
+ return 'ValidationException';
59
+ default:
60
+ return statusCode(status);
61
+ }
62
+ }
63
+ /** Last resort when the body carried no code at all (bare gateway responses). */
64
+ function statusCode(status) {
65
+ switch (status) {
66
+ case 400:
67
+ return 'ValidationException';
68
+ case 401:
69
+ return 'UnauthorizedException';
70
+ case 403:
71
+ return 'ForbiddenException';
72
+ case 404:
73
+ return 'NotFoundException';
74
+ case 429:
75
+ return 'ThrottlingException';
76
+ case 502:
77
+ return 'UpstreamException';
78
+ case 503:
79
+ return 'ServiceUnavailableException';
80
+ case 504:
81
+ return 'TimeoutException';
82
+ default:
83
+ return status >= 500 ? 'InternalException' : 'ServiceException';
84
+ }
85
+ }
86
+ /**
87
+ * `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
88
+ * API sends the first; a date is handled so a proxy or gateway cannot surprise us.
89
+ */
90
+ export function parseRetryAfter(value) {
91
+ if (!value)
92
+ return undefined;
93
+ const seconds = Number(value);
94
+ if (Number.isFinite(seconds) && seconds >= 0)
95
+ return seconds * 1000;
96
+ const date = Date.parse(value);
97
+ if (!Number.isNaN(date))
98
+ return Math.max(0, date - Date.now());
99
+ return undefined;
100
+ }
@@ -0,0 +1,24 @@
1
+ /** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
2
+ export declare const DEFAULT_TIMEOUT_MS = 10000;
3
+ export declare const DEFAULT_MAX_ATTEMPTS = 3;
4
+ export interface RequestOptions {
5
+ /** Caller cancellation. Aborting rejects with code `AbortedException`. */
6
+ signal?: AbortSignal;
7
+ /** Per ATTEMPT, not for the whole call. Default 10 s. */
8
+ timeoutMs?: number;
9
+ /** `false` disables retries entirely. Default 3 attempts = 2 retries. */
10
+ retry?: false | {
11
+ maxAttempts?: number;
12
+ };
13
+ }
14
+ /** Exponential backoff with FULL jitter, so retries never march in lockstep. */
15
+ export declare function backoffMs(attempt: number, random?: () => number): number;
16
+ /**
17
+ * One JSON request, with timeout, cancellation and retry.
18
+ *
19
+ * Every failure leaves as a LocationServiceException — a fetch rejection
20
+ * becomes `NetworkException` with the original as `cause`, an abort becomes
21
+ * `AbortedException`, a timeout becomes `TimeoutException` with
22
+ * `details.source = 'client'` so it is distinguishable from the API's own 504.
23
+ */
24
+ export declare function requestJson<T>(url: string, init: RequestInit, options?: RequestOptions): Promise<T>;
@@ -0,0 +1,134 @@
1
+ import debug from 'debug';
2
+ import { LocationServiceException } from '../errors/LocationServiceException';
3
+ import { parseErrorResponse, parseRetryAfter } from './errors';
4
+ const log = debug('location-client:transport');
5
+ /** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
6
+ export const DEFAULT_TIMEOUT_MS = 10000;
7
+ export const DEFAULT_MAX_ATTEMPTS = 3;
8
+ const BACKOFF_BASE_MS = 250;
9
+ const BACKOFF_CAP_MS = 4000;
10
+ /**
11
+ * Combine the caller's signal with a per-attempt timeout.
12
+ *
13
+ * `AbortSignal.any` is the clean way and exists in Node 20+ and current
14
+ * browsers; the manual fan-in keeps older runtimes working rather than
15
+ * throwing at import time.
16
+ */
17
+ function attemptSignal(caller, timeoutMs) {
18
+ const timeout = AbortSignal.timeout(timeoutMs);
19
+ if (!caller)
20
+ return { signal: timeout, cleanup: () => { } };
21
+ if (typeof AbortSignal.any === 'function') {
22
+ return { signal: AbortSignal.any([caller, timeout]), cleanup: () => { } };
23
+ }
24
+ const controller = new AbortController();
25
+ const abort = () => controller.abort();
26
+ if (caller.aborted || timeout.aborted)
27
+ controller.abort();
28
+ caller.addEventListener('abort', abort);
29
+ timeout.addEventListener('abort', abort);
30
+ return {
31
+ signal: controller.signal,
32
+ cleanup: () => {
33
+ caller.removeEventListener('abort', abort);
34
+ timeout.removeEventListener('abort', abort);
35
+ },
36
+ };
37
+ }
38
+ /** Exponential backoff with FULL jitter, so retries never march in lockstep. */
39
+ export function backoffMs(attempt, random = Math.random) {
40
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
41
+ return Math.floor(random() * ceiling);
42
+ }
43
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
44
+ /**
45
+ * One JSON request, with timeout, cancellation and retry.
46
+ *
47
+ * Every failure leaves as a LocationServiceException — a fetch rejection
48
+ * becomes `NetworkException` with the original as `cause`, an abort becomes
49
+ * `AbortedException`, a timeout becomes `TimeoutException` with
50
+ * `details.source = 'client'` so it is distinguishable from the API's own 504.
51
+ */
52
+ export async function requestJson(url, init, options = {}) {
53
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
54
+ const maxAttempts = options.retry === false
55
+ ? 1
56
+ : (options.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
57
+ let lastError;
58
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
59
+ // Checked before every attempt: a signal aborted during backoff must not fire one more.
60
+ if (options.signal?.aborted)
61
+ throw abortedException(options.signal);
62
+ const { signal, cleanup } = attemptSignal(options.signal, timeoutMs);
63
+ try {
64
+ const response = await fetch(url, { ...init, signal });
65
+ if (response.ok)
66
+ return (await response.json());
67
+ const error = parseErrorResponse(response.status, response.statusText, await response.text(), response.headers);
68
+ lastError = error;
69
+ if (!error.isRetryable || attempt === maxAttempts - 1)
70
+ throw error;
71
+ log('attempt %d failed (%s), retrying', attempt + 1, error.code);
72
+ await sleep(error.retryAfterMs ??
73
+ parseRetryAfter(response.headers.get('retry-after')) ??
74
+ backoffMs(attempt));
75
+ }
76
+ catch (err) {
77
+ if (err instanceof LocationServiceException) {
78
+ // Already classified above, or thrown on the final attempt.
79
+ if (!err.isRetryable || attempt === maxAttempts - 1)
80
+ throw err;
81
+ lastError = err;
82
+ continue;
83
+ }
84
+ const wrapped = wrapFetchError(err, options.signal);
85
+ lastError = wrapped;
86
+ if (!wrapped.isRetryable || attempt === maxAttempts - 1)
87
+ throw wrapped;
88
+ log('attempt %d failed (%s), retrying', attempt + 1, wrapped.code);
89
+ await sleep(backoffMs(attempt));
90
+ }
91
+ finally {
92
+ cleanup();
93
+ }
94
+ }
95
+ /* c8 ignore next */
96
+ throw (lastError ??
97
+ new LocationServiceException({
98
+ code: 'InternalException',
99
+ message: 'Request failed',
100
+ }));
101
+ }
102
+ function abortedException(signal) {
103
+ return new LocationServiceException({
104
+ code: 'AbortedException',
105
+ message: 'Request was aborted by the caller',
106
+ details: { source: 'client' },
107
+ cause: signal?.reason,
108
+ });
109
+ }
110
+ /**
111
+ * fetch rejects with a raw `TypeError` for a network fault and a `DOMException`
112
+ * named AbortError for both cancellation and timeout — indistinguishable from
113
+ * each other unless the caller's own signal is checked, which is why that is
114
+ * checked first.
115
+ */
116
+ function wrapFetchError(err, callerSignal) {
117
+ const name = err?.name;
118
+ if (callerSignal?.aborted)
119
+ return abortedException(callerSignal);
120
+ if (name === 'TimeoutError' || name === 'AbortError') {
121
+ return new LocationServiceException({
122
+ code: 'TimeoutException',
123
+ message: 'Request timed out',
124
+ details: { source: 'client' },
125
+ cause: err,
126
+ });
127
+ }
128
+ return new LocationServiceException({
129
+ code: 'NetworkException',
130
+ message: err instanceof Error ? err.message : 'Network request failed',
131
+ details: { source: 'client' },
132
+ cause: err,
133
+ });
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.1.14",
3
+ "version": "0.2.1",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -21,11 +21,10 @@
21
21
  "lint": "eslint . && prettier --check .",
22
22
  "lint:fix": "eslint --fix . && prettier --write .",
23
23
  "format": "prettier --write .",
24
- "test": "vitest run --passWithNoTests",
24
+ "test": "vitest run",
25
25
  "test:watch": "vitest",
26
26
  "prepublishOnly": "npm run build",
27
- "prepare": "husky",
28
- "postversion": "git push --follow-tags"
27
+ "prepare": "husky"
29
28
  },
30
29
  "keywords": [
31
30
  "aws",