@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.
- package/dist/__tests__/client/api.test.js +35 -0
- package/dist/__tests__/client/apiError.test.js +33 -0
- package/dist/__tests__/shared/http/status.test.js +2 -0
- package/dist/client/api.d.ts +6 -3
- package/dist/client/api.js +57 -24
- package/dist/client/apiError.d.ts +10 -0
- package/dist/client/apiError.js +14 -0
- package/dist/server/auth/token.js +1 -1
- package/dist/shared/http/status.d.ts +2 -0
- package/dist/shared/http/status.js +2 -0
- package/package.json +1 -1
|
@@ -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
|
});
|
package/dist/client/api.d.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { ApiResponse } from './types';
|
|
2
2
|
/**
|
|
3
|
-
* Initializes the API client with token retrieval and optional
|
|
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
|
*/
|
package/dist/client/api.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
93
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
94
|
+
throw new apiError_1.ApiError(0, 'Request timeout');
|
|
69
95
|
}
|
|
70
|
-
throw
|
|
96
|
+
throw error;
|
|
71
97
|
}
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|
package/dist/client/apiError.js
CHANGED
|
@@ -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
|
|
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
|
};
|