@hkdigital/lib-sveltekit 0.1.68 → 0.1.70

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.
@@ -11,297 +11,364 @@ import { toURL } from './url.js';
11
11
  import { setRequestHeaders } from './headers.js';
12
12
  import { waitForAndCheckResponse } from './response.js';
13
13
 
14
- /**
15
- * @callback requestHandler
16
- * @param {Object} _
17
- * @param {AbortController} _.controller
18
- * @param {( reason?: Error ) => void} _.abort
19
- * @param {( delayMs: number) => void} _.timeout
20
- */
14
+ import { getCachedResponse, storeResponseInCache } from './caching.js';
21
15
 
22
16
  /**
23
- * Make GET request
24
- *
25
- * @param {object} _
17
+ * Default configuration for HTTP requests
26
18
  *
27
- * @param {string|URL} _.url - Url string or URL object
19
+ * This object contains default settings used by the HTTP request functions.
20
+ * It can be used as a reference for available options and their default values.
28
21
  *
29
- * @param {object} [_.urlSearchParams]
30
- * Parameters that should be added to the request url
22
+ * @type {Object}
23
+ */
24
+ export const DEFAULT_HTTP_CONFIG = {
25
+ // Request
26
+ method: METHOD_GET,
27
+ urlSearchParams: null,
28
+ body: null,
29
+ headers: null,
30
+ withCredentials: false,
31
+ timeoutMs: null, // No timeout by default
32
+
33
+ // Fetch
34
+ mode: 'cors',
35
+ cache: 'no-cache',
36
+ redirect: 'follow',
37
+ referrerPolicy: 'no-referrer',
38
+
39
+ // Cache
40
+ cacheEnabled: true
41
+ };
42
+
43
+ /**
44
+ * Make a GET request
31
45
  *
32
- * @param {object} [_.headers]
33
- * Object that contains custom headers. A header is a name, value pair.
46
+ * This function performs an HTTP GET request with optional parameters,
47
+ * headers, credentials, and timeout functionality.
34
48
  *
35
- * e.g. options.headers = { "content-type": "application/json" }
49
+ * @param {import('./typedef').HttpRequestOptions} options
50
+ * Request configuration options
36
51
  *
37
- * @param {boolean} [_.withCredentials=false]
52
+ * @returns {Promise<Response>} Response promise
38
53
  *
39
- * @param {requestHandler} [_.requestHandler]
54
+ * @example
55
+ * // Basic GET request
56
+ * const response = await httpGet({
57
+ * url: 'https://api.example.com/data'
58
+ * });
40
59
  *
41
- * @param {number} [_.timeoutMs]
42
- * If defined, this request will abort after the specified number of
43
- * milliseconds. Values above the the built-in request timeout won't work.
60
+ * @example
61
+ * // GET request with URL parameters and timeout
62
+ * const response = await httpGet({
63
+ * url: 'https://api.example.com/search',
64
+ * urlSearchParams: new URLSearchParams({ q: 'search term' }),
65
+ * timeoutMs: 5000
66
+ * });
44
67
  *
45
- * @returns {Promise<Response>} responsePromise
68
+ * @example
69
+ * // GET request with abort capability
70
+ * const response = await httpGet({
71
+ * url: 'https://api.example.com/large-data',
72
+ * requestHandler: ({ abort }) => {
73
+ * // Store abort function for later use
74
+ * window.abortDataRequest = abort;
75
+ * }
76
+ * });
46
77
  */
