@markwharton/pwa-core 1.6.0 → 1.7.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.
@@ -69,6 +69,19 @@ global.fetch = mockFetch;
69
69
  await (0, vitest_1.expect)(apiCall('/api/test')).rejects.toThrow();
70
70
  (0, vitest_1.expect)(onUnauthorized).toHaveBeenCalled();
71
71
  });
72
+ (0, vitest_1.it)('sets custom timeout', async () => {
73
+ const { initApiClient, apiCall } = await Promise.resolve().then(() => __importStar(require('../../client/api')));
74
+ initApiClient({ getToken: () => 'token', timeout: 5000 });
75
+ mockFetch.mockResolvedValue({
76
+ ok: true,
77
+ text: () => Promise.resolve('{}')
78
+ });
79
+ await apiCall('/api/test');
80
+ // Verify signal was passed to fetch
81
+ (0, vitest_1.expect)(mockFetch).toHaveBeenCalledWith('/api/test', vitest_1.expect.objectContaining({
82
+ signal: vitest_1.expect.any(AbortSignal)
83
+ }));
84
+ });
72
85
  });
73
86
  (0, vitest_1.describe)('apiCall', () => {
74
87
  (0, vitest_1.it)('makes authenticated request', async () => {
@@ -149,6 +162,17 @@ global.fetch = mockFetch;
149
162
  message: 'Request failed'
150
163
  });
151
164
  });
165
+ (0, vitest_1.it)('handles request timeout', async () => {
166
+ const { initApiClient, apiCall } = await Promise.resolve().then(() => __importStar(require('../../client/api')));
167
+ initApiClient({ getToken: () => 'token' });
168
+ const abortError = new Error('Aborted');
169
+ abortError.name = 'AbortError';
170
+ mockFetch.mockRejectedValue(abortError);
171
+ await (0, vitest_1.expect)(apiCall('/api/test')).rejects.toMatchObject({
172
+ status: 0,
173
+ message: 'Request timeout'
174
+ });
175
+ });
152
176
  });
153
177
  (0, vitest_1.describe)('HTTP method helpers', () => {
154
178
  (0, vitest_1.beforeEach)(async () => {
@@ -330,5 +354,16 @@ global.fetch = mockFetch;
330
354
  (0, vitest_1.expect)(result.ok).toBe(false);
331
355
  (0, vitest_1.expect)(result.error).toBe('Request failed');
332
356
  });
357
+ (0, vitest_1.it)('handles request timeout', async () => {
358
+ const { initApiClient, apiCallSafe } = await Promise.resolve().then(() => __importStar(require('../../client/api')));
359
+ initApiClient({ getToken: () => 'token' });
360
+ const abortError = new Error('Aborted');
361
+ abortError.name = 'AbortError';
362
+ mockFetch.mockRejectedValue(abortError);
363
+ const result = await apiCallSafe('/api/test');
364
+ (0, vitest_1.expect)(result.ok).toBe(false);
365
+ (0, vitest_1.expect)(result.status).toBe(0);
366
+ (0, vitest_1.expect)(result.error).toBe('Request timeout');
367
+ });
333
368
  });
334
369
  });
@@ -55,4 +55,37 @@ const apiError_1 = require("../../client/apiError");
55
55
  (0, vitest_1.expect)(new apiError_1.ApiError(500, 'Server error').isBadRequest()).toBe(false);
56
56
  });
57
57
  });
