grab-url 0.9.135 → 0.9.137

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.
@@ -0,0 +1,783 @@
1
+ import {
2
+ printJSONStructure,
3
+ log,
4
+ showAlert,
5
+ setupDevTools,
6
+ type LogOptions,
7
+ } from "./log";
8
+
9
+ /**
10
+ * TODO
11
+ * - react tests
12
+ * - grab error popup and dev tool
13
+ * - show net log in alert
14
+ * - progress
15
+ * - pagination working
16
+ * - tests in stackblitz
17
+ * - loading icons
18
+ * - cache revalidation
19
+ */
20
+
21
+ /**
22
+ * ### GRAB: Generate Request to API from Browser
23
+ * ![GrabAPILogo](https://i.imgur.com/qrQWkeb.png)
24
+ *
25
+ * 1. **GRAB is the FBEST Request Manager: Functionally Brilliant, Elegantly Simple Tool**: One Function, no dependencies,
26
+ * minimalist syntax, [more features than alternatives](https://grab.js.org/guide/Comparisons)
27
+ * 2. **Auto-JSON Convert**: Pass parameters and get response or error in JSON, handling other data types as is.
28
+ * 3. **isLoading Status**: Sets `.isLoading=true` on the pre-initialized response object so you can show a "Loading..." in any framework
29
+ * 4. **Debug Logging**: Adds global `log()` and prints colored JSON structure, response, timing for requests in test.
30
+ * 5. **Mock Server Support**: Configure `window.grab.mock` for development and testing environments
31
+ * 6. **Cancel Duplicates**: Prevent this request if one is ongoing to same path & params, or cancel the ongoing request.
32
+ * 7. **Timeout & Retry**: Customizable request timeout, default 30s, and auto-retry on error
33
+ * 8. **DevTools**: `Ctrl+I` overlays webpage with devtools showing all requests and responses, timing, and JSON structure.
34
+ * 9. **Request History**: Stores all request and response data in global `grab.log` object
35
+ * 10. **Pagination Infinite Scroll**: Built-in pagination for infinite scroll to auto-load and merge next result page, with scroll position recovery.
36
+ * 11. **Base URL Based on Environment**: Configure `grab.defaults.baseURL` once at the top, overide with `SERVER_API_URL` in `.env`.
37
+ * 12. **Frontend Cache**: Set cache headers and retrieve from frontend memory for repeat requests to static data.
38
+ * 13. **Regrab On Error**: Regrab on timeout error, or on window refocus, or on network change, or on stale data.
39
+ * 14. **Framework Agnostic**: Alternatives like TanStack work only in component initialization and depend on React & others.
40
+ * 15. **Globals**: Adds to window in browser or global in Node.js so you only import once: `grab()`, `log()`, `grab.log`, `grab.mock`, `grab.defaults`
41
+ * 16. **TypeScript Tooltips**: Developers can hover over option names and autocomplete TypeScript.
42
+ * 17. **Request Stategies**: [🎯 Examples](https://grab.js.org/guide/Examples) show common stategies like debounce, repeat, proxy, unit tests, interceptors, file upload, etc
43
+ * 18. **Rate Limiting**: Built-in rate limiting to prevent multi-click cascading responses, require to wait seconds between requests.
44
+ * 19. **Repeat**: Repeat request this many times, or repeat every X seconds to poll for updates.
45
+ * 20. **Loading Icons**: Import from `grab-api.js/icons` to get enhanced animated loading icons.
46
+ *
47
+ * @param {string} path The full URL path OR relative path on this server after `grab.defaults.baseURL`
48
+ * @param {object} [options={}] Request params for GET or body for POST/PUT/PATCH and utility options
49
+ * @param {string} [options.method] default="GET" The HTTP method to use
50
+ * @param {object} [options.response] Pre-initialized object which becomes response JSON, no need for `.data`.
51
+ * isLoading and error may also be set on this object. May omit and use return if load status is not needed.
52
+ * @param {boolean} [options.cancelOngoingIfNew] default=false Cancel previous requests to same path
53
+ * @param {boolean} [options.cancelNewIfOngoing] default=false Cancel if a request to path is in progress
54
+ * @param {boolean} [options.cache] default=false Whether to cache the request and from frontend cache
55
+ * @param {boolean} [options.debug] default=false Whether to log the request and response
56
+ * @param {number} [options.timeout] default=30 The timeout for the request in seconds
57
+ * @param {number} [options.cacheForTime] default=60 Seconds to consider data stale and invalidate cache
58
+ * @param {number} [options.rateLimit] default=0 If set, how many seconds to wait between requests
59
+ * @param {string} [options.baseURL] default='/api/' base url prefix, override with SERVER_API_URL env
60
+ * @param {boolean} [options.setDefaults] default=false Pass this with options to set
61
+ * those options as defaults for all requests.
62
+ * @param {number} [options.retryAttempts] default=0 Retry failed requests this many times
63
+ * @param {array} [options.infiniteScroll] default=null [page key, response field to concatenate, element with results]
64
+ * @param {number} [options.repeat] default=0 Repeat request this many times
65
+ * @param {number} [options.repeatEvery] default=null Repeat request every seconds
66
+ * @param {function} [options.logger] default=log Custom logger to override the built-in color JSON log()
67
+ * @param {function} [options.onRequest] Set with defaults to modify each request data.
68
+ * Takes and returns in order: path, response, params, fetchParams
69
+ * @param {function} [options.onResponse] Set with defaults to modify each request data.
70
+ * Takes and returns in order: path, response, params, fetchParams
71
+ * @param {function} [options.onStream] Set with defaults to process the response as a stream (i.e., for instant unzip)
72
+ * @param {function} [options.onError] Set with defaults to modify the error data. Takes: error, path, params
73
+ * @param {number} [options.debounce] default=0 Seconds to debounce request, wait to execute so that other requests may override
74
+ * @param {boolean} [options.regrabOnStale] default=false Refetch when cache is past cacheForTime
75
+ * @param {boolean} [options.regrabOnFocus] default=false Refetch on window refocus
76
+ * @param {boolean} [options.regrabOnNetwork] default=false Refetch on network change
77
+ * @param {any} [...params] All other params become GET params, POST body, and other methods.
78
+ * @returns {Promise<Object>} The response object with resulting data or .error if error.
79
+ * @author [vtempest (2025)](https://github.com/vtempest/grab-api)
80
+ * @see [🎯 Examples](https://grab.js.org/guide/Examples) [📑 Docs](https://grab.js.org)
81
+ * @example import grab from 'grab-api.js';
82
+ * let res = {};
83
+ * await grab('search', {
84
+ * response: res,
85
+ * query: "search words"
86
+ * })
87
+ */
88
+ export default async function grab<TResponse = any, TParams = any>(
89
+ path: string,
90
+ options: GrabOptions<TResponse, TParams>
91
+ ): Promise<GrabResponse<TResponse>> {
92
+ let {
93
+ headers,
94
+ response = {} as any, // Pre-initialized object to set the response in. isLoading and error are also set on this object.
95
+ method = options.post // set post: true for POST, omit for GET
96
+ ? "POST"
97
+ : options.put
98
+ ? "PUT"
99
+ : options.patch
100
+ ? "PATCH"
101
+ : "GET",
102
+ cache = false, // Enable/disable frontend caching
103
+ cacheForTime = 60, // Seconds to consider data stale and invalidate cache
104
+ timeout = 30, // Request timeout in seconds
105
+ baseURL = (typeof process !== "undefined" && process.env.SERVER_API_URL) ||
106
+ "/api/", // Use env var or default to /api/
107
+ cancelOngoingIfNew = false, // Cancel previous request for same path
108
+ cancelNewIfOngoing = false, // Don't make new request if one is ongoing
109
+ rateLimit = 0, // Minimum seconds between requests
110
+ debug = false, // Auto-enable debug on localhost
111
+ // typeof window !== "undefined" && window?.location?.hostname?.includes("localhost"),
112
+ infiniteScroll = null, // page key, response field to concatenate, element with results
113
+ setDefaults = false, // Set these options as defaults for future requests
114
+ retryAttempts = 0, // Retry failed requests once
115
+ logger = log, // Custom logger to override the built-in color JSON log()
116
+ onRequest = null, // Hook to modify request data before request is made
117
+ onResponse = null, // Hook to modify request data after request is made
118
+ onError = null, // Hook to modify request data after request is made
119
+ onStream = null, // Hook to process the response as a stream (i.e., for instant unarchiving)
120
+ repeatEvery = null, // Repeat request every seconds
121
+ repeat = 0, // Repeat request this many times
122
+ debounce = 0, // Seconds to debounce request, wait to execute so that other requests may override
123
+ regrabOnStale = false, // Refetch when cache is past cacheForTime
124
+ regrabOnFocus = false, // Refetch on window refocus
125
+ regrabOnNetwork = false, // Refetch on network change
126
+ post = false,
127
+ put = false,
128
+ patch = false,
129
+ body = null,
130
+ ...params // All other params become request params/query
131
+ } = {
132
+ // Destructure options with defaults, merging with any globally set defaults
133
+ ...(typeof window !== "undefined"
134
+ ? window?.grab?.defaults
135
+ : (global || globalThis)?.grab?.defaults || {}),
136
+ ...options,
137
+ };
138
+
139
+ // Handle URL construction
140
+ // Ensures proper joining of baseURL and path
141
+ let s = (t) => path.startsWith(t);
142
+ if (s("http:") || s("https:")) baseURL = "";
143
+ else if (!s("/") && !baseURL.endsWith("/")) path = "/" + path;
144
+ else if (s("/") && baseURL.endsWith("/")) path = path.slice(1)
145
+
146
+
147
+ try {
148
+ //handle debounce
149
+ if (debounce > 0)
150
+ return (await debouncer(async () => {
151
+ await grab(path, { ...options, debounce: 0 });
152
+ }, debounce * 1000)) as GrabResponse;
153
+
154
+ // Handle repeat options:
155
+ // - repeat: Makes the same request multiple times sequentially
156
+ // - repeatEvery: Makes the request periodically on an interval
157
+ if (repeat > 1) {
158
+ for (let i = 0; i < repeat; i++) {
159
+ await grab(path, { ...options, repeat: 0 });
160
+ }
161
+ return response;
162
+ }
163
+ if (repeatEvery) {
164
+ setInterval(async () => {
165
+ await grab(path, { ...options, repeat: 0, repeatEvery: null });
166
+ }, repeatEvery * 1000);
167
+ return response;
168
+ }
169
+
170
+ // Store the provided options as new defaults if setDefaults flag is set
171
+ // This allows configuring default options that apply to all future requests
172
+ if (options?.setDefaults) {
173
+ if (typeof window !== "undefined")
174
+ window.grab.defaults = { ...options, setDefaults: undefined };
175
+ else if (typeof (global || globalThis).grab !== "undefined")
176
+ (global || globalThis).grab.defaults = {
177
+ ...options,
178
+ setDefaults: undefined,
179
+ };
180
+
181
+ return;
182
+ }
183
+
184
+ // regrab on stale, on window refocus, on network
185
+ if (typeof window !== undefined) {
186
+ const regrab = async () => await grab(path, { ...options, cache: false });
187
+ if (regrabOnStale && cache) setTimeout(regrab, 1000 * cacheForTime);
188
+ if (regrabOnNetwork) window.addEventListener("online", regrab);
189
+ if (regrabOnFocus) {
190
+ window.addEventListener("focus", regrab);
191
+ document.addEventListener("visibilitychange", async () => {
192
+ if (document.visibilityState === "visible") await regrab();
193
+ });
194
+ }
195
+ }
196
+
197
+ // Handle response parameter which can be either an object to populate
198
+ // or a function to call with results (e.g. React setState)
199
+ let resFunction = typeof response === "function" ? response : null;
200
+ if (!response || resFunction) response = {};
201
+
202
+ var [paginateKey, paginateResult, paginateElement] = infiniteScroll || [];
203
+
204
+ // Configure infinite scroll behavior if enabled
205
+ // Attaches scroll listener to specified element that triggers next page load
206
+ if (infiniteScroll?.length && typeof window == "undefined") {
207
+ let paginateDOM =
208
+ typeof paginateElement === "string"
209
+ ? document.querySelector(paginateElement)
210
+ : paginateElement;
211
+
212
+ if (paginateDOM)
213
+ paginateDOM.removeEventListener(
214
+ "scroll",
215
+ (window ?? globalThis)?.scrollListener
216
+ );
217
+
218
+ // Your modified scroll listener with position saving
219
+ (window ?? globalThis).scrollListener = paginateDOM.addEventListener(
220
+ "scroll",
221
+ async ({ target }: { target: EventTarget }) => {
222
+ // Save scroll position whenever user scrolls
223
+ const t = target as HTMLElement;
224
+
225
+ localStorage.setItem(
226
+ "scroll",
227
+ JSON.stringify([t.scrollTop, t.scrollLeft, paginateElement])
228
+ );
229
+
230
+ if (t.scrollHeight - t.scrollTop <= t.clientHeight + 200) {
231
+ await grab(path, {
232
+ ...options,
233
+ cache: false,
234
+ [paginateKey]: priorRequest?.currentPage + 1,
235
+ });
236
+ }
237
+ }
238
+ );
239
+ }
240
+
241
+ // Check request history for a previous request with same path/params
242
+ // Used for caching and pagination. Ignores pagination params when comparing.
243
+ let paramsAsText = JSON.stringify(
244
+ paginateKey ? { ...params, [paginateKey]: undefined } : params
245
+ );
246
+ let priorRequest = grab?.log?.find(
247
+ (e) => e.request == paramsAsText && e.path == path
248
+ );
249
+
250
+ // Handle response data management based on pagination settings
251
+ if (!paginateKey) {
252
+ // Clear any existing response data
253
+ for (let key of Object.keys(response)) response[key] = undefined;
254
+
255
+ // For non-paginated requests:
256
+ // Return cached response if caching enabled and identical request exists
257
+ // after returning cache, proceed with call to revalidate ensure data is up to date
258
+ if (
259
+ cache &&
260
+ (!cacheForTime ||
261
+ priorRequest?.lastFetchTime > Date.now() - 1000 * cacheForTime)
262
+ ) {
263
+ // set response to cache data
264
+ for (let key of Object.keys(priorRequest.res))
265
+ response[key] = priorRequest.res[key];
266
+ if (resFunction) response = resFunction(response);
267
+
268
+ // if (!cacheValidate) return response;
269
+ }
270
+ } else {
271
+ // For paginated requests:
272
+ // Track current page number and append results to existing data
273
+ let pageNumber =
274
+ priorRequest?.currentPage + 1 || params?.[paginateKey] || 1;
275
+
276
+ // Clear response if this is a new request with new params
277
+ if (!priorRequest) {
278
+ response[paginateResult] = [];
279
+ pageNumber = 1;
280
+ }
281
+
282
+ // Update page tracking
283
+ if (priorRequest) priorRequest.currentPage = pageNumber;
284
+ // @ts-ignore
285
+ params[paginateKey] = pageNumber;
286
+ }
287
+
288
+ // Set loading state on response object
289
+ if (resFunction) resFunction({ isLoading: true });
290
+ else if (typeof response === "object") response.isLoading = true;
291
+
292
+ if (resFunction) response = resFunction(response);
293
+
294
+ // Enforce rate limiting between requests if configured
295
+ if (
296
+ rateLimit > 0 &&
297
+ priorRequest?.lastFetchTime &&
298
+ priorRequest.lastFetchTime > Date.now() - 1000 * rateLimit
299
+ )
300
+ throw new Error(`Fetch rate limit exceeded for ${path}.
301
+ Wait ${rateLimit}s between requests.`);
302
+
303
+ // Handle request cancellation based on configuration:
304
+ // - cancelOngoingIfNew: Cancels any in-progress request for same path
305
+ // - cancelNewIfOngoing: Prevents new request if one is already in progress
306
+ if (priorRequest?.controller)
307
+ if (cancelOngoingIfNew) priorRequest.controller.abort();
308
+ else if (cancelNewIfOngoing) return { isLoading: true } as GrabResponse;
309
+
310
+ // Track new request in history log
311
+ if (typeof grab.log != "undefined")
312
+ grab.log?.unshift({
313
+ path,
314
+ request: paramsAsText,
315
+ lastFetchTime: Date.now(),
316
+ controller: new AbortController(),
317
+ });
318
+
319
+ // Configure fetch request parameters including headers, cache settings,
320
+ // and timeout/cancellation signals
321
+ let fetchParams = {
322
+ method,
323
+ headers: {
324
+ "Content-Type": "application/json",
325
+ Accept: "application/json",
326
+ ...headers,
327
+ },
328
+ body: params.body,
329
+ redirect: "follow" as RequestRedirect,
330
+ cache: cache ? "force-cache" : ("no-store" as RequestCache),
331
+ signal: cancelOngoingIfNew
332
+ ? grab.log[0]?.controller?.signal
333
+ : AbortSignal.timeout(timeout * 1000),
334
+ } as RequestInit;
335
+
336
+ // Format request parameters based on HTTP method
337
+ // POST/PUT/PATCH send data in request body
338
+ // GET/DELETE append data as URL query parameters
339
+ let paramsGETRequest = "";
340
+ if (["POST", "PUT", "PATCH"].includes(method))
341
+ fetchParams.body = params.body || JSON.stringify(params);
342
+ else
343
+ paramsGETRequest =
344
+ (Object.keys(params).length ? "?" : "") +
345
+ new URLSearchParams(params).toString();
346
+
347
+ // Execute pre-request hook if configured
348
+ // Allows modifying request data before sending
349
+ if (typeof onRequest === "function")
350
+ [path, response, params, fetchParams] = onRequest(
351
+ path,
352
+ response,
353
+ params,
354
+ fetchParams
355
+ );
356
+
357
+ // Process request through mock handler if configured
358
+ // Otherwise make actual API request
359
+ let res = null,
360
+ startTime = new Date(),
361
+ mockHandler = grab.mock?.[path] as GrabMockHandler;
362
+
363
+ let wait = (s) => new Promise((res) => setTimeout(res, s * 1000 || 0));
364
+
365
+ if (
366
+ mockHandler &&
367
+ (!mockHandler.params || mockHandler.method == method) &&
368
+ (!mockHandler.params ||
369
+ paramsAsText == JSON.stringify(mockHandler.params))
370
+ ) {
371
+ await wait(mockHandler.delay);
372
+
373
+ res =
374
+ typeof mockHandler.response === "function"
375
+ ? mockHandler.response(params)
376
+ : mockHandler.response;
377
+ } else {
378
+ // Make actual API request and handle response based on content type
379
+ res = await fetch(baseURL + path + paramsGETRequest, fetchParams).catch(
380
+ (e) => {
381
+ throw new Error(e);
382
+ }
383
+ );
384
+
385
+ if (!res.ok)
386
+ throw new Error(`HTTP error: ${res.status} ${res.statusText}`);
387
+
388
+ // Convert browser ReadableStream to Node.js stream
389
+ let type = res.headers.get("content-type");
390
+
391
+ if (onStream) await onStream(res.body);
392
+ else
393
+ res = await (type
394
+ ? type.includes("application/json")
395
+ ? res && res.json()
396
+ : type.includes("application/pdf") ||
397
+ type.includes("application/octet-stream")
398
+ ? res.blob()
399
+ : res.text()
400
+ : res.json()
401
+ ).catch((e) => {
402
+ throw new Error("Error parsing response: " + e);
403
+ });
404
+ }
405
+
406
+ // Execute post-request hook if configured
407
+ // Allows modifying response data before processing
408
+ if (typeof onResponse === "function")
409
+ [path, response, params, fetchParams] = onResponse(
410
+ path,
411
+ response,
412
+ params,
413
+ fetchParams
414
+ );
415
+
416
+ // Clear request tracking states
417
+ if (resFunction) resFunction({ isLoading: undefined });
418
+ else if (typeof response === "object") delete response?.isLoading;
419
+
420
+ delete priorRequest?.controller;
421
+
422
+ // Log debug information if enabled
423
+ // Includes request details, timing, and response structure
424
+ const elapsedTime = (
425
+ (Number(new Date()) - Number(startTime)) /
426
+ 1000
427
+ ).toFixed(1);
428
+ if (debug) {
429
+ logger(
430
+ "Path:" +
431
+ baseURL +
432
+ path +
433
+ paramsGETRequest +
434
+ "\n" +
435
+ JSON.stringify(options, null, 2) +
436
+ "\nTime: " +
437
+ elapsedTime +
438
+ "s\nResponse: " +
439
+ printJSONStructure(res)
440
+ );
441
+ // console.log(res);
442
+ }
443
+
444
+ // if (typeof res === "undefined") return;
445
+
446
+ // Update response object with results
447
+ // For paginated requests, concatenates with existing results
448
+ if (typeof res === "object") {
449
+ for (let key of Object.keys(res))
450
+ response[key] =
451
+ paginateResult == key && response[key]?.length
452
+ ? [...response[key], ...res[key]]
453
+ : res[key];
454
+
455
+ if (typeof response !== "undefined") response.data = res; // for axios compat
456
+ } else if (resFunction) resFunction({ data: res, ...res });
457
+ else if (typeof response === "object") response.data = res;
458
+
459
+
460
+
461
+ // Store request/response in history log
462
+ if (typeof grab.log != "undefined")
463
+ grab.log?.unshift({
464
+ path,
465
+ request: JSON.stringify({ ...params, paginateKey: undefined }),
466
+ response,
467
+ lastFetchTime: Date.now(),
468
+ });
469
+
470
+ if (resFunction) response = resFunction(response);
471
+
472
+ return response;
473
+ } catch (error) {
474
+ // Handle any errors that occurred during request processing
475
+ let errorMessage =
476
+ "Error: " + error.message + "\nPath:" + baseURL + path + "\n";
477
+ JSON.stringify(params);
478
+
479
+ if (typeof onError === "function")
480
+ onError(error.message, baseURL + path, params);
481
+
482
+ // Retry request if retries are configured and attempts remain
483
+ if (options.retryAttempts > 0)
484
+ return await grab(path, {
485
+ ...options,
486
+ retryAttempts: --options.retryAttempts,
487
+ });
488
+
489
+ // Update error state in response object
490
+ // Do not show errors for duplicate aborted requests
491
+ if (!error.message.includes("signal") && options.debug) {
492
+ logger(errorMessage, { color: "red" });
493
+ if (debug && typeof document !== undefined) showAlert(errorMessage);
494
+ }
495
+ response.error = error.message;
496
+ if (typeof response === "function") {
497
+ response.data = response({ isLoading: undefined, error: error.message });
498
+ response = response.data;
499
+ } else delete response?.isLoading;
500
+
501
+ // Log error in request history
502
+ if (typeof grab.log != "undefined")
503
+ grab.log?.unshift({
504
+ path,
505
+ request: JSON.stringify(params),
506
+ error: error.message,
507
+ });
508
+
509
+ // if (typeof options.response === "function")
510
+ // response = options.response(response);
511
+ return response;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Creates a new instance of grab with default options
517
+ * to apply to all requests made by this instance
518
+ * @param {Object} defaults - options for all requests by instance
519
+ * @returns {Function} grab() function using those options
520
+ */
521
+ grab.instance =
522
+ (defaults = {}) =>
523
+ (path, options = {}) =>
524
+ grab(path, { ...defaults, ...options });
525
+
526
+ // delays execution so that future calls may override and only executes last one
527
+ const debouncer = async (func, wait) => {
528
+ let timeout;
529
+ return async function executedFunction(...args) {
530
+ const later = async () => {
531
+ clearTimeout(timeout);
532
+ await func(...args);
533
+ };
534
+ clearTimeout(timeout);
535
+ timeout = setTimeout(later, wait);
536
+ };
537
+ };
538
+
539
+ // Add globals to window in browser, or global in Node.js
540
+ if (typeof window !== "undefined") {
541
+ window.log = log;
542
+ // @ts-ignore
543
+ window.grab = grab;
544
+
545
+ window.grab.log = [];
546
+ window.grab.mock = {};
547
+ window.grab.defaults = {};
548
+
549
+ //Ctrl+I setup dev tools
550
+ setupDevTools();
551
+
552
+ // Restore scroll position when page loads or component mounts
553
+ document.addEventListener("DOMContentLoaded", () => {
554
+ let [scrollTop, scrollLeft, paginateElement] =
555
+ JSON.parse(localStorage.getItem("scroll")) || [];
556
+ if (!scrollTop) return;
557
+ document.querySelector(paginateElement).scrollTop = scrollTop;
558
+ document.querySelector(paginateElement).scrollLeft = scrollLeft;
559
+ });
560
+ } else if (typeof global !== "undefined") {
561
+ grab.log = [];
562
+ grab.mock = {};
563
+ grab.defaults = {};
564
+ global.log = log;
565
+ global.grab = grab.instance();
566
+ } else if (typeof globalThis !== "undefined") {
567
+ grab.log = [];
568
+ grab.mock = {};
569
+ grab.defaults = {};
570
+ globalThis.log = log;
571
+ globalThis.grab = grab.instance();
572
+ }
573
+
574
+ /***************** TYPESCRIPT INTERFACES *****************/
575
+
576
+ // Core response object that gets populated with API response data
577
+ export type GrabResponse<TResponse = any> = TResponse & {
578
+ /** Indicates if request is currently in progress */
579
+ isLoading?: boolean;
580
+ /** Error message if request failed */
581
+ error?: string;
582
+ /** Binary or text response data (JSON is set to the root)*/
583
+ data?: TResponse | any;
584
+ /** The actual response data - type depends on API endpoint */
585
+ [key: string]: unknown;
586
+ };
587
+
588
+ export type GrabOptions<TResponse = any, TParams = any> = TParams & {
589
+ /** include headers and authorization in the request */
590
+ headers?: Record<string, string>;
591
+ /** Pre-initialized object which becomes response JSON, no need for .data */
592
+ response?: TResponse | ((params: TParams) => TResponse) | any;
593
+ /** default="GET" The HTTP method to use */
594
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD";
595
+ /** default=false Whether to cache the request and from frontend cache */
596
+ cache?: boolean;
597
+ /** default=60 Seconds to consider data stale and invalidate cache */
598
+ cacheForTime?: number;
599
+ /** default=30 The timeout for the request in seconds */
600
+ timeout?: number;
601
+ /** default='/api/' base url prefix, override with SERVER_API_URL env */
602
+ baseURL?: string;
603
+ /** default=true Cancel previous requests to same path */
604
+ cancelOngoingIfNew?: boolean;
605
+ /** default=false Cancel if a request to path is in progress */
606
+ cancelNewIfOngoing?: boolean;
607
+ /** default=false If set, how many seconds to wait between requests */
608
+ rateLimit?: number;
609
+ /** default=false Whether to log the request and response */
610
+ debug?: boolean;
611
+ /** default=null [page key, response field to concatenate, element with results] */
612
+ infiniteScroll?: [string, string, string];
613
+ /** default=false Pass this with options to set those options as defaults for all requests */
614
+ setDefaults?: boolean;
615
+ /** default=0 Retry failed requests this many times */
616
+ retryAttempts?: number;
617
+ /** default=log Custom logger to override the built-in color JSON log() */
618
+ logger?: (...args: any[]) => void;
619
+ /** Set with defaults to modify each request data. Takes and returns in order: path, response, params, fetchParams */
620
+ onRequest?: (...args: any[]) => any;
621
+ /** Set with defaults to modify each request data. Takes and returns in order: path, response, params, fetchParams */
622
+ onResponse?: (...args: any[]) => any;
623
+ /** Set with defaults to modify each request data. Takes and returns in order: error, path, params */
624
+ onError?: (...args: any[]) => any;
625
+ /** Set with defaults to process the response as a stream (i.e., for instant unzip) */
626
+ onStream?: (...args: any[]) => any;
627
+ /** default=0 Repeat request this many times */
628
+ repeat?: number;
629
+ /** default=null Repeat request every seconds */
630
+ repeatEvery?: number;
631
+ /** default=0 Seconds to debounce request, wait to execute so that other requests may override */
632
+ debounce?: number;
633
+ /** default=false Refetch when cache is past cacheForTime */
634
+ regrabOnStale?: boolean;
635
+ /** default=false Refetch on window refocus */
636
+ regrabOnFocus?: boolean;
637
+ /** default=false Refetch on network change */
638
+ regrabOnNetwork?: boolean;
639
+ /** shortcut for method: "POST" */
640
+ post?: boolean;
641
+ /** shortcut for method: "PUT" */
642
+ put?: boolean;
643
+ /** shortcut for method: "PATCH" */
644
+ patch?: boolean;
645
+ /** default=null The body of the POST/PUT/PATCH request (can be passed into main)*/
646
+ body?: any;
647
+ /** All other params become GET params, POST body, and other methods */
648
+ [key: string]: TParams | any;
649
+ };
650
+
651
+ // Combined options and parameters interface
652
+
653
+ // Mock server configuration for testing
654
+ export interface GrabMockHandler<TParams = any, TResponse = any> {
655
+ /** Mock response data or function that returns response */
656
+ response: TResponse | ((params: TParams) => TResponse);
657
+ /** HTTP method this mock should respond to */
658
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD";
659
+ /** Request parameters this mock should match */
660
+ params?: TParams;
661
+ /** Delay in seconds before returning mock response */
662
+ delay?: number;
663
+ }
664
+
665
+ // Request log entry for debugging and history
666
+ export interface GrabLogEntry {
667
+ /** API path that was requested */
668
+ path: string;
669
+ /** Stringified request parameters */
670
+ request: string;
671
+ /** Response data (only present for successful requests) */
672
+ response?: any;
673
+ /** Error message (only present for failed requests) */
674
+ error?: string;
675
+ /** Timestamp when request was made */
676
+ lastFetchTime: number;
677
+ /** Abort controller for request cancellation */
678
+ controller?: AbortController;
679
+ /** Current page number for paginated requests */
680
+ currentPage?: number;
681
+ }
682
+
683
+ // Global grab configuration and state
684
+ export interface GrabGlobal {
685
+ /** Default options applied to all requests */
686
+ defaults?: Partial<GrabOptions>;
687
+ /** Request history and debugging info */
688
+ log?: GrabLogEntry[];
689
+ /** Mock server handlers for testing */
690
+ mock?: Record<string, GrabMockHandler>;
691
+ /** Create a separate instance of grab with separate default options */
692
+ instance?: (defaultOptions?: Partial<GrabOptions>) => GrabFunction;
693
+ }
694
+
695
+ // Main grab function signature with overloads for different use cases
696
+ export interface GrabFunction {
697
+ /**
698
+ * ### GRAB: Generate Request to API from Browser
699
+ * ![grabAPILogo](https://i.imgur.com/qrQWkeb.png)
700
+ * Make API request with path
701
+ * @returns {Promise<Object>} The response object with resulting data or .error if error.
702
+ * @author [vtempest (2025)](https://github.com/vtempest/grab-api)
703
+ * @see [🎯 Examples](https://grab.js.org/guide/Examples) [📑 Docs](https://grab.js.org/lib)
704
+ */
705
+ <TResponse = any, TParams = Record<string, any>>(path: string): Promise<
706
+ GrabResponse<TResponse>
707
+ >;
708
+
709
+ /**
710
+ * ### GRAB: Generate Request to API from Browser
711
+ * ![grabAPILogo](https://i.imgur.com/qrQWkeb.png)
712
+ * Make API request with path and options/parameters
713
+ * @returns {Promise<Object>} The response object with resulting data or .error if error.
714
+ * @author [vtempest (2025)](https://github.com/vtempest/grab-api)
715
+ * @see [🎯 Examples](https://grab.js.org/guide/Examples) [📑 Docs](https://grab.js.org/lib)
716
+ */
717
+ <TResponse = any, TParams = Record<string, any>>(
718
+ path: string,
719
+ config: GrabOptions<TResponse, TParams>
720
+ ): Promise<GrabResponse<TResponse>>;
721
+
722
+ /** Default options applied to all requests */
723
+ defaults?: Partial<GrabOptions>;
724
+
725
+ /** Request history and debugging info for all requests */
726
+ log?: GrabLogEntry[];
727
+
728
+ /** Mock server handlers for testing */
729
+ mock?: Record<string, GrabMockHandler>;
730
+
731
+ /** Create a separate instance of grab with separate default options */
732
+ instance?: (defaultOptions?: Partial<GrabOptions>) => GrabFunction;
733
+ }
734
+
735
+ // Log function for debugging
736
+ export interface LogFunction {
737
+ /**
738
+ * Log messages with custom styling
739
+ * @param message - Message to log (string or object)
740
+ */
741
+ (message: string | object, options?: LogOptions): void;
742
+ }
743
+
744
+ // Utility function to describe JSON structure
745
+ export interface printJSONStructureFunction {
746
+ /**
747
+ * Generate TypeDoc-like description of JSON object structure
748
+ * @param obj - The JSON object to describe
749
+ * @returns String representation of object structure
750
+ */
751
+ (obj: any): string;
752
+ }
753
+
754
+ // Helper type for creating typed API clients
755
+ // export type TypedGrabFunction = <
756
+ // TResponse = any,
757
+ // TParams = Record<string, any>
758
+ // >(
759
+ // path: string,
760
+ // config?: GrabOptions<TResponse, TParams>
761
+ // ) => Promise<GrabResponse<TResponse>>;
762
+
763
+ declare global {
764
+ // Browser globals
765
+ interface Window {
766
+ grab: GrabFunction;
767
+ log: LogFunction;
768
+ }
769
+
770
+ // Node.js globals
771
+ namespace NodeJS {
772
+ interface Global {
773
+ grab: GrabFunction;
774
+ log: LogFunction;
775
+ }
776
+ }
777
+
778
+ // Global variables available after script inclusion
779
+ var log: LogFunction;
780
+ var grab: GrabFunction;
781
+ }
782
+
783
+ export { grab, log, showAlert, printJSONStructure };