47
- export async function httpGet({
48
- url,
49
- urlSearchParams,
50
- headers,
51
- withCredentials,
52
- requestHandler,
53
- timeoutMs
54
- }) {
55
- const responsePromise = httpRequest({
56
- method: METHOD_GET,
57
- url,
58
- urlSearchParams,
59
- headers,
60
- requestHandler,
61
- timeoutMs
62
- });
63
-
64
- return await waitForAndCheckResponse(responsePromise, url);
78
+ export async function httpGet(options) {
79
+ return await httpRequest({
80
+ ...options,
81
+ method: METHOD_GET
82
+ });
65
83
  }
66
84
 
67
85
  /**
68
- * Make POST request
69
- *
70
- * @param {object} _
71
- *
72
- * @param {string|URL} _.url - Url string or URL object
73
- *
74
- * @param {any} [_.body] - POST data
75
- *
76
- * @param {object} [_.headers]
77
- * Object that contains custom headers. A header is a name, value pair.
86
+ * Make a POST request
78
87
  *
79
- * e.g. options.headers = { "content-type": "application/json" }
88
+ * This function performs an HTTP POST request with optional body,
89
+ * headers, credentials, and timeout functionality.
80
90
  *
81
- * @param {boolean} [_.withCredentials=false]
91
+ * @param {import('./typedef').HttpRequestOptions} options
92
+ * Request configuration options
82
93
  *
83
- * @param {requestHandler} [_.requestHandler]
94
+ * @returns {Promise<Response>} Response promise
84
95
  *
85
- * @param {number} [_.timeoutMs]
86
- * If defined, this request will abort after the specified number of
87
- * milliseconds. Values above the the built-in request timeout won't work.
96
+ * @example
97
+ * // Basic POST request with JSON data
98
+ * const response = await httpPost({
99
+ * url: 'https://api.example.com/users',
100
+ * body: JSON.stringify({ name: 'John Doe', email: 'john@example.com' }),
101
+ * headers: { 'content-type': 'application/json' }
102
+ * });
88
103
  *
89
- * @returns {Promise<Response>} responsePromise
104
+ * @example
105
+ * // POST request with timeout
106
+ * const response = await httpPost({
107
+ * url: 'https://api.example.com/upload',
108
+ * body: formData,
109
+ * timeoutMs: 30000 // 30 seconds timeout
110
+ * });
90
111
  */
91
- export async function httpPost({
92
- url,
93
- body = null,
94
- headers,
95
- withCredentials,
96
- requestHandler,
97
- timeoutMs
98
- }) {
99
- const responsePromise = httpRequest({
100
- method: METHOD_POST,
101
- url,
102
- body,
103
- headers,
104
- withCredentials,
105
- requestHandler,
106
- timeoutMs
107
- });
108
-
109
- return await waitForAndCheckResponse(responsePromise, url);
112
+ export async function httpPost(options) {
113
+ return await httpRequest({
114
+ ...options,
115
+ method: METHOD_POST
116
+ });
110
117
  }
111
118
 
112
119
  // -----------------------------------------------------------------------------
113
120
 
114
121
  /**
115
- * Make an HTTP request
116
- * - This is a low level function, consider using
117
- * httpGet, httpPost, jsonGet or jsonPost instead
122
+ * Make an HTTP request (low-level function)
118
123
  *
119
- * @param {object} _
124
+ * This is a low-level function that powers httpGet and httpPost.
125
+ * It provides complete control over request configuration.
120
126
  *
121
- * @param {string|URL} _.url - Url string or URL object
127
+ * @param {import('./typedef').HttpRequestOptions} options
128
+ * Request configuration options
122
129
  *
123
- * @param {string} _.method - Request method: METHOD_GET | METHOD_POST
130
+ * @throws {TypeError} If a network error occurred
131
+ * @returns {Promise<Response>} Response promise
124
132
  *
125
- * @param {object} [_.urlSearchParams] - URL search parameters as key-value pairs
133
+ * @example
134
+ * // Custom HTTP request with PUT method
135
+ * const response = await httpRequest({
136
+ * method: 'PUT',
137
+ * url: 'https://api.example.com/resources/123',
138
+ * body: JSON.stringify({ status: 'updated' }),
139
+ * headers: { 'content-type': 'application/json' },
140
+ * withCredentials: true
141
+ * });
126
142
  *
127
- * @param {any} [_.body] - POST data
128
- *
129
- * @param {object} [_.headers]
130
- * Object that contains custom headers. A header is a name, value pair.
131
- *
132
- * e.g. options.headers = { "content-type": "application/json" }
133
- *
134
- * @param {boolean} [_.withCredentials=false]
135
- * Whether to include credentials (cookies, HTTP authentication, and client
136
- * SSL certificates) with cross-origin requests. When true, sets fetch
137
- * credentials to 'include', otherwise to 'omit'.
138
- *
139
- * @param {requestHandler} [_.requestHandler]
140
- *
141
- * @param {number} [_.timeoutMs]
142
- * If defined, this request will abort after the specified number of
143
- * milliseconds. Values above the the built-in request timeout won't work.
144
- *
145
- * @throws TypeError - If a network error occurred
146
- *
147
- * @note Check the `ok` property of the resolved response to check if the
148
- * response was successfull (e.g. in case of a 404, ok is false)
149
- *
150
- * @returns {Promise<Response>} responsePromise
143
+ * // Check if response was successful
144
+ * if (response.ok) {
145
+ * // Process response
146
+ * } else {
147
+ * // Handle error based on status
148
+ * }
151
149
  */
152
- export async function httpRequest({
153
- method,
154
- url,
155
- urlSearchParams = null,
156
- body = null,
157
- headers,
158
- withCredentials,
159
- requestHandler,
160
- timeoutMs
161
- }) {
162
- url = toURL(url);
163
-
164
- // @see https://developer.mozilla.org/en-US/docs/Web/API/Headers
165
-
166
- const requestHeaders = new Headers();
167
-
168
- if (headers) {
169
- setRequestHeaders(requestHeaders, headers);
170
-
171
- if (
172
- headers[CONTENT_TYPE] === APPLICATION_JSON &&
173
- typeof body !== 'string'
174
- ) {
175
- throw new Error(
176
- `Trying to send request with [content-type:${APPLICATION_JSON}], ` +
177
- 'but body is not a (JSON encoded) string.'
178
- );
179
- }
180
- // IDEA: try to decode the body to catch errors on client side
181
- }
182
-
183
- /** @type {RequestInit} */
184
- const init = {
185
- mode: 'cors',
186
- cache: 'no-cache',
187
- credentials: withCredentials ? 'include': 'omit',
188
- redirect: 'follow',
189
- referrerPolicy: 'no-referrer',
190
- headers: requestHeaders
191
- };
192
-
193
- // Allow search params also for other request types than GET
194
-
195
- if (urlSearchParams) {
196
- if (!(urlSearchParams instanceof URLSearchParams)) {
197
- throw new Error(
198
- 'Invalid parameter [urlSearchParams] ' +
199
- '(expected instanceof URLSearchParams)'
200
- );
201
- }
202
-
203
- const existingParams = url.searchParams;
204
-
205
- for (const [name, value] of urlSearchParams.entries()) {
206
- if (existingParams.has(name)) {
207
- throw new Error(
208
- `Cannot set URL search parameter [${name}] ` +
209
- `in url [${url.href}] (already set)`
210
- );
211
- }
212
-
213
- existingParams.set(name, value);
214
- } // end for
215
- }
216
-
217
- //
218
- // Sort search params to make the url nicer
219
- //
220
- url.searchParams.sort();
221
-
222
- // console.log( "url", url );
223
-
224
- init.method = method;
225
-
226
- if (METHOD_POST === method) {
227
- init.body = body || null; /* : JSON.stringify( body ) */
228
- }
229
-
230
- // @see https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
231
-
232
- // console.log( "init", init );
233
- // console.log( "headers", init.headers );
234
-
235
- const request = new Request(url, init);
236
-
237
- // @see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
238
-
239
- const controller = new AbortController();
240
- const signal = controller.signal;
241
-
242
- //
243
- // A fetch() promise will reject with a TypeError when a network error
244
- // is encountered or CORS is misconfigured on the server-side,
245
- // although this usually means permission issues or similar
246
- // — a 404 does not constitute a network error, for example.
247
- // An accurate check for a successful fetch() would include checking
248
- // that the promise resolved, then checking that the Response.ok property
249
- // has a value of true. The code would look something like this:
250
- //
251
- // fetch()
252
- // .then( () => {
253
- // if( !response.ok ) {
254
- // throw new Error('Network response was not OK');
255
- // }
256
- // ...
257
- // }
258
- // .catch((error) => { .. }
259
- //
260
-
261
- const promise = fetch(request, { signal });
262
-
263
- if (requestHandler || timeoutMs) {
264
- /**
265
- * @type {(reason?: any) => void}
266
- */
267
- const abort = (reason) => {
268
- if (!reason) {
269
- reason = new AbortError(`Request [${url.href}] aborted`);
270
- }
271
-
272
- controller.abort(reason);
273
- };
274
-
275
- /**
276
- * Function that can be used to set a timeout on a request
277
- *
278
- * @param {number} delayMs
279
- */
280
- const timeout = (delayMs = 10000) => {
281
- expect.positiveNumber(delayMs);
282
-
283
- const timerId = setTimeout(() => {
284
- controller.abort(
285
- new TimeoutError(`Request [${url.href}] timed out [${delayMs}]`)
286
- );
287
- }, delayMs);
288
-
289
- promise.finally(() => {
290
- clearTimeout(timerId);
291
- });
292
- };
293
-
294
- if (timeoutMs) {
295
- timeout(timeoutMs);
296
- }
297
-
298
- if (requestHandler) {
299
- expect.function(requestHandler);
300
-
301
- requestHandler({ controller, abort, timeout });
302
- }
303
- }
304
-
305
- // response promise
306
- return promise;
150
+ export async function httpRequest(options) {
151
+ // Apply default configuration
152
+ const config = { ...DEFAULT_HTTP_CONFIG, ...options };
153
+
154
+ const {
155
+ method,
156
+ url: rawUrl,
157
+ urlSearchParams,
158
+ body,
159
+ headers,
160
+ withCredentials,
161
+ requestHandler,
162
+ timeoutMs,
163
+ mode,
164
+ cache,
165
+ redirect,
166
+ referrerPolicy,
167
+ cacheEnabled
168
+ } = config;
169
+
170
+ const url = toURL(rawUrl);
171
+
172
+ console.debug(`httpRequest:load [${url.pathname}]`);
173
+
174
+ // Only consider caching for GET requests
175
+ const shouldAttemptCache = cacheEnabled && method === METHOD_GET;
176
+
177
+ // Try to get from cache if appropriate
178
+ if (shouldAttemptCache && cache !== 'no-store' && cache !== 'reload') {
179
+ const cacheKeyParams = { url, ...headers };
180
+ const cachedResponse = await getCachedResponse(cacheKeyParams);
181
+
182
+ if (cachedResponse) {
183
+ console.debug(`httpRequest:cache-hit [${url.pathname}]`);
184
+ return cachedResponse;
185
+ }
186
+ else {
187
+ console.debug(`httpRequest:cache-miss [${url.pathname}]`);
188
+ }
189
+ }
190
+
191
+ // @see https://developer.mozilla.org/en-US/docs/Web/API/Headers
192
+ const requestHeaders = new Headers();
193
+
194
+ if (headers) {
195
+ setRequestHeaders(requestHeaders, headers);
196
+
197
+ if (
198
+ headers[CONTENT_TYPE] === APPLICATION_JSON &&
199
+ typeof body !== 'string'
200
+ ) {
201
+ throw new Error(
202
+ `Trying to send request with [content-type:${APPLICATION_JSON}], ` +
203
+ 'but body is not a (JSON encoded) string.'
204
+ );
205
+ }
206
+ // IDEA: try to decode the body to catch errors on client side
207
+ }
208
+
209
+ /** @type {RequestInit} */
210
+ const init = {
211
+ mode,
212
+ cache,
213
+ credentials: withCredentials ? 'include': 'omit',
214
+ redirect,
215
+ referrerPolicy,
216
+ headers: requestHeaders
217
+ };
218
+
219
+ // Allow search params also for other request types than GET
220
+ if (urlSearchParams) {
221
+ if (!(urlSearchParams instanceof URLSearchParams)) {
222
+ throw new Error(
223
+ 'Invalid parameter [urlSearchParams] ' +
224
+ '(expected instanceof URLSearchParams)'
225
+ );
226
+ }
227
+
228
+ const existingParams = url.searchParams;
229
+
230
+ for (const [name, value] of urlSearchParams.entries()) {
231
+ if (existingParams.has(name)) {
232
+ throw new Error(
233
+ `Cannot set URL search parameter [${name}] ` +
234
+ `in url [${url.href}] (already set)`
235
+ );
236
+ }
237
+
238
+ existingParams.set(name, value);
239
+ } // end for
240
+ }
241
+
242
+ //
243
+ // Sort search params to make the url nicer
244
+ //
245
+ url.searchParams.sort();
246
+
247
+ init.method = method;
248
+
249
+ if (METHOD_POST === method) {
250
+ init.body = body || null; /* : JSON.stringify( body ) */
251
+ }
252
+
253
+ // @see https://developer.mozilla.org/en-US/docs/Web/API/Request/Request
254
+ const request = new Request(url, init);
255
+
256
+ // @see https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
257
+ const controller = new AbortController();
258
+ const signal = controller.signal;
259
+
260
+ //
261
+ // A fetch() promise will reject with a TypeError when a network error
262
+ // is encountered or CORS is misconfigured on the server-side,
263
+ // although this usually means permission issues or similar
264
+ // — a 404 does not constitute a network error, for example.
265
+ // An accurate check for a successful fetch() would include checking
266
+ // that the promise resolved, then checking that the Response.ok property
267
+ // has a value of true. The code would look something like this:
268
+ //
269
+ // fetch()
270
+ // .then( () => {
271
+ // if( !response.ok ) {
272
+ // throw new Error('Network response was not OK');
273
+ // }
274
+ // ...
275
+ // }
276
+ // .catch((error) => { .. }
277
+ //
278
+
279
+ const promise = fetch(request, { signal });
280
+
281
+ if (requestHandler || timeoutMs) {
282
+ /**
283
+ * @type {(reason?: any) => void}
284
+ */
285
+ const abort = (reason) => {
286
+ if (!reason) {
287
+ reason = new AbortError(`Request [${url.href}] aborted`);
288
+ }
289
+
290
+ controller.abort(reason);
291
+ };
292
+
293
+ /**
294
+ * Function that can be used to set a timeout on a request
295
+ *
296
+ * @param {number} delayMs - Milliseconds to wait before timeout
297
+ */
298
+ const timeout = (delayMs = 10000) => {
299
+ expect.positiveNumber(delayMs);
300
+
301
+ const timerId = setTimeout(() => {
302
+ controller.abort(
303
+ new TimeoutError(`Request [${url.href}] timed out [${delayMs}]`)
304
+ );
305
+ }, delayMs);
306
+
307
+ promise.finally(() => {
308
+ clearTimeout(timerId);
309
+ });
310
+ };
311
+
312
+ if (timeoutMs) {
313
+ timeout(timeoutMs);
314
+ }
315
+
316
+ if (requestHandler) {
317
+ expect.function(requestHandler);
318
+
319
+ requestHandler({ controller, abort, timeout });
320
+ }
321
+ }
322
+
323
+ // Wait for the response and check it
324
+ const response = await waitForAndCheckResponse(promise, url);
325
+
326
+ // If caching is enabled, store the response in cache
327
+ if (shouldAttemptCache && response.ok) {
328
+ // Extract cache control headers
329
+ const cacheControl = response.headers.get('Cache-Control') || '';
330
+ const etag = response.headers.get('ETag');
331
+ const lastModified = response.headers.get('Last-Modified');
332
+
333
+ // Parse cache-control directives
334
+ const directives = {};
335
+ cacheControl.split(',').forEach(directive => {
336
+ const [key, value] = directive.trim().split('=');
337
+ directives[key.toLowerCase()] = value !== undefined ? value : true;
338
+ });
339
+
340
+ // Determine if cacheable
341
+ const isCacheable = !directives['no-store'] && !directives['private'];
342
+
343
+ if (isCacheable) {
344
+ // Calculate expiration time
345
+ let expires = null;
346
+ if (directives['max-age']) {
347
+ const maxAge = parseInt(directives['max-age'], 10);
348
+ expires = Date.now() + (maxAge * 1000);
349
+ } else if (response.headers.get('Expires')) {
350
+ expires = new Date(response.headers.get('Expires')).getTime();
351
+ }
352
+
353
+ // Create stale info
354
+ const staleInfo = {
355
+ isStale: false,
356
+ fresh: null,
357
+ timestamp: Date.now(),
358
+ expires
359
+ };
360
+
361
+ // Store response in cache
362
+ const cacheKeyParams = { url, ...headers };
363
+ await storeResponseInCache(cacheKeyParams, response.clone(), {
364
+ etag,
365
+ lastModified,
366
+ expires,
367
+ immutable: directives['immutable'] || false
368
+ });
369
+ }
370
+ }
371
+
372
+ return response;
307
373
  }
374
+