58
+ (0, vitest_1.describe)('isClientError', () => {
59
+ (0, vitest_1.it)('returns true for 4xx statuses', () => {
60
+ (0, vitest_1.expect)(new apiError_1.ApiError(400, 'Bad request').isClientError()).toBe(true);
61
+ (0, vitest_1.expect)(new apiError_1.ApiError(401, 'Unauthorized').isClientError()).toBe(true);
62
+ (0, vitest_1.expect)(new apiError_1.ApiError(403, 'Forbidden').isClientError()).toBe(true);
63
+ (0, vitest_1.expect)(new apiError_1.ApiError(404, 'Not found').isClientError()).toBe(true);
64
+ (0, vitest_1.expect)(new apiError_1.ApiError(422, 'Unprocessable').isClientError()).toBe(true);
65
+ (0, vitest_1.expect)(new apiError_1.ApiError(429, 'Too many requests').isClientError()).toBe(true);
66
+ (0, vitest_1.expect)(new apiError_1.ApiError(499, 'Client closed').isClientError()).toBe(true);
67
+ });
68
+ (0, vitest_1.it)('returns false for non-4xx statuses', () => {
69
+ (0, vitest_1.expect)(new apiError_1.ApiError(200, 'OK').isClientError()).toBe(false);
70
+ (0, vitest_1.expect)(new apiError_1.ApiError(301, 'Moved').isClientError()).toBe(false);
71
+ (0, vitest_1.expect)(new apiError_1.ApiError(500, 'Server error').isClientError()).toBe(false);
72
+ (0, vitest_1.expect)(new apiError_1.ApiError(0, 'Network error').isClientError()).toBe(false);
73
+ });
74
+ });
75
+ (0, vitest_1.describe)('isServerError', () => {
76
+ (0, vitest_1.it)('returns true for 5xx statuses', () => {
77
+ (0, vitest_1.expect)(new apiError_1.ApiError(500, 'Internal server error').isServerError()).toBe(true);
78
+ (0, vitest_1.expect)(new apiError_1.ApiError(501, 'Not implemented').isServerError()).toBe(true);
79
+ (0, vitest_1.expect)(new apiError_1.ApiError(502, 'Bad gateway').isServerError()).toBe(true);
80
+ (0, vitest_1.expect)(new apiError_1.ApiError(503, 'Service unavailable').isServerError()).toBe(true);
81
+ (0, vitest_1.expect)(new apiError_1.ApiError(504, 'Gateway timeout').isServerError()).toBe(true);
82
+ (0, vitest_1.expect)(new apiError_1.ApiError(599, 'Network timeout').isServerError()).toBe(true);
83
+ });
84
+ (0, vitest_1.it)('returns false for non-5xx statuses', () => {
85
+ (0, vitest_1.expect)(new apiError_1.ApiError(200, 'OK').isServerError()).toBe(false);
86
+ (0, vitest_1.expect)(new apiError_1.ApiError(400, 'Bad request').isServerError()).toBe(false);
87
+ (0, vitest_1.expect)(new apiError_1.ApiError(404, 'Not found').isServerError()).toBe(false);
88
+ (0, vitest_1.expect)(new apiError_1.ApiError(0, 'Network error').isServerError()).toBe(false);
89
+ });
90
+ });
58
91
  });
@@ -13,6 +13,8 @@ const status_1 = require("../../../shared/http/status");
13
13
  (0, vitest_1.expect)(status_1.HTTP_STATUS.NOT_FOUND).toBe(404);
14
14
  (0, vitest_1.expect)(status_1.HTTP_STATUS.CONFLICT).toBe(409);
15
15
  (0, vitest_1.expect)(status_1.HTTP_STATUS.GONE).toBe(410);
16
+ (0, vitest_1.expect)(status_1.HTTP_STATUS.UNPROCESSABLE_ENTITY).toBe(422);
17
+ (0, vitest_1.expect)(status_1.HTTP_STATUS.TOO_MANY_REQUESTS).toBe(429);
16
18
  (0, vitest_1.expect)(status_1.HTTP_STATUS.INTERNAL_ERROR).toBe(500);
17
19
  (0, vitest_1.expect)(status_1.HTTP_STATUS.SERVICE_UNAVAILABLE).toBe(503);
18
20
  });
@@ -1,19 +1,22 @@
1
1
  import { ApiResponse } from './types';
2
2
  /**
3
- * Initializes the API client with token retrieval and optional 401 handler.
3
+ * Initializes the API client with token retrieval and optional configuration.
4
4
  * Call once at application startup.
5
5
  * @param config - Client configuration
6
6
  * @param config.getToken - Function that returns the current auth token (or null)
7
7
  * @param config.onUnauthorized - Optional callback for 401 responses (e.g., redirect to login)
8
+ * @param config.timeout - Optional request timeout in milliseconds (default: 30000)
8
9
  * @example
9
10
  * initApiClient({
10
11
  * getToken: () => localStorage.getItem('token'),
11
- * onUnauthorized: () => window.location.href = '/login'
12
+ * onUnauthorized: () => window.location.href = '/login',
13
+ * timeout: 60000 // 60 seconds
12
14
  * });
13
15
  */
14
16
  export declare function initApiClient(config: {
15
17
  getToken: () => string | null;
16
18
  onUnauthorized?: () => void;
19
+ timeout?: number;
17
20
  }): void;
18
21
  /**
19
22
  * Makes an authenticated API call. Throws ApiError on non-2xx responses.
@@ -21,7 +24,7 @@ export declare function initApiClient(config: {
21
24
  * @param url - The API endpoint URL
22
25
  * @param options - Optional fetch options (method, body, headers)
23
26
  * @returns The parsed JSON response
24
- * @throws ApiError on non-2xx HTTP status
27
+ * @throws ApiError on non-2xx HTTP status or timeout
25
28
  * @example
26
29
  * const user = await apiCall<User>('/api/users/123');
27
30
  */
@@ -16,21 +16,26 @@ const apiError_1 = require("./apiError");
16
16
  let getToken = null;
17
17
  // Callback for 401 responses (e.g., redirect to login)
18
18
  let onUnauthorized = null;
19
+ // Request timeout in milliseconds (default: 30 seconds)
20
+ let requestTimeout = 30000;
19
21
  /**
20
- * Initializes the API client with token retrieval and optional 401 handler.
22
+ * Initializes the API client with token retrieval and optional configuration.
21
23
  * Call once at application startup.
22
24
  * @param config - Client configuration
23
25
  * @param config.getToken - Function that returns the current auth token (or null)
24
26
  * @param config.onUnauthorized - Optional callback for 401 responses (e.g., redirect to login)
27
+ * @param config.timeout - Optional request timeout in milliseconds (default: 30000)
25
28
  * @example
26
29
  * initApiClient({
27
30
  * getToken: () => localStorage.getItem('token'),
28
- * onUnauthorized: () => window.location.href = '/login'
31
+ * onUnauthorized: () => window.location.href = '/login',
32
+ * timeout: 60000 // 60 seconds
29
33
  * });
30
34
  */
31
35
  function initApiClient(config) {
32
36
  getToken = config.getToken;
33
37
  onUnauthorized = config.onUnauthorized ?? null;
38
+ requestTimeout = config.timeout ?? 30000;
34
39
  }
35
40
  /**
36
41
  * Makes an authenticated API call. Throws ApiError on non-2xx responses.
@@ -38,7 +43,7 @@ function initApiClient(config) {
38
43
  * @param url - The API endpoint URL
39
44
  * @param options - Optional fetch options (method, body, headers)
40
45
  * @returns The parsed JSON response
41
- * @throws ApiError on non-2xx HTTP status
46
+ * @throws ApiError on non-2xx HTTP status or timeout
42
47
  * @example
43
48
  * const user = await apiCall<User>('/api/users/123');
44
49
  */
@@ -51,30 +56,48 @@ async function apiCall(url, options = {}) {
51
56
  if (token) {
52
57
  headers['Authorization'] = `Bearer ${token}`;
53
58
  }
54
- const response = await fetch(url, {
55
- ...options,
56
- headers
57
- });
58
- if (!response.ok) {
59
- if (response.status === 401 && onUnauthorized) {
60
- onUnauthorized();
59
+ // Setup timeout with AbortController
60
+ const controller = new AbortController();
61
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
62
+ try {
63
+ const response = await fetch(url, {
64
+ ...options,
65
+ headers,
66
+ signal: controller.signal
67
+ });
68
+ if (!response.ok) {
69
+ if (response.status === 401 && onUnauthorized) {
70
+ onUnauthorized();
71
+ }
72
+ let errorMessage = 'Request failed';
73
+ try {
74
+ const errorData = (await response.json());
75
+ errorMessage = errorData.error || errorMessage;
76
+ }
77
+ catch {
78
+ // Ignore JSON parse errors
79
+ }
80
+ throw new apiError_1.ApiError(response.status, errorMessage);
61
81
  }
62
- let errorMessage = 'Request failed';
63
- try {
64
- const errorData = (await response.json());
65
- errorMessage = errorData.error || errorMessage;
82
+ // Handle empty responses
83
+ const text = await response.text();
84
+ if (!text) {
85
+ return {};
86
+ }
87
+ return JSON.parse(text);
88
+ }
89
+ catch (error) {
90
+ if (error instanceof apiError_1.ApiError) {
91
+ throw error;
66
92
  }
67
- catch {
68
- // Ignore JSON parse errors
93
+ if (error instanceof Error && error.name === 'AbortError') {
94
+ throw new apiError_1.ApiError(0, 'Request timeout');
69
95
  }
70
- throw new apiError_1.ApiError(response.status, errorMessage);
96
+ throw error;
71
97
  }
72
- // Handle empty responses
73
- const text = await response.text();
74
- if (!text) {
75
- return {};
98
+ finally {
99
+ clearTimeout(timeoutId);
76
100
  }
77
- return JSON.parse(text);
78
101
  }
79
102
  /**
80
103
  * Makes an authenticated GET request.
@@ -173,10 +196,14 @@ async function apiCallSafe(url, options = {}) {
173
196
  if (token) {
174
197
  headers['Authorization'] = `Bearer ${token}`;
175
198
  }
199
+ // Setup timeout with AbortController
200
+ const controller = new AbortController();
201
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
176
202
  try {
177
203
  const response = await fetch(url, {
178
204
  ...options,
179
- headers
205
+ headers,
206
+ signal: controller.signal
180
207
  });
181
208
  if (!response.ok) {
182
209
  if (response.status === 401 && onUnauthorized) {
@@ -197,7 +224,13 @@ async function apiCallSafe(url, options = {}) {
197
224
  const data = text ? JSON.parse(text) : undefined;
198
225
  return { ok: true, status: response.status, data };
199
226
  }
200
- catch {
227
+ catch (error) {
228
+ if (error instanceof Error && error.name === 'AbortError') {
229
+ return { ok: false, status: 0, error: 'Request timeout' };
230
+ }
201
231
  return { ok: false, status: 0, error: 'Network error' };
202
232
  }
233
+ finally {
234
+ clearTimeout(timeoutId);
235
+ }
203
236
  }
@@ -35,4 +35,14 @@ export declare class ApiError extends Error {
35
35
  * @returns True if status is 400
36
36
  */
37
37
  isBadRequest(): boolean;
38
+ /**
39
+ * Checks if this is a client error (4xx status).
40
+ * @returns True if status is 400-499
41
+ */
42
+ isClientError(): boolean;
43
+ /**
44
+ * Checks if this is a server error (5xx status).
45
+ * @returns True if status is 500-599
46
+ */
47
+ isServerError(): boolean;
38
48
  }
@@ -47,5 +47,19 @@ class ApiError extends Error {
47
47
  isBadRequest() {
48
48
  return this.status === 400;
49
49
  }
50
+ /**
51
+ * Checks if this is a client error (4xx status).
52
+ * @returns True if status is 400-499
53
+ */
54
+ isClientError() {
55
+ return this.status >= 400 && this.status < 500;
56
+ }
57
+ /**
58
+ * Checks if this is a server error (5xx status).
59
+ * @returns True if status is 500-599
60
+ */
61
+ isServerError() {
62
+ return this.status >= 500 && this.status < 600;
63
+ }
50
64
  }
51
65
  exports.ApiError = ApiError;
@@ -115,7 +115,7 @@ exports.DEFAULT_TOKEN_EXPIRY = '7d';
115
115
  /** Default token expiry in seconds (7 days = 604800 seconds) */
116
116
  exports.DEFAULT_TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
117
117
  // ============================================================================
118
- // Auth Middleware
118
+ // Auth Helpers
119
119
  // ============================================================================
120
120
  /**
121
121
  * Validates auth header and returns typed payload or error response.
@@ -11,6 +11,8 @@ export declare const HTTP_STATUS: {
11
11
  readonly NOT_FOUND: 404;
12
12
  readonly CONFLICT: 409;
13
13
  readonly GONE: 410;
14
+ readonly UNPROCESSABLE_ENTITY: 422;
15
+ readonly TOO_MANY_REQUESTS: 429;
14
16
  readonly INTERNAL_ERROR: 500;
15
17
  readonly SERVICE_UNAVAILABLE: 503;
16
18
  };
@@ -14,6 +14,8 @@ exports.HTTP_STATUS = {
14
14
  NOT_FOUND: 404,
15
15
  CONFLICT: 409,
16
16
  GONE: 410,
17
+ UNPROCESSABLE_ENTITY: 422,
18
+ TOO_MANY_REQUESTS: 429,
17
19
  INTERNAL_ERROR: 500,
18
20
  SERVICE_UNAVAILABLE: 503
19
21
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markwharton/pwa-core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Shared patterns for Azure PWA projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",